SLOPSHOPPER

Band

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…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · band
│ ┃ Log ✕ › fix the failing auth test and add an audit log call │ ┃ 08:53 band session start · band unknow │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /band-log │ ⎿ band: Log pane opened. │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── cache 60m │ opus-5-5 ▾ │ ctx 49% │ 5h 31% │ [ Compact ] [ Clear ] [ Log ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── cache 60m │ opus-5-5 ▾ │ ctx 49% │ 5h 31% │ [ Compact ] [ Clear ] [ Log ]
Pane · Log
08:53 band session start · band unknown
Pane · band-progress
no task checklist
README

band

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.

Status

  • Claude Code only: band is a hooks module (mods), which other hosts do not run. It ships no Cursor, Codex or OpenCode adapter, and scripts/install.sh --plugin band exits 2.
  • Works alone. With base or fnd it adds the task digest, the Progress pane and that plugin's log lines; with slim it adds slim's log lines; with a team plugin (fe, qa, be, pm) that plugin's log lines. None of them is a dependency.
  • The drawing shows in the terminal and in the desktop app's Code tab. Where nothing draws (a cloud session, the VS Code chat panel, claude -p) the hooks still run and /band-log and /band-progress answer as text.

Install

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

Status band

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.

SegmentShowsRule
cachecache 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.
modelfable-5-1The 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.
ctxctx 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.
rates5h 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.
costcost $12.40Opt-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.
digestELC-1591 3/5 ▶ Preview themesThe 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: CompactAlways 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: ClearRuns /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: ProgressDrawn 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: LogDrawn 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.

Progress pane

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.

  • Whose task. band takes 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.
  • Which task is the publisher's decision, not band's: its resolver (pin, the ticket you named, the branch, the newest 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.
  • Pinning stays the publisher's command: /base-progress <KEY> (fnd: /fnd-progress <KEY>) pins a task, /base-progress - (fnd: /fnd-progress -) clears the pin. /band-progress takes no argument.
  • No task. The Progress button is not drawn while the publisher resolves nothing (or neither base nor fnd is installed), and /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.
  • Redraw. The pane and the digest redraw whenever the publisher writes a new snapshot (a Write or Edit under .claude/tasks/, its 30 s tick, a branch or directory change, a pin).
  • Where the surface cannot place the pane, the command toasts progress pane not placed: <reason> and answers with the same reason.
  • Where nothing draws a pane (a cloud session, the VS Code chat panel, claude -p), /band-progress answers with the checklist as text: the header, one ✓ / ▶ / ◌ / ☐ line per row, then the footer lines.

Event log pane

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.

KindWriterWritten whenText
sessionbandband'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 nonestart · band <version> (the version names the drawer: fnd's own line reads start), resume, fork, clear (a resume can log both start and resume)
modelbandA /model switch to another modelThe full model id, claude-opus-5-5
compactbandA compaction of the main threadThe 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
ratebandA rate window first reaches 90 %The alarm toast's text, 5h window: 92% used, resets in 1h 05m
startbase, slim, fe, qa, be, pmThe plugin's session start, once per session id<plugin> <version>: base <version>, slim <version>, qa <version>
installbase, fe, qa, be, pmbase: slim is missing, or fnd is loaded beside base. A team plugin: base is not loadedThe install pointer, or the advice to uninstall fnd; a team plugin's reads needs the base plugin — claude plugin install base@domaine
profilefefe decides the project profileThe profile word and how it was decided, theme (project-profile.sh)
workspacebase, fndThe task the plugin resolved differs from the last one loggedThe work id, or none
refusebasebase refuses a reader spawn because slim is missing<agent>: slim is not loaded
titlebasebase titles the session after the ticket keyThe title
doctorbase, fe, qa, be, pmThe plugin's doctor runs (/base-doctor, /fe-doctor, /qa-doctor, /be-doctor, /pm-doctor)The run's counts
fnd-slimfndfnd's own MCP slimming finds a savings figureAs fnd writes it; the kind reads slim while slim has written no line, fnd-slim once it has
promptfndfnd rewrites a pasted JSON promptThe toast's figure
guardbase, fndA guard of the plugin refuses a tool callThe tool and the first line of the reason
slimslimslim compresses or stubs a result<tool>: compressed … · <engine>, a subagent's call prefixed with its agent type
lookupslimslim's lookup tool answers a questionlookup: <question…> · <model> · <tokens>
viewslimslim's view tool returns a file, URL or command outputview <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.

Event log on disk

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:

  • Where: $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.
  • Line: one JSON object per line, oldest first: {"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 line: the first line a plugin writes for a session id is 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.
  • band's lines: 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.
  • Writing: the whole file is rewritten after every line (the engine has no append), at most 2000 lines or 256 KB, oldest dropped first. A reload of the module (a /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>.
  • Off: BAND_EVENT_LOG=0 stops band's file and its pane lines alike.
  • Clean-up: base sweeps session folders whose newes
Source 10 files
hooks/mods/register.tsx 21 lines
1// 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}
21
hooks/mods/band.tsx 337 lines
1// 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}
337
hooks/mods/checklist.tsx 114 lines
1// 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}
114
hooks/mods/info.ts 53 lines
1// 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}
53
hooks/mods/log.tsx 99 lines
1// 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}
99
hooks/mods/marker.ts 43 lines
1// 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}
43
hooks/mods/usage.ts 345 lines
1// 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}
345
hooks/mods/lib.ts 452 lines
1// 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}
452
hooks/mods/events.ts 161 lines
1// 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}
161
types/index.d.ts 93 lines
1// 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