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

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.
Idle, light and dark follow your system theme:
A narrow window stacks the card and lists subagents inline, so nothing is lost when there is no room for the panel:
The agents panel (/monitor, or /agents-live):
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.
| Part | Shows | Updates |
|---|---|---|
| Model chip | Model name and effort (Opus 5.5 · high) | Every model request |
| Context | Fill, tokens used of the window, HIGH from 60%, CRITICAL from 85% | As the engine reports it, and every 5 s mid-turn |
| 5H / 7D limits | Usage of each window and when it resets | As the engine reports it; countdowns refresh every 15 s when idle |
| Cost | Session cost so far | As the engine reports it |
| Status | WORKING / IDLE, elapsed time, steps, tool calls, running agents | Every second while a turn runs |
| Task bar | Progress of the task being worked on, 3/5 · 60%, segmented per step | When the task is reported (see below) |
| Agents | One row per subagent, inline when there is no room for the panel | Live |
| Agents panel | Summary tiles plus a card per subagent: status, type, model, effort, time, tokens, steps, context bar | Live |
| Sessions | Every 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 working | Re-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.
| Command | Does |
|---|---|
/monitor | Shows or hides everything: the band above the prompt and the agents panel together |
/monitor on · /monitor off | Forces it on or off |
/agents-live | Toggles only the agents panel |
The agents panel opens by itself at session start, and the band is on from the first moment.
The side panel lists every live session on your machine, so you can keep an eye on a long run in another window:
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.
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.
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:
TodoWrite call passes through unchanged, so nothing extra is sent or returned.task-style tool whose definition would ride along on every request./monitor, /agents-live) answer with a few words, shown once in your transcript.| Window | What changes |
|---|---|
| Wide | One row of model, three gauges and the task bar; the agents panel docks beside the transcript |
| Under 520 px | The 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 |
| Terminal | The 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.
The mod is built to stay out of the way:
prefers-reduced-motion.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.
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.
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.
MIT, see the repository LICENSE.
hooks/register.tsx 1220 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 lines1export 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