High-performance statusline and above-the-prompt activity band for Claude Code, with setup commands and diagnostics

A multi-line statusline for Claude Code that shows context usage, cost tracking, active tools, running agents, and todo progress — updated live as you work.

Four things at a glance, re-colored live as you work:
CTX% · tokens · cost) are always visible. Config counts (CLAUDE.md / rules / MCPs / skills …) are an opt-in L2 row.none, console, rail, … — see docs/layouts.md for the catalog)--preview (see src/themes/ for the full set)# npm (recommended — works on macOS, Linux, Windows)
npm install -g @cc-pulseline/cc-pulseline
# From source
cargo install cc-pulseline
# Or clone and build
git clone https://github.com/GregoryHo/cc-pulseline.git
cd cc-pulseline && ./scripts/install.sh
Add to ~/.claude/settings.json:
{
"statusLine": {
"type": "command",
"command": "~/.claude/pulseline/cc-pulseline"
}
}
Start a Claude Code session — the statusline appears automatically.
| Method | Command | Best For |
|---|---|---|
| npm | npm i -g @cc-pulseline/cc-pulseline | Claude Code users |
| cargo-binstall | cargo binstall cc-pulseline | Rust devs (prebuilt) |
| cargo install | cargo install cc-pulseline | Rust devs (from source) |
| install.sh | ./scripts/install.sh | Local clone |
The same binary also draws above the prompt, fed by Claude Code's function hooks rather than the statusline hook. It is the other half of this repository's plugin, so one install gets both it and the setup commands:
/plugin install pulseline --marketplace GregoryHo/cc-pulseline
The band draws the turn — the tool running now and its target, subagents, todo progress, per-tool completed counts — and holds that state between turns; /pulseline opens a pane with the full ledger. The two surfaces are a division of labour rather than a replacement: the band draws no identity, context or cost row, so keep your statusLine setting, which stays the steady-state summary.
Requires cc-pulseline 1.6.0 or newer on the machine. The plugin finds the binary on its own — a path you configure, ~/.claude/pulseline/, PATH, or a local build — and says what is missing when none of them answers. See the activity band for the full picture.
cc-pulseline uses TOML configuration with two scopes:
~/.claude/pulseline/config.toml{project}/.claude/pulseline.toml (overrides user)cc-pulseline --init # Create user config
cc-pulseline --init --project # Create project config
cc-pulseline --check # Validate configs
cc-pulseline --print # Show effective merged config
[display]
theme = "tokyo-night" # tokyo-night | echo-sub-zero | titanium-precision | ...
# variant = "dark" # dark | light (overrides theme default)
icons = true # nerd font icons vs ascii
[segments.identity] # Line 1 — model, style, version, project, git
show_model = true
show_style = true
show_version = true
show_project = true
show_git = true
show_git_stats = false # !3 +1 ✘2 ?4 file stats after branch
show_agent = true # AG:agent-name when --agent is active
show_worktree = true # (WT) indicator when in a worktree session
show_effort = true # effort level pill (low/medium/high/xhigh/max, CC 2.1.119+)
show_thinking = true # thinking mode indicator (CC 2.1.119+)
[segments.config] # Line 2 — CLAUDE.md, rules, memories, hooks, MCPs, skills, plugins, duration
enabled = true # L2 row is opt-in (off by default)
show_claude_md = true
show_rules = true
show_memory = true
show_hooks = true
show_mcp = true
show_skills = true
show_plugins = true
show_duration = true
[segments.budget] # Line 3 — context, tokens, cost
show_context = true
show_tokens = true
show_cost = true
show_speed = false # output tok/s rate
show_cache_trend = false # cache trend: compact C-cell sparkline + ledger CACHE row (opt-in)
[segments.quota] # Usage/quota tracking (subscription plans)
enabled = false # opt-in: driven by CC's native rate_limits stdin field
show_five_hour = true
show_seven_day = false
[segments.tools]
enabled = true
max_lines = 2 # max running tools shown
max_completed = 4 # max completed tool counts
max_completed_lines = 1 # max rows of completed tools (overflow folds into a ` +N` tail)
[segments.agents]
enabled = true
max_lines = 1
[segments.todo]
enabled = true
max_lines = 1
[layout].name picks how rows are arranged and decorated. Layouts ship from flat (none) to framed (console) to Powerline single-bar (rail / anchor) — see docs/layouts.md for the catalog.

