SLOPSHOPPER

cockpit

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

newpanespinnerrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ /work/app ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Context ⏺ Update(src/auth.ts) │ ┃ ████████████████████░░░░░░░░░░░░░░░░░░░░ 49% ⎿ Added 2 lines, removed 1 line │ ┃ 97k / 200k tokens ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Cost │ ┃ $0.42 of $5.00 alert ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ $0.84/h · alert in ~5h28m │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Rate limits │ ┃ five_hour ██████░░░░░░░░░░░░░░ 31% › /cockpit │ ┃ ⎿ cockpit: Cockpit opened. │ ┃ Modes │ ┃ none │ ┃ │ ┃ MCP servers │ ┃ 0 ok · 0 need sign-in · 0 failed │ ┃ │ ┃ Cloud-sync guard │ ┃ nothing blocked │ ✻ app · Thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cockpit: app · ctx 49% · $0.42

Draws

Pane · Cockpit
app /work/app Context ████████████████████░░░░░░░░░░░░░░░░░░░░ 49% 97k / 200k tokens Cost $0.42 of $5.00 alert $0.84/h · alert in ~5h28m Rate limits five_hour ██████░░░░░░░░░░░░░░ 31% Modes none MCP servers 0 ok · 0 need sign-in · 0 failed Cloud-sync guard nothing blocked
README

cockpit: status line, cost alert and rm -rf guard for Claude Code

CI Release License: MIT

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.

Contents

Quick start

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.

  1. In Claude Code, in a terminal, run:
   /plugin install cockpit --marketplace izzatum/claude-code-cockpit
  1. Confirm adding the marketplace, keep the default (user) scope, and set or skip the options.

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.

Features

FeatureWhere you see itWhat it does
Status lineUnder the promptProject name, context used, session cost and active modes, always visible. Example: web-app · ctx 31% · $0.84 · caveman.
Dashboard/cockpit paneContext 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 alertPop-upOne pop-up when the session's cost reaches your budget (default $5), and the dashboard's cost turns red. See Settings.
Context alertPop-upOne pop-up when the context window reaches your line (default 80%), suggesting /compact; it re-arms once context drops back under.
Rate-limit alertPop-upOne 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 guardEvery Bash and Monitor command Claude runsBlocks 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 healthDashboard, plus one pop-upCounts 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 rowsConversationA finished Read call (and Glob or Grep search, on Claude Code builds that have those tools) shrinks to one dim line.
Spinner tagConversationThe working spinner shows which project you are in.

Use the dashboard

  • Open or close it: type /cockpit; type it again to close it. It works while Claude is busy too.
  • Size: the pane needs about 25 rows. If it cannot be drawn, cockpit replies Cockpit is open but not drawn with the reason; make the window taller.
  • Best layout (terminal): run /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.

Settings

Open them with:

/plugin configure cockpit@claude-code-cockpit
SettingDefaultWhat it means
budget5Cost 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.
contextAlert80Context 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.
rateAlert90Rate-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.
guardPathsemptyExtra folders the guard protects, split by ;, such as ~/work; /Volumes/NAS. See Your own protected folders.
desktopSyncfalseTreat ~/Desktop and ~/Documents as synced folders. Turn it on if iCloud Drive's "Desktop & Documents Folders" (or OneDrive folder backup) syncs them.
compactToolstrueShrink finished Read rows (and Glob or Grep, where available) to one dim line. false keeps Claude Code's normal rows.
projectTagsemptyFriendly 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.

Project tags (optional)

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.

  • The needle is matched anywhere in the full path, parent folders included, so pick distinctive words: me would match everything under /Users/me.
  • The first = ends the needle, so a label may contain =. A label cannot contain ;. An entry without both a needle and a label is skipped.

rm -rf guard for Dropbox, iCloud Drive, Google Drive and OneDrive

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.

Which folders count as synced

Sync appPaths 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.

What is blocked, warned or allowed

CommandResult
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 --forceBlocked. 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 buildAllowed, 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 foldersAllowed, no message.

It also catches these commands behind sudo, xargs, bash -c, && and similar.

  • after sudo, doas, env, nice, timeout, xargs, time, nohup, command, builtin, exec and eval;
  • after ;, &&, ||, |, (, {, !, $( and backticks, and after if, then, elif, else, do, while and until;
  • inside bash -c "…", sh -c '…' (also zsh, dash, ksh, and flags like -lc), and in heredocs that a shell reads (bash <<EOF, cat <<EOF | sh);
  • behind NAME=value prefixes such as CI=1 npm ci, and when called by path (/bin/rm, \rm, 'rm').

Your own protected folders

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.

  • How a folder is recognised: with ~, $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.
  • Names only, as for the sync apps' folders: the guard does not follow a relative name (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.

Limits and caveats

  • The guard is a safety net, not a sandbox. It reads the command's text. It misses a synced folder reached through a symlink, a glob or a variable other than $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.
  • While Claude works inside a synced folder, every recursive delete is blocked, even one aimed somewhere else such as /tmp. The same goes for a command that names a synced path anywhere.
  • Cost is Claude Code's own session cost estimate, shown as-is. It is not a bill. If you are on a subscription plan and don't want the alert, set budget to 0.
  • The burn rate is the session's average since it started. For a resumed session, Claude Code counts from its first launch, so time away lowers the rate and stretches the "alert in" estimate.
  • Rate limits, such as the 5-hour limit, appear only when Claude Code reports them for your session. Otherwise that section is hidden, and the rate-limit alert never fires.
  • MCP health runs 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.

cockpit vs ccusage, /cost, /context and statusLine scripts

Claude Code already shows some of this on request, and other community tools cover parts of it. Many people use several together.

cockpitBuilt-in /costBuilt-in /contextYour own statusLine script
Context % always visibleYesNoOn requestIf you script it
Session cost always visibleYesOn requestNoIf you script it
Budget alert pop-upYesNoNoNo
Context alert pop-upYesNoNoNo
Rate-limit alert with reset timeYesNoNoNo
MCP server healthDashboard + pop-upNoNoIf you script it
Cost history across days and monthsNoNoNoNo
Blocks recursive deletes in synced foldersYesNoNoNo
SetupPlugin installBuilt inBuilt inWrite 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.

FAQ

How do I see Claude Code context usage?

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.

How do I track Claude Code cost per session?

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.

How do I get a budget alert in Claude Code?

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.

Does the cost figure apply on a Claude Pro or Max subscription?

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.

Can Claude Code delete files in my Dropbox, iCloud Drive, Google Drive or OneDrive?

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.

Is it safe to run Claude Code in a Dropbox, iCloud Drive or OneDrive folder?

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.

How do I stop Claude Code from running rm -rf?

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.

Why use a hook instead of a CLAUDE.md rule?

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.

How do I add a custom status line to Claude Code?

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.

How do I see my Claude Code 5-hour rate limit and when it resets?

When Claude Code reports rate limits for your se

Source 3 files
hooks/register.tsx 331 lines
1import { 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}
331
hooks/logic.ts 316 lines
1// 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}
316
types/index.d.ts 45 lines
1// 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