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

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
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.
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.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.$.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.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.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.
hooks/register.tsx 294 lines1import { 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}
294types/index.d.ts 35 lines1// 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