SLOPSHOPPER

statusline

Draws the statusline binary's segments next to the prompt and refreshes them on its own clock

newbandspinnerguardpromptprocess
★ 6v2.4.1MITupdated 2026-10-09ryanclark/statusline/crates/statusline/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · statusline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › statusline binary is too old for this plugin: update statusline (brew upgrade ryanclark/tap/statusline, or rerun in…

Draws

Prompt hint
statusline binary is too old for this plugin: update statusline (brew upgrade ryanclark/tap/statusl…
README

statusline

A fast, native statusline for Claude Code that shows context window usage, session/weekly usage limits and extra usage credits.

<img src="screenshots/hero.png" alt="statusline drawn by the Claude Code plugin" />

Install

brew install ryanclark/tap/statusline

macOS (Apple Silicon) and Linux (arm64/amd64) are supported. Without Homebrew, install a prebuilt binary from the latest GitHub release:

curl -fsSL https://raw.githubusercontent.com/ryanclark/statusline/main/install.sh | sh

The binary is installed to ~/.local/bin (override with INSTALL_DIR=...). Append -s -- v1.1.0 after sh to pin a version. Or build from source with just install (requires just).

Then wire it into Claude Code as a plugin (recommended) or as a native statusLine.

Claude Code plugin (recommended)

statusline install --plugin

The plugin draws the line under the prompt and refreshes it on its own clock, so countdowns tick between turns, and it adds the live activity segments. Needs Claude Code 2.1.287 or later.

Claude Code's background task pills, such as 1 shell, show in the first row of the line, and light up when Claude Code selects them.

When an enabled segment needs it, the plugin also fetches your usage with the session's own claude.ai login, about once a minute shared across chats on the same account. Authorization, account identification and fetching run in the background, so the line keeps drawing while they finish. The account lookup runs when authorizing, and the login is rechecked every 15 minutes so a still-valid old token cannot hold a switched account indefinitely. If identification fails, that chat keeps usage privately, refreshes it at most once every five minutes, and backs off lookup retries.

five_hour and seven_day prefer the shared account result so idle chats update too. Claude Code's per-chat values remain the fallback until a result arrives, or if it is more than five minutes old. Expired windows are omitted, and an older cached window never replaces a newer session window. These segments share the same request as extra_usage, fable_usage and credits; a layout with none of them makes no usage request. Rate-limit responses back off retries. No Chrome cookie or Keychain access is needed for the plugin's account request. Sessions on an API key or a third-party provider, and sessions where Claude Code refuses plugins network access, use per-chat limits and the cookie path below for extra usage and credits.

This creates ~/.statusline/settings.json if it is missing, adds the ryanclark marketplace and installs the plugin at user scope, pointed at the binary you ran. Pass --dry-run to see what would change, and --claude <PATH> when claude is not on your PATH. If you installed the plugin with statusline 2.0.0, upgrade and run it again so the marketplace checks out the plugin's new path.

Claude Code keeps an empty row for a statusLine that prints nothing, so an existing statusLine in ~/.claude/settings.json is saved to ~/.statusline/native-statusline.json and removed. One that runs statusline is removed without asking, anything else only after you confirm. ~/.claude/settings.json is backed up to ~/.statusline/backups/ before it changes. Pass --keep-native to keep it as a fallback for sessions where the plugin does not load. It stays silent while the plugin draws.

To install from inside a session instead:

/plugin install statusline --marketplace ryanclark/statusline

The options screen sets:

OptionDefaultDescription
binarystatuslinePath to the statusline binary, in full if it is not on PATH
placementbelowbelow replaces the hint line under the prompt, above uses the band over it
intervalMs1000How often countdowns and git state are re-rendered between turns
cacheTtl1hPrompt cache TTL assumed until a model switch reports the real one (1h or 5m)

Change them later with /plugin, or from a shell:

echo '{"binary": "/opt/homebrew/bin/statusline"}' | claude plugin configure statusline@ryanclark --values-stdin

statusline install --native uninstalls the plugin and restores the saved statusLine. Add --remove-marketplace to also remove the ryanclark marketplace.

Native statusLine

statusline install

Use this when the plugin cannot load: Claude Code older than 2.1.287, a third-party provider, or nonessential traffic disabled. It wires Claude Code's settings.json to call statusline and creates ~/.statusline/settings.json when it is missing. Pass --subagent to also wire the agent panel rows to statusline subagent. The line only refreshes when Claude Code redraws it, and the live activity segments stay empty.

Keychain access (API segments only)

The plugin fetches usage with the session's login, so this applies to the native statusLine, and to plugin sessions on an API key or a third-party provider, with CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC set, or whose organization's policy blocks plugins' network access. If you include extra_usage, fable_usage, or credits, statusline reads your Chrome session cookie to fetch usage data from claude.ai. On first run, macOS will prompt you to allow access to "Chrome Safe Storage" in Keychain. Select Always Allow so it doesn't prompt on every invocation. The organization is read from Claude Code's own ~/.claude.json, so it always matches the active account.

Examples

Regenerate these with just screenshots.

Live activity (plugin only)

What Claude is running, how long the turn has taken, plan progress and running agents, with the subagent panel below.

["context_percentage", "total_input_tokens", "output_tokens", "divider", "current_tool", "divider", "turn_elapsed", "divider", "todo_progress", "divider", "agents"]

<img src="screenshots/live-activity.png" alt="live activity" />

Prompt cache

How long the cache stays warm, why it last missed and how often it missed in the last 30 minutes. Miss causes and the window are fullest with the plugin.

["context_percentage", "divider", "model", "divider", "cache_warm", "divider", "cache_last_miss", "divider", "cache_misses", "divider", "session_cache_hit_ratio"]

<img src="screenshots/cache.png" alt="prompt cache" />

Under pressure

Context and rate limits near their ceilings, autocompact headroom, compaction history and the last API error. The last three need the plugin.

["context_percentage", "total_input_tokens", "divider", "five_hour", "seven_day", "divider", "autocompact_headroom", "divider", "compaction", "divider", "last_api_error"]

<img src="screenshots/pressure.png" alt="limits under pressure" />

Git, on two rows (native statusLine)

["context_percentage", "total_input_tokens", "output_tokens", "divider", "model", "divider", "cost", "newline", "cwd", "divider", {"type": "git_branch", "dirty": true}, "git_ahead_behind", "git_stash", "divider", "pr"]

<img src="screenshots/git.png" alt="git info on two rows" />

What it shows

By default:

  • Context window — percentage used, input/output token counts
  • 5-hour rate limit — current utilization with reset countdown when above threshold
  • 7-day rate limit — same as above
  • Extra usage — spend against monthly limit (fetched by the plugin, or with Chrome cookie auth)

Customising segments

Add a segments array to ~/.statusline/settings.json to control what's shown and in what order:

{
  "five_hour_reset_threshold": 70,
  "seven_day_reset_threshold": 100,
  "segments": [
    "context_percentage",
    "input_tokens",
    "output_tokens",
    "divider",
    "cwd",
    {"type": "git_branch", "dirty": true},
    "model",
    "divider",
    "five_hour",
    "seven_day",
    "divider",
    "extra_usage"
  ]
}

If segments is not set, the default layout is used: context_percentage, total_input_tokens, output_tokens, divider, five_hour, seven_day, divider, extra_usage.

Interactive editor

Instead of editing the JSON by hand, run:

statusline configure

This opens an interactive editor with a live preview to add, remove, reorder and toggle segments and edit their options, writing the result to ~/.statusline/settings.json (existing keys are preserved). Key hints: ↑↓ move, ⇧↑/⇧↓ reorder, space toggle, → options, a add, s save, q quit.

<img src="screenshots/configure.png" alt="statusline configure" />

<img src="screenshots/configure-options.png" alt="statusline configure with the cache_warm options open" />

Press Tab to switch between the status line and the subagent layout. The subagent tab shows one preview row per sample task, and offers i to wire up subagentStatusLine when Claude Code does not have it yet.

Directory paths

Press g in statusline configure to set a directory width and one or more trim prefixes. The preview uses your current directory and updates as you type. Use ↓ to reach the empty prefix row to add another, Ctrl+U to clear a field, Esc to return, then s to save. A blank or zero width means unlimited; clearing a prefix removes it.

The same options can be set in ~/.statusline/settings.json:

{
  "path_format": {
    "trim_prefixes": ["~/go/src/remote/ryanclark", "~/code"],
    "max_width": 40
  }
}

These rules apply to cwd and project_dir, including subagent paths. Prefixes accept absolute paths or ~/, match whole directory components, and the longest match wins. For example, ~/go/src/remote/ryanclark/statusline becomes …/statusline. If the result still exceeds max_width terminal columns, statusline collapses directories to …, keeping as much of the trailing path as fits. The final directory is shortened in the middle only when necessary. Both settings are optional; without them, paths keep the usual ~ abbreviation.

Available segments

Some segments need a recent Claude Code, and stay empty on older versions: spend_limit, cache_warm, session_cache_hit_ratio, and cache_misses need 2.1.251 or later, cache_last_miss needs 2.1.260, pr needs 2.1.234 for GitLab merge requests, and repo needs 2.1.260 for GitLab projects nested in subgroups. In the agent panel, the per-task model needs 2.1.205 and effort needs 2.1.214.

Context window
SegmentDescription
context_percentageContext window used % (colored)
context_remainingRemaining context % (colored)
context_window_sizeTotal context size (e.g. 200k)
total_input_tokensCurrent context input tokens (input + cache creation + cache read) with ↑ icon
input_tokensCumulative input tokens across the session with ↑ icon
output_tokensTotal output tokens with ↓ icon
cache_read_tokensCache read tokens with ↻ icon
cache_hit_ratioCache read as % of total input
cache_warmPrompt cache state with ♨ icon: warm with time until it goes cold, or cold
session_cache_hit_ratioCache reads as % of all input tokens this session
cache_missesPrompt cache misses in the last 30 minutes (2 misses in 30m), hidden at zero. Without the plugin, the session total, shown even at zero
cache_last_missCause of the last prompt cache miss (e.g. tools changed (+2 −1), expired after 5m idle) and how long ago, hidden after 30 minutes
exceeds200kWarning indicator when context exceeds 200k tokens
Rate limits
SegmentDescription
five_hour5-hour rate limit % with optional reset countdown
seven_day7-day rate limit % with optional reset countdown
spend_limitSpend limit % with reset countdown (only present behind a Claude apps gateway)
fable_usageFable weekly rate limit % with reset countdown (calls the API)
extra_usageExtra usage $used/$limit (calls the API)
Cost & performance
SegmentDescription
costTotal session cost in USD
cost_rateCost per minute ($/m)
durationTotal session duration
api_durationTotal API call time
tokens_per_secondOutput tokens per second of API time
lines_addedLines added with + icon
lines_removedLines removed with - icon
Git
SegmentDescription
git_branchCurrent git branch name
git_ahead_behindCommits ahead/behind upstream (e.g. ↑3 ↓1)
git_stashStash count with ⚑ icon
prOpen pull request number (! for GitLab merge requests) with ⎇ icon, colored by review state, clickable link to the PR (link needs colors)
repoRepository owner/name from the origin remote, clickable link to its web page (link needs colors)

Git segments read a shared cache, refreshed in the background at most once every five seconds per directory and requested set of fields. The first render can leave them empty until the refresh finishes; later renders keep the last result while refreshing. Only enabled segments trigger Git work, and the working tree is scanned only when git_branch has a nonempty dirty indicator. A cold or previously timed-out scan publishes a quick branch lookup first. Refreshes have a ten-second budget and cannot overlap for the same cache. Timeouts back off from 30 seconds to five minutes, while retaining the last result; a successful refresh restores the normal interval. Scans respect Git's status.showUntrackedFiles setting.

Environment
SegmentDescription
cwdCurrent working directory (supports path_format)
project_dirProject directory (supports path_format)
modelModel display name
model_idFull model ID
versionClaude Code version
session_idSession ID
session_nameSession name (--name or /rename, else the generated title)
vim_modeVim mode (NORMAL, INSERT, etc.)
agent_nameActive agent name
effortReasoning effort level (low to max), colored by level
thinkingthinking when extended thinking is enabled
fast_modefast when fast mode is on
worktreeWorktree name (a worktree session, or any linked git worktree)
accountCurrent Claude account nickname (from ~/.statusline/accounts.json, colored per entry)
Live activity

These need the plugin. With the plain statusLine command they render nothing.

SegmentDescription
current_toolTool call in flight with ⚙ icon, its command or path, and how long it has run (e.g. ⚙ Bash cargo test 12s), +2 when more run in parallel
turn_elapsedTime the running turn has taken with ⏱ icon (⏱ 1m42s), or the last turn's length dimmed between turns (last 2m10s)
permission_pendingTool waiting on approval with ⏸ icon and how long, kept up while it runs (⏸ Bash waiting or running 45s)
last_api_errorWhy the last turn failed with ⚠ icon and how long ago (⚠ overloaded 2m ago, rate limited, hit max tokens, interrupted)
todo_progressTodo items done out of total with ☑ icon and the active item (☑ 3/7 · Running tests)
agentsBackground agents with ⁂ icon (⁂ 3 running · 1 idle)
background_tasksBackground shells, monitors and workflows still running with ⧗ icon: the task and its description when there is one (⧗ shell · npm run dev), else a count by kind (⧗ 2 shells · 1 monitor). Subagents are counted by agents
compactionWith ⟳ icon, ⟳ compacting 18s while one runs, else how many, when, and the tokens before and after (compacted ×2 · 14m ago · 182.0k→21.0k)
autocompact_headroomTokens left before autocompact triggers with ↧ icon (compact in 38.0k), or autocompact off
Layout
SegmentDescription
dividerSeparator character (default •)
newlineLine break: segments after it render on the next row

Claude Code shows each line of output as its own status row, so newline splits the status line into multiple rows. Dividers next to a line break are dropped, and a break whose row would be empty is skipped.

Advanced segment options

Each segment can be a plain string or an object with options:

[
  "context_percentage",
  {"type": "input_tokens", "icon": false},
  {"type": "model", "style": "dim"},
  {"type": "git_branch", "dirty": true, "dirty_color": "yellow"},
  {"type": "cost", "style": "bold"},
  {"type": "divider", "label": "|"}
]
OptionTypeDefaultDescription
colorsbooltrueEnable/disable ANSI colors
iconbooltrue (false for task_status)Show/hide the segment's icon
icon_colorstring—Custom icon color
labelstring—Custom label replacing the default icon
stylestring—Text style: bold, dim, italic, underline

Colors can be specified as named colors (red, cyan, yellow, green, blue, purple, orange, white, gray), hex (#FF5050, #F00), or RGB (rgb(255, 80, 80)).

git_branch options
OptionTypeDefaultDescription
dirtybool or stringfalseShow dirty indicator. true for default *, or a custom string
dirty_colorstringredColor of the dirty indicator
cache_warm options
OptionTypeDefaultDescription
warm_colorstringgreenColor of the ♨ icon and the warm state
cold_colorstringyellowColor of the ♨ icon and the cold state
Countdown options

five_hour, seven_day, spend_limit, fable_usage, and cache_warm count down to a reset or expiry. Each can also print the clock time it counts down to, in your local time zone, as 2h 10m (16:00):

OptionTypeDefaultDescription
show_countdownbooltrueShow the time left
show_timeboolfalse (true for cache_warm)Show the clock time after the countdown, or alone when the countdown is off
time_formatstring24h24h for 16:00, 12h for 4:00pm
Cache miss options
OptionTypeDefaultDescription
withinstring or null"30m"How far back to look, e.g. "90s", "2h" or "1h30m", or "session"/null for no limit. A value that does not parse counts as "30m". Without the plugin cache_misses shows the session total
detailsbooltruecache_last_miss only: add the tool count and system prompt size changes, as in tools changed (+2 −1)
[{"type": "cache_misses", "within": "2h"}, {"type": "cache_last_miss", "within": "session", "details": false}]
account options
OptionTypeDefaultDescription
capitalizebooltrueCapitalise the first letter of the nickname

Nerd Font icons

If you use a Nerd Font, enable richer icons by setting nerd_font in ~/.statusline/settings.json:

{
  "nerd_font": true
}

When enabled, segments use Nerd Font glyphs instead of the default Unicode symbols. To install one:

brew install font-fira-code-nerd-font

Custom divider

Set the divider field in settings to change the default divider character:

{
  "divider": "|"
}

Per-account overrides

If you keep multiple Claude Code accounts, you can point each one at its own browser profile and even its own layout. Create ~/.statusline/accounts.json:

{
  "accounts": [
    {
      "nickname": "work",
      "email": "ryan@work.com",
      "organization_uuid": "work-org-uuid",
      "color": "cyan",
      "browser": "chrome",
      "profile": "Profile 2",
      "segments": ["context_percentage", "divider", "extra_usage"]
    },
    {
      "nickname": "personal",
      "email": "ryan@home.com",
      "organization_uuid": "personal-org-uuid"
    }
  ]
}

When the active Claude Code account matches an entry, statusline uses that entry's browser and profile to read cookies. When segments is present on the entry, it replaces the global layout for that account. Missing fields fall back to global settings.

To list the browser profiles available on your machine:

statusline profiles
statusline profiles --browser chrome

Disabling the update check

Update checks run in the background, shared across chats. A successful check is cached for 24 hours; a failed attempt waits an hour before trying again. In plugin mode the check and notice are limited to chats with no user messages. The notice disappears as soon as the first message is submitted, and resumed chats with messages never show it.

Set skip_update_check in ~/.statusline/settings.json to suppress the once-a-day update check and the update banner:

{
  "skip_update_check": true
}

Letting Claude see its own session

Set capture_snapshots in ~/.statusline/settings.json (or toggle it under g in statusline configure) to save the JSON Claude Code pipes in on every render to ~/.statusline/sessions/<session_id>.json. Snapshots untouched for 7 days are deleted when a new session starts. With the plugin, snapshots come from the native statusLine when it runs, since its input is complete.

{
  "capture_snapshots": true
}

When Claude runs statusline itself from inside Claude Code (CLAUDECODE and CLAUDE_CODE_SESSION_ID set, nothing on stdin), it prints a JSON report for that session instead of a status line: model, context window, cost, the 5-hour and 7-day rate limits with reset countdowns, plus the account's claude.ai limits (including Fable), extra usage and credits from the usage cache.

Data sources

Most segments read from the JSON that Claude Code pipes via stdin — no external calls needed. The exceptions:

  • extra_usage, fable_usage, credits — the plugin fetches them with the session's login. The native statusLine calls the claude.ai API with your Chrome session cookie
  • git_branch, git_ahead_behind, git_stash — run git commands in the project directory

If you don't include extra_usage, fable_usage, or credits in your segments, the native statusLine skips the API call and Chrome cookie auth entirely. The plugin fetches usage either way.

Subagent status line

Claude Code can also hand the agent panel's rows to a command through subagentStatusLine in ~/.claude/settings.json. statusline install --subagent wires it up; by hand, the entry is:

"subagentStatusLine": {"type": "command", "command": "statusline subagent"}

statusline subagent reads the task list on stdin and prints one row per task, built from the subagent_segments list in ~/.statusline/settings.json. The default layout is task_name, task_status, divider, model, task_tokens, divider, task_description, divider, task_label. Ad-hoc agents carry no name, so that column simply drops out for them. Every task carries its own model, effort, cwd, and token counts, so model, model_id, effort, cwd, context_percentage, context_window_size, and total_input_tokens work per task next to the task segments below. A task whose row renders empty keeps Claude Code's default row.

Rows are laid out as a grid: each segment is a column as wide as its widest value across the tasks, and a divider sits after the padded cell before it, so the columns and dividers line up down the panel. Set "subagent_grid": false in ~/.statusline/settings.json, or toggle subagent_grid under g global in statusline configure, for one free-form line per task instead.

Subagent
SegmentDescription
task_nameSubagent name with ⚙ icon

| task_status | Task status (`runnin

Source 11 files
hooks/register.tsx 889 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  AgentInfo,
4  EngineInterface,
5  PluginOptions,
6  PromptSubmitInput,
7  PromptSubmitResult,
8  Register,
9} from 'claude-code'
10
11import type { Autocompact, CacheTtl, Compaction, ComposeShape, Rendered } from '../types'
12import {
13  agentStatuses,
14  AGENTS_REWRITE_MS,
15  BACKGROUND_TOOLS,
16  backgroundSnapshot,
17  detailOf,
18  EMPTY_LIVE,
19  endedTasks,
20  foldBackground,
21  foldTodos,
22  NO_COMPACTION,
23  RUNNING,
24  TODO_TOOLS,
25  turnError,
26  withoutBackground,
27  withoutPermission,
28} from './activity'
29import { isSpanRows, MISSING, NO_NEEDS, NOT_FOUND, PLUGIN_DATA_FLAG, REQUIRED_FLAGS, tooOldMessage, UNKNOWN_FLAG, WIDTH_FLAG } from './binary'
30import type { Needs, Verdict } from './binary'
31import { EMPTY_TRACKER, observeStep, TTL_MS } from './cache'
32import { blank, draw, hintWidth } from './draw'
33import { autocompactOf, inputJson } from './input'
34import {
35  answered,
36  backedOff,
37  claimed,
38  fetchDue,
39  EMPTY_USAGE,
40  FETCH_EVERY_MS,
41  newUsageMemo,
42  parseUsageFile,
43  PROFILE_URL,
44  PRIVATE_FETCH_EVERY_MS,
45  REAUTHORIZE_EVERY_MS,
46  REFUSED,
47  shown,
48  UNKNOWN_HANDLE,
49  USAGE_HEADERS,
50  USAGE_URL,
51  usageAccount,
52  usagePath,
53  waiting,
54} from './usage'
55import type { UsageFile, UsageInput, UsageMemo } from './usage'
56import { cut, dataPath, obj, plain, str } from './util'
57import type { Json } from './util'
58
59const rendered = atom({ plugin: 'statusline', key: 'rendered' } as const, null)
60const tracker = atom({ plugin: 'statusline', key: 'tracker' } as const, EMPTY_TRACKER)
61const live = atom({ plugin: 'statusline', key: 'live' } as const, EMPTY_LIVE)
62const mode = atom({ plugin: 'statusline', key: 'mode' } as const, null)
63
64type State = {
65  binary: string
66  placement: 'below' | 'above'
67  defaultTtl: CacheTtl
68  intervalMs: number
69  heartbeatMs: number
70  inFlight: boolean
71  again: boolean
72  last: string
73  breakdownAt: number
74  autocompact: Autocompact | null
75  // The last composed system prompt. The compose event names no loop, so only a main-loop request directly after it
76  // claims it.
77  pendingCompose: ComposeShape | null
78  // A subagent request may have taken the main loop's compose or left its own.
79  composeAmbiguous: boolean
80  handshake: Promise<Verdict> | null
81  // Whether the binary can drop whole segments to fit, and the width the line last had. A render hook may not run the
82  // binary, so the next refresh passes the width on.
83  fits: boolean
84  reportsNeeds: boolean
85  needs: Needs
86  userTurns: number | null
87  fitWidth: number | undefined
88  tooOld: string | null
89  usage: UsageMemo
90  // The agents map last written for the binary and when, so an unchanged map is not written every tick.
91  agentsKey: string
92  agentsAt: number
93}
94
95// A session that never had a waiting agent writes no file, which the binary reads the same as an empty map.
96const NO_AGENTS = '{}'
97
98// Session ids become file names, so only the plain tokens the binary itself accepts are written.
99const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
100
101// Outlives three missed ticks. The cap bounds how long a plugin that stops silently leaves the session with no line.
102const HEARTBEAT_MAX_MS = 30_000
103
104function fresh(options: PluginOptions): State {
105  const configured = Number(options.intervalMs)
106  const intervalMs = Number.isFinite(configured) && configured > 0 ? Math.max(250, Math.round(configured)) : 1000
107  return {
108    binary: String(options.binary || 'statusline'),
109    placement: options.placement === 'above' ? 'above' : 'below',
110    defaultTtl: options.cacheTtl === '5m' ? '5m' : '1h',
111    intervalMs,
112    heartbeatMs: Math.min(HEARTBEAT_MAX_MS, Math.max(10_000, 3 * intervalMs + 2000)),
113    inFlight: false,
114    again: false,
115    last: '',
116    breakdownAt: -Infinity,
117    autocompact: null,
118    pendingCompose: null,
119    composeAmbiguous: false,
120    handshake: null,
121    fits: false,
122    reportsNeeds: false,
123    needs: { usage: true, autocompact: true }, // Older binaries cannot report their layout's requirements.
124    userTurns: null,
125    fitWidth: undefined,
126    tooOld: null,
127    usage: newUsageMemo(),
128    agentsKey: NO_AGENTS,
129    agentsAt: -Infinity,
130  }
131}
132
133let state = fresh({})
134
135const BREAKDOWN_EVERY_MS = 10_000
136
137async function buildInput($: EngineInterface): Promise<string> {
138  const now = await $.clock.now()
139  const due = state.needs.autocompact && now - state.breakdownAt >= BREAKDOWN_EVERY_MS
140  if (due) {
141    state.breakdownAt = now
142  }
143  const [id, cwd, root, model, version, usage, t, l, agents, turns] = await Promise.all([
144    $.session.id(),
145    $.session.cwd(),
146    $.session.root(),
147    $.session.model(),
148    $.session.version(),
149    due ? $.session.usage({ breakdown: 'summary' }) : $.session.usage(),
150    read($, tracker),
151    read($, live),
152    $.agent.list(),
153    state.userTurns === null ? Promise.resolve().then(() => $.session.turns()).catch(() => 1) : state.userTurns,
154  ])
155  // A prompt submitted while the first reading was pending must never be undone by that reading.
156  state.userTurns = Math.max(state.userTurns ?? 0, turns)
157  if (due) {
158    state.autocompact = autocompactOf(usage.context.breakdown)
159  }
160  await writeAgents($, id, now, agents)
161  return inputJson({
162    now,
163    id,
164    cwd,
165    root,
166    model,
167    version: version.version,
168    usage,
169    tracker: t,
170    live: l,
171    agents,
172    defaultTtl: state.defaultTtl,
173    autocompact: state.autocompact,
174    accountUsage: state.reportsNeeds ? accountUsage(state.usage) : pollUsage($),
175    showUpdate: state.userTurns === 0,
176  })
177}
178
179async function tooOld($: EngineInterface, binary: string): Promise<string> {
180  const [stat, out] = await Promise.all([
181    $.fs.stat(binary, { resolve: true }).catch(() => undefined),
182    $.process.run([binary, '--version'], { timeoutMs: 5000 }).catch(() => undefined),
183  ])
184  return tooOldMessage(out?.exitCode === 0 ? out.stdout : null, stat?.realPath ?? '')
185}
186
187// Checks `--help` for the flags rather than the version, since a source build can carry a release's number without
188// that release's flags. Any other exit is left for the refresh to report.
189async function probe($: EngineInterface, binary: string): Promise<Verdict> {
190  let out
191  try {
192    out = await $.process.run([binary, '--help'], { timeoutMs: 5000 })
193  } catch (err) {
194    return MISSING.test(String(err))
195      ? { error: NOT_FOUND, final: true }
196      : { error: `statusline: ${String(err)}`, final: false }
197  }
198  const help = out.stdout
199  state.fits = WIDTH_FLAG.test(help)
200  state.reportsNeeds = PLUGIN_DATA_FLAG.test(help)
201  if (state.reportsNeeds) {
202    state.needs = { ...NO_NEEDS }
203  }
204  if (out.exitCode === 0 && !REQUIRED_FLAGS.every(flag => flag.test(help))) {
205    return { error: await tooOld($, binary), final: true }
206  }
207  return null
208}
209
210// The file the binary's own heartbeat writes (session.rs), holding the expiry in epoch ms. Best effort, since every
211// refresh writes it again through the binary.
212async function writeHeartbeat($: EngineInterface, sessionId: string, expiresMs: number) {
213  if (!SESSION_ID.test(sessionId)) {
214    return
215  }
216  try {
217    const home = await $.env.get('HOME')
218    if (home) {
219      await $.fs.write(dataPath(home, `sessions/${sessionId}.plugin`), String(Math.floor(expiresMs)))
220    }
221  } catch {
222    // A missed write leaves the native line drawing until the next refresh, as if the plugin were not installed.
223  }
224}
225
226async function writeAgents($: EngineInterface, sessionId: string, now: number, agents: readonly AgentInfo[]) {
227  const statuses = agentStatuses(agents)
228  const key = JSON.stringify(statuses)
229  const unchanged = key === state.agentsKey && (key === NO_AGENTS || now - state.agentsAt < AGENTS_REWRITE_MS)
230  if (unchanged || !SESSION_ID.test(sessionId)) {
231    return
232  }
233  try {
234    const home = await $.env.get('HOME')
235    if (home) {
236      const file = { written_at_ms: now, agents: statuses }
237      await $.fs.write(dataPath(home, `sessions/${sessionId}.agents.json`), JSON.stringify(file))
238      state.agentsKey = key
239      state.agentsAt = now
240    }
241  } catch {
242    // Left unmarked so the next refresh tries again. Until then the panel shows Claude Code's own status.
243  }
244}
245
246// Null when nothing should be fetched yet, as a file that does not parse and was just written is another chat's
247// write still under way.
248async function readUsage($: EngineInterface, path: string, now: number): Promise<UsageFile | null> {
249  let text: string
250  try {
251    text = await $.fs.read(path)
252  } catch {
253    return EMPTY_USAGE
254  }
255  const file = parseUsageFile(text)
256  if (file) {
257    return file
258  }
259  const stat = await $.fs.stat(path).catch(() => undefined)
260  return stat && now - stat.mtimeMs < FETCH_EVERY_MS ? null : EMPTY_USAGE
261}
262
263async function writeUsage($: EngineInterface, path: string, file: UsageFile) {
264  try {
265    await $.fs.write(path, JSON.stringify(file))
266  } catch {
267    // The next chat to find the file due fetches again.
268  }
269}
270
271async function authorizeUsage($: EngineInterface, memo: UsageMemo): Promise<string | null> {
272  // A reauthorized handle may belong to another account. Nothing from the old login survives until verified.
273  memo.account = null
274  memo.last = { fetched_at_ms: null, body: null }
275  memo.profileNextAt = 0
276  let auth
277  try {
278    auth = await $.session.authorize()
279  } catch {
280    // A failed call must allow the cookie fallback, without retrying authorization on every render.
281    memo.handle = null
282    memo.authFailed = true
283    memo.nextAt = await $.clock.now() + FETCH_EVERY_MS
284    return null
285  }
286  memo.authFailed = false
287  memo.reauthorizeAt = await $.clock.now() + REAUTHORIZE_EVERY_MS
288  // An API key or a third-party provider has no claude.ai usage, and the binary keeps its cookie path for those.
289  memo.handle = auth?.kind === 'bearer' ? auth.handle : null
290  memo.off = memo.handle === null
291  return memo.handle
292}
293
294async function identifyUsage($: EngineInterface, memo: UsageMemo) {
295  if (memo.account !== null || memo.handle === null || !state.needs.usage) {
296    return
297  }
298  const now = await $.clock.now()
299  if (!state.needs.usage || state.usage !== memo || waiting(memo.profileNextAt, now)) {
300    return
301  }
302  memo.profileNextAt = now + FETCH_EVERY_MS
303  const backoff = async (retryAfter?: string) => {
304    const next = backedOff({ ...EMPTY_USAGE, backoff_ms: memo.profileBackoffMs }, await $.clock.now(), retryAfter)
305    memo.profileNextAt = next.backoff_until_ms
306    memo.profileBackoffMs = next.backoff_ms
307  }
308  try {
309    const res = await $.http.fetch(PROFILE_URL, { auth: memo.handle, headers: USAGE_HEADERS })
310    if (res.ok) {
311      memo.account = usageAccount(res.text)
312      if (memo.account) {
313        memo.profileBackoffMs = 0
314      } else {
315        await backoff()
316      }
317    } else if (res.status === 401) {
318      memo.handle = null
319      memo.nextAt = memo.profileNextAt
320    } else {
321      await backoff(res.headers['retry-after'])
322    }
323  } catch (err) {
324    if (REFUSED.test(String(err))) {
325      memo.off = true
326    } else if (UNKNOWN_HANDLE.test(String(err))) {
327      memo.handle = null
328      memo.nextAt = memo.profileNextAt
329    } else {
330      await backoff()
331    }
332  }
333}
334
335async function fetchUsage($: EngineInterface, memo: UsageMemo, path: string | null, file: UsageFile, retryAuth: boolean): Promise<boolean> {
336  let handle = memo.handle
337  if (handle === null || !state.needs.usage) {
338    return false
339  }
340  const start = await $.clock.now()
341  if (!state.needs.usage) {
342    return false
343  }
344  memo.nextAt = start + (path ? FETCH_EVERY_MS : PRIVATE_FETCH_EVERY_MS)
345  if (path) {
346    await writeUsage($, path, claimed(file, start))
347  }
348  if (!state.needs.usage) {
349    return false
350  }
351  let res
352  try {
353    res = await $.http.fetch(USAGE_URL, { auth: handle, headers: USAGE_HEADERS })
354    // A handle keeps the token it was minted with, so one minted before the session refreshed its login fails until
355    // it is minted again. Once per fetch keeps that to a handle a minute.
356    if (res.status === 401 && retryAuth) {
357      const account = memo.account
358      handle = await authorizeUsage($, memo)
359      if (handle === null || !state.needs.usage) {
360        return false
361      }
362      await identifyUsage($, memo)
363      if (memo.handle === null || memo.off || !state.needs.usage) {
364        return false
365      }
366      if (account !== memo.account) {
367        // The old account keeps its claim, but the new login reads and writes only its own cache.
368        memo.nextAt = 0
369        memo.backoffMs = 0
370        await refreshUsage($, memo, false)
371        return false
372      }
373      if (account === null) {
374        file = EMPTY_USAGE
375      }
376      res = await $.http.fetch(USAGE_URL, { auth: handle, headers: USAGE_HEADERS })
377    }
378  } catch (err) {
379    const message = String(err)
380    if (REFUSED.test(message)) {
381      memo.off = true
382    } else if (UNKNOWN_HANDLE.test(message)) {
383      memo.handle = null
384    }
385    return false
386  }
387  const next = answered(file, res, await $.clock.now())
388  if (next === null) {
389    return false
390  }
391  memo.nextAt = Math.max(memo.nextAt, next.backoff_until_ms)
392  memo.backoffMs = next.backoff_ms
393  memo.last = shown(next)
394  if (path) {
395    await writeUsage($, path, next)
396  }
397  return res.ok
398}
399
400// Authorization, cache reads and HTTP all run outside the render. The empty value reserves ownership while login
401// is pending, so the binary cannot start its cookie fallback at the same time.
402function accountUsage(memo: UsageMemo): UsageInput | null {
403  return memo.off || memo.authFailed ? null : memo.last
404}
405
406function pollUsage($: EngineInterface): UsageInput | null {
407  const memo = state.usage
408  if (memo.off) {
409    return null
410  }
411  if (state.needs.usage && !memo.polling) {
412    const owned = accountUsage(memo) !== null
413    memo.polling = true
414    void refreshUsage($, memo)
415      .catch(() => {})
416      .finally(() => {
417        memo.polling = false
418        if (owned !== (accountUsage(memo) !== null) && state.usage === memo) {
419          void refresh($)
420        }
421      })
422  }
423  return accountUsage(memo)
424}
425
426async function refreshUsage($: EngineInterface, memo: UsageMemo, retryAuth = true) {
427  const started = await $.clock.now()
428  if (!state.needs.usage || state.usage !== memo) {
429    return
430  }
431  const reauthorize = memo.handle !== null && !waiting(memo.reauthorizeAt, started) &&
432    (memo.account !== null || !waiting(memo.profileNextAt, started))
433  const previousAccount = memo.account
434  if (memo.handle === null && waiting(memo.nextAt, started)) {
435    return
436  }
437  if ((memo.handle === null || reauthorize) && (await authorizeUsage($, memo)) === null) {
438    return
439  }
440  if (!state.needs.usage || state.usage !== memo) {
441    return
442  }
443  await identifyUsage($, memo)
444  if (memo.handle === null || memo.off || !state.needs.usage || state.usage !== memo) {
445    return
446  }
447  if (reauthorize && memo.account !== null && memo.account !== previousAccount) {
448    memo.nextAt = 0
449    memo.backoffMs = 0
450  }
451  const home = memo.account === null ? null : await $.env.get('HOME')
452  const path = home && memo.account ? usagePath(home, memo.account) : null
453  const now = await $.clock.now()
454  // A missing profile or HOME leaves this chat using only its own result, with the same retry limits.
455  const file = path ? await readUsage($, path, now) : {
456    ...EMPTY_USAGE, ...memo.last, backoff_until_ms: memo.nextAt, backoff_ms: memo.backoffMs,
457  }
458  if (file) {
459    if ((file.fetched_at_ms ?? -Infinity) >= (memo.last.fetched_at_ms ?? -Infinity) &&
460        (file.fetched_at_ms === null || file.fetched_at_ms - now <= FETCH_EVERY_MS)) {
461      const changed = JSON.stringify(memo.last) !== JSON.stringify(shown(file))
462      memo.last = shown(file)
463      if (changed) {
464        void refresh($)
465      }
466    }
467    if (!waiting(memo.nextAt, now) && fetchDue(file, now) && await fetchUsage($, memo, path, file, retryAuth)) {
468      void refresh($)
469    }
470  }
471}
472
473async function run($: EngineInterface): Promise<Rendered> {
474  state.handshake ??= probe($, state.binary)
475  const verdict = await state.handshake
476  if (verdict) {
477    if (!verdict.final) {
478      state.handshake = null
479    }
480    return { rows: [], error: verdict.error }
481  }
482  const { binary } = state
483  const stdin = await buildInput($)
484  let out
485  try {
486    const argv = [binary, '--format', 'spans', '--heartbeat-ms', String(state.heartbeatMs)]
487    if (state.reportsNeeds) {
488      argv.push('--plugin-data')
489    }
490    if (state.fits && state.fitWidth !== undefined) {
491      argv.push('--width', String(state.fitWidth))
492    }
493    out = await $.process.run(argv, { stdin, timeoutMs: 5000 })
494  } catch (err) {
495    return { rows: [], error: MISSING.test(String(err)) ? NOT_FOUND : `statusline: ${String(err)}` }
496  }
497  const stderr = plain(out.stderr)
498  if (out.exitCode === 2 && state.reportsNeeds && /unexpected argument '--plugin-data'/.test(stderr)) {
499    // A running chat can outlive a binary downgrade. Optional capabilities may be dropped without losing the line.
500    state.reportsNeeds = false
501    state.needs = { usage: true, autocompact: true }
502    return run($)
503  }
504  if (out.exitCode === 2 && state.fits && /unexpected argument '--width'/.test(stderr)) {
505    state.fits = false
506    return run($)
507  }
508  if (out.exitCode === 2 && UNKNOWN_FLAG.test(stderr)) {
509    state.tooOld ??= await tooOld($, binary)
510    return { rows: [], error: state.tooOld }
511  }
512  if (out.exitCode !== 0) {
513    return { rows: [], error: `statusline: ${binary} exited ${out.exitCode}: ${stderr}` }
514  }
515  let rows: unknown = null
516  try {
517    rows = JSON.parse(out.stdout || 'null')
518  } catch {
519    // Plain text means an ANSI-only build or another binary, which a JSON parse error would not say.
520  }
521  let updateNotice: string | undefined
522  if (state.reportsNeeds) {
523    const envelope = obj(rows)
524    const needs = obj(envelope?.needs)
525    if (!needs || typeof needs.usage !== 'boolean' || typeof needs.autocompact !== 'boolean') {
526      return { rows: [], error: 'statusline: invalid plugin data from binary' }
527    }
528    const nextNeeds = { usage: needs.usage, autocompact: needs.autocompact }
529    if (nextNeeds.autocompact && !state.needs.autocompact) {
530      state.again = true
531    }
532    state.needs = nextNeeds
533    // The binary has just resolved settings and account overrides. Checking after its reply also prevents an old
534    // layout from starting a due request on the very tick the user removes the usage segment.
535    pollUsage($)
536    updateNotice = typeof envelope?.update === 'string' && state.userTurns === 0 ? envelope.update : undefined
537    rows = envelope?.rows
538  }
539  return isSpanRows(rows)
540    ? { rows, ...(updateNotice ? { update: updateNotice } : {}) }
541    : { rows: [], error: `statusline: ${binary} did not print span rows, it needs --format spans` }
542}
543
544async function refresh($: EngineInterface) {
545  if (state.inFlight) {
546    state.again = true
547    return
548  }
549  state.inFlight = true
550  state.again = false
551  try {
552    let next: Rendered
553    try {
554      next = await run($)
555    } catch (err) {
556      next = { rows: [], error: `statusline: ${String(err)}` }
557    }
558    const key = JSON.stringify(next)
559    if (key !== state.last) {
560      await update($, rendered, () => next)
561      state.last = key
562    }
563  } catch {
564    // Left unmarked so the next refresh stores the line again.
565  } finally {
566    state.inFlight = false
567    if (state.again) {
568      void refresh($)
569    }
570  }
571}
572
573async function drawLine(
574  $: EngineInterface,
575  e: Parameters<EngineInterface['ui']['resolve']>[0],
576  working: boolean,
577  hint?: unknown,
578  width?: number,
579) {
580  const r = await read($, rendered)
581  const visible = r && state.userTurns !== 0 ? { ...r, update: undefined } : r
582  return visible && !blank(visible) ? draw($.ui.resolve(e), visible, working, hint, width) : null
583}
584
585async function noteTodos($: EngineInterface, tool: string, args: Json, result: Json, main: boolean) {
586  if (!TODO_TOOLS.has(tool)) {
587    return
588  }
589  await update($, live, l => foldTodos(l, tool, args, result, main) ?? l)
590  void refresh($)
591}
592
593async function noteBackground($: EngineInterface, tool: string, args: Json, result: Json) {
594  if (!BACKGROUND_TOOLS.has(tool)) {
595    return
596  }
597  await update($, live, l => foldBackground(l, tool, args, result) ?? l)
598  void refresh($)
599}
600
601// A task's end reaches Claude as a prompt, delivered into the running turn or starting one once the session is idle.
602async function noteTaskEnd(
603  $: EngineInterface,
604  e: PromptSubmitInput,
605  next: (e: PromptSubmitInput) => Promise<PromptSubmitResult>,
606): Promise<PromptSubmitResult> {
607  if (e.origin.kind !== 'task-notification') {
608    state.userTurns = Math.max(1, state.userTurns ?? 0)
609    await update($, rendered, r => r?.update ? { ...r, update: undefined } : r)
610    void refresh($)
611  }
612  const ended = e.origin.kind === 'task-notification' ? endedTasks(e.text) : []
613  if (ended.length > 0) {
614    await update($, live, l => withoutBackground(l, ended) ?? l)
615    void refresh($)
616  }
617  return next(e)
618}
619
620// Stop and SubagentStop both list the whole session's work in flight, which settles the tasks whose end went unseen.
621async function settleBackground<E extends { background_tasks?: unknown }, R>(
622  $: EngineInterface,
623  e: E,
624  next: (e: E) => Promise<R>,
625): Promise<R> {
626  const background = backgroundSnapshot(e.background_tasks)
627  if (background) {
628    await update($, live, l => ({ ...l, background }))
629    void refresh($)
630  }
631  return next(e)
632}
633
634async function clearPermission($: EngineInterface, tool: string, agent: string | undefined) {
635  const who = agent ?? null
636  if (!(await read($, live)).permissions.some(p => p.tool === tool && p.agent === who)) {
637    return
638  }
639  await update($, live, l => ({ ...l, permissions: withoutPermission(l.permissions, tool, who) }))
640  void refresh($)
641}
642
643// No event marks the user approving a call, so a wait ends when its call does.
644async function settlePermission<E extends { tool_name: string; agent_id?: string }, R>(
645  $: EngineInterface,
646  e: E,
647  next: (e: E) => Promise<R>,
648): Promise<R> {
649  await clearPermission($, e.tool_name, e.agent_id)
650  return next(e)
651}
652
653// No event reports a shift+tab, so the mode is as of the last hook that carried it.
654async function noteMode<E extends { permission_mode?: string; agent_id?: string }, R>(
655  $: EngineInterface,
656  e: E,
657  next: (e: E) => Promise<R>,
658): Promise<R> {
659  const seen = e.permission_mode
660  if (seen && !e.agent_id && seen !== (await read($, mode))) {
661    await update($, mode, () => seen)
662  }
663  return next(e)
664}
665
666function passThrough<E, R>(_$: EngineInterface, e: E, next: (e: E) => R): R {
667  return next(e)
668}
669
670export const register: Register = (on, options) => {
671  state = fresh(options)
672
673  on('session.start', async ($, e, next) => {
674    state.userTurns = null
675    // The first render waits on the probe and a run of the binary, and the native line would draw beside it until then.
676    const [id, now] = await Promise.all([$.session.id(), $.clock.now()])
677    await writeHeartbeat($, id, now + state.heartbeatMs)
678    // The engine drops this timer when the module reloads, so a new interval never stacks on an old one.
679    $.clock.every(state.intervalMs, () => void refresh($))
680    void refresh($)
681    return next(e)
682  })
683  // No event marks this module unloading, so an ending session is the one chance to hand the line back before the
684  // heartbeat runs out.
685  on('session.end', async ($, e, next) => {
686    await writeHeartbeat($, e.sessionId, 0)
687    return next(e)
688  })
689  on('session.measure', async ($, e, next) => {
690    const result = await next(e)
691    void refresh($)
692    return result
693  })
694  on('turn.start', async ($, e, next) => {
695    state.userTurns = Math.max(1, state.userTurns ?? 0)
696    const now = await $.clock.now()
697    // A turn that ends before its first response would otherwise report the previous turn's stop reason.
698    await update($, tracker, t => ({ ...t, lastStopReason: null }))
699    await update($, live, l => ({
700      ...l,
701      turn: {
702        started_at_ms: now,
703        last_duration_ms: l.turn?.last_duration_ms ?? null,
704        ended_at_ms: l.turn?.ended_at_ms ?? null,
705      },
706    }))
707    void refresh($)
708    return next(e)
709  })
710
711  on('turn.complete', async ($, e, next) => {
712    const result = await next(e)
713    if (!e.agentId) {
714      const [now, t] = await Promise.all([$.clock.now(), read($, tracker)])
715      const refusal = e.reason === 'refusal' ? (e.refusal.explanation ?? e.refusal.category) : null
716      await update($, live, l => ({
717        ...l,
718        // A call cut short by an interrupt may never report its end, and a rejected permission raises no PostToolUse.
719        // A subagent's waits outlive the main turn, as a background agent keeps running.
720        tools: [],
721        permissions: l.permissions.filter(p => p.agent !== null),
722        turn: { started_at_ms: null, last_duration_ms: e.durationMs, ended_at_ms: now },
723        lastError: turnError(l, e.reason, refusal, t.lastStopReason, now, e.durationMs),
724      }))
725    } else if ((await read($, live)).permissions.some(p => p.agent === e.agentId)) {
726      // The loop events' agentId is the classic events' agent_id. A loop that ended no longer waits on anything.
727      await update($, live, l => ({ ...l, permissions: l.permissions.filter(p => p.agent !== e.agentId) }))
728    }
729    void refresh($)
730    return result
731  })
732
733  // Main-loop requests only: a subagent's usage would overwrite the last response the context segments describe.
734  on('turn.step', async function* ($, e, next) {
735    const composed = state.pendingCompose
736    state.pendingCompose = null
737    if (e.agentId) {
738      state.composeAmbiguous = true
739      return yield* next(e)
740    }
741    // A running subagent may compose a prompt before its first request, so the one claimed here may be its own.
742    const agents = await $.agent.list()
743    const ambiguous = state.composeAmbiguous || agents.some(a => RUNNING.has(a.status))
744    state.composeAmbiguous = false
745    const sentAt = await $.clock.now()
746    const r = yield* next(e)
747    const u = r.usage
748    await update($, tracker, t => {
749      const stepped = { ...t, lastStopReason: r.stopReason }
750      if (!u) {
751        return stepped
752      }
753      const effort = typeof e.effort === 'string' ? e.effort : null
754      const s = { sentAt, model: u.model || e.model, usage: u, compose: composed, effort }
755      return observeStep(stepped, s, TTL_MS[t.cacheTtl ?? state.defaultTtl], ambiguous)
756    })
757    void refresh($)
758    return r
759  })
760
761  on('prompt.compose', async ($, e, next) => {
762    const result = await next(e)
763    state.pendingCompose = {
764      tools: [...e.tools].sort(),
765      chars: result.sections.reduce((n, s) => n + s.text.length, 0),
766    }
767    return result
768  })
769
770  on('tool.call', async ($, e, next) => {
771    const args = e as unknown as Json
772    const id = str(args.tool_use_id)
773    // A subagent's tools run inside the main loop's Agent call, which is already shown.
774    const tracked = !e.agentId && id !== null
775    if (tracked) {
776      const now = await $.clock.now()
777      await update($, live, l => ({
778        ...l,
779        tools: [...l.tools, { id, tool: e.tool, detail: detailOf(args), started_at_ms: now }],
780      }))
781      void refresh($)
782    }
783    try {
784      const result = await next(e)
785      const out = obj(result.result)
786      if (!result.deny && !result.isError && out) {
787        await noteTodos($, e.tool, args, out, !e.agentId)
788        await noteBackground($, e.tool, args, out)
789      }
790      return result
791    } finally {
792      if (tracked) {
793        await update($, live, l => ({ ...l, tools: l.tools.filter(t => t.id !== id) }))
794        void refresh($)
795      }
796    }
797  }).catch(passThrough)
798
799  on('classic.PermissionRequest', async ($, e, next) => {
800    const now = await $.clock.now()
801    const waiting = { tool: e.tool_name, since_ms: now, agent: e.agent_id ?? null }
802    await update($, live, l => ({ ...l, permissions: [...l.permissions, waiting] }))
803    void refresh($)
804    return next(e)
805  }).catch(passThrough)
806  on('classic.PostToolUse', ($, e, next) => noteMode($, e, seen => settlePermission($, seen, next))).catch(passThrough)
807  on('classic.UserPromptSubmit', noteMode).catch(passThrough)
808  on('classic.SessionStart', noteMode).catch(passThrough)
809  on('prompt.submit', noteTaskEnd).catch(passThrough)
810  // A notification can end a task before this plugin loaded or while a build without the prompt hook ran.
811  on('classic.Stop', ($, e, next) => noteMode($, e, seen => settleBackground($, seen, next))).catch(passThrough)
812  on('classic.SubagentStop', settleBackground).catch(passThrough)
813  on('classic.PostToolUseFailure', settlePermission).catch(passThrough)
814  on('classic.PermissionDenied', settlePermission).catch(passThrough)
815
816  // Carries the API's own word for the failure, which turn.complete reduces to 'error'.
817  on('classic.StopFailure', async ($, e, next) => {
818    if (!e.agent_id) {
819      const now = await $.clock.now()
820      const detail = e.error_details ? cut(plain(e.error_details), 200) || null : null
821      await update($, live, l => ({ ...l, lastError: { kind: e.error, detail, at_ms: now } }))
822      void refresh($)
823    }
824    return next(e)
825  }).catch(passThrough)
826
827  // A model switch is the only place the engine reports the cache TTL, and the new model starts with a cold cache.
828  on('classic.PostModelSwitch', async ($, e, next) => {
829    await update($, tracker, t => ({ ...t, cacheTtl: e.cache_ttl, lastRequestAt: null }))
830    void refresh($)
831    return next(e)
832  }).catch(passThrough)
833
834  // Main conversation only: precompute installs nothing and a subagent's compaction leaves the main window alone.
835  on('session.compact', async ($, e, next) => {
836    if (e.trigger === 'precompute' || e.agentId) {
837      return next(e)
838    }
839    const trigger = e.trigger === 'auto' || e.trigger === 'manual' ? e.trigger : null
840    const [start, before] = await Promise.all([$.clock.now(), read($, live)])
841    const prior = before.compaction
842    await update($, live, l => ({
843      ...l,
844      compaction: { ...(l.compaction ?? NO_COMPACTION), running_since_ms: start, trigger },
845    }))
846    void refresh($)
847    let done: Compaction | null = null
848    try {
849      const result = await next(e)
850      if (result.messages) {
851        const now = await $.clock.now()
852        done = {
853          count: (prior?.count ?? 0) + 1,
854          last_at_ms: now,
855          tokens_before: result.tokensBefore ?? null,
856          tokens_after: result.tokensAfter ?? null,
857          running_since_ms: null,
858          trigger,
859        }
860        // Compaction starts a new window, so the last response no longer describes the context.
861        await update($, tracker, t => ({ ...t, lastUsage: null, compactedSinceLast: true }))
862      }
863      return result
864    } finally {
865      const settled = done ?? (prior ? { ...prior, running_since_ms: null } : null)
866      await update($, live, l => ({ ...l, compaction: settled }))
867      void refresh($)
868    }
869  }).catch(passThrough)
870
871  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
872    if (state.placement !== 'below') {
873      return next(e)
874    }
875    const width = hintWidth(e.viewport?.columns, await read($, mode))
876    state.fitWidth = width
877    // The hint line is the only place the engine says how to interrupt, so it is carried over while a turn runs.
878    return (await drawLine($, e, e.props.isWorking, e.props.hint, width)) ?? next(e)
879  })
880
881
882  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
883    if (state.placement !== 'above' || e.props.hasSurvey) {
884      return next(e)
885    }
886    return (await drawLine($, e, false)) ?? next(e)
887  })
888}
889
hooks/activity.ts 279 lines
1import type { AgentInfo } from 'claude-code'
2
3import type { BackgroundTask, Compaction, LastError, Live, PendingPermission, TaskItem, TodoProgress } from '../types'
4import { cut, obj, plain, str } from './util'
5import type { Json } from './util'
6
7// Detail shown beside a running tool, in the order the built-in tools name their main argument.
8const DETAIL_KEYS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description'] as const
9const DETAIL_MAX = 80
10
11export function detailOf(args: Json): string | null {
12  for (const k of DETAIL_KEYS) {
13    const v = str(args[k])
14    const s = v === null ? '' : plain(v)
15    if (s) {
16      return cut(s, DETAIL_MAX, '…')
17    }
18  }
19  return null
20}
21
22export const EMPTY_LIVE: Live = {
23  tools: [],
24  turn: null,
25  permissions: [],
26  lastError: null,
27  todos: null,
28  tasks: {},
29  compaction: null,
30  background: {},
31}
32
33export const TODO_TOOLS: ReadonlySet<string> = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
34
35function progress(items: TaskItem[]): TodoProgress | null {
36  if (items.length === 0) {
37    return null
38  }
39  const active = items.find(i => i.status === 'in_progress')
40  return {
41    done: items.filter(i => i.status === 'completed').length,
42    total: items.length,
43    active: active ? (active.activeForm ?? active.subject) : null,
44  }
45}
46
47// Folds one todo or task tool result into the list. Returns undefined when the result changes nothing.
48export function foldTodos(l: Live, tool: string, args: Json, result: Json, main: boolean): Live | undefined {
49  let tasks: Record<string, TaskItem> | undefined
50  switch (tool) {
51    case 'TodoWrite': {
52      // A subagent's todo list is its own and would replace the one the main loop is working through.
53      if (!main || !Array.isArray(result.newTodos)) {
54        return undefined
55      }
56      const items = result.newTodos
57        .map(obj)
58        .filter((t): t is Json => t !== undefined)
59        .map(t => ({
60          subject: str(t.content) ?? '',
61          status: str(t.status) ?? 'pending',
62          activeForm: str(t.activeForm),
63        }))
64      return { ...l, todos: progress(items) }
65    }
66    // The task list is shared by every loop of the session, so a subagent's updates count too.
67    case 'TaskCreate':
68    case 'TaskGet': {
69      const task = obj(result.task)
70      const id = str(task?.id)
71      if (id !== null) {
72        tasks = {
73          ...l.tasks,
74          [id]: {
75            subject: str(task?.subject) ?? '',
76            status: tool === 'TaskCreate' ? 'pending' : (str(task?.status) ?? 'pending'),
77            activeForm: str(args.activeForm) ?? l.tasks[id]?.activeForm ?? null,
78          },
79        }
80      }
81      break
82    }
83    case 'TaskUpdate': {
84      const id = str(args.taskId)
85      if (id !== null && result.success !== false) {
86        tasks = { ...l.tasks }
87        if (args.status === 'deleted') {
88          delete tasks[id]
89        } else {
90          const prev = tasks[id] ?? { subject: '', status: 'pending', activeForm: null }
91          tasks[id] = {
92            subject: str(args.subject) ?? prev.subject,
93            status: str(args.status) ?? prev.status,
94            activeForm: str(args.activeForm) ?? prev.activeForm,
95          }
96        }
97      }
98      break
99    }
100    case 'TaskList': {
101      if (!Array.isArray(result.tasks)) {
102        break
103      }
104      // The listing carries no activeForm, so the one a create or update gave survives it.
105      tasks = {}
106      for (const t of result.tasks.map(obj)) {
107        const id = str(t?.id)
108        if (id !== null) {
109          tasks[id] = {
110            subject: str(t?.subject) ?? '',
111            status: str(t?.status) ?? 'pending',
112            activeForm: l.tasks[id]?.activeForm ?? null,
113          }
114        }
115      }
116      break
117    }
118  }
119  return tasks === undefined ? undefined : { ...l, tasks, todos: progress(Object.values(tasks)) }
120}
121
122export const BACKGROUND_TOOLS: ReadonlySet<string> = new Set(['Bash', 'Monitor', 'Workflow', 'TaskStop'])
123
124// The kinds a tool result can add. A remote agent or teammate in Stop's list is agent work, not a local task.
125const BACKGROUND_KINDS: ReadonlySet<string> = new Set(['shell', 'monitor', 'workflow'])
126
127// The same rule for a tool's own result and a Stop snapshot, so the line does not change when the snapshot lands.
128function backgroundTask(type: string, description: unknown, command: unknown, name: unknown): BackgroundTask {
129  const label = (type === 'workflow' ? str(name) : null) || str(description) || str(command)
130  const text = label === null ? '' : plain(label)
131  return { type, description: text ? cut(text, DETAIL_MAX, '…') : null }
132}
133
134// Folds one tool result into the background tasks. Returns undefined when the result changes nothing.
135export function foldBackground(l: Live, tool: string, args: Json, result: Json): Live | undefined {
136  // A session that ran an older build of the plugin keeps its live value across the reload, without this field.
137  const background = l.background ?? {}
138  let id: string | null
139  let task: BackgroundTask
140  switch (tool) {
141    case 'Bash':
142      id = str(result.backgroundTaskId)
143      // A synchronous subagent's shell is killed with that agent's answer, so it is not work the session waits on.
144      if (result.backgroundEndsWithFinalResponse === true) {
145        return undefined
146      }
147      task = backgroundTask('shell', args.description, args.command, null)
148      break
149    case 'Monitor':
150      id = str(result.taskId)
151      task = backgroundTask('monitor', args.description, args.command, null)
152      break
153    case 'Workflow':
154      if (result.taskType === 'remote_agent' || result.status === 'remote_launched') {
155        return undefined
156      }
157      id = str(result.taskId)
158      task = backgroundTask('workflow', null, null, result.workflowName ?? args.name)
159      break
160    case 'TaskStop': {
161      const stopped = str(result.task_id)
162      return stopped === null ? undefined : withoutBackground(l, [stopped])
163    }
164    default:
165      return undefined
166  }
167  return id === null ? undefined : { ...l, background: { ...background, [id]: task } }
168}
169
170// Stop's list of the session's work still in flight. Undefined when the event carries none, as an older engine's.
171export function backgroundSnapshot(list: unknown): Record<string, BackgroundTask> | undefined {
172  if (!Array.isArray(list)) {
173    return undefined
174  }
175  const background: Record<string, BackgroundTask> = {}
176  for (const t of list.map(obj)) {
177    const id = str(t?.id)
178    const type = str(t?.type)
179    if (t && id !== null && type !== null && BACKGROUND_KINDS.has(type)) {
180      background[id] = backgroundTask(type, t.description, t.command, t.name)
181    }
182  }
183  return background
184}
185
186export function withoutBackground(l: Live, ids: readonly string[]): Live | undefined {
187  const background = l.background ?? {}
188  const gone = ids.filter(id => id in background)
189  if (gone.length === 0) {
190    return undefined
191  }
192  const rest = { ...background }
193  for (const id of gone) {
194    delete rest[id]
195  }
196  return { ...l, background: rest }
197}
198
199const NOTIFICATION = /<task-notification>([\s\S]*?)<\/task-notification>/g
200const ENDED = new Set(['completed', 'failed', 'killed'])
201
202// The tasks a notification prompt reports ended. Several queued notifications can arrive as one prompt, and one with
203// any other status, or none, may come from a monitor that keeps running.
204export function endedTasks(text: string): string[] {
205  const ids: string[] = []
206  for (const [, body = ''] of text.matchAll(NOTIFICATION)) {
207    const id = /<task-id>([^<]+)<\/task-id>/.exec(body)?.[1]?.trim()
208    const status = /<status>([^<]+)<\/status>/.exec(body)?.[1]?.trim()
209    if (id && status && ENDED.has(status)) {
210      ids.push(id)
211    }
212  }
213  return ids
214}
215
216// PermissionRequest carries no tool_use_id, so a call's end settles the oldest wait on the same tool in the same loop.
217export function withoutPermission(list: PendingPermission[], tool: string, agent: string | null): PendingPermission[] {
218  const i = list.findIndex(p => p.tool === tool && p.agent === agent)
219  return i < 0 ? list : [...list.slice(0, i), ...list.slice(i + 1)]
220}
221
222// StopFailure and turn.complete come in no documented order, so whichever is second keeps the more specific word.
223export function turnError(
224  l: Live,
225  reason: 'answer' | 'aborted' | 'refusal' | 'error',
226  refusal: string | null,
227  stopReason: string | null,
228  now: number,
229  durationMs: number,
230): LastError | null {
231  switch (reason) {
232    case 'answer':
233      return stopReason === 'max_tokens' ? { kind: 'max_tokens', detail: null, at_ms: now } : null
234    case 'aborted':
235      return { kind: 'aborted', detail: null, at_ms: now }
236    case 'refusal':
237      return { kind: 'refusal', detail: refusal, at_ms: now }
238    case 'error': {
239      const startedAt = l.turn?.started_at_ms ?? now - durationMs
240      return l.lastError && l.lastError.at_ms >= startedAt ? l.lastError : { kind: 'error', detail: null, at_ms: now }
241    }
242  }
243}
244
245export const NO_COMPACTION: Compaction = {
246  count: 0,
247  last_at_ms: null,
248  tokens_before: null,
249  tokens_after: null,
250  running_since_ms: null,
251  trigger: null,
252}
253
254// Waiting agents still hold work, so they count as running. Ended ones linger in the list for a while and are left out.
255export const RUNNING: ReadonlySet<AgentInfo['status']> = new Set(['pending', 'running', 'waiting'])
256
257export function agentCounts(list: readonly AgentInfo[]): { running: number; idle: number } | null {
258  // A waiting agent is held on its own background work or an approval, which Claude Code's panel shows as done.
259  const running = list.filter(a => a.status === 'pending' || a.status === 'running').length
260  const idle = list.filter(a => a.status === 'idle' || a.status === 'waiting').length
261  return running + idle === 0 ? null : { running, idle }
262}
263
264// The agent panel's command is told an agent held on its own background work has completed, so the binary takes the
265// waiting ones from this map instead (subagent.rs). Only waiting agents are kept, as the binary reads nothing else and
266// a finished agent stays listed for the rest of the session. Sorted so a reordered list is not a change.
267export function agentStatuses(list: readonly AgentInfo[]): Record<string, AgentInfo['status']> {
268  const out: Record<string, AgentInfo['status']> = {}
269  for (const a of [...list].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0))) {
270    if (a.status === 'waiting') {
271      out[a.id] = a.status
272    }
273  }
274  return out
275}
276
277// The binary trusts the map for 30s, so an unchanged one is written again well before then.
278export const AGENTS_REWRITE_MS = 10_000
279
hooks/binary.ts 45 lines
1import type { Span } from '../types'
2import { obj } from './util'
3
4export const NOT_FOUND = 'statusline not found: brew install ryanclark/tap/statusline, then statusline install --plugin'
5// process.run also rejects on timeout or when the binary exists but cannot start, so only ENOENT means missing.
6export const MISSING = /ENOENT|not found|No such file/i
7export const REQUIRED_FLAGS = [/--format\b/, /--heartbeat-ms\b/]
8// Optional, so a binary that predates it still draws, cut at a character instead of between segments.
9export const WIDTH_FLAG = /--width\b/
10export const PLUGIN_DATA_FLAG = /--plugin-data\b/
11// clap's wording for a flag a build predates. The probe sees it first, and a refresh catches a binary swapped later.
12export const UNKNOWN_FLAG = /unexpected argument '--(format|heartbeat-ms|plugin-data)'/
13
14export type Needs = { usage: boolean; autocompact: boolean }
15export const NO_NEEDS: Needs = { usage: false, autocompact: false }
16
17// A `final` error holds until reload. Others show for one refresh, then the binary is probed again.
18export type Verdict = { error: string; final: boolean } | null
19
20export const isSpanRows = (v: unknown): v is Span[][] =>
21  Array.isArray(v) && v.every(row => Array.isArray(row) && row.every(s => typeof obj(s)?.text === 'string'))
22
23const parseVersion = (s: string): string | null => /statusline (\d+\.\d+\.\d+)/.exec(s)?.[1] ?? null
24
25// Matches how the binary was installed, since upgrading the wrong install fails or adds a second copy.
26// statusline is not on crates.io, so cargo's bin dir only holds a `just install` build.
27function upgradeFor(realPath: string): string {
28  if (/[\\/](Cellar|homebrew)[\\/]/i.test(realPath)) {
29    return 'brew upgrade ryanclark/tap/statusline'
30  }
31  if (/[\\/]\.local[\\/]bin[\\/]/.test(realPath)) {
32    return 'curl -fsSL https://raw.githubusercontent.com/ryanclark/statusline/main/install.sh | sh'
33  }
34  if (/[\\/]\.cargo[\\/]bin[\\/]/.test(realPath)) {
35    return 'just install (from a checkout)'
36  }
37  return 'update statusline (brew upgrade ryanclark/tap/statusline, or rerun install.sh)'
38}
39
40// The version only appears in the message, so a build too old for `--version` still gets one.
41export function tooOldMessage(versionOutput: string | null, realPath: string): string {
42  const version = versionOutput === null ? null : parseVersion(versionOutput)
43  return `statusline ${version ?? 'binary'} is too old for this plugin: ${upgradeFor(realPath)}`
44}
45
hooks/cache.ts 130 lines
1import type { CacheTtl, ComposeShape, LastUsage, MissCause, Tracker } from '../types'
2
3export const TTL_MS: Record<CacheTtl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
4
5// A system prompt change keeps the tools prefix cached, so a miss still reads some cache. Half allows for the
6// breakpoint moving.
7const MISS_RATIO = 0.5
8// Expected rebuilds, which Claude Code counts separately from misses.
9const EXPECTED_CAUSES = new Set(['model_changed', 'compacted'])
10// Bounds the session state on a pathological session. The binary only windows the recent ones.
11const MISS_TIMES_CAP = 1000
12
13export type Step = {
14  sentAt: number
15  model: string
16  usage: LastUsage
17  compose: ComposeShape | null
18  effort: string | null
19}
20
21export const EMPTY_TRACKER: Tracker = {
22  lastUsage: null,
23  lastRequestAt: null,
24  cacheObserved: false,
25  cacheTtl: null,
26  requests: 0,
27  cacheReadTokens: 0,
28  cacheWriteTokens: 0,
29  promptTokens: 0,
30  effort: null,
31  prevSentAt: null,
32  prevModel: null,
33  prevCached: 0,
34  compactedSinceLast: false,
35  compose: null,
36  misses: 0,
37  expectedRebuilds: 0,
38  missTimes: [],
39  missRecacheTokens: 0,
40  lastMissAt: null,
41  lastMissCause: null,
42  missCauses: {},
43  lastStopReason: null,
44}
45
46function diffCompose(before: ComposeShape | null, after: ComposeShape | null): MissCause {
47  const out: MissCause = { causes: [] }
48  if (!before || !after) {
49    return out
50  }
51  const old = new Set(before.tools)
52  const now = new Set(after.tools)
53  const added = after.tools.filter(n => !old.has(n)).length
54  const removed = before.tools.filter(n => !now.has(n)).length
55  if (added + removed > 0) {
56    out.causes.push('tools_changed')
57    out.tools_added = added
58    out.tools_removed = removed
59  }
60  if (after.chars !== before.chars) {
61    out.causes.push('system_changed')
62    out.system_char_delta = after.chars - before.chars
63  }
64  return out
65}
66
67// `ambiguous` means a subagent sent a request since the last main one or is still running, so the compose this request
68// claimed may not be the main loop's.
69export function observeStep(t: Tracker, s: Step, ttlMs: number, ambiguous: boolean): Tracker {
70  const u = s.usage
71  const read = u.cache_read_input_tokens
72  const write = u.cache_creation_input_tokens
73  const next: Tracker = {
74    ...t,
75    // Copied field by field, since the step's usage is the engine's own and carries more than the binary reads.
76    lastUsage: {
77      input_tokens: u.input_tokens,
78      output_tokens: u.output_tokens,
79      cache_creation_input_tokens: write,
80      cache_read_input_tokens: read,
81    },
82    // Send time rather than arrival, so the expiry errs early by at most one request's duration.
83    lastRequestAt: s.sentAt,
84    cacheObserved: t.cacheObserved || read + write > 0,
85    requests: t.requests + 1,
86    cacheReadTokens: t.cacheReadTokens + read,
87    cacheWriteTokens: t.cacheWriteTokens + write,
88    promptTokens: t.promptTokens + u.input_tokens + read + write,
89    effort: s.effort ?? t.effort,
90    prevSentAt: s.sentAt,
91    prevModel: s.model,
92    prevCached: read + write,
93    compactedSinceLast: false,
94    // Left unset after an ambiguous request, so the next diff waits for a compose the main loop surely claimed.
95    compose: ambiguous ? null : (s.compose ?? t.compose),
96  }
97  if (t.prevCached <= 0 || read >= t.prevCached * MISS_RATIO) {
98    return next
99  }
100  const seen: string[] = []
101  if (t.prevSentAt !== null && s.sentAt - t.prevSentAt > ttlMs) {
102    seen.push('ttl_expired')
103  }
104  if (t.prevModel !== null && s.model !== t.prevModel) {
105    seen.push('model_changed')
106  }
107  if (t.compactedSinceLast) {
108    seen.push('compacted')
109  }
110  const diff: MissCause = ambiguous ? { causes: [] } : diffCompose(t.compose, s.compose)
111  const cause: MissCause = { ...diff, causes: [...seen, ...diff.causes] }
112  if (cause.causes.some(c => EXPECTED_CAUSES.has(c))) {
113    return { ...next, expectedRebuilds: t.expectedRebuilds + 1 }
114  }
115  const at = Math.floor(s.sentAt / 1000)
116  const missCauses = { ...t.missCauses }
117  for (const c of cause.causes) {
118    missCauses[c] = (missCauses[c] ?? 0) + 1
119  }
120  return {
121    ...next,
122    misses: t.misses + 1,
123    missTimes: [...t.missTimes, at].slice(-MISS_TIMES_CAP),
124    missRecacheTokens: t.missRecacheTokens + write,
125    lastMissAt: at,
126    lastMissCause: cause,
127    missCauses,
128  }
129}
130
hooks/draw.tsx 108 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Rendered } from '../types'
4import { parsePills } from './pills'
5
6// Claude Code draws a statusLine command's uncoloured text in the theme's muted grey, not the terminal foreground,
7// so spans without their own colour take that theme key to match the native line.
8const DEFAULT_FG = 'inactive'
9
10// The components `$.ui.resolve` hands back for the surface being drawn.
11type Ui = ReturnType<EngineInterface['ui']['resolve']>
12
13// The hint row's padding, the "⏵⏵ " before the mode label, and the " · " Claude Code draws between it and this
14// line.
15const ROW_PADDING = 4
16const MODE_GLYPHS = 3
17const SEPARATOR = 3
18// Terminals disagree on whether ⏵ and ⏸ take one cell or two.
19const GLYPH_SLACK = 2
20
21// The label Claude Code draws for each permission mode. Default mode is reserved too, since what it draws there is
22// unconfirmed and a few spare columns beat a wrapped row.
23const MODE_LABELS: Record<string, string> = {
24  default: 'manual mode on',
25  acceptEdits: 'accept edits on',
26  plan: 'plan mode on',
27  auto: 'auto mode on',
28  dontAsk: "don't ask on",
29  bypassPermissions: 'bypass permissions on',
30}
31// Assumed for a mode not seen yet or not in the table, so the line never wraps.
32const LONGEST_LABEL = Math.max(...Object.values(MODE_LABELS).map(label => label.length))
33
34// Claude Code lays the mode label and this line out in one row and shrinks both when the line overflows, which wraps
35// the label's " · " onto a second row. A fixed width keeps the line to what the label leaves.
36export function hintWidth(columns: number | undefined, mode: string | null): number | undefined {
37  if (columns === undefined) {
38    return undefined
39  }
40  const label = (mode === null ? undefined : MODE_LABELS[mode])?.length ?? LONGEST_LABEL
41  return Math.max(1, columns - ROW_PADDING - MODE_GLYPHS - label - SEPARATOR - GLYPH_SLACK)
42}
43
44// A line with no text and no error leaves the slot to Claude Code.
45export const blank = (r: Rendered): boolean => !r.error && !r.update && r.rows.every(row => row.length === 0)
46
47export function draw(ui: Ui, r: Rendered, working: boolean, hint?: unknown, width?: number) {
48  const { Box, Text, Link } = ui
49  if (r.error) {
50    return (
51      <Text color="error" wrap="truncate-end">
52        {r.error}
53      </Text>
54    )
55  }
56  const lastRow = r.rows.length - 1
57  const { pills, selected } = parsePills(hint)
58  return (
59    <Box flexDirection="column" width={width}>
60      {r.update ? <Text color="success" wrap="truncate-end">{r.update}</Text> : null}
61      {r.rows.map((row, i) => (
62        // One Text per row so an overflowing row is cut once at its end rather than every span shrinking on its own.
63        <Text key={`row${i}`} wrap="truncate-end">
64          {i === 0 && pills.length > 0 ? (
65            <Text key="pills">
66              {pills.map((p, k) => (
67                <Text key={`pill${k}`} color={selected ? 'inverseText' : 'cyan'} backgroundColor={selected ? 'cyan' : undefined}>
68                  {k > 0 ? ` ${p}` : p}
69                </Text>
70              ))}
71              <Text dimColor>{' · '}</Text>
72            </Text>
73          ) : null}
74          {row.map((s, j) => {
75            const text = (
76              <Text
77                key={`s${j}`}
78                color={s.fg ?? DEFAULT_FG}
79                backgroundColor={s.bg}
80                bold={s.bold}
81                dimColor={s.dim}
82                italic={s.italic}
83                underline={s.underline}
84                strikethrough={s.strikethrough}
85                inverse={s.inverse}
86              >
87                {s.text}
88              </Text>
89            )
90            return s.href ? (
91              <Link key={`l${j}`} href={s.href}>
92                {text}
93              </Link>
94            ) : (
95              text
96            )
97          })}
98          {working && i === lastRow ? (
99            <Text key="esc" dimColor>
100              {' · esc to interrupt'}
101            </Text>
102          ) : null}
103        </Text>
104      ))}
105    </Box>
106  )
107}
108
hooks/input.ts 107 lines
1import type { AgentInfo, SessionUsage } from 'claude-code'
2
3import type { Autocompact, CacheTtl, Live, Tracker } from '../types'
4import { agentCounts } from './activity'
5import { TTL_MS } from './cache'
6import { rateLimits } from './limits'
7import type { UsageInput } from './usage'
8
9export type Sources = {
10  now: number
11  id: string
12  cwd: string
13  root: string
14  model: string
15  version: string
16  usage: SessionUsage
17  tracker: Tracker
18  live: Live
19  agents: readonly AgentInfo[]
20  defaultTtl: CacheTtl
21  autocompact: Autocompact | null
22  accountUsage: UsageInput | null
23  showUpdate?: boolean
24}
25
26export function autocompactOf(b: SessionUsage['context']['breakdown']): Autocompact | null {
27  if (!b) {
28    return null
29  }
30  return {
31    enabled: b.isAutoCompactEnabled,
32    headroom_tokens:
33      b.isAutoCompactEnabled && b.autoCompactThreshold !== undefined
34        ? Math.max(0, b.autoCompactThreshold - b.totalTokens)
35        : null,
36  }
37}
38
39// Rebuilds Claude Code's statusLine input from the mod API, which does not expose it. Fields with no source (vim,
40// fast_mode, thinking, PR) are left out, and the binary saves the result as the session's snapshot.
41export function inputJson(src: Sources): string {
42  const { now, usage, tracker: t, live: l } = src
43
44  const tokens = usage.context.tokens ?? 0
45  const ttl = t.cacheTtl ?? src.defaultTtl
46  const expiresAt = t.lastRequestAt === null ? null : t.lastRequestAt + TTL_MS[ttl]
47  const warm = expiresAt !== null && now < expiresAt
48  const [waiting] = l.permissions
49
50  return JSON.stringify({
51    session_id: src.id,
52    cwd: src.cwd,
53    workspace: { current_dir: src.cwd, project_dir: src.root },
54    // display_name is left empty so the binary derives it from the id, which is all $.session.model() reports.
55    model: { id: src.model, display_name: '' },
56    version: src.version,
57    cost: { total_cost_usd: usage.cost?.usd ?? 0, total_duration_ms: Math.max(0, now - usage.startedAt) },
58    context_window: {
59      context_window_size: usage.context.window,
60      total_input_tokens: tokens,
61      total_output_tokens: t.lastUsage?.output_tokens ?? 0,
62      current_usage: t.lastUsage,
63      ...(usage.context.percent === undefined
64        ? {}
65        : { used_percentage: usage.context.percent, remaining_percentage: 100 - usage.context.percent }),
66    },
67    exceeds_200k_tokens: tokens > 200_000,
68    rate_limits: rateLimits(usage.rateLimits, src.accountUsage, now),
69    ...(t.effort ? { effort: { level: t.effort } } : {}),
70    ...(t.cacheObserved
71      ? {
72          prompt_cache: {
73            warm,
74            caching_observed: true,
75            ttl,
76            expires_at: warm ? Math.floor(expiresAt / 1000) : null,
77            requests: t.requests,
78            misses: t.misses,
79            expected_rebuilds: t.expectedRebuilds,
80            hit_ratio: t.promptTokens > 0 ? t.cacheReadTokens / t.promptTokens : null,
81            cache_write_tokens: t.cacheWriteTokens,
82            miss_recache_tokens: t.missRecacheTokens,
83            last_miss_at: t.lastMissAt,
84            last_miss_cause: t.lastMissCause,
85            miss_causes: t.missCauses,
86            miss_times: t.missTimes,
87            // The compacted window caches a new, smaller prefix, so the old size says nothing until the next request.
88            recache_tokens_if_cold: t.prevCached > 0 && !t.compactedSinceLast ? t.prevCached : null,
89          },
90        }
91      : {}),
92    mod: {
93      show_update: src.showUpdate === true,
94      tools: l.tools.map(({ tool, detail, started_at_ms }) => ({ tool, detail, started_at_ms })),
95      turn: l.turn,
96      permission: waiting ? { tool: waiting.tool, since_ms: waiting.since_ms } : null,
97      last_error: l.lastError,
98      todos: l.todos,
99      agents: agentCounts(src.agents),
100      background_tasks: Object.values(l.background ?? {}),
101      compaction: l.compaction,
102      autocompact: src.autocompact,
103      ...(src.accountUsage ? { usage: src.accountUsage } : {}),
104    },
105  })
106}
107
hooks/usage.ts 155 lines
1import type { HttpResponse } from 'claude-code'
2
3import { dataPath, obj } from './util'
4import type { Json } from './util'
5
6export const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
7export const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
8export const USAGE_HEADERS = { 'anthropic-beta': 'oauth-2025-04-20' }
9// Claude Code keeps its own snapshot for a minute too. Polling every 30s drew 429s within minutes.
10export const FETCH_EVERY_MS = 60_000
11// Without a verified account, requests cannot be coordinated across chats. Keep the private fallback conservative.
12export const PRIVATE_FETCH_EVERY_MS = 5 * 60_000
13// Login handles freeze the credentials they were minted with; a still-valid old token does not necessarily get a 401.
14export const REAUTHORIZE_EVERY_MS = 15 * 60_000
15// After a 429 the endpoint kept answering 429 while it was polled, so the wait grows to half an hour.
16const MAX_BACKOFF_MS = 30 * 60_000
17const BACKOFF_STEPS_MS = [5 * 60_000, 10 * 60_000, 20 * 60_000, MAX_BACKOFF_MS]
18
19// Shared by chats on the same account, so the endpoint sees about one request a minute however many are open. Two chats that find
20// it due within the same few milliseconds can both fetch, as the fs API has no rename or lock. For the same reason a
21// torn read counts as no file.
22export type UsageFile = {
23  fetched_at_ms: number | null
24  body: Json | null
25  backoff_until_ms: number
26  backoff_ms: number
27}
28
29// Sent whenever this chat holds a login, empty until a body lands, so the binary never falls back to cookies for it.
30export type UsageInput = { fetched_at_ms: number | null; body: Json | null }
31
32// One chat's own share. The handle is minted once and reused, since each plugin holds at most four. `nextAt` keeps
33// this chat to the shared pace when the file cannot be written. `last` stands in for a torn read.
34export type UsageMemo = {
35  handle: string | null
36  account: string | null
37  profileNextAt: number
38  profileBackoffMs: number
39  backoffMs: number
40  reauthorizeAt: number
41  off: boolean
42  authFailed: boolean
43  polling: boolean
44  nextAt: number
45  last: UsageInput
46}
47
48export const newUsageMemo = (): UsageMemo => ({
49  handle: null,
50  account: null,
51  profileNextAt: 0,
52  profileBackoffMs: 0,
53  backoffMs: 0,
54  reauthorizeAt: 0,
55  off: false,
56  authFailed: false,
57  polling: false,
58  nextAt: 0,
59  last: { fetched_at_ms: null, body: null },
60})
61
62export const EMPTY_USAGE: UsageFile = { fetched_at_ms: null, body: null, backoff_until_ms: 0, backoff_ms: 0 }
63
64export const usagePath = (home: string, account: string) => dataPath(home, `cache/plugin-usage-${account}.json`)
65
66// Only identifiers from this handle's authenticated profile may select a shared cache. Global Claude settings can
67// belong to a different login than an already-open chat. UUIDs also keep server data out of directory components.
68export function usageAccount(text: string): string | null {
69  let value
70  try {
71    value = obj(JSON.parse(text))
72  } catch {
73    return null
74  }
75  const account = obj(value?.account)?.uuid
76  const organization = obj(value?.organization)?.uuid
77  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
78  return typeof account === 'string' && uuid.test(account) && typeof organization === 'string' && uuid.test(organization)
79    ? `${organization.toLowerCase()}.${account.toLowerCase()}`
80    : null
81}
82
83// The binary reads these as i64 and fails the whole line on anything else.
84const num = (v: unknown): number | null => (Number.isSafeInteger(v) ? (v as number) : null)
85
86export function parseUsageFile(text: string): UsageFile | null {
87  let v: unknown
88  try {
89    v = JSON.parse(text)
90  } catch {
91    return null
92  }
93  const o = obj(v)
94  if (!o) {
95    return null
96  }
97  return {
98    fetched_at_ms: num(o.fetched_at_ms),
99    body: obj(o.body) ?? null,
100    backoff_until_ms: num(o.backoff_until_ms) ?? 0,
101    backoff_ms: num(o.backoff_ms) ?? 0,
102  }
103}
104
105// A wait further off than any this plugin sets was dated before the clock stepped back, and would hold every chat until
106// the clock caught up.
107export const waiting = (until: number, now: number): boolean => now < until && until - now <= MAX_BACKOFF_MS
108
109export function fetchDue(file: UsageFile, now: number): boolean {
110  const at = file.fetched_at_ms
111  const stale = at === null || now - at >= FETCH_EVERY_MS || at - now > FETCH_EVERY_MS
112  return stale && !waiting(file.backoff_until_ms, now)
113}
114
115// The engine's own refusals: nonessential traffic is off, the organization's policy blocks plugins' network access, or
116// the session withholds its credential. Timeouts and network errors say `aborted` or `failed` and are retried.
117export const REFUSED = /\$\.http\.fetch: refused: /
118// The handle was evicted by newer ones the plugin minted, or the engine forgot it.
119export const UNKNOWN_HANDLE = /\$\.http\.fetch: unknown auth handle/
120
121// Written before the request, so a chat polling meanwhile waits, and a failure is not retried on every refresh.
122export const claimed = (file: UsageFile, now: number): UsageFile => ({
123  ...file,
124  backoff_until_ms: now + FETCH_EVERY_MS,
125})
126
127const nextBackoff = (ms: number): number => BACKOFF_STEPS_MS.find(step => step > ms) ?? MAX_BACKOFF_MS
128
129export function backedOff(file: UsageFile, now: number, retryAfter?: string): UsageFile {
130  const step = nextBackoff(file.backoff_ms)
131  const seconds = Number(retryAfter)
132  // The endpoint has answered retry-after: 0 while still limiting, so only a positive one is believed.
133  const wait = Number.isFinite(seconds) && seconds > 0 ? Math.max(step, seconds * 1000) : step
134  return { ...file, backoff_until_ms: now + Math.min(wait, MAX_BACKOFF_MS), backoff_ms: step }
135}
136
137// The file a response leaves behind, or null to leave the claim standing until the next minute.
138export function answered(file: UsageFile, res: HttpResponse, now: number): UsageFile | null {
139  if (res.status === 429) {
140    return backedOff(file, now, res.headers['retry-after'])
141  }
142  if (!res.ok) {
143    return null
144  }
145  let body: Json | undefined
146  try {
147    body = obj(JSON.parse(res.text))
148  } catch {
149    return null
150  }
151  return body ? { fetched_at_ms: now, body, backoff_until_ms: 0, backoff_ms: 0 } : null
152}
153
154export const shown = (file: UsageFile): UsageInput => ({ fetched_at_ms: file.fetched_at_ms, body: file.body })
155
hooks/util.ts 24 lines
1export type Json = Record<string, unknown>
2
3export const obj = (v: unknown): Json | undefined => (v && typeof v === 'object' ? (v as Json) : undefined)
4export const str = (v: unknown): string | null => (typeof v === 'string' ? v : null)
5
6// The binary's own data directory, where it reads what the plugin leaves for it.
7export const dataPath = (home: string, rel: string) => `${home}/.statusline/${rel}`
8
9// Cut by code point: a lone surrogate from a UTF-16 cut is escaped by JSON.stringify, and serde_json then rejects the
10// whole input.
11export function cut(s: string, max: number, mark = ''): string {
12  const points = Array.from(s)
13  return points.length > max ? points.slice(0, max - mark.length).join('') + mark : s
14}
15
16// Text refuses control characters, and the binary colours its stderr.
17export const plain = (s: string) =>
18  s
19    .replace(/\x1b\[[0-9;:]*[A-Za-z]/g, '')
20    .replace(/\t/g, ' ')
21    .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
22    .trim()
23    .split('\n')[0] ?? ''
24
hooks/pills.ts 23 lines
1export type Pills = { pills: string[]; selected: boolean }
2
3const NO_PILLS: Pills = { pills: [], selected: false }
4
5// The nouns the footer counts, taken from the Claude Code 2.1.290 binary. The hint reaches a plugin read the way a
6// screen reader reads it, so the parts may be joined by " · " or by one space and a pill is found by its shape alone.
7const PILL = /\b\d+ (?:(?:local|cloud|mcp) )?(?:shell|monitor|workflow|agent|teammate|task|dream)s?\b/gi
8
9// The engine only words the selection as "Enter to view ...". It does not say which pill holds the focus, so `selected`
10// is a fact about the whole row and the caller lights every pill.
11const VIEW = /\benter to view\b/i
12
13export function parsePills(hint: unknown): Pills {
14  if (typeof hint !== 'string' || hint.length > 500) {
15    return NO_PILLS
16  }
17  const pills = hint.match(PILL) ?? []
18  if (pills.length === 0) {
19    return NO_PILLS
20  }
21  return { pills, selected: VIEW.test(hint) }
22}
23
hooks/limits.ts 48 lines
1import type { SessionUsage } from 'claude-code'
2
3import type { UsageInput } from './usage'
4import { obj } from './util'
5
6type Period = { used_percentage: number; resets_at: number }
7
8// Match the binary's stale-usage threshold and tolerance for clock skew between chats.
9const STALE_MS = 5 * 60_000
10const AHEAD_MS = 60_000
11// Headers contain whole seconds; the account endpoint includes subsecond precision.
12const RESET_SLOP_SECONDS = 1
13
14function period(percent: unknown, reset: unknown, now: number): Period | null {
15  // ECMAScript guarantees milliseconds, while the endpoint returns six fractional digits.
16  const at = typeof reset === 'string' ? Date.parse(reset.replace(/(\.\d{3})\d+(?=Z|[+-]\d{2}:\d{2}$)/, '$1')) : NaN
17  if (typeof percent !== 'number' || !Number.isFinite(percent) || percent < 0 || !Number.isFinite(at) || at <= now) {
18    return null
19  }
20  return { used_percentage: percent, resets_at: Math.floor(at / 1000) }
21}
22
23// $.session.usage() reads the current process's last observations, so polling it does not refresh idle chats.
24// Prefer a recent account snapshot, with the process's still-active windows as the immediate fallback.
25export function rateLimits(session: SessionUsage['rateLimits'], shared: UsageInput | null, now: number): Record<string, Period> {
26  const result: Record<string, Period> = {}
27  for (const limit of session) {
28    const value = period(limit.percentUsed, limit.resetsAt, now)
29    if (value) {
30      result[limit.kind] = value
31    }
32  }
33  const at = shared?.fetched_at_ms
34  if (at === null || at === undefined || !Number.isSafeInteger(at) || now - at > STALE_MS || at - now > AHEAD_MS) {
35    return result
36  }
37  for (const kind of ['five_hour', 'seven_day']) {
38    const raw = shared?.body?.[kind]
39    // A null/missing window cannot suppress a valid fallback: it may have opened after that snapshot.
40    const window = obj(raw)
41    const value = period(window?.utilization, window?.resets_at, now)
42    if (value && (!result[kind] || value.resets_at + RESET_SLOP_SECONDS >= result[kind].resets_at)) {
43      result[kind] = value
44    }
45  }
46  return result
47}
48
types/index.d.ts 111 lines
1export type Span = {
2  text: string
3  fg?: string
4  bg?: string
5  bold?: boolean
6  dim?: boolean
7  italic?: boolean
8  underline?: boolean
9  strikethrough?: boolean
10  inverse?: boolean
11  href?: string
12}
13
14export type Rendered = { rows: Span[][]; error?: string; update?: string }
15
16export type LastUsage = {
17  input_tokens: number
18  output_tokens: number
19  cache_creation_input_tokens: number
20  cache_read_input_tokens: number
21}
22
23export type MissCause = {
24  causes: string[]
25  tools_added?: number
26  tools_removed?: number
27  system_char_delta?: number
28}
29
30// Only the difference between two main-loop system prompts is reported, so the text itself is not kept.
31export type ComposeShape = { tools: string[]; chars: number }
32
33export type CacheTtl = '5m' | '1h'
34
35// What the plugin learns from watching main-loop requests, since no API reports it directly. It fills the input's
36// `prompt_cache`, which mirrors the object Claude Code documents for a statusLine command, so some fields go unread.
37export type Tracker = {
38  lastUsage: LastUsage | null
39  lastRequestAt: number | null
40  cacheObserved: boolean
41  cacheTtl: CacheTtl | null
42  requests: number
43  cacheReadTokens: number
44  cacheWriteTokens: number
45  promptTokens: number
46  effort: string | null
47  // The previous main-loop request, kept across a model switch or compaction unlike `lastRequestAt` and `lastUsage`.
48  prevSentAt: number | null
49  prevModel: string | null
50  prevCached: number
51  compactedSinceLast: boolean
52  compose: ComposeShape | null
53  misses: number
54  expectedRebuilds: number
55  missTimes: number[]
56  missRecacheTokens: number
57  lastMissAt: number | null
58  lastMissCause: MissCause | null
59  missCauses: Record<string, number>
60  lastStopReason: string | null
61}
62
63export type ToolInFlight = { id: string; tool: string; detail: string | null; started_at_ms: number }
64
65export type TaskItem = { subject: string; status: string; activeForm: string | null }
66
67// PermissionRequest carries no tool_use_id, so a wait is known only by its tool and loop.
68export type PendingPermission = { tool: string; since_ms: number; agent: string | null }
69
70export type LastError = { kind: string; detail: string | null; at_ms: number }
71
72export type TurnInfo = { started_at_ms: number | null; last_duration_ms: number | null; ended_at_ms: number | null }
73
74export type TodoProgress = { done: number; total: number; active: string | null }
75
76export type Compaction = {
77  count: number
78  last_at_ms: number | null
79  tokens_before: number | null
80  tokens_after: number | null
81  running_since_ms: number | null
82  trigger: 'auto' | 'manual' | null
83}
84
85// What a background shell, monitor or workflow is shown as. Subagents are left to the agents segment.
86export type BackgroundTask = { type: string; description: string | null }
87
88export type Autocompact = { enabled: boolean; headroom_tokens: number | null }
89
90// Sources of the input's `mod` object, apart from the agents and autocompact figures polled on each refresh.
91export type Live = {
92  tools: ToolInFlight[]
93  turn: TurnInfo | null
94  // Oldest first. Parallel calls and subagents can each wait on the user at once.
95  permissions: PendingPermission[]
96  lastError: LastError | null
97  todos: TodoProgress | null
98  // Task tools report one task per call, so the list is rebuilt here to count it.
99  tasks: Record<string, TaskItem>
100  compaction: Compaction | null
101  // Keyed by task id. Tool results and end notifications add and remove tasks as they happen, and each Stop's snapshot
102  // replaces the lot.
103  background: Record<string, BackgroundTask>
104}
105
106declare module 'claude-code' {
107  interface PluginState {
108    statusline: { rendered: Rendered | null; tracker: Tracker; live: Live; mode: string | null }
109  }
110}
111