SLOPSHOPPER

Cockpit

A quiet band above the prompt: model, effort, context %, cost, current task and live subagents

newbandguardcommandtimeragents
★ 1v0.2.5MITupdated 2026-10-0611Fred11/fredj-hammami-plugins/plugins/cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cockpit ⎿ cockpit: cockpit: showing every subagent opus 5.5 ctx ▰▰▰▰▰▱▱▱▱▱ 49% $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
opus 5.5 ctx ▰▰▰▰▰▱▱▱▱▱ 49% $0.42
README

cockpit

A minimal band above the Claude Code prompt:

opus 5.5 · high  ctx ▰▰▰▰▱▱▱▱▱▱ 36%  $1.42  2 agents  ▸ Running tests
● Explore  haiku 4.5  ▰▱▱▱▱  42k  $0.04  0:37  Map the auth flow — Grep session
✓ Plan     opus 5.5   ▰▰▱▱▱  61k  $0.31  2:03  Draft migration plan
  • Main model and effort, context fill (3 green cells, 2 amber, the rest red), session cost, current task.
  • A subagent's cost is an estimate from its own token counts at list prices (cache writes at the 5-minute rate), because Claude Code reports dollars for the whole session only. A model the mod has no price for shows no cost.
  • Makes no model or network calls: every figure comes from the model's own responses and Claude Code's cost ledger.
  • One row per subagent: status, type, model and effort, task and the tool it runs now, a 5-cell context bar colored by the same limits, context size, estimated cost and elapsed time, with the task text last. A subagent on another model than the session's is measured against a 200k window, since Claude Code reports only the main model's.
  • Finished subagents fade off after 20 s; /cockpit toggles showing every subagent of the session.

Requirements

  • Claude Code v2.1.287 or later (mods). Tested with v2.1.289.
  • A mod runs in Claude Code only. The band draws in the terminal and in the desktop app's Code tab; claude.ai chat and Cowork skip it.

Install

claude plugin marketplace add 11Fred11/fredj-hammami-plugins
claude plugin install cockpit@fredj-hammami

Then restart Claude Code, or run /reload-plugins in a running session.

Settings

Set where the context bar turns amber and red with /plugin configure cockpit@fredj-hammami in Claude Code, or from a shell:

echo '{"warnAt": "40%", "dangerAt": "120k"}' | claude plugin configure cockpit@fredj-hammami --values-stdin
SettingDefaultAccepts
warnAt: context amber from30%a share of the window (40%), or a token count (80k, 1.2m)
dangerAt: context red from50%the same

A percentage moves with the model's window. A token count stays put when you switch to a model with a larger window.

Develop

claude plugin validate plugins/cockpit
claude plugin test plugins/cockpit

Built on Claude Code's mods API, whose events and methods can change between releases. Develop against the folder with claude --plugin-dir plugins/cockpit.

