SLOPSHOPPER

cockpit

A band above the prompt: context, rate limits, cost, and each live subagent with its model and its calls against the builder cap.

newbandguardtimeragents
★ 1v0.1.0MITupdated 2026-10-04alexpeta/claude-playbook/mods/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 ✻ Opus 5.5 ctx ━━━━━╺━━━━ 49% 97k/200k 5h 31% ↻NaNdNaNh $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✻ Opus 5.5 ctx ━━━━━╺━━━━ 49% 97k/200k 5h 31% ↻NaNdNaNh $0.42
README

cockpit

A band above the Claude Code prompt. The first line is the session: its model, the context window, the rate-limit windows and what the session has cost. Under it, one row per live subagent: its type, its model, its tool calls (against the builder cap, for the builder type), how long it has run, and its task. A finished agent keeps its row for a minute, with a tick.

✻ Opus 5.5   ctx ━━━━━━━━━╸━━━━━━ 62% 124k/200k ▁▂▃▅▆   5h ━━━╸━━━━ 41% ↻2h10m   $3.10
  ├─ ⠹ builder  Opus 5.5   ━━━━━━━━╸━━━━━━━ 212/400     34m  slice-a
  └─ ✓ Explore  Haiku 4.5  17 calls                     <1m  list the decisions

Install

Once per machine, from the playbook's clone (the marketplace reads the plugin in place, so a git pull is the update, from the next session start):

claude plugin marketplace add ~/Github/claude-playbook claude plugin install cockpit@claude-playbook

Mods need Claude Code 2.1.287 or later. Turn it off in /plugin, Installed tab.

What it reads, and what it never does

  • The cap is mirrored, not enforced. The builder count reads CLAUDE_CALL_CAP_AGENTS, CLAUDE_CALL_CAP and CLAUDE_CALL_CAP_WARN_AT with hooks/call-cap.py's defaults (builder, 400, 360): the band shows the count, the hook stops the build. The count turns from green to yellow at the warning and red at the cap.
  • It only reads and draws. claude plugin validate mods/cockpit lists its calls: the session's usage and model, the agent list, the clock, those three variables, its own state, and drawing. No file, process or network call, and it never approves or refuses a tool call.
  • Its state is session-local. The counts live in $.state and die with the session (and with /clear, after which the figures are read again). The durable record of a build is the call-cap hook's ledger.
  • A figure Claude Code does not report is left out, never zeroed: no context fill before the first response, no rate limits off a subscription. An agent that started before the band loaded is named with no model or count.
  • Colours are Claude Code's own theme keys (claude, permission, rate_limit_fill, success, warning, error, subtle, and the subagent palette), read off the 2.1.289 binary's theme tables, so the band follows the person's theme as /usage does.
  • Width decides the detail. From 130 columns: every meter and the context sparkline (the fill after each turn, so a compaction shows as a drop). From 90: the context and builder meters. Below 90: the numbers.

Develop

claude --plugin-dir mods/cockpit # loads it for one session, reloads on save claude plugin validate mods/cockpit claude plugin test mods/cockpit # 7 tests, terminal and desktop surfaces tsc -p mods/cockpit # once Claude Code has loaded it: it writes the tsconfig