Each layout asserts a tasteful default for every widget-bearing segment — see docs/layouts.md for the per-segment reference. The user can override per segment via *_visual strings — same widget, any layout:
# Console layout, but with a CTX gauge added (default is text-only).
[layout]
name = "console"
[segments.budget]
context_visual = "gauge"
# Console without quota gauge — text quota only.
[layout]
name = "console"
[segments.quota]
visual = "text"
Recognized context/quota widgets: gauge, sparkline, plot, text (context); gauge, text (quota). Combine with + (e.g. "gauge+sparkline"). Other segments compose too — tools_visual (counts/targets/ticker), todo_visual (text/bar), effort_visual (word/ramp), agents_visual (name/description/model). Empty string defers to the layout default. Full reference: docs/layouts.md.
cc-pulseline # render statusline from stdin JSON (empty stdin = {})
cc-pulseline --init # create user config (--init --project for project config)
cc-pulseline --check # validate config files
cc-pulseline --print # show effective merged config
cc-pulseline --preview # preview themes (optional theme names)
cc-pulseline --preview-layouts # preview every layout (optional widths)
cc-pulseline --select-theme # interactively select and apply a theme (--project for project config)
cc-pulseline --palette-map # show palette field → UI element mapping (optional theme)
cc-pulseline --layout rail --columns 120 --rows 3 # one-off overrides, handy for previewing
Run cc-pulseline --help for the full option reference and the built-in theme list.
| Variable | Effect |
|---|---|
NO_COLOR | Disable all color output |
COLUMNS | Terminal width for layout degradation |
| Requirement | Minimum |
|---|---|
| Terminal | 256-color ANSI |
| Rust (build) | 1.85+ |
| Node.js (npm) | 14+ |
| Platform | npm | cargo install | cargo-binstall |
|---|---|---|---|
| macOS ARM64 | Yes | Yes | Yes |
| macOS x64 | Yes | Yes | Yes |
| Linux x64 | Yes | Yes | Yes |
| Linux x64 (musl) | Yes | Yes | Yes |
| Linux ARM64 | Yes | Yes | Yes |
| Linux ARM64 (musl) | Yes | Yes | Yes |
| Windows x64 | Yes | Yes | Yes |
Designed for repeated invocation by Claude Code's statusline hook:
| Scenario | Target (p95) |
|---|---|
| Baseline (no activity) | < 10ms |
| Active session (10 tools + 5 agents) | < 20ms |
| Large transcript (2500 events) | < 50ms |
Benchmarks use Criterion.rs. Run with:
cargo bench
See docs/benchmarks.md for methodology and detailed results.
No color output? Check that the NO_COLOR environment variable is not set. Ensure your terminal supports 256-color ANSI. In tmux, verify TERM is set to xterm-256color or similar.
Icons look broken? Set icons = false in your config file, or install a Nerd Font.
Statusline not appearing? Verify the statusLine entry in ~/.claude/settings.json points to the correct binary path. Test directly with:
echo '{}' | cc-pulseline
Config changes not taking effect? Run cc-pulseline --check to validate your config files and cc-pulseline --print to see the effective merged config.
| Guide | Description |
|---|---|
| Architecture | Pipeline design, module responsibilities, transcript two-path event dispatcher |
| Layouts | Layout catalog and per-segment visual (gauge/sparkline/plot/text) reference |
| Metrics Reference | Per-metric data sources, parsing methods, cache strategies, and output examples |
| Theme & Palette | 256-color system specification, emphasis tiers, and color-annotated rendering examples |
| Activity Band | The above-the-prompt band and pane: what they draw, the binary ladder, the test kit |
| Benchmarks | Performance methodology and Criterion benchmark results |
| Changelog | Release history and version notes |
See CONTRIBUTING.md for development setup, testing, and pull request guidelines.
cc-pulseline draws inspiration from these excellent projects:
MIT License. See LICENSE for details.
hooks/register.tsx 1713 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, PluginOptions, Register, SessionUsage } from 'claude-code'
3
4import {
5 PROBE_TIMEOUT_MS,
6 candidates,
7 formatVersion,
8 isNewEnough,
9 missingMessage,
10 parseVersion,
11 tooOldMessage,
12} from './binary'
13import type { Version } from './binary'
14import { buildPayload } from './payload'
15import type { ActiveTool } from './payload'
16import { shouldRefresh } from './throttle'
17import type {
18 BandRun,
19 BinaryChoice,
20 DrawnOutput,
21 Span,
22 SpanStyle,
23 TaskItem,
24 Tasks,
25 Todo,
26} from '../types'
27
28/**
29 * cc-pulseline as a mod, stage 0c.
30 *
31 * What the first two runs established:
32 * - a user-tier hooks module loads, and `$.process.run` with stdin is
33 * allowed there, so the Rust binary can stay the renderer;
34 * - the `AbovePrompt` band refuses a whole tree if any text child holds a
35 * control character, so the binary's ANSI has to be parsed into Text
36 * props (see `spansOf`), never passed through;
37 * - the band is small outside fullscreen: `maxRows` came back as 2, and a
38 * taller tree scrolls behind a `n more` row.
39 *
40 * So this revision draws the statusline rows and nothing else, clipped to
41 * `maxRows`, and refreshes them when a turn ends rather than only at
42 * session start.
43 *
44 * Every `$` call is written literally: the loader statically scans this
45 * source for them, so aliasing one would make it invisible to the scan.
46 *
47 * Every gating hook carries `.catch(($, e, next) => next(e))`. This mod
48 * only observes — it never denies and never rewrites what it is handed — so
49 * the right answer when one of its hooks throws or outruns its budget is
50 * whatever the chain beneath says. `next` is replay-safe inside a handler:
51 * where the hook had already called it, `next(e)` resolves to what that call
52 * settled to and nothing beneath runs twice, so a `tool.call` that failed
53 * after the tool ran still answers with the tool's real result and never a
54 * fabricated one.
55 *
56 * The budget those handlers guard is the hook's own code: 10s
57 * (`HookBudget.ms`), with waits on `next` and on `$` not counted. So the
58 * binary's `timeoutMs: 5000` sits outside it — it is a `$.process.run` wait
59 * — and only the work around the spawn, chiefly `spansOf` over a few
60 * hundred bytes, is ever on the clock.
61 */
62
63const PANE_ID = 'pulseline'
64
65/**
66 * The layouts the pane's switcher row offers, in order.
67 *
68 * `console` is left out on purpose: inside a pane it reads as `ledger` with
69 * a label column, and four visibly different views are worth more than five
70 * where two look alike.
71 */
72/**
73 * Calls that are not tool activity, mirrored from `providers/transcript.rs`.
74 *
75 * Two groups, excluded there in two different ways. `Agent`, `TaskCreate`,
76 * `TaskUpdate` and `TodoWrite` are dispatched and then returned from before
77 * the tool tracking, because each belongs somewhere else — the agent list, or
78 * the todo row. The rest are that file's `NOISE_TOOLS`, Claude Code's own
79 * control calls.
80 *
81 * Without this the band inflated its completed totals with its own
82 * bookkeeping, and an `Agent` call was drawn twice: once as a running tool
83 * and once as an agent.
84 */
85const NOT_TOOL_ACTIVITY = new Set([
86 'Agent',
87 'TaskCreate',
88 'TaskUpdate',
89 'TodoWrite',
90 'EnterPlanMode',
91 'ExitPlanMode',
92 'EnterWorktree',
93 'ExitWorktree',
94 'TaskGet',
95 'TaskList',
96 'TaskOutput',
97 'TaskStop',
98 'ToolSearch',
99])
100
101const LAYOUTS = ['ledger', 'budgets', 'compact', 'rail']
102
103/** Button key prefix, so a press can name the layout it asks for. */
104const LAYOUT_KEY = 'layout:'
105
106/** Set true to draw the band/run diagnostics above the statusline rows. */
107const DEBUG = false
108
109/**
110 * The session's values, held by the host so a hot reload does not zero them.
111 *
112 * `plugin` and `key` are source literals on purpose: `claude plugin validate`
113 * lists every `$.state` key and holds it to the contract in `../types`, and
114 * only a family member's `id` may be computed.
115 */
116const sessionFacts = atom({ plugin: 'pulseline', key: 'session' } as const, {
117 cwd: '',
118 version: null,
119 effort: null,
120 tokens: null,
121 compactCount: 0,
122 outputStyle: null,
123 lastApiErrorMs: null,
124 cacheReadTotal: 0,
125})
126
127const toolTotals = atom({ plugin: 'pulseline', key: 'tools' } as const, {
128 counts: {},
129 completedTotal: 0,
130 failedTotal: 0,
131})
132
133const agentTimings = atom({ plugin: 'pulseline', key: 'agents' } as const, {
134 startedAt: {},
135 finishedAt: {},
136 agentType: {},
137})
138
139const instructions = atom({ plugin: 'pulseline', key: 'instructions' } as const, {
140 claudeMd: [],
141 agentsMd: [],
142 rules: [],
143 memories: [],
144})
145
146const todoList = atom({ plugin: 'pulseline', key: 'todo' } as const, null)
147
148/**
149 * The Task API's items, which the todo row is rebuilt from.
150 *
151 * An atom rather than a closure field for the same reason as the rest: an
152 * edit to this module reloads it, and a `TaskUpdate` naming an item created
153 * before the reload has to find it.
154 */
155const taskItems = atom({ plugin: 'pulseline', key: 'tasks' } as const, {
156 items: {} as Record<string, TaskItem>,
157})
158
159/** The person's own pane choices: a reload must not close it or reset it. */
160const paneWanted = atom({ plugin: 'pulseline', key: 'pane' } as const, {
161 open: false,
162 layout: 'ledger',
163})
164
165/**
166 * The binary's last output.
167 *
168 * Held rather than rebuilt for two reasons: a reload redraws the rows it
169 * already had instead of flashing the not-run-yet note, and a render hook
170 * that reads this subscribes its drawing, so writing it here draws the band
171 * again with nobody calling `$.ui.invalidate`.
172 */
173/**
174 * Which binary was resolved, `null` until the ladder has been walked.
175 *
176 * Cleared on `classic.ConfigChange`, so configuring the path or installing
177 * the binary mid-session takes effect without restarting.
178 */
179const binaryChoice = atom({ plugin: 'pulseline', key: 'binary' } as const, null)
180
181const drawnOutput = atom({ plugin: 'pulseline', key: 'output' } as const, {
182 rows: [],
183 paneRows: [],
184 // Not `'failed'`: nothing has gone wrong yet, and the band draws the note
185 // only for `'failed'`. See `BandRun` in `../types`.
186 bandRun: 'never',
187 runNote: 'binary: not run yet',
188 paneNote: 'pane: not run yet',
189})
190
191// Built from a char code on purpose: a literal escape in this source
192// does not survive every editor round-trip.
193const ESC = String.fromCharCode(27)
194const ANSI_PATTERN = new RegExp(ESC + '\\[([0-9;]*)m', 'g')
195
196/** xterm-256 index to `#rrggbb`: 16 base colours, a 6^3 cube, a 24-step ramp. */
197export function ansi256Hex(index: number): string {
198 const base = [
199 0x000000, 0x800000, 0x008000, 0x808000, 0x000080, 0x800080, 0x008080, 0xc0c0c0, 0x808080,
200 0xff0000, 0x00ff00, 0xffff00, 0x0000ff, 0xff00ff, 0x00ffff, 0xffffff,
201 ]
202 const hex = (value: number) => `#${value.toString(16).padStart(6, '0')}`
203 if (index < 16) return hex(base[index] ?? 0)
204 if (index < 232) {
205 const step = [0, 95, 135, 175, 215, 255]
206 const offset = index - 16
207 const r = step[Math.floor(offset / 36)] ?? 0
208 const g = step[Math.floor((offset % 36) / 6)] ?? 0
209 const b = step[offset % 6] ?? 0
210 return hex((r << 16) | (g << 8) | b)
211 }
212 const level = 8 + (index - 232) * 10
213 return hex((level << 16) | (level << 8) | level)
214}
215
216/**
217 * Flattens text from outside the module before it goes on the band: a
218 * stderr dump, a caught error.
219 *
220 * The band refuses the *whole tree* over one control character — the engine
221 * says `ui.render (AbovePrompt) refused: a text child holds a control
222 * character (an escape sequence); the engine drew its own` — so an
223 * unflattened panic takes down the note that was meant to explain it, and the
224 * band goes blank with nothing to read. Runs become one space rather than
225 * nothing, where `stripControls` below drops them: a panic's first line is
226 * the useful one, and `at src/main.rs:1:1` must not be glued to the end of
227 * it. Flatten before slicing, or a cut can land inside an escape sequence.
228 */
229function noteText(text: string): string {
230 // SGR codes first, whole. Dropping the escape alone and keeping the rest
231 // left `[31mthread 'main' panicked [0m` on the band: accepted by the
232 // engine, and unreadable.
233 let flat = ''
234 let spaced = false
235 for (const char of text.replace(ANSI_PATTERN, '')) {
236 const code = char.codePointAt(0) ?? 32
237 if (code >= 32 && code !== 127) {
238 flat += char
239 spaced = false
240 continue
241 }
242 if (!spaced && flat.length > 0) {
243 flat += ' '
244 spaced = true
245 }
246 }
247 return flat.trimEnd()
248}
249
250/** Drops the control characters the band refuses to draw. */
251function stripControls(text: string): string {
252 let clean = ''
253 for (const char of text) {
254 const code = char.codePointAt(0) ?? 32
255 if (code >= 32 && code !== 127) clean += char
256 }
257 return clean
258}
259
260/** Splits one line of the binary's output into styled spans. */
261export function spansOf(line: string): Span[] {
262 const spans: Span[] = []
263 let style: SpanStyle = {}
264 let cursor = 0
265
266 const push = (text: string) => {
267 const clean = stripControls(text)
268 if (clean.length > 0) spans.push({ ...style, children: clean })
269 }
270
271 for (const match of line.matchAll(ANSI_PATTERN)) {
272 push(line.slice(cursor, match.index))
273 cursor = match.index + match[0].length
274
275 const codes = (match[1] ?? '').split(';').filter(code => code.length > 0)
276 for (let at = 0; at < codes.length; at += 1) {
277 const code = Number(codes[at])
278 if (code === 0) style = {}
279 else if (code === 1) style = { ...style, bold: true }
280 else if (code === 2) style = { ...style, dimColor: true }
281 else if (code === 7) style = { ...style, inverse: true }
282 else if (code === 49) {
283 // The one reset code the renderer emits besides 0: `powerline.rs`'s
284 // DEFAULT_BG, written at the pointed outer edge of a rail or anchor
285 // cluster to hand the terminal's own background back. Dropping the
286 // property is the whole fix — carried over, the previous segment's
287 // background stays on the seam span and the point paints as a filled
288 // rectangle. 39 is not handled because nothing emits it.
289 const cleared = { ...style }
290 delete cleared.backgroundColor
291 style = cleared
292 } else if ((code === 38 || code === 48) && codes[at + 1] === '5') {
293 const hex = ansi256Hex(Number(codes[at + 2]))
294 style = code === 38 ? { ...style, color: hex } : { ...style, backgroundColor: hex }
295 at += 2
296 }
297 }
298 }
299 push(line.slice(cursor))
300 return spans
301}
302
303/** What one refresh's engine calls answered, kept so a failed one can reuse it. */
304type Collected = {
305 usage: SessionUsage
306 modelId: string
307 sessionId: string
308 agents: readonly AgentInfo[]
309 now: number
310}
311
312/**
313 * What stays in the module's closure, and so starts over on a hot reload.
314 *
315 * Everything the person would notice losing is in `$.state` instead (see
316 * `../types`). What is left is of two kinds:
317 *
318 * - geometry, which a render hook writes as it measures the box it is
319 * given. It cannot be an atom: the host refuses a state write while a
320 * `ui.render` hook draws. It is re-measured on the next draw anyway.
321 * - values a reload cannot be caught in the middle of: `active` is the
322 * tools in flight, and nothing is in flight at the instant the module
323 * is rebuilt; `lastRunMs` only gates the next 400ms; `lastCollected`
324 * is a cache of the engine's own answers, re-asked on the next refresh.
325 */
326type State = {
327 active: Map<string, ActiveTool>
328 lastRunMs: number
329 bandRows: number
330 bandCols: number
331 paneHeight: number
332 paneCols: number
333 /** MCP servers that are serving tools right now, by `$.tool.list()`. */
334 mcpServers: Set<string>
335 /** The last reading that came back whole, drawn again when one fails. */
336 lastCollected: Collected | null
337 /** Which refresh is newest; only that one may publish. See `refresh`. */
338 runSeq: number
339 /**
340 * The project root `syncRoot` last read, so a move can be told from the
341 * first read of a session. A closure field on purpose: a reload is not a
342 * move, and this starting over is what says so.
343 */
344 rootRead: string | null
345 /**
346 * The manifest's `userConfig` values, as `register`'s second parameter
347 * handed them over. Fixed for this activation: a change to them reloads
348 * the module, so a closure is the right place for them.
349 */
350 options: PluginOptions
351}
352
353/**
354 * The argument a tool row shows beside the tool name.
355 *
356 * `tool.call` carries the tool's own arguments on `e`, so this is a lookup,
357 * not the guess-by-shape that the transcript parser has to make.
358 */
359/**
360 * Which key each tool's useful argument is under, mirrored from
361 * `providers/transcript.rs`'s `extract_target`.
362 *
363 * A generic key sweep is not enough and was quietly wrong: `Grep` carries
364 * both `path` and `pattern`, and reading `path` first showed the directory
365 * being searched instead of what was being looked for. Tools whose argument
366 * is under none of the generic keys — `WebSearch`, `Skill`, `SendMessage`,
367 * `Monitor`, `PushNotification` — showed no target at all.
368 */
369const TARGET_KEYS: Record<string, readonly string[]> = {
370 Read: ['file_path'],
371 Write: ['file_path'],
372 Edit: ['file_path'],
373 NotebookEdit: ['file_path'],
374 Bash: ['command'],
375 PowerShell: ['command'],
376 Monitor: ['script_id', 'pattern'],
377 PushNotification: ['title'],
378 Advisor: ['query'],
379 MCPSearch: ['query'],
380 Glob: ['pattern'],
381 Grep: ['pattern'],
382 WebFetch: ['url'],
383 WebSearch: ['query'],
384 Skill: ['skill'],
385 SendMessage: ['to'],
386 LSP: ['command'],
387}
388
389/** The generic sweep, for a tool with no entry above. */
390const FALLBACK_TARGET_KEYS = ['file_path', 'command', 'pattern'] as const
391
392/**
393 * The argument a tool call shows in the band.
394 *
395 * Returned whole, only flattened to one line: the renderer truncates per tool
396 * kind and per available width (`render/activity/cells/recent_tool.rs` keeps
397 * the *tail* of a Read or Edit path, so cutting the head off here lost the
398 * filename and showed the wrong end).
399 */
400export function targetOf(event: Record<string, unknown>): string | undefined {
401 const name = typeof event.tool === 'string' ? event.tool : ''
402 const read = (key: string): string | undefined => {
403 const value = event[key]
404 return typeof value === 'string' && value.length > 0 ? value : undefined
405 }
406
407 // `AskUserQuestion` keeps its text one level down, in the first question.
408 if (name === 'AskUserQuestion') {
409 const first = Array.isArray(event.questions) ? event.questions[0] : undefined
410 const asked = (first as { question?: unknown } | undefined)?.question
411 return typeof asked === 'string' && asked.length > 0 ? singleLine(asked) : undefined
412 }
413
414 for (const key of TARGET_KEYS[name] ?? FALLBACK_TARGET_KEYS) {
415 const value = read(key)
416 if (value !== undefined) return singleLine(value)
417 }
418 return undefined
419}
420
421/** `fmt::sanitize_single_line`: a row is one line, so these become spaces. */
422function singleLine(text: string): string {
423 return text.replace(/[\n\r\t]/g, ' ')
424}
425
426/**
427 * Folds the Task API's items into the todo row's shape.
428 *
429 * A mirror of `state/mod.rs`'s `build_todo_from_tasks`, down to the summary
430 * wording and the sort, so the band draws the same row whichever path fed it.
431 * Note `pending` is everything not completed, the in-progress items included —
432 * that is the transcript path's arithmetic, not an oversight.
433 */
434export function todoFromTasks(items: Record<string, TaskItem>): Todo | null {
435 const all = Object.values(items)
436 if (all.length === 0) return null
437
438 const total = all.length
439 const completed = all.filter(item => item.status === 'completed').length
440 const pending = total - completed
441 const running = all
442 .filter(item => item.status === 'in_progress')
443 .map(item => ({ text: item.activeForm ?? item.subject, started_at: item.startedAt }))
444 .sort((a, b) => (a.started_at ?? Infinity) - (b.started_at ?? Infinity))
445
446 return {
447 text: `${completed}/${total} done, ${pending} pending`,
448 pending,
449 completed,
450 total,
451 in_progress_items: running,
452 all_done: pending === 0 && completed > 0,
453 is_task_api: true,
454 }
455}
456
457/**
458 * Folds one answered `TodoWrite`, `TaskCreate` or `TaskUpdate` into the todo
459 * row.
460 *
461 * All three run after `next(e)` and only when the call did not fail, because
462 * a list or a task the engine refused must not be drawn as current. Both ids
463 * come from the engine: the created task's from the call's own result, an
464 * update's from the event. Nothing is counted here, so a refused create
465 * cannot leave a phantom that a later update would move instead of the real
466 * task.
467 */
468async function rememberTodoCall(
469 $: EngineInterface,
470 name: string,
471 seen: Record<string, unknown>,
472 result: { result?: unknown },
473): Promise<void> {
474 if (name === 'TodoWrite' && Array.isArray(seen.todos)) {
475 const todo = todoOf(seen.todos as { content?: string; status?: string }[])
476 await update($, todoList, () => todo)
477 return
478 }
479
480 if (name === 'TaskCreate' && typeof seen.subject === 'string') {
481 const subject = seen.subject
482 const answered = (result.result as { task?: { id?: unknown } } | undefined)?.task?.id
483 if (typeof answered !== 'string' || answered.length === 0) return
484 const item: TaskItem = {
485 subject,
486 status: 'pending',
487 ...(typeof seen.activeForm === 'string' ? { activeForm: seen.activeForm } : {}),
488 }
489 await rebuildTodo($, held => ({ items: { ...held.items, [answered]: item } }))
490 return
491 }
492
493 if (name === 'TaskUpdate' && typeof seen.taskId === 'string') {
494 const taskId = seen.taskId
495 // `TaskUpdate` answers `{ success }`; a call that reported failure moved
496 // nothing, so neither does this.
497 const answered = result.result as { success?: unknown } | undefined
498 if (answered !== undefined && answered.success === false) return
499 const asked = typeof seen.status === 'string' ? seen.status : undefined
500 const subject = typeof seen.subject === 'string' ? seen.subject : undefined
501 const activeForm = typeof seen.activeForm === 'string' ? seen.activeForm : undefined
502 const at = await $.clock.now()
503 await rebuildTodo($, held => {
504 if (asked === 'deleted') {
505 const items = { ...held.items }
506 delete items[taskId]
507 return { items }
508 }
509 const was = held.items[taskId]
510 if (was === undefined) return held
511 // `status` is optional on `TaskUpdate`: the call also carries a new
512 // subject, active form or dependency set on its own. Defaulting it to
513 // `pending` reopened a completed task whenever the model only renamed
514 // one.
515 const status = asked ?? was.status
516 // The start is stamped once, so the row's elapsed time does not restart
517 // every time the same task is touched again.
518 const startedAt =
519 status === 'in_progress' && was.status !== 'in_progress' ? at : was.startedAt
520 return {
521 items: {
522 ...held.items,
523 [taskId]: {
524 ...was,
525 status,
526 startedAt,
527 ...(subject !== undefined ? { subject } : {}),
528 ...(activeForm !== undefined ? { activeForm } : {}),
529 },
530 },
531 }
532 })
533 }
534}
535
536/**
537 * Applies one change to the task items. One write, and the only one.
538 *
539 * It used to fold the result into the `todo` atom as a second write, and two
540 * task calls in flight together could interleave: both commit their items
541 * correctly, then the one that committed the *older* snapshot writes its fold
542 * last and the row drops or reopens a task until some later task event
543 * rebuilt it. The row is a fold over every item, so it is derived where it is
544 * drawn instead — `refresh` folds whatever the items atom holds at that
545 * moment, which is always the committed state.
546 */
547async function rebuildTodo($: EngineInterface, change: (held: Tasks) => Tasks): Promise<void> {
548 await update($, taskItems, change)
549}
550
551/** Folds one TodoWrite call's arguments into the todo row's shape. */
552function todoOf(
553 items: { content?: string; status?: string; activeForm?: string }[],
554): Todo | null {
555 // A cleared list is no list, not a list of nothing. The renderer reads any
556 // non-null todo as present and drew `0/0 done · 0 pending` in the ledger
557 // the pane shows; both siblings already answer this way —
558 // `extract_todo_summary` with `None`, `todoFromTasks` with `null` — and the
559 // transcript path's `dispatch_todo_write` passes that `None` straight to
560 // `set_todo`, so clearing is what the other path does too.
561 if (items.length === 0) return null
562
563 const running = items.filter(item => item.status === 'in_progress')
564 const completed = items.filter(item => item.status === 'completed').length
565 // Everything not completed, the running item included — which is what the
566 // transcript path's `extract_todo_summary` does and what `todoFromTasks`
567 // above does. Counting only `pending` left the ledger's
568 // `done = completed.max(total - pending)` reading a running item as done,
569 // so a lone running task drew `1/1 done · 0 pending`.
570 const pending = items.length - completed
571 const first = running[0]
572 return {
573 text: first?.activeForm ?? first?.content ?? '',
574 pending,
575 completed,
576 total: items.length,
577 in_progress_items: running.map(item => ({ text: item.activeForm ?? item.content ?? '' })),
578 all_done: items.length > 0 && completed === items.length,
579 is_task_api: true,
580 }
581}
582
583/**
584 * Resolves Claude Code's own version, once per session.
585 *
586 * No call on `$` answers it, and `CLAUDE_CODE_EXECPATH` is set on the
587 * children Claude Code spawns rather than on its own environment, so this
588 * asks the CLI. Left null when that fails: the statusline then draws its
589 * unknown marker, which is the truth.
590 */
591async function probeVersion(engine: EngineInterface): Promise<void> {
592 const held = await read(engine, sessionFacts)
593 if (held.version !== null) return
594 const fromEnv = await engine.env.get('CLAUDE_CODE_EXECPATH')
595 const guess = fromEnv?.split('/').pop()
596 if (guess && /^\d+\.\d+\.\d+$/.test(guess)) {
597 await update(engine, sessionFacts, facts => ({ ...facts, version: guess }))
598 return
599 }
600 try {
601 const run = await engine.process.run(['claude', '--version'], { timeoutMs: 10000 })
602 const found = run.stdout.match(/\d+\.\d+\.\d+/)
603 if (found) {
604 const version = found[0]
605 await update(engine, sessionFacts, facts => ({ ...facts, version }))
606 }
607 } catch {
608 // Left as it was: the statusline draws its unknown marker, which is true.
609 }
610}
611
612/**
613 * Asks the engine everything one draw needs, in one round trip.
614 *
615 * These used to be six sequential awaits in `refresh`, outside any `try`, so
616 * a single engine call failing took the whole draw down to a note. They run
617 * together now and the caller owns the failure.
618 *
619 * It also stamps `lastRunMs` first thing, before anything is awaited on the
620 * engine: the stamp used to come after all six, so two callers could both
621 * clear the refresh gate and spawn the binary twice. Only
622 * `classic.ConfigChange` is still gated, and that is the stamp it reads.
623 */
624async function collect(engine: EngineInterface, state: State): Promise<Collected> {
625 const now = await engine.clock.now()
626 state.lastRunMs = now
627
628 const [usage, modelId, sessionId, agents, tools] = await Promise.all([
629 engine.session.usage(),
630 engine.session.model(),
631 engine.session.id(),
632 engine.agent.list(),
633 engine.tool.list(),
634 ])
635
636 // Which MCP servers are actually serving tools, as against how many are
637 // written down in the settings files.
638 state.mcpServers = new Set(
639 tools
640 .filter(tool => tool.mcp)
641 .map(tool => tool.name.split('__')[1])
642 .filter((server): server is string => server !== undefined && server.length > 0),
643 )
644
645 // An agent the engine lists that no SubagentStart announced starts now,
646 // and stays started then: the stamp has to outlive this draw, or the
647 // agent's age would reset to zero on every row the band draws. It is a
648 // write, not a default, which is why it cannot live in `buildPayload`.
649 await update(engine, agentTimings, timings => {
650 const startedAt = { ...timings.startedAt }
651 const finishedAt = { ...timings.finishedAt }
652 let added = false
653 for (const agent of agents) {
654 if (startedAt[agent.id] === undefined) {
655 startedAt[agent.id] = now
656 added = true
657 }
658 // The same write at the other end. An agent the engine no longer lists
659 // as running that no `SubagentStop` stamped finished *now*, and stays
660 // finished: the renderer shows `completed_at - started_at`, so without
661 // the stamp a completed agent's elapsed time grows by one refresh
662 // interval on every draw instead of standing still.
663 if (agent.status !== 'running' && finishedAt[agent.id] === undefined) {
664 finishedAt[agent.id] = now
665 added = true
666 }
667 }
668 return added ? { ...timings, startedAt, finishedAt } : timings
669 })
670
671 return { usage, modelId, sessionId, agents, now }
672}
673
674/**
675 * Walks the ladder until something answers `--version` and is new enough.
676 *
677 * Each rung is probed by running it, never by `$.fs.stat`: on a machine
678 * that has used the statusline, `~/.claude/pulseline/` exists and holds
679 * `config.toml` and no binary, and a stat of that directory answers yes.
680 * Running the candidate tells a binary from a folder and checks the version
681 * in the same call.
682 *
683 * `stdin: ''` is passed on purpose. `ProcessRunInit.stdin` is documented as
684 * "written to the child's standard input, then closed"; what an *absent*
685 * `stdin` does is not stated for `$.process.run` (its sibling
686 * `ProcessSpawnRequest.input` does say "absent, standard input is closed
687 * from the start", but `run` does not repeat it). Passing the empty string
688 * puts it on the documented path, so a same-named executable on PATH that
689 * reads standard input cannot sit there until the timeout. The timeout is
690 * the second guard, at 400ms a rung.
691 *
692 * The answer is cached in `$.state`, so this runs once a session rather
693 * than once a refresh.
694 */
695/**
696 * How long a failed walk is kept before the ladder is tried again.
697 *
698 * Long enough that a session with nothing installed does not re-probe five
699 * rungs on every refresh, short enough that following the band's own
700 * `npm install -g` line is seen without restarting.
701 */
702const NEGATIVE_TTL_MS = 10_000
703
704async function resolveBinary(engine: EngineInterface, state: State): Promise<BinaryChoice> {
705 const held = await read(engine, binaryChoice)
706 const now = await engine.clock.now()
707 if (held !== null && (held.kind === 'ok' || now - held.at < NEGATIVE_TTL_MS)) return held
708
709 const configured = state.options.binaryPath
710 const home = (await engine.env.get('HOME')) ?? (await engine.env.get('USERPROFILE')) ?? ''
711 const facts = await read(engine, sessionFacts)
712
713 const ladder = candidates({
714 configured: typeof configured === 'string' ? configured : '',
715 pluginRoot: engine.plugin.root,
716 home,
717 cwd: facts.cwd,
718 })
719
720 // The *newest* thing that answered but was too old, so the note names the
721 // version the person actually has. Keeping the first one found would name
722 // an older copy further up the ladder and send them updating to a version
723 // they are already past.
724 let outdated: { path: string; version: Version; rung: string } | null = null
725
726 for (const candidate of ladder) {
727 try {
728 const run = await engine.process.run([candidate.path, '--version'], {
729 stdin: '',
730 timeoutMs: PROBE_TIMEOUT_MS,
731 })
732 if (run.exitCode !== 0) continue
733 const version = parseVersion(run.stdout)
734 if (version === null) continue
735 if (isNewEnough(version)) {
736 const choice: BinaryChoice = {
737 kind: 'ok',
738 path: candidate.path,
739 version: formatVersion(version),
740 rung: candidate.rung,
741 }
742 await update(engine, binaryChoice, () => choice)
743 return choice
744 }
745 if (outdated === null || isNewEnough(version, outdated.version)) {
746 outdated = { path: candidate.path, version, rung: candidate.rung }
747 }
748 } catch {
749 // Not there, not runnable, or it outran the probe. Next rung.
750 }
751 }
752
753 const choice: BinaryChoice =
754 outdated === null
755 ? { kind: 'missing', at: now }
756 : {
757 kind: 'too-old',
758 path: outdated.path,
759 version: formatVersion(outdated.version),
760 rung: outdated.rung,
761 at: now,
762 }
763 await update(engine, binaryChoice, () => choice)
764 return choice
765}
766
767/** Whether a path sits inside a directory, native separators included. */
768function under(dir: string, path: string): boolean {
769 if (dir.length === 0) return false
770 const slashed = (text: string) => text.replace(/\\/g, '/').replace(/\/+$/, '')
771 return `${slashed(path)}/`.startsWith(`${slashed(dir)}/`)
772}
773
774/**
775 * Puts the session's own project root in the atom, and forgets a binary that
776 * was found under the old one.
777 *
778 * `session.start`'s `e.cwd` is where the session began, not where it is.
779 * `$.session.root()` is the one that moves: its contract names `/cd`, a
780 * host's directory change and a worktree move, and says nested instruction
781 * files are read only beneath it — which is exactly what the binary scans
782 * for the config row, and what its git shell-out runs in. Held from the
783 * start, every later payload described the directory the person left.
784 *
785 * Read before the ladder rather than inside `collect`, which runs after it:
786 * the dev rung is `<root>/target/release/cc-pulseline`, so a choice made
787 * under the old root points into the old checkout.
788 */
789async function syncRoot(engine: EngineInterface, state: State): Promise<void> {
790 const root = await engine.session.root()
791 const facts = await read(engine, sessionFacts)
792 if (facts.cwd !== root) {
793 await update(engine, sessionFacts, held => ({ ...held, cwd: root }))
794 // Dropped rather than re-walked here: the ladder does that once, on the
795 // root just written.
796 await update(engine, binaryChoice, () => null)
797 }
798
799 // Only a root this function read before and now finds changed is a move.
800 // The first read is not one: `cwd` is empty until then, or holds
801 // `session.start`'s `e.cwd`, which is where the session began rather than
802 // where its root is — and the instructions loaded before this point were
803 // loaded for the root either way. Nor is a reload a move, which is why this
804 // sits in the closure: it starts over with the module, as it should.
805 const left = state.rootRead
806 state.rootRead = root
807 if (left === null || left === root) return
808 // The instruction counts are the other thing the root owns. Left standing,
809 // the ENV row reported the project the person had left — and then kept
810 // adding the new one's files to it, so the counts were two projects deep
811 // for the rest of the session. Reading the root live without this made that
812 // worse than leaving both stale: the path was right and the counts were not.
813 await update(engine, instructions, loaded => ({
814 // Kept: the files that are nobody's project — `~/.claude/CLAUDE.md`,
815 // `~/.claude/rules/`. Those were loaded for the session, not for the root.
816 claudeMd: loaded.claudeMd.filter(path => !under(left, path)),
817 agentsMd: loaded.agentsMd.filter(path => !under(left, path)),
818 rules: loaded.rules.filter(path => !under(left, path)),
819 // Cleared outright, where the others are filtered: every memory file is
820 // keyed by the project it belongs to — the binary reads them from
821 // `~/.claude/projects/<encoded-root>/memory/` — so none of the old root's
822 // are the new root's. The prefix test cannot see that, because they live
823 // under HOME rather than under the root.
824 memories: [],
825 }))
826 // A count of zero is sent as no field at all, so until the engine names the
827 // new root's files the binary's own scan stands. That is the right way to
828 // be wrong here: the scan reads the directory the session is actually in.
829}
830
831/**
832 * Builds the payload from the engine, runs the binary, parses its rows.
833 *
834 * **Call it through `drawSoon`, never directly.** It guards its own writes
835 * with `runSeq`, but a throw that gets past its internal handlers — an atom
836 * read, the write at the end — leaves the atom untouched, and the band's note
837 * is keyed on `bandRun` rather than on the row count: a direct caller that
838 * lets the rejection go therefore makes the band go quiet about it. `drawSoon`
839 * is where that rejection is recorded, under this run's own sequence number.
840 *
841 * Declared at the top of the file on purpose: the loader only lets `$` be
842 * passed to a function declared here, never to a closure inside `register`.
843 */
844async function refresh(engine: EngineInterface, state: State): Promise<void> {
845 // Each refresh claims a number and only the newest may publish. Several can
846 // be in flight at once — parallel tool calls, a turn event landing beside
847 // them — each with its own reading and its own spawn, and the write is a
848 // plain overwrite. Without this the slowest finisher wins, so an older
849 // reading can replace a newer one and leave a finished tool drawn as
850 // running until the next event.
851 const seq = (state.runSeq += 1)
852 // The running list as it is *now*, not as it will be when the payload is
853 // built: between here and there are a binary probe, a root read and seven
854 // engine calls, and a tool that finishes in that window is deleted from
855 // `state.active` by its own hook. Read late, this refresh would either
856 // lose the tool it was started to draw, or — from another event's refresh
857 // — serialise a tool that had already finished as still running.
858 const running = [...state.active.values()]
859 const publish = async (change: (out: DrawnOutput) => DrawnOutput): Promise<void> => {
860 if (seq !== state.runSeq) return
861 // Checked again *inside* the change, because `update` is a read-apply-write
862 // with retries: it yields, and a newer refresh can claim the number and
863 // publish in that window, which the outer check above has already passed.
864 // The change runs inside the transaction and on each retry, so this is the
865 // one that decides. Returning the value unchanged is the no-op.
866 await update(engine, drawnOutput, out => (seq === state.runSeq ? change(out) : out))
867 }
868
869 // Before the ladder, because the dev rung is built from the root.
870 try {
871 await syncRoot(engine, state)
872 } catch {
873 // Not worth blanking the band for: the root already held is the one the
874 // session started in, and `collect` below reports an engine refusing
875 // calls through `stale`.
876 }
877
878 // Nothing to run means nothing to draw but the reason. The band draws
879 // `runNote` when `bandRun` is `'failed'`, so this is what reaches it.
880 const binary = await resolveBinary(engine, state)
881 if (binary.kind !== 'ok') {
882 const note = binary.kind === 'missing' ? missingMessage() : tooOldMessage(binary.version)
883 // `'failed'`, not `'never'`: the ladder walked and came back empty-handed,
884 // and this message is the only place an install line can reach the person.
885 await publish(out => ({
886 ...out,
887 rows: [],
888 paneRows: [],
889 bandRun: 'failed',
890 runNote: note,
891 }))
892 return
893 }
894
895 let collected: Collected
896 let stale: string | null = null
897 try {
898 collected = await collect(engine, state)
899 // Only the newest refresh may leave the fallback behind it. Unguarded, an
900 // older refresh finishing its collection late replaced a newer reading —
901 // and the next refresh whose own collection fails draws that older one,
902 // with its stale usage and agent list, under the `stale:` note.
903 if (seq === state.runSeq) state.lastCollected = collected
904 } catch (error) {
905 // Nothing has ever come back whole: there is no reading to draw, and
906 // spawning the binary with fabricated zeros would draw a lie.
907 if (state.lastCollected === null) {
908 await publish(out => ({
909 ...out,
910 rows: [],
911 paneRows: [],
912 bandRun: 'failed',
913 runNote: `engine refused — ${noteText(String(error)).slice(0, 160)}`,
914 }))
915 return
916 }
917 collected = state.lastCollected
918 stale = noteText(String(error)).slice(0, 80)
919 }
920
921 // One read per atom the payload is built from. `collect` has already made
922 // its own writes, so these are the values the payload goes on to describe.
923 const [facts, totals, timings, loaded, written, tasks, pane, held] = await Promise.all([
924 read(engine, sessionFacts),
925 read(engine, toolTotals),
926 read(engine, agentTimings),
927 read(engine, instructions),
928 read(engine, todoList),
929 read(engine, taskItems),
930 read(engine, paneWanted),
931 read(engine, drawnOutput),
932 ])
933
934 // The Task API's row is folded here rather than written beside each change,
935 // so it is always the committed items and never an older snapshot's fold.
936 // Tasks win while any exist, because `todoFromTasks` answers `null` when
937 // none do — which is also how a session that only ever calls `TodoWrite`
938 // keeps its own row.
939 const todo = todoFromTasks(tasks.items) ?? written
940
941 // Effort rides on every classic hook input (see the `classic.PostToolUse`
942 // hook below); `$.env` does not carry it, being Claude Code's own
943 // environment rather than the one it hands to the children it spawns.
944 const payload = buildPayload({
945 sessionId: collected.sessionId,
946 modelId: collected.modelId,
947 usage: collected.usage,
948 agents: collected.agents,
949 now: collected.now,
950 cwd: facts.cwd,
951 version: facts.version,
952 effort: facts.effort,
953 outputStyle: facts.outputStyle,
954 lastApiErrorMs: facts.lastApiErrorMs,
955 cacheReadTotal: facts.cacheReadTotal ?? 0,
956 tokens: facts.tokens,
957 mcpCount: state.mcpServers.size,
958 claudeMdCount: loaded.claudeMd.length,
959 agentsMdCount: loaded.agentsMd.length,
960 rulesCount: loaded.rules.length,
961 memoryCount: loaded.memories.length,
962 active: running,
963 counts: totals.counts,
964 completedTotal: totals.completedTotal,
965 failedTotal: totals.failedTotal,
966 startedAt: timings.startedAt,
967 finishedAt: timings.finishedAt,
968 agentType: timings.agentType,
969 compactCount: facts.compactCount,
970 todo,
971 })
972
973 // The run's results are gathered locally and written once at the end: one
974 // write is one redraw, where a write per field would draw the band four
975 // times for a single refresh.
976 let rows: Span[][] = held.rows
977 // Normalized, because the annotation is a promise about the contract and
978 // not about what the host is holding: `$.state` outlives a reload,
979 // including the reload that upgrades the plugin, and an object written
980 // before `bandRun` existed has no such field. The atom's initial value
981 // only applies when the key is absent, so without this a run that somehow
982 // reached the write without setting it would persist `undefined`.
983 let bandRun: BandRun = held.bandRun ?? 'never'
984 let runNote = held.runNote
985 let paneRows: Span[][] = held.paneRows
986 let paneNote = held.paneNote
987
988 try {
989 const argv = [
990 binary.path,
991 '--rows',
992 String(state.bandRows),
993 '--columns',
994 String(state.bandCols),
995 // The band is not a second statusline. `activity` is the one layout
996 // that draws no identity and no budget row — those belong to the
997 // statusline, which is always on screen and is the only path that can
998 // sample tokens often enough to rate them. What is left is the turn in
999 // flight, led by the running tools so `--rows 2` outside fullscreen
1000 // cuts the cumulative counts rather than the live row. Without this the
1001 // band drew whatever layout the person configured: the statusline's own
1002 // rows, stacked above the prompt while the statusline drew them below.
1003 '--layout',
1004 'activity',
1005 ]
1006 const run = await engine.process.run(argv, { stdin: payload, timeoutMs: 5000 })
1007 rows = run.stdout
1008 .split('\n')
1009 .filter(line => line.length > 0)
1010 .map(spansOf)
1011 // Zero rows with a zero exit is the idle band, not a fault — which is why
1012 // the health of the run is tracked apart from the count of its rows.
1013 bandRun = run.exitCode === 0 ? 'ok' : 'failed'
1014 runNote =
1015 run.exitCode === 0
1016 ? `${rows.length}r band ${state.bandRows}x${state.bandCols} · ${running.length} running · ${totals.completedTotal} done · ${paneNote}`
1017 : `exit ${run.exitCode} — ${noteText(run.stderr).slice(0, 120)}`
1018 } catch (error) {
1019 bandRun = 'failed'
1020 runNote = `run refused — ${noteText(String(error)).slice(0, 160)}`
1021 rows = []
1022 }
1023
1024 // A reading that did not come back is worth saying out loud: the rows
1025 // above are the previous one's, and silence would pass them off as live.
1026 if (stale !== null) runNote = `${runNote} · stale: ${stale}`
1027
1028 if (!pane.open) {
1029 paneRows = []
1030 } else {
1031 // The pane is a different box, so it needs its own render: one row count
1032 // cannot serve a 1-row band and a 12-row pane.
1033 try {
1034 // One row short of the box on purpose: the render hook puts the layout
1035 // switcher under these rows, so a layout that does honour `--rows` and
1036 // fills the box exactly would push the switcher — and its hotkeys —
1037 // below the fold.
1038 const paneBodyRows = Math.max(1, state.paneHeight - 1)
1039 const paneArgv = [
1040 binary.path,
1041 '--rows',
1042 String(paneBodyRows),
1043 '--columns',
1044 String(state.paneCols),
1045 '--layout',
1046 pane.layout,
1047 ]
1048 const paneRun = await engine.process.run(paneArgv, { stdin: payload, timeoutMs: 5000 })
1049 paneRows = paneRun.stdout
1050 .split('\n')
1051 .filter(line => line.length > 0)
1052 .map(spansOf)
1053 paneNote =
1054 paneRun.exitCode === 0
1055 ? `pane: ${paneRows.length}r ${pane.layout} at ${state.paneHeight}x${state.paneCols}`
1056 : `pane: exit ${paneRun.exitCode} — ${noteText(paneRun.stderr).slice(0, 120)}`
1057 } catch (error) {
1058 paneRows = []
1059 paneNote = `pane: run failed — ${noteText(String(error)).slice(0, 160)}`
1060 }
1061 }
1062
1063 await publish(() => ({ rows, bandRun, runNote, paneRows, paneNote }))
1064}
1065
1066/**
1067 * Writes the turn's effort level, when the hook that saw it carried one.
1068 *
1069 * Declared at the top of the file because the loader only lets `$` be passed
1070 * to a function declared here — a closure inside `register` is refused, and
1071 * the refusal is a module that does not load at all.
1072 */
1073async function rememberEffort(engine: EngineInterface, level: string | undefined): Promise<void> {
1074 if (level) await update(engine, sessionFacts, facts => ({ ...facts, effort: level }))
1075}
1076
1077/**
1078 * Starts a refresh and does not wait for it, noting a failure on the band.
1079 *
1080 * The rule every site follows: await the drawing only where a person is
1081 * waiting for the drawing itself — `/pulseline`, a keypress, the session's
1082 * first draw. Everywhere else the engine is waiting on the hook, and a band
1083 * is not worth holding up the model or the turn for: the binary's
1084 * `timeoutMs` is five seconds, twice over with the pane open.
1085 *
1086 * The engine's contract floats work out of a hook the same way.
1087 * `$.process.spawn`'s second example runs its loop `void (async () => …)()`
1088 * from `session.start`, and names what ends the child: the loop, the stream,
1089 * the signal, the module unloading, and "nothing else does, a hook's return
1090 * included". So a captured `$` outlives the hook that took it.
1091 *
1092 * The promise it hands back is already handled, so chaining onto it is safe
1093 * and dropping it leaves nothing unhandled.
1094 */
1095function drawSoon(engine: EngineInterface, state: State): Promise<void> {
1096 // `refresh` claims its sequence number in its first statement, and an
1097 // `async function` runs to its first `await` synchronously — so the number
1098 // read here is the one the run below claimed. That is what lets this
1099 // handler, which sits outside `refresh`, apply the same rule `publish`
1100 // does.
1101 const run = refresh(engine, state)
1102 const seq = state.runSeq
1103 return run.catch(async error => {
1104 const note = `refresh failed: ${noteText(String(error)).slice(0, 120)}`
1105 // `bandRun` as well as the note. The band draws its note only for
1106 // `'failed'`, so a rejection that left `'ok'` behind would hold the rows
1107 // of the run before it and say nothing about the one that threw. This is
1108 // the only handler for a throw that got past `refresh`'s own — an atom
1109 // read, or the write at its end — so there is nowhere else to record it.
1110 //
1111 // Sequence-guarded inside the change, exactly as `publish` is: several
1112 // refreshes are in flight at once, and an older one rejecting after a
1113 // newer one has published must not stamp `'failed'` over the newer rows,
1114 // because the render hook puts the note ahead of the rows it holds.
1115 // Annotated for the same reason `publish`'s callers are not: nothing here
1116 // types the literal, so `'failed'` would widen to `string`.
1117 await update(engine, drawnOutput, (out): DrawnOutput =>
1118 seq === state.runSeq ? { ...out, bandRun: 'failed', runNote: note } : out,
1119 ).catch(() => undefined)
1120 })
1121}
1122
1123/**
1124 * Refreshes only if the last run is older than `gapMs`.
1125 *
1126 * One caller is left: `classic.ConfigChange`, which a single settings write
1127 * can raise several times over. Every other site refreshes unconditionally,
1128 * because a gated refresh is not a draw made late — it is a draw not made at
1129 * all, and the band's rows are only ever the binary's last stdout.
1130 */
1131async function maybeRefresh(
1132 engine: EngineInterface,
1133 state: State,
1134 gapMs: number,
1135): Promise<void> {
1136 const now = await engine.clock.now()
1137 if (!shouldRefresh(state.lastRunMs, now, gapMs)) return
1138 // Claim the window before awaiting anything else, or a second caller
1139 // arriving while this one is in `refresh` reads the old stamp and spawns
1140 // the binary too. `collect` stamps it again with its own reading.
1141 state.lastRunMs = now
1142 await drawSoon(engine, state)
1143}
1144
1145export const register: Register = (on, options) => {
1146 const state: State = {
1147 active: new Map(),
1148 lastRunMs: 0,
1149 bandRows: 3,
1150 bandCols: 120,
1151 paneHeight: 12,
1152 paneCols: 120,
1153 mcpServers: new Set(),
1154 lastCollected: null,
1155 runSeq: 0,
1156 rootRead: null,
1157 options,
1158 }
1159
1160 on('session.start', async ($, e, next) => {
1161 const result = await next(e)
1162 // The seed, not the source: `syncRoot` reads the root before every ladder
1163 // walk, and this is the value it falls back to if that read is refused.
1164 const cwd = e.cwd
1165 await update($, sessionFacts, facts => ({ ...facts, cwd }))
1166 // Scaffolding. `$.ui.status` pins an entry beside the prompt and
1167 // `$.ui.log` writes a transcript line, and a plugin a person installs
1168 // should say neither: what the band cannot draw, the band itself says.
1169 //
1170 // Cleared rather than merely not set. A status entry belongs to the
1171 // session, not to the load that pinned it, so a version that used to pin
1172 // one leaves it beside the prompt for the rest of that session — through
1173 // every reload, because the module that would have replaced it no longer
1174 // says anything. `undefined` is what takes it down.
1175 if (DEBUG) $.ui.status('alive')
1176 else $.ui.status(undefined)
1177 await probeVersion($)
1178 try {
1179 await $.command.register({
1180 name: 'pulseline',
1181 description: 'Open the statusline in a pane, where it has room for activity rows',
1182 })
1183 } catch (error) {
1184 const note = `command refused — ${noteText(String(error)).slice(0, 120)}`
1185 // `'failed'` with it. A note the band will not draw is a note nobody
1186 // reads, and since Phase E the band draws one only for `'failed'`. The
1187 // `refresh` on the next line replaces both fields the moment the binary
1188 // answers, which is the right lifetime for it: the band works, so it
1189 // should go back to drawing activity.
1190 await update($, drawnOutput, (out): DrawnOutput => ({
1191 ...out,
1192 bandRun: 'failed',
1193 runNote: note,
1194 }))
1195 }
1196 await drawSoon($, state)
1197 if (DEBUG) $.ui.log((await read($, drawnOutput)).runNote)
1198 return result
1199 })
1200hooks/binary.ts 148 lines1/**
2 * Where the cc-pulseline binary is, and whether it is new enough.
3 *
4 * Everything here is pure: the ladder is a list of paths built from four
5 * strings, and the version gate is string parsing. The probing itself lives
6 * in register.tsx, because a function handed `$` has to be declared in the
7 * module the loader scans for `$` calls — the host refuses a call its scan
8 * did not list.
9 *
10 * No binary is bundled and none is downloaded. `$.fs.write` is text-only,
11 * `FsStat` carries no mode bit and there is no chmod, so a downloaded file
12 * could never be made executable; and the npm package already ships seven
13 * platforms with musl detection, so there is nothing here to rebuild.
14 */
15
16/**
17 * The oldest binary whose CLI this mod can drive.
18 *
19 * A rung that answers but is older is passed over, not accepted and not
20 * fatal: the walk goes on and a later rung may be new enough. That is the
21 * real shape of the machine this was written on — `cargo install` left an
22 * older copy on PATH while the dev build was current — and a ladder that
23 * stopped at the first thing to answer would have taken the old one. The
24 * too-old message is only reached when no rung at all is new enough.
25 *
26 * It tracks the release, because each release so far has added the CLI the
27 * band of that release drives. 1.6.0 added `--layout activity`, which the
28 * band now always asks for, and an unknown layout name is not an error to
29 * the binary: it warns on stderr, falls back to `none` and exits 0. So a
30 * 1.5.x binary would answer a band spawn with the identity and budget rows
31 * — the statusline, drawn a second time above the prompt and now resident —
32 * and `bandRun` would read `'ok'`. Refusing it outright is the only way that
33 * failure gets said out loud.
34 */
35export const MIN_VERSION: Version = [1, 6, 0]
36
37/** How long one probe may take. Five rungs, so this bounds the whole ladder. */
38export const PROBE_TIMEOUT_MS = 400
39
40export type Version = [number, number, number]
41
42/** Which rung a candidate came from, for the note and for the tests. */
43export type Rung = 'configured' | 'bundled' | 'installed' | 'path' | 'dev'
44
45export type Candidate = { path: string; rung: Rung }
46
47export type Where = {
48 /** The `binaryPath` userConfig field; empty when the person left it unset. */
49 configured?: string | undefined
50 /** `$.plugin.root`: the folder holding plugin.json. */
51 pluginRoot: string
52 /** The person's home directory, for the installed path. */
53 home: string
54 /** The session's working directory, for the dev rung. */
55 cwd: string
56}
57
58export const BINARY_NAME = 'cc-pulseline'
59
60/**
61 * The ladder, in the order it is tried.
62 *
63 * Each rung is only a path; whether anything is there is settled by running
64 * it, never by `$.fs.stat`. That is not caution — on a machine that has used
65 * the statusline, `~/.claude/pulseline/` exists and holds `config.toml` and
66 * no binary, and a stat of the *directory* answers yes. Running the
67 * candidate is the only thing that tells a binary from a folder, and it
68 * doubles as the version check.
69 */
70export function candidates(where: Where): Candidate[] {
71 const found: Candidate[] = []
72 const add = (path: string, rung: Rung) => {
73 if (path.length > 0 && !found.some(one => one.path === path)) found.push({ path, rung })
74 }
75
76 // 1. What the person named, which always wins.
77 add((where.configured ?? '').trim(), 'configured')
78
79 // 2. Shipped beside the mod. Reserved for a bundling decision that has not
80 // been taken, so this is expected to miss. No engine call answers the
81 // platform, so there is no `<platform>` segment to build: whoever
82 // bundles picks the layout and this rung follows it.
83 if (where.pluginRoot.length > 0) add(`${where.pluginRoot}/bin/${BINARY_NAME}`, 'bundled')
84
85 // 3. Where scripts/install.sh puts it on its download path.
86 if (where.home.length > 0) add(`${where.home}/.claude/pulseline/${BINARY_NAME}`, 'installed')
87
88 // 4. On PATH — the npm package, or `cargo install` landing in ~/.cargo/bin,
89 // which is what install.sh does when it is run inside the repo.
90 add(BINARY_NAME, 'path')
91
92 // 5. The dev rung, and labelled as such. It cannot be dropped: this
93 // repository's own session draws from its own build, and install.sh run
94 // inside the repo never writes to the installed path. It is the session's
95 // working directory rather than anyone's absolute path, so it works in
96 // any checkout.
97 if (where.cwd.length > 0) add(`${where.cwd}/target/release/${BINARY_NAME}`, 'dev')
98
99 return found
100}
101
102/** Reads `cc-pulseline 1.5.0` — or any line carrying a three-part version. */
103export function parseVersion(stdout: string): Version | null {
104 const found = stdout.match(/(\d+)\.(\d+)\.(\d+)/)
105 if (found === null) return null
106 const [major, minor, patch] = [Number(found[1]), Number(found[2]), Number(found[3])]
107 if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) return null
108 return [major, minor, patch]
109}
110
111export function formatVersion(version: Version): string {
112 return version.join('.')
113}
114
115/** True when `version` is at least `MIN_VERSION`. */
116export function isNewEnough(version: Version, minimum: Version = MIN_VERSION): boolean {
117 for (let at = 0; at < 3; at += 1) {
118 const have = version[at] ?? 0
119 const need = minimum[at] ?? 0
120 if (have !== need) return have > need
121 }
122 return true
123}
124
125const INSTALL = 'npm install -g @cc-pulseline/cc-pulseline'
126
127/** What the band says when no rung answered. */
128export function missingMessage(): string {
129 return `cc-pulseline not found — install it with: ${INSTALL}`
130}
131
132/**
133 * What the band says when a binary answered but is too old.
134 *
135 * It names the statusline on purpose. Every version below the floor draws
136 * the statusline perfectly well; what they lack is a band flag — `--rows`
137 * and `--columns` before 1.5.0, `--layout activity` before 1.6.0 — and none
138 * of them refuses. They draw the classic layout into the band at the wrong
139 * width, row count or both. Someone reading "too old" would otherwise go
140 * looking for a broken statusline that is not broken.
141 */
142export function tooOldMessage(version: string): string {
143 return (
144 `cc-pulseline ${version} is too old — the band needs ${formatVersion(MIN_VERSION)} ` +
145 `or newer; your statusline is unaffected. Update with: ${INSTALL}`
146 )
147}
148hooks/payload.ts 191 lines1import type { AgentInfo, SessionUsage } from 'claude-code'
2
3import type { Todo, Tokens, ToolCount } from '../types'
4
5/**
6 * The stdin contract between the mod and the cc-pulseline binary.
7 *
8 * The binary reads the same JSON Claude Code's own statusline hook writes,
9 * so this file is the one place that knows that schema: everything else in
10 * the mod collects facts, and `buildPayload` is the only thing that names a
11 * payload field. It touches no `$` and mutates nothing — hand it the same
12 * snapshot twice and it returns the same bytes.
13 */
14
15/**
16 * One running tool call, keyed by its `tool_use_id`.
17 *
18 * Not in the `$.state` contract: running tools live in the module's closure,
19 * since nothing is mid-flight at the moment a reload happens.
20 */
21export type ActiveTool = { id: string; name: string; target?: string }
22
23/**
24 * Everything one payload is built from: what the engine answered for this
25 * refresh, and what the hooks have accumulated since the session began.
26 *
27 * The maps are read-only here on purpose. An agent with no `startedAt`
28 * record must be stamped *before* this is built, or its start time would be
29 * the moment of each draw rather than the moment it was first seen; the
30 * caller owns that stamping, which is why this stays pure.
31 */
32export type PayloadSnapshot = {
33 /** What the engine answered this refresh. */
34 sessionId: string
35 modelId: string
36 usage: SessionUsage
37 agents: readonly AgentInfo[]
38 now: number
39
40 /**
41 * Session facts the hooks resolved once.
42 *
43 * `outputStyle` and `lastApiErrorMs` may arrive `undefined` rather than
44 * `null` from a session whose `session` atom the host wrote before these
45 * two existed, so both are read defensively below.
46 */
47 cwd: string
48 version: string | null
49 effort: string | null
50 outputStyle: string | null
51 lastApiErrorMs: number | null
52 cacheReadTotal: number
53 tokens: Tokens | null
54
55 /**
56 * Counts the engine answered for. A zero means "nothing seen", and the
57 * field is then left out so the binary's own filesystem scan stands.
58 */
59 mcpCount: number
60 claudeMdCount: number
61 agentsMdCount: number
62 rulesCount: number
63 memoryCount: number
64
65 /**
66 * Activity the hooks accumulated.
67 *
68 * Plain records rather than `Map`s: every one of these but `active` is an
69 * atom value now, and an atom value crosses to the host as JSON. Key order
70 * is insertion order either way, so the rows come out as they did — tool
71 * names are never integer-like, which is the one case where an object
72 * would reorder them.
73 *
74 * `active` is a list, and a copy: `refresh` takes it when it starts, so a
75 * tool that finishes while the draw is still gathering cannot be drawn as
76 * running by it, nor lost from the draw it was started for.
77 */
78 active: readonly ActiveTool[]
79 counts: Readonly<Record<string, ToolCount>>
80 completedTotal: number
81 failedTotal: number
82 startedAt: Readonly<Record<string, number>>
83 finishedAt: Readonly<Record<string, number>>
84 agentType: Readonly<Record<string, string>>
85 compactCount: number
86 todo: Todo | null
87}
88
89/**
90 * Turns a model id into the name the statusline shows.
91 *
92 * The engine hands out ids (`claude-opus-5[1m]`); the statusline payload
93 * that Claude Code itself writes carries a display name, and there is no
94 * call for that one, so this rebuilds it.
95 */
96export function modelName(id: string): string {
97 const isMillion = id.includes('[1m]')
98 const base = id
99 .replace('[1m]', '')
100 .replace(/^claude-/, '')
101 .replace(/-\d{8}$/, '')
102 const words = base
103 .split('-')
104 .map(word => word.charAt(0).toUpperCase() + word.slice(1))
105 .join(' ')
106 return isMillion ? `${words} (1M context)` : words
107}
108
109/** The JSON the binary reads on stdin. */
110export function buildPayload(snapshot: PayloadSnapshot): string {
111 const { usage, now } = snapshot
112
113 const limits: Record<string, { used_percentage: number; resets_at?: number }> = {}
114 for (const limit of usage.rateLimits) {
115 // Left out when the engine named no reset, and when it named one that
116 // does not parse. `resets_at: 0` is not "unknown" to the binary — it is
117 // the epoch, and the quota cell then reads `resets <1m` on every refresh
118 // for the rest of the session. An absent key is `None` there, which draws
119 // the percentage and no reset at all.
120 const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt) / 1000
121 limits[limit.kind] = {
122 used_percentage: limit.percentUsed,
123 ...(Number.isFinite(resetsAt) ? { resets_at: Math.floor(resetsAt) } : {}),
124 }
125 }
126
127 return JSON.stringify({
128 session_id: snapshot.sessionId,
129 model: { display_name: modelName(snapshot.modelId) },
130 output_style: snapshot.outputStyle ? { name: snapshot.outputStyle } : undefined,
131 version: snapshot.version ?? undefined,
132 effort: snapshot.effort ? { level: snapshot.effort } : undefined,
133 cwd: snapshot.cwd,
134 workspace: { current_dir: snapshot.cwd },
135 context_window: {
136 context_window_size: usage.context.window,
137 used_percentage: usage.context.percent,
138 current_usage: snapshot.tokens ?? undefined,
139 },
140 cost: usage.cost ? { total_cost_usd: usage.cost.usd } : undefined,
141 rate_limits: limits,
142 // Only the counts the engine answered for; the rest stay as the
143 // binary's own filesystem scan found them.
144 env: {
145 mcp_count: snapshot.mcpCount,
146 claude_md_count: snapshot.claudeMdCount > 0 ? snapshot.claudeMdCount : undefined,
147 agents_md_count: snapshot.agentsMdCount > 0 ? snapshot.agentsMdCount : undefined,
148 rules_count: snapshot.rulesCount > 0 ? snapshot.rulesCount : undefined,
149 memory_count: snapshot.memoryCount > 0 ? snapshot.memoryCount : undefined,
150 },
151 activity: {
152 tools: snapshot.active.map(tool => ({
153 id: tool.id,
154 name: tool.name,
155 target: tool.target ?? null,
156 })),
157 completed_counts: Object.entries(snapshot.counts).map(([name, count]) => ({
158 name,
159 count: count.count,
160 failed: count.failed,
161 // What the binary ranks on, beside the count. Left out when a kind
162 // has no stamp — a session whose atom the host wrote before this
163 // field existed — where the binary then scores it on its count alone.
164 last_completed_at: count.lastAt > 0 ? count.lastAt : undefined,
165 })),
166 completed_total: snapshot.completedTotal,
167 failed_total: snapshot.failedTotal,
168 agents: snapshot.agents.map(agent => ({
169 id: agent.id,
170 description: agent.description,
171 agent_type: snapshot.agentType[agent.id] ?? agent.type,
172 started_at: snapshot.startedAt[agent.id] ?? now,
173 // A stamp from `SubagentStop`, or the one `collect` wrote the first
174 // time the engine stopped listing the agent as running. The `now`
175 // below is only reached by a snapshot built without that write — a
176 // live refresh always has the stamp, which is what keeps a completed
177 // agent's elapsed time still.
178 completed_at:
179 snapshot.finishedAt[agent.id] ?? (agent.status === 'running' ? null : now),
180 })),
181 compact_count: snapshot.compactCount,
182 last_api_error_ms: snapshot.lastApiErrorMs ?? undefined,
183 // Already totalled, because only this side can total it. Left out
184 // while nothing has been read, so the binary keeps its own figure
185 // rather than being told the session read zero.
186 cache_read_total: snapshot.cacheReadTotal > 0 ? snapshot.cacheReadTotal : undefined,
187 todo: snapshot.todo,
188 },
189 })
190}
191hooks/throttle.ts 26 lines1/**
2 * The refresh gate, as a function of three numbers.
3 *
4 * Pulled out for the same reason `buildPayload` was: the decision is
5 * arithmetic, and arithmetic can be tested without a clock, a spawn or a
6 * mounted drawing. `maybeRefresh` keeps the part that is genuinely about
7 * timing — reading the clock and claiming the window — and asks this.
8 *
9 * Several sites can trigger a refresh. The gate guards the ones that fire
10 * without a tool call of their own to account for — a run of
11 * `classic.ConfigChange` writes, say — so a burst of them does not spawn the
12 * binary once each.
13 *
14 * It does not guard the tool-call path any more, at either end. A gated start
15 * is not drawn late but never drawn at all, since the window is closed by the
16 * previous call's completion and the next refresh is this call's own, after
17 * its entry is gone; and a gated completion leaves a finished tool drawn as
18 * running. Both ends of a tool call therefore refresh unconditionally and pay
19 * a spawn each, against a floor under 3ms.
20 */
21export function shouldRefresh(lastRunMs: number, nowMs: number, gapMs: number): boolean {
22 // `>=`, so a gap of 0 always refreshes — a test that wants every call to
23 // go through asks for 0 rather than having to move a clock.
24 return nowMs - lastRunMs >= gapMs
25}
26types/index.d.ts 237 lines1/**
2 * The mod's `$.state` contract: what the host holds for the session, and so
3 * what survives a hot reload of this code.
4 *
5 * The test for a field belonging here is not "does it accumulate" but "would
6 * the person notice it being lost", because a reload happens every time the
7 * mod is edited. That covers three kinds of value: what the hooks have added
8 * up over the session, what the person has asked for (their pane, their
9 * layout), and the binary's last output, so a reload redraws the rows it
10 * already had instead of flashing a not-run-yet note.
11 *
12 * Geometry (`bandRows`, `bandCols`, `paneCols`, `paneHeight`) is deliberately
13 * NOT here: it is written from inside `ui.render`, and the host refuses a
14 * state write while a render hook draws.
15 *
16 * Values are grouped by concern rather than one atom per field: `update` is a
17 * read-modify-write with `ifVersion` and a retry, so a group is safe against
18 * concurrent writers, and `refresh` sits in the tool-call path where seven
19 * round trips beat twenty-one.
20 */
21
22/**
23 * The styles `spansOf` sets from the binary's SGR codes.
24 *
25 * Spelled out rather than taken from `TextProps`, because a contract has to
26 * be self-contained — `claude plugin validate` refuses one that imports. It
27 * is the subset the ANSI parser actually produces, and every member is a
28 * `TextProps` member, so a span still spreads onto a `Text`.
29 */
30export type SpanStyle = {
31 color?: string
32 backgroundColor?: string
33 bold?: boolean
34 dimColor?: boolean
35 inverse?: boolean
36}
37
38/** One run of text carrying the style the SGR codes before it set. */
39export type Span = SpanStyle & { children: string }
40
41/** Turn token counts, kept from the last `turn.complete`. */
42export type Tokens = {
43 input_tokens: number
44 output_tokens: number
45 cache_read_input_tokens: number
46 cache_creation_input_tokens: number
47}
48
49/** Session totals per tool name, as the completed-counts row shows them. */
50/**
51 * One tool kind's tally.
52 *
53 * `lastAt` is when it last finished, in epoch milliseconds. The binary ranks
54 * the kinds by count plus a recency bonus and keeps only
55 * `max_completed_tools` of them — four by default — so without a stamp the
56 * four a session opened with would outrank the ones it actually leans on.
57 */
58export type ToolCount = { count: number; failed: number; lastAt: number }
59
60/** The todo list as the last TodoWrite call left it. */
61export type Todo = {
62 text: string
63 pending: number
64 completed: number
65 total: number
66 in_progress_items: { text: string; started_at?: number }[]
67 all_done: boolean
68 is_task_api: boolean
69}
70
71/**
72 * One Task API item: `TaskCreate` opens it, `TaskUpdate` moves its status.
73 *
74 * The transcript path keeps the same four fields (`state/mod.rs`'s `TaskItem`)
75 * because the todo row is rebuilt from the whole set on every change — a
76 * status arrives with an id and nothing else, so the rest has to be held.
77 */
78export type TaskItem = {
79 subject: string
80 status: string
81 activeForm?: string
82 startedAt?: number
83}
84
85/**
86 * The Task API's items, by the id the engine gave each one.
87 *
88 * `TaskCreate`'s *result* carries that id (`{ task: { id } }`), which is the
89 * id a later `TaskUpdate` names — so nothing here is counted or guessed, and
90 * a refused create leaves no entry behind.
91 */
92export type Tasks = {
93 items: Record<string, TaskItem>
94}
95
96/**
97 * Facts about the session the hooks resolve as the engine reports them.
98 *
99 * `cwd` holds the session's project root, which `$.session.root()` answers
100 * before every ladder walk: it moves under the mod — `/cd`, a host's
101 * directory change, a worktree move — and it is both what the binary scans
102 * for the config row and where its git shell-out runs, so a held one reports
103 * the project the person left. `session.start`'s `e.cwd` still seeds it, as
104 * the value the read falls back to: the window between `register` running
105 * and that event arriving draws the project it already knew rather than an
106 * empty path, and a payload with no `cwd` resolves to an unknown project
107 * where the binary has no env, no git and no project config.
108 */
109export type SessionFacts = {
110 cwd: string
111 version: string | null
112 effort: string | null
113 tokens: Tokens | null
114 compactCount: number
115 /** What the person chose in place of the default way of answering. */
116 outputStyle: string | null
117 /**
118 * When the last turn ended in an API error, in epoch milliseconds.
119 *
120 * Never cleared: the binary already drops a reading older than 30s, so a
121 * stale one cannot light the badge.
122 */
123 lastApiErrorMs: number | null
124 /**
125 * Cache-read tokens summed over the session, for the ledger's CACHE row.
126 *
127 * The mod has to total this itself. The binary cannot: only the transcript
128 * path can dedupe per-API-call usage by `message.id`, and every refresh
129 * re-sends the same last-turn usage as `current_usage` — twice over when
130 * the pane is open — so accumulating there would multiply-count.
131 *
132 * Not reset on compaction, deliberately. The transcript path keeps it
133 * across `compact_boundary` and clears only the trend window
134 * (`providers/transcript.rs`), and the row claims a session total, so
135 * resetting here would make the two paths disagree.
136 */
137 cacheReadTotal: number
138}
139
140/** What the tool-call hook has counted this session. */
141export type ToolTotals = {
142 counts: Record<string, ToolCount>
143 completedTotal: number
144 failedTotal: number
145}
146
147/** Agent timings and resolved types, by agent id. */
148export type AgentTimings = {
149 startedAt: Record<string, number>
150 finishedAt: Record<string, number>
151 agentType: Record<string, string>
152}
153
154/** The instruction files the engine said it loaded, by kind. */
155export type Instructions = {
156 claudeMd: string[]
157 /**
158 * Kept apart from `claudeMd` because the binary counts the two separately:
159 * `env.rs`'s `count_agents_md` has its own column on the ENV row, so an
160 * AGENTS.md folded into the CLAUDE.md count is drawn twice.
161 */
162 agentsMd: string[]
163 rules: string[]
164 memories: string[]
165}
166
167/** What the person asked of the pane, which a reload must not undo. */
168export type PaneWanted = {
169 open: boolean
170 layout: string
171}
172
173/**
174 * Which binary the mod resolved, and whether it can be used.
175 *
176 * Held rather than recomputed because resolving it means running up to five
177 * candidates: a closure would re-probe the whole ladder on every hot reload.
178 * It is set back to `null` on `classic.ConfigChange`, so a binary installed
179 * or a path configured mid-session is picked up — without that an atom would
180 * keep one bad answer for the life of the session.
181 */
182/**
183 * Where the binary is, as the ladder last answered.
184 *
185 * The two negative answers carry `at`, the moment the walk was made, because
186 * they expire: the band's own message is `npm install -g …`, and an install
187 * raises no event at all — so a cached failure that never expired would
188 * outlive the fix it asked for and the band could not recover inside the
189 * session. A successful answer has no `at`; it is kept until a settings
190 * write clears it.
191 */
192export type BinaryChoice =
193 | { kind: 'ok'; path: string; version: string; rung: string }
194 | { kind: 'too-old'; path: string; version: string; rung: string; at: number }
195 | { kind: 'missing'; at: number }
196
197/**
198 * How the last band run ended, which is not the same question as whether
199 * there are rows.
200 *
201 * The band asks the binary for the `activity` layout, whose output is empty
202 * whenever nothing is running — so zero rows is the ordinary state between
203 * turns, not a fault. Only `'failed'` is worth saying out loud, and it has
204 * to be said whether or not a turn is running: a missing binary raises no
205 * event, and an install line nobody sees fixes nothing.
206 *
207 * `'never'` is the value before the first walk finishes. It draws nothing,
208 * so the band does not flash a not-run-yet note over an idle prompt while
209 * `session.start` is still probing.
210 */
211export type BandRun = 'never' | 'ok' | 'failed'
212
213/** The binary's last output, kept so a reload redraws rather than blanks. */
214export type DrawnOutput = {
215 rows: Span[][]
216 paneRows: Span[][]
217 bandRun: BandRun
218 runNote: string
219 paneNote: string
220}
221
222declare module 'claude-code' {
223 interface PluginState {
224 'pulseline': {
225 session: SessionFacts
226 tools: ToolTotals
227 agents: AgentTimings
228 instructions: Instructions
229 todo: Todo | null
230 tasks: Tasks
231 pane: PaneWanted
232 output: DrawnOutput
233 binary: BinaryChoice | null
234 }
235 }
236}
237