One pane for the whole session: the original intent and drift, subagents and background shells with model, tokens, idle and typical duration, files changed…

A Claude Code mod that opens a pane beside the session and keeps the session's state in one place. It shows the intent (your first real prompt), the jobs that are running (subagents and background shells, each with a Stop button), the files touched with lines added and removed, the open questions from the last reply, Shorts (one-click prompts such as TLDR, Prove it, or Waiting on me?), and the pace of your 5-hour and 7-day usage limits. It also shows context use, session cost, and how long the prompt cache has left.
Two-minute walkthrough, recorded from a real session: youtu.be/rY4fyCVfD5c

The first prompt of 30 characters or more becomes the session's intent. You can replace it with the latest prompt at any time.
After 15 prompts since the intent was set or last checked, or 150 tool calls since the intent was set with no check yet, the mod does two things on your next prompt:
The thresholds are DRIFT_PROMPTS and DRIFT_TOOLS in hooks/center.ts.
/plugin marketplace add BioInfo/claudelicious
/plugin install command-center@claudelicious
/reload-plugins
The pane opens when a session starts. Run /command-center to open and focus it.
Requirements:
hooks/hooks.json modules).Read this before you install any mod. It runs inside your Claude Code session.
It reads:
autoCompactWindow.HOME environment variable, only to shorten paths to ~/... in the pane..claude/CONTINUITY.md in the working directory, every 15 seconds, to show how far behind a session cursor file is. If the file does not exist nothing is shown.It writes:
atom store (key command-center/center).It acts only when you press a button:
TaskStop tool on that agent or shell, with a consent note saying you pressed Stop.claude plugin test mods/command-center
MIT. See LICENSE.
hooks/register.tsx 596 lines1import { atom, read, update } from 'claude-code'
2import type { Register, RenderChildren } from 'claude-code'
3import type { FileStat, View } from '../types'
4import type { Short } from './center'
5
6import {
7 DRIFT_PROMPTS, DRIFT_TOOLS, findShort, migrate, PAL, QUICK_SHORTS, SHORTS, shortPrompt, age, alerts, bannerCells, bar, burnPerHour, cacheLeft, cachePct, clearWaiting, clipLines, ctxOf, cursorGap, drainCells, driftContextNote, driftNote, editDelta, emptyCenter, fadeCells, finishShells,
8 intentCheckPrompt, markChecked, meterCells, meterTone, onAgentTool, onEdit, onMainTurn, onFailure, onMainTool, onPrompt, onResponse, onStep, onTurn, paceNote, questionsIn,
9 resetIntent, setCursor, setMainModel, setReply, setVitals, setView, setWaiting, shortModel, sparkCells, spinner, splitPath, startShell, stallLevel, syncAgents, tailTrunc, toneHex, tok, trunc, typicalMs, usd, waitKind, warnOnce,
10} from './center'
11
12const PANE = 'command-center'
13const TICK_MS = 15_000
14let anyRunning = false
15const PANE_COLUMNS = 50 // about a quarter of a 200-column terminal; a width the person drags wins
16const centerAtom = atom({ plugin: 'command-center', key: 'center' } as const, emptyCenter())
17
18// One pane for the whole session. Everything shown is read from the engine's own events:
19// nothing here is a model's guess, and "how long to go" is only ever the median of agents of the
20// same type that already finished.
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 const s = await $.settings.read()
24 const compactAt = typeof s.autoCompactWindow === 'number' ? s.autoCompactWindow : null
25 await update($, centerAtom, c => ({ ...migrate(c), compactAt }))
26 await $.command.register({ name: 'command-center', description: 'Command center: intent, subagents, jobs, files changed, last reply' })
27 void $.ui.open({ id: PANE, title: 'Command center', columns: PANE_COLUMNS })
28
29 $.clock.every(1000, () => {
30 if (anyRunning) $.ui.invalidate('ui.render')
31 })
32
33 $.clock.every(TICK_MS, async () => {
34 const list = await $.agent.list()
35 const now = Date.now()
36 const cur = await $.fs.stat('.claude/CONTINUITY.md').catch(() => null)
37 await update($, centerAtom, c => setCursor(syncAgents(c, list, now), cur && cur.kind === 'file' ? cur.mtimeMs : null))
38 const c = await read($, centerAtom)
39 for (const a of list) {
40 const key = `stuck:${a.id}`
41 if (stallLevel(a.status, c.agents[a.id], now) === 'stuck' && !c.warned.includes(key)) {
42 await update($, centerAtom, x => warnOnce(x, key).c)
43 $.ui.toast(`${a.type} "${a.description.slice(0, 40)}" has been quiet for ${age(now - c.agents[a.id].lastSeenAt)}`)
44 }
45 }
46 $.ui.invalidate('ui.render')
47 })
48
49 return next(e)
50 })
51
52 on('command.run', { command: 'command-center' }, async $ => {
53 await $.ui.open({ id: PANE, title: 'Command center', columns: PANE_COLUMNS, focus: true, closeOnEscape: true })
54
55 return { text: 'Command center opened.' }
56 })
57
58 on('prompt.submit', async ($, e, next) => {
59 if (e.origin.kind === 'composer') {
60 await update($, centerAtom, c => onPrompt(c, e.text, Date.now()))
61 const c = await read($, centerAtom)
62 const note = driftNote(c)
63 const since = c.prompts - Math.max(c.promptsAtIntent, c.checkedAtPrompt)
64 const key = `drift:${c.checkedAtPrompt}:${c.promptsAtIntent}:${Math.floor(since / DRIFT_PROMPTS)}:${Math.floor(c.toolsSinceIntent / DRIFT_TOOLS)}`
65 if (note && !c.warned.includes(key)) {
66 await update($, centerAtom, x => warnOnce(x, key).c)
67 $.ui.toast(`${note}: open the command center and press Check intent`)
68
69 return next({ ...e, context: [...(e.context ?? []), driftContextNote(c)] })
70 }
71 }
72
73 return next(e)
74 })
75
76 on('tool.call', async ($, e, next) => {
77 const now = Date.now()
78 const tool: string = e.tool
79 const isSub = e.agentId !== undefined
80 const wait = isSub ? null : waitKind(tool)
81
82 if (isSub) {
83 await update($, centerAtom, c => onAgentTool(c, e.agentId as string, now, tool))
84 } else {
85 await update($, centerAtom, c => onMainTool(c, tool))
86 }
87 if (wait) await update($, centerAtom, c => setWaiting(c, wait, now))
88
89 const ran = await next(e)
90
91 if (wait) await update($, centerAtom, c => clearWaiting(c))
92
93 if ('isError' in ran && ran.isError === true) {
94 const why = 'text' in ran && typeof ran.text === 'string' ? ran.text : ''
95 await update($, centerAtom, c => onFailure(c, tool, why, isSub, Date.now()))
96 }
97
98 if (!isSub && e.tool === 'Bash' && e.run_in_background) {
99 const text = 'text' in ran && typeof ran.text === 'string' ? ran.text : ''
100 const m = /\b(?:id|ID)[:\s]+([a-z0-9_-]{6,})/.exec(text)
101 await update($, centerAtom, c => startShell(c, m?.[1] ?? e.tool_use_id, e.command, Date.now(), m !== null))
102 }
103
104 if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'MultiEdit') {
105 const d = editDelta(e.tool, e as unknown as Record<string, unknown>)
106 const failed = 'deny' in ran && ran.deny !== undefined ? true : 'isError' in ran && ran.isError === true
107 if (d && !failed) {
108 const by = e.agentId ? `sub:${e.agentId.slice(0, 6)}` : 'main'
109 await update($, centerAtom, c => onEdit(c, d.path, d.added, d.removed, by, Date.now()))
110 }
111 }
112
113 return ran
114 })
115
116 on('turn.step', async function* ($, e, next) {
117 if (e.agentId) await update($, centerAtom, c => onStep(c, e.agentId as string, Date.now(), e.model, String(e.effort ?? ''), e.messageCount))
118 else await update($, centerAtom, c => setMainModel(c, e.model, String(e.effort ?? '')))
119
120 const r = yield* next(e)
121 if (e.agentId) await update($, centerAtom, c => onResponse(c, e.agentId as string, Date.now(), ctxOf(r.usage)))
122
123 return r
124 })
125
126 on('turn.complete', async ($, e, next) => {
127 if (e.agentId) {
128 await update($, centerAtom, c => onTurn(c, e.agentId as string, Date.now(), e.usage))
129 } else if (!e.isAborted && e.answer.trim() !== '') {
130 await update($, centerAtom, c => onMainTurn(c, Date.now()))
131 await update($, centerAtom, c => setReply(c, e.answer, Date.now(), Math.round(e.durationMs / 1000)))
132 } else if (!e.isAborted) {
133 await update($, centerAtom, c => onMainTurn(c, Date.now()))
134 }
135
136 return next(e)
137 })
138
139 on('session.measure', async ($, e, next) => {
140 const limits = e.rateLimits.map(l => ({ kind: l.kind, pct: Math.round(l.percentUsed), resetsAt: l.resetsAt }))
141 await update($, centerAtom, c => setVitals(c, e.context.tokens ?? null, limits, e.cost?.usd ?? null, Date.now()))
142
143 return next(e)
144 })
145
146 on('session.receive', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
147 await update($, centerAtom, c => finishShells(c, e.text, Date.now()))
148
149 return next(e)
150 })
151
152 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
153 const els = $.ui.resolve(e)
154 const { Box, Button, Text } = els
155 // Raster is the terminal's truecolor cell grid; other surfaces get the plain-text bar.
156 const Raster = 'Raster' in els ? els.Raster : null
157 const c = migrate(await read($, centerAtom))
158 const list = await $.agent.list()
159 const now = Date.now()
160 const rows = e.viewport?.rows ?? 40
161 const width = Math.max(28, (e.props.bodyColumns ?? 50) - 2)
162 const iw = width - 4 // inside a bordered card
163 const home = (await $.env.get('HOME')) ?? ''
164 const go = (v: View) => () => update($, centerAtom, x => setView(x, v))
165 const b64 = (w: Uint32Array) => (new Uint8Array(w.buffer) as Uint8Array & { toBase64(): string }).toBase64()
166
167 const running = list.filter(a => a.status === 'running')
168 anyRunning = running.length > 0
169 const finished = list.filter(a => a.status !== 'running').reverse()
170 const openShells = c.shells.filter(s => s.doneAt === null)
171 const doneShells = c.shells.filter(s => s.doneAt !== null).reverse()
172 const fileList = Object.entries(c.files).sort((a, b) => b[1].lastAt - a[1].lastAt)
173 const totalAdd = fileList.reduce((n, [, f]) => n + f.added, 0)
174 const totalDel = fileList.reduce((n, [, f]) => n + f.removed, 0)
175 const qs = c.reply ? questionsIn(c.reply.text) : []
176 const five = c.limits.find(l => l.kind === 'five_hour')
177 const seven = c.limits.find(l => l.kind === 'seven_day')
178 const ctxPct = c.ctx !== null && c.compactAt ? Math.round((c.ctx / c.compactAt) * 100) : null
179 const todo = alerts(c, list, now)
180 const cache = cacheLeft(c, now)
181 const gap = cursorGap(c)
182 const barW = Math.max(8, width - 20)
183 const spin = spinner(now)
184
185 // A rounded card: the border carries the tone, the title carries the meaning.
186 const Card = (p: { title: string; note?: string; tone?: string; children?: RenderChildren }) => (
187 <Box flexDirection="column" borderStyle="round" borderColor={p.tone ?? PAL.line} paddingX={1} marginTop={1}>
188 <Text wrap="truncate-end">
189 <Text bold color={p.tone ?? PAL.accent}>
190 {p.title}
191 </Text>
192 {p.note ? <Text color={PAL.mute}> {p.note}</Text> : null}
193 </Text>
194 {p.children}
195 </Box>
196 )
197
198 const Meter = (p: { label: string; pct: number; note?: string | null }) => (
199 <Box>
200 <Box width={6}>
201 <Text color={PAL.mute}>{p.label}</Text>
202 </Box>
203 {Raster ? (
204 <Raster key={`m-${p.label}`} columns={barW} rows={1} cells={b64(meterCells(p.pct, barW))} />
205 ) : (
206 <Text color={toneHex(meterTone(p.pct))}>{bar(p.pct, barW)}</Text>
207 )}
208 <Text bold color={toneHex(meterTone(p.pct))}>
209 {' '}
210 {String(p.pct).padStart(3)}%
211 </Text>
212 </Box>
213 )
214
215 const tab = (v: View, hotkey: string, icon: string, label: string, n?: number) => (
216 <Box marginRight={1}>
217 <Button variant={c.view === v ? 'primary' : undefined} hotkey={hotkey} onPress={go(v)}>
218 {`${label}${n !== undefined && n > 0 ? ` ${n}` : ''}`}
219 </Button>
220 </Box>
221 )
222 const stop = (id: string, what: string) => async () => {
223 const r = await $.tool.call({ tool: 'TaskStop', task_id: id, consent: `The user pressed Stop on this ${what} in the command center.` })
224 $.ui.toast('deny' in r && r.deny !== undefined ? `Stop refused: ${r.deny}` : 'isError' in r && r.isError === true ? 'Stop failed' : 'Stop sent')
225 }
226
227 const send = (s: Short) => async () => {
228 await $.prompt.submit({ text: shortPrompt(s), asUser: true })
229 $.ui.toast(`Sent: ${s.label}`)
230 }
231 // Click only, no letter keys: typing while the pane has focus would fire a run of prompts.
232 const ShortButton = (p: { s: Short }) => (
233 <Box marginRight={1}>
234 <Button onPress={send(p.s)}>
235 {p.s.label}
236 </Button>
237 </Box>
238 )
239
240 const AgentCard = (p: { a: (typeof list)[number] }) => {
241 const a = p.a
242 const s = c.agents[a.id]
243 const level = stallLevel(a.status, s, now)
244 const typical = typicalMs(c, a.type)
245 const up = s ? (s.endedAt ?? now) - s.startedAt : 0
246 const pct = s ? cachePct(s) : null
247 const live = a.status === 'running'
248 const ok = /complet|done|success/i.test(a.status)
249 const tone = level === 'stuck' ? PAL.bad : level === 'quiet' ? PAL.warn : live ? PAL.good : ok ? PAL.line : PAL.bad
250
251 return (
252 <Box flexDirection="column" borderStyle="round" borderColor={tone} paddingX={1} marginTop={1}>
253 <Text wrap="truncate-end">
254 <Text color={live ? PAL.good : ok ? PAL.mute : PAL.bad}>{live ? spin : ok ? '✓' : '✗'} </Text>
255 <Text bold={live} color={live ? undefined : PAL.mute}>
256 {a.type}
257 </Text>
258 <Text color={PAL.mute}> {age(up)}</Text>
259 {level === 'quiet' ? <Text color={PAL.warn}> quiet {age(now - (s?.lastSeenAt ?? now))}</Text> : null}
260 {level === 'stuck' ? <Text color={PAL.bad}> stuck? {age(now - (s?.lastSeenAt ?? now))}</Text> : null}
261 </Text>
262 <Text color={PAL.mute} wrap="truncate-end">
263 {trunc(a.description, iw)}
264 </Text>
265 {s ? (
266 <Box flexDirection="column">
267 <Text color={PAL.data} wrap="truncate-end">
268 {shortModel(s.model)}
269 {s.effort ? ` ${s.effort}` : ''}
270 <Text color={PAL.mute}>
271 {' '}
272 · ctx {tok(s.ctx)} · {s.tools} tools{s.lastTool ? ` (${s.lastTool})` : ''}
273 </Text>
274 </Text>
275 <Text color={PAL.mute} wrap="truncate-end">
276 {s.turns > 0 ? `in ${tok(s.input)} out ${tok(s.output)}` : 'tokens counted when it finishes'}
277 {pct !== null ? ` · cache ${pct}%` : ''}
278 {live ? ` · idle ${age(now - s.lastSeenAt)}` : ''}
279 {live && typical ? ` · typ ${age(typical.ms)}` : ''}
280 </Text>
281 </Box>
282 ) : (
283 <Text color={PAL.mute}>started before this mod loaded</Text>
284 )}
285 {level !== 'ok' ? (
286 <Box>
287 <Button onPress={stop(a.id, 'agent')}>Stop this agent</Button>
288 </Box>
289 ) : null}
290 </Box>
291 )
292 }
293
294 const ShellRow = (p: { s: (typeof c.shells)[number] }) => (
295 <Box flexDirection="column">
296 <Text wrap="truncate-end">
297 <Text color={p.s.doneAt === null ? PAL.good : PAL.mute}>{p.s.doneAt === null ? spin : '✓'} </Text>
298 <Text color={PAL.mute}>{age((p.s.doneAt ?? now) - p.s.startedAt)} </Text>
299 <Text>{trunc(p.s.command.replace(/\s+/g, ' '), iw - 8)}</Text>
300 </Text>
301 {p.s.doneAt === null && p.s.stoppable ? (
302 <Box marginLeft={2}>
303 <Button onPress={stop(p.s.id, 'background shell')}>Stop</Button>
304 </Box>
305 ) : null}
306 </Box>
307 )
308
309 const FileRow = (p: { path: string; f: FileStat }) => {
310 const { dir, base } = splitPath(p.path, home)
311
312 return (
313 <Box flexDirection="column">
314 <Text wrap="truncate-end">
315 <Text color={PAL.good}>+{p.f.added}</Text>
316 <Text color={PAL.bad}> −{p.f.removed} </Text>
317 <Text bold color={PAL.data}>
318 {trunc(base, iw - 10)}
319 </Text>
320 </Text>
321 <Text color={PAL.mute} wrap="truncate-end">
322 {tailTrunc(dir, iw - 14)} · ×{p.f.edits} {age(now - p.f.lastAt)}
323 </Text>
324 </Box>
325 )
326 }
327
328 const body = () => {
329 if (c.view === 'jobs') {
330 const cap = Math.max(3, Math.floor((rows - 14) / 6))
331
332 return (
333 <Box flexDirection="column">
334 <Card title="SUBAGENTS" note={`${running.length} running · ${list.length} total`} tone={running.length > 0 ? PAL.good : undefined}>
335 {list.length === 0 && <Text color={PAL.mute}>None.</Text>}
336 </Card>
337 {running.map(a => (
338 <AgentCard a={a} />
339 ))}
340 {finished.slice(0, cap).map(a => (
341 <AgentCard a={a} />
342 ))}
343 {finished.length > cap ? <Text color={PAL.mute}>+{finished.length - cap} older</Text> : null}
344 <Card title="BACKGROUND SHELLS" note={`${openShells.length} open · ${c.shells.length} started`} tone={openShells.length > 0 ? PAL.good : undefined}>
345 {c.shells.length === 0 && <Text color={PAL.mute}>None.</Text>}
346 {openShells.map(s => (
347 <ShellRow s={s} />
348 ))}
349 {doneShells.slice(0, 3).map(s => (
350 <ShellRow s={s} />
351 ))}
352 </Card>
353 </Box>
354 )
355 }
356
357 if (c.view === 'files') {
358 const cap = Math.max(6, Math.floor((rows - 14) / 2))
359
360 return (
361 <Card title="FILES CHANGED" note={`${fileList.length} · +${totalAdd} −${totalDel}`} tone={PAL.data}>
362 {fileList.length === 0 && <Text color={PAL.mute}>None this session.</Text>}
363 {fileList.slice(0, cap).map(([p, f]) => (
364 <FileRow path={p} f={f} />
365 ))}
366 {fileList.length > cap ? <Text color={PAL.mute}>+{fileList.length - cap} older</Text> : null}
367 </Card>
368 )
369 }
370
371 if (c.view === 'shorts') {
372 return (
373 <Box flexDirection="column">
374 <Text color={PAL.mute} wrap="wrap">
375 Click one to send it as your next prompt.
376 </Text>
377 {SHORTS.map(g => (
378 <Card title={g.title}>
379 <Box flexWrap="wrap">
380 {g.shorts.map(s => (
381 <ShortButton s={s} />
382 ))}
383 </Box>
384 </Card>
385 ))}
386 </Box>
387 )
388 }
389
390 if (c.view === 'reply') {
391 const lines = c.reply ? clipLines(c.reply.text, Math.max(8, rows - 16), 4000) : [] // Text wraps long lines itself; this tab must not cut them
392
393 return (
394 <Box flexDirection="column">
395 {qs.length > 0 ? (
396 <Card title="OPEN QUESTIONS FOR YOU" tone={PAL.warn}>
397 {qs.map(q => (
398 <Text color={PAL.warn}>▸ {q}</Text>
399 ))}
400 </Card>
401 ) : null}
402 <Card title="LAST REPLY" note={c.reply ? `${age(now - c.reply.at)} ago · took ${c.reply.seconds}s` : ''}>
403 {!c.reply && <Text color={PAL.mute}>None yet.</Text>}
404 {lines.map(l => (
405 <Text color={PAL.mute}>{l}</Text>
406 ))}
407 </Card>
408 </Box>
409 )
410 }
411
412 return (
413 <Box flexDirection="column">
414 <Card title="INTENT" tone={PAL.accent}>
415 {c.intent === '' ? (
416 <Text color={PAL.mute}>None yet. The first real prompt becomes the intent.</Text>
417 ) : (
418 <Box flexDirection="column">
419 {clipLines(c.intent, 3, iw).map(l => (
420 <Text>{l}</Text>
421 ))}
422 <Text color={PAL.mute} wrap="truncate-end">
423 {age(now - c.intentAt)} ago · {c.prompts - c.promptsAtIntent} prompts · {c.toolsSinceIntent} tools since
424 </Text>
425 {c.cursorAt !== null ? (
426 <Text color={gap && gap.behindMs > 30 * 60_000 ? PAL.warn : PAL.mute} wrap="truncate-end">
427 cursor {age(now - c.cursorAt)} old{gap ? ` · ${gap.files} file${gap.files > 1 ? 's' : ''} edited since` : ' · current'}
428 </Text>
429 ) : null}
430 </Box>
431 )}
432 <Box marginTop={1}>
433 <Box marginRight={1}>
434 <Button
435 variant="primary"
436 hotkey="i"
437 onPress={async () => {
438 await update($, centerAtom, x => markChecked(x))
439 await $.prompt.submit({ text: intentCheckPrompt(c.intent), asUser: true })
440 }}
441 >
442 Check intent
443 </Button>
444 </Box>
445 <Button hotkey="s" onPress={() => update($, centerAtom, x => resetIntent(x, Date.now()))}>
446 Use latest prompt
447 </Button>
448 </Box>
449 </Card>
450
451 <Box marginTop={1} flexWrap="wrap">
452 {QUICK_SHORTS.map(l => findShort(l)).map(s => (s ? <ShortButton s={s} /> : null))}
453 <Button plain dimColor onPress={go('shorts')}>
454 more →
455 </Button>
456 </Box>
457
458 <Card title="RUNNING" note={`${running.length} agents · ${openShells.length} shells`} tone={running.length + openShells.length > 0 ? PAL.good : undefined}>
459 {running.length + openShells.length === 0 && <Text color={PAL.mute}>Nothing running.</Text>}
460 {running.slice(0, 4).map(a => {
461 const s = c.agents[a.id]
462 const level = stallLevel(a.status, s, now)
463
464 return (
465 <Text wrap="truncate-end">
466 <Text color={level === 'stuck' ? PAL.bad : level === 'quiet' ? PAL.warn : PAL.good}>{spin} </Text>
467 <Text bold>{a.type} </Text>
468 <Text color={PAL.mute}>
469 {s ? age(now - s.startedAt) : ''} {trunc(a.description, iw - a.type.length - 9)}
470 </Text>
471 </Text>
472 )
473 })}
474 {openShells.slice(0, 3).map(s => (
475 <Text wrap="truncate-end">
476 <Text color={PAL.good}>{spin} </Text>
477 <Text color={PAL.mute}>{age(now - s.startedAt)} </Text>
478 <Text>{trunc(s.command.replace(/\s+/g, ' '), iw - 8)}</Text>
479 </Text>
480 ))}
481 {running.length > 4 || openShells.length > 3 ? (
482 <Button plain dimColor onPress={go('jobs')}>
483 all jobs →
484 </Button>
485 ) : null}
486 </Card>
487
488 <Card title="FILES" note={`${fileList.length} · +${totalAdd} −${totalDel}`} tone={PAL.data}>
489 {fileList.length === 0 && <Text color={PAL.mute}>None this session.</Text>}
490 {fileList.slice(0, 3).map(([p, f]) => (
491 <Text wrap="truncate-end">
492 <Text color={PAL.good}>+{f.added}</Text>
493 <Text color={PAL.bad}> −{f.removed} </Text>
494 <Text color={PAL.data}>{trunc(splitPath(p, home).base, iw - 14)}</Text>
495 <Text color={PAL.mute}> {age(now - f.lastAt)}</Text>
496 </Text>
497 ))}
498 {fileList.length > 3 ? (
499 <Button plain dimColor onPress={go('files')}>
500 {`all ${fileList.length} files →`}
501 </Button>
502 ) : null}
503 </Card>
504
505 <Card title="LAST REPLY" note={c.reply ? `${age(now - c.reply.at)} ago` : ''}>
506 {!c.reply && <Text color={PAL.mute}>None yet.</Text>}
507 {c.reply ? clipLines(c.reply.text, 3, iw).map(l => <Text color={PAL.mute}>{l}</Text>) : null}
508 {c.reply ? (
509 <Button plain dimColor onPress={go('reply')}>
510 read all →
511 </Button>
512 ) : null}
513 </Card>
514 </Box>
515 )
516 }
517
518 return (
519 <Box flexDirection="column">
520 {Raster ? (
521 <Box flexDirection="column">
522 <Raster key="banner" columns={width} rows={1} cells={b64(bannerCells(`◆ COMMAND CENTER${c.inline > 0 ? ` ${c.inline} inline` : ''}`, width))} />
523 <Raster key="fade" columns={width} rows={1} cells={b64(fadeCells(width))} />
524 </Box>
525 ) : (
526 <Text bold color={PAL.accent}>
527 ◆ COMMAND CENTER{c.inline > 0 ? ` ${c.inline} inline` : ''}
528 </Text>
529 )}
530 {ctxPct !== null && c.ctx !== null ? <Meter label="ctx" pct={ctxPct} /> : <Text color={PAL.mute}>ctx {c.ctx !== null ? tok(c.ctx) : 'n/a'}</Text>}
531 {Raster && c.ctxHist.length > 1 ? (
532 <Box>
533 <Box width={6}>
534 <Text color={PAL.mute}> </Text>
535 </Box>
536 <Raster key="spark" columns={barW} rows={1} cells={b64(sparkCells(c.ctxHist, barW, c.compactAt ?? undefined))} />
537 <Text color={PAL.mute}> {c.ctx !== null ? tok(c.ctx) : ''}</Text>
538 </Box>
539 ) : null}
540 <Text wrap="truncate-end">
541 <Text color={PAL.cream} bold>
542 {c.cost !== null ? usd(c.cost) : '$–'}
543 </Text>
544 <Text color={PAL.mute}>
545 {burnPerHour(c, now) !== null ? ` · ${usd(burnPerHour(c, now) as number)}/h` : ''}
546 {c.mainModel ? ` · ${shortModel(c.mainModel)}${c.mainEffort ? ` ${c.mainEffort}` : ''}` : ''}
547 </Text>
548 </Text>
549 {cache ? (
550 <Box>
551 <Box width={6}>
552 <Text color={PAL.mute}>cache</Text>
553 </Box>
554 {Raster ? <Raster key="cache" columns={Math.max(4, barW - 4)} rows={1} cells={b64(drainCells(cache.ms / c.ttlMs, Math.max(4, barW - 4)))} /> : null}
555 <Text color={cache.warm ? (cache.ms < 5 * 60_000 ? PAL.warn : PAL.good) : PAL.bad}>{cache.warm ? ` ${age(cache.ms)}` : ' cold'}</Text>
556 </Box>
557 ) : null}
558 {five ? <Meter label="5h" pct={five.pct} /> : null}
559 {five && paceNote(five, now) && paceNote(five, now) !== 'resets first' ? <Text color={PAL.warn}> 5h {paceNote(five, now)}</Text> : null}
560 {seven ? <Meter label="7d" pct={seven.pct} /> : null}
561 {seven && paceNote(seven, now) && paceNote(seven, now) !== 'resets first' ? <Text color={PAL.warn}> 7d {paceNote(seven, now)}</Text> : null}
562
563 {todo.length > 0 ? (
564 <Box flexDirection="column" borderStyle="round" borderColor={todo.some(t => t.tone === 'red') ? PAL.bad : PAL.warn} paddingX={1} marginTop={1}>
565 <Text bold color={todo.some(t => t.tone === 'red') ? PAL.bad : PAL.warn}>
566 NEEDS YOU
567 </Text>
568 {todo.slice(0, 4).map(t => (
569 <Box>
570 <Text color={t.tone === 'red' ? PAL.bad : PAL.warn}>▸ </Text>
571 <Button plain onPress={go(t.view)}>
572 {trunc(t.text, iw - 3)}
573 </Button>
574 </Box>
575 ))}
576 </Box>
577 ) : null}
578
579 <Box marginTop={1} flexWrap="wrap">
580 {tab('now', '1', '◈', 'Now')}
581 {tab('jobs', '2', '▶', 'Jobs', running.length + openShells.length)}
582 {tab('files', '3', '▤', 'Files', fileList.length)}
583 {tab('reply', '4', '✉', 'Reply', qs.length)}
584 {tab('shorts', '5', '⚡', 'Shorts')}
585 </Box>
586
587 {body()}
588 </Box>
589 )
590 }).catch(async ($, e, next) => {
591 const { Text } = $.ui.resolve(e)
592
593 return <Text color="red">Command center could not draw: {next.error.message ?? next.error.kind}. Run claude --debug for the cause.</Text>
594 })
595}
596hooks/center.ts 654 lines1import type { AgentStat, Center, FileStat, Limit, View, Waiting } from '../types'
2
3export const STALL_MS = 10 * 60 * 1000
4export const STALL_HARD_MS = 20 * 60 * 1000
5export const MAX_SHELLS = 40
6export const MAX_FILES = 200
7export const DRIFT_PROMPTS = 15
8export const DRIFT_TOOLS = 150
9export const MIN_INTENT_CHARS = 30
10
11export const emptyCenter = (): Center => ({
12 view: 'now',
13 ctxHist: [],
14 cost: null,
15 sessionAt: 0,
16 lastTurnAt: 0,
17 ttlMs: 3600_000,
18 cursorAt: null,
19 mainModel: '',
20 mainEffort: '',
21 intent: '',
22 intentAt: 0,
23 intentSource: '',
24 lastPrompt: '',
25 prompts: 0,
26 promptsAtIntent: 0,
27 toolsSinceIntent: 0,
28 checkedAtPrompt: 0,
29 agents: {},
30 shells: [],
31 files: {},
32 reply: null,
33 ctx: null,
34 compactAt: null,
35 limits: [],
36 inline: 0,
37 warned: [],
38 waiting: null,
39 failures: { main: 0, sub: 0, last: '', lastAt: 0 },
40})
41
42// State saved by an older build lacks the fields added since; a reload keeps that state, so fill the gaps.
43export const migrate = (c: Partial<Center>): Center => ({ ...emptyCenter(), ...c })
44
45export const blankAgent = (now: number): AgentStat => ({
46 model: '',
47 effort: '',
48 steps: 0,
49 turns: 0,
50 messages: 0,
51 input: 0,
52 output: 0,
53 cacheRead: 0,
54 cacheWrite: 0,
55 tools: 0,
56 lastTool: '',
57 startedAt: now,
58 lastSeenAt: now,
59 ctx: 0,
60 ctxPeak: 0,
61 endedAt: null,
62 type: '',
63 description: '',
64})
65
66// ---- intent ------------------------------------------------------------------------------
67
68// A prompt worth anchoring on: typed by the person, long enough to state a goal, not a slash command.
69export const isIntentCandidate = (text: string): boolean => {
70 const t = text.trim()
71
72 return t.length >= MIN_INTENT_CHARS && !t.startsWith('/') && !t.startsWith('<') && !isShortcut(t) && !t.startsWith(INTENT_CHECK_HEAD)
73}
74
75// ---- shorts: one-press prompts for the asks that recur ------------------------------------
76// Worded from the history file's most frequent asks (tldr, "wait what", "now what plainly",
77// "what is waiting on me") plus skills that are rarely called by name.
78
79export type Short = { label: string; prompt: string }
80export type ShortGroup = { title: string; shorts: Short[] }
81
82export const SHORTS: ShortGroup[] = [
83 {
84 title: 'UNDERSTAND',
85 shorts: [
86 { label: 'TLDR', prompt: 'Give me a TLDR of where this stands: three sentences at most, plain words, no file paths unless I need to act on one.' },
87 { label: 'Wait, what?', prompt: 'I did not follow your last reply. Explain it again in plain spoken English: short sentences, no jargon or acronyms, one concrete example. End with what it means for me and whether I need to do anything.' },
88 { label: 'Where are we?', prompt: 'Recap this conversation so far: what I asked for, what is done and verified, what is in progress, and what is next. A short list under each, plain words. Say plainly what is unverified.' },
89 { label: 'Waiting on me?', prompt: 'What is waiting on me? List every open question, decision or action you need from me across this whole conversation, including ones in earlier replies I may have missed. If nothing is, say so in one line.' },
90 { label: 'Now what?', prompt: 'So now what, in plain terms? Give the one next step you recommend, why, and anything I have to do.' },
91 ],
92 },
93 {
94 title: 'CHECK',
95 shorts: [
96 { label: 'Prove it', prompt: 'Show me the evidence for your recent claims: for each, the file, command output or result that proves it. Mark anything you have not actually checked as unverified, and run a check now where it is cheap.' },
97 { label: "What's shaky?", prompt: 'What are you least sure about right now? List the assumptions and unverified claims in this session, ranked by how much damage each would do if it is wrong.' },
98 { label: 'Red-team it', prompt: 'Red-team your last answer or plan: what bug in your instrument, code or method would produce exactly this result? Test the most likely one, then tell me whether the conclusion holds.' },
99 { label: 'Second opinion', prompt: 'Get a second opinion on your current recommendation: make the strongest case against it, then tell me whether it still holds and what would change your mind.' },
100 ],
101 },
102 {
103 title: 'DECIDE',
104 shorts: [
105 { label: 'Your call', prompt: 'Give me your recommendation in one paragraph: what you would do and why, the option you rejected and why, and the part you are least sure of.' },
106 { label: 'What could fail?', prompt: 'Premortem: it is six months later and this approach failed badly. Give 3 to 5 concrete ways it failed, each with its mechanism, then the top 2 mitigations.' },
107 { label: 'Plan first', prompt: 'Before doing anything else, explain what you plan to do, step by step, in plain words, and wait for my go.' },
108 ],
109 },
110 {
111 title: 'MOVE',
112 shorts: [
113 { label: 'Keep going', prompt: 'Keep going with the plan. Fix rather than report where it is safe; stop only for something that is genuinely my call.' },
114 { label: 'Delegate', prompt: 'Hand the remaining mechanical work to subagents with complete work orders, and keep this thread for planning and review.' },
115 { label: 'Review diff', prompt: 'Run the code-review skill on the changes made in this session.' },
116 { label: 'Past sessions', prompt: 'Search past sessions for earlier work related to what we are doing now, and tell me what is relevant.' },
117 { label: 'Wrap up', prompt: 'Wrap this session: write down where things stand (done, in progress, next) in the project notes, then give me the big-picture TLDR of what changed and what it unlocks.' },
118 ],
119 },
120]
121
122// Shown on the Now tab as well, because they are the asks typed most often.
123export const QUICK_SHORTS = ['TLDR', 'Wait, what?', 'Waiting on me?']
124
125const SHORT_HEAD = '(Shortcut: '
126export const shortPrompt = (s: Short): string => `${SHORT_HEAD}${s.label}) ${s.prompt}`
127export const isShortcut = (text: string): boolean => text.trim().startsWith(SHORT_HEAD)
128export const findShort = (label: string): Short | undefined => SHORTS.flatMap(g => g.shorts).find(s => s.label === label)
129
130export const onPrompt = (c: Center, text: string, now: number): Center => {
131 const next: Center = { ...c, prompts: c.prompts + 1, lastPrompt: text.slice(0, 600) }
132
133 if (c.intent === '' && isIntentCandidate(text)) {
134 return { ...next, intent: text.trim().slice(0, 600), intentAt: now, intentSource: 'first-prompt', promptsAtIntent: next.prompts, toolsSinceIntent: 0 }
135 }
136
137 return next
138}
139
140export const resetIntent = (c: Center, now: number): Center =>
141 c.lastPrompt === ''
142 ? c
143 : { ...c, intent: c.lastPrompt, intentAt: now, intentSource: 'latest-prompt', promptsAtIntent: c.prompts, toolsSinceIntent: 0, checkedAtPrompt: c.prompts }
144
145export const markChecked = (c: Center): Center => ({ ...c, checkedAtPrompt: c.prompts })
146
147const INTENT_CHECK_HEAD = 'Intent check.'
148export const intentCheckPrompt = (intent: string): string =>
149 `${INTENT_CHECK_HEAD} The original intent this session was:\n\n"${intent}"\n\nIn a short list: what is done and verified, what is not done, and what we did that was off that intent. Name files or results for each item, and say plainly where something is unverified.`
150
151// Prompts and tool calls since the intent was set, and since the last check.
152export const driftNote = (c: Center): string | null => {
153 if (c.intent === '') return null
154 const since = c.prompts - Math.max(c.promptsAtIntent, c.checkedAtPrompt)
155
156 if (since >= DRIFT_PROMPTS) return `${since} prompts since the last intent check`
157 if (c.toolsSinceIntent >= DRIFT_TOOLS && c.checkedAtPrompt < c.promptsAtIntent + 1) return `${c.toolsSinceIntent} tool calls since the intent was set`
158
159 return null
160}
161
162// ---- subagents ---------------------------------------------------------------------------
163
164type Usage = {
165 input_tokens: number
166 output_tokens: number
167 cache_read_input_tokens: number
168 cache_creation_input_tokens: number
169 model?: string
170}
171
172const withAgent = (c: Center, id: string, now: number, fn: (a: AgentStat) => Partial<AgentStat>): Center => {
173 const a = c.agents[id] ?? blankAgent(now)
174
175 return { ...c, agents: { ...c.agents, [id]: { ...a, ...fn(a), lastSeenAt: now } } }
176}
177
178export const onStep = (c: Center, id: string, now: number, model: string, effort: string, messages: number): Center =>
179 withAgent(c, id, now, a => ({ model, effort, messages, steps: a.steps + 1 }))
180
181// One response's context size: everything the request carried, cached or not.
182export const ctxOf = (u: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } | null | undefined): number =>
183 u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
184
185export const onResponse = (c: Center, id: string, now: number, ctx: number): Center =>
186 ctx <= 0 ? c : withAgent(c, id, now, a => ({ ctx, ctxPeak: Math.max(a.ctxPeak, ctx) }))
187
188export const onAgentTool = (c: Center, id: string, now: number, tool: string): Center =>
189 withAgent(c, id, now, a => ({ tools: a.tools + 1, lastTool: tool }))
190
191export const onTurn = (c: Center, id: string, now: number, u: Usage | undefined): Center =>
192 withAgent(c, id, now, a => ({
193 turns: a.turns + 1,
194 model: u?.model || a.model,
195 input: a.input + (u?.input_tokens ?? 0),
196 output: a.output + (u?.output_tokens ?? 0),
197 cacheRead: a.cacheRead + (u?.cache_read_input_tokens ?? 0),
198 cacheWrite: a.cacheWrite + (u?.cache_creation_input_tokens ?? 0),
199 }))
200
201type Listed = { id: string; type: string; description: string; status: string }
202
203// Fold the engine's agent list into our stats: labels, and the end time of anything no longer running.
204export const syncAgents = (c: Center, list: Listed[], now: number): Center => {
205 let agents = c.agents
206 let changed = false
207
208 for (const l of list) {
209 const a = agents[l.id]
210 if (!a) continue
211 const ended = l.status !== 'running' && a.endedAt === null ? a.lastSeenAt : a.endedAt
212 if (a.type !== l.type || a.description !== l.description || a.endedAt !== ended) {
213 agents = { ...agents, [l.id]: { ...a, type: l.type, description: l.description, endedAt: ended } }
214 changed = true
215 }
216 }
217
218 return changed ? { ...c, agents } : c
219}
220
221export const median = (xs: number[]): number => {
222 const s = xs.slice().sort((a, b) => a - b)
223 const m = Math.floor(s.length / 2)
224
225 return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
226}
227
228// "How long to go" is not knowable. What we can say is how long finished agents of the same type took.
229export const typicalMs = (c: Center, type: string): { ms: number; n: number } | null => {
230 const done = Object.values(c.agents)
231 .filter(a => a.type === type && a.endedAt !== null && a.endedAt > a.startedAt)
232 .map(a => (a.endedAt as number) - a.startedAt)
233
234 return done.length >= 2 ? { ms: median(done), n: done.length } : null
235}
236
237export type Stall = 'ok' | 'quiet' | 'stuck'
238
239export const stallLevel = (status: string, a: AgentStat | undefined, now: number): Stall => {
240 if (status !== 'running' || !a) return 'ok'
241 const idle = now - a.lastSeenAt
242
243 return idle > STALL_HARD_MS ? 'stuck' : idle > STALL_MS ? 'quiet' : 'ok'
244}
245
246// ---- shells ------------------------------------------------------------------------------
247
248// `stoppable` is true only when the id came from the engine's own result: a made-up id must not reach TaskStop.
249export const startShell = (c: Center, id: string, command: string, now: number, stoppable = false): Center => ({
250 ...c,
251 shells: [...c.shells, { id, command: command.slice(0, 300), startedAt: now, doneAt: null, stoppable }].slice(-MAX_SHELLS),
252})
253
254export const finishShells = (c: Center, text: string, now: number): Center => {
255 let hit = false
256 const shells = c.shells.map(s => {
257 if (s.doneAt === null && s.id && text.includes(s.id)) {
258 hit = true
259
260 return { ...s, doneAt: now }
261 }
262
263 return s
264 })
265
266 return hit ? { ...c, shells } : c
267}
268
269// ---- files -------------------------------------------------------------------------------
270
271const lines = (s: unknown): number => (typeof s === 'string' && s !== '' ? s.split('\n').length : 0)
272
273type EditInput = {
274 file_path?: unknown
275 old_string?: unknown
276 new_string?: unknown
277 content?: unknown
278 edits?: unknown
279}
280
281export const editDelta = (tool: string, input: EditInput): { path: string; added: number; removed: number } | null => {
282 const path = typeof input.file_path === 'string' ? input.file_path : ''
283 if (!path) return null
284 if (tool === 'Write') return { path, added: lines(input.content), removed: 0 }
285 if (tool === 'Edit') return { path, added: lines(input.new_string), removed: lines(input.old_string) }
286 if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
287 let added = 0
288 let removed = 0
289 for (const e of input.edits as EditInput[]) {
290 added += lines(e.new_string)
291 removed += lines(e.old_string)
292 }
293
294 return { path, added, removed }
295 }
296
297 return null
298}
299
300export const onEdit = (c: Center, path: string, added: number, removed: number, by: string, now: number): Center => {
301 const f: FileStat = c.files[path] ?? { edits: 0, added: 0, removed: 0, by: [], lastAt: now }
302 const files = {
303 ...c.files,
304 [path]: { edits: f.edits + 1, added: f.added + added, removed: f.removed + removed, by: f.by.includes(by) ? f.by : [...f.by, by], lastAt: now },
305 }
306 const keys = Object.keys(files)
307 if (keys.length > MAX_FILES) {
308 const oldest = keys.sort((a, b) => files[a].lastAt - files[b].lastAt)[0]
309 delete files[oldest]
310 }
311
312 return { ...c, files }
313}
314
315export const shortPath = (p: string, home = ''): string => {
316 const q = home && p.startsWith(home) ? `~${p.slice(home.length)}` : p
317 const parts = q.split('/')
318
319 return parts.length > 4 ? `…/${parts.slice(-3).join('/')}` : q
320}
321
322// ---- last reply --------------------------------------------------------------------------
323
324// Sentences in the reply that ask the person something: the thing most easily scrolled past.
325export const questionsIn = (text: string, max = 3): string[] => {
326 const out: string[] = []
327 const withoutCode = text.replace(/```[\s\S]*?```/g, ' ')
328
329 for (const raw of withoutCode.split(/\n+/)) {
330 const line = raw.replace(/^[\s>*#\-\d.)]+/, '').trim()
331 if (line.endsWith('?') && line.length > 12) out.push(line.slice(0, 220))
332 if (out.length >= max) break
333 }
334
335 return out
336}
337
338export const setReply = (c: Center, text: string, now: number, seconds: number): Center => ({ ...c, reply: { text, at: now, seconds } })
339
340// ---- vitals and counters -----------------------------------------------------------------
341
342export const MAX_HIST = 60
343
344// The context history only grows when the number moves, so a quiet session draws a flat line, not a fake one.
345// A subscription reports rate-limit windows and caches for an hour; without them the cache lives five minutes.
346export const setVitals = (c: Center, ctx: number | null, limits: Limit[], cost: number | null = null, now = 0): Center => {
347 const last = c.ctxHist[c.ctxHist.length - 1]
348 const hist = ctx !== null && ctx !== last ? [...c.ctxHist, ctx].slice(-MAX_HIST) : c.ctxHist
349
350 return {
351 ...c,
352 ctx,
353 limits,
354 ctxHist: hist,
355 cost: cost ?? c.cost,
356 sessionAt: c.sessionAt === 0 && now > 0 ? now : c.sessionAt,
357 ttlMs: limits.length > 0 ? 3600_000 : 300_000,
358 }
359}
360
361export const onMainTurn = (c: Center, now: number): Center => ({ ...c, lastTurnAt: now })
362
363export const setMainModel = (c: Center, model: string, effort: string): Center =>
364 c.mainModel === model && c.mainEffort === effort ? c : { ...c, mainModel: model, mainEffort: effort }
365
366export const setCursor = (c: Center, at: number | null): Center => (c.cursorAt === at ? c : { ...c, cursorAt: at })
367
368// Time left before the prompt cache expires. Null until the main thread has answered once.
369export const cacheLeft = (c: Center, now: number): { ms: number; warm: boolean } | null => {
370 if (c.lastTurnAt === 0) return null
371 const ms = c.ttlMs - (now - c.lastTurnAt)
372
373 return { ms: Math.max(0, ms), warm: ms > 0 }
374}
375
376// Dollars per hour, once the session is old enough for the rate to mean something.
377export const burnPerHour = (c: Center, now: number): number | null => {
378 if (c.cost === null || c.sessionAt === 0 || now - c.sessionAt < 10 * 60_000) return null
379
380 return c.cost / ((now - c.sessionAt) / 3600_000)
381}
382
383export const usd = (n: number): string => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
384
385// Edits made after the continuity cursor was last written: the cursor does not describe them.
386export const cursorGap = (c: Center): { behindMs: number; files: number } | null => {
387 if (c.cursorAt === null) return null
388 const newer = Object.values(c.files).filter(f => f.lastAt > (c.cursorAt as number))
389 if (newer.length === 0) return null
390
391 return { behindMs: Math.max(...newer.map(f => f.lastAt)) - (c.cursorAt as number), files: newer.length }
392}
393
394const DISPATCHERS = new Set(['Agent', 'Workflow', 'Monitor'])
395const ACTION_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
396
397export const onMainTool = (c: Center, tool: string): Center => {
398 if (DISPATCHERS.has(tool)) return { ...c, inline: 0, toolsSinceIntent: c.toolsSinceIntent + 1 }
399
400 return { ...c, inline: ACTION_TOOLS.has(tool) ? c.inline + 1 : c.inline, toolsSinceIntent: c.toolsSinceIntent + 1 }
401}
402
403export const warnOnce = (c: Center, key: string): { c: Center; fresh: boolean } =>
404 c.warned.includes(key) ? { c, fresh: false } : { c: { ...c, warned: [...c.warned, key].slice(-60) }, fresh: true }
405
406// ---- waiting on the person, and failed calls ----------------------------------------------
407
408// A question or a plan the model put to the person is a tool call with no answer yet.
409export const waitKind = (tool: string): Waiting['kind'] | null =>
410 tool === 'AskUserQuestion' ? 'question' : tool === 'ExitPlanMode' ? 'plan' : null
411
412export const setWaiting = (c: Center, kind: Waiting['kind'], now: number): Center => ({ ...c, waiting: { kind, at: now } })
413
414export const clearWaiting = (c: Center): Center => (c.waiting === null ? c : { ...c, waiting: null })
415
416export const onFailure = (c: Center, tool: string, text: string, isSub: boolean, now: number): Center => ({
417 ...c,
418 failures: {
419 main: c.failures.main + (isSub ? 0 : 1),
420 sub: c.failures.sub + (isSub ? 1 : 0),
421 last: `${tool}: ${text.replace(/\s+/g, ' ').slice(0, 140)}`,
422 lastAt: now,
423 },
424})
425
426// What the model reads at a drift threshold, so it re-anchors as well as the person.
427export const driftContextNote = (c: Center): string =>
428 `command-center: ${driftNote(c) ?? 'long session'}. The intent for this session was: "${c.intent}". If the current request has moved away from it, say so in one line and ask whether to continue or return to the intent.`
429
430// ---- limit pace --------------------------------------------------------------------------
431
432const WINDOW_MS: Record<string, number> = { five_hour: 5 * 3600_000, seven_day: 7 * 24 * 3600_000 }
433
434// Average use since the window opened, projected forward: cycle-averaged, as a slope over a few
435// recent samples reads a burst as the whole trend. Null until the window is old enough to say.
436export const paceNote = (l: Limit, now: number): string | null => {
437 const win = WINDOW_MS[l.kind]
438 if (!win || !l.resetsAt) return null
439 const resets = Date.parse(l.resetsAt)
440 if (Number.isNaN(resets)) return null
441 if (l.pct >= 100) return 'at the limit'
442 const elapsed = win - (resets - now)
443 if (elapsed < 10 * 60_000 || l.pct <= 0) return null
444 const toFull = ((100 - l.pct) / l.pct) * elapsed
445
446 return now + toFull >= resets ? 'resets first' : `full in ~${age(toFull)}`
447}
448
449// ---- formatting --------------------------------------------------------------------------
450
451export const tok = (n: number): string => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n))
452
453export const age = (ms: number): string => {
454 const s = Math.max(0, Math.round(ms / 1000))
455
456 return s < 90 ? `${s}s` : s < 5400 ? `${Math.round(s / 60)}m` : `${(s / 3600).toFixed(1)}h`
457}
458
459export const shortModel = (id: string): string => id.replace(/^claude-/, '').replace(/-\d{8}$/, '') || '?'
460
461export const cachePct = (a: AgentStat): number | null => {
462 const total = a.input + a.cacheRead + a.cacheWrite
463
464 return total > 0 ? Math.round((a.cacheRead / total) * 100) : null
465}
466
467export const clipLines = (text: string, max: number, width = 110): string[] => {
468 const out: string[] = []
469 for (const raw of text.split('\n')) {
470 const l = raw.trimEnd()
471 if (l === '' && (out.length === 0 || out[out.length - 1] === '')) continue
472 out.push(l.length > width ? `${l.slice(0, width - 1)}…` : l)
473 if (out.length >= max) break
474 }
475
476 return out
477}
478
479// ---- narrow-pane layout ------------------------------------------------------------------
480
481export const setView = (c: Center, view: View): Center => (c.view === view ? c : { ...c, view })
482
483export const trunc = (s: string, w: number): string => (s.length > w ? `${s.slice(0, Math.max(1, w - 1))}…` : s)
484
485// A fixed-width meter: filled cells for the share used.
486export const bar = (pct: number, w: number): string => {
487 const f = Math.max(0, Math.min(w, Math.round((pct / 100) * w)))
488
489 return '█'.repeat(f) + '░'.repeat(w - f)
490}
491
492export const meterTone = (pct: number): 'green' | 'yellow' | 'red' => (pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green')
493
494export const splitPath = (p: string, home = ''): { dir: string; base: string } => {
495 const q = home && p.startsWith(home) ? `~${p.slice(home.length)}` : p
496 const i = q.lastIndexOf('/')
497
498 return i < 0 ? { dir: '', base: q } : { dir: q.slice(0, i), base: q.slice(i + 1) }
499}
500
501// Keeps the tail of a directory, the part that tells two files of the same name apart.
502export const tailTrunc = (s: string, w: number): string => (s.length > w ? `…${s.slice(s.length - Math.max(1, w - 1))}` : s)
503
504export type Alert = { tone: 'red' | 'yellow'; text: string; view: View }
505
506// Everything that wants the person, most urgent first. The pane shows these before anything else.
507export const alerts = (c: Center, list: Listed[], now: number): Alert[] => {
508 const out: Alert[] = []
509
510 if (c.waiting) out.push({ tone: 'red', text: `${c.waiting.kind === 'plan' ? 'Plan needs approval' : 'Question open'} ${age(now - c.waiting.at)}`, view: 'now' })
511
512 const stuck = list.filter(a => stallLevel(a.status, c.agents[a.id], now) === 'stuck').length
513 const quiet = list.filter(a => stallLevel(a.status, c.agents[a.id], now) === 'quiet').length
514 if (stuck > 0) out.push({ tone: 'red', text: `${stuck} agent${stuck > 1 ? 's' : ''} stuck`, view: 'jobs' })
515 else if (quiet > 0) out.push({ tone: 'yellow', text: `${quiet} agent${quiet > 1 ? 's' : ''} quiet`, view: 'jobs' })
516
517 const failed = c.failures.main + c.failures.sub
518 if (failed > 0) out.push({ tone: 'yellow', text: `${failed} failed call${failed > 1 ? 's' : ''}: ${c.failures.last.slice(0, 60)}`, view: 'now' })
519
520 const drift = driftNote(c)
521 if (drift) out.push({ tone: 'yellow', text: `Drift: ${drift}`, view: 'now' })
522
523 if (c.reply && questionsIn(c.reply.text).length > 0) out.push({ tone: 'yellow', text: 'Last reply asks you something', view: 'reply' })
524
525 // Rule: compacting is far cheaper before an idle break than after the cache has gone.
526 const cache = cacheLeft(c, now)
527 if (cache && cache.warm && cache.ms < 5 * 60_000 && (c.ctx ?? 0) > 100_000 && list.every(a => a.status !== 'running'))
528 out.push({ tone: 'yellow', text: `Cache goes cold in ${age(cache.ms)}: compact or wrap now`, view: 'now' })
529
530 const gap = cursorGap(c)
531 if (gap && gap.behindMs > 30 * 60_000) out.push({ tone: 'yellow', text: `Cursor ${age(gap.behindMs)} behind ${gap.files} edited file${gap.files > 1 ? 's' : ''}`, view: 'now' })
532
533 return out
534}
535
536// ---- palette and raster cells ------------------------------------------------------------
537
538// Run Data Run: navy ground, cream text, one orange accent, cyan for data only. Teal and rose are
539// the brand's good/bad lifted for a dark terminal; gold is the warning, so orange stays the accent.
540export const PAL = {
541 accent: '#F97316',
542 data: '#00C0E0',
543 good: '#3FA9BD',
544 warn: '#FBBF24',
545 bad: '#E0607A',
546 cream: '#F5F0E8',
547 mute: '#9FB6CF',
548 line: '#1D3A66',
549} as const
550
551export const toneHex = (t: 'green' | 'yellow' | 'red'): string => (t === 'red' ? PAL.bad : t === 'yellow' ? PAL.warn : PAL.good)
552
553export const DEFAULT_COLOR = 0x01000000
554const TRACK = 0x13315f
555const NAVY = 0x0e2856
556
557export const mix = (a: number, b: number, t: number): number => {
558 const k = Math.max(0, Math.min(1, t))
559 const ch = (sh: number) => Math.round(((a >> sh) & 255) * (1 - k) + ((b >> sh) & 255) * k)
560
561 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
562}
563
564const GOOD = 0x3fa9bd
565const WARN = 0xfbbf24
566const BAD = 0xe0607a
567const ORANGE = 0xf97316
568const CREAM = 0xf5f0e8
569
570// Teal through gold to rose: the end of a full meter reads as danger before the number does.
571export const heat = (t: number): number => (t < 0.6 ? mix(GOOD, WARN, t / 0.6) : mix(WARN, BAD, (t - 0.6) / 0.4))
572
573const LEFT_EIGHTHS = [' ', '\u258F', '\u258E', '\u258D', '\u258C', '\u258B', '\u258A', '\u2589']
574const cell = (cells: Uint32Array, i: number, cp: string, fg: number, bg: number) => {
575 cells[i * 3] = cp.codePointAt(0) as number
576 cells[i * 3 + 1] = fg
577 cells[i * 3 + 2] = bg
578}
579
580// One row, `w` cells: a heat-gradient fill to `pct`, eighth-cell precision, on a dark track.
581export const meterCells = (pct: number, w: number): Uint32Array => {
582 const out = new Uint32Array(w * 3)
583 const eighths = Math.round((Math.max(0, Math.min(100, pct)) / 100) * w * 8)
584
585 for (let i = 0; i < w; i++) {
586 const left = eighths - i * 8
587 const fg = heat(i / Math.max(1, w - 1))
588 if (left >= 8) cell(out, i, '\u2588', fg, TRACK)
589 else if (left > 0) cell(out, i, LEFT_EIGHTHS[left] as string, fg, TRACK)
590 else cell(out, i, ' ', DEFAULT_COLOR, TRACK)
591 }
592
593 return out
594}
595
596const SPARK = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588']
597
598// The last `w` readings, scaled to `ceil` (or their own max), each bar tinted by how full it is.
599export const sparkCells = (vals: number[], w: number, ceil?: number): Uint32Array => {
600 const out = new Uint32Array(w * 3)
601 const tail = vals.slice(-w)
602 const top = Math.max(1, ceil ?? Math.max(...tail, 1))
603 const pad = w - tail.length
604
605 for (let i = 0; i < w; i++) {
606 const v = i < pad ? null : (tail[i - pad] as number)
607 if (v === null) cell(out, i, '\u2581', TRACK, DEFAULT_COLOR)
608 else {
609 const f = Math.min(1, v / top)
610 cell(out, i, SPARK[Math.min(7, Math.floor(f * 8))] as string, heat(f), DEFAULT_COLOR)
611 }
612 }
613
614 return out
615}
616
617// A one-row title bar: cream lettering on a navy sweep, the first glyph the one orange focal.
618export const bannerCells = (text: string, w: number): Uint32Array => {
619 const out = new Uint32Array(w * 3)
620 const label = ` ${text} `
621
622 for (let i = 0; i < w; i++) {
623 const bg = mix(0x0a1f44, NAVY, i / Math.max(1, w - 1))
624 const ch = i < label.length ? (label[i] as string) : ' '
625 cell(out, i, ch, i === 1 ? ORANGE : CREAM, bg)
626 }
627
628 return out
629}
630
631// A hairline that fades from orange into the navy line colour.
632export const fadeCells = (w: number): Uint32Array => {
633 const out = new Uint32Array(w * 3)
634 for (let i = 0; i < w; i++) cell(out, i, '\u2594', mix(ORANGE, 0x1d3a66, Math.min(1, (i / Math.max(1, w - 1)) * 1.6)), DEFAULT_COLOR)
635
636 return out
637}
638
639// A drain bar for the cache clock: full when freshly cached, empty when cold.
640export const drainCells = (frac: number, w: number): Uint32Array => {
641 const out = meterCells(0, w)
642 const eighths = Math.round(Math.max(0, Math.min(1, frac)) * w * 8)
643 for (let i = 0; i < w; i++) {
644 const left = eighths - i * 8
645 const fg = frac < 0.1 ? BAD : frac < 0.3 ? WARN : GOOD
646 if (left >= 8) cell(out, i, '\u2588', fg, TRACK)
647 else if (left > 0) cell(out, i, LEFT_EIGHTHS[left] as string, fg, TRACK)
648 }
649
650 return out
651}
652
653export const spinner = (now: number): string => ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'][Math.floor(now / 120) % 10] as string
654types/index.d.ts 72 lines1export type AgentStat = {
2 model: string
3 effort: string
4 steps: number
5 turns: number
6 messages: number
7 input: number
8 output: number
9 cacheRead: number
10 cacheWrite: number
11 tools: number
12 lastTool: string
13 startedAt: number
14 lastSeenAt: number
15 ctx: number
16 ctxPeak: number
17 endedAt: number | null
18 type: string
19 description: string
20}
21
22export type ShellStat = { id: string; command: string; startedAt: number; doneAt: number | null; stoppable: boolean }
23
24export type FileStat = { edits: number; added: number; removed: number; by: string[]; lastAt: number }
25
26export type Reply = { text: string; at: number; seconds: number }
27
28export type Limit = { kind: string; pct: number; resetsAt?: string }
29
30export type Waiting = { kind: 'question' | 'plan'; at: number }
31
32export type Failures = { main: number; sub: number; last: string; lastAt: number }
33
34export type View = 'now' | 'jobs' | 'files' | 'reply' | 'shorts'
35
36export type Center = {
37 view: View
38 ctxHist: number[]
39 cost: number | null
40 sessionAt: number
41 lastTurnAt: number
42 ttlMs: number
43 cursorAt: number | null
44 mainModel: string
45 mainEffort: string
46 intent: string
47 intentAt: number
48 intentSource: 'first-prompt' | 'latest-prompt' | ''
49 lastPrompt: string
50 prompts: number
51 promptsAtIntent: number
52 toolsSinceIntent: number
53 checkedAtPrompt: number
54 agents: Record<string, AgentStat>
55 shells: ShellStat[]
56 files: Record<string, FileStat>
57 reply: Reply | null
58 ctx: number | null
59 compactAt: number | null
60 limits: Limit[]
61 inline: number
62 warned: string[]
63 waiting: Waiting | null
64 failures: Failures
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 'command-center': { center: Center }
70 }
71}
72