The tests were mutation-checked on 2026-10-04: twelve deliberate breaks (the count, the warning and cap thresholds, the finished-agent window, the context colour, the reset clock, the width tiers, the sparkline's scale and history, the capped type) each turned a test red.

Source 2 files
hooks/register.tsx 294 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { AgentRow, Usage } from '../types'
5
6const usage = atom({ plugin: 'cockpit', key: 'usage' } as const, null)
7const agents = atom({ plugin: 'cockpit', key: 'agents' } as const, {})
8const history = atom({ plugin: 'cockpit', key: 'history' } as const, [])
9
10// Claude Code's own theme keys (read off the 2.1.289 binary's theme tables), so the band follows
11// the person's theme the way /usage and the agent list do.
12const THEME = { accent: 'claude', warn: 'warning', bad: 'error', dim: 'subtle', ok: 'success', ctx: 'permission', limit: 'rate_limit_fill' }
13const HUES = ['blue', 'green', 'purple', 'orange', 'pink', 'cyan', 'yellow', 'red'].map(h => `${h}_FOR_SUBAGENTS_ONLY`)
14
15const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
16const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
17const SPARK = '▁▂▃▄▅▆▇█'
18const TICK_MS = 250
19const DONE_SHOWN_MS = 60_000
20const HISTORY = 24
21
22// The builder call cap, mirrored from the playbook's hooks/call-cap.py, which enforces it: the
23// same environment names and the same defaults. The band shows the count; the hook stops a build.
24async function capOf($: EngineInterface) {
25  const types = (await $.env.get('CLAUDE_CALL_CAP_AGENTS')) ?? 'builder'
26  return {
27    types: types.split(',').filter(Boolean),
28    cap: Number((await $.env.get('CLAUDE_CALL_CAP')) ?? 400),
29    warnAt: Number((await $.env.get('CLAUDE_CALL_CAP_WARN_AT')) ?? 360),
30  }
31}
32
33function toUsage(u: { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }): Usage {
34  const out: Usage = {
35    window: u.context.window,
36    limits: u.rateLimits.map(l => (l.resetsAt === undefined
37      ? { kind: l.kind, percentUsed: l.percentUsed }
38      : { kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
39  }
40  if (u.context.tokens !== undefined) out.tokens = u.context.tokens
41  if (u.context.percent !== undefined) out.percent = u.context.percent
42  if (u.cost !== undefined) out.usd = u.cost.usd
43  return out
44}
45
46async function seed($: EngineInterface) {
47  const u = toUsage(await $.session.usage())
48  await update($, usage, () => u)
49}
50
51function tokens(n: number) {
52  return n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}k`
53}
54
55function span(ms: number) {
56  const m = Math.floor(ms / 60_000)
57  if (m < 1) return '<1m'
58  if (m < 60) return `${m}m`
59  const h = Math.floor(m / 60)
60  if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`
61  return `${Math.floor(h / 24)}d${h % 24}h`
62}
63
64function heat(percent: number, base: string) {
65  return percent >= 80 ? THEME.bad : percent >= 60 ? THEME.warn : base
66}
67
68// claude-opus-5-5 → Opus 5.5, claude-haiku-4-5-20251001 → Haiku 4.5; anything else as given.
69function modelName(id: string) {
70  const m = /^(?:claude-)?([a-z]+)-(\d+)-(\d+)(?:-\d{8})?$/.exec(id)
71  if (m === null || m[1] === undefined) return id
72  return `${m[1].charAt(0).toUpperCase()}${m[1].slice(1)} ${m[2]}.${m[3]}`
73}
74
75function hueOf(type: string) {
76  let h = 0
77  for (const ch of type) h = (h * 31 + (ch.codePointAt(0) ?? 0)) >>> 0
78  return HUES[h % HUES.length] ?? THEME.accent
79}
80
81// A bar in the style of rich's progress bars: a heavy rule, the done part in colour and a half
82// cell at its edge, the rest dim.
83function meter(fraction: number, width: number) {
84  const halves = Math.round(Math.min(Math.max(fraction, 0), 1) * width * 2)
85  const full = Math.floor(halves / 2)
86  const isHalf = halves % 2 === 1
87  let rest = width - full - (isHalf ? 1 : 0)
88  let empty = ''
89  if (rest > 0 && !isHalf && full > 0) {
90    empty = '╺'
91    rest -= 1
92  }
93  return { filled: '━'.repeat(full) + (isHalf ? '╸' : ''), empty: empty + '━'.repeat(rest) }
94}
95
96export const register: Register = on => {
97  on('session.start', async ($, e, next) => {
98    await seed($)
99    // Spinners turn while an agent runs; otherwise a redraw every 30 s keeps the clocks honest.
100    let ticks = 0
101    $.clock.every(TICK_MS, () => {
102      ticks += 1
103      void $.agent.list().then(list => {
104        if (ticks % 120 === 0 || list.some(a => a.status === 'running')) void $.ui.invalidate('ui.render')
105      })
106    })
107    return next(e)
108  })
109
110  // /clear, /resume and /branch empty $.state and fire no session.start.
111  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
112    await seed($)
113    return next(e)
114  })
115
116  on('session.measure', async ($, e, next) => {
117    const u = toUsage(e)
118    await update($, usage, () => u)
119    const percent = e.context.percent
120    if (percent !== undefined && e.changed.includes('context')) {
121      await update($, history, h => [...h, percent].slice(-HISTORY))
122    }
123    return next(e)
124  })
125
126  on('agent.spawn', async ($, e, next) => {
127    const result = await next(e)
128    const id = result.agentId
129    if (id !== undefined) {
130      const row: AgentRow = {
131        type: e.subagentType,
132        model: result.model,
133        description: e.description,
134        startedAt: await $.clock.now(),
135        calls: 0,
136        isDone: false,
137        endedAt: null,
138      }
139      await update($, agents, m => ({ ...m, [id]: row }))
140    }
141    return result
142  })
143
144  on('tool.call', async ($, e, next) => {
145    const id = e.agentId
146    if (id !== undefined) {
147      await update($, agents, m => {
148        const row = m[id]
149        return row === undefined ? m : { ...m, [id]: { ...row, calls: row.calls + 1, isDone: false, endedAt: null } }
150      })
151    }
152    return next(e)
153  })
154
155  // A subagent's run ends with its own turn.complete; a SendMessage that wakes it again
156  // shows it again on its next tool call.
157  on('turn.complete', async ($, e, next) => {
158    const result = await next(e)
159    const id = e.agentId
160    if (id !== undefined) {
161      const now = await $.clock.now()
162      await update($, agents, m => {
163        const row = m[id]
164        return row === undefined ? m : { ...m, [id]: { ...row, isDone: true, endedAt: now } }
165      })
166    }
167    return result
168  })
169
170  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
171    if (e.props.hasSurvey) return next(e)
172
173    const u = await read($, usage)
174    const rows = await read($, agents)
175    const past = await read($, history)
176    const now = await $.clock.now()
177    const running = (await $.agent.list()).filter(a => a.status === 'running' && rows[a.id]?.isDone !== true)
178    const finished = Object.entries(rows).filter(([, r]) => r.isDone && r.endedAt !== null && now - r.endedAt < DONE_SHOWN_MS)
179    if (u === null && running.length === 0 && finished.length === 0) return next(e)
180
181    const { Box, Text } = $.ui.resolve(e)
182    const { types, cap, warnAt } = await capOf($)
183
184    const cols = e.props.bodyColumns
185    const isWide = cols >= 130
186    const isMid = cols >= 90
187    const ctxWidth = isWide ? 16 : isMid ? 10 : 0
188    const limitWidth = isWide ? 8 : 0
189    const sparkCount = isWide ? 12 : 0
190    const callWidth = isWide ? 16 : isMid ? 8 : 0
191
192    const bar = (fraction: number, width: number, color: string) => {
193      const { filled, empty } = meter(fraction, width)
194      return [<Text color={color}>{filled}</Text>, <Text color={THEME.dim}>{empty}</Text>, <Text>{' '}</Text>]
195    }
196
197    // ── the head: model, context, rate limits, cost
198    const head = [
199      <Box flexDirection="row">
200        <Text color={THEME.accent}>{'✻ '}</Text>
201        <Text bold>{modelName(await $.session.model())}</Text>
202      </Box>,
203    ]
204    if (u !== null) {
205      if (u.percent !== undefined && u.tokens !== undefined) {
206        const color = heat(u.percent, THEME.ctx)
207        const spark = []
208        for (const p of past.slice(-sparkCount)) {
209          spark.push(<Text color={heat(p, THEME.dim)}>{SPARK[Math.min(SPARK.length - 1, Math.floor((p / 100) * SPARK.length))] ?? ''}</Text>)
210        }
211        head.push(
212          <Box flexDirection="row">
213            <Text color={THEME.dim}>{'ctx '}</Text>
214            {ctxWidth > 0 ? bar(u.percent / 100, ctxWidth, color) : []}
215            <Text color={color} bold>{`${u.percent}%`}</Text>
216            <Text color={THEME.dim}>{` ${tokens(u.tokens)}/${tokens(u.window)}`}</Text>
217            {spark.length > 1 ? [<Text>{' '}</Text>, ...spark] : []}
218          </Box>,
219        )
220      } else {
221        head.push(<Text color={THEME.dim}>{`ctx ${tokens(u.window)}`}</Text>)
222      }
223      for (const l of u.limits) {
224        const color = heat(l.percentUsed, THEME.limit)
225        head.push(
226          <Box flexDirection="row">
227            <Text color={THEME.dim}>{`${LIMIT_LABEL[l.kind] ?? l.kind} `}</Text>
228            {limitWidth > 0 ? bar(l.percentUsed / 100, limitWidth, color) : []}
229            <Text color={color}>{`${l.percentUsed}%`}</Text>
230            {l.resetsAt === undefined ? [] : <Text color={THEME.dim}>{` ↻${span(Date.parse(l.resetsAt) - now)}`}</Text>}
231          </Box>,
232        )
233      }
234      if (u.usd !== undefined) head.push(<Text color={THEME.dim}>{`$${u.usd.toFixed(2)}`}</Text>)
235    }
236
237    // ── one row per agent: running first, then the ones that finished in the last minute
238    type Line = { id: string; row: AgentRow | undefined; type: string; description: string; isDone: boolean }
239    const lines: Line[] = [
240      ...running.map(a => ({ id: a.id, row: rows[a.id], type: rows[a.id]?.type ?? a.type, description: rows[a.id]?.description ?? a.description, isDone: false })),
241      ...finished.map(([id, r]) => ({ id, row: r, type: r.type, description: r.description, isDone: true })),
242    ]
243    const typeWidth = Math.max(0, ...lines.map(l => l.type.length))
244    const modelWidth = Math.max(0, ...lines.map(l => (l.row === undefined ? 0 : modelName(l.row.model).length)))
245    const countWidth = callWidth + `${cap}/${cap}`.length + (callWidth > 0 ? 1 : 0)
246    const frame = SPINNER[Math.floor(now / TICK_MS) % SPINNER.length] ?? '•'
247
248    const agentLines = lines.map((l, i) => {
249      const branch = i === lines.length - 1 ? '  └─ ' : '  ├─ '
250      const hue = hueOf(l.type)
251      const lead = [
252        <Text color={THEME.dim}>{branch}</Text>,
253        l.isDone ? <Text color={THEME.ok}>{'✓ '}</Text> : <Text color={hue}>{`${frame} `}</Text>,
254        <Text color={l.isDone ? THEME.dim : hue} bold={!l.isDone}>{`${l.type.padEnd(typeWidth)}  `}</Text>,
255      ]
256      const row = l.row
257      if (row === undefined) {
258        // Started before this band loaded: no model or count to show, and none is made up.
259        return (
260          <Box key={l.id} flexDirection="row">
261            {lead}
262            <Text color={THEME.dim} wrap="truncate">{`${l.description}  (started before the band loaded)`}</Text>
263          </Box>
264        )
265      }
266      const isCapped = types.includes(row.type)
267      const countColor = row.calls >= cap ? THEME.bad : row.calls >= warnAt ? THEME.warn : THEME.ok
268      const count = isCapped
269        ? [
270            ...(callWidth > 0 && !l.isDone ? bar(row.calls / cap, callWidth, countColor) : []),
271            <Text color={l.isDone ? THEME.dim : countColor}>{`${row.calls}/${cap}`.padEnd(l.isDone || callWidth === 0 ? countWidth : countWidth - callWidth - 1)}</Text>,
272          ]
273        : [<Text color={THEME.dim}>{`${row.calls} calls`.padEnd(countWidth)}</Text>]
274      const elapsed = span((l.isDone && row.endedAt !== null ? row.endedAt : now) - row.startedAt)
275      return (
276        <Box key={l.id} flexDirection="row">
277          {lead}
278          <Text color={THEME.dim}>{`${modelName(row.model).padEnd(modelWidth)}  `}</Text>
279          {count}
280          <Text color={THEME.dim}>{`  ${elapsed.padStart(6)}  `}</Text>
281          <Text color={l.isDone ? THEME.dim : undefined} wrap="truncate">{row.description}</Text>
282        </Box>
283      )
284    })
285
286    return (
287      <Box flexDirection="column">
288        <Box flexDirection="row" columnGap={3}>{head}</Box>
289        {agentLines}
290      </Box>
291    )
292  })
293}
294
types/index.d.ts 35 lines
1// What the band draws. Session-local: kept in $.state, never written to $.store or disk;
2// the call-cap hook's ledger (~/.claude/builder-calls.jsonl) is the durable record of a build.
3
4// The figures $.session.usage() and session.measure report. One the engine does not have is
5// left out, never zeroed, and the band leaves it out too.
6export type Usage = {
7  tokens?: number
8  window: number
9  percent?: number
10  limits: { kind: string; percentUsed: number; resetsAt?: string }[]
11  usd?: number
12}
13
14// One subagent this session started.
15export type AgentRow = {
16  type: string
17  model: string
18  description: string
19  startedAt: number
20  calls: number
21  isDone: boolean
22  endedAt: number | null
23}
24
25declare module 'claude-code' {
26  interface PluginState {
27    cockpit: {
28      usage: Usage | null
29      agents: Record<string, AgentRow>
30      // The context fill after each measurement that moved it, oldest first.
31      history: number[]
32    }
33  }
34}
35