SLOPSHOPPER

live-status

A live monitor for Claude Code: model, effort, context, 5h/7d usage limits, task progress and a subagents panel, in one responsive card.

newpanebandguardcommandtoast
v0.4.1MITupdated 2026-10-10fanoisme/claude-mods/plugins/live-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · live-status
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ 0/0 running · 0 tokens │ ┃ Collapse ⏺ Read(src/auth.ts) │ ┃ No subagents yet. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /monitor │ ⎿ live-status: Monitor off. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
0/0 running · 0 tokens Collapse No subagents yet.
README

live-status

Part of claude-mods, a collection of Claude Code mods.

A live monitor for Claude Code, drawn above the prompt. One responsive card shows what the session is doing right now: model and effort, context, 5-hour and 7-day usage limits, cost, the task in progress, and your subagents. A matching panel lists every subagent with its own context bar.

The monitor through a whole turn

Idle, light and dark follow your system theme:

Monitor, idle

A narrow window stacks the card and lists subagents inline, so nothing is lost when there is no room for the panel:

Monitor in a narrow window

The agents panel (/monitor, or /agents-live):

Agents panel summary A working subagent A finished subagent

Install

In a Claude Code terminal session:

/plugin install live-status --marketplace fanoisme/claude-mods

Answer y to add the marketplace, then pick the user scope. The mod is active at once and in every session you start afterwards. If you use the Claude desktop app, install it once from a terminal at the user scope: the Code tab loads it too (the /plugin command itself is a terminal command).

Prefer to try it first without installing? Clone the repo and run:

git clone https://github.com/fanoisme/claude-mods
claude --plugin-dir ./claude-mods/plugins/live-status

To remove it: /plugin uninstall live-status.

What you get

PartShowsUpdates
Model chipModel name and effort (Opus 5.5 · high)Every model request
ContextFill, tokens used of the window, HIGH from 60%, CRITICAL from 85%As the engine reports it, and every 5 s mid-turn
5H / 7D limitsUsage of each window and when it resetsAs the engine reports it; countdowns refresh every 15 s when idle
CostSession cost so farAs the engine reports it
StatusWORKING / IDLE, elapsed time, steps, tool calls, running agentsEvery second while a turn runs
Task barProgress of the task being worked on, 3/5 · 60%, segmented per stepWhen the task is reported (see below)
AgentsOne row per subagent, inline when there is no room for the panelLive
Agents panelSummary tiles plus a card per subagent: status, type, model, effort, time, tokens, steps, context barLive
SessionsEvery Claude Code session on the machine, with name, folder, where it runs (desktop / cli), WORKING or IDLE and when it last changed; this one is marked THIS. The band shows 3 sessions · 2 workingRe-read every 3 s, redrawn only when something changed

Level is never shown by color alone: the words HIGH and CRITICAL sit next to the bar. A toast appears once when context or a limit crosses 85%, and again at 95%.

The 5H and 7D limits are reported only for accounts whose plan has them; without a reading the card says so instead of guessing. Subagents show tokens, time and context, not dollars: the engine reports tokens, not money.

Commands

CommandDoes
/monitorShows or hides everything: the band above the prompt and the agents panel together
/monitor on · /monitor offForces it on or off
/agents-liveToggles only the agents panel

The agents panel opens by itself at session start, and the band is on from the first moment.

Watching other sessions

The side panel lists every live session on your machine, so you can keep an eye on a long run in another window:

Sessions panel

Each card shows the task that session is working on, as 3/6 · 50% with a segmented bar. Progress comes from the todo list Claude keeps (see Task progress): every session that has live-status installed publishes its own task to ~/.claude/live-status/progress/<session>.json, and the others read it. A session that reports nothing shows an empty bar and the note "no task progress reported", never a made-up percentage.

It also reads the small registry entries Claude Code keeps in ~/.claude/sessions/<pid>.json (name, folder, status, last update). The only thing it writes is its own progress file (task title, step counts, current step). No transcripts, no keys, no network. Entries untouched for 24 hours are treated as dead and hidden. Per-session model and context are not in that registry, so other sessions show status and activity only; your own session shows the full card.

Task progress

Nothing can know in advance how big a task is, so the task bar reads the todo list Claude already keeps (the built-in TodoWrite tool): 3/5 · 60% is "3 of the 5 todos are completed", and the step in progress is shown next to it. There is nothing to set up and no CLAUDE.md snippet to add. If Claude keeps no todo list, the bar falls back to subagents finished out of subagents started, and with neither the row stays hidden and the card stays compact.

No tokens

The mod never talks to the model. It adds no tool, no system-prompt text and no instructions, and it makes no model calls: it only watches events the engine already raises and draws what it sees. Concretely:

  • The todo list is observed: the TodoWrite call passes through unchanged, so nothing extra is sent or returned.
  • There is no task-style tool whose definition would ride along on every request.
  • Slash commands (/monitor, /agents-live) answer with a few words, shown once in your transcript.
  • Everything else (the band, the panel, toasts, the status line, the progress files) is drawn locally and costs no tokens.

Responsive by design

WindowWhat changes
WideOne row of model, three gauges and the task bar; the agents panel docks beside the transcript
Under 520 pxThe card stacks into two rows of gauges
No room for the panel (under 144 columns, or the panel is closed)Up to three subagents are listed inline in the band, two when very narrow
TerminalThe same figures as colored text rows, no images

Motion is limited to a slow sheen on the context and task bars and a travelling highlight on the bottom rail, only while a turn is running. It stops under prefers-reduced-motion, and light and dark follow the system theme.

Light on your machine

The mod is built to stay out of the way:

  • Event-driven first. Context, limits and cost arrive from the engine when they change; nothing polls for them while idle beyond one read every 15 s.
  • Adaptive polling. Other sessions are checked every 5 s while something is moving (a turn here, or a working session elsewhere) and every 15 s otherwise, and only files whose modification time changed are read again.
  • A slow clock for the panel. The side panel redraws every 5 to 15 s, not every second; only the band above the prompt ticks each second, and only while a turn runs.
  • Small and bounded. At most 40 subagent runs are kept, sessions idle for a day are dropped, and the panel has no animation.
  • Motion budget. While a turn runs the band draws a single light sweep along its bottom edge; it stops under prefers-reduced-motion.

Customize

All colors and sizes are plain constants at the top of hooks/register.tsx: the CSS string for the card and lozenges, the <linearGradient> stops for the bars, and tone() for the terminal palette. Edit, then reinstall or reload.

Develop

git clone https://github.com/fanoisme/claude-mods
cd claude-mods
claude --plugin-dir ./plugins/live-status       # loads the mod from the folder
claude plugin validate ./plugins/live-status    # manifest + hooks check
npx -p typescript tsc -p ./plugins/live-status --noEmit   # after the mod has loaded once

While a mod loads, Claude Code writes its API types into .claude-plugin/types/, which is what tsc reads (and why that folder is git-ignored). The mod keeps its values in $.state, declared in types/index.d.ts.

Compatibility

Written against the Claude Code mod API of build 2.1.293 (session.measure, turn.step, agent.spawn, ui.render). The desktop app draws the card as SVG; the terminal draws text. If a later build renames an event, claude plugin validate will say which one.

License

MIT, see the repository LICENSE.

