SLOPSHOPPER

command-center

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

newpaneguardcommandtoastprompt
★ 7v0.2.0MITupdated 2026-10-06BioInfo/claudelicious/mods/command-center
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · command-center
│ ┃ Command center ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ COMMAND CENTER 7 inline │ ┃ ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ ⏺ Read(src/auth.ts) │ ┃ ctx 97k ⎿ Read 6 lines │ ┃ $0.42 ⏺ Update(src/auth.ts) │ ┃ cache ██████████████████████████████ 60m ⎿ Added 2 lines, removed 1 line │ ┃ 5h ██████████▌ 31 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭─────────────────────────────────────────── │ ┃ │ NEEDS YOU ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ▸ 1 failed call: Bash: src/auth.test.ts: ✓ │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ Now ] [ Jobs ] [ Files 3 ] [ Reply ] [ Sho › /command-center │ ┃ ⎿ command-center: Command center opened. │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ INTENT │ │ ┃ │ fix the failing auth test and add an │ │ ┃ │ audit log ca… │ │ ┃ │ 0s ago · 0 prompts · 9 tools since │ │ ┃ │ │ │ ┃ │ [ Check intent ] [ Use latest prompt ] │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ [ TLDR ] [ Wait, what? ] [ Waiting on me? ] │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Command center
◆ COMMAND CENTER 7 inline ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ ctx 97k $0.42 cache ██████████████████████████████ 60m 5h ██████████▌ 31% ╭──────────────────────────────────────────────────────────╮ │ NEEDS YOU │ │ ▸ 1 failed call: Bash: src/auth.test.ts: ✓ refre… │ ╰──────────────────────────────────────────────────────────╯ [ Now ] [ Jobs ] [ Files 3 ] [ Reply ] [ Shorts ] ╭──────────────────────────────────────────────────────────╮ │ INTENT │ │ fix the failing auth test and add an audit log ca… │ │ 0s ago · 0 prompts · 9 tools since │ │ │ │ [ Check intent ] [ Use latest prompt ] │ ╰──────────────────────────────────────────────────────────╯ [ TLDR ] [ Wait, what? ] [ Waiting on me? ] more → ╭──────────────────────────────────────────────────────────╮ │ RUNNING 0 agents · 0 shells │ │ Nothing running. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ FILES 3 · +16 −1 │ │ +2 −1 auth.ts 0s │ │ +6 −0 audit.ts 0s │ │ +8 −0 cache.ts 0s │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ LAST REPLY 0s ago │ │ Done. I made `refresh` reject expired claims, add… │ │ read all → │ ╰──────────────────────────────────────────────────────────╯
README

command-center

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.

Watch the two-minute walkthrough

Two-minute walkthrough, recorded from a real session: youtu.be/rY4fyCVfD5c

The command center pane

The intent drift check

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:

  • It shows a toast asking you to open the command center and press Check intent. That button sends a prompt asking what is done, what is not, and what drifted off the intent.
  • It adds a note to the model's context for that prompt. The note restates the intent and asks the model to say in one line if the current request has moved away from it.

The thresholds are DRIFT_PROMPTS and DRIFT_TOOLS in hooks/center.ts.

Install

/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:

  • A Claude Code build that supports mods (plugins with function hooks, loaded through hooks/hooks.json modules).
  • A wide terminal. The engine decides where to seat the pane; a side pane needs about 144 columns or more. The mod asks for a 50-column pane, and a width you drag wins.

What it reads and writes

Read this before you install any mod. It runs inside your Claude Code session.

It reads:

  • Session events from Claude Code: your prompts, tool calls (tool name and input, for example the path and text of an Edit or the command of a background Bash), tool results (error text), turn steps and completions (model, effort, token usage, the final reply text), task notifications, and session measurements (context tokens, cost, rate-limit percentages and reset times).
  • The list of running subagents, every 15 seconds.
  • Your settings, once at session start, for autoCompactWindow.
  • The HOME environment variable, only to shorten paths to ~/... in the pane.
  • File metadata (not contents) of .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:

  • No files. It makes no network calls.
  • Its own state through the mod runtime's atom store (key command-center/center).

It acts only when you press a button:

  • Stop calls the TaskStop tool on that agent or shell, with a consent note saying you pressed Stop.
  • Check intent and the Shorts submit a prompt as you.
  • The drift check adds the context note described above. This is the one thing it sends to the model without a click.

Tests

claude plugin test mods/command-center

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 596 lines
1import { 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}
596
hooks/center.ts 654 lines
1import 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
654
types/index.d.ts 72 lines
1export 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