SLOPSHOPPER

session-radar

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

newpaneguardcommandtoastprompt
★ 3v0.3.0MITupdated 2026-10-15ibrahimkobeissy/claude-mods/mods/session-radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-radar
│ ┃ session-radar ✕ › fix the failing auth test and add an audit log call │ ┃ RADAR · Opus 5.5 │ ┃ ■ main ■ agents ■ mcp ■ skills ■ s ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ╭─────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ │ ▾ Opus 5.5 · main ⎿ Added 2 lines, removed 1 line │ ┃ │ effort — session 30m ⏺ Bash(bun test) │ ┃ │ ctx ▬▬▬▬▬▬▱▱▱▱▱▱ 49% 97k/200k ⎿ 3 pass, 1 fail │ ┃ │ 5h ▬▬▱▱▱▱▱▱ 31% │ ┃ │ dir /work/app ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ▾ AGENTS │ ┃ │ none yet › /radar │ ┃ ╰─────────────────────────────────────────── ⎿ session-radar: Radar closed. │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▾ MCP │ ┃ │ no MCP servers connected │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▾ SKILLS │ ┃ │ none in use │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▾ BACKGROUND │ ┃ │ nothing in the background ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · session-radar
RADAR · Opus 5.5 ■ main ■ agents ■ mcp ■ skills ■ shells ╭──────────────────────────────────────────────────────╮ │ ▾ Opus 5.5 · main │ │ effort — session 30m │ │ ctx ▬▬▬▬▬▬▱▱▱▱▱▱ 49% 97k/200k │ │ 5h ▬▬▱▱▱▱▱▱ 31% │ │ dir /work/app │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ ▾ AGENTS │ │ none yet │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ ▾ MCP │ │ no MCP servers connected │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ ▾ SKILLS │ │ none in use │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ ▾ BACKGROUND │ │ nothing in the background │ ╰──────────────────────────────────────────────────────╯ ctrl+x tab to use · /radar to close
README

📡 session-radar

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%">

Install

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

What it shows

Each part of the session gets its own colour-coded box:

BoxWhat'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.

A background shell, opened

<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.

Keys

The radar never takes over your keyboard. While you type at the prompt, every Claude Code shortcut works as it always does.

KeyWhereDoes
ctrl+x tabpromptMoves the keyboard into the pane (press it again if another plugin's band is showing)
tab / shift+tabpaneNext / previous row
enterpaneOpen a shell, show an agent's last message, fold or unfold a box
↑ ↓paneScroll
b / c / sshell viewBack / copy / stop
escpaneBack to the prompt

You can also click rows.

Options

OptionDefault
autoOpentrueOpen 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.

Good to know

  • The pane docks as a sidebar in the fullscreen layout. In a narrow terminal it opens above the prompt instead and scrolls.
  • Limits appear once the first API response reports them, and only on a Claude subscription. The 5h forecast appears after about 5 minutes of usage data.
  • An agent's context is shown in tokens (from its last model request), since agents can run on different models.
  • It tracks what happens while it's loaded. Shells and agents started before you installed it won't show up.

Permissions and privacy

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.

Develop

claude --plugin-dir mods/session-radar       # try it live; saves hot-reload
scripts/validate.sh mods/session-radar       # validate + run tests/
Source 3 files
hooks/register.tsx 1062 lines
1import { 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}
1062
hooks/lib.ts 246 lines
1import 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}
246
types/index.d.ts 94 lines
1export 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