A live sidebar for long sessions: usage limits, context, agents, background shells and progress at a glance

A live sidebar for long Claude Code sessions. Usage limits, context, agents, MCP servers, skills, background shells and progress, all in one small pane next to your transcript. It changes none of Claude Code's own behaviour.
<img src="assets/overview.png" alt="session-radar docked beside a real Claude Code session with four agents and two background shells" width="100%">
/plugin marketplace add ibrahimkobeissy/claude-mods
/plugin install session-radar@claude-mods
It opens by itself when a session starts, wherever it fits as a sidebar. /radar opens or closes it at any time.
Each part of the session gets its own colour-coded box:
| Box | What's in it |
|---|---|
| ⚠ Needs you (yellow) | Shown while a permission prompt, a question or a plan approval is waiting, with who it's from and for how long |
| Main (orange) | Model, effort, context gauge (turns yellow, then red with "compaction soon"), 5-hour and 7-day limits with reset times, when the 5h window fills at your current pace, the folder the session runs in, and your git branch or worktree |
| Agents (lavender) | Up to four cards, one per subagent: task, type, context, tool calls, errors, branch or worktree, running time, and the MCP server or skill it is using right now. Select a card to see its last message |
| MCP (sky) | Every connected MCP server with its tool count, in two rows (+ N more shows the rest). A server lights up while one of its tools is being called, with the tool and who called it |
| Skills (pink) | The skills in use right now, by main or by agent. A skill counts as in use from when it is invoked until that agent's turn ends, so when Claude is idle nothing is lit. + N available lists every skill, the ones in use highlighted |
| Background (green) | The background shells and monitors the footer counts (1 shell · 4 monitors), running ones first, each with its process ID (teal), including those subagents started, plus scheduled cron jobs and /loop wakeups. Select one to open its live output, exit code and test result |
| Progress (purple) | The task list and the approved plan, shown only when there is one |
You also get a toast when a background shell or agent finishes or fails.
<img src="assets/shell.png" alt="A background shell's live output in the radar pane" width="100%">
You get the last lines of output, refreshed every 2 seconds. Copy puts them on your clipboard. Stop calls TaskStop, which asks for permission like any other tool call.
The radar never takes over your keyboard. While you type at the prompt, every Claude Code shortcut works as it always does.
| Key | Where | Does |
|---|---|---|
ctrl+x tab | prompt | Moves the keyboard into the pane (press it again if another plugin's band is showing) |
tab / shift+tab | pane | Next / previous row |
enter | pane | Open a shell, show an agent's last message, fold or unfold a box |
↑ ↓ | pane | Scroll |
b / c / s | shell view | Back / copy / stop |
esc | pane | Back to the prompt |
You can also click rows.
| Option | Default | |
|---|---|---|
autoOpen | true | Open the radar when a session starts. When opened this way it only appears where it fits as a sidebar (144+ columns); in a narrower terminal, type /radar |
Set it from /plugin → session-radar → configure.
It reads your session's own events and runs git rev-parse locally to find branches. While a background shell runs it reads the last line of its output file (where Claude Code writes [exited with code N]) and runs ps to find its process ID. No network access, and nothing is written to disk.
claude --plugin-dir mods/session-radar # try it live; saves hot-reload
scripts/validate.sh mods/session-radar # validate + run tests/hooks/register.tsx 1062 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentRow, AgentStatusName, Catalog, Cron, InUse, Main, ProgressItem, ShellRow, Usage, View } from '../types'
5import {
6 addSamples, clip, clock, duration, effortMeter, exitTrailer, findPid, gauge, modelName, shortPath, stopwatch, exitCodeOf, forecast,
7 lastLine, limitLabel, mcpParts, outputPathOf, pack, parentDir, parsePs, parseTails, parseTaskNotice, resetLabel, tail, testSummary, tokens,
8} from './lib'
9import type { TaskNotice } from './lib'
10
11const PANE = 'session-radar'
12const WAKEUP = 'wakeup'
13const TICK_MS = 5000
14const OUTPUT_MS = 2000
15// Branches are read with git, so not on every tick.
16const BRANCH_EVERY_TICKS = 6
17const MAX_OUTPUT_LINES = 200
18const MAX_CARDS = 4
19// The MCP and skill lists show this many rows until unrolled.
20const LIST_ROWS = 2
21const ACTIVE_ROWS = 3
22const C = {
23 main: '#d97757', agents: '#a5b4fc', shells: '#4ade80', progress: '#c084fc',
24 warn: '#facc15', bad: '#f87171', branch: '#7ab7ff', mcp: '#38bdf8', skills: '#f472b6', pid: '#5eead4',
25} as const
26const FINISHED_SHELLS_SHOWN = 3
27// Beyond this the newest live tasks show and the rest are counted.
28const LIVE_SHELLS_SHOWN = 8
29const ENDED: ReadonlySet<AgentStatusName> = new Set(['completed', 'failed', 'killed'])
30const WAITING_NOTIFICATIONS = new Set(['permission_prompt', 'elicitation_dialog'])
31
32const usage = atom({ plugin: 'session-radar', key: 'usage' } as const, null)
33const samples = atom({ plugin: 'session-radar', key: 'samples' } as const, [])
34const main = atom({ plugin: 'session-radar', key: 'main' } as const, { isWorktree: false })
35const agents = atom({ plugin: 'session-radar', key: 'agents' } as const, {})
36const shells = atom({ plugin: 'session-radar', key: 'shells' } as const, {})
37const crons = atom({ plugin: 'session-radar', key: 'crons' } as const, [])
38const progress = atom({ plugin: 'session-radar', key: 'progress' } as const, { todos: [], tasks: [] })
39const needs = atom({ plugin: 'session-radar', key: 'needs' } as const, null)
40const view = atom({ plugin: 'session-radar', key: 'view' } as const, { screen: 'overview', folded: [] })
41const output = atom({ plugin: 'session-radar', key: 'output' } as const, null)
42const tick = atom({ plugin: 'session-radar', key: 'tick' } as const, 0)
43const catalog = atom({ plugin: 'session-radar', key: 'catalog' } as const, { servers: [], toolServer: {}, skills: [] })
44const inUse = atom({ plugin: 'session-radar', key: 'inUse' } as const, {})
45
46let ticker: Timer | undefined
47let outputTimer: Timer | undefined
48let ticks = 0
49// The radar's own version, from its plugin.json; '' when it cannot be read.
50let version: string | undefined
51// The engine's own process, the parent of the shells it starts; null when it cannot be read.
52let enginePid: number | null | undefined
53// Agent tool calls that asked for a worktree, until agent.spawn names the agent.
54const worktreeCalls = new Set<string>()
55const branchOf = new Map<string, { branch?: string; isWorktree: boolean }>()
56// Skill tool calls in flight, by skill name: their skill.prompt is already counted.
57const skillCalls = new Map<string, number>()
58
59// ── reading the world ────────────────────────────────────────────────────────
60
61async function readBranch($: EngineInterface, cwd: string) {
62 // Absolute paths: from a subfolder git prints the git dir absolute and the
63 // common dir relative, which would read as a worktree. Git before 2.31 lacks
64 // the flag, so the branch alone is asked for then.
65 const git = (args: string[]) => $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 3000 }).catch(() => undefined)
66 const ran = await git(['rev-parse', '--abbrev-ref', 'HEAD', '--path-format=absolute', '--git-dir', '--git-common-dir'])
67 const out = ran?.exitCode === 0 ? ran : await git(['rev-parse', '--abbrev-ref', 'HEAD'])
68 if (!out || out.exitCode !== 0) return { isWorktree: false }
69 const [head, gitDir, commonDir] = out.stdout.trim().split('\n')
70 const found = { branch: head === 'HEAD' ? 'detached' : head, isWorktree: Boolean(gitDir && commonDir && gitDir !== commonDir) }
71 branchOf.set(cwd, found)
72 return found
73}
74
75async function refreshBranches($: EngineInterface) {
76 const cwd = await $.session.cwd()
77 const found = await readBranch($, cwd)
78 await update($, main, (m): Main => ({ ...m, cwd, ...found }))
79 const list = Object.values(await read($, agents)).filter(a => a.cwd && !ENDED.has(a.status))
80 for (const a of list) {
81 const b = await readBranch($, a.cwd!)
82 await update($, agents, all => (all[a.id] ? { ...all, [a.id]: { ...all[a.id]!, ...b } } : all))
83 }
84}
85
86async function saveUsage($: EngineInterface, u: Omit<Usage, 'startedAt'>) {
87 const now = await $.clock.now()
88 await update($, usage, (old): Usage => ({ startedAt: old?.startedAt ?? now, ...u }))
89 await update($, samples, s => addSamples(s, u.rateLimits, now))
90}
91
92// The servers and skills the context lists, estimated locally (no API call).
93async function refreshCatalog($: EngineInterface) {
94 const b = (await $.session.usage({ breakdown: 'summary' }).catch(() => undefined))?.context.breakdown
95 if (!b) return
96 const count = new Map<string, number>()
97 const toolServer: Record<string, string> = {}
98 for (const t of b.mcpTools) {
99 count.set(t.serverName, (count.get(t.serverName) ?? 0) + 1)
100 toolServer[t.name] = t.serverName
101 }
102 const servers = [...count].map(([name, tools]) => ({ name, tools })).sort((x, y) => x.name.localeCompare(y.name))
103 const skills = (b.skills?.skillFrontmatter ?? []).map(s => s.name).sort()
104 await update($, catalog, (): Catalog => ({ servers, toolServer, skills }))
105}
106
107async function startUse($: EngineInterface, use: Omit<InUse, 'startedAt'>) {
108 const startedAt = await $.clock.now()
109 await update($, inUse, all => ({ ...all, [use.id]: { ...use, startedAt } }))
110}
111
112function endUse($: EngineInterface, keep: (x: InUse) => boolean) {
113 return update($, inUse, all => {
114 const gone = Object.values(all).filter(x => !keep(x))
115 if (gone.length === 0) return all
116 const next = { ...all }
117 for (const x of gone) delete next[x.id]
118 return next
119 })
120}
121
122// A loop's turn is over: what it used is no longer in use.
123function endTurn($: EngineInterface, agentId: string | undefined) {
124 return endUse($, x => x.agentId !== agentId)
125}
126
127async function pollAgents($: EngineInterface) {
128 const list = await $.agent.list().catch(() => [])
129 const now = await $.clock.now()
130 const before = await read($, agents)
131 await update($, agents, all => {
132 let next = all
133 for (const info of list) {
134 const row = next[info.id]
135 const status = info.status as AgentStatusName
136 if (!row) {
137 next = {
138 ...next,
139 [info.id]: {
140 id: info.id, type: info.type, description: info.description, status, startedAt: now,
141 tools: 0, errors: 0, isWorktree: false, endedAt: ENDED.has(status) ? now : undefined,
142 },
143 }
144 } else if (row.status !== status) {
145 next = { ...next, [info.id]: { ...row, status, endedAt: ENDED.has(status) ? (row.endedAt ?? now) : undefined } }
146 }
147 }
148 return next
149 })
150 for (const info of list) {
151 const was = before[info.id]
152 if (was && !ENDED.has(was.status) && ENDED.has(info.status as AgentStatusName)) {
153 await announceAgentEnd($, was, info.status as AgentStatusName, now)
154 }
155 }
156}
157
158async function announceAgentEnd($: EngineInterface, agent: AgentRow, status: AgentStatusName, now: number) {
159 const word = status === 'completed' ? 'completed' : status
160 $.ui.toast(`${status === 'completed' ? '✓' : '✗'} agent ${word}: ${clip(agent.description, 40)}`)
161 await endTurn($, agent.id)
162 // The engine stops a foreground subagent's background shells with it.
163 await update($, shells, all => {
164 let next = all
165 for (const x of Object.values(all)) {
166 if (x.agentId === agent.id && x.endsWithAgent && x.status === 'running') {
167 next = { ...next, [x.id]: { ...x, status: 'completed', endedAt: now, summary: 'ended with its agent' } }
168 }
169 }
170 return next
171 })
172}
173
174// A Stop names every task still in flight, the list the footer counts from: a
175// row it leaves out ended without a notice reaching us (a monitor's timeout, a
176// task stopped with its agent), so it is marked done.
177async function reconcileShells($: EngineInterface, inFlight: readonly { id: string }[] | undefined) {
178 if (!inFlight) return
179 const live = new Set(inFlight.map(t => t.id))
180 const now = await $.clock.now()
181 await update($, shells, all => {
182 let next = all
183 for (const x of Object.values(all)) {
184 if (x.status === 'running' && !live.has(x.id)) next = { ...next, [x.id]: { ...x, status: 'completed', endedAt: now, summary: x.summary ?? 'ended' } }
185 }
186 return next
187 })
188}
189
190// A background task ended, however we heard: the row is marked once, with one toast.
191async function finishShell($: EngineInterface, id: string, end: { status: ShellRow['status']; summary: string; exitCode?: number; endedAt: number; outputPath?: string }) {
192 const shell = (await read($, shells))[id]
193 if (!shell || shell.status !== 'running') return
194 await patchShell($, id, s => ({ ...s, ...end, outputPath: s.outputPath ?? end.outputPath }))
195 const code = end.exitCode ?? exitCodeOf(end.summary)
196 $.ui.toast(`${end.status === 'completed' ? '✓' : '✗'} ${shell.kind} ${end.status}: ${clip(shell.command, 40)}${code !== undefined ? ` (exit ${code})` : ''}`)
197}
198
199async function readEnginePid($: EngineInterface) {
200 const ran = await $.process.run(['sh', '-c', 'echo $PPID'], { timeoutMs: 3000 }).catch(() => undefined)
201 const pid = Number(ran?.stdout.trim())
202 return ran?.exitCode === 0 && pid > 1 ? pid : null
203}
204
205// The engine's word on a live task, read from the task itself rather than
206// waited for: its output file's last line once it exits, and its process id.
207async function checkShells($: EngineInterface, only?: string) {
208 const live = Object.values(await read($, shells)).filter(s => s.status === 'running' && (only === undefined || s.id === only))
209 const files = live.flatMap(s => (s.outputPath ? [s.outputPath] : []))
210 if (files.length) {
211 const ran = await $.process.run(['tail', '-n', '1', '--', ...files], { timeoutMs: 3000 }).catch(() => undefined)
212 const last = parseTails(ran?.stdout ?? '', files)
213 for (const s of live) {
214 const code = exitTrailer(s.outputPath ? last.get(s.outputPath) : undefined)
215 if (code === undefined) continue
216 const endedAt = (await $.fs.stat(s.outputPath!).catch(() => undefined))?.mtimeMs || (await $.clock.now())
217 await finishShell($, s.id, { status: code === 0 ? 'completed' : 'failed', summary: `exit code ${code}`, exitCode: code, endedAt })
218 }
219 }
220 const unknown = live.filter(s => s.pid === undefined && s.command)
221 if (unknown.length === 0) return
222 enginePid ??= await readEnginePid($)
223 const ps = await $.process.run(['ps', '-A', '-ww', '-o', 'pid=,ppid=,args='], { timeoutMs: 3000 }).catch(() => undefined)
224 if (!ps || ps.exitCode !== 0) return
225 const procs = parsePs(ps.stdout)
226 const taken = new Set(Object.values(await read($, shells)).flatMap(s => (s.pid ? [s.pid] : [])))
227 for (const s of unknown) {
228 const pid = findPid(procs, s.command, enginePid ?? undefined, taken)
229 if (pid === undefined) continue
230 taken.add(pid)
231 await patchShell($, s.id, x => ({ ...x, pid }))
232 }
233}
234
235// "1 shell · 4 monitors", the way the footer counts what runs in the background.
236function backgroundCount(live: readonly ShellRow[]) {
237 const shells = live.filter(x => x.kind === 'shell').length
238 const others = live.length - shells
239 const parts = [shells ? `${shells} shell${shells === 1 ? '' : 's'}` : '', others ? `${others} monitor${others === 1 ? '' : 's'}` : '']
240 return parts.filter(Boolean).join(' · ')
241}
242
243async function onTick($: EngineInterface) {
244 ticks += 1
245 await update($, tick, () => ticks)
246 await pollAgents($)
247 await checkShells($)
248 if (ticks % BRANCH_EVERY_TICKS === 0) {
249 await refreshBranches($)
250 await refreshCatalog($)
251 }
252}
253
254async function readOutput($: EngineInterface, shell: ShellRow, count: number) {
255 if (!shell.outputPath) return { lines: [], error: 'No output file for this task.' }
256 try {
257 const stat = await $.fs.stat(shell.outputPath)
258 if (stat.size > 2 * 1024 * 1024) {
259 const ran = await $.process.run(['tail', '-n', String(count), shell.outputPath], { timeoutMs: 3000 })
260 return { lines: tail(ran.stdout, count) }
261 }
262 return { lines: tail(await $.fs.read(shell.outputPath), count) }
263 } catch {
264 return { lines: [], error: 'The output file is gone.' }
265 }
266}
267
268async function refreshOutput($: EngineInterface) {
269 const v = await read($, view)
270 const shell = v.shellId ? (await read($, shells))[v.shellId] : undefined
271 if (v.screen !== 'shell' || !shell) return
272 if (shell.status === 'running') await checkShells($, shell.id)
273 const got = await readOutput($, shell, MAX_OUTPUT_LINES)
274 const at = await $.clock.now()
275 await update($, output, () => ({ shellId: shell.id, at, ...got }))
276}
277
278async function openShell($: EngineInterface, id: string) {
279 await update($, output, () => null)
280 await update($, view, (v): View => ({ ...v, screen: 'shell', shellId: id }))
281 outputTimer?.cancel()
282 outputTimer = $.clock.every(OUTPUT_MS, () => void refreshOutput($))
283 await refreshOutput($)
284}
285
286async function back($: EngineInterface) {
287 outputTimer?.cancel()
288 outputTimer = undefined
289 await update($, view, (v): View => ({ ...v, screen: 'overview', shellId: undefined }))
290}
291
292async function setNeeds($: EngineInterface, agentId: string | undefined, what: string) {
293 const who = agentId ? ((await read($, agents))[agentId]?.type ?? 'agent') : 'main'
294 const since = await $.clock.now()
295 await update($, needs, () => ({ who, agentId, what, since }))
296}
297
298async function clearNeeds($: EngineInterface, agentId?: string | null) {
299 await update($, needs, n => (n && (agentId === null || n.agentId === agentId) ? null : n))
300}
301
302function patchAgent($: EngineInterface, id: string | undefined, fn: (a: AgentRow) => AgentRow) {
303 if (!id) return Promise.resolve()
304 return update($, agents, all => (all[id] ? { ...all, [id]: fn(all[id]!) } : all))
305}
306
307function patchShell($: EngineInterface, id: string, fn: (s: ShellRow) => ShellRow) {
308 return update($, shells, all => (all[id] ? { ...all, [id]: fn(all[id]!) } : all))
309}
310
311async function togglePane($: EngineInterface) {
312 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
313 if (isOpen) {
314 await $.ui.close({ id: PANE })
315 return 'Radar closed.'
316 }
317 await $.ui.open({ id: PANE, title: 'Radar' })
318 return 'Radar opened. ctrl+x tab moves the keyboard into it; Esc hands it back.'
319}
320
321async function applyNotices($: EngineInterface, text: string) {
322 for (const part of text.split('</task-notification>')) {
323 const notice = parseTaskNotice(part)
324 if (notice) await applyNotice($, notice)
325 }
326}
327
328async function applyNotice($: EngineInterface, notice: TaskNotice) {
329 const now = await $.clock.now()
330 const shell = (await read($, shells))[notice.id]
331 const agent = (await read($, agents))[notice.id]
332 if (shell && shell.status === 'running') {
333 const code = exitCodeOf(notice.summary)
334 await finishShell($, notice.id, {
335 status: notice.status, summary: notice.summary, endedAt: now, outputPath: notice.outputPath, exitCode: code === undefined ? undefined : Number(code),
336 })
337 } else if (agent && !ENDED.has(agent.status)) {
338 const status: AgentStatusName = notice.status
339 await patchAgent($, notice.id, a => ({ ...a, status, endedAt: now }))
340 await announceAgentEnd($, agent, status, now)
341 }
342}
343
344async function readVersion($: EngineInterface) {
345 const text = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '')
346 try {
347 const v = (JSON.parse(text) as { version?: unknown }).version
348 return typeof v === 'string' ? v : ''
349 } catch {
350 return ''
351 }
352}
353
354// ── hooks ────────────────────────────────────────────────────────────────────
355
356export const register: Register = (on, options) => {
357 on('session.start', async ($, e, next) => {
358 const result = await next(e)
359 await $.command.register({ name: 'radar', description: 'Toggle the session radar sidebar' })
360 const u = await $.session.usage().catch(() => undefined)
361 if (u) {
362 await update($, usage, (): Usage => ({
363 startedAt: u.startedAt, contextTokens: u.context.tokens, contextWindow: u.context.window,
364 contextPercent: u.context.percent, rateLimits: u.rateLimits,
365 }))
366 }
367 const model = await $.session.model().catch(() => undefined)
368 await update($, main, (m): Main => ({ ...m, model: m.model ?? model }))
369 ticker?.cancel()
370 ticker = $.clock.every(TICK_MS, () => void onTick($))
371 const cwd = await $.session.cwd().catch(() => undefined)
372 await update($, main, (m): Main => ({ ...m, cwd: m.cwd ?? cwd }))
373 await refreshBranches($)
374 await refreshCatalog($)
375 if (options.autoOpen === true) void $.ui.open({ id: PANE, title: 'Radar' })
376 return result
377 })
378
379 on('command.run', { command: 'radar' }, async $ => ({ text: await togglePane($) }))
380
381 on('ui.close', async ($, e, next) => {
382 if (e.id === PANE) await back($)
383 return next(e)
384 })
385
386 on('session.measure', async ($, e, next) => {
387 const result = await next(e)
388 await saveUsage($, {
389 contextTokens: e.context.tokens, contextWindow: e.context.window, contextPercent: e.context.percent,
390 rateLimits: e.rateLimits,
391 })
392 return result
393 })
394
395 // Each model request: the main loop's model and effort, an agent's context and words.
396 on('turn.step', async function* ($, e, next) {
397 const result = yield* next(e)
398 if (e.agentId === undefined) {
399 const effort = e.effort === undefined ? undefined : String(e.effort)
400 await update($, main, (m): Main => ({ ...m, model: e.model, effort }))
401 } else {
402 const u = result.usage
403 const said = lastLine(result.answer)
404 await patchAgent($, e.agentId, a => ({
405 ...a,
406 model: a.model ?? e.model,
407 contextTokens: u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : a.contextTokens,
408 lastMessage: said || a.lastMessage,
409 }))
410 }
411 return result
412 })
413
414 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
415 if (e.isolation === 'worktree') worktreeCalls.add(e.tool_use_id)
416 return next(e)
417 })
418
419 on('agent.spawn', async ($, e, next) => {
420 const result = await next(e)
421 if (result.agentId) {
422 const id = result.agentId
423 const startedAt = await $.clock.now()
424 const isWorktree = worktreeCalls.delete(e.tool_use_id)
425 await update($, agents, (all): Record<string, AgentRow> => ({
426 ...all,
427 [id]: {
428 id, type: e.subagentType, description: e.description, status: 'running', model: result.model,
429 startedAt, tools: all[id]?.tools ?? 0, errors: 0, isWorktree, cwd: e.cwd,
430 },
431 }))
432 }
433 return result
434 })
435
436 // Every tool call: counts per agent, a question or a dialog answered, and an
437 // MCP call in use from its start until it returns.
438 on('tool.call', async ($, e, next) => {
439 if (e.agentId) await patchAgent($, e.agentId, a => ({ ...a, tools: a.tools + 1 }))
440 if (e.tool === 'AskUserQuestion') await setNeeds($, e.agentId, 'a question for you')
441 if (e.tool === 'ExitPlanMode') await setNeeds($, e.agentId, 'plan approval')
442 const mcp = mcpParts(e.tool)
443 if (mcp) {
444 const known = (await read($, catalog)).toolServer[e.tool]
445 await startUse($, { id: e.tool_use_id, kind: 'mcp', name: known ?? mcp.server, tool: mcp.tool, agentId: e.agentId })
446 }
447 let ran: Awaited<ReturnType<typeof next>>
448 try {
449 ran = await next(e)
450 } finally {
451 if (mcp) await endUse($, x => x.id !== e.tool_use_id)
452 }
453 if (e.agentId && ran.isError === true) await patchAgent($, e.agentId, a => ({ ...a, errors: a.errors + 1 }))
454 await clearNeeds($, e.agentId)
455 return ran
456 })
457
458 // A skill is in use from its call until the turn of the loop that called it ends.
459 on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
460 skillCalls.set(e.skill, (skillCalls.get(e.skill) ?? 0) + 1)
461 try {
462 const ran = await next(e)
463 if (ran.deny === undefined && ran.isError !== true) await startUse($, { id: e.tool_use_id, kind: 'skill', name: e.skill, agentId: e.agentId })
464 return ran
465 } finally {
466 const left = (skillCalls.get(e.skill) ?? 1) - 1
467 if (left > 0) skillCalls.set(e.skill, left)
468 else skillCalls.delete(e.skill)
469 }
470 })
471
472 // A skill typed as /name expands with no Skill tool call: it runs in the main loop.
473 on('skill.prompt', async ($, e, next) => {
474 const result = await next(e)
475 if (!skillCalls.has(e.skill)) await startUse($, { id: `typed-${e.skill}`, kind: 'skill', name: e.skill })
476 return result
477 })
478
479 // A background shell: Bash with run_in_background, or one the person backgrounded.
480 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
481 const ran = await next(e)
482 const result = ran.deny === undefined && ran.isError !== true ? ran.result : undefined
483 const id = result?.backgroundTaskId
484 if (id) {
485 const known = Object.values(await read($, shells)).find(s => s.outputPath)?.outputPath
486 const outputPath = outputPathOf(ran.text) ?? (known ? `${parentDir(known)}/${id}.output` : undefined)
487 const startedAt = await $.clock.now()
488 const endsWithAgent = result.backgroundEndsWithFinalResponse === true
489 await update($, shells, (all): Record<string, ShellRow> => ({
490 ...all,
491 [id]: { id, kind: 'shell', command: e.command, description: e.description, startedAt, status: 'running', outputPath, agentId: e.agentId, endsWithAgent },
492 }))
493 }
494 return ran
495 })
496
497 // A monitor: a background watch the footer counts beside the shells.
498 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
499 const ran = await next(e)
500 const id = ran.deny === undefined && ran.isError !== true ? ran.result?.taskId : undefined
501 if (id) {
502 const startedAt = await $.clock.now()
503 const command = e.command ?? e.ws?.url ?? e.description
504 await update($, shells, (all): Record<string, ShellRow> => ({
505 ...all,
506 [id]: { id, kind: 'monitor', command, description: e.description, startedAt, status: 'running', outputPath: outputPathOf(ran.text), agentId: e.agentId },
507 }))
508 }
509 return ran
510 })
511
512 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
513 const ran = await next(e)
514 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
515 const todos = e.todos.map((t, i): ProgressItem => ({
516 id: String(i), text: t.status === 'in_progress' ? t.activeForm || t.content : t.content, status: t.status,
517 }))
518 await update($, progress, p => ({ ...p, todos }))
519 }
520 return ran
521 })
522
523 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
524 const ran = await next(e)
525 const task = ran.deny === undefined && ran.isError !== true ? ran.result?.task : undefined
526 if (e.agentId === undefined && task) {
527 const item: ProgressItem = { id: task.id, text: task.subject, status: 'pending' }
528 await update($, progress, p => ({ ...p, tasks: [...p.tasks, item] }))
529 }
530 return ran
531 })
532
533 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
534 const ran = await next(e)
535 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
536 const status = e.status
537 await update($, progress, p => ({
538 ...p,
539 tasks: status === 'deleted'
540 ? p.tasks.filter(t => t.id !== e.taskId)
541 : p.tasks.map((t): ProgressItem => (t.id === e.taskId ? { ...t, text: e.subject ?? t.text, status: status ?? t.status } : t)),
542 }))
543 }
544 return ran
545 })
546
547 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
548 const ran = await next(e)
549 const plan = ran.deny === undefined && ran.isError !== true ? ran.result?.plan : undefined
550 if (e.agentId === undefined && plan) {
551 const title = plan.split('\n').map(l => l.replace(/^#+\s*/, '').trim()).find(Boolean)
552 await update($, progress, p => ({ ...p, plan: title }))
553 }
554 return ran
555 })
556
557 // An agent working in a directory of its own (a worktree): its tool calls say where.
558 on('classic.PostToolUse', async ($, e, next) => {
559 const id = e.agent_id
560 if (id) {
561 const row = (await read($, agents))[id]
562 if (row && row.cwd !== e.cwd) {
563 const b = branchOf.get(e.cwd) ?? (await readBranch($, e.cwd))
564 await patchAgent($, id, a => ({ ...a, cwd: e.cwd, ...b, isWorktree: a.isWorktree || b.isWorktree }))
565 }
566 }
567 return next(e)
568 })
569
570 on('classic.Notification', async ($, e, next) => {
571 if (WAITING_NOTIFICATIONS.has(e.notification_type)) await setNeeds($, e.agent_id, e.message || 'permission needed')
572 return next(e)
573 })
574
575 on('prompt.submit', async ($, e, next) => {
576 if (e.origin.kind === 'task-notification') await applyNotices($, e.text)
577 else if (e.origin.kind === 'scheduled-trigger') await update($, crons, list => list.filter(c => c.recurring || (c.id !== WAKEUP && c.prompt.trim() !== e.text.trim())))
578 else {
579 await clearNeeds($, null)
580 // A new prompt: the last turn is over even if it was interrupted before its Stop.
581 await endTurn($, undefined)
582 }
583 return next(e)
584 })
585
586 on('classic.SubagentStop', async ($, e, next) => {
587 if (e.last_assistant_message) {
588 await patchAgent($, e.agent_id, a => ({ ...a, lastMessage: lastLine(e.last_assistant_message ?? '') }))
589 }
590 await reconcileShells($, e.background_tasks)
591 await endTurn($, e.agent_id)
592 return next(e)
593 })
594
595 on('classic.Stop', async ($, e, next) => {
596 await reconcileShells($, e.background_tasks)
597 await endTurn($, undefined)
598 return next(e)
599 })
600
601 // Scheduled work: cron jobs and /loop wakeups the model sets up.
602 on('tool.call', { tool: 'CronCreate' }, async ($, e, next) => {
603 const ran = await next(e)
604 const job = ran.deny === undefined && ran.isError !== true ? ran.result : undefined
605 if (job) {
606 const cron: Cron = { id: job.id, schedule: job.humanSchedule, recurring: job.recurring, prompt: e.prompt }
607 await update($, crons, list => [...list.filter(c => c.id !== job.id), cron])
608 }
609 return ran
610 })
611
612 on('tool.call', { tool: 'CronDelete' }, async ($, e, next) => {
613 const ran = await next(e)
614 if (ran.deny === undefined && ran.isError !== true) await update($, crons, list => list.filter(c => c.id !== e.id))
615 return ran
616 })
617
618 on('tool.call', { tool: 'ScheduleWakeup' }, async ($, e, next) => {
619 const ran = await next(e)
620 if (ran.deny === undefined && ran.isError !== true) {
621 const others = (list: readonly Cron[]) => list.filter(c => c.id !== WAKEUP)
622 if (e.stop || !e.delaySeconds) {
623 await update($, crons, list => others(list))
624 } else {
625 const at = (await $.clock.now()) + Math.min(3600, Math.max(60, e.delaySeconds)) * 1000
626 const cron: Cron = { id: WAKEUP, schedule: `at ${clock(at)}`, recurring: false, prompt: e.reason || e.prompt || 'wakeup', at }
627 await update($, crons, list => [...others(list), cron])
628 }
629 }
630 return ran
631 })
632
633 // A stopped task, by the model or the Stop button.
634 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
635 const ran = await next(e)
636 const id = ran.deny === undefined && ran.isError !== true ? (ran.result?.task_id ?? e.task_id ?? e.shell_id) : undefined
637 if (id) {
638 const now = await $.clock.now()
639 await patchShell($, id, s => (s.status === 'running' ? { ...s, status: 'killed', endedAt: now } : s))
640 await patchAgent($, id, a => (ENDED.has(a.status) ? a : { ...a, status: 'killed', endedAt: now }))
641 }
642 return ran
643 })
644
645 // A background task ended: its notice reaches the session one of three ways.
646 on('session.receive', async ($, e, next) => {
647 if (e.origin.kind === 'task-notification') await applyNotices($, e.text)
648 return next(e)
649 })
650
651 // Notices also arrive mid-turn as queued attachments, and a subagent's own
652 // shells report to that subagent: any row, in any conversation, can carry one.
653 // Only rows that are a notice: a file read or a reply may quote one.
654 on('session.append', async ($, e, next) => {
655 if (e.message.role === 'user' && e.door !== 'tool-result') {
656 const text = e.message.content.map(b => (b.type === 'text' ? b.text : '')).join('\n').trim()
657 if (text.startsWith('<task-notification>')) await applyNotices($, text)
658 }
659 return next(e)
660 })
661
662 // ── drawing ────────────────────────────────────────────────────────────────
663
664 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
665 const { Box, Text, Button } = $.ui.resolve(e)
666 const W = Math.max(28, e.props.bodyColumns)
667 const IN = W - 4
668 const v = await read($, view)
669 await read($, tick)
670 const now = await $.clock.now()
671 version ??= await readVersion($)
672
673 const toggle = (section: string) => () =>
674 void update($, view, x => ({
675 ...x, folded: x.folded.includes(section) ? x.folded.filter(s => s !== section) : [...x.folded, section],
676 }))
677 const isFolded = (section: string) => v.folded.includes(section)
678
679 // A section: a rounded box in the section's color, its title on the first row.
680 const box = (section: string, color: string, title: string, right: JSX.Element | string | null, body: JSX.Element | null) => (
681 <Box key={`box-${section}`} flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={W}>
682 <Box justifyContent="space-between" width={IN}>
683 <Box>
684 <Button key={`fold-${section}`} plain dimColor label={isFolded(section) ? '▸ ' : '▾ '} onPress={toggle(section)} />
685 <Text color={color} bold>{clip(title, IN - 14)}</Text>
686 </Box>
687 {typeof right === 'string' ? <Text dimColor>{right}</Text> : right}
688 </Box>
689 {isFolded(section) ? null : body}
690 </Box>
691 )
692 const hint = (focused: string) => (
693 <Box key="hint" marginTop={1}>
694 <Text dimColor>{clip(e.props.isFocused ? focused : 'ctrl+x tab to use · /radar to close', W)}</Text>
695 </Box>
696 )
697
698 // ── one background task, opened ──
699 if (v.screen === 'shell' && v.shellId) {
700 const shell = (await read($, shells))[v.shellId]
701 const out = await read($, output)
702 if (!shell) return <Box flexDirection="column"><Button key="back" autoFocus plain hotkey="b" label="back" onPress={() => void back($)} /></Box>
703 const room = Math.max(4, (e.viewport?.rows ?? 30) - 14)
704 const all = out?.shellId === shell.id ? out.lines : []
705 const lines = all.slice(-room)
706 // The output's own last line outranks the row: the view never says running under "[exited …]".
707 const trailer = exitTrailer(all.at(-1))
708 const isLive = shell.status === 'running' && trailer === undefined
709 const code = shell.exitCode ?? trailer
710 const status = isLive ? 'running' : shell.status === 'running' ? (trailer === 0 ? 'completed' : 'failed') : shell.status
711 const isGood = status === 'completed'
712 const statusColor = isLive ? C.shells : isGood ? C.shells : C.bad
713 const statusText = isLive ? '● running' : status === 'killed' ? '✗ killed' : `${isGood ? '✓' : '✗'} ${code !== undefined ? `exit ${code}` : status}`
714 const owner = shell.agentId ? ((await read($, agents))[shell.agentId]?.type ?? 'agent') : 'main'
715 const took = stopwatch((shell.endedAt ?? (isLive ? now : (out?.at ?? now))) - shell.startedAt)
716 const result = testSummary(all)
717 const meta = [shell.kind, `by ${owner}`, shell.id].join(' · ')
718 return (
719 <Box flexDirection="column" width={W}>
720 <Box flexDirection="column" borderStyle="round" borderColor={C.shells} paddingX={1} width={W}>
721 <Box justifyContent="space-between" width={IN}>
722 <Box>
723 <Button key="back" autoFocus plain hotkey="b" label="back" onPress={() => void back($)} />
724 <Text color={C.shells} bold>{` ${clip(shell.description || shell.command, IN - 22)}`}</Text>
725 </Box>
726 <Text color={statusColor} bold={!isLive}>{statusText}</Text>
727 </Box>
728 {shell.description ? <Text wrap="truncate-end">{clip(`$ ${shell.command}`, IN)}</Text> : null}
729 <Text wrap="truncate-end">
730 {shell.pid ? <Text color={C.pid}>{`pid ${shell.pid}`}</Text> : null}
731 {shell.pid ? <Text dimColor>{' · '}</Text> : null}
732 <Text>{`${isLive ? 'running' : 'ran'} ${took}`}</Text>
733 <Text dimColor>{clip(` · ${meta}`, Math.max(0, IN - took.length - 8 - (shell.pid ? String(shell.pid).length + 7 : 0)))}</Text>
734 </Text>
735 {result ? <Text color={isLive ? undefined : statusColor} bold wrap="truncate-end">{clip(result, IN)}</Text> : null}
736 {!result && shell.summary && exitCodeOf(shell.summary) === undefined ? <Text dimColor>{clip(shell.summary, IN)}</Text> : null}
737 </Box>
738 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1} width={W}>
739 <Box justifyContent="space-between" width={IN}>
740 <Text dimColor>{`output · last ${lines.length} lines`}</Text>
741 <Text color={isLive ? C.shells : undefined} dimColor={!isLive}>{isLive ? '● live' : 'ended'}</Text>
742 </Box>
743 {out?.error ? <Text dimColor>{out.error}</Text> : null}
744 {!out ? <Text dimColor>reading…</Text> : null}
745 {out && !out.error && lines.length === 0 ? <Text dimColor>no output yet</Text> : null}
746 {lines.map((line, i) => <Text key={`l${i}`} wrap="truncate-end">{line || ' '}</Text>)}
747 </Box>
748 <Box marginTop={1} gap={2}>
749 <Button key="copy" hotkey="c" label="Copy" onPress={press => void $.ui.copy({ text: lines.join('\n'), surface: press.surface })} />
750 {isLive ? <Button key="stop" hotkey="s" label="Stop" onPress={() => void $.tool.call({ tool: 'TaskStop', task_id: shell.id }).catch(() => undefined)} /> : null}
751 </Box>
752 {isLive ? <Text dimColor>{clip('Stop asks for permission like any tool call.', W)}</Text> : null}
753 {hint('tab next · b back · c copy · esc prompt')}
754 </Box>
755 )
756 }
757
758 // ── overview ──
759 const u = await read($, usage)
760 const m = await read($, main)
761 const n = await read($, needs)
762 const hist = await read($, samples)
763 const agentRows = Object.values(await read($, agents))
764 const shellRows = Object.values(await read($, shells))
765 const cronRows = await read($, crons)
766 const prog = await read($, progress)
767 const cat = await read($, catalog)
768 const using = Object.values(await read($, inUse)).sort((a, b) => a.startedAt - b.startedAt)
769
770 const model = m.model ? modelName(m.model) : 'claude'
771 const home = await $.env.get('HOME').catch(() => undefined)
772 const G = Math.max(6, Math.min(12, IN - 26))
773
774 // Main
775 const effort = effortMeter(m.effort)
776 const ctxPct = u?.contextPercent ?? (u?.contextTokens && u.contextWindow ? (u.contextTokens / u.contextWindow) * 100 : undefined)
777 const ctxColor = ctxPct === undefined ? undefined : ctxPct >= 85 ? C.bad : ctxPct >= 65 ? C.warn : C.main
778 const ctx = gauge(ctxPct ?? 0, G)
779 const five = u?.rateLimits.find(l => l.kind === 'five_hour')
780 const fc = five ? forecast(hist, five, now) : undefined
781 const limitLine = (l: { kind: string; percentUsed: number; resetsAt?: string }) => {
782 const g = gauge(l.percentUsed, Math.max(4, G - 4))
783 const color = l.percentUsed >= 90 ? C.bad : l.percentUsed >= 70 ? C.warn : C.main
784 return (
785 <Text key={`lim-${l.kind}`} wrap="truncate-end">
786 <Text dimColor>{`${limitLabel(l.kind).padEnd(3)}`}</Text>
787 <Text color={color}>{g.full}</Text>
788 <Text dimColor>{g.empty}</Text>
789 <Text bold>{` ${String(Math.round(l.percentUsed)).padStart(3)}%`}</Text>
790 <Text dimColor>{` ${resetLabel(l.resetsAt, now)}`}</Text>
791 </Text>
792 )
793 }
794 const mainBody = (
795 <Box flexDirection="column">
796 <Text wrap="truncate-end">
797 <Text dimColor>{'effort '}</Text>
798 {effort ? <Text color={C.main}>{effort.full}</Text> : null}
799 {effort ? <Text dimColor>{effort.empty}</Text> : null}
800 <Text bold>{` ${m.effort ?? '—'}`}</Text>
801 <Text dimColor>{u ? ` session ${duration(now - u.startedAt)}` : ''}</Text>
802 </Text>
803 <Text wrap="truncate-end">
804 <Text dimColor>{'ctx '}</Text>
805 <Text color={ctxColor}>{ctx.full}</Text>
806 <Text dimColor>{ctx.empty}</Text>
807 <Text bold color={ctxColor}>{ctxPct !== undefined ? ` ${Math.round(ctxPct)}%` : ' —'}</Text>
808 <Text dimColor>{u?.contextTokens !== undefined ? ` ${tokens(u.contextTokens)}/${tokens(u.contextWindow)}` : ''}</Text>
809 {ctxPct !== undefined && ctxPct >= 85 ? <Text color={C.bad}>{' compaction soon'}</Text> : null}
810 </Text>
811 {(u?.rateLimits ?? []).map(limitLine)}
812 {fc?.kind === 'full' ? <Text color={C.warn} wrap="truncate-end">{`5h full ≈ ${clock(fc.at)} at this pace`}</Text> : null}
813 {fc?.kind === 'ok' ? <Text color={C.shells} wrap="truncate-end">{'5h lasts until reset at this pace'}</Text> : null}
814 {m.cwd ? (
815 <Text wrap="truncate-end">
816 <Text dimColor>{'dir '}</Text>
817 <Text>{shortPath(m.cwd, home, IN - 7)}</Text>
818 </Text>
819 ) : null}
820 {m.branch ? <Text color={C.branch} wrap="truncate-end">{`⎇ ${m.branch}${m.isWorktree ? ' (worktree)' : ''}`}</Text> : null}
821 </Box>
822 )
823
824 // Agents, as cards
825 const running = agentRows.filter(a => !ENDED.has(a.status)).sort((a, b) => a.startedAt - b.startedAt)
826 const done = agentRows.filter(a => ENDED.has(a.status)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
827 const shown = [...running.slice(-MAX_CARDS), ...done.slice(0, Math.max(0, MAX_CARDS - running.length))]
828 const moreRunning = running.length - Math.min(running.length, MAX_CARDS)
829 const moreDone = done.length - (shown.length - (running.length - moreRunning))
830 // Cards sit inside the agents box, so they share its inner width; four make a 2×2 grid.
831 const cols = IN >= 40 ? 2 : 1
832 const cardW = Math.floor((IN - (cols - 1)) / cols)
833 const cardIn = cardW - 4
834 const card = (a: AgentRow, index: number) => {
835 const isEnded = ENDED.has(a.status)
836 const isBad = a.status === 'failed' || a.status === 'killed'
837 const isOpen = v.expanded === a.id
838 const glyph = isBad ? '✗' : isEnded ? '✓' : a.status === 'waiting' ? '◐' : '◐'
839 const glyphColor = isBad ? C.bad : isEnded ? C.shells : a.status === 'waiting' ? C.warn : C.agents
840 const uses = isEnded ? [] : using.filter(x => x.agentId === a.id)
841 const facts = [a.contextTokens !== undefined ? `ctx ${tokens(a.contextTokens)}` : '', `${a.tools} tools`].filter(Boolean).join(' · ')
842 return (
843 <Box key={`card-${a.id}`} flexDirection="column" borderStyle="round" borderColor={C.agents} borderDimColor={isEnded} paddingX={1} width={cardW}>
844 <Button key={`agent-${a.id}`} plain label={clip(`${index + 1}: ${a.description.trim()}`, cardIn)} onPress={() => void update($, view, x => ({ ...x, expanded: x.expanded === a.id ? undefined : a.id }))} />
845 <Text dimColor wrap="truncate-end">{clip(a.type, cardIn)}</Text>
846 <Text dimColor wrap="truncate-end">
847 {clip(facts, cardIn)}
848 {a.errors > 0 ? <Text color={C.bad}>{` · ${a.errors} err`}</Text> : null}
849 </Text>
850 {uses.length ? (
851 <Text wrap="truncate-end">
852 {uses.map((x, i) => (
853 <Text key={`use-${a.id}-${x.id}`} color={x.kind === 'mcp' ? C.mcp : C.skills}>{`${i ? ' ' : ''}${x.kind === 'mcp' ? '●' : '⚡'} ${x.name}`}</Text>
854 ))}
855 </Text>
856 ) : null}
857 {a.branch || a.isWorktree ? <Text color={C.branch} wrap="truncate-end">{clip(`⎇ ${a.branch ?? 'worktree'}`, cardIn)}</Text> : null}
858 <Text wrap="truncate-end">
859 <Text color={glyphColor}>{`${glyph} `}</Text>
860 <Text dimColor={isEnded}>{stopwatch((a.endedAt ?? now) - a.startedAt)}</Text>
861 {isBad ? <Text color={C.bad}>{` ${a.status}`}</Text> : null}
862 </Text>
863 {isOpen && a.lastMessage ? <Text dimColor wrap="wrap">{clip(`"${a.lastMessage}"`, cardIn * 3)}</Text> : null}
864 {isOpen && a.model ? <Text dimColor wrap="truncate-end">{clip(modelName(a.model), cardIn)}</Text> : null}
865 </Box>
866 )
867 }
868
869 // MCP and skills: who is using one, and the list of what there is.
870 const cardNo = new Map(shown.map((a, i) => [a.id, i + 1]))
871 const owner = (x: InUse) => {
872 if (!x.agentId) return 'main'
873 const no = cardNo.get(x.agentId)
874 return no ? `agent ${no}` : ((agentRows.find(a => a.id === x.agentId)?.type) ?? 'agent')
875 }
876 const isUnrolled = (list: string) => (v.unrolled ?? []).includes(list)
877 const unroll = (list: string) => () =>
878 void update($, view, x => {
879 const open = x.unrolled ?? []
880 return { ...x, unrolled: open.includes(list) ? open.filter(l => l !== list) : [...open, list] }
881 })
882 // Names laid side by side; the ones in use first and bright, the rest dim.
883 const chips = (list: string, color: string, names: readonly { name: string; label: string }[], active: ReadonlySet<string>, limit: number | undefined) => {
884 const ordered = [...names.filter(n => active.has(n.name)), ...names.filter(n => !active.has(n.name))]
885 const words = ordered.map(n => `${active.has(n.name) ? '● ' : ''}${clip(n.label, IN)}`)
886 const rows = pack(words, IN, 3)
887 const seen = limit === undefined ? rows : rows.slice(0, limit)
888 const hidden = rows.slice(seen.length).reduce((sum, r) => sum + r.length, 0)
889 return {
890 hidden,
891 rows: seen.map((row, r) => (
892 <Text key={`${list}-row-${r}`} wrap="truncate-end">
893 {row.map((i, k) => (
894 <Text key={`${list}-${i}`}>
895 {k ? <Text dimColor>{' · '}</Text> : null}
896 {active.has(ordered[i]!.name) ? <Text color={color} bold>{words[i]}</Text> : <Text dimColor>{words[i]}</Text>}
897 </Text>
898 ))}
899 </Text>
900 )),
901 }
902 }
903 const useRow = (x: InUse, color: string) => {
904 const time = stopwatch(now - x.startedAt)
905 const who = owner(x)
906 const what = x.kind === 'mcp' ? `● ${x.name}${x.tool ? ` · ${x.tool}` : ''}` : `⚡ ${x.name}`
907 return (
908 <Box key={`use-${x.id}`} justifyContent="space-between" width={IN}>
909 <Text color={color} wrap="truncate-end">{clip(what, IN - who.length - time.length - 3)}</Text>
910 <Text dimColor>{`${who} ${time}`}</Text>
911 </Box>
912 )
913 }
914
915 const mcpUses = using.filter(x => x.kind === 'mcp')
916 const mcpActive = new Set(mcpUses.map(x => x.name))
917 const mcpNames = cat.servers.map(sv => ({ name: sv.name, label: `${sv.name} (${sv.tools})` }))
918 // A server the catalog has not caught up with yet still shows while in use.
919 for (const name of mcpActive) if (!cat.servers.some(sv => sv.name === name)) mcpNames.push({ name, label: name })
920 const mcpList = chips('mcp', C.mcp, mcpNames, mcpActive, isUnrolled('mcp') ? undefined : LIST_ROWS)
921 const mcpBody = (
922 <Box flexDirection="column">
923 {mcpNames.length === 0 ? <Text dimColor>no MCP servers connected</Text> : null}
924 {mcpList.rows}
925 {mcpList.hidden > 0 || isUnrolled('mcp') ? (
926 <Button key="more-mcp" plain dimColor label={isUnrolled('mcp') ? '− show less' : `+ ${mcpList.hidden} more`} onPress={unroll('mcp')} />
927 ) : null}
928 {mcpUses.slice(-ACTIVE_ROWS).map(x => useRow(x, C.mcp))}
929 {mcpUses.length > ACTIVE_ROWS ? <Text dimColor>{`+ ${mcpUses.length - ACTIVE_ROWS} more calls`}</Text> : null}
930 </Box>
931 )
932
933 const skillUses = using.filter(x => x.kind === 'skill')
934 const skillActive = new Set(skillUses.map(x => x.name))
935 const skillNames = [...new Set([...cat.skills, ...skillActive])].map(name => ({ name, label: name }))
936 const skillList = chips('skills', C.skills, skillNames, skillActive, undefined)
937 const skillsBody = (
938 <Box flexDirection="column">
939 {skillUses.length === 0 ? <Text dimColor>none in use</Text> : null}
940 {skillUses.slice(-ACTIVE_ROWS).map(x => useRow(x, C.skills))}
941 {skillUses.length > ACTIVE_ROWS ? <Text dimColor>{`+ ${skillUses.length - ACTIVE_ROWS} more`}</Text> : null}
942 {skillNames.length ? (
943 <Button key="more-skills" plain dimColor label={isUnrolled('skills') ? '− hide skills' : `+ ${skillNames.length} available`} onPress={unroll('skills')} />
944 ) : null}
945 {isUnrolled('skills') ? skillList.rows : null}
946 </Box>
947 )
948
949 // Background
950 const liveShells = shellRows.filter(x => x.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
951 const endedShells = shellRows.filter(x => x.status !== 'running').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
952 const shellRow = (x: ShellRow) => {
953 const time = stopwatch((x.endedAt ?? now) - x.startedAt)
954 const isLive = x.status === 'running'
955 const glyph = isLive ? '●' : x.status === 'completed' ? '✓' : '✗'
956 const color = isLive ? C.shells : x.status === 'completed' ? undefined : C.bad
957 const pid = x.pid ? `${x.pid} ` : ''
958 return (
959 <Box key={`s-${x.id}`} justifyContent="space-between" width={IN}>
960 <Box>
961 <Text color={color} dimColor={!isLive}>{`${glyph} `}</Text>
962 {pid ? <Text color={C.pid} dimColor={!isLive}>{pid}</Text> : null}
963 <Button key={`shell-${x.id}`} plain dimColor={!isLive || undefined} label={clip(x.description || x.command, IN - time.length - pid.length - 4)} onPress={() => void openShell($, x.id)} />
964 </Box>
965 <Text dimColor>{time}</Text>
966 </Box>
967 )
968 }
969 const bgBody = (
970 <Box flexDirection="column">
971 {shellRows.length === 0 && cronRows.length === 0 ? <Text dimColor>nothing in the background</Text> : null}
972 {liveShells.slice(-LIVE_SHELLS_SHOWN).map(shellRow)}
973 {liveShells.length > LIVE_SHELLS_SHOWN ? <Text dimColor>{`+ ${liveShells.length - LIVE_SHELLS_SHOWN} more running`}</Text> : null}
974 {endedShells.slice(0, FINISHED_SHELLS_SHOWN).map(shellRow)}
975 {cronRows.map(c => (
976 <Text key={`c-${c.id}`} dimColor wrap="truncate-end">{`↻ ${clip(c.prompt, IN - c.schedule.length - 5)} · ${c.schedule}`}</Text>
977 ))}
978 </Box>
979 )
980
981 // Progress
982 const items = prog.todos.length > 0 ? prog.todos : prog.tasks
983 const finished = items.filter(i => i.status === 'completed').length
984 const doneShown = items.length > 8 ? items.filter(i => i.status === 'completed').slice(-2) : items.filter(i => i.status === 'completed')
985 const rest = items.filter(i => i.status !== 'completed')
986 const hidden = finished - doneShown.length
987 const pg = gauge(items.length ? (finished / items.length) * 100 : 0, IN)
988 const progBody = (
989 <Box flexDirection="column">
990 {prog.plan ? <Text color={C.progress} wrap="truncate-end">{clip(`plan · ${prog.plan}`, IN)}</Text> : null}
991 {items.length > 0 ? (
992 <Text>
993 <Text color={C.progress}>{pg.full}</Text>
994 <Text dimColor>{pg.empty}</Text>
995 </Text>
996 ) : null}
997 {hidden > 0 ? <Text dimColor>{`✓ ${hidden} earlier done`}</Text> : null}
998 {[...doneShown, ...rest].map(i => (
999 <Text key={`p-${i.id}`} wrap="truncate-end">
1000 <Text color={i.status === 'completed' ? C.shells : i.status === 'in_progress' ? C.progress : undefined} dimColor={i.status === 'pending'}>
1001 {i.status === 'completed' ? '✓ ' : i.status === 'in_progress' ? '◆ ' : '◇ '}
1002 </Text>
1003 <Text dimColor={i.status === 'completed'} bold={i.status === 'in_progress'}>{clip(i.text, IN - 2)}</Text>
1004 </Text>
1005 ))}
1006 </Box>
1007 )
1008
1009 return (
1010 <Box flexDirection="column" width={W}>
1011 <Box justifyContent="center" width={W}>
1012 <Text bold>{'RADAR · '}</Text>
1013 <Text color={C.main} bold>{model}</Text>
1014 {version ? <Text dimColor>{` · v${version}`}</Text> : null}
1015 </Box>
1016 <Box justifyContent="center" flexWrap="wrap" columnGap={2} width={W}>
1017 <Text><Text color={C.main}>■</Text><Text dimColor> main</Text></Text>
1018 <Text><Text color={C.agents}>■</Text><Text dimColor> agents</Text></Text>
1019 <Text><Text color={C.mcp}>■</Text><Text dimColor> mcp</Text></Text>
1020 <Text><Text color={C.skills}>■</Text><Text dimColor> skills</Text></Text>
1021 <Text><Text color={C.shells}>■</Text><Text dimColor> shells</Text></Text>
1022 {items.length || prog.plan ? <Text><Text color={C.progress}>■</Text><Text dimColor> progress</Text></Text> : null}
1023 </Box>
1024
1025 {n ? (
1026 <Box key="needs" flexDirection="column" borderStyle="round" borderColor={C.warn} paddingX={1} width={W} marginTop={1}>
1027 <Box justifyContent="space-between" width={IN}>
1028 <Text color={C.warn} bold>{`⚠ NEEDS YOU · ${clip(n.who, IN - 26)}`}</Text>
1029 <Text color={C.warn}>{stopwatch(now - n.since)}</Text>
1030 </Box>
1031 <Text wrap="truncate-end">{clip(n.what.trim(), IN)}</Text>
1032 </Box>
1033 ) : null}
1034
1035 <Box marginTop={n ? 0 : 1} />
1036 {box('main', C.main, `${model} · main`, '', mainBody)}
1037
1038 {box('agents', C.agents, 'AGENTS', agentRows.length ? `${running.length} running · ${agentRows.length} total` : '', (
1039 <Box flexDirection="column">
1040 {shown.length === 0 ? <Text dimColor>none yet</Text> : null}
1041 {shown.length ? (
1042 <Box flexDirection="row" flexWrap="wrap" columnGap={1} width={IN}>
1043 {shown.map(card)}
1044 </Box>
1045 ) : null}
1046 {agentRows.length > shown.length ? <Text dimColor>{`+ ${[moreRunning ? `${moreRunning} more running` : '', moreDone ? `${moreDone} more done` : ''].filter(Boolean).join(' · ')}`}</Text> : null}
1047 </Box>
1048 ))}
1049
1050 {box('mcp', C.mcp, 'MCP', cat.servers.length ? `${mcpActive.size} in use · ${cat.servers.length} servers` : '', mcpBody)}
1051
1052 {box('skills', C.skills, 'SKILLS', skillUses.length ? `${skillActive.size} in use` : '', skillsBody)}
1053
1054 {box('background', C.shells, 'BACKGROUND', backgroundCount(liveShells), bgBody)}
1055 {items.length || prog.plan ? box('progress', C.progress, 'PROGRESS', items.length ? `${finished}/${items.length}` : '', progBody) : null}
1056
1057 {hint('tab next · ⏎ open · ↑↓ scroll · esc prompt')}
1058 </Box>
1059 )
1060 })
1061}
1062hooks/lib.ts 246 lines1import type { LimitSample, RateLimit, ShellStatus } from '../types'
2
3const MIN = 60_000
4// A forecast needs this much history in the current window before it means anything.
5const FORECAST_MIN_SPAN = 5 * MIN
6// And looks back no further than this, so it follows the current pace.
7const FORECAST_LOOKBACK = 60 * MIN
8
9export function duration(ms: number) {
10 const s = Math.max(0, Math.floor(ms / 1000))
11 if (s < 60) return `${s}s`
12 const m = Math.floor(s / 60)
13 if (m < 60) return `${m}m`
14 const h = Math.floor(m / 60)
15 return `${h}h${String(m % 60).padStart(2, '0')}`
16}
17
18export function tokens(n: number) {
19 if (n < 1000) return String(n)
20 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
21 return `${(n / 1_000_000).toFixed(1)}M`
22}
23
24export function clock(ms: number) {
25 const d = new Date(ms)
26 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
27}
28
29// "resets 16:00" today, "resets Sat 00:00" on another day.
30export function resetLabel(iso: string | undefined, now: number) {
31 if (!iso) return ''
32 const at = Date.parse(iso)
33 if (Number.isNaN(at)) return ''
34 const isToday = new Date(at).toDateString() === new Date(now).toDateString()
35 if (isToday) return `resets ${clock(at)}`
36 return `resets ${new Date(at).toLocaleDateString('en-US', { weekday: 'short' })} ${clock(at)}`
37}
38
39// "claude-opus-5-5" → "Opus 5.5", "claude-haiku-4-5-20251001" → "Haiku 4.5".
40export function modelName(model: string) {
41 const [family, ...rest] = model.replace(/^claude-/, '').replace(/\[.*\]$/, '').split('-')
42 if (!family) return model
43 const version = rest.filter(p => /^\d{1,2}$/.test(p)).join('.')
44 return `${family[0]!.toUpperCase()}${family.slice(1)}${version ? ` ${version}` : ''}`
45}
46
47export function clip(text: string, width: number) {
48 const flat = text.replace(/\s+/g, ' ')
49 if (width <= 1) return flat.slice(0, Math.max(0, width))
50 return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat
51}
52
53export function bar(percent: number, width: number) {
54 const full = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
55 return { full: '█'.repeat(full), empty: '░'.repeat(width - full) }
56}
57
58export const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
59
60export function limitLabel(kind: string) {
61 return LIMIT_LABEL[kind] ?? kind.replace(/_/g, ' ')
62}
63
64// Keeps a short history per window, restarted whenever the window resets.
65export function addSamples(samples: readonly LimitSample[], limits: readonly RateLimit[], at: number) {
66 let next = samples.filter(s => at - s.at <= FORECAST_LOOKBACK)
67 for (const l of limits) {
68 next = next.filter(s => s.kind !== l.kind || s.resetsAt === l.resetsAt)
69 const last = next.filter(s => s.kind === l.kind).at(-1)
70 if (last && last.percentUsed === l.percentUsed && at - last.at < MIN) continue
71 next.push({ kind: l.kind, at, percentUsed: l.percentUsed, resetsAt: l.resetsAt })
72 }
73 return next
74}
75
76export type Forecast = { kind: 'full'; at: number } | { kind: 'ok' } | undefined
77
78// When the window fills at the pace of the samples, or 'ok' when it resets first.
79export function forecast(samples: readonly LimitSample[], limit: RateLimit, now: number): Forecast {
80 const mine = samples.filter(s => s.kind === limit.kind && s.resetsAt === limit.resetsAt)
81 const first = mine[0]
82 if (!first || now - first.at < FORECAST_MIN_SPAN) return undefined
83 const rate = (limit.percentUsed - first.percentUsed) / (now - first.at)
84 if (rate <= 0) return { kind: 'ok' }
85 const at = now + (100 - limit.percentUsed) / rate
86 const resets = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN
87 if (!Number.isNaN(resets) && at >= resets) return { kind: 'ok' }
88 return { kind: 'full', at }
89}
90
91export type TaskNotice = { id: string; status: ShellStatus; summary: string; outputPath?: string }
92
93const tag = (text: string, name: string) => text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim()
94
95// The <task-notification> the engine delivers when a background task ends.
96export function parseTaskNotice(text: string): TaskNotice | undefined {
97 const id = tag(text, 'task-id')
98 if (!id) return undefined
99 // A monitor's events come as notices too, with no status: only an end has one.
100 const raw = tag(text, 'status')
101 if (!raw) return undefined
102 const status: ShellStatus = raw === 'failed' || raw === 'killed' ? raw : 'completed'
103 return { id, status, summary: tag(text, 'summary') ?? '', outputPath: tag(text, 'output-file') }
104}
105
106// Bash's result names where a backgrounded command writes its output.
107export function outputPathOf(text: string | undefined) {
108 return text?.match(/written to:\s*(\S+?\.output)\b/)?.[1]
109}
110
111export function exitCodeOf(summary: string) {
112 return summary.match(/exit code (-?\d+)/)?.[1]
113}
114
115const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
116
117// The last `count` lines as a terminal would show them: colors dropped, a
118// carriage return keeping only what came after it.
119export function tail(text: string, count: number) {
120 const lines = text.replace(ANSI, '').split('\n')
121 if (lines.at(-1) === '') lines.pop()
122 return lines.slice(-count).map(line => line.slice(line.lastIndexOf('\r') + 1).replace(/\t/g, ' '))
123}
124
125export function lastLine(text: string) {
126 return text.split('\n').map(l => l.trim()).filter(Boolean).at(-1) ?? ''
127}
128
129export function parentDir(path: string) {
130 return path.slice(0, Math.max(0, path.lastIndexOf('/')))
131}
132
133// "0:42", "12:05", "1:02:09": a running time as a stopwatch reads it.
134export function stopwatch(ms: number) {
135 const s = Math.max(0, Math.floor(ms / 1000))
136 const h = Math.floor(s / 3600)
137 const m = Math.floor((s % 3600) / 60)
138 const ss = String(s % 60).padStart(2, '0')
139 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
140}
141
142const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
143
144// Five cells, filled up to the effort level: ▮▮▯▯▯ for medium.
145export function effortMeter(effort: string | undefined) {
146 const level = effort === undefined ? -1 : EFFORTS.indexOf(effort)
147 if (level < 0) return undefined
148 return { full: '▮'.repeat(level + 1), empty: '▯'.repeat(EFFORTS.length - level - 1) }
149}
150
151// A thin gauge: ▬▬▬▬▱▱▱▱.
152export function gauge(percent: number, width: number) {
153 const full = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
154 return { full: '▬'.repeat(full), empty: '▱'.repeat(width - full) }
155}
156
157// "/home/me/projects/app" → "~/projects/app"; too long, it keeps the end: "…/deep/app".
158export function shortPath(path: string, home: string | undefined, width: number) {
159 const tilde = home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
160 return tilde.length > width ? `…${tilde.slice(tilde.length - width + 1)}` : tilde
161}
162
163// "mcp__claude_ai_Gmail__create_draft" → { server: "claude_ai_Gmail", tool: "create_draft" }.
164export function mcpParts(name: string) {
165 if (!name.startsWith('mcp__')) return undefined
166 const rest = name.slice(5)
167 const cut = rest.indexOf('__')
168 return cut < 0 ? { server: rest, tool: '' } : { server: rest.slice(0, cut), tool: rest.slice(cut + 2) }
169}
170
171// Lays words into rows of `width`, `gap` apart; a word too wide gets a row of its own.
172export function pack(words: readonly string[], width: number, gap: number) {
173 const rows: number[][] = []
174 let used = 0
175 words.forEach((w, i) => {
176 const row = rows.at(-1)
177 if (row && used + gap + w.length <= width) {
178 row.push(i)
179 used += gap + w.length
180 } else {
181 rows.push([i])
182 used = w.length
183 }
184 })
185 return rows
186}
187
188// The engine ends a background shell's output file with "[exited with code N]".
189export function exitTrailer(line: string | undefined) {
190 const code = line?.trim().match(/^\[exited with code (-?\d+)\]$/)?.[1]
191 return code === undefined ? undefined : Number(code)
192}
193
194// What `tail -n 1 a b c` prints: a "==> a <==" header before each file's lines
195// once there is more than one file.
196export function parseTails(stdout: string, paths: readonly string[]) {
197 const last = new Map<string, string>()
198 if (paths.length === 1) {
199 last.set(paths[0]!, lastLine(stdout))
200 return last
201 }
202 for (const part of stdout.split(/^==> /m).slice(1)) {
203 const cut = part.indexOf(' <==')
204 if (cut >= 0) last.set(part.slice(0, cut), lastLine(part.slice(cut + 4)))
205 }
206 return last
207}
208
209export type Proc = { pid: number; ppid: number; args: string }
210
211// `ps -A -ww -o pid=,ppid=,args=`, one process per line.
212export function parsePs(stdout: string): Proc[] {
213 return stdout.split('\n').flatMap(line => {
214 const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/)
215 return m ? [{ pid: Number(m[1]), ppid: Number(m[2]), args: m[3]! }] : []
216 })
217}
218
219// The shell the engine started for `command`: it runs it as `eval '<command>'`,
220// single quotes escaped. One the engine itself started comes first.
221export function findPid(procs: readonly Proc[], command: string, enginePid: number | undefined, taken: ReadonlySet<number>) {
222 const quoted = `eval '${command.replace(/'/g, `'\\''`)}'`
223 const hits = procs.filter(p => !taken.has(p.pid) && p.args.includes(quoted))
224 return (hits.find(p => p.ppid === enginePid) ?? hits[0])?.pid
225}
226
227// One line on how a test run ended, when the output has a runner's summary:
228// vitest, jest, node --test or pytest.
229export function testSummary(lines: readonly string[]) {
230 const text = lines.join('\n')
231 // The last run's summary, when the output holds several.
232 const vitest = [...text.matchAll(/^\s*Tests\s+(.+?)\s*$/gm)].at(-1)
233 if (vitest && /\d+ (passed|failed)/.test(vitest[1]!)) {
234 const files = text.match(/^\s*Test Files\s+.*?(\d+) passed/m)?.[1]
235 return `${vitest[1]!.replace(/\s*\(\d+\)$/, '').replace(/\s*\|\s*/g, ' · ')}${files ? ` · ${files} files` : ''}`
236 }
237 const jest = text.match(/^Tests:\s+(.+?)\s*$/m)
238 if (jest) return jest[1]!.replace(/, \d+ total$/, '').replace(/, /g, ' · ')
239 const pass = text.match(/^ℹ pass (\d+)$/m)?.[1]
240 const fail = text.match(/^ℹ fail (\d+)$/m)?.[1]
241 if (pass !== undefined) return `${pass} passed${fail && fail !== '0' ? ` · ${fail} failed` : ''}`
242 const pytest = text.match(/^=+ (.*\b(?:passed|failed)\b.*?) in [\d.]+s.* =+$/m)
243 if (pytest) return pytest[1]!.replace(/, /g, ' · ')
244 return undefined
245}
246types/index.d.ts 94 lines1export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = {
4 startedAt: number
5 contextTokens?: number
6 contextWindow: number
7 contextPercent?: number
8 rateLimits: RateLimit[]
9}
10
11export type LimitSample = { kind: string; at: number; percentUsed: number; resetsAt?: string }
12
13export type Main = { model?: string; effort?: string; cwd?: string; branch?: string; isWorktree: boolean }
14
15export type AgentStatusName = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
16
17export type AgentRow = {
18 id: string
19 type: string
20 description: string
21 status: AgentStatusName
22 model?: string
23 startedAt: number
24 endedAt?: number
25 tools: number
26 errors: number
27 contextTokens?: number
28 lastMessage?: string
29 cwd?: string
30 branch?: string
31 isWorktree: boolean
32}
33
34export type ShellStatus = 'running' | 'completed' | 'failed' | 'killed'
35
36export type ShellRow = {
37 id: string
38 kind: string
39 command: string
40 description?: string
41 startedAt: number
42 status: ShellStatus
43 endedAt?: number
44 summary?: string
45 outputPath?: string
46 // The process the engine started for it, found by its command line.
47 pid?: number
48 exitCode?: number
49 agentId?: string
50 // Started by a foreground subagent: the engine stops it when that agent ends.
51 endsWithAgent?: boolean
52}
53
54export type Cron = { id: string; schedule: string; recurring: boolean; prompt: string; at?: number }
55
56export type ProgressItem = { id: string; text: string; status: 'pending' | 'in_progress' | 'completed' }
57
58export type Progress = { todos: ProgressItem[]; tasks: ProgressItem[]; plan?: string }
59
60export type Needs = { who: string; agentId?: string; what: string; since: number }
61
62// A list shown whole ('mcp', 'skills') rather than cut to its first lines.
63export type View = { screen: 'overview' | 'shell'; shellId?: string; expanded?: string; folded: string[]; unrolled?: string[] }
64
65export type McpServer = { name: string; tools: number }
66
67// What the session has: its MCP servers (each tool's wire name → its server) and its skills.
68export type Catalog = { servers: McpServer[]; toolServer: Record<string, string>; skills: string[] }
69
70// Something in use right now: an MCP call until it returns, a skill until its agent's turn ends.
71export type InUse = { id: string; kind: 'mcp' | 'skill'; name: string; tool?: string; agentId?: string; startedAt: number }
72
73export type Output = { shellId: string; lines: string[]; at: number; error?: string }
74
75declare module 'claude-code' {
76 interface PluginState {
77 'session-radar': {
78 usage: Usage | null
79 samples: LimitSample[]
80 main: Main
81 agents: Record<string, AgentRow>
82 shells: Record<string, ShellRow>
83 crons: Cron[]
84 progress: Progress
85 needs: Needs | null
86 view: View
87 output: Output | null
88 catalog: Catalog
89 inUse: Record<string, InUse>
90 tick: number
91 }
92 }
93}
94