Source 2 files
hooks/register.tsx 429 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CockpitAgent, CockpitLoop, CockpitUsage } from '../types'
5
6const main = atom({ plugin: 'cockpit', key: 'main' } as const, {})
7const task = atom({ plugin: 'cockpit', key: 'task' } as const, null)
8const agents = atom({ plugin: 'cockpit', key: 'agents' } as const, [])
9const usage = atom({ plugin: 'cockpit', key: 'usage' } as const, {})
10const now = atom({ plugin: 'cockpit', key: 'now' } as const, 0)
11const isExpanded = atom({ plugin: 'cockpit', key: 'isExpanded' } as const, false)
12
13// Finished agents stay on the band this long before they drop off.
14const LINGER_MS = 20_000
15const MAX_AGENT_ROWS = 6
16const CTX_CELLS = 10
17const AGENT_CTX_CELLS = 5
18// Claude Code reports the main model's window alone; a subagent on another
19// model is measured against the window most models have.
20const ASSUMED_WINDOW = 200_000
21const DEFAULT_WARN_AT = '30%'
22const DEFAULT_DANGER_AT = '50%'
23
24// Where the context bar turns amber and red, as percentages of the window.
25export type CtxLimits = { warn: number; danger: number }
26
27// A limit as the person wrote it: "30%" (or a bare 30) is a share of the
28// window; "120k", "1.2m" or a bare 120000 a token count, which holds still
29// when a model with a larger window comes in.
30export const parseLimit = (text: string, window: number | undefined): number | undefined => {
31  const match = /^\s*(\d+(?:\.\d+)?)\s*(%|k|m)?\s*$/i.exec(text)
32  if (!match) return undefined
33  const n = Number(match[1])
34  const unit = match[2]?.toLowerCase() ?? (n <= 100 ? '%' : '')
35  if (unit === '%') return n
36  if (!window) return undefined
37  const tokens = unit === 'k' ? n * 1_000 : unit === 'm' ? n * 1_000_000 : n
38
39  return (tokens / window) * 100
40}
41
42export const ctxLimits = (warnAt: string, dangerAt: string, window: number | undefined): CtxLimits => {
43  const danger = parseLimit(dangerAt, window) ?? parseLimit(DEFAULT_DANGER_AT, window)!
44  const warn = parseLimit(warnAt, window) ?? parseLimit(DEFAULT_WARN_AT, window)!
45
46  return { warn: Math.min(warn, danger), danger }
47}
48
49export const ctxColor = (pct: number, limits: CtxLimits): string =>
50  pct >= limits.danger ? 'error' : pct >= limits.warn ? 'warning' : 'success'
51
52// The filled cells in runs of one color, each cell colored by the share at its
53// middle; any context at all fills one cell.
54export const ctxRuns = (pct: number, limits: CtxLimits, cells = CTX_CELLS): { color: string; cells: string }[] => {
55  const step = 100 / cells
56  const filled = pct > 0 ? Math.max(1, Math.min(cells, Math.round(pct / step))) : 0
57  const runs: { color: string; cells: string }[] = []
58  for (let i = 0; i < filled; i++) {
59    const color = ctxColor((i + 0.5) * step, limits)
60    const last = runs[runs.length - 1]
61    if (last?.color === color) last.cells += '▰'
62    else runs.push({ color, cells: '▰' })
63  }
64
65  return runs
66}
67
68// List prices in $ per million tokens, from the Claude API model table; cache
69// writes at the 5-minute rate. Claude Code reports dollars for the whole
70// session only, so a subagent's cost is estimated from its own token counts.
71type Price = { input: number; output: number; read: number; write: number }
72const PRICES: [family: string, price: Price][] = [
73  ['haiku', { input: 1, output: 5, read: 0.1, write: 1.25 }],
74  ['sonnet', { input: 2, output: 10, read: 0.2, write: 2.5 }],
75  ['opus', { input: 4, output: 20, read: 0.2, write: 5 }],
76  ['fable', { input: 10, output: 50, read: 0.25, write: 12.5 }],
77  ['mythos', { input: 10, output: 50, read: 0.25, write: 12.5 }],
78]
79
80export type CallUsage = {
81  input_tokens: number
82  output_tokens: number
83  cache_read_input_tokens: number
84  cache_creation_input_tokens: number
85}
86
87// One request's cost in dollars; undefined for a model with no known price.
88export const usageUsd = (model: string | undefined, u: CallUsage): number | undefined => {
89  const price = PRICES.find(([family]) => model?.includes(family))?.[1]
90  if (!price) return undefined
91
92  return (
93    (u.input_tokens * price.input +
94      u.output_tokens * price.output +
95      u.cache_read_input_tokens * price.read +
96      u.cache_creation_input_tokens * price.write) /
97    1_000_000
98  )
99}
100
101export const fmtUsd = (usd: number): string => (usd < 0.01 ? '<$0.01' : `$${usd.toFixed(2)}`)
102
103// The window a subagent's context is measured against.
104export const agentWindow = (agentModel: string | undefined, mainModel: string | undefined, mainWindow: number | undefined): number =>
105  mainWindow && agentModel && shortModel(agentModel) === shortModel(mainModel) ? mainWindow : ASSUMED_WINDOW
106
107export const shortModel = (id: string | undefined): string => {
108  if (!id) return ''
109
110  return id
111    .replace(/^claude-/, '')
112    .replace(/-\d{8}$/, '')
113    .replace(/-(\d+)-(\d+)$/, ' $1.$2')
114}
115
116export const shortEffort = (effort: string | number | undefined): string | undefined => {
117  if (effort === undefined) return undefined
118  if (typeof effort === 'number') return `${effort}`
119
120  return effort === 'medium' ? 'med' : effort
121}
122
123export const fmtTokens = (n: number): string =>
124  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
125
126export const fmtElapsed = (ms: number): string => {
127  const s = Math.max(0, Math.floor(ms / 1000))
128
129  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
130}
131
132const basename = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
133
134const clip = (text: string, max: number): string =>
135  text.length > max ? `${text.slice(0, max - 1)}…` : text
136
137// One short line for what a tool call is doing: "Bash npm test", "Edit app.ts".
138export const describeCall = (tool: string, args: Record<string, unknown>): string => {
139  const str = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : undefined)
140  const path = str('file_path') ?? str('notebook_path') ?? str('path')
141  const detail =
142    str('description') ??
143    str('command') ??
144    (path ? basename(path) : undefined) ??
145    str('pattern') ??
146    str('query') ??
147    str('url') ??
148    str('skill') ??
149    str('subject')
150  const name = tool.startsWith('mcp__') ? tool.split('__').slice(1).join(':') : tool
151
152  return clip(detail ? `${name} ${detail.split('\n')[0]}` : name, 60)
153}
154
155const loopOf = (agentId: string | undefined) => agentId ?? 'main'
156
157const patchLoop = async ($: EngineInterface, agentId: string | undefined, patch: CockpitLoop) => {
158  if (agentId === undefined) {
159    await update($, main, loop => ({ ...loop, ...patch }))
160    return
161  }
162  await update($, agents, list => list.map(a => (a.id === agentId ? { ...a, ...patch } : a)))
163}
164
165const refreshUsage = async ($: EngineInterface) => {
166  const u = await $.session.usage()
167  const next: CockpitUsage = { percent: u.context.percent, window: u.context.window, usd: u.cost?.usd }
168  await update($, usage, () => next)
169}
170
171// Picks up agents the spawn hook missed (a reload mid-run) and their ends.
172const syncAgents = async ($: EngineInterface) => {
173  const listed = await $.agent.list()
174  const at = await $.clock.now()
175  await update($, agents, list => {
176    const known = new Map(list.map(a => [a.id, a]))
177    for (const info of listed) {
178      if (info.teammateId) continue
179      const ended = info.status === 'completed' || info.status === 'failed' || info.status === 'killed'
180      const mine = known.get(info.id)
181      if (!mine) {
182        if (ended) continue
183        known.set(info.id, {
184          id: info.id,
185          type: info.type,
186          description: info.description,
187          status: 'running',
188          tokens: 0,
189          startedAt: at,
190        })
191      } else if (ended && mine.status === 'running') {
192        known.set(info.id, { ...mine, status: info.status === 'completed' ? 'done' : 'failed', endedAt: at })
193      }
194    }
195
196    return [...known.values()].slice(-50)
197  })
198}
199
200export const register: Register = (on, options) => {
201  const warnAt = String(options.warnAt ?? DEFAULT_WARN_AT)
202  const dangerAt = String(options.dangerAt ?? DEFAULT_DANGER_AT)
203
204  const taskTitles = new Map<string, string>()
205
206  on('session.start', async ($, e, next) => {
207    await $.command.register({
208      name: 'cockpit',
209      description: 'Toggle cockpit between compact and showing every subagent of this session',
210      immediate: true,
211    })
212    // Figures are read from the engine's own ledger, refreshed as each model
213    // response lands; nothing here polls or calls the API.
214    await refreshUsage($)
215    await syncAgents($)
216    // The model is known before the first response; effort arrives with it.
217    const model = await $.session.model()
218    await update($, main, loop => (loop.model ? loop : { ...loop, model }))
219
220    // Only moves the elapsed-time column while a subagent row is on screen.
221    $.clock.every(1000, () => {
222      void (async () => {
223        const list = await read($, agents)
224        const at = await $.clock.now()
225        const isTicking = list.some(a => a.status === 'running' || (a.endedAt ?? 0) > at - LINGER_MS - 1000)
226        if (isTicking) await update($, now, () => at)
227      })()
228    })
229
230    return next(e)
231  })
232
233  on('command.run', { command: 'cockpit' }, async $ => {
234    const was = await read($, isExpanded)
235    await update($, isExpanded, () => !was)
236
237    return { text: was ? 'cockpit: compact' : 'cockpit: showing every subagent' }
238  })
239
240  on('turn.step', async function* ($, e, next) {
241    await patchLoop($, e.agentId, { model: e.model, effort: shortEffort(e.effort) })
242    const result = yield* next(e)
243    const u = result.usage
244    if (u && e.agentId !== undefined) {
245      const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
246      const usd = usageUsd(u.model ?? e.model, u)
247      await update($, agents, list =>
248        list.map(a =>
249          a.id === e.agentId ? { ...a, tokens: total, usd: usd === undefined ? a.usd : (a.usd ?? 0) + usd } : a,
250        ),
251      )
252    }
253    await refreshUsage($)
254
255    return result
256  })
257
258  on('agent.spawn', async ($, e, next) => {
259    const spawned = await next(e)
260    if (spawned.agentId && !e.isTeammate) {
261      const agent: CockpitAgent = {
262        id: spawned.agentId,
263        type: e.subagentType,
264        description: e.description,
265        model: spawned.model,
266        status: 'running',
267        tokens: 0,
268        startedAt: await $.clock.now(),
269      }
270      await update($, agents, list => [...list.filter(a => a.id !== agent.id), agent].slice(-50))
271      await update($, now, () => agent.startedAt)
272    }
273
274    return spawned
275  })
276
277  on('tool.call', async ($, e, next) => {
278    const args = e as unknown as Record<string, unknown>
279    await patchLoop($, e.agentId, { activity: describeCall(String(e.tool), args) })
280    const ran = await next(e)
281
282    // The main loop's task list names what it is working on.
283    if (e.agentId === undefined && !('deny' in ran && ran.deny)) {
284      if (e.tool === 'TodoWrite') {
285        const doing = e.todos.find(t => t.status === 'in_progress')
286        await update($, task, () => doing?.activeForm ?? null)
287      } else if (e.tool === 'TaskCreate') {
288        const created = (ran.result as { task?: { id: string } } | undefined)?.task
289        if (created) taskTitles.set(created.id, e.activeForm ?? e.subject)
290      } else if (e.tool === 'TaskUpdate') {
291        const title = e.activeForm ?? e.subject ?? taskTitles.get(e.taskId)
292        if (e.activeForm ?? e.subject) taskTitles.set(e.taskId, (e.activeForm ?? e.subject)!)
293        if (e.status === 'in_progress' && title) await update($, task, () => title)
294        if (e.status === 'completed' || e.status === 'deleted') {
295          await update($, task, current => (current === title ? null : current))
296        }
297      }
298    }
299
300    return ran
301  })
302
303  // /model, the picker or a fallback: show the new model before its first response.
304  on('classic.PostModelSwitch', async ($, e, next) => {
305    await update($, main, loop => ({ ...loop, model: e.to_model, effort: undefined }))
306    await refreshUsage($)
307
308    return next(e)
309  })
310
311  on('turn.complete', async ($, e, next) => {
312    const done = await next(e)
313    if (e.agentId === undefined) {
314      await patchLoop($, undefined, { activity: undefined })
315      await refreshUsage($)
316    } else {
317      const at = await $.clock.now()
318      const status = e.reason === 'answer' ? 'done' : 'failed'
319      await update($, agents, list =>
320        list.map(a => (a.id === e.agentId ? { ...a, status, endedAt: at, activity: undefined } : a)),
321      )
322    }
323
324    return done
325  })
326
327  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
328    if (e.props.hasSurvey) return next(e)
329
330    const { Box, Text } = $.ui.resolve(e)
331    const loop = await read($, main)
332    const current = await read($, task)
333    const list = await read($, agents)
334    const u = await read($, usage)
335    const at = await read($, now)
336    const isAll = await read($, isExpanded)
337    const width = e.props.bodyColumns
338
339    const shown = list
340      .filter(a => isAll || a.status === 'running' || (a.endedAt ?? 0) > at - LINGER_MS)
341      .slice(-MAX_AGENT_ROWS)
342    const running = list.filter(a => a.status === 'running').length
343
344    const pct = u.percent
345    const limits = ctxLimits(warnAt, dangerAt, u.window)
346    const runs = pct === undefined ? [] : ctxRuns(pct, limits)
347    const filled = runs.reduce((n, run) => n + run.cells.length, 0)
348    const doing = current ?? (e.props.isWorking ? loop.activity : undefined)
349
350    const typeWidth = Math.min(16, Math.max(...shown.map(a => a.type.length), 4))
351    const modelWidth = Math.max(...shown.map(a => shortModel(a.model).length + (a.effort ? a.effort.length + 3 : 0)), 4)
352    // Name, model, bar and figures sit together on the left, as on the main line; the task takes the rest.
353    const hasCost = shown.some(a => a.usd !== undefined)
354    const costWidth = hasCost ? 7 : 0
355
356    return (
357      <Box flexDirection="column" width={width}>
358        <Box gap={2}>
359          {loop.model && (
360            <Text>
361              <Text bold>{shortModel(loop.model)}</Text>
362              {loop.effort && <Text dimColor> · {loop.effort}</Text>}
363            </Text>
364          )}
365          <Text>
366            <Text dimColor>ctx </Text>
367            {runs.map(run => (
368              <Text color={run.color}>{run.cells}</Text>
369            ))}
370            <Text dimColor>{'▱'.repeat(CTX_CELLS - filled)}</Text>
371            <Text color={pct === undefined ? undefined : ctxColor(pct, limits)}> {pct === undefined ? '–' : `${pct}%`}</Text>
372          </Text>
373          {u.usd !== undefined && <Text dimColor>${u.usd.toFixed(2)}</Text>}
374          {running > 0 && <Text color="claude">{running} agent{running === 1 ? '' : 's'}</Text>}
375          {doing && (
376            <Box flexShrink={1}>
377              <Text wrap="truncate-end" dimColor={!current}>▸ {doing}</Text>
378            </Box>
379          )}
380        </Box>
381        {shown.map(a => {
382          const isRunning = a.status === 'running'
383          const mark = isRunning ? '●' : a.status === 'done' ? '✓' : '✗'
384          const markColor = isRunning ? 'claude' : a.status === 'done' ? 'success' : 'error'
385          const label = `${shortModel(a.model)}${a.effort ? ` · ${a.effort}` : ''}`
386          const what = isRunning && a.activity ? `${a.description} — ${a.activity}` : a.description
387          const agentLimits = ctxLimits(warnAt, dangerAt, agentWindow(a.model, loop.model, u.window))
388          const agentPct = (a.tokens / agentWindow(a.model, loop.model, u.window)) * 100
389          // Too few cells to color by position: the whole bar takes the current level's color.
390          const agentFilled = ctxRuns(agentPct, agentLimits, AGENT_CTX_CELLS).reduce((n, run) => n + run.cells.length, 0)
391          const agentColor = ctxColor(agentPct, agentLimits)
392
393          return (
394            <Box key={a.id}>
395              <Text color={markColor}>{mark} </Text>
396              <Box width={typeWidth + 1} flexShrink={0}>
397                <Text wrap="truncate-end">{a.type}</Text>
398              </Box>
399              <Box width={modelWidth + 1} flexShrink={0}>
400                <Text dimColor>{label}</Text>
401              </Box>
402              <Box width={AGENT_CTX_CELLS + 1} flexShrink={0}>
403                <Text>
404                  <Text color={agentColor} dimColor={!isRunning}>{'▰'.repeat(agentFilled)}</Text>
405                  <Text dimColor>{'▱'.repeat(AGENT_CTX_CELLS - agentFilled)}</Text>
406                </Text>
407              </Box>
408              <Box width={6} flexShrink={0} justifyContent="flex-end">
409                <Text dimColor>{a.tokens ? fmtTokens(a.tokens) : ''}</Text>
410              </Box>
411              {hasCost && (
412                <Box width={costWidth} flexShrink={0} justifyContent="flex-end">
413                  <Text dimColor>{a.usd === undefined ? '' : fmtUsd(a.usd)}</Text>
414                </Box>
415              )}
416              <Box width={6} flexShrink={0} justifyContent="flex-end">
417                <Text dimColor>{fmtElapsed((a.endedAt ?? at) - a.startedAt)}</Text>
418              </Box>
419              <Box flexShrink={1} marginLeft={2}>
420                <Text wrap="truncate-end" dimColor={!isRunning}>{what}</Text>
421              </Box>
422            </Box>
423          )
424        })}
425      </Box>
426    )
427  })
428}
429
types/index.d.ts 36 lines
1export type CockpitLoop = {
2  model?: string
3  effort?: string
4  activity?: string
5}
6
7export type CockpitAgent = CockpitLoop & {
8  id: string
9  type: string
10  description: string
11  status: 'running' | 'done' | 'failed'
12  tokens: number
13  usd?: number
14  startedAt: number
15  endedAt?: number
16}
17
18export type CockpitUsage = {
19  percent?: number
20  window?: number
21  usd?: number
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'cockpit': {
27      main: CockpitLoop
28      task: string | null
29      agents: CockpitAgent[]
30      usage: CockpitUsage
31      now: number
32      isExpanded: boolean
33    }
34  }
35}
36