SLOPSHOPPER

cockpit

Agent cockpit for Claude Code: live context, limits, cost, tools, files, tests and a guard for risky commands

newpanebandguardcommandtoast
★ 3v1.0.2MITupdated 2026-10-09drkokorev/cockpit-for-claude/plugins/cockpit
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 │ ┃ ◆ COCKPIT claude-opus-5-5 · 47:00 │ ┃ ▸ CONTEXT ████████████████░░░░░░░░ 66% 13 ⏺ Read(src/auth.ts) │ ┃ messages 98k · system prompt 14k ⎿ Read 6 lines │ ┃ ▸ LIMITS 5h ██████████░░░░░░░░ 58% re ⏺ Update(src/auth.ts) │ ┃ 7d ████░░░░░░░░░░░░░░ 23% re ⎿ Added 2 lines, removed 1 line │ ┃ ▸ COST $4.86 API value (covered by your ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ ◉ cache in use ⎿ Denied by cockpit: Blocked by the Cockpit guard (recursiv │ ┃ pace $6.20/h · cache hits 90% │ ┃ ▸ TURN ● working 1:23 · 7 steps · 11 to ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▾ AGENTS 2 total 1 running a: hide list │ ┃ ▸ ● general-purpose Check RFC… 3 ✻ Worked for 42s · done 4:20 PM │ ┃ ▸ ✓ Explore Map every… 3:00 · 14 │ ┃ ▸ TOOLS Read 41 · Bash 23 · Edit 17 · Gr › /cockpit │ ┃ 97 calls · 5 errors · slowest Ba ⎿ cockpit: Cockpit panel opened. │ ┃ ▸ FILES src/auth/session.ts +84 −31 │ ┃ src/auth/session.test.ts +57 −4 │ ┃ src/api/login.ts +12 −9 │ ┃ src/auth/token-store.ts new │ ┃ 4 files · +153 −44 vs HEAD │ ┃ ▸ TESTS ✓ passed · 48 passed · 2:00 ago │ ┃ npm test -- auth · 3 runs, 2 fai │ ┃ ▸ GUARD off · 1 blocked · 1 allowed · 1 │ ┃ ✓ rm -rf node_modules │ ┃ ▸ ALERTS ⟳ 1 loop alert · Bash ×3: npm te │ ┃ 1 compaction · last 184k → 41k ( ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cockpit: ◆ ctx 66% · 5h 58% · 1 agent · 97 tools · working

Draws

Pane · Cockpit
◆ COCKPIT claude-opus-5-5 · 47:00 ▸ CONTEXT ████████████████░░░░░░░░ 66% 131k / 200k messages 98k · system prompt 14k · system tools ▸ LIMITS 5h ██████████░░░░░░░░ 58% resets in 1h36m · 7d ████░░░░░░░░░░░░░░ 23% resets in 72h00m · ▸ COST $4.86 API value (covered by your plan) · $0.44 l ◉ cache in use pace $6.20/h · cache hits 90% ▸ TURN ● working 1:23 · 7 steps · 11 tools · 1 subagent ▾ AGENTS 2 total 1 running a: hide list ▸ ● general-purpose Check RFC… 3:00 · 3 tools ▸ ✓ Explore Map every… 3:00 · 14 tools ▸ TOOLS Read 41 · Bash 23 · Edit 17 · Grep 12 · Agent 2 97 calls · 5 errors · slowest Bash 48s ▸ FILES src/auth/session.ts +84 −31 src/auth/session.test.ts +57 −4 src/api/login.ts +12 −9 src/auth/token-store.ts new 4 files · +153 −44 vs HEAD ▸ TESTS ✓ passed · 48 passed · 2:00 ago npm test -- auth · 3 runs, 2 failed ▸ GUARD off · 1 blocked · 1 allowed · 1 secret edits sto ✓ rm -rf node_modules ▸ ALERTS ⟳ 1 loop alert · Bash ×3: npm test -- auth 1 compaction · last 184k → 41k (auto) ▸ HISTORY ▂▅▃█▄ input tokens per turn 1:12 9t $0.66 · 3:29 31t $1.58 · 38s 4t $0.44 ▸ ALL-TIME 38 sessions · 412 turns · 5120 tools · $96.40 [ Expand all ] [ Compact ] [ Guard: off ] [ Status line: on
README

Cockpit

A live instrument panel for Claude Code, built as a mod: context fill, rate limits, cost, subagents, tool calls, changed files and test runs, each section opening to its details, plus a guard that asks before risky commands and edits to secrets files.

Works in the Claude Code terminal and the Claude desktop app (Code tab). Requires Claude Code v2.1.287 or later.

  • /cockpit opens the panel
  • /cockpit-demo fills it with sample data (/cockpit-demo off restores yours)
  • /cockpit-report, /cockpit-export, /cockpit-budget, /cockpit-guard, /cockpit-cache

What it runs, reads, writes and sends

Cockpit is one hooks module, hooks/register.tsx, readable TypeScript with no dependencies.

Sends: nothing. Cockpit makes no network requests and does not send the conversation, files or any other data anywhere. Everything it reads is shown in your own Claude Code window and stays on your machine.

Programs it starts, and why. Only git, only in your project, only to show changed files:

  • git diff --numstat HEAD (or git diff --numstat) and git ls-files --others --exclude-standard: the list of changed and new files with +/- line counts, refreshed after an edit and after each turn
  • git rev-parse --show-toplevel and git diff --unified=1 --no-color [HEAD] -- <file>: the diff of one file, only when you click that file in the Files section. <file> is the path git itself listed

What it reads. Through the Claude Code mods API: the session's usage figures (context, rate limits, cost), the tool calls Claude makes and their results (to count calls, time them, size their output and spot loops), the subagent list, and the model's turns. From disk: the contents of a new file only when you click it in Files. It never reads secrets files (.env*, keys, credentials) and shows "contents not read" for them instead.

What it writes. One file, .cockpit/cockpit-report.md in the session's working directory, only when you run /cockpit-export or press Export; each export replaces it. It never writes build, settings, start-up or instruction files. All-time totals (session, turn and tool-call counts, spend) are kept in the plugin's own store on your machine; session data lives in memory and ends with the session.

Slash commands it runs itself. One: /compact, and only when you press the Compact now button (shown when the context is over 70% and Claude is idle).

Commands it adds. /cockpit, /cockpit-report, /cockpit-export, /cockpit-budget, /cockpit-guard, /cockpit-cache, /cockpit-demo.

Events it hooks. tool.call: counts and times each call; for a risky Bash command or an edit to a secrets file it asks you first in Claude Code's own question dialog and refuses the call only if you block it. It never changes a call's input or result. session.compact: reads the result of a compaction to count it and show tokens before and after; it never changes the compaction. It also listens to session.start, session.measure, turn.start, turn.step and turn.complete to read figures, and draws its panel and band with ui.render.

About "download-and-run" patterns in the source. The text curl … | sh appears in register.tsx only as one of the guard's detection rules: Cockpit stops such a command and asks you. Cockpit itself never downloads or runs anything.

Full documentation, screenshots and the changelog: https://github.com/drkokorev/cockpit-for-claude

Source 2 files
hooks/register.tsx 1937 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import type { CockpitAgent, CockpitCall, CockpitDiffFile, CockpitPane, CockpitPrefs, CockpitStats, CockpitTest, CockpitTurnRecord } from '../types'
5
6// Agent Cockpit: a live instrument panel for Claude Code sessions.
7// Context fill, rate limits, cost, the running turn, tool usage, edited files,
8// test runs, turn history, all-time totals, and a guard for risky commands.
9
10const PANE = 'cockpit'
11const STORE_LIFETIME = 'lifetime'
12const STORE_PLAN = 'plan'
13
14const EMPTY_STATS: CockpitStats = {
15  startedAt: 0,
16  model: '',
17  cwd: '',
18  ctxWindow: 0,
19  limits: [],
20  costUsd: 0,
21  turnActive: false,
22  turnStartedAt: 0,
23  turnSteps: 0,
24  turnTools: 0,
25  turnCostAtStart: 0,
26  tools: {},
27  toolTotal: 0,
28  toolErrors: 0,
29  edits: {},
30  diff: [],
31  diffAdd: 0,
32  diffDel: 0,
33  lastTest: null,
34  testRuns: 0,
35  testFails: 0,
36  turns: [],
37  guardBlocked: 0,
38  guardAllowed: 0,
39  guardLog: [],
40  warned: [],
41  lifetimeTurns: 0,
42  lifetimeTools: 0,
43  lifetimeUsd: 0,
44  lifetimeSessions: 0,
45  ctxParts: [],
46  cacheRead: 0,
47  inputTotal: 0,
48  toolMs: {},
49  slowest: null,
50  recentSigs: [],
51  loopSigs: [],
52  loopAlerts: 0,
53  lastLoop: '',
54  agentsActive: [],
55  agentsSpawned: 0,
56  compactions: 0,
57  lastCompact: null,
58  limitStart: {},
59  secretsBlocked: 0,
60  agents: {},
61  turnLog: [],
62  testHistory: [],
63  loopLog: [],
64  compactLog: [],
65  ctxFree: 0,
66  ctxMemory: [],
67  ctxMcp: [],
68  callLog: [],
69  topOutputs: [],
70  lastResponseAt: 0,
71  models: {},
72  fileView: null,
73}
74
75const stats = atom({ plugin: 'cockpit', key: 'stats' } as const, EMPTY_STATS)
76const DEFAULT_PREFS: CockpitPrefs = {
77  isCompact: false,
78  isGuardOn: true,
79  isStatusOn: true,
80  isBandOn: false,
81  budgetUsd: 0,
82  budgetMode: 'auto',
83  isCostHidden: false,
84  isAgentsOpen: true,
85  agentOpen: [],
86  openSections: [],
87  toolOpen: [],
88  cacheTtlMin: 5,
89}
90const prefs = atom({ plugin: 'cockpit', key: 'prefs' } as const, DEFAULT_PREFS)
91const pane = atom({ plugin: 'cockpit', key: 'pane' } as const, { isPlaced: false, reason: '' } as CockpitPane)
92
93// ---------- risky command patterns (the guard) ----------
94
95const RISKS: { pattern: RegExp; reason: string }[] = [
96  { pattern: /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r|-[a-zA-Z]*R[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*R)\b/, reason: 'recursive force delete' },
97  { pattern: /\brm\s+.*--recursive\b.*--force\b|\brm\s+.*--force\b.*--recursive\b/, reason: 'recursive force delete' },
98  { pattern: /\bgit\s+push\b.*(\s--force(\s|$)|\s-f(\s|$))/, reason: 'force push rewrites remote history' },
99  { pattern: /\bgit\s+reset\s+--hard\b/, reason: 'discards uncommitted work' },
100  { pattern: /\bgit\s+clean\s+-[a-zA-Z]*f/, reason: 'deletes untracked files' },
101  { pattern: /\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/, reason: 'discards all local changes' },
102  { pattern: /\bgit\s+branch\s+-D\b/, reason: 'force-deletes a branch' },
103  { pattern: /\b(drop\s+(table|database|schema)|truncate\s+table)\b/i, reason: 'destroys database data' },
104  { pattern: /\bchmod\s+-R\s+777\b/, reason: 'opens permissions on a whole tree' },
105  { pattern: /\b(mkfs(\.\w+)?|fdisk|diskutil\s+erase\w*)\b/, reason: 'formats a disk' },
106  { pattern: /\bdd\s+.*\bof=\/dev\//, reason: 'writes raw bytes to a device' },
107  { pattern: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/, reason: 'pipes a download straight into a shell' },
108  { pattern: /(^|[;&|]\s*)sudo\s/, reason: 'runs as root' },
109  { pattern: /\bkubectl\s+delete\b/, reason: 'deletes cluster resources' },
110  { pattern: /\bterraform\s+(destroy|apply\b.*-auto-approve)/, reason: 'changes infrastructure without review' },
111  { pattern: /\b(npm|pnpm|yarn)\s+publish\b/, reason: 'publishes a package' },
112  { pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, reason: 'fork bomb' },
113]
114
115const TEST_COMMAND =
116  /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bnpx\s+(jest|vitest|playwright\s+test|mocha)\b|\b(jest|vitest|pytest|rspec|phpunit|mocha)\b|\bgo\s+test\b|\bcargo\s+test\b|\bmvn\s+test\b|\bgradlew?\s+test\b|\bmix\s+test\b|\bdotnet\s+test\b|\bdeno\s+test\b|\bmake\s+test\b|\bpython3?\s+-m\s+(pytest|unittest)\b/
117
118const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
119
120const SECRET_FILE =
121  /(^|\/)(\.env(\.[\w.-]+)?|\.npmrc|\.pypirc|\.netrc|id_(rsa|ed25519|ecdsa)(\.pub)?|credentials(\.json)?|secrets?\.(json|ya?ml|toml)|[\w.-]+\.(pem|key|p12|pfx|keystore))$/i
122
123// What makes two tool calls "the same" for the loop detector.
124const signatureOf = (tool: string, args: Record<string, unknown>) => {
125  const key = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'path']
126    .map(name => args[name])
127    .find(value => typeof value === 'string')
128  return `${tool}:${typeof key === 'string' ? key.slice(0, 200) : ''}`
129}
130
131// A subscription reports rate-limit windows; API-key billing does not. The
132// windows arrive with the first reply, so a plan seen once is remembered.
133const isSubscription = (s: CockpitStats) => s.limits.length > 0 || s.isPlan === true
134
135// On a subscription the dollars are only what the API would charge: the
136// budget and the dollars in the status line and band stay out of the way
137// unless the budget is turned on.
138const isBudgetControlShown = (p: CockpitPrefs, s: CockpitStats) => !isSubscription(s) || p.budgetMode === 'on' || p.budgetUsd > 0
139
140const isMoneyInLine = (p: CockpitPrefs, s: CockpitStats) => !p.isCostHidden && (!isSubscription(s) || p.budgetMode === 'on')
141
142const isBudgetVisible = (p: CockpitPrefs, s: CockpitStats) =>
143  !p.isCostHidden && p.budgetUsd > 0 && (p.budgetMode === 'on' || (p.budgetMode === 'auto' && !isSubscription(s)))
144
145const newAgent = (id: string, now: number): CockpitAgent => ({
146  id,
147  type: '',
148  description: '',
149  status: 'running',
150  startedAt: now,
151  steps: 0,
152  tools: 0,
153  errors: 0,
154  toolCounts: {},
155  lastTool: '',
156  tokensIn: 0,
157  tokensOut: 0,
158})
159
160// Agents the list never names (compaction and memory forks) and that called no tool stay hidden.
161const visibleAgents = (s: CockpitStats) =>
162  Object.values(s.agents ?? {})
163    .filter(a => a.type !== '' || a.tools > 0)
164    .sort((a, b) => b.startedAt - a.startedAt)
165
166const LOOP_WINDOW = 8
167const LOOP_REPEATS = 3
168
169// ---------- formatting ----------
170
171const fmtTokens = (n?: number) =>
172  n === undefined ? '—' : n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
173
174const fmtUsd = (n: number) => (n > 0 && n < 0.01 ? '<$0.01' : `$${n.toFixed(2)}`)
175
176const fmtDur = (ms: number) => {
177  if (ms > 0 && ms < 100) return '<0.1s'
178  if (ms > 0 && ms < 10_000) return `${(ms / 1000).toFixed(1).replace(/\.0$/, '')}s`
179  const sec = Math.max(0, Math.round(ms / 1000))
180  if (sec < 60) return `${sec}s`
181  const min = Math.floor(sec / 60)
182  if (min < 60) return `${min}:${String(sec % 60).padStart(2, '0')}`
183  return `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}m`
184}
185
186const fmtAgo = (ms: number) => (ms < 60_000 ? 'just now' : `${fmtDur(ms)} ago`)
187
188const bar = (pct: number, width: number) => {
189  const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
190  return '█'.repeat(filled) + '░'.repeat(width - filled)
191}
192
193const SPARKS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
194const sparkline = (values: number[]) => {
195  const max = Math.max(1, ...values)
196  return values.map(v => SPARKS.at(Math.min(7, Math.floor((v / max) * 7))) ?? '▁').join('')
197}
198
199const levelColor = (pct: number) => (pct >= 85 ? 'red' : pct >= 60 ? 'yellow' : 'green')
200
201const limitLabel = (kind: string) =>
202  kind === 'five_hour' ? '5h' : kind === 'seven_day' ? '7d' : kind === 'spend_limit' ? 'spend' : kind
203
204const resetIn = (iso: string | undefined, now: number) => {
205  if (!iso) return ''
206  const at = Date.parse(iso)
207  return Number.isNaN(at) ? '' : `resets in ${fmtDur(Math.max(0, at - now))}`
208}
209
210const relPath = (path: string, cwd: string) =>
211  cwd && path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
212
213const shorten = (text: string, max: number) => (text.length > max ? text.slice(0, max - 1) + '…' : text)
214
215// ---------- state helpers ----------
216
217type Dollar = EngineInterface
218type CockpitElements = Pick<Elements['terminal'] | Elements['desktop'], 'Box' | 'Text' | 'Button'>
219
220const pushStatus = async ($: Dollar) => {
221  const p = await read($, prefs)
222  if (!p.isStatusOn) {
223    $.ui.status(undefined)
224    return
225  }
226  const s = await read($, stats)
227  const parts = [`ctx ${s.ctxPercent === undefined ? '—' : `${s.ctxPercent}%`}`]
228  const fiveHour = s.limits.find(l => l.kind === 'five_hour')
229  if (fiveHour) parts.push(`5h ${Math.round(fiveHour.percentUsed)}%`)
230  if (isMoneyInLine(p, s)) parts.push(isBudgetVisible(p, s) ? `${fmtUsd(s.costUsd)}/${fmtUsd(p.budgetUsd)}` : fmtUsd(s.costUsd))
231  const running = visibleAgents(s).filter(a => a.status === 'running').length
232  if (running) parts.push(`${running} agent${running > 1 ? 's' : ''}`)
233  parts.push(`${s.toolTotal} tools`)
234  if (s.turnActive) parts.push('working')
235  $.ui.status(`◆ ${parts.join(' · ')}`)
236}
237
238const warnOnce = async ($: Dollar) => {
239  const s = await read($, stats)
240  const due: { id: string; text: string }[] = []
241  const pct = s.ctxPercent ?? 0
242  if (pct >= 95) due.push({ id: 'ctx95', text: `Context ${pct}% full: compaction is imminent` })
243  else if (pct >= 80) due.push({ id: 'ctx80', text: `Context ${pct}% full: consider /compact soon` })
244  for (const limit of s.limits) {
245    if (limit.percentUsed >= 90) due.push({ id: `lim90:${limit.kind}`, text: `${limitLabel(limit.kind)} limit ${Math.round(limit.percentUsed)}% used` })
246    else if (limit.percentUsed >= 75) due.push({ id: `lim75:${limit.kind}`, text: `${limitLabel(limit.kind)} limit ${Math.round(limit.percentUsed)}% used` })
247  }
248  const p = await read($, prefs)
249  if (isBudgetVisible(p, s)) {
250    const share = (s.costUsd / p.budgetUsd) * 100
251    if (share >= 100) due.push({ id: `budget100:${p.budgetUsd}`, text: `budget ${fmtUsd(p.budgetUsd)} reached (${fmtUsd(s.costUsd)} spent)` })
252    else if (share >= 80) due.push({ id: `budget80:${p.budgetUsd}`, text: `${Math.round(share)}% of the ${fmtUsd(p.budgetUsd)} budget spent` })
253  }
254  const fresh = due.filter(d => !s.warned.includes(d.id))
255  if (fresh.length === 0) return
256  await update($, stats, cur => ({ ...cur, warned: [...cur.warned, ...fresh.map(d => d.id)] }))
257  for (const d of fresh) $.ui.toast(`Cockpit: ${d.text}`, { timeoutMs: 6000 })
258}
259
260const applyUsage = async (
261  $: Dollar,
262  usage: { context: { tokens?: number; window: number; percent?: number }; rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]; cost?: { usd: number } },
263) => {
264  await update($, stats, s => ({
265    ...s,
266    ctxTokens: usage.context.tokens,
267    ctxWindow: usage.context.window,
268    ctxPercent: usage.context.percent,
269    limits: usage.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
270    costUsd: usage.cost?.usd ?? s.costUsd,
271    isPlan: s.isPlan || usage.rateLimits.length > 0,
272  }))
273  if (usage.rateLimits.length > 0 && !isPlanStored) {
274    isPlanStored = true
275    await $.store.set(STORE_PLAN, true)
276  }
277  const now = await $.clock.now()
278  await update($, stats, s => {
279    const limitStart = { ...(s.limitStart ?? {}) }
280    for (const l of usage.rateLimits) {
281      const first = limitStart[l.kind]
282      // a window that reset (percent fell) starts its pace sample over
283      if (!first || l.percentUsed < first.pct) limitStart[l.kind] = { pct: l.percentUsed, at: now }
284    }
285    return { ...s, limitStart }
286  })
287  await warnOnce($)
288  await pushStatus($)
289}
290
291const refreshUsage = async ($: Dollar) => {
292  try {
293    const usage = await $.session.usage()
294    const model = await $.session.model()
295    await update($, stats, s => ({ ...s, model }))
296    await applyUsage($, usage)
297  } catch (error) {
298    $.ui.log(`cockpit: usage unavailable (${String(error)})`, { to: 'debug' })
299  }
300}
301
302const refreshBreakdown = async ($: Dollar) => {
303  try {
304    const usage = await $.session.usage({ breakdown: 'summary' })
305    const breakdown = usage.context.breakdown
306    const categories = breakdown?.categories ?? []
307    const parts = categories
308      .filter(c => c.kind === 'used' && c.tokens > 0)
309      .sort((a, b) => b.tokens - a.tokens)
310      .slice(0, 10)
311      .map(c => ({ name: c.name, tokens: c.tokens }))
312    const free = categories.filter(c => c.kind === 'free').reduce((sum, c) => sum + c.tokens, 0)
313    const memory = (breakdown?.memoryFiles ?? [])
314      .slice()
315      .sort((a, b) => b.tokens - a.tokens)
316      .slice(0, 6)
317      .map(m => ({ path: m.path, tokens: m.tokens }))
318    const servers: Record<string, { tokens: number; tools: number }> = {}
319    for (const tool of breakdown?.mcpTools ?? []) {
320      const cur = servers[tool.serverName] ?? { tokens: 0, tools: 0 }
321      servers[tool.serverName] = { tokens: cur.tokens + tool.tokens, tools: cur.tools + 1 }
322    }
323    const mcp = Object.entries(servers)
324      .map(([server, v]) => ({ server, tokens: v.tokens, tools: v.tools }))
325      .sort((a, b) => b.tokens - a.tokens)
326      .slice(0, 6)
327    await update($, stats, s => ({ ...s, ctxParts: parts, ctxFree: free, ctxMemory: memory, ctxMcp: mcp }))
328  } catch {
329    // breakdown unavailable on this host
330  }
331}
332
333// Minutes until a rate-limit window fills at the pace seen this session.
334const paceEta = (s: CockpitStats, kind: string, now: number) => {
335  const limit = s.limits.find(l => l.kind === kind)
336  const first = s.limitStart?.[kind]
337  if (!limit || !first || now - first.at < 5 * 60_000) return undefined
338  const perMs = (limit.percentUsed - first.pct) / (now - first.at)
339  if (perMs <= 0) return undefined
340  return (100 - limit.percentUsed) / perMs
341}
342
343const burnPerHour = (s: CockpitStats, now: number) => {
344  const hours = (now - s.startedAt) / 3_600_000
345  return s.startedAt && hours > 0.05 ? s.costUsd / hours : undefined
346}
347
348const cacheShare = (s: CockpitStats) =>
349  s.inputTotal > 0 ? Math.round((s.cacheRead / s.inputTotal) * 100) : undefined
350
351const refreshAgents = async ($: Dollar) => {
352  try {
353    const list = await $.agent.list()
354    const now = await $.clock.now()
355    await update($, stats, s => {
356      const agents = { ...(s.agents ?? {}) }
357      for (const info of list) {
358        const cur = agents[info.id] ?? newAgent(info.id, now)
359        agents[info.id] = {
360          ...cur,
361          type: info.type,
362          description: info.description,
363          name: info.name,
364          status: info.status,
365          parentId: info.parentId,
366          endedAt: info.status !== 'running' && !cur.endedAt ? now : cur.endedAt,
367        }
368      }
369      // keep the 20 most recent
370      const kept = Object.values(agents).sort((a, b) => b.startedAt - a.startedAt).slice(0, 20)
371      return { ...s, agents: Object.fromEntries(kept.map(a => [a.id, a])) }
372    })
373  } catch {
374    // agent list unavailable
375  }
376}
377
378const parseNumstat = (text: string): CockpitDiffFile[] =>
379  text
380    .split('\n')
381    .map(line => line.split('\t'))
382    .filter(cols => cols.length >= 3)
383    .map(cols => ({
384      path: cols.slice(2).join('\t'),
385      add: Number(cols.at(0)) || 0,
386      del: Number(cols.at(1)) || 0,
387    }))
388
389const refreshDiff = async ($: Dollar) => {
390  try {
391    let run = await $.process.run(['git', 'diff', '--numstat', 'HEAD'], { timeoutMs: 5000 })
392    if (run.exitCode !== 0) run = await $.process.run(['git', 'diff', '--numstat'], { timeoutMs: 5000 })
393    if (run.exitCode !== 0) return
394    const files = parseNumstat(run.stdout)
395    const untracked = await $.process.run(['git', 'ls-files', '--others', '--exclude-standard'], { timeoutMs: 5000 })
396    if (untracked.exitCode === 0) {
397      for (const path of untracked.stdout.split('\n').filter(Boolean).slice(0, 50)) {
398        files.push({ path, add: -1, del: 0 })
399      }
400    }
401    await update($, stats, s => ({
402      ...s,
403      diff: files.slice(0, 100),
404      diffAdd: files.reduce((sum, f) => sum + Math.max(0, f.add), 0),
405      diffDel: files.reduce((sum, f) => sum + f.del, 0),
406    }))
407  } catch {
408    // not a git repository, or git is missing: the files section falls back to edits
409  }
410}
411
412const checkRisk = (command: string) => RISKS.find(r => r.pattern.test(command))?.reason
413
414const parseTestOutput = (text: string) => {
415  const passed = text.match(/(\d+)\s+(passed|passing)\b/i)
416  const failed = text.match(/(\d+)\s+(failed|failing)\b/i)
417  return {
418    passed: passed ? Number(passed[1]) : undefined,
419    failed: failed ? Number(failed[1]) : undefined,
420  }
421}
422
423const buildReport = (s: CockpitStats, now: number) => {
424  const lines = [`Cockpit report · session ${fmtDur(now - s.startedAt)} · ${s.model || 'model ?'}`]
425  lines.push(`Context: ${s.ctxPercent ?? '—'}% (${fmtTokens(s.ctxTokens)} / ${fmtTokens(s.ctxWindow)})`)
426  for (const l of s.limits) lines.push(`Limit ${limitLabel(l.kind)}: ${l.percentUsed}% ${resetIn(l.resetsAt, now)}`)
427  lines.push(`Cost: ${fmtUsd(s.costUsd)} · turns: ${s.turns.length} · tool calls: ${s.toolTotal} (${s.toolErrors} errors)`)
428  const top = Object.entries(s.tools).sort((a, b) => b[1].calls - a[1].calls).slice(0, 8)
429  if (top.length) lines.push(`Tools: ${top.map(([name, t]) => `${name} ${t.calls}`).join(', ')}`)
430  const files = s.diff.length ? s.diff.map(f => `${f.path} ${f.add < 0 ? 'new' : `+${f.add} -${f.del}`}`) : Object.entries(s.edits).map(([p, n]) => `${relPath(p, s.cwd)} (${n} edits)`)
431  if (files.length) lines.push(`Files: ${files.slice(0, 12).join(', ')}`)
432  if (s.lastTest) lines.push(`Tests: ${s.testRuns} runs, ${s.testFails} failed; last ${s.lastTest.isOk ? 'passed' : 'failed'} (${s.lastTest.command})`)
433  const cache = cacheShare(s)
434  const burn = burnPerHour(s, now)
435  lines.push(`Pace: ${burn === undefined ? '—' : `${fmtUsd(burn)}/h`} · prompt cache hits ${cache === undefined ? '—' : `${cache}%`}`)
436  if (s.ctxParts?.length) lines.push(`Context parts: ${s.ctxParts.map(c => `${c.name} ${fmtTokens(c.tokens)}`).join(', ')}`)
437  if (s.slowest) lines.push(`Slowest tool call: ${s.slowest.tool} ${fmtDur(s.slowest.ms)} (${s.slowest.label})`)
438  lines.push(`Subagents: ${s.agentsSpawned} spawned · compactions: ${s.compactions} · loop alerts: ${s.loopAlerts}`)
439  for (const a of visibleAgents(s).slice(0, 10)) {
440    lines.push(`  ${a.status === 'running' ? '●' : a.status === 'completed' ? '✓' : '✗'} ${a.type || 'agent'} "${a.description}" · ${fmtDur((a.endedAt ?? now) - a.startedAt)} · ${a.tools} tools · ${fmtTokens(a.tokensIn)} in`)
441  }
442  lines.push(`Guard: ${s.guardBlocked} blocked, ${s.guardAllowed} allowed, ${s.secretsBlocked} secret-file edits stopped`)
443  lines.push(`All-time: ${s.lifetimeSessions} sessions · ${s.lifetimeTurns} turns · ${s.lifetimeTools} tools · ${fmtUsd(s.lifetimeUsd)}`)
444  return lines.join('\n')
445}
446
447const buildMarkdown = (s: CockpitStats, now: number) => {
448  const turns = s.turns
449    .map((t, i) => `| ${i + 1} | ${fmtDur(t.durationMs)} | ${t.steps} | ${t.tools} | ${fmtTokens(t.tokensIn)} | ${fmtTokens(t.tokensOut)} | ${fmtUsd(t.costUsd)} | ${t.reason} |`)
450    .join('\n')
451  const tools = Object.entries(s.tools)
452    .sort((a, b) => b[1].calls - a[1].calls)
453    .map(([name, t]) => `| ${name} | ${t.calls} | ${t.errors} | ${fmtDur(s.toolMs?.[name]?.totalMs ?? 0)} |`)
454    .join('\n')
455  return [
456    `# Cockpit report`,
457    '',
458    '```',
459    buildReport(s, now),
460    '```',
461    '',
462    '## Turns',
463    '',
464    '| # | Duration | Steps | Tools | In | Out | Cost | End |',
465    '| --- | --- | --- | --- | --- | --- | --- | --- |',
466    turns || '| – | | | | | | | |',
467    '',
468    '## Tools',
469    '',
470    '| Tool | Calls | Errors | Time |',
471    '| --- | --- | --- | --- |',
472    tools || '| – | | | |',
473    '',
474    `_Generated by Cockpit for Claude Code on ${new Date(now).toISOString()}_`,
475    '',
476  ].join('\n')
477}
478
479const exportReport = async ($: Dollar) => {
480  const s = await read($, stats)
481  const now = await $.clock.now()
482  const root = s.cwd || (await $.session.cwd())
483  try {
484    // one fixed file, relative to the session's working directory; each export replaces it
485    await $.fs.write('.cockpit/cockpit-report.md', buildMarkdown(s, now))
486    return `Cockpit report saved to ${root}/.cockpit/cockpit-report.md`
487  } catch (error) {
488    return `Cockpit could not save the report: ${String(error)}`
489  }
490}
491
492// The full cockpit tree, drawn in the side panel or in the expanded band.
493const drawCockpit = async ($: Dollar, els: CockpitElements, bodyColumns: number) => {
494  const { Box, Text, Button } = els
495  const s = await read($, stats)
496  const p = await read($, prefs)
497  const now = await $.clock.now()
498  const cols = Math.max(30, bodyColumns || 60)
499  const barWidth = Math.max(8, Math.min(24, cols - 26))
500  const labelWidth = 12
501  const mini = (share: number, width = 10) => bar(Math.max(0, Math.min(100, share)), width)
502  const pad = (text: string, width: number) => (text.length >= width ? text.slice(0, width - 1) + ' ' : text.padEnd(width))
503  const nameWidth = Math.max(10, Math.min(22, cols - labelWidth - 30))
504
505  const isOpen = (label: string) => p.openSections.includes(label)
506  const toggleSection = (label: string) =>
507    update($, prefs, cur => ({
508      ...cur,
509      openSections: cur.openSections.includes(label) ? cur.openSections.filter(x => x !== label) : [...cur.openSections, label],
510    }))
511
512  // A section: its label opens the details, which show under the summary.
513  const row = (label: string, body: RenderChildren, details?: () => RenderChildren, onToggle?: () => unknown, isToggled?: boolean) => (
514    <Box flexDirection="row">
515      <Box width={labelWidth} flexShrink={0}>
516        {details || onToggle ? (
517          <Button
518            key={`sec-${label}`}
519            plain
520            dimColor={!(onToggle ? isToggled : isOpen(label))}
521            label={`${(onToggle ? isToggled : isOpen(label)) ? '▾' : '▸'} ${label}`}
522            onPress={() => (onToggle ? onToggle() : toggleSection(label))}
523          />
524        ) : (
525          <Text dimColor bold>{`  ${label}`}</Text>
526        )}
527      </Box>
528      <Box flexDirection="column" flexGrow={1}>
529        {body}
530        {details && isOpen(label) ? (
531          <Box flexDirection="column" paddingLeft={1} marginBottom={1} borderStyle="single" borderColor="gray" borderDimColor>
532            {details()}
533          </Box>
534        ) : null}
535      </Box>
536    </Box>
537  )
538
539  const line = (...parts: { text: string; color?: string; dim?: boolean; bold?: boolean }[]) => (
540    <Box flexDirection="row">
541      {parts.map(part => (
542        <Text color={part.color} dimColor={part.dim} bold={part.bold} wrap="truncate-end">
543          {part.text}
544        </Text>
545      ))}
546    </Box>
547  )
548
549  // context
550  const pct = s.ctxPercent
551  const contextBody =
552    pct === undefined
553      ? line({ text: 'waiting for the first response', dim: true })
554      : [
555          line(
556            { text: bar(pct, barWidth), color: levelColor(pct) },
557            { text: ` ${pct}%`, bold: true, color: levelColor(pct) },
558            { text: `  ${fmtTokens(s.ctxTokens)} / ${fmtTokens(s.ctxWindow)}`, dim: true },
559          ),
560          s.ctxParts.length > 0
561            ? line({ text: s.ctxParts.slice(0, 4).map(c => `${c.name.toLowerCase()} ${fmtTokens(c.tokens)}`).join(' · '), dim: true })
562            : null,
563          pct >= 70 && !s.turnActive ? (
564            <Box flexDirection="row" gap={1}>
565              <Button key="compact-now" variant="primary" label="Compact now" onPress={() => $.command.run({ command: 'compact' })} />
566              <Text dimColor>summarize the history before it happens mid-task</Text>
567            </Box>
568          ) : null,
569        ]
570
571  // limits
572  const limitsBody =
573    s.limits.length === 0
574      ? line({ text: 'no rate-limit data (API key or no response yet)', dim: true })
575      : s.limits.map(l =>
576          line(
577            { text: `${limitLabel(l.kind).padEnd(5)} ` },
578            { text: bar(l.percentUsed, Math.max(6, barWidth - 6)), color: levelColor(l.percentUsed) },
579            { text: ` ${Math.round(l.percentUsed)}%`, bold: true },
580            { text: `  ${resetIn(l.resetsAt, now)}`, dim: true },
581            (() => {
582              const eta = paceEta(s, l.kind, now)
583              const resetsAt = l.resetsAt ? Date.parse(l.resetsAt) : NaN
584              if (eta === undefined) return { text: '' }
585              const isBeforeReset = Number.isNaN(resetsAt) || now + eta < resetsAt
586              return isBeforeReset ? { text: ` · full in ${fmtDur(eta)} at this pace`, color: 'yellow' } : { text: ' · on pace', dim: true }
587            })(),
588          ),
589        )
590
591  // cost
592  const lastTurn = s.turns.at(-1)
593  const burn = burnPerHour(s, now)
594  const cache = cacheShare(s)
595  const budgetShare = isBudgetVisible(p, s) ? (s.costUsd / p.budgetUsd) * 100 : undefined
596  const costBody = [
597    line(
598      { text: fmtUsd(s.costUsd), bold: true },
599      { text: isSubscription(s) ? ' API value (covered by your plan)' : ' this session', dim: true },
600      { text: lastTurn ? ` · ${fmtUsd(lastTurn.costUsd)} last turn` : '', dim: true },
601    ),
602    (() => {
603      const left = cacheLeftMs(s, p, now)
604      if (left === undefined) return null
605      if (s.turnActive) return line({ text: '◉ cache in use', color: 'green' })
606      return left > 0
607        ? line({ text: `◉ cache warm ${fmtDur(left)}`, color: left > 60_000 ? 'green' : 'yellow' }, { text: ' · reply before it expires to keep requests cheap', dim: true })
608        : line({ text: '○ cache expired', color: 'yellow' }, { text: ' · the next request re-reads the whole context at full price', dim: true })
609    })(),
610    line(
611      { text: burn === undefined ? 'pace —' : `pace ${fmtUsd(burn)}/h`, dim: true },
612      { text: cache === undefined ? '' : ` · cache hits ${cache}%`, color: cache === undefined ? undefined : cache >= 70 ? 'green' : cache >= 40 ? 'yellow' : 'red' },
613    ),
614    budgetShare === undefined
615      ? null
616      : line(
617          { text: `budget ${bar(Math.min(100, budgetShare), Math.max(6, barWidth - 7))}`, color: levelColor(budgetShare) },
618          { text: ` ${Math.round(budgetShare)}% of ${fmtUsd(p.budgetUsd)}`, bold: budgetShare >= 80 },
619        ),
620  ]
621
622  // the running turn
623  const turnBody = s.turnActive
624    ? line(
625        { text: '● working ', color: 'cyan', bold: true },
626        { text: fmtDur(now - s.turnStartedAt) },
627        { text: ` · ${s.turnSteps} steps · ${s.turnTools} tools`, dim: true },
628        { text: s.agentsActive.length ? ` · ${s.agentsActive.length} subagent${s.agentsActive.length > 1 ? 's' : ''}` : '', color: 'magenta' },
629      )
630    : line(
631        { text: '○ idle', dim: true },
632        { text: lastTurn ? ` · last ${fmtDur(lastTurn.durationMs)}, ${lastTurn.tools} tools, ${fmtTokens(lastTurn.tokensIn)} in / ${fmtTokens(lastTurn.tokensOut)} out` : '', dim: true },
633      )
634
635  const header = line(
636    { text: '◆ COCKPIT', bold: true, color: 'cyan' },
637    { text: `  ${s.model || ''}`, dim: true },
638    { text: s.startedAt ? `  · ${fmtDur(now - s.startedAt)}` : '', dim: true },
639  )
640
641  const controls = (
642    <Box flexDirection="row" gap={1} marginTop={1} flexWrap="wrap">
643      <Button
644        key="sections"
645        hotkey="x"
646        label={p.openSections.length ? 'Collapse all' : 'Expand all'}
647        onPress={() =>
648          update($, prefs, cur => ({
649            ...cur,
650            openSections: cur.openSections.length ? [] : ['CONTEXT', 'LIMITS', 'COST', 'TURN', 'TOOLS', 'FILES', 'TESTS', 'GUARD', 'ALERTS', 'HISTORY', 'ALL-TIME'],
651          }))
652        }
653      />
654      <Button key="compact" hotkey="c" label={p.isCompact ? 'Full view' : 'Compact'} onPress={() => update($, prefs, cur => ({ ...cur, isCompact: !cur.isCompact }))} />
655      <Button
656        key="guard"
657        hotkey="g"
658        variant={p.isGuardOn ? 'primary' : 'secondary'}
659        label={p.isGuardOn ? 'Guard: on' : 'Guard: off'}
660        onPress={() => update($, prefs, cur => ({ ...cur, isGuardOn: !cur.isGuardOn }))}
661      />
662      <Button
663        key="status"
664        hotkey="s"
665        label={p.isStatusOn ? 'Status line: on' : 'Status line: off'}
666        onPress={async () => {
667          await update($, prefs, cur => ({ ...cur, isStatusOn: !cur.isStatusOn }))
668          await pushStatus($)
669        }}
670      />
671      <Button
672        key="money"
673        hotkey="m"
674        label={p.isCostHidden ? 'Cost: hidden' : 'Cost: shown'}
675        onPress={async () => {
676          await update($, prefs, cur => ({ ...cur, isCostHidden: !cur.isCostHidden }))
677          await pushStatus($)
678        }}
679      />
680      {isBudgetControlShown(p, s) ? (
681        <Button
682          key="budget"
683          hotkey="b"
684          label={`Budget: ${p.budgetMode}${p.budgetUsd > 0 ? ` ${fmtUsd(p.budgetUsd)}` : ''}`}
685          onPress={async () => {
686            await update($, prefs, cur => ({ ...cur, budgetMode: (cur.budgetMode === 'auto' ? 'on' : cur.budgetMode === 'on' ? 'off' : 'auto') as CockpitPrefs['budgetMode'] }))
687            await pushStatus($)
688          }}
689        />
690      ) : null}
691      <Button
692        key="report"
693        hotkey="p"
694        label="Report"
695        onPress={async () => {
696          const cur = await read($, stats)
697          $.ui.log(buildReport(cur, await $.clock.now()))
698        }}
699      />
700      <Button key="export" hotkey="e" label="Export .md" onPress={async () => $.ui.toast(await exportReport($), { timeoutMs: 8000 })} />
701      <Button
702        key="reset"
703        hotkey="r"
704        dimColor
705        label="Reset"
706        onPress={async () => {
707          await update($, stats, cur => ({
708            ...EMPTY_STATS,
709            startedAt: cur.startedAt,
710            model: cur.model,
711            cwd: cur.cwd,
712            ctxTokens: cur.ctxTokens,
713            ctxWindow: cur.ctxWindow,
714            ctxPercent: cur.ctxPercent,
715            limits: cur.limits,
716            costUsd: cur.costUsd,
717            turnActive: cur.turnActive,
718            turnStartedAt: cur.turnStartedAt,
719            turnCostAtStart: cur.turnCostAtStart,
720            diff: cur.diff,
721            diffAdd: cur.diffAdd,
722            diffDel: cur.diffDel,
723            lifetimeTurns: cur.lifetimeTurns,
724            lifetimeTools: cur.lifetimeTools,
725            lifetimeUsd: cur.lifetimeUsd,
726            lifetimeSessions: cur.lifetimeSessions,
727          }))
728          await pushStatus($)
729        }}
730      />
731    </Box>
732  )
733
734  if (p.isCompact) {
735    return (
736      <Box flexDirection="column">
737        {header}
738        {row('CONTEXT', contextBody)}
739        {row('LIMITS', limitsBody)}
740        {p.isCostHidden ? null : row('COST', costBody)}
741        {row('TURN', turnBody)}
742        {controls}
743      </Box>
744    )
745  }
746
747  // tools
748  const topTools = Object.entries(s.tools)
749    .sort((a, b) => b[1].calls - a[1].calls)
750    .slice(0, 6)
751  const toolsBody =
752    topTools.length === 0
753      ? line({ text: 'no tool calls yet', dim: true })
754      : [
755          line({ text: topTools.map(([name, t]) => `${name.replace(/^mcp__[^_]+__/, '')} ${t.calls}`).join(' · ') }),
756          line(
757            { text: `${s.toolTotal} calls`, dim: true },
758            { text: s.toolErrors ? ` · ${s.toolErrors} errors` : '', color: s.toolErrors ? 'red' : undefined },
759            { text: s.slowest && s.slowest.ms >= 1000 ? ` · slowest ${s.slowest.tool} ${fmtDur(s.slowest.ms)}` : '', dim: true },
760          ),
761        ]
762
763  // files
764  const fileRows: RenderChildren[] = []
765  if (s.diff.length > 0) {
766    for (const f of s.diff.slice(0, 6)) {
767      fileRows.push(
768        line(
769          { text: shorten(f.path, Math.max(12, cols - labelWidth - 14)) },
770          f.add < 0 ? { text: '  new', color: 'cyan' } : { text: `  +${f.add}`, color: 'green' },
771          f.add < 0 ? { text: '' } : { text: ` −${f.del}`, color: 'red' },
772        ),
773      )
774    }
775    fileRows.push(
776      line({ text: `${s.diff.length} files · +${s.diffAdd} −${s.diffDel} vs HEAD`, dim: true }),
777    )
778  } else {
779    const edited = Object.entries(s.edits).sort((a, b) => b[1] - a[1])
780    if (edited.length === 0) fileRows.push(line({ text: 'no edits yet', dim: true }))
781    for (const [path, count] of edited.slice(0, 6)) {
782      fileRows.push(line({ text: shorten(relPath(path, s.cwd), Math.max(12, cols - labelWidth - 12)) }, { text: `  ${count} edits`, dim: true }))
783    }
784  }
785
786  // tests
787  const testBody = s.lastTest
788    ? [
789        line(
790          { text: s.lastTest.isOk ? '✓ passed' : '✗ failed', color: s.lastTest.isOk ? 'green' : 'red', bold: true },
791          { text: s.lastTest.passed !== undefined ? ` · ${s.lastTest.passed} passed` : '' },
792          { text: s.lastTest.failed ? ` · ${s.lastTest.failed} failed` : '', color: 'red' },
793          { text: ` · ${fmtAgo(now - s.lastTest.at)}`, dim: true },
794        ),
795        line({ text: `${s.lastTest.command} · ${s.testRuns} runs, ${s.testFails} failed`, dim: true }),
796      ]
797    : line({ text: 'no test runs seen yet', dim: true })
798
799  // guard
800  const lastGuard = s.guardLog.at(-1)
801  const guardBody = [
802    line(
803      { text: p.isGuardOn ? 'on' : 'off', color: p.isGuardOn ? 'green' : 'yellow', bold: true },
804      { text: ` · ${s.guardBlocked} blocked · ${s.guardAllowed} allowed`, dim: true },
805      { text: s.secretsBlocked ? ` · ${s.secretsBlocked} secret edits stopped` : '', color: 'yellow' },
806    ),
807    lastGuard
808      ? line(
809          { text: lastGuard.verdict === 'blocked' ? '✗ ' : '✓ ', color: lastGuard.verdict === 'blocked' ? 'red' : 'yellow' },
810          { text: shorten(lastGuard.command, Math.max(12, cols - labelWidth - 4)), dim: true },
811        )
812      : line({ text: 'watching rm -rf, force push, reset --hard, sudo, .env and key files…', dim: true }),
813  ]
814
815  // agents: one line each, details on demand
816  const agents = visibleAgents(s)
817  const runningCount = agents.filter(a => a.status === 'running').length
818  const agentCard = (a: CockpitAgent) => {
819    const isOpen = p.agentOpen.includes(a.id)
820    const isRunning = a.status === 'running'
821    const took = (a.endedAt ?? now) - a.startedAt
822    const mark = isRunning ? '●' : a.status === 'completed' ? '✓' : '✗'
823    const markColor = isRunning ? 'magenta' : a.status === 'completed' ? 'green' : 'red'
824    const title = a.description || a.name || a.id.slice(0, 8)
825    const topTools = Object.entries(a.toolCounts).sort((x, y) => y[1] - x[1]).slice(0, 5)
826    return (
827      <Box flexDirection="column">
828        <Box flexDirection="row" gap={1}>
829          <Button
830            key={`agent-${a.id}`}
831            plain
832            dimColor={!isOpen}
833            label={isOpen ? '▾' : '▸'}
834            onPress={() =>
835              update($, prefs, cur => ({
836                ...cur,
837                agentOpen: cur.agentOpen.includes(a.id) ? cur.agentOpen.filter(id => id !== a.id) : [...cur.agentOpen, a.id].slice(-10),
838              }))
839            }
840          />
841          <Text color={markColor}>{mark}</Text>
842          <Text bold>{a.type || 'agent'}</Text>
843          <Text wrap="truncate-end">{shorten(title, Math.max(10, cols - labelWidth - 34))}</Text>
844          <Text dimColor>{`${fmtDur(took)} · ${a.tools} tools`}</Text>
845        </Box>
846        {isOpen ? (
847          <Box flexDirection="column" paddingLeft={4}>
848            {line({ text: `${a.status} · ${a.steps} steps · ${fmtTokens(a.tokensIn)} in / ${fmtTokens(a.tokensOut)} out`, dim: true }, { text: a.errors ? ` · ${a.errors} errors` : '', color: 'red' })}
849            {topTools.length ? line({ text: `tools: ${topTools.map(([name, n]) => `${name.replace(/^mcp__[^_]+__/, '')} ${n}`).join(' · ')}`, dim: true }) : null}
850            {a.lastTool ? line({ text: `last: ${shorten(a.lastTool, Math.max(12, cols - labelWidth - 12))}`, dim: true }) : null}
851            {a.name || a.parentId ? line({ text: [a.name ? `name ${a.name}` : '', a.parentId ? `spawned by ${a.parentId.slice(0, 8)}` : 'spawned by main'].filter(Boolean).join(' · '), dim: true }) : null}
852            {(() => {
853              const calls = (s.callLog ?? []).filter(c => c.agentId === a.id).slice(-8)
854              return calls.length
855                ? [
856                    line({ text: 'timeline', bold: true }),
857                    ...calls.map(c =>
858                      line(
859                        { text: c.isError ? '✗ ' : '✓ ', color: c.isError ? 'red' : 'green' },
860                        { text: pad(c.tool.replace(/^mcp__[^_]+__/, ''), 10), bold: true },
861                        { text: pad(fmtDur(c.ms), 6), dim: true },
862                        { text: shorten(c.label, Math.max(10, cols - labelWidth - 26)), dim: true },
863                      ),
864                    ),
865                  ]
866                : null
867            })()}
868            {a.result ? line({ text: 'result: ', bold: true }, { text: shorten(a.result, Math.max(20, (cols - labelWidth - 14) * 2)), dim: true }) : null}
869          </Box>
870        ) : null}
871      </Box>
872    )
873  }
874  const agentsBody =
875    agents.length === 0
876      ? line({ text: 'no subagents yet', dim: true })
877      : [
878          <Box flexDirection="row" gap={1}>
879            <Text>{`${agents.length} total`}</Text>
880            <Text color={runningCount ? 'magenta' : undefined} dimColor={!runningCount}>{`${runningCount} running`}</Text>
881            <Button
882              key="agents-toggle"
883              plain
884              dimColor
885              hotkey="a"
886              label={p.isAgentsOpen ? 'hide list' : 'show list'}
887              onPress={() => update($, prefs, cur => ({ ...cur, isAgentsOpen: !cur.isAgentsOpen }))}
888            />
889          </Box>,
890          p.isAgentsOpen ? agents.slice(0, 6).map(agentCard) : null,
891        ]
892
893  // alerts: loops, compactions, subagents
894  const alertLines = [
895    s.loopAlerts
896      ? line({ text: `⟳ ${s.loopAlerts} loop alert${s.loopAlerts > 1 ? 's' : ''}`, color: 'yellow', bold: true }, { text: ` · ${shorten(s.lastLoop, Math.max(10, cols - labelWidth - 18))}`, dim: true })
897      : null,
898    s.compactions
899      ? line({ text: `${s.compactions} compaction${s.compactions > 1 ? 's' : ''}`, color: 'cyan' }, { text: s.lastCompact ? ` · last ${fmtTokens(s.lastCompact.before)} → ${fmtTokens(s.lastCompact.after)} (${s.lastCompact.trigger})` : '', dim: true })
900      : null,
901  ].filter(Boolean)
902  const alertsBody = alertLines.length ? alertLines : line({ text: 'no loops or compactions yet', dim: true })
903
904  // history
905  const recent = s.turns.slice(-10)
906  const historyBody =
907    recent.length === 0
908      ? line({ text: 'no finished turns yet', dim: true })
909      : [
910          line({ text: sparkline(recent.map(t => t.tokensIn)), color: 'cyan' }, { text: '  input tokens per turn', dim: true }),
911          line({
912            text: recent
913              .slice(-3)
914              .map(t => `${fmtDur(t.durationMs)} ${t.tools}t ${fmtUsd(t.costUsd)}`)
915              .join(' · '),
916            dim: true,
917          }),
918        ]
919
920
921  // ---------- details, drawn only for an open section ----------
922
923  const hogLines = (limit: number) =>
924    (s.topOutputs ?? []).slice(0, limit).map(o =>
925      line(
926        { text: pad(`~${fmtTokens(o.tokens)}`, 8), color: o.tokens >= 10_000 ? 'red' : o.tokens >= 3000 ? 'yellow' : undefined, bold: true },
927        { text: pad(o.tool.replace(/^mcp__[^_]+__/, ''), 9) },
928        { text: o.agentId ? '↳ ' : '', color: 'magenta' },
929        { text: shorten(o.label || '(no input)', Math.max(10, cols - labelWidth - 24)), dim: true },
930      ),
931    )
932
933  const contextDetails = () => [
934    line({ text: `${s.model || 'model'} · window ${fmtTokens(s.ctxWindow)} · free ${fmtTokens(s.ctxFree || (s.ctxWindow - (s.ctxTokens ?? 0)))}`, dim: true }),
935    ...s.ctxParts.map(c =>
936      line(
937        { text: pad(c.name, nameWidth) },
938        { text: mini(s.ctxWindow ? (c.tokens / s.ctxWindow) * 100 : 0), color: 'cyan' },
939        { text: ` ${fmtTokens(c.tokens)}`, dim: true },
940      ),
941    ),
942    (s.topOutputs ?? []).length ? line({ text: 'biggest tool outputs (context hogs)', bold: true }) : null,
943    ...hogLines(3),
944    s.ctxMemory.length ? line({ text: 'memory files', bold: true }) : null,
945    ...s.ctxMemory.map(m => line({ text: pad(shorten(relPath(m.path, s.cwd), nameWidth), nameWidth) }, { text: fmtTokens(m.tokens), dim: true })),
946    s.ctxMcp.length ? line({ text: 'MCP servers (tool schemas)', bold: true }) : null,
947    ...s.ctxMcp.map(m => line({ text: pad(m.server, nameWidth) }, { text: `${fmtTokens(m.tokens)} · ${m.tools} tools`, dim: true })),
948    line({
949      text: (s.ctxPercent ?? 0) >= 80 ? 'tip: run /compact now, before it runs on its own mid-task' : 'tip: /context draws the full grid',
950      color: (s.ctxPercent ?? 0) >= 80 ? 'yellow' : undefined,
951      dim: (s.ctxPercent ?? 0) < 80,
952    }),
953  ]
954
955  const limitsDetails = () =>
956    s.limits.length === 0
957      ? [line({ text: 'Rate-limit windows are reported on a Pro or Max subscription after the first response.', dim: true })]
958      : s.limits.map(l => {
959          const first = s.limitStart?.[l.kind]
960          const hours = first ? (now - first.at) / 3_600_000 : 0
961          const perHour = first && hours > 0.08 ? (l.percentUsed - first.pct) / hours : undefined
962          const eta = paceEta(s, l.kind, now)
963          return [
964            line({ text: `${limitLabel(l.kind)} window`, bold: true }, { text: ` · ${l.percentUsed}% used · ${resetIn(l.resetsAt, now) || 'reset time unknown'}`, dim: true }),
965            line({
966              text:
967                perHour === undefined
968                  ? '  pace: measuring (needs a few minutes)'
969                  : `  pace: +${perHour.toFixed(1)}% per hour this session${eta !== undefined ? ` · full in ${fmtDur(eta)}` : ''}`,
970              dim: true,
971            }),
972          ]
973        })
974
975  const costDetails = () => [
976    line({ text: `session ${fmtUsd(s.costUsd)} · pace ${burn === undefined ? '—' : `${fmtUsd(burn)}/h`} · cache hits ${cache === undefined ? '—' : `${cache}%`}`, dim: true }),
977    line({ text: `cache served ${fmtTokens(s.cacheRead)} of ${fmtTokens(s.inputTotal)} input tokens`, dim: true }),
978    s.turns.length ? line({ text: 'last turns', bold: true }) : null,
979    ...s.turns
980      .slice(-6)
981      .reverse()
982      .map(t =>
983        line(
984          { text: pad(fmtUsd(t.costUsd), 8) },
985          { text: `${pad(fmtDur(t.durationMs), 7)}${pad(`${fmtTokens(t.tokensIn)} in`, 10)}cache ${t.tokensIn ? Math.round((t.cacheRead / t.tokensIn) * 100) : 0}%`, dim: true },
986        ),
987      ),
988    !isBudgetControlShown(p, s) ? line({ text: 'budget: hidden on a subscription · /cockpit-budget on shows it', dim: true }) : line({
989      text: `budget: ${p.budgetMode}${p.budgetUsd > 0 ? ` · ${fmtUsd(p.budgetUsd)}` : ' · not set'}${isSubscription(s) ? ' · subscription detected' : ' · API billing'}`,
990      dim: true,
991    }),
992    Object.keys(s.models ?? {}).length ? line({ text: 'by model', bold: true }) : null,
993    ...Object.entries(s.models ?? {})
994      .sort((a, b) => b[1].tokensIn - a[1].tokensIn)
995      .map(([model, m]) =>
996        line(
997          { text: pad(shortModel(model), nameWidth) },
998          { text: `${pad(`${fmtTokens(m.tokensIn)} in`, 10)}${pad(`${fmtTokens(m.tokensOut)} out`, 10)}${m.turns} runs · cache ${m.tokensIn ? Math.round((m.cacheRead / m.tokensIn) * 100) : 0}%`, dim: true },
999        ),
1000      ),
1001    <Box flexDirection="row" gap={1}>
1002      <Text dimColor>{`cache lifetime assumed: ${p.cacheTtlMin} min`}</Text>
1003      <Button
1004        key="cache-ttl"
1005        plain
1006        dimColor
1007        label={p.cacheTtlMin === 60 ? 'use 5 min' : 'use 1 hour'}
1008        onPress={() => update($, prefs, cur => ({ ...cur, cacheTtlMin: cur.cacheTtlMin === 60 ? 5 : 60 }))}
1009      />
1010    </Box>,
1011    line({ text: `all-time spend ${fmtUsd(s.lifetimeUsd)}`, dim: true }),
1012  ]
1013
1014  const turnDetails = () => {
1015    const log = (s.turnLog ?? []).slice(-10)
1016    return log.length === 0
1017      ? [line({ text: 'no tool calls in this turn yet', dim: true })]
1018      : [
1019          line({ text: s.turnActive ? 'this turn, latest last' : 'last turn, latest last', dim: true }),
1020          ...log.map(entry =>
1021            line(
1022              { text: entry.isError ? '✗ ' : '✓ ', color: entry.isError ? 'red' : 'green' },
1023              { text: entry.agentId ? '↳ ' : '', color: 'magenta' },
1024              { text: pad(entry.tool.replace(/^mcp__[^_]+__/, ''), 12), bold: true },
1025              { text: pad(fmtDur(entry.ms), 6), dim: true },
1026              { text: shorten(entry.label, Math.max(10, cols - labelWidth - 26)), dim: true },
1027            ),
1028          ),
1029        ]
1030  }
1031
1032  const toolsDetails = () => [
1033    line({ text: `${pad('  tool', 16)}${pad('calls', 7)}${pad('errors', 8)}${pad('avg', 7)}max`, dim: true, bold: true }),
1034    ...Object.entries(s.tools)
1035      .sort((a, b) => b[1].calls - a[1].calls)
1036      .slice(0, 12)
1037      .map(([name, t]) => {
1038        const timing = s.toolMs?.[name]
1039        const isToolOpen = p.toolOpen.includes(name)
1040        const calls = (s.callLog ?? []).filter(c => c.tool === name).slice(-8).reverse()
1041        const okShare = t.calls ? Math.round(((t.calls - t.errors) / t.calls) * 100) : 100
1042        return (
1043          <Box flexDirection="column">
1044            <Box flexDirection="row">
1045              <Button
1046                key={`tool-${name}`}
1047                plain
1048                dimColor={!isToolOpen}
1049                label={pad(`${isToolOpen ? '▾' : '▸'} ${name.replace(/^mcp__[^_]+__/, '')}`, 16)}
1050                onPress={() =>
1051                  update($, prefs, cur => ({
1052                    ...cur,
1053                    toolOpen: cur.toolOpen.includes(name) ? cur.toolOpen.filter(x => x !== name) : [...cur.toolOpen, name].slice(-8),
1054                  }))
1055                }
1056              />
1057              {line(
1058                { text: pad(String(t.calls), 7) },
1059                { text: pad(String(t.errors), 8), color: t.errors ? 'red' : undefined, dim: !t.errors },
1060                { text: `${pad(timing ? fmtDur(timing.totalMs / Math.max(1, t.calls)) : '—', 7)}${timing ? fmtDur(timing.maxMs) : '—'}`, dim: true },
1061              )}
1062            </Box>
1063            {isToolOpen ? (
1064              <Box flexDirection="column" paddingLeft={2}>
1065                {line(
1066                  { text: `${okShare}% ok`, color: okShare >= 90 ? 'green' : okShare >= 70 ? 'yellow' : 'red' },
1067                  { text: ` · total ${fmtDur(timing?.totalMs ?? 0)} · last ${calls.length} calls, newest first`, dim: true },
1068                )}
1069                {calls.map(c => [
1070                  line(
1071                    { text: c.isError ? '✗ ' : '✓ ', color: c.isError ? 'red' : 'green' },
1072                    { text: pad(fmtAgo(now - c.at), 10), dim: true },
1073                    { text: pad(fmtDur(c.ms), 6) },
1074                    { text: c.agentId ? '↳ ' : '', color: 'magenta' },
1075                    { text: shorten(c.label || '(no input)', Math.max(10, cols - labelWidth - 30)), dim: true },
1076                  ),
1077                  c.isError && c.errorText ? line({ text: `    ${shorten(c.errorText, Math.max(10, cols - labelWidth - 8))}`, color: 'red' }) : null,
1078                ])}
1079              </Box>
1080            ) : null}
1081          </Box>
1082        )
1083      }),
1084  ]
1085
1086  const toolsDetailsWithHogs = () => [
1087    ...toolsDetails(),
1088    line({ text: 'biggest outputs (context hogs)', bold: true }),
1089    ...((s.topOutputs ?? []).length ? hogLines(8) : [line({ text: 'no tool output over ~500 tokens yet', dim: true })]),
1090  ]
1091
1092  const filesDetails = () => {
1093    const edited = Object.entries(s.edits).sort((a, b) => b[1] - a[1])
1094    return [
1095      ...(s.diff.length
1096        ? s.diff.slice(0, 20).map(f => {
1097            const isViewed = s.fileView?.path === f.path
1098            const width = Math.max(12, cols - labelWidth - 20)
1099            return (
1100              <Box flexDirection="column">
1101                <Box flexDirection="row">
1102                  <Button
1103                    key={`file-${f.path}`}
1104                    plain
1105                    dimColor={!isViewed}
1106                    label={pad(`${isViewed ? '▾' : '▸'} ${shorten(f.path, width - 3)}`, width)}
1107                    onPress={() => (isViewed ? update($, stats, cur => ({ ...cur, fileView: null })) : loadFileView($, f.path, f.add < 0))}
1108                  />
1109                  {f.add < 0 ? <Text color="cyan">new</Text> : <Text color="green">{`+${f.add}`}</Text>}
1110                  {f.add < 0 ? null : <Text color="red">{` −${f.del}`}</Text>}
1111                </Box>
1112                {isViewed && s.fileView ? (
1113                  <Box flexDirection="column" paddingLeft={2}>
1114                    {s.fileView.lines.map(l =>
1115                      line({
1116                        text: shorten(l || ' ', Math.max(10, cols - labelWidth - 8)),
1117                        color: l.startsWith('@@') ? 'cyan' : l.startsWith('+') ? 'green' : l.startsWith('-') ? 'red' : undefined,
1118                        dim: !(l.startsWith('+') || l.startsWith('-') || l.startsWith('@@')),
1119                      }),
1120                    )}
1121                  </Box>
1122                ) : null}
1123              </Box>
1124            )
1125          })
1126        : [line({ text: 'no uncommitted changes vs HEAD (or not a git repository)', dim: true })]),
1127      edited.length ? line({ text: 'edited by Claude this session', bold: true }) : null,
1128      ...edited.slice(0, 10).map(([path, n]) => line({ text: shorten(relPath(path, s.cwd), Math.max(12, cols - labelWidth - 14)) }, { text: `  ${n}×`, dim: true })),
1129    ]
1130  }
1131
1132  const testsDetails = () =>
1133    (s.testHistory ?? []).length === 0
1134      ? [line({ text: 'Runs of npm/pnpm/yarn test, jest, vitest, pytest, go test, cargo test… show up here.', dim: true })]
1135      : (s.testHistory ?? [])
1136          .slice()
1137          .reverse()
1138          .map(t =>
1139            line(
1140              { text: t.isOk ? '✓ ' : '✗ ', color: t.isOk ? 'green' : 'red' },
1141              { text: pad(fmtAgo(now - t.at), 10), dim: true },
1142              { text: `${t.passed !== undefined ? `${t.passed} passed ` : ''}${t.failed ? `${t.failed} failed ` : ''}`, color: t.failed ? 'red' : undefined },
1143              { text: t.command, dim: true },
1144            ),
1145          )
1146
1147  const guardDetails = () => [
1148    ...(s.guardLog.length === 0
1149      ? [line({ text: 'nothing stopped yet', dim: true })]
1150      : s.guardLog
1151          .slice()
1152          .reverse()
1153          .map(g =>
1154            line(
1155              { text: g.verdict === 'blocked' ? '✗ blocked ' : '✓ allowed ', color: g.verdict === 'blocked' ? 'red' : 'yellow' },
1156              { text: pad(fmtAgo(now - g.at), 10), dim: true },
1157              { text: `${g.reason}: `, dim: true },
1158              { text: shorten(g.command, Math.max(10, cols - labelWidth - 40)) },
1159            ),
1160          )),
1161    line({ text: 'watches: rm -rf · force push · reset --hard · clean -f · checkout -- . · sudo · curl|sh · DROP TABLE · kubectl delete · terraform destroy · npm publish · .env and key files', dim: true }),
1162  ]
1163
1164  const alertsDetails = () => [
1165    ...(s.loopLog ?? []).slice().reverse().map(l => line({ text: '⟳ ', color: 'yellow' }, { text: pad(fmtAgo(now - l.at), 10), dim: true }, { text: shorten(l.text, Math.max(10, cols - labelWidth - 16)) })),
1166    ...(s.compactLog ?? []).slice().reverse().map(c => line({ text: '⇣ ', color: 'cyan' }, { text: pad(fmtAgo(now - c.at), 10), dim: true }, { text: `compacted ${fmtTokens(c.before)} → ${fmtTokens(c.after)} (${c.trigger})` })),
1167    (s.loopLog ?? []).length + (s.compactLog ?? []).length === 0 ? line({ text: 'A loop alert fires when the same call repeats 3 times within 8 calls.', dim: true }) : null,
1168  ]
1169
1170  const historyDetails = () => [
1171    line({ text: `${pad('#', 4)}${pad('time', 7)}${pad('steps', 7)}${pad('tools', 7)}${pad('in', 7)}${pad('out', 7)}${p.isCostHidden ? '' : pad('cost', 8)}end`, dim: true, bold: true }),
1172    ...s.turns
1173      .slice(-10)
1174      .map((t, i, list) =>
1175        line({
1176          text: `${pad(String(s.turns.length - list.length + i + 1), 4)}${pad(fmtDur(t.durationMs), 7)}${pad(String(t.steps), 7)}${pad(String(t.tools), 7)}${pad(fmtTokens(t.tokensIn), 7)}${pad(fmtTokens(t.tokensOut), 7)}${p.isCostHidden ? '' : pad(fmtUsd(t.costUsd), 8)}${t.reason}`,
1177        }),
1178      ),
1179  ]
1180
1181  const lifetimeDetails = () => [
1182    line({ text: `per session: ${s.lifetimeSessions ? (s.lifetimeTurns / s.lifetimeSessions).toFixed(1) : '—'} turns · ${p.isCostHidden ? '' : `${fmtUsd(s.lifetimeSessions ? s.lifetimeUsd / s.lifetimeSessions : 0)} · `}${s.lifetimeTurns ? (s.lifetimeTools / s.lifetimeTurns).toFixed(1) : '—'} tools per turn`, dim: true }),
1183    line({ text: 'kept on this machine in the plugin store; Reset clears this session only', dim: true }),
1184  ]
1185
1186  const lifetimeBody = line({
1187    text: `${s.lifetimeSessions} sessions · ${s.lifetimeTurns} turns · ${s.lifetimeTools} tools · ${fmtUsd(s.lifetimeUsd)}`,
1188    dim: true,
1189  })
1190
1191  return (
1192    <Box flexDirection="column">
1193      {header}
1194      {row('CONTEXT', contextBody, contextDetails)}
1195      {row('LIMITS', limitsBody, limitsDetails)}
1196      {p.isCostHidden ? null : row('COST', costBody, costDetails)}
1197      {row('TURN', turnBody, turnDetails)}
1198      {row('AGENTS', agentsBody, undefined, () => update($, prefs, cur => ({ ...cur, isAgentsOpen: !cur.isAgentsOpen })), p.isAgentsOpen)}
1199      {row('TOOLS', toolsBody, toolsDetailsWithHogs)}
1200      {row('FILES', fileRows, filesDetails)}
types/index.d.ts 142 lines
1export type CockpitLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type CockpitTurnRecord = {
4  durationMs: number
5  tools: number
6  steps: number
7  tokensIn: number
8  tokensOut: number
9  costUsd: number
10  cacheRead: number
11  reason: string
12}
13
14export type CockpitTest = {
15  command: string
16  isOk: boolean
17  passed?: number
18  failed?: number
19  at: number
20}
21
22export type CockpitDiffFile = { path: string; add: number; del: number }
23
24export type CockpitGuardEvent = { command: string; reason: string; verdict: 'allowed' | 'blocked'; at: number }
25
26export type CockpitAgent = {
27  id: string
28  type: string
29  description: string
30  name?: string
31  status: string
32  parentId?: string
33  startedAt: number
34  endedAt?: number
35  steps: number
36  tools: number
37  errors: number
38  toolCounts: Record<string, number>
39  lastTool: string
40  tokensIn: number
41  tokensOut: number
42  result?: string
43}
44
45export type CockpitCall = {
46  tool: string
47  label: string
48  ms: number
49  isError: boolean
50  errorText?: string
51  agentId?: string
52  at: number
53}
54
55export type CockpitStats = {
56  startedAt: number
57  model: string
58  cwd: string
59  ctxTokens?: number
60  ctxWindow: number
61  ctxPercent?: number
62  limits: CockpitLimit[]
63  /** a subscription was seen (now or in an earlier session) */
64  isPlan?: boolean
65  costUsd: number
66  turnActive: boolean
67  turnStartedAt: number
68  turnSteps: number
69  turnTools: number
70  turnCostAtStart: number
71  tools: Record<string, { calls: number; errors: number }>
72  toolTotal: number
73  toolErrors: number
74  edits: Record<string, number>
75  diff: CockpitDiffFile[]
76  diffAdd: number
77  diffDel: number
78  lastTest: CockpitTest | null
79  testRuns: number
80  testFails: number
81  turns: CockpitTurnRecord[]
82  guardBlocked: number
83  guardAllowed: number
84  guardLog: CockpitGuardEvent[]
85  warned: string[]
86  lifetimeTurns: number
87  lifetimeTools: number
88  lifetimeUsd: number
89  lifetimeSessions: number
90  ctxParts: { name: string; tokens: number }[]
91  cacheRead: number
92  inputTotal: number
93  toolMs: Record<string, { totalMs: number; maxMs: number }>
94  slowest: { tool: string; ms: number; label: string } | null
95  recentSigs: string[]
96  loopSigs: string[]
97  loopAlerts: number
98  lastLoop: string
99  agentsActive: string[]
100  agentsSpawned: number
101  compactions: number
102  lastCompact: { before?: number; after?: number; trigger: string } | null
103  limitStart: Record<string, { pct: number; at: number }>
104  secretsBlocked: number
105  agents: Record<string, CockpitAgent>
106  turnLog: { tool: string; label: string; ms: number; isError: boolean; agentId?: string; at: number }[]
107  testHistory: CockpitTest[]
108  loopLog: { text: string; at: number }[]
109  compactLog: { before?: number; after?: number; trigger: string; at: number }[]
110  ctxFree: number
111  ctxMemory: { path: string; tokens: number }[]
112  ctxMcp: { server: string; tokens: number; tools: number }[]
113  callLog: CockpitCall[]
114  topOutputs: { tool: string; label: string; tokens: number; agentId?: string; at: number }[]
115  lastResponseAt: number
116  models: Record<string, { tokensIn: number; tokensOut: number; cacheRead: number; turns: number }>
117  fileView: { path: string; lines: string[]; at: number } | null
118}
119
120export type CockpitPrefs = {
121  isCompact: boolean
122  isGuardOn: boolean
123  isStatusOn: boolean
124  isBandOn: boolean
125  budgetUsd: number
126  budgetMode: 'auto' | 'on' | 'off'
127  isCostHidden: boolean
128  isAgentsOpen: boolean
129  agentOpen: string[]
130  openSections: string[]
131  toolOpen: string[]
132  cacheTtlMin: number
133}
134
135export type CockpitPane = { isPlaced: boolean; reason: string }
136
137declare module 'claude-code' {
138  interface PluginState {
139    cockpit: { stats: CockpitStats; prefs: CockpitPrefs; pane: CockpitPane }
140  }
141}
142