SLOPSHOPPER

cockpit

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

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-091kaliel/claude-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 │ ┃ [ Home ] [ Compact ] [ Map ] [ Stuck ] [ Ins │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Compact guard: ON ] ⎿ Read 6 lines │ ┃ pins and rules survive compaction ⏺ Update(src/auth.ts) │ ┃ [ Stuck detector: ON ] ⎿ Added 2 lines, removed 1 line │ ┃ loops, error streaks, thrashing, stalls ⏺ Bash(bun test) │ ┃ [ Pixel inspector: ON ] ⎿ 3 pass, 1 fail │ ┃ UI edits need a screenshot check │ ┃ [ Architect: off ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ plan, delegate by difficulty, review │ ┃ [ Project map: ON ] ✻ Worked for 42s · done 4:20 PM │ ┃ goal, tasks and decisions, kept per │ ┃ project › /cockpit │ ┃ [ Token meter: ON ] ⎿ cockpit: Cockpit opened. │ ┃ tokens per model in the status line │ ┃ │ ┃ Now │ ┃ Tasks done: 0 of 0 │ ┃ Stuck alerts: 0 │ ┃ UI files awaiting a visual check: 0 │ ┃ claude-opus-5-5: 6.4k in, 1.5k out, 91.0k │ ┃ cached, 1 t… │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ cockpit: cockpit 6.4k in / 1.5k out

Draws

Pane · Cockpit
[ Home ] [ Compact ] [ Map ] [ Stuck ] [ Inspect ] [ Archite [ Compact guard: ON ] pins and rules survive compaction [ Stuck detector: ON ] loops, error streaks, thrashing, stalls [ Pixel inspector: ON ] UI edits need a screenshot check [ Architect: off ] plan, delegate by difficulty, review [ Project map: ON ] goal, tasks and decisions, kept per project [ Token meter: ON ] tokens per model in the status line Now Tasks done: 0 of 0 Stuck alerts: 0 UI files awaiting a visual check: 0 claude-opus-5-5: 6.4k in, 1.5k out, 91.0k cached, 1 t…
README

cockpit

A Claude Code mod that puts five helpers behind one pane, so you control them with buttons instead of remembering commands.

TabWhat it does
HomeAn on/off switch for each feature, live counts, and tokens used per model.
CompactPin 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.
MapThe 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.
StuckFlags 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.
InspectWhen 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.
ArchitectThe 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).

Install

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.

Requirements

  • Claude Code 2.1.293 or newer. The mod is written against the function-hooks plugin API, which is early access and can change between releases. Older builds refuse to load it.

Things to know

  • Architect is off by default. It adds instructions to the system prompt that make the main session delegate more. Turn it on from the pane when you want it.
  • The compact guard works with any compactor, the built-in one or a plugin such as a Jev-based one. It runs after the compaction and repairs the result. If another plugin's compaction hook sits above this one and answers on its own, the guard is never asked; the ledger on the Compact tab tells you whether it ran.
  • The stuck detector's limits are adjustable on its tab. A legitimate polling loop can trip the repeat check; raise the limit or switch it off for that session.
  • The inspector only counts a screenshot or zoom in a browser tool as looking. Reading the DOM does not clear it. "I checked it myself" on the Inspect tab does.
  • Settings and the project map are kept in the plugin's own store under your Claude Code configuration directory. Nothing is sent anywhere.

Develop

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.

License

MIT

Source 7 files
hooks/register.tsx 804 lines
1import { 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}
804
hooks/lib/guard.ts 176 lines
1import 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}
176
hooks/lib/inspect.ts 49 lines
1const 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}
49
hooks/lib/map.ts 165 lines
1import 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}
165
hooks/lib/route.ts 40 lines
1import 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}
40
hooks/lib/stuck.ts 90 lines
1import 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}
90
types/index.d.ts 81 lines
1export 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