Source 2 files
hooks/register.tsx 1220 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Crew, Limit, Panel, Run, SessionRow, Task, Turn, Usage } from '../types'
5
6const NO_USAGE: Usage = { ctxPercent: null, ctxTokens: null, ctxWindow: 0, limits: [], costUsd: null }
7const NO_TURN: Turn = { isRunning: false, startedAt: 0, steps: 0, tools: 0, lastSeconds: 0, agents: 0 }
8
9const usage = atom({ plugin: 'live-status', key: 'usage' } as const, NO_USAGE)
10const model = atom({ plugin: 'live-status', key: 'model' } as const, '')
11const effort = atom({ plugin: 'live-status', key: 'effort' } as const, '')
12const turn = atom({ plugin: 'live-status', key: 'turn' } as const, NO_TURN)
13const task = atom({ plugin: 'live-status', key: 'task' } as const, null)
14const crew = atom({ plugin: 'live-status', key: 'crew' } as const, { spawned: 0, done: 0 })
15const runs = atom({ plugin: 'live-status', key: 'runs' } as const, [])
16const sessions = atom({ plugin: 'live-status', key: 'sessions' } as const, [])
17const panel = atom({ plugin: 'live-status', key: 'panel' } as const, { isCompact: false, isDoneCollapsed: true })
18const now = atom({ plugin: 'live-status', key: 'now' } as const, 0)
19const slow = atom({ plugin: 'live-status', key: 'slow' } as const, 0)
20
21const PANE = 'live-agents'
22
23// What the card shows for the work in hand: the reported task, else the crew.
24type TaskView = { title: string; done: number; total: number; current: string; isFinished: boolean }
25
26const taskView = (t: Task | null, c: Crew): TaskView | null => {
27  if (t && t.total > 0) return { title: t.title, done: t.done, total: t.total, current: t.current, isFinished: t.isFinished }
28  if (c.spawned > 0) return { title: 'Subagents', done: c.done, total: c.spawned, current: '', isFinished: c.done >= c.spawned }
29  return null
30}
31const isHidden = atom({ plugin: 'live-status', key: 'isHidden' } as const, false)
32const warned = atom({ plugin: 'live-status', key: 'warned' } as const, '')
33
34// Terminal palette (raw colors, the terminal has no CSS).
35const GREEN = '#5fbf8f'
36const AMBER = '#FFBC25'
37const RED = '#E5484D'
38const BLUE = '#8f8cf4'
39
40const tone = (pct: number): string => (pct >= 85 ? RED : pct >= 60 ? AMBER : GREEN)
41
42const bar = (pct: number, width: number): string => {
43  const filled = Math.max(0, Math.min(width, Math.round((width * pct) / 100)))
44  return '█'.repeat(filled) + '░'.repeat(width - filled)
45}
46
47const modelName = (id: string): string => {
48  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
49  const [, family = '', major = '', minor] = m ?? []
50  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '...'
51  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
52}
53
54const fmtTokens = (n: number): string =>
55  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
56
57const fmtTime = (ms: number): string => {
58  const s = Math.max(0, Math.round(ms / 1000))
59  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
60}
61
62const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
63
64const fmtReset = (iso: string | undefined, at: number): string => {
65  if (!iso) return ''
66  const ms = Date.parse(iso) - at
67  if (!Number.isFinite(ms) || ms <= 0) return ''
68  const m = Math.round(ms / 60000)
69  if (m < 60) return `${m}m`
70  const h = Math.floor(m / 60)
71  return h < 24 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${Math.floor(h / 24)}d${h % 24}h`
72}
73
74const LIMIT_LABEL: Record<string, string> = { five_hour: '5H', seven_day: '7D', spend_limit: 'SPEND' }
75
76type RawUsage = {
77  context: { tokens?: number; window: number; percent?: number }
78  rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
79  cost?: { usd: number }
80}
81
82const fromUsage = (u: RawUsage): Usage => ({
83  ctxPercent: u.context.percent ?? null,
84  ctxTokens: u.context.tokens ?? null,
85  ctxWindow: u.context.window,
86  limits: u.rateLimits.map((r): Limit => ({ kind: r.kind, pct: r.percentUsed, resetsAt: r.resetsAt })),
87  costUsd: u.cost?.usd ?? null,
88})
89
90async function refresh($: EngineInterface): Promise<void> {
91  try {
92    const u = await $.session.usage()
93    await update($, usage, () => fromUsage(u))
94  } catch {
95    // Usage is not available yet; the next measure fills it in.
96  }
97}
98
99// Before the first model step, the effort is whatever the person configured.
100async function configuredEffort($: EngineInterface): Promise<string> {
101  try {
102    const fromEnv = await $.env.get('CLAUDE_EFFORT')
103    if (fromEnv) return fromEnv
104    const s = (await $.settings.read()) as Record<string, unknown>
105    const level = s.effortLevel ?? s.effort
106    return typeof level === 'string' ? level : ''
107  } catch {
108    return ''
109  }
110}
111
112// ---------------------------------------------------------------------------
113// Desktop drawing: one SVG card. Only looping CSS motion, phase-locked to the
114// wall clock through a negative animation-delay, so the once-a-second redraw
115// of a running turn continues the motion instead of restarting it.
116
117// Design tokens: Inter, soft grey surface, charcoal text, a yellow→orange rail,
118// info / discovery / neutral lozenges, 12px card radius. Change colors in CSS below.
119const FONT = "Inter,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif"
120const EASE = 'cubic-bezier(.65,0,.35,1)' // ease-in-out, for on-screen motion
121
122const xml = (s: string): string =>
123  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
124
125const charEm = (ch: string): number =>
126  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MW]/.test(ch) ? 0.7 : 0.55
127
128const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
129
130const fitText = (s: string, size: number, maxW: number): string => {
131  if (textWidth(s, size) <= maxW) return s
132  let out = ''
133  for (const ch of s) {
134    if (textWidth(out + ch + '…', size) > maxW) break
135    out += ch
136  }
137  return out + '…'
138}
139
140// Motion budget: the band keeps one light sweep (the bottom rail) while a turn runs;
141// the shimmer on the bars costs more for little, so it is off.
142const BAND_SHEEN = false
143
144type AgentRow = { name: string; status: 'running' | 'done' | 'failed'; ctx: number }
145
146type Gauge = { label: string; pct: number | null; caption: string; sheen: boolean }
147
148const gradFor = (pct: number | null): string => (pct === null ? 'gNone' : pct >= 85 ? 'gBad' : pct >= 60 ? 'gWarn' : 'gOk')
149
150const CSS = `
151.card{fill:#F8F9FB;stroke:#D0D6DD}.nm{fill:#333333}.mu{fill:#626B79}.pc{fill:#232933}
152.trk{fill:#FFF4D6}.tick{fill:#626B79}.chip{fill:#F1EBFD}.chipt{fill:#5B3BB0}.chipx{fill:none;stroke:#7D8695}.rail{fill:#EDF0F2}
153.dot-ok{fill:#1F7A4D}.dot-run{fill:#1D5FBF}
154.lz-run{fill:#E8F0FC}.lz-runt{fill:#1D5FBF}.lz-idle{fill:#EDF0F2}.lz-idlet{fill:#333333}
155.lv-hi{fill:#B54708}.lv-bad{fill:#C62F2F}.seg{fill:#F8F9FB}
156@media (prefers-color-scheme: dark){
157.card{fill:#282828;stroke:#3F3F3F}.nm{fill:#FFFFFF}.mu{fill:#C4C9D0}.pc{fill:#FFFFFF}
158.trk{fill:#3A3A38}.tick{fill:#9AA3AF}.chip{fill:#3A2F63}.chipt{fill:#D9CCFA}.chipx{stroke:#626B79}.rail{fill:#3A3A3A}
159.dot-ok{fill:#4FD08F}.dot-run{fill:#6DA7EC}
160.lz-run{fill:#1F3558}.lz-runt{fill:#B7D3F6}.lz-idle{fill:#3A3A3A}.lz-idlet{fill:#FFFFFF}
161.lv-hi{fill:#EB6834}.lv-bad{fill:#FF7A78}.seg{fill:#282828}}
162`
163
164function cardSvg(args: {
165  W: number
166  stack: boolean
167  name: string
168  fx: string
169  cost: string
170  state: string
171  detail: string
172  isRunning: boolean
173  gauges: Gauge[]
174  task: TaskView | null
175  agents: { rows: AgentRow[]; working: number; more: number }
176  sessions?: { total: number; working: number; attention?: number }
177  nowMs: number
178}): { svg: string; H: number } {
179  const { W, stack, name, fx, cost, state, detail, isRunning, gauges, task: tv, agents: ag, sessions: ss, nowMs } = args
180  const PAD = 14
181  const baseH = stack ? 118 : 76
182  const taskH = tv ? 32 : 0
183  const agentH = ag.rows.length ? 24 + ag.rows.length * 22 : 0
184  const H = baseH + taskH + agentH
185  const leftW = stack ? W - PAD * 2 : Math.max(232, Math.min(330, Math.round(W * 0.38)))
186  const gx0 = stack ? PAD : PAD + leftW + 22
187  const gy0 = stack ? 58 : 17
188  const gAreaW = stack ? W - PAD * 2 : W - gx0 - PAD
189  const gGap = 18
190  const gw = Math.floor((gAreaW - gGap * (gauges.length - 1)) / Math.max(1, gauges.length))
191
192  const phase = (period: number): string => `-${((nowMs % (period * 1000)) / 1000).toFixed(2)}s`
193
194  // Left block: orb, model, effort chip, cost, status line.
195  const nameFit = fitText(name, 13, leftW - 120)
196  const nameW = textWidth(nameFit, 13) * 1.04
197  const chipText = (fx || 'effort ?').toUpperCase()
198  const chipW = Math.round(textWidth(chipText, 9.5) + 16 + chipText.length * 0.6)
199  const chipX = PAD + 20 + Math.round(nameW) + 8
200  const known = fx !== ''
201  const costX = PAD + leftW
202  const sessText = ss && ss.total > 1 ? `${ss.total} sessions · ${ss.working} working${ss.attention ? ` · ${ss.attention} need you` : ''}` : ''
203  const stateText = state.toUpperCase()
204  const lzW = Math.round(textWidth(stateText, 9.5) + 16 + stateText.length * 0.6)
205  const left = `
206<g>
207  <circle class="${isRunning ? 'dot-run' : 'dot-ok'}" cx="${PAD + 5}" cy="${PAD + 11}" r="4"/>
208  <text class="nm" x="${PAD + 20}" y="${PAD + 15}" font-family="${FONT}" font-size="13" font-weight="650">${xml(nameFit)}</text>
209  <rect class="${known ? 'chip' : 'chipx'}" x="${chipX}" y="${PAD + 3}" width="${chipW}" height="17" rx="8.5"${known ? '' : ' stroke-dasharray="2.5 2.5"'}/>
210  <text class="${known ? 'chipt' : 'mu'}" x="${chipX + chipW / 2}" y="${PAD + 15}" text-anchor="middle" font-family="${FONT}" font-size="9.5" font-weight="700" letter-spacing=".6">${xml(chipText)}</text>
211  ${cost ? `<text class="mu" x="${costX}" y="${PAD + 15}" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="600" font-variant-numeric="tabular-nums">${xml(cost)}</text>` : ''}
212  <rect class="${isRunning ? 'lz-run' : 'lz-idle'}" x="${PAD + 20}" y="${PAD + 26}" width="${lzW}" height="17" rx="8.5"/>
213  <text class="${isRunning ? 'lz-runt' : 'lz-idlet'}" x="${PAD + 20 + lzW / 2}" y="${PAD + 38}" text-anchor="middle" font-family="${FONT}" font-size="9.5" font-weight="700" letter-spacing=".6">${xml(stateText)}</text>
214  ${sessText ? `<text class="mu" x="${costX}" y="${PAD + 39}" text-anchor="end" font-family="${FONT}" font-size="10" font-weight="600">${xml(sessText)}</text>` : ''}
215  <text class="mu" x="${PAD + 20 + lzW + 8}" y="${PAD + 39}" font-family="${FONT}" font-size="11">${xml(fitText(detail, 11, leftW - 22 - lzW - 8 - (sessText ? Math.round(textWidth(sessText, 10)) + 12 : 0)))}</text>
216</g>`
217
218  // Gauges.
219  const keyframes: string[] = []
220  const draw = gauges.map((g, i) => {
221    const x = gx0 + i * (gw + gGap)
222    const y = gy0
223    const pct = g.pct === null ? 0 : Math.max(0, Math.min(100, g.pct))
224    // Level is never colour alone: the word comes with it.
225    const lvl = g.pct === null ? '' : pct >= 85 ? 'CRITICAL' : pct >= 60 ? 'HIGH' : ''
226    const fillW = g.pct === null ? 0 : Math.max(pct > 0 ? 6 : 0, Math.round((gw * pct) / 100))
227    const ticks = [60, 85]
228      .map(t => ({ t, tx: x + Math.round((gw * t) / 100) }))
229      .filter(({ tx }) => tx > x + fillW + 3)
230      .map(({ tx }) => `<rect class="tick" x="${tx}" y="${y + 15}" width="1" height="8" rx=".5" opacity=".55"/>`)
231      .join('')
232    let sheen = ''
233    if (BAND_SHEEN && isRunning && g.sheen && fillW > 12) {
234      keyframes.push(`@keyframes sh${i}{0%{transform:translateX(-30px)}100%{transform:translateX(${fillW}px)}}`)
235      sheen = `<clipPath id="cl${i}"><rect x="${x}" y="${y + 16}" width="${fillW}" height="6" rx="3"/></clipPath>
236<g clip-path="url(#cl${i})"><rect x="${x}" y="${y + 16}" width="30" height="6" fill="url(#gSheen)" style="animation:sh${i} 1.6s ${EASE} infinite;animation-delay:${phase(1.6)}"/></g>`
237    }
238    return `
239<g>
240  <text class="mu" x="${x}" y="${y + 10}" font-family="${FONT}" font-size="9.5" font-weight="700" letter-spacing=".9">${xml(g.label)}${lvl ? `<tspan class="${pct >= 85 ? 'lv-bad' : 'lv-hi'}" dx="5">${lvl}</tspan>` : ''}</text>
241  <text class="pc" x="${x + gw}" y="${y + 10}" text-anchor="end" font-family="${FONT}" font-size="12" font-weight="700" font-variant-numeric="tabular-nums">${g.pct === null ? '—' : Math.round(g.pct) + '%'}</text>
242  <rect class="trk" x="${x}" y="${y + 16}" width="${gw}" height="6" rx="3"/>
243  ${fillW ? `<rect x="${x}" y="${y + 16}" width="${fillW}" height="6" rx="3" fill="url(#${gradFor(g.pct)})"/>` : ''}
244  ${sheen}${ticks}
245  <text class="mu" x="${x}" y="${y + 38}" font-family="${FONT}" font-size="10" font-variant-numeric="tabular-nums">${xml(fitText(g.caption, 10, gw))}</text>
246</g>`
247  })
248
249  // The task being worked on: full width, thicker than a gauge, segmented per step.
250  let taskRow = ''
251  if (tv) {
252    const tx = PAD
253    const ty = baseH - 10
254    const tw = W - PAD * 2
255    const pct = Math.max(0, Math.min(100, Math.round((tv.done / Math.max(1, tv.total)) * 100)))
256    const fillW = pct > 0 ? Math.max(8, Math.round((tw * pct) / 100)) : 0
257    const grad = tv.isFinished ? 'gDone' : 'gOk'
258    const stepsText = `${tv.done}/${tv.total}`
259    const right = `${stepsText}  ·  ${pct}%`
260    const labelW = tw - textWidth(right, 12) - 24
261    const head = `TASK${tv.title ? '  ' + tv.title : ''}${tv.current && !tv.isFinished ? '  ›  ' + tv.current : ''}`
262    const segs: string[] = []
263    if (tv.total <= 16) {
264      for (let i = 1; i < tv.total; i++) {
265        segs.push(`<rect class="seg" x="${tx + Math.round((tw * i) / tv.total) - 1}" y="${ty + 16}" width="2" height="8"/>`)
266      }
267    }
268    let sheen = ''
269    if (BAND_SHEEN && isRunning && !tv.isFinished && fillW > 16) {
270      keyframes.push(`@keyframes sht{0%{transform:translateX(-36px)}100%{transform:translateX(${fillW}px)}}`)
271      sheen = `<clipPath id="clt"><rect x="${tx}" y="${ty + 16}" width="${fillW}" height="8" rx="4"/></clipPath>
272<g clip-path="url(#clt)"><rect x="${tx}" y="${ty + 16}" width="36" height="8" fill="url(#gSheen)" style="animation:sht 1.6s ${EASE} infinite;animation-delay:${phase(1.6)}"/></g>`
273    }
274    taskRow = `
275<g>
276  <text class="mu" x="${tx}" y="${ty + 10}" font-family="${FONT}" font-size="9.5" font-weight="700" letter-spacing=".9">${xml(fitText(head, 9.5, labelW))}</text>
277  <text class="pc" x="${tx + tw}" y="${ty + 10}" text-anchor="end" font-family="${FONT}" font-size="12" font-weight="700" font-variant-numeric="tabular-nums">${xml(right)}</text>
278  <rect class="trk" x="${tx}" y="${ty + 16}" width="${tw}" height="8" rx="4"/>
279  ${fillW ? `<rect x="${tx}" y="${ty + 16}" width="${fillW}" height="8" rx="4" fill="url(#${grad})"/>` : ''}
280  ${sheen}${segs.join('')}
281</g>`
282  }
283
284  // Subagents inline, for when the agents panel has no room (narrow window): one
285  // compact row each, newest working first.
286  let agentBlock = ''
287  if (ag.rows.length) {
288    const ay = baseH - 10 + taskH
289    const barW = Math.max(48, Math.min(120, Math.round(W * 0.16)))
290    const head = `AGENTS  ·  ${ag.working} working${ag.more ? `  ·  +${ag.more} more` : ''}`
291    const rows = ag.rows.map((r, i) => {
292      const y = ay + 22 + i * 22
293      const fill = Math.round((barW * r.ctx) / 100)
294      const dot = r.status === 'running' ? 'dot-run' : r.status === 'done' ? 'dot-ok' : 'lv-bad'
295      const word = r.status === 'running' ? '' : r.status === 'done' ? 'done' : 'failed'
296      const barX = W - PAD - 40 - barW
297      const wordW = word ? Math.round(textWidth(word, 10) + 10) : 0
298      const nameW = barX - wordW - (PAD + 16) - 10
299      const grad = r.ctx >= 85 ? 'gBad' : r.ctx >= 60 ? 'gWarn' : 'gOk'
300      return `
301<circle class="${dot}" cx="${PAD + 5}" cy="${y + 7}" r="3"/>
302<text class="nm" x="${PAD + 16}" y="${y + 11}" font-family="${FONT}" font-size="11" font-weight="600">${xml(fitText(r.name, 11, Math.max(40, nameW)))}</text>
303${word ? `<text class="${r.status === 'failed' ? 'lv-bad' : 'mu'}" x="${barX - 10}" y="${y + 11}" text-anchor="end" font-family="${FONT}" font-size="10" font-weight="600">${word}</text>` : ''}
304<rect class="trk" x="${barX}" y="${y + 4}" width="${barW}" height="6" rx="3"/>
305${fill > 0 ? `<rect x="${barX}" y="${y + 4}" width="${Math.max(5, fill)}" height="6" rx="3" fill="url(#${r.status === 'done' ? 'gDone' : grad})"/>` : ''}
306<text class="pc" x="${W - PAD}" y="${y + 11}" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="700" font-variant-numeric="tabular-nums">${r.ctx}%</text>`
307    })
308    agentBlock = `<g>
309<text class="mu" x="${PAD}" y="${ay + 10}" font-family="${FONT}" font-size="9.5" font-weight="700" letter-spacing=".9">${xml(head)}</text>
310${rows.join('')}
311</g>`
312  }
313
314  // Turn rail along the bottom: a travelling highlight while working.
315  const railW = W - PAD * 2
316  const seg = Math.round(railW * 0.28)
317  keyframes.push(`@keyframes rl{0%{transform:translateX(${-seg}px)}100%{transform:translateX(${railW}px)}}`)
318  const rail = `
319<clipPath id="rc"><rect x="${PAD}" y="${H - 6}" width="${railW}" height="2" rx="1"/></clipPath>
320<rect class="rail" x="${PAD}" y="${H - 6}" width="${railW}" height="2" rx="1"/>
321${isRunning ? `<g clip-path="url(#rc)"><rect x="${PAD}" y="${H - 6}" width="${seg}" height="2" rx="1" fill="url(#gRail)" style="animation:rl 1.8s ${EASE} infinite;animation-delay:${phase(1.8)}"/></g>` : ''}`
322
323  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
324<style>${CSS}
325${keyframes.join('\n')}
326@media (prefers-reduced-motion: reduce){*{animation:none!important}}</style>
327<defs>
328<linearGradient id="gOk" x1="0" x2="1"><stop offset="0" stop-color="#FFBC25"/><stop offset="1" stop-color="#FFA500"/></linearGradient>
329<linearGradient id="gWarn" x1="0" x2="1"><stop offset="0" stop-color="#EB6834"/><stop offset="1" stop-color="#F58A5B"/></linearGradient>
330<linearGradient id="gBad" x1="0" x2="1"><stop offset="0" stop-color="#C62F2F"/><stop offset="1" stop-color="#E34948"/></linearGradient>
331<linearGradient id="gDone" x1="0" x2="1"><stop offset="0" stop-color="#1F7A4D"/><stop offset="1" stop-color="#138A5E"/></linearGradient>
332<linearGradient id="gNone" x1="0" x2="1"><stop offset="0" stop-color="#999"/><stop offset="1" stop-color="#bbb"/></linearGradient>
333<linearGradient id="gSheen" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
334<linearGradient id="gRail" x1="0" x2="1"><stop offset="0" stop-color="#FFA500" stop-opacity="0"/><stop offset=".5" stop-color="#FFA500"/><stop offset="1" stop-color="#FFBC25" stop-opacity="0"/></linearGradient>
335</defs>
336<rect class="card" x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="12"/>
337${left}
338${draw.join('')}
339${taskRow}
340${agentBlock}
341${rail}
342</svg>`
343  return { svg, H }
344}
345
346// ---------------------------------------------------------------------------
347// Agents pane: one card per subagent, in the monitor's design language.
348
349const PANE_CSS = `${CSS}
350.lz-ok{fill:#E3F5EB}.lz-okt{fill:#1F7A4D}.lz-bad{fill:#FDECEC}.lz-badt{fill:#C62F2F}.tile{fill:#EDF0F2}
351.lz-att{fill:#FFF4D6}.lz-attt{fill:#8A5A00}
352@media (prefers-color-scheme: dark){
353.lz-ok{fill:#1E3A2C}.lz-okt{fill:#7BE0B0}.lz-bad{fill:#4A2323}.lz-badt{fill:#FF9C9A}.tile{fill:#333333}
354.lz-att{fill:#4A3A12}.lz-attt{fill:#FFD36B}}`
355
356const DEFS = `<defs>
357<linearGradient id="gOk" x1="0" x2="1"><stop offset="0" stop-color="#FFBC25"/><stop offset="1" stop-color="#FFA500"/></linearGradient>
358<linearGradient id="gWarn" x1="0" x2="1"><stop offset="0" stop-color="#EB6834"/><stop offset="1" stop-color="#F58A5B"/></linearGradient>
359<linearGradient id="gBad" x1="0" x2="1"><stop offset="0" stop-color="#C62F2F"/><stop offset="1" stop-color="#E34948"/></linearGradient>
360<linearGradient id="gDone" x1="0" x2="1"><stop offset="0" stop-color="#1F7A4D"/><stop offset="1" stop-color="#138A5E"/></linearGradient>
361<linearGradient id="gSheen" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
362</defs>`
363
364const paneSvg = (W: number, H: number, extra: string, body: string): string =>
365  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><style>${PANE_CSS}${extra}
366@media (prefers-reduced-motion: reduce){*{animation:none!important}}</style>${DEFS}${body}</svg>`
367
368const runElapsed = (a: Run, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
369const runContext = (a: Run): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
370
371const LOZENGE: Record<Run['status'], { text: string; bg: string; fg: string }> = {
372  running: { text: 'WORKING', bg: 'lz-run', fg: 'lz-runt' },
373  done: { text: 'DONE', bg: 'lz-ok', fg: 'lz-okt' },
374  failed: { text: 'FAILED', bg: 'lz-bad', fg: 'lz-badt' },
375}
376
377const statsSvg = (W: number, list: Run[], at: number): string => {
378  const running = list.filter(a => a.status === 'running').length
379  const tokens = list.reduce((s, a) => s + a.tokens, 0)
380  const start = list.length ? Math.min(...list.map(a => a.startedAt)) : 0
381  const end = list.length ? Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt))) : 0
382  const gap = 8
383  const tw = Math.floor((W - gap * 2) / 3)
384  const tile = (i: number, k: string, v: string): string => {
385    const x = i * (tw + gap)
386    return `<rect class="tile" x="${x}" y="0" width="${tw}" height="46" rx="8"/>
387<text class="mu" x="${x + 12}" y="18" font-family="${FONT}" font-size="9.5" font-weight="700" letter-spacing=".9">${k}</text>
388<text class="pc" x="${x + 12}" y="37" font-family="${FONT}" font-size="16" font-weight="700" font-variant-numeric="tabular-nums">${xml(v)}</text>`
389  }
390  return paneSvg(
391    W,
392    46,
393    '',
394    tile(0, 'RUNNING', `${running} / ${list.length}`) + tile(1, 'TOKENS', fmtTokens(tokens)) + tile(2, 'TIME', fmtTime(end - start)),
395  )
396}
397
398const agentSvg = (W: number, a: Run, at: number, nowMs: number): string => {
399  const H = 100
400  const PAD = 14
401  const lz = LOZENGE[a.status]
402  const lzW = Math.round(textWidth(lz.text, 9.5) + 16 + lz.text.length * 0.6)
403  const ctx = runContext(a)
404  const typeText = a.type.replace(/^[^:]*:/, '')
405  const chipW = Math.round(textWidth(typeText, 10) + 18)
406  const meta = [
407    modelName(a.model),
408    a.effort ? a.effort : '',
409    fmtTime(runElapsed(a, at)),
410    `${fmtTokens(a.tokens)} tok`,
411    `${a.steps} ${a.steps === 1 ? 'step' : 'steps'}`,
412  ]
413    .filter(Boolean)
414    .join('  ·  ')
415  const barW = W - PAD * 2
416  const fillW = ctx > 0 ? Math.max(6, Math.round((barW * ctx) / 100)) : 0
417  const lvl = ctx >= 85 ? 'CRITICAL' : ctx >= 60 ? 'HIGH' : ''
418  const grad = ctx >= 85 ? 'gBad' : ctx >= 60 ? 'gWarn' : 'gOk'
419  const dot = a.status === 'running' ? 'dot-run' : a.status === 'done' ? 'dot-ok' : 'lv-bad'
420  const sheen = ''
421  const kf = ''
422  const body = `
423<rect class="card" x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="12"/>
424<circle class="${dot}" cx="${PAD + 5}" cy="${PAD + 11}" r="4"/>
425<text class="nm" x="${PAD + 20}" y="${PAD + 15}" font-family="${FONT}" font-size="13" font-weight="650">${xml(fitText(a.description || a.type, 13, W - PAD * 2 - 20 - lzW - 12))}</text>
426<rect class="${lz.bg}" x="${W - PAD - lzW}" y="${PAD + 3}" width="${lzW}" height="17" rx="8.5"/>
427<text class="${lz.fg}" x="${W - PAD - lzW / 2}" y="${PAD + 15}" text-anchor="middle" font-family="${FONT}" font-size="9.5" font-weight="700" letter-spacing=".6">${lz.text}</text>
428<rect class="chip" x="${PAD + 20}" y="${PAD + 27}" width="${chipW}" height="16" rx="8"/>
429<text class="chipt" x="${PAD + 20 + chipW / 2}" y="${PAD + 38.5}" text-anchor="middle" font-family="${FONT}" font-size="10" font-weight="600">${xml(typeText)}</text>
430<text class="mu" x="${PAD + 20 + chipW + 8}" y="${PAD + 39}" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(meta, 11, W - PAD * 2 - 20 - chipW - 8))}</text>
431<text class="mu" x="${PAD}" y="${H - 30}" font-family="${FONT}" font-size="9.5" font-weight="700" letter-spacing=".9">CONTEXT${lvl ? `<tspan class="${ctx >= 85 ? 'lv-bad' : 'lv-hi'}" dx="5">${lvl}</tspan>` : ''}</text>
432<text class="pc" x="${W - PAD}" y="${H - 30}" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="700" font-variant-numeric="tabular-nums">${ctx}%  <tspan class="mu" font-weight="500">${fmtTokens(a.contextTokens)} of ${fmtTokens(a.contextMax)}</tspan></text>
433<rect class="trk" x="${PAD}" y="${H - 24}" width="${barW}" height="6" rx="3"/>
434${fillW ? `<rect x="${PAD}" y="${H - 24}" width="${fillW}" height="6" rx="3" fill="url(#${a.status === 'done' ? 'gDone' : grad})"/>` : ''}
435${sheen}`
436  return paneSvg(W, H, kf, body)
437}
438
439// ---------------------------------------------------------------------------
440// Every session on this machine. Claude Code keeps a small registry entry per
441// live session in ~/.claude/sessions/<pid>.json (name, folder, status, last
442// update). Only that metadata is read: never transcripts, never the .key files.
443
444const DEAD_AFTER_MS = 24 * 3600 * 1000
445
446// Parsed registry and progress files, keyed by name and file time: a poll only reads
447// the files that changed since the last one.
448const regCache = new Map<string, { m: number; raw: Record<string, unknown> }>()
449const progCache = new Map<string, { m: number; raw: Record<string, unknown> }>()
450
451// An idle session that stopped recently is most likely waiting for you (a turn just
452// finished); one that has been quiet for longer is background noise and is hidden.
453// The registry only says busy or idle, so "needs you" is that recency, not a flag.
454const ATTENTION_MS = 30 * 60 * 1000
455
456const needsYou = (s: SessionRow, at: number): boolean => s.status === 'idle' && !s.isSelf && at - s.updatedAt <= ATTENTION_MS
457const isShown = (s: SessionRow, at: number): boolean => s.isSelf || s.status === 'busy' || at - s.updatedAt <= ATTENTION_MS
458
459const lastSegment = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() || p
460
461async function homeDir($: EngineInterface): Promise<string> {
462  return ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
463}
464
465// Each session publishes the progress of its own task to
466// ~/.claude/live-status/progress/<sessionId>.json, so the others can draw it.
467// Only the task's title, step counts and current step are written.
468async function publishProgress($: EngineInterface): Promise<void> {
469  try {
470    const home = await homeDir($)
471    if (!home) return
472    const id = await $.session.id()
473    const tv = taskView(await read($, task), await read($, crew))
474    const at = await $.clock.now()
475    const body = tv ? { title: tv.title, done: tv.done, total: tv.total, current: tv.current, isFinished: tv.isFinished, updatedAt: at } : { total: 0, updatedAt: at }
476    await $.fs.write(`${home}/.claude/live-status/progress/${id}.json`, JSON.stringify(body))
477  } catch {
478    // Not writable: other sessions simply see no progress from this one.
479  }
480}
481
482async function readProgress($: EngineInterface, home: string, id: string, at: number): Promise<SessionRow['task']> {
483  try {
484    const path = `${home}/.claude/live-status/progress/${id}.json`
485    const st = await $.fs.stat(path)
486    const hit = progCache.get(id)
487    let raw: Record<string, unknown>
488    if (hit && hit.m === st.mtimeMs) {
489      raw = hit.raw
490    } else {
491      raw = JSON.parse((await $.fs.read(path)) as string) as Record<string, unknown>
492      progCache.set(id, { m: st.mtimeMs, raw })
493    }
494    const total = Number(raw.total ?? 0)
495    if (!(total > 0) || at - Number(raw.updatedAt ?? 0) > DEAD_AFTER_MS) return undefined
496    return {
497      title: typeof raw.title === 'string' ? raw.title : '',
498      done: Math.max(0, Math.min(total, Number(raw.done ?? 0))),
499      total,
500      current: typeof raw.current === 'string' ? raw.current : '',
501      isFinished: raw.isFinished === true,
502    }
503  } catch {
504    return undefined
505  }
506}
507
508const sessionSig = (rows: SessionRow[]): string =>
509  JSON.stringify(rows.map(r => [r.id, r.status, r.updatedAt, r.name, r.task ? [r.task.done, r.task.total, r.task.current, r.task.isFinished] : 0]))
510
511async function readSessions($: EngineInterface): Promise<SessionRow[]> {
512  try {
513    const home = await homeDir($)
514    if (!home) return []
515    const dir = `${home}/.claude/sessions`
516    const entries = await $.fs.list(dir)
517    const self = await $.session.id()
518    const at = await $.clock.now()
519    const rows: SessionRow[] = []
520    for (const en of entries) {
521      if (!/^\d+\.json$/.test(en.name)) continue
522      try {
523        const hit = regCache.get(en.name)
524        let raw: Record<string, unknown>
525        if (hit && hit.m === en.mtimeMs) {
526          raw = hit.raw
527        } else {
528          raw = JSON.parse((await $.fs.read(`${dir}/${en.name}`)) as string) as Record<string, unknown>
529          regCache.set(en.name, { m: en.mtimeMs, raw })
530        }
531        const id = typeof raw.sessionId === 'string' ? raw.sessionId : ''
532        if (!id) continue
533        const updatedAt = Number(raw.updatedAt ?? raw.statusUpdatedAt ?? raw.startedAt ?? 0)
534        if (at - updatedAt > DEAD_AFTER_MS) continue
535        const cwd = typeof raw.cwd === 'string' ? raw.cwd : ''
536        rows.push({
537          id,
538          pid: Number(raw.pid ?? 0),
539          name: typeof raw.name === 'string' && raw.name ? raw.name : lastSegment(cwd) || 'session',
540          project: lastSegment(cwd),
541          entry: typeof raw.entrypoint === 'string' ? raw.entrypoint.replace(/^claude-/, '') : 'cli',
542          status: raw.status === 'busy' ? 'busy' : 'idle',
543          updatedAt,
544          startedAt: Number(raw.startedAt ?? 0),
545          isSelf: id === self,
546        })
547      } catch {
548        // A half-written or unreadable entry: skip it, the next poll gets it.
549      }
550    }
551    for (const k of [...regCache.keys()]) if (!entries.some(en => en.name === k)) regCache.delete(k)
552    for (const r of rows) {
553      if (r.isSelf) continue
554      const progress = await readProgress($, home, r.id, at)
555      if (progress) r.task = progress
556    }
557    // This session first, then working ones, then the most recently active.
558    return rows.sort(
559      (a, b) =>
560        Number(b.isSelf) - Number(a.isSelf) ||
561        (a.status === b.status ? b.updatedAt - a.updatedAt : a.status === 'busy' ? -1 : 1),
562    )
563  } catch {
564    return []
565  }
566}
567
568const ago = (ms: number): string => {
569  const s = Math.max(0, Math.round(ms / 1000))
570  if (s < 60) return `${s}s ago`
571  const m = Math.floor(s / 60)
572  return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ago`
573}
574
575const sessionSvg = (W: number, s: SessionRow, at: number, nowMs: number): string => {
576  void nowMs
577  const H = 96
578  const PAD = 14
579  const busy = s.status === 'busy'
580  const attention = needsYou(s, at)
581  const lz = busy
582    ? { text: 'WORKING', bg: 'lz-run', fg: 'lz-runt' }
583    : attention
584      ? { text: 'NEEDS YOU', bg: 'lz-att', fg: 'lz-attt' }
585      : { text: 'IDLE', bg: 'lz-idle', fg: 'lz-idlet' }
586  const lzW = Math.round(textWidth(lz.text, 9.5) + 16 + lz.text.length * 0.6)
587  const thisW = s.isSelf ? Math.round(textWidth('THIS', 9) + 14) : 0
588  const meta = `${s.entry} · ${s.project} · ${ago(at - s.updatedAt)}`
589  const thisChip = s.isSelf
590    ? `<rect class="chip" x="${W - PAD - lzW - thisW - 8}" y="${PAD + 4}" width="${thisW}" height="15" rx="7.5"/><text class="chipt" x="${W - PAD - lzW - thisW / 2 - 8}" y="${PAD + 15}" text-anchor="middle" font-family="${FONT}" font-size="9" font-weight="700" letter-spacing=".6">THIS</text>`
591    : ''
592
593  // Progress is the todo list that session keeps. A session that
594  // reports none gets an empty bar and a plain note, never an invented percentage.
595  const tk = s.task && s.task.total > 0 ? s.task : null
596  const pct = tk ? Math.round((tk.done / tk.total) * 100) : 0
597  const barW = W - PAD * 2
598  const barY = H - 20
599  const fillW = tk && pct > 0 ? Math.max(6, Math.round((barW * pct) / 100)) : 0
600  const right = tk ? `${tk.done}/${tk.total}  ·  ${pct}%` : ''
601  const head = tk
602    ? `${tk.title}${tk.current && !tk.isFinished ? '  ›  ' + tk.current : ''}`
603    : busy
604      ? 'working · no task progress reported'
605      : 'no task reported'
606  const segs: string[] = []
607  if (tk && tk.total <= 16) {
608    for (let i = 1; i < tk.total; i++) {
609      segs.push(`<rect class="seg" x="${PAD + Math.round((barW * i) / tk.total) - 1}" y="${barY}" width="2" height="6"/>`)
610    }
611  }
612  const body = `
613<rect class="card" x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="12"${s.isSelf ? ' style="stroke:#FFBC25"' : ''}/>
614<circle class="${busy ? 'dot-run' : 'dot-ok'}" cx="${PAD + 5}" cy="${PAD + 11}" r="4"/>
615<text class="nm" x="${PAD + 20}" y="${PAD + 15}" font-family="${FONT}" font-size="13" font-weight="650">${xml(fitText(s.name, 13, W - PAD * 2 - 20 - lzW - thisW - 20))}</text>
616${thisChip}
617<rect class="${lz.bg}" x="${W - PAD - lzW}" y="${PAD + 3}" width="${lzW}" height="17" rx="8.5"/>
618<text class="${lz.fg}" x="${W - PAD - lzW / 2}" y="${PAD + 15}" text-anchor="middle" font-family="${FONT}" font-size="9.5" font-weight="700" letter-spacing=".6">${lz.text}</text>
619<text class="mu" x="${PAD + 20}" y="${PAD + 38}" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(meta, 11, W - PAD * 2 - 20))}</text>
620<text class="${tk ? 'nm' : 'mu'}" x="${PAD}" y="${H - 27}" font-family="${FONT}" font-size="10" font-weight="${tk ? 600 : 500}">${xml(fitText(head, 10, barW - (right ? textWidth(right, 11) + 16 : 0)))}</text>
621${right ? `<text class="pc" x="${W - PAD}" y="${H - 27}" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="700" font-variant-numeric="tabular-nums">${xml(right)}</text>` : ''}
622<rect class="trk" x="${PAD}" y="${barY}" width="${barW}" height="6" rx="3"/>
623${fillW ? `<rect x="${PAD}" y="${barY}" width="${fillW}" height="6" rx="3" fill="url(#${tk && tk.isFinished ? 'gDone' : 'gOk'})"/>` : ''}
624${segs.join('')}`
625  return paneSvg(W, H, '', body)
626}
627
628// A section header: caption left, summary right, and an overview progress bar.
629const sectionSvg = (W: number, title: string, summary: string, pct: number | null, isDone = false): string => {
630  const H = 38
631  const fillW = pct === null ? 0 : Math.max(pct > 0 ? 6 : 0, Math.round((W * Math.min(100, pct)) / 100))
632  const body = `
633<text class="nm" x="0" y="14" font-family="${FONT}" font-size="10.5" font-weight="800" letter-spacing="1.2">${xml(title)}</text>
634<text class="mu" x="${W}" y="14" text-anchor="end" font-family="${FONT}" font-size="11" font-weight="600" font-variant-numeric="tabular-nums">${xml(fitText(summary, 11, W - textWidth(title, 10.5) - 24))}</text>
635<rect class="trk" x="0" y="22" width="${W}" height="7" rx="3.5"/>
636${fillW ? `<rect x="0" y="22" width="${fillW}" height="7" rx="3.5" fill="url(#${isDone ? 'gDone' : 'gOk'})"/>` : ''}`
637  return paneSvg(W, H, '', body)
638}
639
640async function togglePane($: EngineInterface): Promise<boolean> {
641  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
642  if (isOpen) {
643    await $.ui.close({ id: PANE })
644    return false
645  }
646  const at = await $.clock.now()
647  await update($, now, () => at)
648  await $.ui.open({ id: PANE, title: 'Agents' })
649  return true
650}
651
652export const register: Register = on => {
653  on('session.start', async ($, e, next) => {
654    const started = await next(e)
655    await update($, now, () => 0)
656    try {
657      const m = await $.session.model()
658      await update($, model, () => m)
659    } catch {
660      // The model shows up with the first step.
661    }
662    await $.command.register({
663      name: 'monitor',
664      description: 'Show or hide the whole monitor: the band above the prompt and the agents panel (on / off to force)',
665    })
666    await $.command.register({
667      name: 'agents-live',
668      description: 'Show or hide the panel of subagents: status, model, context, tokens and time',
669    })
670    await refresh($)
671    const configured = await configuredEffort($)
672    await update($, effort, cur => cur || configured)
673
674    // On from the first moment: the band draws by itself; open the agents panel too.
675    void $.ui.open({ id: PANE, title: 'Agents' })
676
677    // Realtime: every second while a turn runs (clock, steps), every 5th second
678    // re-reading usage mid-step; idle, every 15 seconds so reset countdowns and
679    // limit windows stay true. Changes the engine reports arrive at once through
680    // session.measure.
681    let ticks = 0
682    let lastSessionSig = ''
683    let othersBusy = false
684    // First read right away, so the panel is not empty for the first seconds.
685    const firstRows = await readSessions($)
686    lastSessionSig = sessionSig(firstRows)
687    othersBusy = firstRows.some(r => !r.isSelf && r.status === 'busy')
688    void publishProgress($)
689    await update($, sessions, () => firstRows)
690    $.clock.every(1000, () => {
691      void (async () => {
692        ticks += 1
693        const t = await read($, turn)
694        // Cheap by design. Other sessions: polled every 5 s while something moves (a turn
695        // here, or a working session elsewhere), every 15 s otherwise, and only files
696        // that changed are read. The panel redraws on that slow clock. Usage is
697        // re-read every 15 s, since the engine also reports changes by itself.
698        const every = t.isRunning || othersBusy ? 5 : 15
699        if (ticks % every === 0) {
700          const rows = await readSessions($)
701          othersBusy = rows.some(r => !r.isSelf && r.status === 'busy')
702          const sig = sessionSig(rows)
703          if (sig !== lastSessionSig) {
704            lastSessionSig = sig
705            await update($, sessions, () => rows)
706          }
707          const at = await $.clock.now()
708          await update($, slow, () => at)
709        }
710        if (t.isRunning || ticks % 15 === 0) {
711          if (ticks % 15 === 0) await refresh($)
712          const at = await $.clock.now()
713          await update($, now, () => at)
714        }
715      })()
716    })
717
718    return started
719  })
720
721  // Context, limits and cost move on their own: the engine reports each change.
722  on('session.measure', async ($, e, next) => {
723    await update($, usage, () => fromUsage(e))
724
725    const worst = Math.max(e.context.percent ?? 0, ...e.rateLimits.map(r => r.percentUsed))
726    const level = worst >= 95 ? '95' : worst >= 85 ? '85' : ''
727    if (level && (await read($, warned)) !== level) {
728      await update($, warned, () => level)
729      $.ui.toast(`Usage at ${Math.round(worst)}%: see the monitor above the prompt`)
730    } else if (!level) {
731      await update($, warned, () => '')
732    }
733
734    return next(e)
735  })
736
737  // One command for both: the band above the prompt and the agents panel.
738  on('command.run', { command: 'monitor' }, async ($, e) => {
739    const arg = e.args.trim().toLowerCase()
740    const hidden = await read($, isHidden)
741    const paneOpen = (await $.ui.panes()).some(p => p.id === PANE)
742    const wantOn = arg === 'on' ? true : arg === 'off' ? false : hidden || !paneOpen
743
744    if (wantOn) {
745      await update($, isHidden, () => false)
746      if (!paneOpen) await togglePane($)
747      return { text: 'Monitor on: band and agents panel.' }
748    }
749    await update($, isHidden, () => true)
750    if (paneOpen) await togglePane($)
751    return { text: 'Monitor off.' }
752  })
753
754  on('command.run', { command: 'agents-live' }, async $ => {
755    const isOpen = await togglePane($)
756    return { text: isOpen ? 'Agents panel opened.' : 'Agents panel closed.' }
757  })
758
759  // Task progress comes from the todo list Claude already keeps (TodoWrite). The call is
760  // only observed and passes through untouched, so this adds no tokens: no tool of our
761  // own in the model's tool list, no extra calls, nothing written to its context.
762  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
763    const ran = await next(e)
764    if (e.agentId) return ran
765    const todos = (e as unknown as { todos?: { content?: string; status?: string; activeForm?: string }[] }).todos ?? []
766    const total = todos.length
767    const done = todos.filter(t => t.status === 'completed').length
768    const active = todos.find(t => t.status === 'in_progress') ?? todos.find(t => t.status === 'pending')
769    await update($, task, () =>
770      total > 0
771        ? {
772            title: 'Tasks',
773            total,
774            done,
775            current: (active?.activeForm || active?.content || '').trim(),
776            isFinished: done === total,
777          }
778        : null,
779    )
780    const at = await $.clock.now()
781    await update($, now, () => at)
782    await publishProgress($)
783    return ran
784  })
785
786  on('turn.start', async ($, e, next) => {
787    await update($, crew, () => ({ spawned: 0, done: 0 }))
788    await publishProgress($)
789    await update($, task, prev => (prev?.isFinished ? null : prev))
790    const at = await $.clock.now()
791    await update($, turn, t => ({ ...NO_TURN, isRunning: true, startedAt: at, agents: t.agents }))
792    await update($, now, () => at)
793    return next(e)
794  })
795
796  on('turn.step', async function* ($, e, next) {
797    if (!e.agentId) {
798      await update($, model, () => e.model)
799      await update($, effort, () => (e.effort === undefined ? '' : String(e.effort)))
800      await update($, turn, t => ({ ...t, steps: t.steps + 1 }))
801    }
802    const result = yield* next(e)
803    const u = result.usage
804    const agentId = e.agentId
805    if (agentId && u) {
806      // A subagent's own request: its live context, tokens and step count.
807      const used =
808        (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0)
809      await update($, runs, list =>
810        list.map(r =>
811          r.agentId !== agentId
812            ? r
813            : {
814                ...r,
815                model: u.model || e.model,
816                effort: typeof e.effort === 'string' ? e.effort : r.effort,
817                status: 'running' as const,
818                endedAt: undefined,
819                contextTokens: used,
820                contextMax: /haiku/i.test(u.model || e.model) ? 200_000 : 1_000_000,
821                tokens: r.tokens + used,
822                steps: r.steps + 1,
823              },
824        ),
825      )
826    }
827    await refresh($)
828    return result
829  })
830
831  on('tool.call', async ($, e, next) => {
832    if (!e.agentId) await update($, turn, t => ({ ...t, tools: t.tools + 1 }))
833    return next(e)
834  })
835
836  on('agent.spawn', async ($, e, next) => {
837    const started = await next(e)
838    if (started.deny === undefined) {
839      await update($, turn, t => ({ ...t, agents: t.agents + 1 }))
840      await update($, crew, c => ({ ...c, spawned: c.spawned + 1 }))
841      await publishProgress($)
842      const at = await $.clock.now()
843      await update($, runs, list => {
844        const run: Run = {
845          id: started.agentId ?? e.tool_use_id,
846          agentId: started.agentId,
847          type: e.subagentType,
848          description: e.description,
849          model: started.model,
850          status: 'running',
851          startedAt: at,
852          contextTokens: 0,
853          contextMax: /haiku/i.test(started.model) ? 200_000 : 1_000_000,
854          tokens: 0,
855          steps: 0,
856        }
857        return [...list.filter(r => r.id !== run.id), run].slice(-40)
858      })
859      await update($, now, () => at)
860    }
861    return started
862  })
863
864  on('turn.complete', async ($, e, next) => {
865    const at = await $.clock.now()
866    if (e.agentId) {
867      await update($, turn, t => ({ ...t, agents: Math.max(0, t.agents - 1) }))
868      await update($, crew, c => ({ ...c, done: Math.min(c.spawned, c.done + 1) }))
869      await publishProgress($)
870      const agentId = e.agentId
871      await update($, runs, list =>
872        list.map(r => (r.agentId === agentId ? { ...r, status: e.reason === 'answer' ? ('done' as const) : ('failed' as const), endedAt: at } : r)),
873      )
874    } else {
875      await update($, turn, t => ({
876        ...t,
877        isRunning: false,
878        lastSeconds: Math.round((at - t.startedAt) / 1000),
879        agents: 0,
880      }))
881    }
882    await refresh($)
883    return next(e)
884  })
885
886  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
887    const ui = $.ui.resolve(e)
888    const { Box, Text, Button } = ui
889    const list = await read($, runs)
890    const p: Panel = await read($, panel)
891    const nowMs = await $.clock.now()
892    await read($, slow) // the panel redraws on this slow clock, not every second
893    const at = nowMs
894    const running = list.filter(r => r.status === 'running').reverse()
895    const finished = list.filter(r => r.status !== 'running').reverse()
896    const everySession = await read($, sessions)
897    const own = taskView(await read($, task), await read($, crew))
898    const sessionList = everySession
899      .filter(s => isShown(s, nowMs))
900      .map(s => (s.isSelf && own ? { ...s, task: { title: own.title, done: own.done, total: own.total, current: own.current, isFinished: own.isFinished } } : s))
901    const sessionsHidden = everySession.length - sessionList.length
902    const sessionsWorking = sessionList.filter(s => s.status === 'busy').length
903    const sessionsNeed = sessionList.filter(s => needsYou(s, nowMs)).length
904
905    const toggleCompact = (
906      <Button
907        key="compact"
908        label={p.isCompact ? 'Expand' : 'Collapse'}
909        plain
910        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
911      />
912    )
913    const toggleDone = (
914      <Button
915        key="done"
916        label={`${p.isDoneCollapsed ? '▸' : '▾'} Finished · ${finished.length}`}
917        plain
918        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
919      />
920    )
921
922    if ('Svg' in ui) {
923      const { Svg } = ui
924      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
925      const card = (r: Run) => (
926        <Svg
927          key={r.id}
928          source={agentSvg(W, r, at, nowMs)}
929          alt={`${r.description || r.type}: ${r.status}, ${modelName(r.model)}, context ${runContext(r)}%`}
930          width={W}
931          height={100}
932        />
933      )
934      return (
935        <Box flexDirection="column" gap={1}>
936          <Svg
937            source={statsSvg(W, list, at)}
938            alt={`${running.length} of ${list.length} agents running, ${fmtTokens(list.reduce((s, r) => s + r.tokens, 0))} tokens`}
939            width={W}
940            height={46}
941          />
942          {toggleCompact}
943          {!p.isCompact && (
944            <Svg
945              source={sectionSvg(W, 'SESSIONS', `${sessionsWorking} working${sessionsNeed ? ` · ${sessionsNeed} need you` : ''}${sessionsHidden ? ` · ${sessionsHidden} idle hidden` : ''}`, sessionList.length ? (sessionsWorking / sessionList.length) * 100 : null)}
946              alt={`Sessions: ${sessionsWorking} of ${sessionList.length} working, ${sessionsNeed} need you, ${sessionsHidden} idle hidden`}
947              width={W}
948              height={38}
949            />
950          )}
951          {!p.isCompact && sessionList.length === 0 && <Text dimColor>No sessions found yet.</Text>}
952          {!p.isCompact &&
953            sessionList.map(s => (
954              <Svg
955                key={s.id}
956                source={sessionSvg(W, s, nowMs, nowMs)}
957                alt={`${s.name}${s.isSelf ? ' (this session)' : ''}: ${s.status === 'busy' ? 'working' : 'idle'}, ${s.project}, updated ${ago(nowMs - s.updatedAt)}`}
958                width={W}
959                height={96}
960              />
961            ))}
962          {!p.isCompact && (
963            <Svg
964              source={sectionSvg(W, 'SUBAGENTS', list.length ? `${finished.length} / ${list.length} finished` : 'none yet', list.length ? (finished.length / list.length) * 100 : null, list.length > 0 && running.length === 0)}
965              alt={`Subagents: ${finished.length} of ${list.length} finished`}
966              width={W}
967              height={38}
968            />
969          )}
970          {list.length === 0 && <Text dimColor>No subagents yet. They show up here the moment one starts.</Text>}
971          {!p.isCompact && running.length > 0 && <Text dimColor>Working · {running.length}</Text>}
972          {!p.isCompact && running.map(card)}
973          {!p.isCompact && finished.length > 0 && toggleDone}
974          {!p.isCompact && !p.isDoneCollapsed && finished.slice(0, 8).map(card)}
975        </Box>
976      )
977    }
978
979    // Terminal: the same rows in text.
980    const cols = Math.max(24, e.props.bodyColumns || 40)
981    const w = Math.max(6, Math.min(20, cols - 30))
982    const row = (r: Run) => {
983      const ctx = runContext(r)
984      return (
985        <Box key={r.id} flexDirection="column" marginBottom={1}>
986          <Text bold wrap="truncate-end">
987            <Text color={r.status === 'failed' ? RED : r.status === 'done' ? GREEN : BLUE}>
988              {r.status === 'running' ? '●' : r.status === 'done' ? '✓' : '✗'}{' '}
989            </Text>
990            {r.description || r.type}
991          </Text>
992          <Text dimColor wrap="truncate-end">
993            {'  '}
994            {r.type.replace(/^[^:]*:/, '')} · {modelName(r.model)}
995            {r.effort ? ` · ${r.effort}` : ''} · {fmtTime(runElapsed(r, at))} · {fmtTokens(r.tokens)} tok
996          </Text>
997          <Text wrap="truncate-end">
998            {'  '}
999            <Text color={tone(ctx)}>{bar(ctx, w)}</Text>
1000            <Text dimColor>
1001              {' '}
1002              ctx {ctx}% · {fmtTokens(r.contextTokens)}
1003            </Text>
1004          </Text>
1005        </Box>
1006      )
1007    }
1008    return (
1009      <Box flexDirection="column">
1010        <Text dimColor>
1011          {running.length}/{list.length} running · {fmtTokens(list.reduce((s, r) => s + r.tokens, 0))} tokens
1012        </Text>
1013        {toggleCompact}
1014        {!p.isCompact && sessionList.length > 0 && (
1015          <Text dimColor>
1016            sessions · {sessionsWorking}/{sessionList.length} working{sessionsNeed ? ` · ${sessionsNeed} need you` : ''}{sessionsHidden ? ` · ${sessionsHidden} hidden` : ''}{' '}
1017            {bar(sessionList.length ? (sessionsWorking / sessionList.length) * 100 : 0, 10)}
1018          </Text>
1019        )}
1020        {!p.isCompact &&
1021          sessionList.map(s => (
1022            <Text key={s.id} wrap="truncate-end">
1023              <Text color={s.status === 'busy' ? BLUE : needsYou(s, nowMs) ? AMBER : GREEN}>{s.status === 'busy' ? '●' : needsYou(s, nowMs) ? '!' : '○'} </Text>
1024              {s.name}
1025              <Text dimColor>
1026                {' '}
1027                {s.isSelf ? '(this) ' : ''}· {s.project} · {ago(nowMs - s.updatedAt)}
1028              </Text>
1029            </Text>
1030          ))}
1031        {!p.isCompact && list.length > 0 && (
1032          <Text dimColor>
1033            subagents · {finished.length}/{list.length} finished {bar((finished.length / list.length) * 100, 10)}
1034          </Text>
1035        )}
1036        {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
1037        {!p.isCompact && running.map(row)}
1038        {!p.isCompact && finished.length > 0 && toggleDone}
1039        {!p.isCompact && !p.isDoneCollapsed && finished.slice(0, 8).map(row)}
1040      </Box>
1041    )
1042  })
1043
1044  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1045    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
1046
1047    const ui = $.ui.resolve(e)
1048    const { Box, Text, Button } = ui
1049    const u = await read($, usage)
1050    const t = await read($, turn)
1051    const at = (await read($, now)) || (await $.clock.now())
1052    const m = await read($, model)
1053    const fx = await read($, effort)
1054    const tv = taskView(await read($, task), await read($, crew))
1055    const runList = await read($, runs)
1056    const shownAt = await $.clock.now()
1057    const allSessions = (await read($, sessions)).filter(s => isShown(s, shownAt))
1058    const sessionSummary = {
1059      total: allSessions.length,
1060      working: allSessions.filter(s => s.status === 'busy').length,
1061      attention: allSessions.filter(s => needsYou(s, shownAt)).length,
1062    }
1063    const crewCount = runList.length
1064
1065    // Agents go inline when the panel has no room (narrow window) or is not open.
1066    let paneOpen = false
1067    try {
1068      paneOpen = (await $.ui.panes()).some(p => p.id === PANE)
1069    } catch {
1070      // Treat as closed: the inline rows are the safe fallback.
1071    }
1072    const needInline = runList.length > 0 && (!paneOpen || (e.props.bodyColumns || 100) < 144)
1073    const maxRows = (e.props.bodyColumns || 100) < 70 ? 2 : 3
1074    const ordered = [...runList.filter(r => r.status === 'running').reverse(), ...runList.filter(r => r.status !== 'running').reverse()]
1075    const inline = {
1076      rows: needInline
1077        ? ordered.slice(0, maxRows).map((r): AgentRow => ({ name: r.description || r.type, status: r.status, ctx: runContext(r) }))
1078        : [],
1079      working: runList.filter(r => r.status === 'running').length,
1080      more: needInline ? Math.max(0, ordered.length - maxRows) : 0,
1081    }
1082    const cols = e.props.bodyColumns || 100
1083    const ctx = u.ctxPercent
1084
1085    const elapsed = t.isRunning ? fmtTime(at - t.startedAt) : t.lastSeconds ? fmtTime(t.lastSeconds * 1000) : ''
1086    const plural = (n: number, w: string): string => `${n} ${w}${n === 1 ? '' : 's'}`
1087    const state = t.isRunning ? 'Working' : 'Idle'
1088    const detail = t.isRunning
1089      ? `${elapsed} · ${plural(t.steps, 'step')} · ${plural(t.tools, 'tool')}${t.agents ? ` · ${plural(t.agents, 'agent')}` : ''}`
1090      : t.lastSeconds
1091        ? `last turn ${elapsed} · ${plural(t.steps, 'step')}`
1092        : 'waiting for a prompt'
1093    const status = `${state} · ${detail}`
1094
1095    const dismiss = <Button key="hide" label="✕" plain role="dismiss" onPress={() => update($, isHidden, () => true)} />
1096
1097    if ('Svg' in ui) {
1098      const { Svg } = ui
1099      const W = Math.max(300, Math.min(1100, cols * 8 - 56))
1100      const gauges: Gauge[] = [
1101        {
1102          label: 'CONTEXT',
1103          pct: ctx,
1104          caption: u.ctxTokens !== null && u.ctxWindow ? `${fmtTokens(u.ctxTokens)} of ${fmtTokens(u.ctxWindow)}` : 'no reading',
1105          sheen: true,
1106        },
1107        ...u.limits.map(
1108          (l): Gauge => ({
1109            label: LIMIT_LABEL[l.kind] ?? l.kind.toUpperCase(),
1110            pct: l.pct,
1111            caption: l.resetsAt ? `resets in ${fmtReset(l.resetsAt, at)}` : 'resets unknown',
1112            sheen: false,
1113          }),
1114        ),
1115      ]
1116      const { svg, H } = cardSvg({
1117        W,
1118        stack: W < 520,
1119        name: m ? modelName(m) : 'Claude',
1120        fx,
1121        cost: u.costUsd !== null ? fmtCost(u.costUsd) : '',
1122        state,
1123        detail,
1124        isRunning: t.isRunning,
1125        gauges,
1126        task: tv,
1127        agents: inline,
1128        sessions: sessionSummary,
1129        nowMs: await $.clock.now(),
1130      })
1131      const alt =
1132        `${m ? modelName(m) : 'Claude'}${fx ? `, effort ${fx}` : ''}; ${status}; ` +
1133        (tv ? `task ${tv.title} ${tv.done} of ${tv.total}, ${Math.round((tv.done / Math.max(1, tv.total)) * 100)}%; ` : '') +
1134        gauges.map(g => `${g.label} ${g.pct === null ? 'no reading' : Math.round(g.pct) + '%'}`).join(', ')
1135      return (
1136        <Box flexDirection="row" alignItems="center" gap={1}>
1137          <Svg source={svg} alt={alt} width={W} height={H} />
1138          {crewCount > 0 ? <Button key="agents" label={`agents ×${crewCount}`} plain onPress={() => void togglePane($)} /> : null}
1139          {dismiss}
1140        </Box>
1141      )
1142    }
1143
1144    // Terminal: the same figures in text rows.
1145    const w = cols < 70 ? 6 : 10
1146    return (
1147      <Box flexDirection="column">
1148        <Box flexDirection="row" gap={2}>
1149          <Text bold color={BLUE}>
1150            {t.isRunning ? '◉' : '○'} {m ? modelName(m) : '...'}
1151            {' · '}
1152            {fx || 'effort ?'}
1153          </Text>
1154          {ctx !== null ? (
1155            <Text>
1156              <Text dimColor>ctx </Text>
1157              <Text color={tone(ctx)}>{bar(ctx, w)}</Text>
1158              <Text bold> {ctx}%</Text>
1159              {u.ctxTokens !== null && u.ctxWindow ? (
1160                <Text dimColor>
1161                  {' '}
1162                  {fmtTokens(u.ctxTokens)}/{fmtTokens(u.ctxWindow)}
1163                </Text>
1164              ) : null}
1165            </Text>
1166          ) : (
1167            <Text dimColor>ctx --</Text>
1168          )}
1169          {u.costUsd !== null ? <Text dimColor>{fmtCost(u.costUsd)}</Text> : null}
1170          {dismiss}
1171        </Box>
1172        <Box flexDirection="row" gap={2}>
1173          {u.limits.length === 0 ? (
1174            <Text dimColor>limits: no reading yet</Text>
1175          ) : (
1176            u.limits.map(l => (
1177              <Text key={l.kind}>
1178                <Text dimColor>{(LIMIT_LABEL[l.kind] ?? l.kind).toLowerCase()} </Text>
1179                <Text color={tone(l.pct)}>{bar(l.pct, w)}</Text>
1180                <Text bold> {Math.round(l.pct)}%</Text>
1181                {l.resetsAt ? <Text dimColor> ↻{fmtReset(l.resetsAt, at)}</Text> : null}
1182              </Text>
1183            ))
1184          )}
1185          <Text color={t.isRunning ? AMBER : undefined} dimColor={!t.isRunning}>
1186            {status}
1187          </Text>
1188        </Box>
1189        {tv ? (
1190          <Box flexDirection="row" gap={2}>
1191            <Text dimColor wrap="truncate-end">
1192              task {tv.title}
1193              {tv.current && !tv.isFinished ? ` › ${tv.current}` : ''}
1194            </Text>
1195            <Text color={tv.isFinished ? GREEN : AMBER}>{bar((tv.done / Math.max(1, tv.total)) * 100, w * 2)}</Text>
1196            <Text bold>
1197              {tv.done}/{tv.total} · {Math.round((tv.done / Math.max(1, tv.total)) * 100)}%
1198            </Text>
1199          </Box>
1200        ) : null}
types/index.d.ts 85 lines
1export type Limit = { kind: string; pct: number; resetsAt?: string }
2
3export type Usage = {
4  ctxPercent: number | null
5  ctxTokens: number | null
6  ctxWindow: number
7  limits: Limit[]
8  costUsd: number | null
9}
10
11export type Turn = {
12  isRunning: boolean
13  startedAt: number
14  steps: number
15  tools: number
16  lastSeconds: number
17  agents: number
18}
19
20// The task being worked on, as reported through the `task` tool.
21export type Task = {
22  title: string
23  total: number
24  done: number
25  current: string
26  isFinished: boolean
27}
28
29// Subagents launched / finished since the turn began: the automatic fallback.
30export type Crew = { spawned: number; done: number }
31
32// One subagent, from agent.spawn to its last turn.
33export type Run = {
34  id: string
35  agentId?: string
36  type: string
37  description: string
38  model: string
39  effort?: string
40  status: 'running' | 'done' | 'failed'
41  startedAt: number
42  endedAt?: number
43  contextTokens: number
44  contextMax: number
45  tokens: number
46  steps: number
47}
48
49export type Panel = { isCompact: boolean; isDoneCollapsed: boolean }
50
51// One Claude Code session on this machine, from its entry in ~/.claude/sessions.
52export type SessionRow = {
53  id: string
54  pid: number
55  name: string
56  project: string
57  entry: string
58  status: 'busy' | 'idle'
59  updatedAt: number
60  startedAt: number
61  isSelf: boolean
62  // The task that session reports (see the `task` tool); absent when it reports none.
63  task?: { title: string; done: number; total: number; current: string; isFinished: boolean }
64}
65
66declare module 'claude-code' {
67  interface PluginState {
68    'live-status': {
69      usage: Usage
70      model: string
71      effort: string
72      turn: Turn
73      task: Task | null
74      crew: Crew
75      runs: Run[]
76      sessions: SessionRow[]
77      panel: Panel
78      now: number
79      slow: number
80      isHidden: boolean
81      warned: string
82    }
83  }
84}
85