SLOPSHOPPER

pulseline

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

newpanebandguardcommandstatus
★ 3v1.6.0MITupdated 2026-10-16GregoryHo/cc-pulseline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pulseline
│ ┃ pulseline ✕ › fix the failing auth test and add an audit log call │ ┃ pane: not run yet │ ┃ cc-pulseline not found — install it with: ⏺ Read(src/auth.ts) │ ┃ npm install -g @cc-pulseline/cc-pulseline ⎿ Read 6 lines │ ┃ 1: ledger 2: budgets 3: compact 4: rail ⏺ 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 │ │ › /pulseline │ │ pulseline: cc-pulseline not found — install it with: npm install -g @cc-pulseline/cc-pulseline ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
pulseline: cc-pulseline not found — install it with: npm install -g @cc-pulseline/cc-pulseline
Pane · pulseline
pane: not run yet cc-pulseline not found — install it with: npm install -g @cc-pulseline/cc-pulseline 1: ledger 2: budgets 3: compact 4: rail
README

cc-pulseline

CI Crates.io npm License: MIT

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.

cc-pulseline

What You'll See

Four things at a glance, re-colored live as you work:

  • Core metrics — identity on L1 (model · style · version · project · git) and budget on L3 (CTX% · tokens · cost) are always visible. Config counts (CLAUDE.md / rules / MCPs / skills …) are an opt-in L2 row.
  • Threshold alerts — context shifts to warn at 55% and red at 70%; burn rate turns magenta past $50/h. The numbers recolor themselves — no reading required.
  • Live activity — running tools with their targets, agent status, and todo progress stream in on L4+, noise-filtered and capped per segment.
  • Layouts & themes — layouts from flat to framed to Powerline single-bar, plus built-in themes and composable per-segment widgets. See docs/layouts.md.

Features

  • Multi-line metrics dashboard — Identity, config counts, budget, quota, and live activity, across layouts from flat to framed to Powerline single-bar (none, console, rail, … — see docs/layouts.md for the catalog)
  • Incremental transcript parsing — Seek-based JSONL parsing with per-session offsets
  • Deep observability — Active tools with targets, agent status, todo tracking
  • Session-aware — Concurrent Claude Code sessions tracked independently
  • Adaptive rendering — Width degradation for narrow terminals
  • Built-in themes — ThemePalette system with custom themes, per-color TOML overrides, and --preview (see src/themes/ for the full set)
  • Minimal dependencies — 4 runtime crates (serde, serde_json, toml, terminal_size)
  • Configurable — TOML config with per-project overrides and segment toggles

Quickstart

1. Install

# 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

2. Configure Claude Code

Add to ~/.claude/settings.json:

{
  "statusLine": {
    "type": "command",
    "command": "~/.claude/pulseline/cc-pulseline"
  }
}

3. Done

Start a Claude Code session — the statusline appears automatically.

Installation Methods

MethodCommandBest For
npmnpm i -g @cc-pulseline/cc-pulselineClaude Code users
cargo-binstallcargo binstall cc-pulselineRust devs (prebuilt)
cargo installcargo install cc-pulselineRust devs (from source)
install.sh./scripts/install.shLocal clone

Activity Band (Claude Code plugin)

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.

Configuration

cc-pulseline uses TOML configuration with two scopes:

  • User: ~/.claude/pulseline/config.toml
  • Project: {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

Example 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

Layouts & Visual Composition

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

console and rail layouts

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.

CLI Usage

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.

Environment Variables

VariableEffect
NO_COLORDisable all color output
COLUMNSTerminal width for layout degradation

Compatibility

Requirements

RequirementMinimum
Terminal256-color ANSI
Rust (build)1.85+
Node.js (npm)14+

Platform Support

Platformnpmcargo installcargo-binstall
macOS ARM64YesYesYes
macOS x64YesYesYes
Linux x64YesYesYes
Linux x64 (musl)YesYesYes
Linux ARM64YesYesYes
Linux ARM64 (musl)YesYesYes
Windows x64YesYesYes

Performance

Designed for repeated invocation by Claude Code's statusline hook:

ScenarioTarget (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.

Troubleshooting

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.

Documentation

GuideDescription
ArchitecturePipeline design, module responsibilities, transcript two-path event dispatcher
LayoutsLayout catalog and per-segment visual (gauge/sparkline/plot/text) reference
Metrics ReferencePer-metric data sources, parsing methods, cache strategies, and output examples
Theme & Palette256-color system specification, emphasis tiers, and color-annotated rendering examples
Activity BandThe above-the-prompt band and pane: what they draw, the binary ladder, the test kit
BenchmarksPerformance methodology and Criterion benchmark results
ChangelogRelease history and version notes

Contributing

See CONTRIBUTING.md for development setup, testing, and pull request guidelines.

Acknowledgements

cc-pulseline draws inspiration from these excellent projects:

  • claude-hud — A Claude Code plugin showing context usage, active tools, running agents, and todo progress
  • CCometixLine — Claude Code statusline tool written in Rust
  • cc-statusline — Informative statusline for Claude Code

License

MIT License. See LICENSE for details.

Source 5 files
hooks/register.tsx 1713 lines
1import { 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  })
1200
hooks/binary.ts 148 lines
1/**
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}
148
hooks/payload.ts 191 lines
1import 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}
191
hooks/throttle.ts 26 lines
1/**
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}
26
types/index.d.ts 237 lines
1/**
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