Claude Code status line plugin: context %, session cost, budget, context and rate-limit alerts, /cockpit dashboard with rate limits, burn rate and MCP health…

cockpit for Claude Code is a plugin that puts context usage and session cost in the status line, alerts you when cost, context or a rate limit crosses a line you set, adds a /cockpit dashboard with rate-limit reset times, burn rate and MCP server health, and blocks rm -rf and other recursive deletes in Dropbox, iCloud Drive, Google Drive and OneDrive folders, and in any folder you protect.
It is a community plugin by Izzatullah Mustafa (@izzatum on GitHub), not affiliated with Anthropic, and not related to the Cockpit Linux web console. It is for developers who use Claude Code in a terminal, especially with projects inside a cloud-synced folder. The GitHub repo izzatum/claude-code-cockpit is also its plugin marketplace. Free and open source under the MIT license. Version 0.4.0 (the source of truth is plugin.json). Tested on Claude Code 2.1.295.
┌─ Claude Code ──────────────────────────────────────────┬────────────────────────────────────┐
│ │ │
│ ❯ fix the login bug │ web-app │
│ │ ~/code/web-app │
│ ✓ Read /Users/you/code/web-app/src/login.ts │ │
│ ✓ Read /Users/you/code/web-app/src/auth.ts │ Context │
│ ▲ compact rows │ ██████░░░░░░░░░░░░░░ 31% │
│ ⏺ Edit src/login.ts │ 62k / 200k tokens │
│ + if (!token) return redirect('/login') │ │
│ │ Cost │
│ ✻ web-app · Sauteing… ◀─ spinner tag │ $0.84 of $5.00 alert │
│ │ $1.12/h · alert in ~3h43m │
│ │ │
│ │ Rate limits │
│ │ five_hour ██░░░░░░ 28% · 2h05m │
│ │ │
│ │ Modes │
│ │ caveman │
│ │ │
│ │ MCP servers │
│ │ 12 ok · 2 need sign-in · 1 failed │
│ │ ✗ my-server │
│ │ ! wiki, mail │
│ ────────────────────────────────────────────────────── │ │
│ ❯ │ Cloud-sync guard │
│ web-app · ctx 31% · $0.84 · caveman ◀─ status line │ nothing blocked │
└────────────────────────────────────────────────────────┴────────────────────────────────────┘
Left: the conversation, with finished file reads shrunk to one line, the working spinner tagged with the project name, and the cockpit status line under the prompt. Right: the /cockpit dashboard pane.
cockpit is a "mod": a Claude Code plugin built from function hooks that draw into Claude Code's own interface (status line, panes, pop-ups). That hook API is early access, so a Claude Code update can break cockpit until cockpit is updated. Check your version with claude --version.
/plugin install cockpit --marketplace izzatum/claude-code-cockpit
That's it. The status line appears at the bottom of the screen. Type /cockpit to open the dashboard.
claude plugin install cockpit --marketplace izzatum/claude-code-cockpit
This adds the marketplace if needed, then installs cockpit. To set an option at the same time, add --config, for example --config budget=10. Then start a new session, or run /reload-plugins in one that is already open.
| Feature | Where you see it | What it does |
|---|---|---|
| Status line | Under the prompt | Project name, context used, session cost and active modes, always visible. Example: web-app · ctx 31% · $0.84 · caveman. |
| Dashboard | /cockpit pane | Context bar with token count, cost against your budget with the burn rate per hour, rate limits with reset times, active modes, MCP server health and how many commands the guard blocked. See Use the dashboard. |
| Budget alert | Pop-up | One pop-up when the session's cost reaches your budget (default $5), and the dashboard's cost turns red. See Settings. |
| Context alert | Pop-up | One pop-up when the context window reaches your line (default 80%), suggesting /compact; it re-arms once context drops back under. |
| Rate-limit alert | Pop-up | One pop-up per rate-limit window, such as the 5-hour limit, when it reaches your line (default 90% used), with the time it resets. /clear does not repeat it for the same window. |
| Cloud-sync guard | Every Bash and Monitor command Claude runs | Blocks recursive deletes (rm -rf, find -delete, rsync --delete, git clean -f and more) in Dropbox, iCloud Drive, Google Drive and OneDrive folders, and in folders you list in guardPaths, and warns before installs and builds in synced folders. See how the guard decides. |
| MCP server health | Dashboard, plus one pop-up | Counts MCP servers that are connected, need sign-in, or failed. One pop-up if the session's first check finds a failed server. |
| Compact tool rows | Conversation | A finished Read call (and Glob or Grep search, on Claude Code builds that have those tools) shrinks to one dim line. |
| Spinner tag | Conversation | The working spinner shows which project you are in. |
/cockpit; type it again to close it. It works while Claude is busy too.Cockpit is open but not drawn with the reason; make the window taller./tui fullscreen. In a wide window the dashboard docks on the right as a sidebar; in a narrower one it opens above the prompt. cockpit also runs in the desktop app's Code tab.The context bar turns yellow at 60% and red at 80%, with the token count underneath, such as 62k / 200k tokens.
Under the cost, after the first 5 minutes, the dashboard shows the session's burn rate, such as $1.12/h, and while you are under your budget, about how long until you reach it: alert in ~3h43m. Each rate limit shows the time left until it resets, such as 2h05m.
Modes shows the caveman and ponytail plugins while they are on, with a level such as caveman:ultra or ponytail:full, and mem while the claude-mem plugin is enabled. These are separate plugins; cockpit only reports them. The status line joins modes with + (for example caveman+ponytail:full) and leaves them out when none are on; the dashboard says none.
Open them with:
/plugin configure cockpit@claude-code-cockpit
| Setting | Default | What it means |
|---|---|---|
budget | 5 | Cost alert in US dollars. One pop-up when the session's cost reaches it, and the dashboard's cost turns red. It fires once per session and budget value; /clear or a new value arms it again. 0 turns it off, and a blank value means 5. |
contextAlert | 80 | Context alert, in percent of the context window. One pop-up when context reaches it; it re-arms when a /compact or /clear brings context back under. 0 turns it off. |
rateAlert | 90 | Rate-limit alert, in percent used. One pop-up per rate-limit window when it reaches it, with the reset time; /clear does not repeat it for the same window. A limit reported with no reset time alerts again only after it drops back under the line. 0 turns it off. |
guardPaths | empty | Extra folders the guard protects, split by ;, such as ~/work; /Volumes/NAS. See Your own protected folders. |
desktopSync | false | Treat ~/Desktop and ~/Documents as synced folders. Turn it on if iCloud Drive's "Desktop & Documents Folders" (or OneDrive folder backup) syncs them. |
compactTools | true | Shrink finished Read rows (and Glob or Grep, where available) to one dim line. false keeps Claude Code's normal rows. |
projectTags | empty | Friendly project names, as needle=Label pairs. See below. |
If you edit settings.json by hand, use numbers for budget, contextAlert and rateAlert, and true or false for desktopSync and compactTools.
By default cockpit shows the project's folder name, such as web-app. The project is the folder the session started in, so a cd inside it does not change the name. To show your own labels, set projectTags to needle=Label pairs separated by ;:
acme=Acme Corp; client-x=Client X
Any project whose path contains acme (in any letter case) now shows Acme Corp in the status line, the dashboard and the spinner.
me would match everything under /Users/me.= ends the needle, so a label may contain =. A label cannot contain ;. An entry without both a needle and a label is skipped.The cloud-sync guard is a check that runs before every Bash or Monitor command Claude issues and blocks recursive deletes (rm -rf, find -delete, rsync --delete, git clean -f) in a Dropbox, iCloud Drive, Google Drive or OneDrive folder. Sync apps copy every change to all your devices: a folder deleted in one place is deleted everywhere, and an install into node_modules means thousands of uploads. The guard acts only when the command names a synced folder, or Claude's shell is already in one.
| Sync app | Paths it recognises |
|---|---|
| Dropbox | ~/Library/CloudStorage/Dropbox…, or a Dropbox or Dropbox (Team) folder anywhere in the path |
| Google Drive | ~/Library/CloudStorage/GoogleDrive…, or a Google Drive folder anywhere in the path |
| OneDrive | ~/Library/CloudStorage/OneDrive…, or a OneDrive or OneDrive - Org folder anywhere in the path |
| iCloud Drive | ~/Library/Mobile Documents/ |
| iCloud Desktop & Documents, OneDrive folder backup | ~/Desktop and ~/Documents, only with desktopSync on |
Folder names need the sync app's own capitals (Dropbox, not dropbox), so a dropbox package or SDK folder is left alone. Shell-escaped spaces (Google\ Drive), Linux paths (/home/you/Dropbox) and Windows paths (C:\Users\you\Dropbox) count.
| Command | Result |
|---|---|
rm -r, rm -rf, rm -R, rm -f -r, rm --recursive (the flag can come anywhere before --), git rm -r, find … -delete, find … -exec rm -rf, rsync --delete (and its --delete-… variants), git clean -f or --force | Blocked. Claude is told why, asked not to retry another way, and asked to leave the delete to you. |
npm, pnpm, yarn or bun with install, i, ci, add, build, run build or run-script build; bare yarn; pip install; pip3 install; cargo build | Allowed, with a warning pop-up suggesting a local, unsynced clone. |
git rm -r --cached …, git clean -n (dry run), rm file.txt, docker run --rm, rm -rf written inside a commit message, a grep pattern, an echo string or a heredoc saved to a file, anything outside synced folders | Allowed, no message. |
It also catches these commands behind sudo, xargs, bash -c, && and similar.
sudo, doas, env, nice, timeout, xargs, time, nohup, command, builtin, exec and eval;;, &&, ||, |, (, {, !, $( and backticks, and after if, then, elif, else, do, while and until;bash -c "…", sh -c '…' (also zsh, dash, ksh, and flags like -lc), and in heredocs that a shell reads (bash <<EOF, cat <<EOF | sh);NAME=value prefixes such as CI=1 npm ci, and when called by path (/bin/rm, \rm, 'rm').To block recursive deletes in other folders too, such as a work folder, a network drive or a backup disk, list them in guardPaths, split by ;:
~/work; /Volumes/NAS
The guard then treats these folders like synced ones: a recursive delete is blocked while Claude works inside one or when a command names one. Installs and builds there go ahead without a warning, since nothing uploads them.
~, $HOME, ${HOME} (also ${HOME:?}) or the full home path for your home folder (in the setting and in commands), either slash, any letter case, quoted or not ("$HOME"/work), with shell-escaped spaces (My\ Projects), and Windows paths also as Git Bash writes them (/c/Users/you, in the setting too). Only whole folder names count: ~/work covers ~/work/app but not ~/workshop.rm -rf work from your home folder), a parent folder (rm -rf ~), a brace list (~/{work,old}) or paths a delete reads from a heredoc. See Limits and caveats.If the guard itself fails, it fails closed: any recursive delete is refused, judged on the command text alone in any folder, and other commands go ahead.
$HOME, a folder named relative to the shell's folder (rm -rf work, ../work), a parent of a guarded folder (rm -rf ~), a brace list (~/{work,old}), paths a delete reads from a heredoc or a loop, a lowercase path, an alias, and deletes done by a script (Python, Node) or by mv out of the folder. macOS iCloud "Desktop & Documents" sync (~/Desktop, ~/Documents) cannot be told from the path, so it is covered only when you turn on desktopSync. Now and then it blocks a harmless command whose quoted text reads like a delete after a ; or a word such as if or then./tmp. The same goes for a command that names a synced path anywhere.budget to 0.claude mcp list in the background 30 seconds after a session starts, and when you open /cockpit, at most once every 10 minutes. A claude -p run, which draws nothing, skips it. That command briefly starts each configured MCP server and can take up to 2 minutes. Servers that are not configured or are waiting for your approval are left out of the counts.Claude Code already shows some of this on request, and other community tools cover parts of it. Many people use several together.
| cockpit | Built-in /cost | Built-in /context | Your own statusLine script | |
|---|---|---|---|---|
| Context % always visible | Yes | No | On request | If you script it |
| Session cost always visible | Yes | On request | No | If you script it |
| Budget alert pop-up | Yes | No | No | No |
| Context alert pop-up | Yes | No | No | No |
| Rate-limit alert with reset time | Yes | No | No | No |
| MCP server health | Dashboard + pop-up | No | No | If you script it |
| Cost history across days and months | No | No | No | No |
| Blocks recursive deletes in synced folders | Yes | No | No | No |
| Setup | Plugin install | Built in | Built in | Write and maintain a script |
Other community tools: ccusage is a CLI that reports Claude Code token usage and cost from your local logs, by day, month and session, so use it for cost history, which cockpit does not keep. ccstatusline is a configurable status line for Claude Code. General command-guard hooks apply rules to dangerous commands in every folder, while cockpit's guard acts only in synced folders.
Claude Code's built-in /context command shows context usage on request. To keep it on screen, the cockpit plugin for Claude Code adds ctx 31% to the status line, and /cockpit shows a bar that turns yellow at 60% and red at 80%, with the token count underneath, such as 62k / 200k tokens.
Run Claude Code's built-in /cost command for a one-off check. To see it all the time, the cockpit plugin shows the session's cost in the status line (for example $0.84) and in the /cockpit dashboard, updated after each turn. It is Claude Code's own estimate, not a bill. For cost across days or months, use a usage-report CLI such as ccusage.
Set cockpit's budget option to an amount in US dollars (default 5) with /plugin configure cockpit@claude-code-cockpit. When the session's cost reaches it, cockpit shows one pop-up and turns the dashboard's cost red. See Settings.
cockpit shows Claude Code's own session cost estimate as-is; it is not a bill, on a subscription or otherwise. If the figure is not useful to you, set budget to 0 to turn the alert off; the status line and dashboard still show it.
Yes. Claude Code can run rm -rf in any folder it is allowed to work in, and the sync app then deletes those files on every linked device. cockpit's cloud-sync guard blocks recursive deletes in these folders and asks Claude to leave the delete to you. See how the guard decides.
It works, with two risks. The sync app copies every change to all your devices, so a recursive delete Claude runs there removes those files everywhere, and an install or build (npm install, pip install, cargo build) can queue thousands of uploads. The safest setup is a local clone outside the synced folder. If you do work inside one, cockpit's guard blocks recursive deletes there and warns before installs and builds. See Limits and caveats for what it cannot see.
Add a deny rule for rm -rf to the permissions section of Claude Code's settings.json, or use a hook that checks each command before it runs. A deny rule matches the command's wording, so a delete written another way (rm -r -f, find -delete, a script) can get past it; a hook can read the whole command. cockpit's guard is one such hook, for cloud-synced folders.
cockpit's guard is a function hook that Claude Code runs before every Bash and Monitor command, like a PreToolUse hook in settings.json, so a command it matches is refused whatever the model decides. A CLAUDE.md rule is an instruction the model usually follows but can miss in a long session. Commands the guard does not recognise still run; see Limits and caveats.
Claude Code's statusLine setting in settings.json runs a script you write and shows its output under the prompt. A plugin can provide one instead: cockpit's status line shows the project, context %, session cost and active modes, with no script to maintain.
When Claude Code reports rate limits for your se
hooks/register.tsx 331 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { McpHealth, RateLimit, Snapshot } from '../types'
5import {
6 GUARD_FAILED,
7 bar,
8 budgetCrossed,
9 burnText,
10 compactLabel,
11 contextCrossed,
12 folderMatcher,
13 isEnabled,
14 isRecursiveDelete,
15 modesOf,
16 money,
17 numberOf,
18 parseMcpList,
19 parsePaths,
20 parseTags,
21 projectOf,
22 rateCrossed,
23 resetText,
24 statusText,
25 syncVerdict,
26 tildify,
27} from './logic'
28import type { Tag } from './logic'
29
30// The options register reads, once per load.
31type Config = { budget: number; contextAlert: number; rateAlert: number; tags: Tag[] }
32
33const PANE = 'cockpit'
34const MCP_DELAY_MS = 30_000 // after the session's own MCP startup
35const MCP_TIMEOUT_MS = 120_000
36const MCP_STALE_MS = 10 * 60_000
37const LABEL_W = 11 // longest rate-limit kind: spend_limit
38const EMPTY: Snapshot = { window: 0, limits: [], modes: [], project: '', path: '', guarded: 0 }
39const snap = atom({ plugin: 'cockpit', key: 'snap' } as const, EMPTY)
40const tick = atom({ plugin: 'cockpit', key: 'tick' } as const, 0)
41
42async function readText($: EngineInterface, path: string): Promise<string | undefined> {
43 try {
44 return await $.fs.read(path)
45 } catch {
46 return undefined
47 }
48}
49
50async function homeOf($: EngineInterface): Promise<string | undefined> {
51 return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || undefined
52}
53
54async function readModes($: EngineInterface, home: string | undefined): Promise<string[]> {
55 const [cave, pony, plugins] = await Promise.all([
56 home ? readText($, `${home}/.claude/.caveman-active`) : undefined,
57 home ? readText($, `${home}/.claude/.ponytail-active`) : undefined,
58 $.settings.read().then(s => s.enabledPlugins, () => undefined),
59 ])
60 return modesOf(cave, pony, isEnabled(plugins, 'claude-mem'))
61}
62
63// Reads the figures, redraws the status line and raises the alerts. Never rejects, so callers
64// need no catch.
65async function refresh($: EngineInterface, c: Config, withModes = false): Promise<Snapshot | undefined> {
66 try {
67 const home = await homeOf($)
68 // The root, not the cwd: a shell `cd` into a subfolder does not relabel the project.
69 const [u, root, modes, now] = await Promise.all([
70 $.session.usage(),
71 $.session.root(),
72 withModes ? readModes($, home) : undefined,
73 $.clock.now(),
74 ])
75 const usd = u.cost?.usd
76 const key = `${u.startedAt}:${c.budget}`
77 let isCostAlert = false
78 let isContextAlert = false
79 let rateFired: RateLimit[] = []
80 const s = await update($, snap, prev => {
81 // A new startedAt is a /clear: the session's own counters start over. Rate-limit windows
82 // are the account's, so their alerts stay.
83 const s = prev.startedAt !== undefined && prev.startedAt !== u.startedAt ? { ...prev, guarded: 0, ctxOver: false } : prev
84 isCostAlert = budgetCrossed(usd, c.budget, s.alertedFor, key)
85 const ctx = contextCrossed(u.context.percent, c.contextAlert, s.ctxOver)
86 isContextAlert = ctx.shouldAlert
87 const rate = rateCrossed(u.rateLimits, c.rateAlert, s.rateAlerted ?? [])
88 rateFired = rate.fired
89 return {
90 ...s,
91 startedAt: u.startedAt,
92 pct: u.context.percent,
93 tokens: u.context.tokens,
94 window: u.context.window,
95 usd,
96 limits: u.rateLimits,
97 modes: modes ?? s.modes,
98 path: tildify(root, home),
99 ...projectOf(root, c.tags),
100 alertedFor: isCostAlert ? key : s.alertedFor,
101 ctxOver: ctx.isOver,
102 rateAlerted: rate.alerted,
103 }
104 })
105 $.ui.status(statusText(s))
106 if (isCostAlert) $.ui.toast(`cockpit: session cost ${money(usd)} reached your ${money(c.budget)} alert`)
107 if (isContextAlert) {
108 $.ui.toast(`cockpit: context is ${Math.round(s.pct ?? 0)}% full (alert at ${c.contextAlert}%): /compact frees room, /clear starts fresh`)
109 }
110 for (const l of rateFired) {
111 const reset = resetText(l.resetsAt, now)
112 $.ui.toast(`cockpit: ${l.kind} rate limit ${Math.round(l.percentUsed)}% used${reset ? `, ${reset}` : ''}`)
113 }
114 return s
115 } catch (err) {
116 $.ui.log(`cockpit: refresh failed: ${String(err)}`, { to: 'debug' })
117 return undefined
118 }
119}
120
121// The hook API lists no MCP server status, so ask the CLI. That starts each configured server
122// once, so it runs at most every MCP_STALE_MS, claimed in $.state (which outlives a reload),
123// and never where nothing draws the result (a plain `claude -p` run).
124async function checkMcp($: EngineInterface): Promise<void> {
125 try {
126 if (!(await $.session.surfaces()).length) return
127 const now = await $.clock.now()
128 let prev: McpHealth | undefined
129 let isDue = false
130 await update($, snap, s => {
131 prev = s.mcp
132 const isFresh = s.mcp !== undefined && now - s.mcp.checkedAt < MCP_STALE_MS
133 // A run lost to a reload expires after its timeout.
134 const isRunning = s.mcpSince !== undefined && now - s.mcpSince < MCP_TIMEOUT_MS + 10_000
135 isDue = !isFresh && !isRunning
136 return isDue ? { ...s, mcpSince: now } : s
137 })
138 if (!isDue) return
139 let mcp: McpHealth
140 try {
141 const run = await $.process.run(['claude', 'mcp', 'list'], { timeoutMs: MCP_TIMEOUT_MS })
142 mcp = parseMcpList(run.stdout, await $.clock.now())
143 if (run.exitCode !== 0 && mcp.ok + mcp.auth.length + mcp.failed.length === 0) {
144 mcp.error = run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`
145 }
146 } catch (err) {
147 mcp = { ok: 0, auth: [], failed: [], checkedAt: await $.clock.now(), error: String(err) }
148 }
149 if (mcp.error) $.ui.log(`cockpit: claude mcp list unavailable: ${mcp.error}`, { to: 'debug' })
150 await update($, snap, s => ({ ...s, mcp, mcpSince: undefined }))
151 // Only a session's first result pops up; re-checks and reloads stay quiet.
152 if (!prev && mcp.failed.length > 0) {
153 $.ui.toast(`cockpit: ${mcp.failed.length} MCP server(s) failed to connect: ${mcp.failed.join(', ')}`)
154 }
155 } catch (err) {
156 $.ui.log(`cockpit: MCP check failed: ${String(err)}`, { to: 'debug' })
157 }
158}
159
160// Each minute while the pane shows, so its reset and budget countdowns move between turns.
161async function tickPane($: EngineInterface): Promise<void> {
162 try {
163 if ((await $.ui.panes()).some(p => p.id === PANE && p.isShown)) await update($, tick, n => n + 1)
164 } catch (err) {
165 $.ui.log(`cockpit: pane tick failed: ${String(err)}`, { to: 'debug' })
166 }
167}
168
169export const register: Register = (on, options) => {
170 const text = (value: unknown) => (typeof value === 'string' ? value : undefined)
171 const c: Config = {
172 budget: numberOf(options.budget, 5),
173 contextAlert: numberOf(options.contextAlert, 80),
174 rateAlert: numberOf(options.rateAlert, 90),
175 tags: parseTags(text(options.projectTags)),
176 }
177 const { budget, tags } = c
178 const isCompact = options.compactTools !== false
179 // macOS iCloud "Desktop & Documents Folders" (or OneDrive folder backup) syncs these two.
180 const syncedPaths = options.desktopSync === true ? ['~/Desktop', '~/Documents'] : []
181 const protectedPaths = parsePaths(text(options.guardPaths))
182
183 on('session.start', async ($, e, next) => {
184 void refresh($, c, true)
185 // Later, so it does not compete with the session's own MCP startup. A reload cancels the
186 // timer, and a short `claude -p` run is over before it fires.
187 $.clock.after(MCP_DELAY_MS, () => void checkMcp($))
188 $.clock.every(60_000, () => void tickPane($))
189 await $.command.register({ name: 'cockpit', description: 'Toggle the cockpit dashboard pane', immediate: true })
190 return next(e)
191 })
192
193 on('command.run', { command: 'cockpit' }, async $ => {
194 if ((await $.ui.panes()).some(p => p.id === PANE && p.isShown && p.isPlaced)) {
195 await $.ui.close({ id: PANE })
196 return { text: 'Cockpit closed.' }
197 }
198 const s = (await refresh($, c, true)) ?? (await read($, snap))
199 void checkMcp($)
200 // The pane's rows: 19 fixed, 2 MCP detail lines, and the rate-limit block, kept for the two
201 // subscription windows even before the first reading (the frame shrinks to a shorter tree).
202 // ponytail: a gateway reporting more kinds after the pane opened gets cut; reopen it then.
203 const rows = 21 + 2 + Math.max(s.limits.length, 2)
204 const opened = await $.ui.open({ id: PANE, title: 'Cockpit', rows })
205 return { text: opened.isPlaced ? 'Cockpit opened.' : `Cockpit is open but not drawn: ${opened.reason}` }
206 })
207
208 // After the UserPromptSubmit hooks beneath have run, so a mode one just switched shows.
209 on('prompt.submit', async ($, e, next) => {
210 const done = await next(e)
211 void refresh($, c, true)
212 return done
213 })
214
215 // Pushed after each main-thread turn and when a rate-limit window moves, one at a time.
216 on('session.measure', async ($, e, next) => {
217 await refresh($, c)
218 return next(e)
219 })
220
221 // Cloud-sync guard: every shell command Claude runs, through Bash or Monitor.
222 on('tool.call', { tool: ['Bash', 'Monitor'] }, async ($, e, next) => {
223 const [cwd, home] = await Promise.all([$.session.cwd(), syncedPaths.length || protectedPaths.length ? homeOf($) : undefined])
224 const guard = { synced: folderMatcher(syncedPaths, home), protected: folderMatcher(protectedPaths, home) }
225 const verdict = syncVerdict(e.command ?? '', cwd, guard)
226 if (verdict && 'deny' in verdict) {
227 await update($, snap, s => ({ ...s, guarded: s.guarded + 1 }))
228 return { deny: verdict.deny }
229 }
230 if (verdict) $.ui.toast(verdict.warn)
231 return next(e)
232 }).catch(($, e, next) => {
233 if (next.called) return next(e)
234 // No `$` here (it rejects on re-entry): judge the command text alone.
235 const command = 'command' in e && typeof e.command === 'string' ? e.command : ''
236 return isRecursiveDelete(command) ? { deny: GUARD_FAILED } : next(e)
237 })
238
239 // Restyle: the spinner carries the project tag, read from the root so no state write redraws it.
240 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
241 const { tag, project } = projectOf(await $.session.root(), tags)
242 const label = tag ?? project
243 if (!label) return next(e)
244 const p = e.props
245 const props = p.message === null ? { ...p, word: `${label} · ${p.word}` } : { ...p, message: `${label} · ${p.message}` }
246 return next({ ...e, props })
247 })
248
249 // Restyle: finished read-only calls (the tools compactLabel knows) as one dim line.
250 if (isCompact) {
251 on(
252 'ui.render',
253 { component: 'ToolUse', props: { tool: ['Read', 'Glob', 'Grep'], isRunning: false, isErrored: false, isInterrupted: false } },
254 async ($, e, next) => {
255 const label = compactLabel(e.props.tool, e.props.input)
256 if (!label) return next(e)
257 const { Text } = $.ui.resolve(e)
258 return <Text dimColor wrap="truncate-middle">{label}</Text>
259 },
260 )
261 }
262
263 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
264 const { Box, Text } = $.ui.resolve(e)
265 const [s, now] = await Promise.all([read($, snap), $.clock.now(), read($, tick)])
266 const cols = e.props.bodyColumns
267 const width = Math.max(10, Math.min(40, cols - 14))
268 // label, space, bar, space, up to "100%", then " · 2h05m" when a reset time is known
269 const hasReset = s.limits.some(l => resetText(l.resetsAt, now))
270 const rateWidth = Math.max(4, Math.min(20, cols - LABEL_W - 6 - (hasReset ? 9 : 0)))
271 const burn = burnText(s.usd, s.startedAt, now, budget)
272 const pct = s.pct ?? 0
273 const ctxColor = pct >= 80 ? 'error' : pct >= 60 ? 'warning' : 'success'
274 const isOver = budget > 0 && (s.usd ?? 0) >= budget
275
276 return (
277 <Box flexDirection="column">
278 <Text bold>{s.tag ? `${s.tag} · ${s.project}` : s.project || '—'}</Text>
279 <Text dimColor wrap="truncate-start">{s.path}</Text>
280 <Text> </Text>
281 <Text bold>Context</Text>
282 <Text>
283 <Text color={ctxColor}>{bar(pct, width)}</Text> {s.pct === undefined ? '—' : `${Math.round(pct)}%`}
284 </Text>
285 <Text dimColor>
286 {s.tokens === undefined ? '' : `${Math.round(s.tokens / 1000)}k / ${Math.round(s.window / 1000)}k tokens`}
287 </Text>
288 <Text> </Text>
289 <Text bold>Cost</Text>
290 <Text color={isOver ? 'error' : undefined}>
291 {money(s.usd)}
292 {budget > 0 ? ` of ${money(budget)} alert` : ''}
293 </Text>
294 <Text dimColor>{burn ?? ''}</Text>
295 {s.limits.length > 0 && <Text> </Text>}
296 {s.limits.length > 0 && <Text bold>Rate limits</Text>}
297 {s.limits.map(l => {
298 const reset = resetText(l.resetsAt, now)?.replace(/^resets (in )?/, '')
299 return (
300 <Text wrap="truncate-end">
301 {l.kind.padEnd(LABEL_W)} {bar(l.percentUsed, rateWidth)} {Math.round(l.percentUsed)}%
302 {reset ? <Text dimColor>{` · ${reset}`}</Text> : ''}
303 </Text>
304 )
305 })}
306 <Text> </Text>
307 <Text bold>Modes</Text>
308 <Text>{s.modes.length ? s.modes.join(' · ') : 'none'}</Text>
309 <Text> </Text>
310 <Text bold>MCP servers</Text>
311 {!s.mcp && <Text dimColor>checking…</Text>}
312 {s.mcp?.error && <Text dimColor>unavailable: run `claude mcp list` to see why</Text>}
313 {s.mcp && !s.mcp.error && (
314 <Text>
315 <Text color="success">{s.mcp.ok} ok</Text>
316 {' · '}
317 <Text color={s.mcp.auth.length ? 'warning' : undefined}>{s.mcp.auth.length} need sign-in</Text>
318 {' · '}
319 <Text color={s.mcp.failed.length ? 'error' : undefined}>{s.mcp.failed.length} failed</Text>
320 </Text>
321 )}
322 {s.mcp && s.mcp.failed.length > 0 && <Text color="error" wrap="truncate-end">✗ {s.mcp.failed.join(', ')}</Text>}
323 {s.mcp && s.mcp.auth.length > 0 && <Text dimColor wrap="truncate-end">! {s.mcp.auth.join(', ')}</Text>}
324 <Text> </Text>
325 <Text bold>Cloud-sync guard</Text>
326 <Text dimColor>{s.guarded ? `${s.guarded} command(s) blocked` : 'nothing blocked'}</Text>
327 </Box>
328 )
329 })
330}
331hooks/logic.ts 316 lines1// Pure helpers: no `$`, so tests run them directly.
2import type { McpHealth, RateLimit, Snapshot } from '../types'
3
4export type Verdict = { deny: string } | { warn: string } | undefined
5
6// Folders a sync client uploads as they change: Dropbox, iCloud Drive, Google Drive, OneDrive.
7// The bare names also match `Dropbox (Team)`, `OneDrive - Org`, shell-escaped spaces and
8// Windows backslashes. Case-sensitive on purpose: a `dropbox` SDK folder is not the sync root.
9// A bare name counts after a slash (then up to a brace, comma, backtick or redirect too), or
10// as a whole word, so `new Dropbox(` or `-t Dropbox,OneDrive` in code or prose is not a folder.
11const SYNC_NAME = String.raw`(Dropbox( \([^)\/\\]*\))?|OneDrive( - [^\/\\"']*)?|Google\\? Drive)`
12const SYNCED = new RegExp(
13 String.raw`[\/\\]Library[\/\\](CloudStorage[\/\\](Dropbox|GoogleDrive|OneDrive)|Mobile\\? Documents)` +
14 String.raw`|[\/\\]${SYNC_NAME}(?=$|[\/\\\s"';&|)<>},\x60])|(^|[\s"'=])${SYNC_NAME}(?=$|[\/\\\s"';&|)<>])`,
15)
16
17// Where a command word starts: the line, after ; & | ( { ` $( or !, after a keyword or
18// wrapper (if, then, do, sudo, xargs, time, find -exec…), inside `sh -lc '…'` / `eval "…"`,
19// past `VAR=value` assignments, and past the command's own path (`/bin/rm`). So `rm -r`
20// quoted in a commit message, grep pattern or echo is not a command. Every step is bounded
21// (10 words or options, 200 characters of a value or path part, the path stops at ( { !)
22// or splits one way only, so long code or data cannot stall the match.
23const CMD = String.raw`(?:^|[;&|\n({!\x60]|\$\(|-exec(?:dir)?\s|\b(?:if|elif|then|do|else|while|until|time|nohup|builtin|command|exec|eval)(?:\s+-\S+){0,10}\s|\b(?:sudo|doas|env|nice|timeout|xargs)(?:\s+[^\s;&|]+){0,10}?\s|\b(?:ba|z|da|k)?sh(?:\s+-[a-zA-Z]+){0,10}\s+-(?=[a-zA-Z]*c)[a-zA-Z]+(?:\s+--)?\s)[^\S\n]*(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|[^\s;&|"']{0,200})\s+){0,10}["']?\\?(?:[^\s;&|"'\x60(){}!/]{0,200}\/)*`
24// Up to 10 of git's global options, those whose value stands apart (`-C dir`) too.
25const GIT_ARG = String.raw`(?:-c|--(?:git-dir|work-tree|namespace|exec-path|super-prefix|config-env))(?=\s)`
26const GIT = String.raw`git\s+(?:${GIT_ARG}\s+(?:"[^"]*"|'[^']*'|[^\s"']\S*)\s+|(?!${GIT_ARG})--?\w[\w-]*(?:=\S*)?\s+){0,10}`
27// A $(…) or `…` on the line is skipped whole: its words are another command's, not rm's flags.
28const SUB = String.raw`\$\([^()\n]*\)|\x60[^\x60\n]*\x60`
29// A recursive flag anywhere in this rm (before `--` or find's `{} +`), except `git rm --cached` (index-only).
30const RM = String.raw`rm["']?(?=\s)(?![^;&|\n]*--cached\b)(?:${SUB}|(?!\s--\s|\s\{\}\s+\+|${SUB})[^;&|\n])*?\s["']?(?:-[a-z]*r[a-z]*|--recursive)\b`
31const FIND = String.raw`find\s[^;&|\n]*\s-delete\b`
32const RSYNC = String.raw`rsync\s[^;&|\n]*\s--delete`
33const GIT_CLEAN = String.raw`clean\b(?![^;&|\n]*\s(?:-[a-z]*n|--dry-run\b))[^;&|\n]*\s(?:-[a-z]*f|--force\b)`
34// ponytail: regex over shell text, a tripwire, not a sandbox. Misses symlinks, globs,
35// lowercase paths, flags in variables, aliases, in-word escapes (r\m) and scripts
36// (python, node, mv out of the folder). Each `rm`, `find` or `rsync` that starts a command
37// scans the rest of its segment, so quoted prose full of `(rm ` or `do rm` stays quadratic.
38// A shell tokenizer if it ever matters.
39const DELETE = new RegExp(`${CMD}(?:${RM}|${FIND}|${RSYNC})|${CMD}${GIT}(?:${RM}|${GIT_CLEAN})`, 'i')
40const HEAVY = new RegExp(
41 CMD +
42 String.raw`(?:(?:npm|pnpm|yarn|bun)\s+(?:install|i|ci|add|(?:run(?:-script)?\s+)?build)\b|yarn(?:\s+-[\w-]+)*\s*(?=$|[;&|\n)])|pip3?\s+install\b|cargo\s+build\b)`,
43)
44// A heredoc opener (`<<EOF`, `<<-'EOF'`), not a here-string (`<<<`).
45const OPENER = /(?<!<)<<(?!<)-?[ \t]*(['"]?)(\w+)\1/g
46// A shell that reads the body: one standing as a command word, not a name such as `clean.sh`.
47const SHELL = new RegExp(CMD + String.raw`(?:(?:ba|z|da|k)?sh|eval|source)\b`, 'i')
48
49const DENY =
50 'cockpit: recursive delete inside a cloud-synced folder is blocked: the sync app would delete it on every device. Do not retry another way (find -delete, rsync, mv, a script). Tell the user what you wanted to delete and let them do it.'
51const DENY_PROTECTED =
52 'cockpit: recursive delete inside a folder the user protected (cockpit guardPaths) is blocked. Do not retry another way (find -delete, rsync, mv, a script). Tell the user what you wanted to delete and let them do it.'
53export const GUARD_FAILED =
54 'cockpit: the cloud-sync guard could not check this recursive delete, so it was not run. Tell the user what you wanted to delete and let them do it.'
55
56// The command as the guard reads it: heredoc data bodies out, and, where a `\` ends a line,
57// also the lines joined (`find x \` then `-delete`). The joined reading leaves comments out,
58// since a `\` that ends one continues nothing; reading both only ever adds matches.
59function code(command: string): string {
60 const kept = bodies(command)
61 if (!/\\\r?\n/.test(kept)) return kept
62 const uncommented = kept.replace(/("[^"\n]*"|'[^'\n]*')|(^|\s)#[^\n]*/g, (_m, q: string | undefined, sp: string) => q ?? sp)
63 return `${kept}\n${uncommented.replace(/\\\r?\n/g, ' ')}`
64}
65
66// The command without heredoc bodies, which are data unless a shell reads them (`bash <<EOF`,
67// `<<EOF | sh`). One pass over the lines, so a long command cannot stall the guard; a body
68// that never closes stays in, as code.
69function bodies(command: string): string {
70 if (!command.includes('<<')) return command
71 const kept: string[] = []
72 let body: string[] = []
73 let ends: string[] = [] // closing words still to come
74 let isData = false
75 for (const line of command.split('\n')) {
76 if (!ends.length) {
77 kept.push(line)
78 ends = Array.from(line.matchAll(OPENER), m => String(m[2]))
79 isData = !SHELL.test(line)
80 continue
81 }
82 if (isData) body.push(line)
83 else kept.push(line)
84 if (line.trim() === ends[0]) ends.shift()
85 if (!ends.length) body = []
86 }
87 return kept.concat(body).join('\n')
88}
89
90// Fails closed: a guard that cannot read the cwd still refuses any recursive delete.
91export const isRecursiveDelete = (command: string): boolean => DELETE.test(code(command))
92
93// Folders from settings, as matchers: `synced` count as the sync apps' own (deletes blocked,
94// installs warned), `protected` only block recursive deletes.
95// `cwd` tests the shell's folder, one exact path; `text` tests the command.
96export type Matcher = { cwd: RegExp; text: RegExp }
97export type Guard = { synced?: Matcher; protected?: Matcher }
98
99export function syncVerdict(command: string, cwd: string, guard: Guard = {}): Verdict {
100 const run = code(command)
101 // For paths: quotes and space escapes gone, so `"$HOME"/work` and `My\ Projects` read whole.
102 const bare = run.replace(/\\(?=[\s"'])/g, '').replace(/["']/g, '')
103 // The text as written too: a quote can be the only boundary (`{Dropbox,"Google Drive"}`).
104 const names = (m?: Matcher) => !!m && (m.cwd.test(cwd) || m.text.test(run) || m.text.test(bare))
105 const isSynced = names({ cwd: SYNCED, text: SYNCED }) || names(guard.synced)
106 if (!isSynced && !names(guard.protected)) return undefined
107 if (DELETE.test(run)) return { deny: isSynced ? DENY : DENY_PROTECTED }
108 if (isSynced && HEAVY.test(run)) {
109 return { warn: 'cockpit: cloud-synced folder: installs and builds here upload node_modules and build output. A local, unsynced clone avoids it.' }
110 }
111 return undefined
112}
113
114// "~/work; /Volumes/NAS" -> ["~/work", "/Volumes/NAS"]; space escapes (`My\ Projects`) and
115// trailing slashes dropped, blanks skipped.
116export function parsePaths(spec: string | undefined): string[] {
117 return (spec ?? '')
118 .split(';')
119 .map(p => p.trim().replace(/\\(?= )/g, '').replace(/(.)[\\/]+$/, '$1'))
120 .filter(Boolean)
121}
122
123const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
124
125// Where a path starts and ends in shell text: `~/work/x` and `~/work` both name `~/work`.
126const START = String.raw`(?:^|[\s"'=(:{,\x60])`
127const SEP = String.raw`[\/\\]+`
128const END = String.raw`(?=$|[\/\\\s"';&|)<>},\x60])`
129// A cwd is one exact path: a space, comma or quote there is part of the folder's name.
130const CWD_END = String.raw`(?=$|[\/\\])`
131// `$HOME`, `${HOME}`, and short expansions such as `${HOME:?}` or `${HOME%/}` (bounded, so
132// a long run of `${HOME:` cannot make the match quadratic).
133// (No literal slash in it: form() turns each slash into a separator class.)
134const HOME_VAR = String.raw`\$HOME|\$\{HOME(?:[:%#\x2f?-][^}\s]{0,20})?\}`
135
136// A matcher for folders from settings, as a cwd or a command names them. A folder under the
137// home folder matches as `~`, `$HOME`, `${HOME}` or the home path (any letter case), Windows
138// paths also as Git Bash writes them (/c/Users), with either slash, doubled or not, and only
139// as a whole folder name: `~/work` matches `~/work/app`, not `~/workshop`.
140// ponytail: names only, like the sync apps' folders. A relative name, a parent folder, a brace
141// list or paths fed from a heredoc are not followed; a shell tokenizer if that ever matters.
142export function folderMatcher(paths: string[], home?: string): Matcher | undefined {
143 const h = home?.replace(/[\\/]+$/, '') || undefined
144 const drive = (p: string) => (/^[A-Za-z]:/.test(p) ? `(?:${escape(p)}|/${p[0]}${escape(p.slice(2))})` : escape(p))
145 const lower = (p: string) => p.replace(/\\/g, '/').toLowerCase()
146 const homeAlt = String.raw`(?:~|${HOME_VAR}${h ? `|${drive(h)}` : ''})`
147 // "~/x", "$HOME/x" or "<home>/x" -> "/x"; undefined for a folder outside home.
148 const restOf = (path: string): string | undefined => {
149 const lead = new RegExp(String.raw`^(?:~|${HOME_VAR})(?=[\\/]|$)`).exec(path)?.[0]
150 if (lead !== undefined) return path.slice(lead.length)
151 const isUnder = h && lower(path).startsWith(lower(h)) && /^([\\/]|$)/.test(path.slice(h.length))
152 return isUnder ? path.slice(h.length) : undefined
153 }
154 const form = (path: string) => {
155 const rest = restOf(path)
156 // A quote in a folder's name may be gone from the quote-stripped reading (`Tom's`).
157 return (rest === undefined ? drive(path) : homeAlt + escape(rest)).replace(/(?:\\\\|\/)+/g, SEP).replace(/["']/g, '$&?')
158 }
159 // A Git Bash entry ("/c/Users/x") also as "c:/Users/x", so it meets Windows cwds and home.
160 const all = paths.flatMap(p => (/^\/[A-Za-z](?=[\\/]|$)/.test(p) ? [p, `${p[1]}:${p.slice(2)}`] : [p]))
161 // A root (`/`) covers everything below it, so it needs no end.
162 const forms = all.map(form)
163 const join = (end: string) => new RegExp(`${START}(?:${forms.map(f => (f === SEP ? f : f + end)).join('|')})`, 'i')
164 return forms.length ? { cwd: join(CWD_END), text: join(END) } : undefined
165}
166
167export type Tag = [needle: string, label: string]
168
169// "acme=Acme Corp; client-x=Client X" -> pairs, split at the first '='; a bad entry is skipped.
170export function parseTags(spec: string | undefined): Tag[] {
171 return (spec ?? '').split(';').flatMap(part => {
172 const i = part.indexOf('=')
173 const needle = part.slice(0, i).trim()
174 const label = part.slice(i + 1).trim()
175 return i > 0 && needle && label ? [[needle, label] as Tag] : []
176 })
177}
178
179// The needle is matched anywhere in the full path, parent folders included.
180export function projectOf(path: string, tags: Tag[]): { project: string; tag?: string } {
181 const lower = path.toLowerCase()
182 const tag = tags.find(([needle]) => lower.includes(needle.toLowerCase()))?.[1]
183 const project = path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
184 return { project, tag }
185}
186
187// "/Users/me/code/app" -> "~/code/app"; a sibling such as /Users/meg is left alone.
188export function tildify(path: string, home?: string): string {
189 const h = home?.replace(/[\\/]+$/, '')
190 const rest = h && path.startsWith(h) ? path.slice(h.length) : undefined
191 return rest !== undefined && /^([\\/]|$)/.test(rest) ? `~${rest}` : path
192}
193
194// A mode file holds one short word (`full`, `lite`); anything else is ignored, never drawn.
195function modeWord(text?: string): string | undefined {
196 const word = text?.split('\n')[0]?.trim()
197 return word && word !== 'off' && /^[\w:-]{1,24}$/.test(word) ? word : undefined
198}
199
200export function modesOf(caveman?: string, ponytail?: string, hasMem = false): string[] {
201 const modes: string[] = []
202 const cave = modeWord(caveman)
203 if (cave) modes.push(cave === 'caveman' ? 'caveman' : `caveman:${cave}`)
204 const pony = modeWord(ponytail)
205 if (pony) modes.push(`ponytail:${pony}`)
206 if (hasMem) modes.push('mem')
207 return modes
208}
209
210// settings' enabledPlugins: { "claude-mem@market": true }.
211export function isEnabled(enabledPlugins: unknown, plugin: string): boolean {
212 if (typeof enabledPlugins !== 'object' || enabledPlugins === null) return false
213 return Object.entries(enabledPlugins).some(([id, isOn]) => id.startsWith(`${plugin}@`) && isOn === true)
214}
215
216export function money(usd?: number): string {
217 return usd === undefined ? '$—' : `$${usd.toFixed(2)}`
218}
219
220export function statusText(s: Pick<Snapshot, 'pct' | 'usd' | 'modes' | 'project' | 'tag'>): string {
221 const where = s.tag ?? s.project
222 const ctx = s.pct === undefined ? 'ctx —' : `ctx ${Math.round(s.pct)}%`
223 return [where, ctx, money(s.usd), s.modes.join('+')].filter(Boolean).join(' · ')
224}
225
226// Options arrive validated, but a number field stored blank reaches register as ''.
227export const numberOf = (value: unknown, fallback: number): number => (typeof value === 'number' ? value : fallback)
228
229// Once per session and budget: `key` is `${startedAt}:${budget}`, so /clear or a new budget re-arms it.
230export function budgetCrossed(usd: number | undefined, budget: number, alertedFor: string | undefined, key: string): boolean {
231 return budget > 0 && usd !== undefined && usd >= budget && alertedFor !== key
232}
233
234// The context alert fires on the way up past the line; falling back under it (a /compact,
235// a /clear) re-arms it. Before the first figure nothing changes.
236export function contextCrossed(pct: number | undefined, threshold: number, wasOver = false): { isOver: boolean; shouldAlert: boolean } {
237 const isOver = pct === undefined ? wasOver : threshold > 0 && pct >= threshold
238 return { isOver, shouldAlert: isOver && !wasOver }
239}
240
241// Once per rate-limit window: the key is the window's kind and reset time.
242export function rateCrossed(limits: RateLimit[], threshold: number, alerted: string[]): { fired: RateLimit[]; alerted: string[] } {
243 const keyOf = (l: RateLimit) => `${l.kind}:${l.resetsAt ?? ''}`
244 const fired = threshold > 0 ? limits.filter(l => l.percentUsed >= threshold && !alerted.includes(keyOf(l))) : []
245 // A key stays until its kind reports another window, or, with no reset time to tell windows
246 // apart, until usage falls back under the line. A reading that leaves a window out keeps it,
247 // so each kind holds one key at most.
248 const kept = alerted.filter(k => {
249 const now = limits.find(l => k.startsWith(`${l.kind}:`))
250 return !now || (keyOf(now) === k && (now.resetsAt !== undefined || now.percentUsed >= threshold))
251 })
252 return { fired, alerted: [...kept, ...fired.map(keyOf)] }
253}
254
255// 5 min -> "5m", 125 min -> "2h05m", 3 days 4 h -> "3d4h"; rounded up to the minute.
256export function duration(ms: number): string {
257 const min = Math.max(1, Math.ceil(ms / 60_000))
258 if (min < 60) return `${min}m`
259 const h = Math.floor(min / 60)
260 if (h < 48) return `${h}h${String(min % 60).padStart(2, '0')}m`
261 return `${Math.floor(h / 24)}d${h % 24}h`
262}
263
264// "resets in 2h05m", "resets now", or undefined when the time is missing or unreadable.
265export function resetText(resetsAt: string | undefined, now: number): string | undefined {
266 const at = resetsAt ? Date.parse(resetsAt) : NaN
267 if (Number.isNaN(at)) return undefined
268 return at <= now ? 'resets now' : `resets in ${duration(at - now)}`
269}
270
271// ponytail: the session's average since startedAt, which for a resumed session counts the time
272// it was away too, so the rate reads low. A recent-window rate would need cost samples over time.
273// "$1.20/h · alert in ~3h30m"; undefined in the first 5 minutes or before any cost.
274export function burnText(usd: number | undefined, startedAt: number | undefined, now: number, budget: number): string | undefined {
275 const elapsed = startedAt === undefined ? 0 : now - startedAt
276 if (!usd || elapsed < 5 * 60_000) return undefined
277 const perMs = usd / elapsed
278 const rate = `${money(perMs * 3_600_000)}/h`
279 return budget > usd ? `${rate} · alert in ~${duration((budget - usd) / perMs)}` : rate
280}
281
282export function bar(pct: number, width: number): string {
283 const cells = Math.max(0, Math.floor(width))
284 const full = Math.max(0, Math.min(cells, Math.round((pct / 100) * cells)))
285 return '█'.repeat(full) + '░'.repeat(cells - full)
286}
287
288// `claude mcp list` rows: "<name>: <target> - <mark> <state>", the mark one of ✔ connected,
289// ! needs authentication, ✘ failed to connect, ⏸ pending approval, - not configured.
290const MCP_ROW = /^(.+?): .*? - ([✔✓!✘✗⏸-]) /u
291
292export function parseMcpList(stdout: string, now: number): McpHealth {
293 const health: McpHealth = { ok: 0, auth: [], failed: [], checkedAt: now }
294 for (const line of stdout.split('\n')) {
295 const [, rawName = '', mark] = MCP_ROW.exec(line) ?? []
296 const name = rawName.replace(/^claude\.ai /, '').replace(/^plugin:/, '')
297 if (mark === '✔' || mark === '✓') health.ok++
298 else if (mark === '!') health.auth.push(name)
299 else if (mark === '✘' || mark === '✗') health.failed.push(name)
300 }
301 return health
302}
303
304// Built-in read-only tools drawn compact. Glob and Grep exist only on some builds.
305const COMPACT: Record<string, string> = { Read: 'file_path', Glob: 'pattern', Grep: 'pattern' }
306
307// One dim line for a finished call, with where a search ran; undefined leaves the engine's row.
308export function compactLabel(tool: string, input: unknown): string | undefined {
309 const field = COMPACT[tool]
310 if (!field || typeof input !== 'object' || input === null) return undefined
311 const args = input as Record<string, unknown>
312 const value = args[field]
313 const where = typeof args.path === 'string' && args.path ? ` in ${args.path}` : ''
314 return typeof value === 'string' ? `✓ ${tool} ${value}${where}` : undefined
315}
316types/index.d.ts 45 lines1// Self-contained (the validator refuses imports here): SessionRateLimit's shape.
2export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
3
4export type McpHealth = {
5 ok: number
6 auth: string[]
7 failed: string[]
8 checkedAt: number
9 // Set when `claude mcp list` could not run or answered nothing.
10 error?: string
11}
12
13export type Snapshot = {
14 pct?: number
15 tokens?: number
16 window: number
17 usd?: number
18 limits: RateLimit[]
19 modes: string[]
20 project: string
21 tag?: string
22 // The project root, `~` for the home folder.
23 path: string
24 // Commands the guard blocked this session.
25 guarded: number
26 // The session's usage.startedAt; a new one (/clear) starts the session's counters over.
27 startedAt?: number
28 // `${startedAt}:${budget}` of the last cost alert.
29 alertedFor?: string
30 // Whether context stood at or over the context alert at the last reading.
31 ctxOver?: boolean
32 // `${kind}:${resetsAt}` of each rate-limit window already alerted.
33 rateAlerted?: string[]
34 mcp?: McpHealth
35 // When a `claude mcp list` run in flight started.
36 mcpSince?: number
37}
38
39declare module 'claude-code' {
40 interface PluginState {
41 // `tick` counts minutes while the pane shows, so its countdowns redraw.
42 cockpit: { snap: Snapshot; tick: number }
43 }
44}
45