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

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" />
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.
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:
| Option | Default | Description |
|---|---|---|
binary | statusline | Path to the statusline binary, in full if it is not on PATH |
placement | below | below replaces the hint line under the prompt, above uses the band over it |
intervalMs | 1000 | How often countdowns and git state are re-rendered between turns |
cacheTtl | 1h | Prompt 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.
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.
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.
Regenerate these with just screenshots.
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" />
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" />
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" />
["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" />
By default:
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.
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.
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.
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.
| Segment | Description |
|---|---|
context_percentage | Context window used % (colored) |
context_remaining | Remaining context % (colored) |
context_window_size | Total context size (e.g. 200k) |
total_input_tokens | Current context input tokens (input + cache creation + cache read) with ↑ icon |
input_tokens | Cumulative input tokens across the session with ↑ icon |
output_tokens | Total output tokens with ↓ icon |
cache_read_tokens | Cache read tokens with ↻ icon |
cache_hit_ratio | Cache read as % of total input |
cache_warm | Prompt cache state with ♨ icon: warm with time until it goes cold, or cold |
session_cache_hit_ratio | Cache reads as % of all input tokens this session |
cache_misses | Prompt 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_miss | Cause of the last prompt cache miss (e.g. tools changed (+2 −1), expired after 5m idle) and how long ago, hidden after 30 minutes |
exceeds200k | Warning indicator when context exceeds 200k tokens |
| Segment | Description |
|---|---|
five_hour | 5-hour rate limit % with optional reset countdown |
seven_day | 7-day rate limit % with optional reset countdown |
spend_limit | Spend limit % with reset countdown (only present behind a Claude apps gateway) |
fable_usage | Fable weekly rate limit % with reset countdown (calls the API) |
extra_usage | Extra usage $used/$limit (calls the API) |
| Segment | Description |
|---|---|
cost | Total session cost in USD |
cost_rate | Cost per minute ($/m) |
duration | Total session duration |
api_duration | Total API call time |
tokens_per_second | Output tokens per second of API time |
lines_added | Lines added with + icon |
lines_removed | Lines removed with - icon |
| Segment | Description |
|---|---|
git_branch | Current git branch name |
git_ahead_behind | Commits ahead/behind upstream (e.g. ↑3 ↓1) |
git_stash | Stash count with ⚑ icon |
pr | Open pull request number (! for GitLab merge requests) with ⎇ icon, colored by review state, clickable link to the PR (link needs colors) |
repo | Repository 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.
| Segment | Description |
|---|---|
cwd | Current working directory (supports path_format) |
project_dir | Project directory (supports path_format) |
model | Model display name |
model_id | Full model ID |
version | Claude Code version |
session_id | Session ID |
session_name | Session name (--name or /rename, else the generated title) |
vim_mode | Vim mode (NORMAL, INSERT, etc.) |
agent_name | Active agent name |
effort | Reasoning effort level (low to max), colored by level |
thinking | thinking when extended thinking is enabled |
fast_mode | fast when fast mode is on |
worktree | Worktree name (a worktree session, or any linked git worktree) |
account | Current Claude account nickname (from ~/.statusline/accounts.json, colored per entry) |
These need the plugin. With the plain statusLine command they render nothing.
| Segment | Description |
|---|---|
current_tool | Tool 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_elapsed | Time the running turn has taken with ⏱ icon (⏱ 1m42s), or the last turn's length dimmed between turns (last 2m10s) |
permission_pending | Tool waiting on approval with ⏸ icon and how long, kept up while it runs (⏸ Bash waiting or running 45s) |
last_api_error | Why the last turn failed with ⚠ icon and how long ago (⚠ overloaded 2m ago, rate limited, hit max tokens, interrupted) |
todo_progress | Todo items done out of total with ☑ icon and the active item (☑ 3/7 · Running tests) |
agents | Background agents with ⁂ icon (⁂ 3 running · 1 idle) |
background_tasks | Background 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 |
compaction | With ⟳ 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_headroom | Tokens left before autocompact triggers with ↧ icon (compact in 38.0k), or autocompact off |
| Segment | Description |
|---|---|
divider | Separator character (default •) |
newline | Line 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.
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": "|"}
]
| Option | Type | Default | Description |
|---|---|---|---|
colors | bool | true | Enable/disable ANSI colors |
icon | bool | true (false for task_status) | Show/hide the segment's icon |
icon_color | string | — | Custom icon color |
label | string | — | Custom label replacing the default icon |
style | string | — | 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)).
| Option | Type | Default | Description |
|---|---|---|---|
dirty | bool or string | false | Show dirty indicator. true for default *, or a custom string |
dirty_color | string | red | Color of the dirty indicator |
| Option | Type | Default | Description |
|---|---|---|---|
warm_color | string | green | Color of the ♨ icon and the warm state |
cold_color | string | yellow | Color of the ♨ icon and the cold state |
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):
| Option | Type | Default | Description |
|---|---|---|---|
show_countdown | bool | true | Show the time left |
show_time | bool | false (true for cache_warm) | Show the clock time after the countdown, or alone when the countdown is off |
time_format | string | 24h | 24h for 16:00, 12h for 4:00pm |
| Option | Type | Default | Description |
|---|---|---|---|
within | string 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 |
details | bool | true | cache_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}]
| Option | Type | Default | Description |
|---|---|---|---|
capitalize | bool | true | Capitalise the first letter of the nickname |
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
Set the divider field in settings to change the default divider character:
{
"divider": "|"
}
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
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
}
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.
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 cookiegit_branch, git_ahead_behind, git_stash — run git commands in the project directoryIf 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.
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.
| Segment | Description |
|---|---|
task_name | Subagent name with ⚙ icon |
| task_status | Task status (`runnin
hooks/register.tsx 889 lines1import { 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}
889hooks/activity.ts 279 lines1import 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
279hooks/binary.ts 45 lines1import 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}
45hooks/cache.ts 130 lines1import 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}
130hooks/draw.tsx 108 lines1import 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}
108hooks/input.ts 107 lines1import 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}
107hooks/usage.ts 155 lines1import 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 })
155hooks/util.ts 24 lines1export 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] ?? ''
24hooks/pills.ts 23 lines1export 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}
23hooks/limits.ts 48 lines1import 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}
48types/index.d.ts 111 lines1export 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