One control pane for compaction pins, model routing, a project map, a stuck detector and a UI inspection gate.

A Claude Code mod that puts five helpers behind one pane, so you control them with buttons instead of remembering commands.
| Tab | What it does |
|---|---|
| Home | An on/off switch for each feature, live counts, and tokens used per model. |
| Compact | Pin tool calls and write keep-rules. After a compaction, anything pinned or matched that was dropped or shortened is put back whole. A ledger shows what each compaction did. |
| Map | The project's goal, its tasks by status, and the decisions made. Claude keeps it current through a map tool, todo lists are mirrored into it, and it is saved per project so the next session starts with it. |
| Stuck | Flags the same call repeated, a run of failed calls, one file edited over and over, and a turn with no activity. Claude is told to stop and change approach, and you get a toast. |
| Inspect | When a turn ends with UI files edited and no screenshot taken since, Claude is sent back once with a checklist: layout at two widths, spacing, text, states, console. |
| Architect | The main session plans and reviews. Subagents tagged [easy], [medium] or [hard] run on the model you pick for that level (haiku, sonnet and opus by default). |
In a Claude Code terminal session:
/plugin install cockpit --marketplace 1kaliel/claude-cockpit
Answer y to add the marketplace, then pick a scope. The user scope loads it in every session, the desktop app's Code tab included.
The pane opens when a session starts. If the window is too narrow for it, type /cockpit.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
The logic lives in hooks/lib as plain functions with tests in tests/. hooks/register.tsx holds the hooks and the pane.
MIT
hooks/register.tsx 804 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 Alert,
6 AlertKind,
7 Config,
8 InspectState,
9 LedgerEntry,
10 Level,
11 ProjectMap,
12 RecentCall,
13 Route,
14 Tab,
15 TaskStatus,
16 Usage,
17} from '../types'
18import { chars, protectedIds, restore } from './lib/guard'
19import { checklist, editedFile, isUiFile, isVisualCheck } from './lib/inspect'
20import {
21 asMap,
22 bar,
23 brief,
24 EMPTY_MAP,
25 fromTodos,
26 MAP_PROMPT,
27 MAP_SCHEMA,
28 mergeMap,
29 progress,
30} from './lib/map'
31import { ARCHITECT_PROMPT, levelOf } from './lib/route'
32import { freshLoop, observe, signature } from './lib/stuck'
33import type { Loop } from './lib/stuck'
34
35const PANE = 'cockpit'
36const MAP_TOOL = 'mcp__cockpit__map'
37const MAIN = 'main'
38const STALL_CHECK_MS = 30_000
39
40const DEFAULTS: Config = {
41 guard: true,
42 stuck: true,
43 inspect: true,
44 architect: false,
45 map: true,
46 meter: true,
47 repeatLimit: 3,
48 errorLimit: 4,
49 stallMinutes: 5,
50 models: { easy: 'haiku', medium: 'sonnet', hard: 'opus' },
51 rules: [],
52}
53
54const NO_INSPECTION: InspectState = { pending: [], lastCheck: '', nudges: 0 }
55
56const tab = atom({ plugin: 'cockpit', key: 'tab' } as const, 'home')
57const config = atom({ plugin: 'cockpit', key: 'config' } as const, DEFAULTS)
58const pins = atom({ plugin: 'cockpit', key: 'pins' } as const, [])
59const recent = atom({ plugin: 'cockpit', key: 'recent' } as const, [])
60const ledger = atom({ plugin: 'cockpit', key: 'ledger' } as const, [])
61const map = atom({ plugin: 'cockpit', key: 'map' } as const, EMPTY_MAP)
62const alerts = atom({ plugin: 'cockpit', key: 'alerts' } as const, [])
63const routes = atom({ plugin: 'cockpit', key: 'routes' } as const, [])
64const inspect = atom({ plugin: 'cockpit', key: 'inspect' } as const, NO_INSPECTION)
65const usage = atom({ plugin: 'cockpit', key: 'usage' } as const, {})
66
67const TABS: readonly { id: Tab; label: string }[] = [
68 { id: 'home', label: 'Home' },
69 { id: 'compact', label: 'Compact' },
70 { id: 'map', label: 'Map' },
71 { id: 'stuck', label: 'Stuck' },
72 { id: 'inspect', label: 'Inspect' },
73 { id: 'architect', label: 'Architect' },
74]
75
76const FEATURES: readonly {
77 key: 'guard' | 'stuck' | 'inspect' | 'architect' | 'map' | 'meter'
78 label: string
79 about: string
80}[] = [
81 { key: 'guard', label: 'Compact guard', about: 'pins and rules survive compaction' },
82 { key: 'stuck', label: 'Stuck detector', about: 'loops, error streaks, thrashing, stalls' },
83 { key: 'inspect', label: 'Pixel inspector', about: 'UI edits need a screenshot check' },
84 { key: 'architect', label: 'Architect', about: 'plan, delegate by difficulty, review' },
85 { key: 'map', label: 'Project map', about: 'goal, tasks and decisions, kept per project' },
86 { key: 'meter', label: 'Token meter', about: 'tokens per model in the status line' },
87]
88
89const MODELS = ['haiku', 'sonnet', 'opus', 'inherit'].map(value => ({ value }))
90const LEVELS: readonly Level[] = ['easy', 'medium', 'hard']
91const NEXT_STATUS: Record<TaskStatus, TaskStatus> = {
92 todo: 'doing',
93 doing: 'done',
94 done: 'todo',
95 blocked: 'doing',
96}
97
98function isRecord(value: unknown): value is Record<string, unknown> {
99 return typeof value === 'object' && value !== null && !Array.isArray(value)
100}
101
102function options(values: readonly number[]): { value: string }[] {
103 return values.map(value => ({ value: String(value) }))
104}
105
106function short(text: string, limit: number): string {
107 return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`
108}
109
110function thousands(count: number): string {
111 return count < 1000 ? String(count) : `${(count / 1000).toFixed(1)}k`
112}
113
114function preview(args: Record<string, unknown>): string {
115 const main = args.file_path ?? args.command ?? args.pattern ?? args.url ?? args.description
116 const text = typeof main === 'string' ? main : (JSON.stringify(args) ?? '')
117
118 return short(text.replace(/\s+/g, ' '), 60)
119}
120
121function loadConfig(saved: unknown): Config {
122 if (!isRecord(saved)) {
123 return DEFAULTS
124 }
125 const models = isRecord(saved.models) ? saved.models : {}
126
127 return {
128 ...DEFAULTS,
129 ...saved,
130 models: { ...DEFAULTS.models, ...models },
131 rules: Array.isArray(saved.rules)
132 ? saved.rules.filter((rule): rule is string => typeof rule === 'string')
133 : [],
134 } as Config
135}
136
137async function setConfig($: EngineInterface, change: (held: Config) => Config): Promise<void> {
138 await update($, config, change)
139 await $.store.set('config', await read($, config))
140}
141
142async function setMap(
143 $: EngineInterface,
144 change: (held: ProjectMap) => ProjectMap,
145): Promise<ProjectMap> {
146 await update($, map, change)
147 const held = await read($, map)
148 await $.store.set(`map:${await $.session.root()}`, held)
149
150 return held
151}
152
153async function raise($: EngineInterface, kind: AlertKind, text: string): Promise<void> {
154 const alert: Alert = { at: await $.clock.now(), kind, text }
155 await update($, alerts, list => [...list, alert].slice(-30))
156 $.ui.toast(short(text, 160), { timeoutMs: 10_000 })
157}
158
159function meterLine(totals: Usage): string | undefined {
160 const models = Object.keys(totals)
161 if (models.length === 0) {
162 return undefined
163 }
164 let input = 0
165 let output = 0
166 for (const model of models) {
167 input += totals[model]?.input ?? 0
168 output += totals[model]?.output ?? 0
169 }
170
171 return `cockpit ${thousands(input)} in / ${thousands(output)} out`
172}
173
174export const register: Register = on => {
175 // Counters a reload may lose: they only pace alerts within one turn.
176 const loops = new Map<string, Loop>()
177 let isTurnRunning = false
178 let lastActivity = 0
179 let hasStalled = false
180 let isBriefed = false
181
182 on('session.start', async ($, e, next) => {
183 const saved = loadConfig(await $.store.get('config'))
184 await update($, config, () => saved)
185 const stored = asMap(await $.store.get(`map:${await $.session.root()}`))
186 await update($, map, () => stored)
187
188 await $.command.register({
189 name: 'cockpit',
190 description: 'Open the Cockpit pane: compaction, routing, map, stuck and UI checks',
191 })
192 await $.tool.register({
193 name: 'map',
194 description:
195 "Updates the user's live project map: the goal, tasks (merged by id; status todo, doing, done or blocked) and decisions. Send only what changed.",
196 inputSchema: MAP_SCHEMA,
197 isDeferred: false,
198 })
199 void $.ui.open({ id: PANE, title: 'Cockpit' })
200
201 $.clock.every(STALL_CHECK_MS, () => {
202 void (async () => {
203 const held = await read($, config)
204 const quietMs = (await $.clock.now()) - lastActivity
205 if (!held.stuck || !isTurnRunning || hasStalled || quietMs < held.stallMinutes * 60_000) {
206 return
207 }
208 hasStalled = true
209 await raise(
210 $,
211 'stall',
212 `cockpit: no tool activity for ${held.stallMinutes} minutes in a running turn. Claude may be stuck; interrupt with Esc if nothing is moving.`,
213 )
214 })()
215 })
216
217 return next(e)
218 })
219
220 on('command.run', { command: 'cockpit' }, async $ => {
221 await $.ui.open({ id: PANE, title: 'Cockpit', focus: true })
222
223 return { text: 'Cockpit opened.' }
224 })
225
226 on('prompt.compose', async ($, e, next) => {
227 const composed = await next(e)
228 const held = await read($, config)
229 const sections = [...composed.sections]
230 if (held.architect) {
231 sections.push({ id: 'cockpit:architect', text: ARCHITECT_PROMPT, scope: 'session' })
232 }
233 if (held.map) {
234 sections.push({ id: 'cockpit:map', text: MAP_PROMPT, scope: 'session' })
235 }
236
237 return { sections }
238 })
239
240 on('prompt.submit', async ($, e, next) => {
241 if (e.origin.kind === 'plugin') {
242 return next(e)
243 }
244 // The person spoke: the inspector may ask once more, and the first prompt
245 // of a session carries the map kept from earlier ones.
246 await update($, inspect, held => (held.nudges === 0 ? held : { ...held, nudges: 0 }))
247 if (isBriefed) {
248 return next(e)
249 }
250 isBriefed = true
251 const held = await read($, config)
252 const text = held.map ? brief(await read($, map)) : undefined
253
254 return text === undefined ? next(e) : next({ ...e, context: [...(e.context ?? []), text] })
255 })
256
257 on('turn.start', async ($, e, next) => {
258 loops.set(MAIN, freshLoop())
259 isTurnRunning = true
260 hasStalled = false
261 lastActivity = await $.clock.now()
262
263 return next(e)
264 })
265
266 on('tool.call', async ($, e, next) => {
267 const tool = String(e.tool)
268 const args = e as unknown as Record<string, unknown>
269 const now = await $.clock.now()
270
271 if (tool === MAP_TOOL) {
272 const held = await setMap($, before => mergeMap(before, args, now))
273 const { done, total } = progress(held)
274
275 return { result: `Map saved: ${done} of ${total} tasks done.` }
276 }
277
278 const held = await read($, config)
279 const call: RecentCall = { id: e.tool_use_id, tool, preview: preview(args) }
280 await update($, recent, list => [...list, call].slice(-40))
281 if (held.map && tool === 'TodoWrite') {
282 await setMap($, before => fromTodos(before, args.todos, now))
283 }
284
285 lastActivity = now
286 const ran = await next(e)
287 lastActivity = await $.clock.now()
288 hasStalled = false
289 if (ran.deny !== undefined) {
290 return ran
291 }
292
293 const isError = ran.isError === true
294 const file = editedFile(tool, args)
295 if (!isError && file !== undefined && isUiFile(file)) {
296 await update($, inspect, before =>
297 before.pending.includes(file)
298 ? before
299 : { ...before, pending: [...before.pending, file].slice(-50) },
300 )
301 } else if (!isError && isVisualCheck(tool, args)) {
302 await update($, inspect, before =>
303 before.pending.length === 0 ? before : { ...before, pending: [], lastCheck: tool },
304 )
305 }
306
307 if (!held.stuck) {
308 return ran
309 }
310 const id = e.agentId ?? MAIN
311 const loop = loops.get(id) ?? freshLoop()
312 loops.set(id, loop)
313 const seen = { tool, signature: signature(tool, args), isError }
314 const found = observe(loop, file === undefined ? seen : { ...seen, file }, held)
315 if (found === undefined) {
316 return ran
317 }
318 await raise($, found.kind, found.text)
319
320 return { ...ran, context: [...(ran.context ?? []), found.text] }
321 })
322
323 on('agent.spawn', async ($, e, next) => {
324 const held = await read($, config)
325 if (!held.architect || e.fork || e.workflow !== undefined || e.isTeammate === true) {
326 return next(e)
327 }
328 const { level, why } = levelOf(e.description, e.prompt)
329 const model = held.models[level]
330 // A guess never overrides a model the caller chose; a tag does.
331 if (model === 'inherit' || (why === 'guess' && e.model !== undefined)) {
332 return next(e)
333 }
334 const route: Route = {
335 at: await $.clock.now(),
336 task: short(e.description, 50),
337 level,
338 model,
339 why,
340 }
341 await update($, routes, list => [...list, route].slice(-30))
342
343 return next({ ...e, model })
344 })
345
346 on('turn.complete', async ($, e, next) => {
347 const held = await read($, config)
348 const spent = e.usage
349 if (spent !== undefined) {
350 await update($, usage, totals => {
351 const before = totals[spent.model] ?? { input: 0, output: 0, cached: 0, turns: 0 }
352
353 return {
354 ...totals,
355 [spent.model]: {
356 input: before.input + spent.input_tokens + spent.cache_creation_input_tokens,
357 output: before.output + spent.output_tokens,
358 cached: before.cached + spent.cache_read_input_tokens,
359 turns: before.turns + 1,
360 },
361 }
362 })
363 }
364 $.ui.status(held.meter ? meterLine(await read($, usage)) : undefined)
365
366 if (e.agentId !== undefined) {
367 loops.delete(e.agentId)
368
369 return next(e)
370 }
371 isTurnRunning = false
372
373 const owed = await read($, inspect)
374 if (held.inspect && e.reason === 'answer' && owed.pending.length > 0 && owed.nudges === 0) {
375 await update($, inspect, before => ({ ...before, nudges: before.nudges + 1 }))
376 $.ui.toast('Cockpit: UI changed with no visual check. Asking for an inspection pass.')
377 // Not awaited: the prompt's turn starts only once this one has ended.
378 void $.prompt.submit({ text: checklist(owed.pending) }).catch(() => undefined)
379 }
380
381 return next(e)
382 })
383
384 on('session.compact', async ($, e, next) => {
385 const result = await next(e)
386 try {
387 const held = await read($, config)
388 const base = { at: await $.clock.now(), trigger: e.trigger, charsBefore: chars(e.messages) }
389 const log = (entry: LedgerEntry) => update($, ledger, list => [...list, entry].slice(-20))
390 if (result.skip !== undefined) {
391 await log({ ...base, charsAfter: base.charsBefore, restored: 0, note: 'skipped' })
392
393 return result
394 }
395 if (!held.guard) {
396 await log({ ...base, charsAfter: chars(result.messages), restored: 0, note: 'guard off' })
397
398 return result
399 }
400
401 const pinned = new Set((await read($, pins)).map(pin => pin.id))
402 const keep = protectedIds(e.messages, pinned, held.rules)
403 const fixed = restore(e.messages, result.messages, keep)
404 await log({
405 ...base,
406 charsAfter: chars(fixed.messages),
407 restored: fixed.restored,
408 note: `${keep.size} protected`,
409 })
410 if (fixed.restored === 0) {
411 return result
412 }
413 $.ui.toast(`Cockpit: put back ${fixed.restored} protected tool results after compaction.`)
414 const { tokensAfter: _stale, ...kept } = result
415
416 return { ...kept, messages: fixed.messages }
417 } catch {
418 return result
419 }
420 })
421
422 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
423 const table = $.ui.resolve(e)
424 const { Box, Text, Button } = table
425 const Input = 'Input' in table ? table.Input : undefined
426 const Select = 'Select' in table ? table.Select : undefined
427
428 const shown = await read($, tab)
429 const held = await read($, config)
430 const width = Math.max(24, e.props.bodyColumns)
431 const fit = (text: string) => short(text, width - 2)
432
433 const toggle = (key: (typeof FEATURES)[number]['key'], label: string) => (
434 <Button
435 key={`toggle:${key}`}
436 label={`${label}: ${held[key] ? 'ON' : 'off'}`}
437 variant={held[key] ? 'primary' : 'secondary'}
438 onPress={() => setConfig($, before => ({ ...before, [key]: !before[key] }))}
439 />
440 )
441
442 const number = (
443 key: 'repeatLimit' | 'errorLimit' | 'stallMinutes',
444 label: string,
445 values: readonly number[],
446 ) =>
447 Select === undefined ? (
448 <Text>
449 {label}: {String(held[key])}
450 </Text>
451 ) : (
452 <Select
453 key={`pick:${key}`}
454 label={label}
455 options={options(values)}
456 value={String(held[key])}
457 onSelect={value => setConfig($, before => ({ ...before, [key]: Number(value) }))}
458 />
459 )
460
461 let body
462 if (shown === 'home') {
463 const owed = await read($, inspect)
464 const seen = await read($, alerts)
465 const { done, total } = progress(await read($, map))
466 const totals = await read($, usage)
467 body = (
468 <Box flexDirection="column">
469 {FEATURES.map(feature => (
470 <Box flexDirection="column">
471 {toggle(feature.key, feature.label)}
472 <Text dimColor>{fit(` ${feature.about}`)}</Text>
473 </Box>
474 ))}
475 <Text> </Text>
476 <Text bold>Now</Text>
477 <Text>{`Tasks done: ${done} of ${total}`}</Text>
478 <Text color={seen.length > 0 ? 'warning' : 'text'}>{`Stuck alerts: ${seen.length}`}</Text>
479 <Text color={owed.pending.length > 0 ? 'warning' : 'text'}>
480 {`UI files awaiting a visual check: ${owed.pending.length}`}
481 </Text>
482 {Object.keys(totals).map(model => (
483 <Text dimColor>
484 {fit(
485 `${model}: ${thousands(totals[model]?.input ?? 0)} in, ${thousands(totals[model]?.output ?? 0)} out, ${thousands(totals[model]?.cached ?? 0)} cached, ${totals[model]?.turns ?? 0} turns`,
486 )}
487 </Text>
488 ))}
489 </Box>
490 )
491 } else if (shown === 'compact') {
492 const pinned = await read($, pins)
493 const calls = (await read($, recent)).filter(call => !pinned.some(pin => pin.id === call.id))
494 const entries = await read($, ledger)
495 body = (
496 <Box flexDirection="column">
497 {toggle('guard', 'Compact guard')}
498 <Text dimColor>{fit('Pinned calls and rule matches are put back whole after compaction.')}</Text>
499 <Button
500 key="compact-now"
501 label="Compact now"
502 onPress={() => {
503 void $.session.compact().catch(() => $.ui.toast('Cockpit: could not compact right now.'))
504 }}
505 />
506 <Text> </Text>
507 <Text bold>{`Pinned (${pinned.length})`}</Text>
508 {pinned.length === 0 && <Text dimColor>Nothing pinned. Pin a call below.</Text>}
509 {pinned.map(pin => (
510 <Box>
511 <Button
512 key={`unpin:${pin.id}`}
513 label="unpin"
514 onPress={() => update($, pins, list => list.filter(one => one.id !== pin.id))}
515 />
516 <Text>{fit(` ${pin.tool} ${pin.preview}`)}</Text>
517 </Box>
518 ))}
519 <Text> </Text>
520 <Text bold>Always keep (rules)</Text>
521 <Text dimColor>{fit('A rule is text found in a tool name or its arguments.')}</Text>
522 {held.rules.map((rule, index) => (
523 <Box>
524 <Button
525 key={`rule-off:${index}`}
526 label="remove"
527 onPress={() =>
528 setConfig($, before => ({
529 ...before,
530 rules: before.rules.filter(one => one !== rule),
531 }))
532 }
533 />
534 <Text>{fit(` ${rule}`)}</Text>
535 </Box>
536 ))}
537 {Input !== undefined && (
538 <Input
539 key="rule-new"
540 label="Add rule"
541 placeholder="npm test, git diff, src/auth"
542 submitLabel="add"
543 onSubmit={value => {
544 const rule = value.trim()
545 if (rule !== '') {
546 void setConfig($, before => ({
547 ...before,
548 rules: [...before.rules.filter(one => one !== rule), rule].slice(-30),
549 }))
550 }
551 }}
552 />
553 )}
554 <Text> </Text>
555 <Text bold>Recent tool calls</Text>
556 {calls.length === 0 && <Text dimColor>None yet.</Text>}
557 {calls
558 .slice(-10)
559 .reverse()
560 .map(call => (
561 <Box>
562 <Button
563 key={`pin:${call.id}`}
564 label="pin"
565 onPress={() =>
566 update($, pins, list =>
567 list.some(one => one.id === call.id) ? list : [...list, call].slice(-50),
568 )
569 }
570 />
571 <Text>{fit(` ${call.tool} ${call.preview}`)}</Text>
572 </Box>
573 ))}
574 <Text> </Text>
575 <Text bold>Compaction ledger</Text>
576 {entries.length === 0 && <Text dimColor>No compaction seen yet.</Text>}
577 {entries
578 .slice(-6)
579 .reverse()
580 .map(entry => (
581 <Text color={entry.restored > 0 ? 'success' : 'text'}>
582 {fit(
583 `${entry.trigger}: ${thousands(entry.charsBefore)} -> ${thousands(entry.charsAfter)} chars, restored ${entry.restored} (${entry.note})`,
584 )}
585 </Text>
586 ))}
587 </Box>
588 )
589 } else if (shown === 'map') {
590 const plan = await read($, map)
591 const { done, total } = progress(plan)
592 const groups: readonly TaskStatus[] = ['doing', 'blocked', 'todo', 'done']
593 const colors = { doing: 'suggestion', blocked: 'error', todo: 'text', done: 'success' } as const
594 body = (
595 <Box flexDirection="column">
596 {toggle('map', 'Project map')}
597 <Text bold>{fit(plan.goal === '' ? 'No goal set yet' : plan.goal)}</Text>
598 <Text>{`[${bar(done, total, Math.min(30, width - 14))}] ${done}/${total}`}</Text>
599 {groups.map(status => {
600 const tasks = plan.tasks.filter(task => task.status === status)
601 const view = status === 'done' ? tasks.slice(-8) : tasks
602
603 return (
604 <Box flexDirection="column">
605 <Text> </Text>
606 <Text bold color={colors[status]}>{`${status.toUpperCase()} (${tasks.length})`}</Text>
607 {view.map(task => (
608 <Box flexDirection="column">
609 <Box>
610 <Button
611 key={`task:${task.id}`}
612 label={`-> ${NEXT_STATUS[task.status]}`}
613 dimColor
614 onPress={() =>
615 setMap($, before =>
616 mergeMap(
617 before,
618 { tasks: [{ id: task.id, status: NEXT_STATUS[task.status] }] },
619 before.updatedAt,
620 ),
621 )
622 }
623 />
624 <Text>{fit(` ${task.level ? `[${task.level}] ` : ''}${task.title}`)}</Text>
625 </Box>
626 {task.note !== undefined && <Text dimColor>{fit(` ${task.note}`)}</Text>}
627 </Box>
628 ))}
629 </Box>
630 )
631 })}
632 <Text> </Text>
633 <Text bold>{`Decisions (${plan.decisions.length})`}</Text>
634 {plan.decisions.slice(-8).map(decision => (
635 <Text wrap="wrap">{`- ${decision}`}</Text>
636 ))}
637 <Text> </Text>
638 {Input !== undefined && (
639 <Input
640 key="goal"
641 label="Goal"
642 value={plan.goal}
643 submitLabel="set"
644 onSubmit={value => {
645 void setMap($, before => mergeMap(before, { goal: value }, before.updatedAt))
646 }}
647 />
648 )}
649 {Input !== undefined && (
650 <Input
651 key="task-new"
652 label="Add task"
653 placeholder="what needs doing"
654 submitLabel="add"
655 onSubmit={value => {
656 const title = value.trim()
657 if (title !== '') {
658 void setMap($, before =>
659 mergeMap(
660 before,
661 { tasks: [{ id: `me:${title.slice(0, 40)}`, title }] },
662 before.updatedAt,
663 ),
664 )
665 }
666 }}
667 />
668 )}
669 <Button
670 key="map-clear-done"
671 label="Clear done tasks"
672 dimColor
673 onPress={() =>
674 setMap($, before => ({
675 ...before,
676 tasks: before.tasks.filter(task => task.status !== 'done'),
677 }))
678 }
679 />
680 </Box>
681 )
682 } else if (shown === 'stuck') {
683 const seen = await read($, alerts)
684 body = (
685 <Box flexDirection="column">
686 {toggle('stuck', 'Stuck detector')}
687 {number('repeatLimit', 'Same call repeats', [2, 3, 4, 5, 6])}
688 {number('errorLimit', 'Errors in a row', [3, 4, 5, 6, 8])}
689 {number('stallMinutes', 'Stall minutes', [2, 5, 10, 15])}
690 <Text dimColor wrap="wrap">
691 On a hit Claude is told to stop and change approach, and you get a toast.
692 </Text>
693 <Text> </Text>
694 <Text bold>{`Alerts (${seen.length})`}</Text>
695 {seen.length === 0 && <Text dimColor>None. Claude is moving.</Text>}
696 {seen
697 .slice(-8)
698 .reverse()
699 .map(alert => (
700 <Text color="warning" wrap="wrap">{`${alert.kind}: ${short(alert.text.replace('cockpit: ', ''), 140)}`}</Text>
701 ))}
702 {seen.length > 0 && (
703 <Button key="alerts-clear" label="Clear alerts" onPress={() => update($, alerts, () => [])} />
704 )}
705 </Box>
706 )
707 } else if (shown === 'inspect') {
708 const owed = await read($, inspect)
709 body = (
710 <Box flexDirection="column">
711 {toggle('inspect', 'Pixel inspector')}
712 <Text dimColor wrap="wrap">
713 When a turn ends with UI files edited and no screenshot taken since, Claude is sent
714 back once to open the UI and check it.
715 </Text>
716 <Text> </Text>
717 <Text bold color={owed.pending.length > 0 ? 'warning' : 'success'}>
718 {owed.pending.length > 0
719 ? `Awaiting a visual check (${owed.pending.length})`
720 : 'Nothing awaiting a check'}
721 </Text>
722 {owed.pending.slice(-12).map(file => (
723 <Text>{fit(`- ${file}`)}</Text>
724 ))}
725 {owed.lastCheck !== '' && <Text dimColor>{fit(`Last check: ${owed.lastCheck}`)}</Text>}
726 {owed.pending.length > 0 && (
727 <Box gap={1}>
728 <Button
729 key="inspect-now"
730 label="Inspect now"
731 variant="primary"
732 onPress={() => {
733 void $.prompt.submit({ text: checklist(owed.pending) }).catch(() => undefined)
734 }}
735 />
736 <Button
737 key="inspect-done"
738 label="I checked it myself"
739 onPress={() => update($, inspect, before => ({ ...before, pending: [] }))}
740 />
741 </Box>
742 )}
743 </Box>
744 )
745 } else {
746 const log = await read($, routes)
747 body = (
748 <Box flexDirection="column">
749 {toggle('architect', 'Architect')}
750 <Text dimColor wrap="wrap">
751 The main session plans and reviews; each subagent runs on the model its task's
752 difficulty picks. Takes effect from the next prompt.
753 </Text>
754 <Text> </Text>
755 {LEVELS.map(level =>
756 Select === undefined ? (
757 <Text>{`${level}: ${held.models[level]}`}</Text>
758 ) : (
759 <Select
760 key={`model:${level}`}
761 label={`${level} tasks run on`}
762 options={MODELS}
763 value={held.models[level]}
764 onSelect={value =>
765 setConfig($, before => ({
766 ...before,
767 models: { ...before.models, [level]: value },
768 }))
769 }
770 />
771 ),
772 )}
773 <Text> </Text>
774 <Text bold>{`Routed subagents (${log.length})`}</Text>
775 {log.length === 0 && <Text dimColor>None yet.</Text>}
776 {log
777 .slice(-10)
778 .reverse()
779 .map(route => (
780 <Text>{fit(`${route.level} -> ${route.model} (${route.why}): ${route.task}`)}</Text>
781 ))}
782 </Box>
783 )
784 }
785
786 return (
787 <Box flexDirection="column">
788 <Box flexWrap="wrap" columnGap={1}>
789 {TABS.map(one => (
790 <Button
791 key={`tab:${one.id}`}
792 label={one.label}
793 variant={one.id === shown ? 'primary' : 'secondary'}
794 onPress={() => update($, tab, () => one.id)}
795 />
796 ))}
797 </Box>
798 <Text> </Text>
799 {body}
800 </Box>
801 )
802 })
803}
804hooks/lib/guard.ts 176 lines1import type { SessionMessage } from 'claude-code'
2
3type Messages = readonly SessionMessage[]
4
5function idsOf(message: SessionMessage): string[] {
6 return [
7 ...message.toolUses.map(use => use.tool_use_id),
8 ...(message.toolResults ?? []).map(result => result.tool_use_id),
9 ]
10}
11
12/** A rule matches when it is found, ignoring case, in the tool's name and arguments. */
13export function matchesRule(
14 tool: string,
15 input: unknown,
16 rules: readonly string[],
17): boolean {
18 const haystack = `${tool} ${JSON.stringify(input) ?? ''}`.toLowerCase()
19
20 return rules.some(rule => {
21 const needle = rule.trim().toLowerCase()
22
23 return needle !== '' && haystack.includes(needle)
24 })
25}
26
27/** The tool calls a compaction must leave whole: the pinned ones and those a rule names. */
28export function protectedIds(
29 messages: Messages,
30 pinned: ReadonlySet<string>,
31 rules: readonly string[],
32): Set<string> {
33 const keep = new Set<string>()
34
35 for (const message of messages) {
36 for (const use of message.toolUses) {
37 if (pinned.has(use.tool_use_id) || matchesRule(use.tool, use.input, rules)) {
38 keep.add(use.tool_use_id)
39 }
40 }
41 }
42
43 return keep
44}
45
46function hasCall(messages: Messages, id: string): boolean {
47 return messages.some(message => message.toolUses.some(use => use.tool_use_id === id))
48}
49
50/** How much of a call's result a transcript holds; -1 when it holds none. */
51function resultLength(messages: Messages, id: string): number {
52 let length = -1
53
54 for (const message of messages) {
55 for (const result of message.toolResults ?? []) {
56 if (result.tool_use_id === id) {
57 length = Math.max(length, result.text.length)
58 }
59 }
60 for (const use of message.toolUses) {
61 if (use.tool_use_id === id && use.text !== undefined) {
62 length = Math.max(length, use.text.length)
63 }
64 }
65 }
66
67 return length
68}
69
70export function chars(messages: Messages): number {
71 let total = 0
72
73 for (const message of messages) {
74 total += message.text.length
75 for (const use of message.toolUses) {
76 total += (JSON.stringify(use.input) ?? '').length
77 }
78 for (const result of message.toolResults ?? []) {
79 total += result.text.length
80 }
81 }
82
83 return total
84}
85
86/**
87 * Puts back, whole and in their place, the protected tool calls a compaction
88 * dropped or shortened. A call comes back with every message it is paired
89 * with, so no tool_use is left without its tool_result.
90 */
91export function restore(
92 before: Messages,
93 after: Messages,
94 keep: ReadonlySet<string>,
95): { messages: SessionMessage[]; restored: number } {
96 const damaged = [...keep].filter(
97 id =>
98 hasCall(before, id) &&
99 (!hasCall(after, id) || resultLength(after, id) < resultLength(before, id)),
100 )
101
102 if (damaged.length === 0) {
103 return { messages: [...after], restored: 0 }
104 }
105
106 const ids = new Set(damaged)
107 const back = new Set<number>()
108 let hasGrown = true
109
110 while (hasGrown) {
111 hasGrown = false
112 before.forEach((message, index) => {
113 if (back.has(index) || !idsOf(message).some(id => ids.has(id))) {
114 return
115 }
116 back.add(index)
117 hasGrown = true
118 for (const id of idsOf(message)) {
119 ids.add(id)
120 }
121 })
122 }
123
124 const byHandle = new Map<string, number>()
125 const byId = new Map<string, number>()
126 before.forEach((message, index) => {
127 if (message.handle !== undefined) {
128 byHandle.set(message.handle, index)
129 }
130 for (const id of idsOf(message)) {
131 if (!byId.has(id)) {
132 byId.set(id, index)
133 }
134 }
135 })
136
137 // Where each surviving message stood before; one the compaction wrote (a
138 // summary) stands where the message ahead of it did.
139 let last = -1
140 const kept: { place: number; message: SessionMessage }[] = []
141 for (const message of after) {
142 const own = idsOf(message)
143 if (own.some(id => ids.has(id))) {
144 continue
145 }
146 const first = own[0]
147 const place =
148 (message.handle === undefined ? undefined : byHandle.get(message.handle)) ??
149 (first === undefined ? undefined : byId.get(first)) ??
150 last
151 last = place
152 kept.push({ place, message })
153 }
154
155 const order = [...back].sort((a, b) => a - b)
156 const messages: SessionMessage[] = []
157 let next = 0
158 for (const { place, message } of kept) {
159 for (; next < order.length && (order[next] ?? 0) < place; next += 1) {
160 const original = before[order[next] ?? 0]
161 if (original !== undefined) {
162 messages.push(original)
163 }
164 }
165 messages.push(message)
166 }
167 for (; next < order.length; next += 1) {
168 const original = before[order[next] ?? 0]
169 if (original !== undefined) {
170 messages.push(original)
171 }
172 }
173
174 return { messages, restored: damaged.length }
175}
176hooks/lib/inspect.ts 49 lines1const UI_FILE = /\.(tsx|jsx|vue|svelte|astro|html?|css|scss|sass|less|styl)$/i
2const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
3const BROWSER = /browser|chrome|computer|playwright|puppeteer|devtools|preview/i
4const CAPTURE = /screenshot|snapshot/i
5const LOOKS = new Set(['screenshot', 'zoom'])
6
7export function isUiFile(path: string): boolean {
8 return UI_FILE.test(path)
9}
10
11/** The file an editing tool's call writes; undefined for any other call. */
12export function editedFile(tool: string, args: Record<string, unknown>): string | undefined {
13 if (!EDIT_TOOLS.has(tool)) {
14 return undefined
15 }
16 const path = args.file_path ?? args.notebook_path
17
18 return typeof path === 'string' ? path : undefined
19}
20
21/** Whether a call looked at rendered pixels: a screenshot or a zoom in a browser tool. */
22export function isVisualCheck(tool: string, args: Record<string, unknown>): boolean {
23 if (!BROWSER.test(tool)) {
24 return false
25 }
26
27 return CAPTURE.test(tool) || (typeof args.action === 'string' && LOOKS.has(args.action))
28}
29
30export function checklist(files: readonly string[]): string {
31 const shown = files.slice(0, 12).map(file => `- ${file}`)
32 const more = files.length > 12 ? [`- and ${files.length - 12} more`] : []
33
34 return [
35 'Pixel inspection is owed before this work counts as done. These UI files changed and nothing rendered was looked at since:',
36 ...shown,
37 ...more,
38 '',
39 'Open the affected screens in a browser and take screenshots, then check each one against this list:',
40 '1. Layout at desktop width and at a phone width (about 375px): nothing overflows, overlaps, or is clipped.',
41 '2. Alignment and spacing: edges line up, gaps are consistent, nothing is jammed against a border.',
42 '3. Text: readable size, enough contrast, no truncation that hides meaning, no placeholder copy left in.',
43 '4. States: hover, focus, disabled, loading, empty and error where the change touches them.',
44 '5. The browser console: no new errors or warnings.',
45 '',
46 'Fix what you find and look again. Then report what you checked and what you saw. If the UI cannot be run here, say so plainly instead of calling it verified.',
47 ].join('\n')
48}
49hooks/lib/map.ts 165 lines1import type { Level, MapTask, ProjectMap, TaskStatus } from '../../types'
2
3const MAX_TASKS = 200
4const MAX_DECISIONS = 100
5const MAX_TEXT = 300
6
7const STATUSES: readonly string[] = ['todo', 'doing', 'done', 'blocked']
8const LEVELS: readonly string[] = ['easy', 'medium', 'hard']
9
10export const EMPTY_MAP: ProjectMap = { goal: '', tasks: [], decisions: [], updatedAt: 0 }
11
12export const MAP_PROMPT = `# Project map (cockpit)
13
14The user watches a live map of this project: its goal, its tasks and the decisions made. Keep it true with the mcp__cockpit__map tool: call it when you settle a plan, when a task starts, finishes or gets blocked, and when a decision is made that later work depends on. Send only what changed; tasks merge by id. Short titles, one line each.`
15
16export const MAP_SCHEMA = {
17 type: 'object',
18 properties: {
19 goal: { type: 'string', description: 'What the project is for, in one line.' },
20 tasks: {
21 type: 'array',
22 description: 'Tasks to add or update, merged by id.',
23 items: {
24 type: 'object',
25 properties: {
26 id: { type: 'string', description: 'A short stable id, like "auth-2".' },
27 title: { type: 'string' },
28 status: { type: 'string', enum: ['todo', 'doing', 'done', 'blocked'] },
29 level: { type: 'string', enum: ['easy', 'medium', 'hard'] },
30 note: { type: 'string', description: 'Why it is blocked, or what was found.' },
31 },
32 required: ['id'],
33 },
34 },
35 decisions: {
36 type: 'array',
37 description: 'Decisions to record, one line each, with the reason.',
38 items: { type: 'string' },
39 },
40 },
41}
42
43function line(value: unknown): string | undefined {
44 return typeof value === 'string' && value.trim() !== ''
45 ? value.trim().replace(/\s+/g, ' ').slice(0, MAX_TEXT)
46 : undefined
47}
48
49function isRecord(value: unknown): value is Record<string, unknown> {
50 return typeof value === 'object' && value !== null && !Array.isArray(value)
51}
52
53/** Reads a stored or model-sent value as a map, dropping what is not one. */
54export function asMap(value: unknown): ProjectMap {
55 return isRecord(value) ? mergeMap(EMPTY_MAP, value, Number(value.updatedAt) || 0) : EMPTY_MAP
56}
57
58/** Merges a patch into the map: the goal replaces, tasks merge by id, decisions append. */
59export function mergeMap(map: ProjectMap, patch: unknown, now: number): ProjectMap {
60 if (!isRecord(patch)) {
61 return map
62 }
63
64 const tasks = map.tasks.map(task => ({ ...task }))
65 for (const raw of Array.isArray(patch.tasks) ? patch.tasks : []) {
66 if (!isRecord(raw)) {
67 continue
68 }
69 const id = line(raw.id)
70 if (id === undefined) {
71 continue
72 }
73 const known = tasks.find(task => task.id === id)
74 const task: MapTask = known ?? { id, title: id, status: 'todo' }
75 const title = line(raw.title)
76 const note = line(raw.note)
77 if (title !== undefined) {
78 task.title = title
79 }
80 if (typeof raw.status === 'string' && STATUSES.includes(raw.status)) {
81 task.status = raw.status as TaskStatus
82 }
83 if (typeof raw.level === 'string' && LEVELS.includes(raw.level)) {
84 task.level = raw.level as Level
85 }
86 if (note !== undefined) {
87 task.note = note
88 }
89 if (known === undefined) {
90 tasks.push(task)
91 }
92 }
93
94 const decisions = [...map.decisions]
95 for (const raw of Array.isArray(patch.decisions) ? patch.decisions : []) {
96 const decision = line(raw)
97 if (decision !== undefined && !decisions.includes(decision)) {
98 decisions.push(decision)
99 }
100 }
101
102 return {
103 goal: line(patch.goal) ?? map.goal,
104 tasks: tasks.slice(-MAX_TASKS),
105 decisions: decisions.slice(-MAX_DECISIONS),
106 updatedAt: now,
107 }
108}
109
110const TODO_STATUS: Record<string, TaskStatus> = {
111 pending: 'todo',
112 in_progress: 'doing',
113 completed: 'done',
114}
115
116/** Mirrors a TodoWrite call's list into the map, each todo a task keyed by its text. */
117export function fromTodos(map: ProjectMap, todos: unknown, now: number): ProjectMap {
118 if (!Array.isArray(todos)) {
119 return map
120 }
121 const tasks = todos.flatMap(todo => {
122 const title = isRecord(todo) ? line(todo.content) : undefined
123 const status = isRecord(todo) ? TODO_STATUS[String(todo.status)] : undefined
124
125 return title === undefined ? [] : [{ id: `todo:${title.slice(0, 60)}`, title, status }]
126 })
127
128 return mergeMap(map, { tasks }, now)
129}
130
131export function progress(map: ProjectMap): { done: number; total: number } {
132 return {
133 done: map.tasks.filter(task => task.status === 'done').length,
134 total: map.tasks.length,
135 }
136}
137
138export function bar(done: number, total: number, width: number): string {
139 const filled = total === 0 ? 0 : Math.round((done / total) * width)
140
141 return `${'#'.repeat(filled)}${'-'.repeat(Math.max(0, width - filled))}`
142}
143
144/** The map as the model reads it when a session opens on a project that has one. */
145export function brief(map: ProjectMap): string | undefined {
146 if (map.goal === '' && map.tasks.length === 0 && map.decisions.length === 0) {
147 return undefined
148 }
149 const { done, total } = progress(map)
150 const open = map.tasks.filter(task => task.status !== 'done').slice(0, 30)
151
152 return [
153 'Project map kept by the cockpit plugin from earlier sessions (it may be stale; check before relying on it):',
154 map.goal === '' ? undefined : `Goal: ${map.goal}`,
155 `Tasks: ${done} of ${total} done.`,
156 ...open.map(
157 task => `- [${task.status}] ${task.id}: ${task.title}${task.note ? ` (${task.note})` : ''}`,
158 ),
159 map.decisions.length === 0 ? undefined : 'Decisions:',
160 ...map.decisions.slice(-10).map(decision => `- ${decision}`),
161 ]
162 .filter(text => text !== undefined)
163 .join('\n')
164}
165hooks/lib/route.ts 40 lines1import type { Level } from '../../types'
2
3const TAG = /\[(easy|medium|hard)\]/i
4const HARD =
5 /architect|design|refactor|security|concurren|race condition|migrat|root cause|debug|performance|review/i
6const EASY =
7 /rename|typo|format|lint|list|find|search|locate|grep|read|summari[sz]e|comment|docstring|bump/i
8
9export const ARCHITECT_PROMPT = `# Architect mode (cockpit)
10
11This section is for the main session; a subagent ignores it.
12
13Work as the architect: plan the work, split it into tasks, hand each task to a subagent with the Agent tool, and review what comes back before you accept it.
14
15- Start each Agent call's description with its difficulty: [easy], [medium] or [hard]. The tag picks the subagent's model, so a mechanical task (a rename, a search, a small edit to a known spot) is [easy], ordinary feature work is [medium], and design, debugging or anything security-sensitive is [hard].
16- Give each subagent everything it needs in its prompt: the goal, the files, what is already ruled out, and what done looks like.
17- Review every result yourself: read the diff or the findings, check them against the plan, and send the task back or redo it when it falls short. Do not report a task done on the subagent's word alone.
18- Keep small or tightly coupled work for yourself; delegate only what stands alone.`
19
20/** A task's difficulty: its tag when the description or prompt carries one, else a guess. */
21export function levelOf(
22 description: string,
23 prompt: string,
24): { level: Level; why: 'tag' | 'guess' } {
25 const tag = TAG.exec(description) ?? TAG.exec(prompt)
26 if (tag?.[1] !== undefined) {
27 return { level: tag[1].toLowerCase() as Level, why: 'tag' }
28 }
29
30 const text = `${description}\n${prompt}`
31 if (HARD.test(text) || prompt.length > 2500) {
32 return { level: 'hard', why: 'guess' }
33 }
34 if (EASY.test(text) && prompt.length < 800) {
35 return { level: 'easy', why: 'guess' }
36 }
37
38 return { level: 'medium', why: 'guess' }
39}
40hooks/lib/stuck.ts 90 lines1import type { AlertKind } from '../../types'
2
3export const THRASH_LIMIT = 6
4
5const RESERVED = new Set(['tool', 'tool_use_id', 'agentId', 'consent'])
6
7function stable(value: unknown): string {
8 if (Array.isArray(value)) {
9 return `[${value.map(stable).join(',')}]`
10 }
11 if (typeof value === 'object' && value !== null) {
12 const record = value as Record<string, unknown>
13
14 return `{${Object.keys(record)
15 .sort()
16 .map(key => `${JSON.stringify(key)}:${stable(record[key])}`)
17 .join(',')}}`
18 }
19
20 return JSON.stringify(value) ?? 'undefined'
21}
22
23/** One call's identity: the tool and its arguments, whatever order they came in. */
24export function signature(tool: string, args: Record<string, unknown>): string {
25 const own: Record<string, unknown> = {}
26 for (const key of Object.keys(args)) {
27 if (!RESERVED.has(key)) {
28 own[key] = args[key]
29 }
30 }
31
32 return `${tool} ${stable(own)}`
33}
34
35export type Loop = {
36 counts: Map<string, number>
37 edits: Map<string, number>
38 errors: number
39}
40
41export function freshLoop(): Loop {
42 return { counts: new Map(), edits: new Map(), errors: 0 }
43}
44
45export type Observation = {
46 tool: string
47 signature: string
48 isError: boolean
49 /** The file the call edited, when it edited one. */
50 file?: string
51}
52
53export type Limits = { repeatLimit: number; errorLimit: number }
54
55export type Finding = { kind: AlertKind; text: string }
56
57/** Counts one finished call into its loop and says when the loop looks stuck. */
58export function observe(loop: Loop, seen: Observation, limits: Limits): Finding | undefined {
59 const repeats = (loop.counts.get(seen.signature) ?? 0) + 1
60 loop.counts.set(seen.signature, repeats)
61 loop.errors = seen.isError ? loop.errors + 1 : 0
62
63 if (repeats >= limits.repeatLimit && repeats % limits.repeatLimit === 0) {
64 return {
65 kind: 'loop',
66 text: `cockpit: this exact ${seen.tool} call has now run ${repeats} times this turn with the same arguments. Repeating it will not change the outcome. Stop, say what you expected and what you got, then try a different approach or ask the user.`,
67 }
68 }
69
70 if (loop.errors >= limits.errorLimit && loop.errors % limits.errorLimit === 0) {
71 return {
72 kind: 'errors',
73 text: `cockpit: ${loop.errors} tool calls in a row have failed. Stop retrying. Read the errors again, name the root cause you believe in, and verify it with one cheap check before the next attempt; ask the user if you are blocked.`,
74 }
75 }
76
77 if (seen.file !== undefined && !seen.isError) {
78 const edits = (loop.edits.get(seen.file) ?? 0) + 1
79 loop.edits.set(seen.file, edits)
80 if (edits >= THRASH_LIMIT && edits % THRASH_LIMIT === 0) {
81 return {
82 kind: 'thrash',
83 text: `cockpit: ${seen.file} has been edited ${edits} times this turn. That reads as thrashing. Re-read the whole file, decide the end state, and make it in one edit.`,
84 }
85 }
86 }
87
88 return undefined
89}
90types/index.d.ts 81 lines1export type Tab = 'home' | 'compact' | 'map' | 'stuck' | 'inspect' | 'architect'
2
3export type Level = 'easy' | 'medium' | 'hard'
4
5export type Config = {
6 guard: boolean
7 stuck: boolean
8 inspect: boolean
9 architect: boolean
10 map: boolean
11 meter: boolean
12 repeatLimit: number
13 errorLimit: number
14 stallMinutes: number
15 models: { easy: string; medium: string; hard: string }
16 rules: string[]
17}
18
19export type RecentCall = { id: string; tool: string; preview: string }
20
21export type LedgerEntry = {
22 at: number
23 trigger: string
24 charsBefore: number
25 charsAfter: number
26 restored: number
27 note: string
28}
29
30export type TaskStatus = 'todo' | 'doing' | 'done' | 'blocked'
31
32export type MapTask = {
33 id: string
34 title: string
35 status: TaskStatus
36 level?: Level
37 note?: string
38}
39
40export type ProjectMap = {
41 goal: string
42 tasks: MapTask[]
43 decisions: string[]
44 updatedAt: number
45}
46
47export type AlertKind = 'loop' | 'errors' | 'thrash' | 'stall'
48
49export type Alert = { at: number; kind: AlertKind; text: string }
50
51export type Route = {
52 at: number
53 task: string
54 level: Level
55 model: string
56 why: 'tag' | 'guess'
57}
58
59export type InspectState = { pending: string[]; lastCheck: string; nudges: number }
60
61export type ModelTotals = { input: number; output: number; cached: number; turns: number }
62
63export type Usage = { [model: string]: ModelTotals }
64
65declare module 'claude-code' {
66 interface PluginState {
67 cockpit: {
68 tab: Tab
69 config: Config
70 pins: RecentCall[]
71 recent: RecentCall[]
72 ledger: LedgerEntry[]
73 map: ProjectMap
74 alerts: Alert[]
75 routes: Route[]
76 inspect: InspectState
77 usage: Usage
78 }
79 }
80}
81