SLOPSHOPPER

agents-panel

A live Agents pane: a progress bar for the helpers, what the main loop is doing, every shell command of the session, and one card per helper agent or Codex run…

newpanebandguardcommandtoast
v0.7.0MITupdated 2026-10-26eranw2000/claude-code-mods/agents-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agents-panel
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ 0 agents · 0 shells running │ ┃ ⏺ Read(src/auth.ts) │ ┃ ● main Bash: Show environment file · 0s a… ⎿ Read 6 lines │ ┃ last "fix the failing auth test and add a… ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Shells · 0 running ⏺ Bash(bun test) │ ┃ ▸ Finished shells · 4 ⎿ 3 pass, 1 fail │ ┃ │ ┃ No helper agents in this session yet. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-panel │ ⎿ agents-panel: Agents pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
0 agents · 0 shells running ● main Bash: Show environment file · 0s ago last "fix the failing auth test and add an …" · 0s ago Shells · 0 running ▸ Finished shells · 4 No helper agents in this session yet.
README

Claude Code mods

Three mods for Claude Code that draw three live side panes (Agents, Thread and Files), showing what Claude and its helpers are doing while they work. A mod is a Claude Code plugin whose hooks draw on Claude's own screen, so these panes update by themselves as the session runs.

  • agents-panel: a live Agents pane.
  • One card per helper agent: its task, what it is doing now, its recent actions, its plan steps, tokens, an approximate cost and its time. Each helper type wears its own pixel crab (a developer with headphones and a laptop, a reviewer in a graduation cap, a planner in a captain's hat, and so on).
  • A progress bar for the current batch of helpers at the top of the pane: tasks done, percent and time left. It can also be drawn above the prompt.
  • What the main loop is doing, and every shell command of the session, with background commands kept until they report back.
  • A card for each Codex run started through codex_delegate.py (from claude-release-workflow): what it reviews, its model and effort, live tokens from its session log, time against its limit or against how long similar past runs took, and its end in words (finished, timed out, refused, and so on).
  • /agents-panel opens the pane.
  • thread-panel: a live Thread pane, your conversation at a glance.
  • Each prompt you typed in this conversation, oldest first, with the first paragraph of Claude's answer under it, and how long ago each was.
  • A long prompt is folded; press to open it whole.
  • Click a prompt to scroll the conversation to it.
  • It opens with your first prompt, and /thread-panel opens it too.
  • files-panel: a live Files pane.
  • Every file Claude reads, edits or writes this session, as a tree, with what is happening to each one and whether git has it committed.
  • Files outside the project folder are folded under their own heading.
  • /files-panel opens the pane.

The panes start over when you /clear or start a new conversation.

Three panes beside the conversation: Agents opens when a helper starts or with your first prompt, Thread lists your prompts and jumps to one on a click, and Files shows every file the session reads or edits

The diagram's source is docs/mods-flow.drawio.

Install

Add this repository as a plugin marketplace, then install the mods you want:

claude plugin marketplace add https://github.com/eranw2000/claude-code-mods
claude plugin install agents-panel@claude-code-mods
claude plugin install files-panel@claude-code-mods
claude plugin install thread-panel@claude-code-mods

Restart Claude Code to load them. The panes open by themselves when there is something to show (Agents and Thread with your first prompt), or on /agents-panel, /thread-panel and /files-panel.

If you installed agents-panel before version 0.7.0, the Thread pane was part of it. It is now thread-panel: install it as above to keep the pane.

To update later:

claude plugin marketplace update claude-code-mods
claude plugin update agents-panel@claude-code-mods
claude plugin update files-panel@claude-code-mods
claude plugin update thread-panel@claude-code-mods

Settings

Each mod has switches in /config:

  • agents-panel
  • Show the Agents pane (on by default). Off: the pane never opens by itself.
  • Show the progress bar above the prompt too (off by default). The bar is always at the top of the Agents pane; on, it is also drawn above the prompt while helpers run.
  • files-panel
  • Show the files panel (on by default). Off: nothing is tracked and the pane never opens.
  • thread-panel
  • Show the Thread pane (on by default). Off: the pane never opens by itself and /thread-panel says it is off.

What the mods run

The Codex card reads a run's own files (.codex-runs/<run>/request.json, receipt.json and child-process.json beside the run's --out file) and uses four read-only programs on macOS: find, printenv, lsof and tail. The Files pane asks git whether a file is committed. The Thread pane runs no programs. None of the mods writes files or changes anything in your repositories.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 2025 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import type { Activity, AgentCard, CodexRun, MainRow, PlanStep, ShellRow } from '../types'
5
6const PANE = 'agents-panel'
7const agents = atom({ plugin: 'agents-panel', key: 'agents' } as const, {})
8const isCollapsed = atom({ plugin: 'agents-panel', key: 'isCollapsed' } as const, true)
9const now = atom({ plugin: 'agents-panel', key: 'now' } as const, 0)
10// The ids of every helper known when the band was hidden; a new id shows it again.
11const hiddenIds = atom({ plugin: 'agents-panel', key: 'hiddenIds' } as const, [] as string[])
12const IDLE: Activity = { doing: '', doingAt: 0, active: 0, idleSince: 0, said: '', saidAt: 0, recent: [] }
13const main = atom({ plugin: 'agents-panel', key: 'main' } as const, { ...IDLE, lastPrompt: '', lastPromptAt: 0 } as MainRow)
14const shells = atom({ plugin: 'agents-panel', key: 'shells' } as const, {} as Record<string, ShellRow>)
15const isShellsCollapsed = atom({ plugin: 'agents-panel', key: 'isShellsCollapsed' } as const, true)
16const openPrompts = atom({ plugin: 'agents-panel', key: 'openPrompts' } as const, [] as string[])
17// The conversation the cards belong to; '' until the first event reads it.
18const sessionId = atom({ plugin: 'agents-panel', key: 'sessionId' } as const, '')
19// Whether the owner has typed a prompt in this conversation yet: the first one
20// opens the pane.
21const hasPrompted = atom({ plugin: 'agents-panel', key: 'hasPrompted' } as const, false)
22
23// How many actions a card lists, how much of a prompt it shows folded, and
24// how many finished shells the pane keeps.
25const RECENT = 5
26const PROMPT_PREVIEW = 200
27const FINISHED_SHELLS = 10
28
29// The band stays this long after the last helper of a batch ends, showing 100%.
30const GRACE_MS = 10_000
31// Expected duration when no helper of this session has finished yet.
32const DEFAULT_MS = 5 * 60_000
33// A running helper never counts as more than this share done.
34const CAP = 0.95
35const BAND_ORANGE = '#d9734e'
36const PILL_PURPLE = '#7c5cd6'
37const TRACK_DARK = '#3a3a40'
38
39// Dollars per million tokens: input, output, cache read, cache write.
40// From the Claude API reference.
41// Cache writes are priced at 2x input, the 1-hour cache Claude Code asks for.
42// An unknown model is priced as Opus 5.5, so the figure is always marked approximate.
43const OPUS_5_5: [number, number, number, number] = [4, 20, 0.2, 8]
44const PRICES: Array<[string, [number, number, number, number]]> = [
45  ['fable', [10, 50, 0.25, 20]],
46  ['mythos', [10, 50, 0.25, 20]],
47  ['opus-5-5', OPUS_5_5],
48  ['opus', [5, 25, 0.5, 10]],
49  ['sonnet-5', [2, 10, 0.2, 4]],
50  ['sonnet', [3, 15, 0.3, 6]],
51  ['haiku-5-5', [0.1, 0.5, 0.01, 0.2]],
52  ['haiku', [1, 5, 0.1, 2]],
53]
54
55const COLORS = ['#d9734e', '#4caf7d', '#4a90e2', '#d4a03a', '#b06ad9', '#3fb8c4']
56
57function priceOf(model: string): [number, number, number, number] {
58  for (const [key, price] of PRICES) {
59    if (model.includes(key)) return price
60  }
61  return OPUS_5_5
62}
63
64function colorOf(text: string): string {
65  let hash = 0
66  for (const ch of text) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
67  return COLORS[hash % COLORS.length] ?? '#888888'
68}
69
70function modelName(model: string): string {
71  const m = model.match(/(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d+))?/)
72  const [, name = '', major = '', minor] = m ?? []
73  if (!name) return model || 'model ?'
74  const family = name.charAt(0).toUpperCase() + name.slice(1)
75  return minor ? `${family} ${major}.${minor}` : `${family} ${major}`
76}
77
78function statusOf(status: AgentStatus): AgentCard['status'] {
79  if (status === 'completed' || status === 'idle') return 'done'
80  if (status === 'failed') return 'failed'
81  if (status === 'killed') return 'stopped'
82  return 'running'
83}
84
85function short(n: number): string {
86  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
87  if (n >= 1000) return `${Math.round(n / 1000)}k`
88  return String(n)
89}
90
91function clock(ms: number): string {
92  const s = Math.max(0, Math.round(ms / 1000))
93  const h = Math.floor(s / 3600)
94  const m = Math.floor((s % 3600) / 60)
95  const sec = String(s % 60).padStart(2, '0')
96  return h ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
97}
98
99function money(usd: number): string {
100  return usd < 10 ? `≈$${usd.toFixed(2)}` : `≈$${Math.round(usd)}`
101}
102
103function blank(id: string, at: number): AgentCard {
104  return {
105    ...IDLE,
106    id, description: 'helper', type: 'agent', model: '', effort: '', status: 'running',
107    startedAt: at, endedAt: 0, steps: 0, tools: 0, lastTool: '', tokens: 0, outTokens: 0,
108    context: 0, cost: 0, prompt: '', plan: [],
109  }
110}
111
112// A cut that keeps a line on one row: the text, or its head and an ellipsis.
113function fit(text: string, cols: number): string {
114  return text.length <= cols ? text : `${text.slice(0, Math.max(1, cols - 1))}…`
115}
116
117function oneLine(text: string): string {
118  return text.replace(/\s+/g, ' ').trim()
119}
120
121function firstLine(text: string): string {
122  return text.split('\n').map(l => l.trim()).find(l => l !== '') ?? ''
123}
124
125// A span as the status line writes it: 40s, 5m14s, 2h3m.
126function span(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
131  return `${Math.floor(m / 60)}h${m % 60}m`
132}
133
134function plural(n: number, word: string): string {
135  return `${n} ${word}${n === 1 ? '' : 's'}`
136}
137
138function str(value: unknown): string {
139  return typeof value === 'string' ? value : ''
140}
141
142function baseName(path: string): string {
143  return path.split('/').filter(Boolean).pop() ?? path
144}
145
146function hostOf(url: string): string {
147  try {
148    return new URL(url).host
149  } catch {
150    return ''
151  }
152}
153
154// What a tool call is doing, in a few words. A shell call is named by its own
155// description and never by its command, which can hold a secret.
156function labelOf(e: ToolCallInput): string {
157  const a = e as unknown as Record<string, unknown>
158  const tool: string = e.tool
159  const named = (what: string) => fit(what ? `${tool}: ${oneLine(what)}` : tool, 200)
160  switch (tool) {
161    case 'Bash': return named(str(a.description) || 'shell command')
162    case 'Monitor': return named(str(a.description) || 'watch')
163    case 'Read': case 'Edit': case 'Write': return named(baseName(str(a.file_path)))
164    case 'NotebookEdit': return named(baseName(str(a.notebook_path)))
165    case 'Grep': case 'Glob': return named(str(a.pattern))
166    case 'WebFetch': return named(hostOf(str(a.url)))
167    case 'WebSearch': return named(str(a.query))
168    case 'Agent': return named(str(a.description))
169    case 'Skill': return named(str(a.skill))
170    default: return tool
171  }
172}
173
174function startCall<A extends Activity>(a: A, label: string, at: number): A {
175  return {
176    ...a, doing: label, doingAt: at, active: a.active + 1, idleSince: 0,
177    recent: [{ label, at }, ...a.recent].slice(0, RECENT),
178  }
179}
180
181function endCall<A extends Activity>(a: A, at: number): A {
182  const active = Math.max(0, a.active - 1)
183  return { ...a, active, idleSince: active === 0 ? at : a.idleSince }
184}
185
186function say<A extends Activity>(a: A, answer: string, at: number): A {
187  const line = firstLine(answer)
188  return line ? { ...a, said: fit(line, 300), saidAt: at } : a
189}
190
191// The most specific thing known: the call in flight; else the last call, then
192// the last words, each with how long ago.
193// Returned as [what, how long ago], so a cut to the width keeps the age.
194function doingNow(a: Activity, t: number): [string, string] {
195  if (a.doing && a.active > 0) return [a.doing, '']
196  if (a.doing) return [a.doing, ` · ${span(t - (a.idleSince || a.doingAt))} ago`]
197  if (a.said) return [a.said, ` · ${span(t - a.saidAt)} ago`]
198  return ['', '']
199}
200
201// A line cut to `cols` from its head, so its tail (an age, a state) stays whole.
202function withTail(head: string, tail: string, cols: number): string {
203  return fit(head, Math.max(4, cols - tail.length)) + tail
204}
205
206// A helper's own task list, from its TodoWrite, TaskCreate and TaskUpdate calls.
207function planAfter(plan: PlanStep[], e: ToolCallInput, result: ToolCallResult | undefined): PlanStep[] {
208  const a = e as unknown as Record<string, unknown>
209  const status = (s: unknown): PlanStep['status'] =>
210    s === 'completed' || s === 'in_progress' ? s : 'pending'
211  if (e.tool === 'TodoWrite' && Array.isArray(a.todos)) {
212    return a.todos.map((raw, i) => {
213      const t = (raw ?? {}) as Record<string, unknown>
214      return { id: String(i), title: oneLine(str(t.content)), active: oneLine(str(t.activeForm)), status: status(t.status) }
215    })
216  }
217  const r = (result && !result.deny && !result.isError ? result.result : undefined) as Record<string, unknown> | undefined
218  if (e.tool === 'TaskCreate') {
219    const task = (r?.task ?? {}) as Record<string, unknown>
220    const id = str(task.id) || `new-${plan.length}`
221    return [...plan, { id, title: oneLine(str(a.subject)), active: oneLine(str(a.activeForm)), status: 'pending' }]
222  }
223  if (e.tool === 'TaskUpdate') {
224    const id = str(a.taskId)
225    if (a.status === 'deleted') return plan.filter(s => s.id !== id)
226    return plan.map(s => s.id !== id ? s : {
227      ...s,
228      title: a.subject === undefined ? s.title : oneLine(str(a.subject)),
229      active: a.activeForm === undefined ? s.active : oneLine(str(a.activeForm)),
230      status: a.status === undefined ? s.status : status(a.status),
231    })
232  }
233  return plan
234}
235
236const PLAN_TOOLS = ['TodoWrite', 'TaskCreate', 'TaskUpdate']
237
238// A background task's notification, read from its tags. The summary is never
239// kept: only the ids, the status word and an exit code.
240type Note = { taskId: string; toolUseId: string; status: ShellRow['status']; exitCode: number }
241
242function parseNote(body: string): Note | undefined {
243  if (!body.includes('<task-notification')) return undefined
244  const tag = (name: string) => body.match(new RegExp(`<${name}>([^<]*)</${name}>`))?.[1]?.trim() ?? ''
245  const word = tag('status')
246  const code = tag('summary').match(/exit code (-?\d+)/i)?.[1]
247  const exitCode = code === undefined ? -1 : Number(code)
248  const status: ShellRow['status'] = word === 'killed' ? 'stopped'
249    : word === 'failed' || exitCode > 0 ? 'failed'
250      : 'ok'
251  return { taskId: tag('task-id'), toolUseId: tag('tool-use-id'), status, exitCode }
252}
253
254// Running shells, then the newest finished ones, at most FINISHED_SHELLS.
255function pruned(all: Record<string, ShellRow>): Record<string, ShellRow> {
256  const rows = Object.values(all)
257  const done = rows.filter(r => r.status !== 'running').sort((a, b) => b.endedAt - a.endedAt)
258  const keep = new Set([...rows.filter(r => r.status === 'running'), ...done.slice(0, FINISHED_SHELLS)].map(r => r.id))
259  return Object.fromEntries(rows.filter(r => keep.has(r.id)).map(r => [r.id, r]))
260}
261
262// How far along a helper is: its own task list when it keeps one (measured),
263// else the band's estimate from finished helpers' run times (marked ≈).
264function progressOf(c: AgentCard, all: AgentCard[], t: number): { share: number; label: string } {
265  const took = clock((c.endedAt || t) - c.startedAt)
266  if (c.status === 'done') return { share: 1, label: `100% · took ${took}` }
267  if (c.status !== 'running') return { share: 0, label: `${c.status} after ${took}` }
268  if (c.plan.length > 0) {
269    const done = c.plan.filter(s => s.status === 'completed').length
270    const now = c.plan.find(s => s.status === 'in_progress')
271    const name = now ? (now.active || now.title) : ''
272    return { share: done / c.plan.length, label: `Steps ${done}/${c.plan.length}${name ? ` · ${name}` : ''}` }
273  }
274  const s = shareOf(c, all, t)
275  const pct = Math.round(100 * s.share)
276  const run = c.codex
277  if (run) {
278    // Past its usual time a Codex run still has its limit, which is real.
279    if (s.isLong) {
280      const toLimit = deadlineOf(c) - t
281      return { share: s.share, label: `≈${pct}% · running long · ${toLimit > 0 ? `limit in ${left(toLimit)}` : 'past its limit'}` }
282    }
283    const isUsual = run.expectedMs > 0 && run.expectedMs <= deadlineOf(c) - c.startedAt
284    return { share: s.share, label: `≈${pct}% · ≈${left(s.leftMs)} left (${isUsual ? `usual ${span(run.expectedMs)}` : 'to its limit'})` }
285  }
286  return { share: s.share, label: `≈${pct}% · ${s.isLong ? 'running long' : `≈${left(s.leftMs)} left`} (estimate)` }
287}
288
289// ---- The crab avatars ----------------------------------------------------
290// One shared crab body, 12 x 12 pixels, with a hat (and a prop in a claw)
291// drawn over it per helper type. Letters are palette entries, '.' is empty.
292const CRAB_COLS = 12
293const CRAB_ROWS = 6 // two pixels to a cell on the terminal
294const CRAB_BODY = [
295  '............',
296  '............',
297  '............',
298  '............',
299  '............',
300  '..BBBBBBBB..',
301  '.BBEBBBBEBB.',
302  'BBBBBBBBBBBB',
303  'B.BBBBBBBB.B',
304  '..BBBBBBBB..',
305  '..B.B..B.B..',
306  '..B.B..B.B..',
307]
308
309// Each hat lists only its own rows from the top; '.' leaves the crab showing.
310const HATS: Record<string, string[]> = {
311  builder: [ // a hard hat, and a wrench by the right claw
312    '............',
313    '....HHHH....',
314    '...HHHHHH...',
315    '..HHHHHHHHGG',
316    '.AAAAAAAAA.G',
317    '...........G',
318  ],
319  explorer: [ // a cap with a white stripe, and a checkered flag on a pole
320    'KWKW........',
321    'WKWK........',
322    'G...HHHH....',
323    'G..HWWWHH...',
324    'G..AAAAAAAA.',
325    'G...........',
326    'G...........',
327  ],
328  planner: [ // a white captain's hat, its band in the type colour, a dark visor
329    '............',
330    '...WWWWWW...',
331    '..WWWWWWWW..',
332    '..HHHHHHHH..',
333    '...KKKKKK...',
334  ],
335  inspector: [ // a mining helmet with a lamp, and a magnifying glass over the right eye
336    '............',
337    '....HHHH....',
338    '...HHYYHH...',
339    '..HHHYYHHH..',
340    '.AAAAAAAAAA.',
341    '.......GGG..',
342    '......GL..G.',
343    '......G...G.',
344    '......G...G.',
345    '.......GGG..',
346    '..........N.',
347    '...........N',
348  ],
349  reviewer: [ // a graduation cap with a gold tassel, its band in the type colour, and the magnifying glass
350    '....DDDD....',
351    '.DDDDDDDDDD.',
352    'DDDDDDDDDDDD',
353    '.Y.DDDDDD...',
354    '.Y.HHHHHH...',
355    '.Y.....GGG..',
356    'YY....GL..G.',
357    '......G...G.',
358    '......G...G.',
359    '.......GGG..',
360    '..........N.',
361    '...........N',
362  ],
363  helper: [ // a puffy white nurse's cap, its band in the type colour
364    '...WW..WW...',
365    '..WWWWWWWW..',
366    '.WWWWWWWWWW.',
367    '.WWWWWWWWWW.',
368    '..HHHHHHHH..',
369  ],
370  developer: [ // headphones (band and cups in the type colour), and an open laptop held up in front, screen lit
371    '............',
372    '...HHHHHH...',
373    '..H......H..',
374    '.H........H.',
375    'HH........HH',
376    'HH........HH',
377    '............',
378    '...GGGGGG...',
379    '...GLLLLG...',
380    '...GLLLLG...',
381    '..WWWWWWWW..',
382  ],
383  designer: [ // a beret tilted to one side, and a paintbrush in the right claw
384    '......A.....',
385    '...HHHHHH...',
386    '..HHHHHHHHH.',
387    '.HHHHHHHHHHH',
388    '...AAAAAA..P',
389    '...........N',
390    '...........N',
391  ],
392  codex: [ // a cap, and reading glasses around the eyes
393    '............',
394    '....HHHH....',
395    '...HHHHHH...',
396    '..HHHHHHHH..',
397    '.AAAAAAAAAA.',
398    '..KKK..KKK..',
399    '..K.KKKK.K..',
400    '..KKK..KKK..',
401  ],
402  bare: [],
403}
404
405// The fixed colours; H is the type's own colour and A a darker shade of it.
406const PALETTE: Record<string, string> = {
407  B: '#d97757', E: '#1a1a1a', K: '#1a1a1a', W: '#f4f4f4', G: '#9aa0a6', D: '#4a4f55',
408  Y: '#ffd84d', L: '#9ecbff', N: '#8d6e63', P: '#29b6f6',
409}
410
411const BLUE = '#2f6fd6'
412const BERRY = '#c2185b'
413
414type Hat = { role: string; color: string }
415type HatRule = { match: (type: string, description: string) => boolean } & Hat
416
417const isType = (...names: string[]) => (type: string) => names.includes(type)
418const startsWith = (re: RegExp) => (type: string, description: string) =>
419  type === 'general-purpose' && re.test(description.trim())
420
421// The one table: which crab a helper gets and its colour, first match wins.
422// A new type is one line; an unknown type gets the bare crab in a hashed colour.
423const HAT_RULES: HatRule[] = [
424  { match: type => /developer/i.test(type), role: 'developer', color: BLUE },
425  { match: type => /designer/i.test(type), role: 'designer', color: BERRY },
426  { match: startsWith(/^(build|implement|develop|code|fix)\b/i), role: 'developer', color: BLUE },
427  { match: startsWith(/^(design|draw|style)\b/i), role: 'designer', color: BERRY },
428  { match: isType('general-purpose', 'claude', 'fork'), role: 'builder', color: '#d4a03a' },
429  { match: isType('Explore'), role: 'explorer', color: '#4caf7d' },
430  { match: isType('Plan', 'plan-auditor'), role: 'planner', color: '#4a90e2' },
431  { match: isType('code-reviewer'), role: 'reviewer', color: '#a0522d' },
432  { match: isType('ai-signal-reviewer'), role: 'reviewer', color: '#8d6e63' },
433  { match: type => /reviewer/i.test(type), role: 'reviewer', color: '#a0522d' },
434  { match: isType('deploy-guard', 'pr-validator'), role: 'inspector', color: '#a0522d' },
435  { match: isType('claude-code-guide'), role: 'helper', color: '#3fb8c4' },
436  { match: isType('statusline-setup', 'hookify:conversation-analyzer'), role: 'helper', color: '#5c9e6e' },
437  { match: isType('codex'), role: 'codex', color: '#2e9d8f' },
438]
439
440function hatOf(type: string, description: string): Hat {
441  const rule = HAT_RULES.find(r => r.match(type, description))
442  return rule ? { role: rule.role, color: rule.color } : { role: 'bare', color: colorOf(type) }
443}
444
445function shade(hex: string, f: number): string {
446  const n = parseInt(hex.slice(1), 16)
447  const ch = (s: number) => Math.round(((n >> s) & 255) * f).toString(16).padStart(2, '0')
448  return `#${ch(16)}${ch(8)}${ch(0)}`
449}
450
451// The crab's pixels, row by row: a colour, or '' where nothing is drawn.
452function spriteOf(hat: Hat): string[][] {
453  const over = HATS[hat.role] ?? []
454  const colors: Record<string, string> = { ...PALETTE, H: hat.color, A: shade(hat.color, 0.65) }
455  return CRAB_BODY.map((row, y) => [...row].map((base, x) => {
456    const top = over[y]?.[x] ?? '.'
457    const letter = top !== '.' ? top : base
458    return letter === '.' ? '' : colors[letter] ?? ''
459  }))
460}
461
462const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
463
464function base64(bytes: Uint8Array): string {
465  let out = ''
466  for (let i = 0; i < bytes.length; i += 3) {
467    const [a = 0, b = 0, c = 0] = [bytes[i], bytes[i + 1], bytes[i + 2]]
468    const n = (a << 16) | (b << 8) | c
469    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
470    out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
471    out += i + 2 < bytes.length ? B64[n & 63]! : '='
472  }
473  return out
474}
475
476// The terminal's drawing: one upper-half block per cell, its colour the upper
477// pixel and its background the lower one; the terminal's own colour where empty.
478function rasterCells(px: string[][]): string {
479  const DEFAULT = 0x01000000
480  const color = (c: string | undefined) => c ? parseInt(c.slice(1), 16) : DEFAULT
481  const words: number[] = []
482  for (let y = 0; y < CRAB_ROWS; y++) {
483    for (let x = 0; x < CRAB_COLS; x++) {
484      const top = px[2 * y]?.[x]
485      const bottom = px[2 * y + 1]?.[x]
486      words.push(top || bottom ? 0x2580 : 0x20, color(top), color(bottom))
487    }
488  }
489  const bytes = new Uint8Array(words.length * 4)
490  const view = new DataView(bytes.buffer)
491  words.forEach((w, i) => view.setUint32(i * 4, w, true))
492  return base64(bytes)
493}
494
495// The other surfaces' drawing: one square per pixel.
496function svgOf(px: string[][]): string {
497  const rects = px.flatMap((row, y) => row.map((c, x) =>
498    c ? `<rect x="${x}" y="${y}" width="1" height="1" fill="${c}"/>` : ''))
499  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CRAB_COLS} ${CRAB_COLS}" shape-rendering="crispEdges">${rects.join('')}</svg>`
500}
501
502// The band's numbers are estimates, so every one of them is drawn with "≈".
503type Estimate = {
504  done: number
505  total: number
506  running: number
507  percent: number
508  leftMs: number
509  isLong: boolean
510}
511
512function median(xs: number[]): number | undefined {
513  if (xs.length === 0) return undefined
514  const s = [...xs].sort((a, b) => a - b)
515  const mid = Math.floor(s.length / 2)
516  return s.length % 2 ? s[mid] : ((s[mid - 1] ?? 0) + (s[mid] ?? 0)) / 2
517}
518
519// Expected run time of a helper: the median of finished helpers of its type,
520// else of every finished helper this session, else five minutes.
521function expectedMs(card: AgentCard, all: AgentCard[]): number {
522  // A Codex run is expected to take as long as past runs like it took, else
523  // its own limit, and never past the moment its limit ends it.
524  if (card.codex) return Math.min(card.codex.expectedMs || Infinity, deadlineOf(card) - card.startedAt)
525  const finished = all.filter(c => c.status === 'done' && c.endedAt > 0)
526  const took = (cs: AgentCard[]) => cs.map(c => c.endedAt - c.startedAt)
527  return median(took(finished.filter(c => c.type === card.type)))
528    ?? median(took(finished))
529    ?? DEFAULT_MS
530}
531
532// The helpers of the current batch: from the first one that started after
533// every earlier helper had ended (plus the grace), up to now.
534function batchOf(all: AgentCard[]): AgentCard[] {
535  const sorted = [...all].sort((a, b) => a.startedAt - b.startedAt)
536  let start = 0
537  let busyUntil = -Infinity
538  sorted.forEach((c, i) => {
539    if (c.startedAt > busyUntil + GRACE_MS) start = i
540    const end = c.status === 'running' ? Infinity : (c.endedAt || c.startedAt)
541    busyUntil = Math.max(busyUntil, end)
542  })
543  return sorted.slice(start)
544}
545
546// One running helper's estimated share done (capped), time left and whether it
547// is past its expected time. The band and the cards both read this.
548function shareOf(c: AgentCard, all: AgentCard[], t: number): { share: number; leftMs: number; isLong: boolean } {
549  const expected = expectedMs(c, all)
550  const elapsed = Math.max(0, t - c.startedAt)
551  return { share: Math.min(CAP, elapsed / expected), leftMs: Math.max(0, expected - elapsed), isLong: elapsed > expected }
552}
553
554function estimate(batch: AgentCard[], all: AgentCard[], t: number): Estimate {
555  const running = batch.filter(c => c.status === 'running')
556  const done = batch.length - running.length
557  let progress = 0
558  let leftMs = 0
559  let isLong = false
560  for (const c of running) {
561    const s = shareOf(c, all, t)
562    progress += s.share
563    if (s.isLong) isLong = true
564    leftMs = Math.max(leftMs, s.leftMs)
565  }
566  const total = batch.length
567  const percent = total ? Math.round((100 * (done + progress)) / total) : 0
568  return { done, total, running: running.length, percent, leftMs: Math.max(0, leftMs), isLong }
569}
570
571// Whether the band shows: a helper runs, or the last one ended within the grace.
572function isBandShown(batch: AgentCard[], t: number): boolean {
573  if (batch.length === 0) return false
574  if (batch.some(c => c.status === 'running')) return true
575  return t - Math.max(...batch.map(c => c.endedAt || c.startedAt)) <= GRACE_MS
576}
577
578function left(ms: number): string {
579  const s = Math.max(1, Math.round(ms / 1000))
580  if (s < 60) return `${s}s`
581  const m = Math.round(s / 60)
582  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`
583}
584
585// The progress bar's words, the same in the band above the prompt and in the pane.
586function barOf(batch: AgentCard[], all: AgentCard[], t: number) {
587  const est = estimate(batch, all, t)
588  const isRunning = est.running > 0
589  return {
590    est,
591    isRunning,
592    title: '● Helpers',
593    pct: `≈${est.percent}%`,
594    eta: !isRunning ? 'done' : est.isLong ? 'running long' : `≈${left(est.leftMs)} left`,
595    count: `×${est.running}`,
596    label: ` Tasks ${est.done}/${est.total} `,
597  }
598}
599
600// How much of a bar `track` cells long is filled; never less than its label.
601function filledOf(track: number, percent: number, label: string): number {
602  return Math.min(track, Math.max(label.length, Math.round((track * percent) / 100)))
603}
604
605async function hideBand($: EngineInterface) {
606  const ids = Object.keys(await read($, agents))
607  await update($, hiddenIds, () => ids)
608}
609
610let window = 1_000_000
611let ticker: { cancel: () => void } | undefined
612
613// Only agent.spawn and the list read at session.start make a card. A helper of
614// an earlier conversation can still be mid-call after a /clear, and its events
615// must not bring its card back.
616async function edit($: EngineInterface, id: string, fn: (card: AgentCard) => AgentCard) {
617  await update($, agents, all => {
618    const card = all[id]
619    return card ? { ...all, [id]: fn(card) } : all
620  })
621}
622
623async function isKnown($: EngineInterface, agentId: string | undefined): Promise<boolean> {
624  return !agentId || !!(await read($, agents))[agentId]
625}
626
627// A tool call starts on its loop: a helper's card, or the main row.
628async function beginCall($: EngineInterface, e: ToolCallInput, at: number) {
629  const label = labelOf(e)
630  if (e.agentId) {
631    await edit($, e.agentId, c => ({ ...startCall(c, label, at), tools: c.tools + 1, lastTool: e.tool }))
632  } else {
633    await update($, main, m => startCall(m, label, at))
634  }
635}
636
637// A tool call ends; a helper's task-list call also updates its plan.
638async function endCallOn($: EngineInterface, e: ToolCallInput, result: ToolCallResult | undefined, at: number) {
639  if (e.agentId) {
640    const isPlan = PLAN_TOOLS.includes(e.tool)
641    await edit($, e.agentId, c => ({ ...endCall(c, at), plan: isPlan ? planAfter(c.plan, e, result) : c.plan }))
642  } else {
643    await update($, main, m => endCall(m, at))
644  }
645}
646
647function newShell(id: string, e: ToolCallInput, at: number): ShellRow {
648  const a = e as unknown as Record<string, unknown>
649  const isMonitor = e.tool === 'Monitor'
650  return {
651    id, taskId: '',
652    description: fit(oneLine(str(a.description)), 200) || (isMonitor ? 'watch' : 'shell command'),
653    owner: e.agentId ?? 'main',
654    kind: isMonitor ? 'monitor' : 'bash',
655    isBackground: isMonitor || a.run_in_background === true,
656    status: 'running', exitCode: -1, startedAt: at, endedAt: 0, endsWithOwner: false,
657  }
658}
659
660// What a shell call's result says: started in the background (it keeps
661// running until its notification), or finished, with its exit code when the
662// error text leads with one.
663function settled(row: ShellRow, result: ToolCallResult | undefined, at: number): ShellRow {
664  if (!result || result.deny !== undefined) return { ...row, status: 'stopped', endedAt: at }
665  const r = typeof result.result === 'object' && result.result ? result.result as Record<string, unknown> : {}
666  const taskId = str(r.backgroundTaskId) || str(r.taskId)
667  if (!result.isError && taskId) {
668    return { ...row, taskId, isBackground: true, endsWithOwner: r.backgroundEndsWithFinalResponse === true }
669  }
670  const code = str(result.text).match(/^Exit code (-?\d+)/)?.[1]
671  const exitCode = code === undefined ? (result.isError ? -1 : 0) : Number(code)
672  const status = r.interrupted === true ? 'stopped' : result.isError ? 'failed' : 'ok'
673  return { ...row, status, exitCode, endedAt: at }
674}
675
676// A shell that keeps running opens the pane the way a helper does, so a Codex
677// review alone still shows: a background command, a monitor, or a Codex run
678// (python running the codex_delegate.py runner, or a raw `codex exec` in
679// command position). A quick foreground command does not, or the pane would
680// open on every ls; neither does the runner's `verify`, a grep for the file
681// or its test file.
682const CODEX_RUN = /\bpython[\d.]*\s+(?:-\S+\s+)*(?:\S*\/)?codex_delegate\.py(?!\s+verify\b)|(?:^|[;&|(]|\b(?:then|do|nohup|time)\s)\s*codex\s+exec\b/m
683
684function opensPane(e: ToolCallInput): boolean {
685  const a = e as unknown as Record<string, unknown>
686  return e.tool === 'Monitor' || a.run_in_background === true || CODEX_RUN.test(str(a.command))
687}
688
689async function startShell($: EngineInterface, id: string, e: ToolCallInput, at: number) {
690  await update($, shells, all => pruned({ ...all, [id]: newShell(id, e, at) }))
691}
692
693async function settleShell($: EngineInterface, id: string, result: ToolCallResult | undefined, at: number) {
694  await update($, shells, all => {
695    const row = all[id]
696    return row ? pruned({ ...all, [id]: settled(row, result, at) }) : all
697  })
698}
699
700// A background task's notification ends the shell it names, by task id or call id.
701async function noteShells($: EngineInterface, body: string, at: number) {
702  const note = parseNote(body)
703  if (!note) return
704  await update($, shells, all => pruned(Object.fromEntries(Object.values(all).map(row => {
705    const isIt = row.status === 'running'
706      && ((note.taskId !== '' && row.taskId === note.taskId) || (note.toolUseId !== '' && row.id === note.toolUseId))
707    return [row.id, isIt ? { ...row, status: note.status, exitCode: note.exitCode, endedAt: at } : row]
708  }))))
709}
710
711// A helper's final answer ends the background commands that die with it.
712async function endOwnedShells($: EngineInterface, owner: string, at: number) {
713  await update($, shells, all => pruned(Object.fromEntries(Object.values(all).map(row => [
714    row.id,
715    row.owner === owner && row.status === 'running' && row.endsWithOwner ? { ...row, status: 'stopped', endedAt: at } : row,
716  ]))))
717}
718
719// ---- Codex runs ------------------------------------------------------------
720// A Bash call that runs codex_delegate.py is a Codex run, and gets a card of its
721// own instead of a shell row. The command is read for its flags only; its text
722// never reaches the pane. Everything else comes from the files the run writes:
723// <dir of --out>/.codex-runs/<run id>/receipt.json and child-process.json, and
724// the Codex session log the child keeps open.
725
726// codex_delegate.py's own limit when the call names none.
727const CODEX_DEFAULT_LIMIT_MS = 1800 * 1000
728// How often a running card's files are read again.
729const CODEX_READ_MS = 10_000
730// How much of the end of the session log is read for the latest token count.
731const CODEX_LOG_TAIL = 262_144
732// A run whose receipt says it ended gets this long for its end notice, then
733// ends from the receipt: a helper's run can end with nobody told.
734const CODEX_RECEIPT_GRACE_MS = 30_000
735// The runner stops Codex at its limit; a run not heard from by its limit plus
736// this has failed, and the card says so.
737const CODEX_OVER_LIMIT_MS = 60_000
738// Past its limit, a read of its files this long under way is not waited for.
739const CODEX_STUCK_READ_MS = 60_000
740// The profiles the runner runs once more on another model, after one of these
741// exits, in a run folder of its own. Both are empty when the runner has no
742// fallback.
743const CODEX_FALLBACK_PROFILES: string[] = []
744const CODEX_FALLBACK_EXITS: number[] = []
745// Past runs: finished receipts of the last 30 days, read again hourly. A usual
746// time needs at least three of them.
747const HISTORY_DAYS = 30
748const HISTORY_TTL_MS = 60 * 60_000
749const HISTORY_MIN = 3
750const HISTORY_MAX = 600
751// A run folder is named <UTC stamp>-<label>-<6 hex>.
752const RUN_DIR_RE = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z-(.+)-[0-9a-f]{6}$/
753// The runner's exit codes, in words (codex-delegation.md, "Exit codes", and the
754// EXIT_ constants in codex_delegate.py). Exit 0 is said by the job type.
755const CODEX_EXITS: Record<number, [AgentCard['status'], string]> = {
756  2: ['failed', 'no codex command on this machine'],
757  3: ['failed', 'no Codex login'],
758  4: ['failed', 'timed out'],
759  5: ['failed', 'Codex itself failed'],
760  6: ['failed', 'empty answer'],
761  7: ['failed', 'unsafe output path'],
762  8: ['failed', 'another run holds this output file'],
763  9: ['failed', 'the answer names other commits'],
764  10: ['failed', 'malformed answer'],
765  11: ['failed', 'the answer says it is incomplete'],
766  12: ['failed', 'the model provider refused it'],
767  13: ['failed', 'refused before it started'],
768  14: ['failed', 'the confined run was refused'],
769  15: ['stopped', 'Codex review is off (CODEX_REVIEW=off)'],
770  130: ['stopped', 'interrupted'],
771  143: ['stopped', 'interrupted'],
772}
773
774type CodexCall = {
775  label: string; out: string; cwd: string; repo: string; base: string; head: string
776  requestId: string; jobType: string; model: string; limitMs: number; hasFallback: boolean
777  vars: Record<string, string>
778}
779
780// A token of a shell command: a word, an operator that ends a simple command
781// (; & | && || ( ) and a newline), a redirection whose target is the next word,
782// or a redirection that is whole by itself (2>&1).
783type Token = { kind: 'word' | 'op' | 'redir' | 'fd'; text: string }
784
785// A shell command's tokens, split as the shell would: quotes, $( ... ) and
786// ` ... ` stay inside their word, a backslash-newline joins, and a comment and
787// a heredoc's body are dropped, so an apostrophe in either opens no quote.
788function shellTokens(command: string): Token[] {
789  const out: Token[] = []
790  const n = command.length
791  let cur = ''
792  let has = false
793  let i = 0
794  // Heredocs whose bodies start at the next newline; and one whose delimiter is the next word.
795  const bodies: Array<{ end: string; strip: boolean }> = []
796  let delimiter: { strip: boolean } | undefined
797  const push = () => {
798    if (has && delimiter) {
799      bodies.push({ end: cur, strip: delimiter.strip })
800      delimiter = undefined
801    } else if (has) {
802      out.push({ kind: 'word', text: cur })
803    }
804    cur = ''
805    has = false
806  }
807  // From a `$(` or a backtick at `from`, the index just past its close.
808  const closeOf = (from: number): number => {
809    if (command[from] === '`') {
810      let j = from + 1
811      while (j < n && command[j] !== '`') j += command[j] === '\\' ? 2 : 1
812      return Math.min(n, j + 1)
813    }
814    let depth = 0
815    let quote = ''
816    for (let j = from + 1; j < n; j++) {
817      const c = command[j]!
818      if (quote) {
819        if (c === quote) quote = ''
820        else if (c === '\\' && quote === '"') j++
821      } else if (c === "'" || c === '"') quote = c
822      else if (c === '\\') j++
823      else if (c === '(') depth++
824      else if (c === ')' && --depth === 0) return j + 1
825    }
826    return n
827  }
828  const opensSub = (j: number) => (command[j] === '$' && command[j + 1] === '(') || command[j] === '`'
829  while (i < n) {
830    const ch = command[i]!
831    if (ch === "'") {
832      const close = command.indexOf("'", i + 1)
833      const end = close < 0 ? n : close
834      cur += command.slice(i + 1, end)
835      has = true
836      i = end + 1
837    } else if (ch === '"') {
838      has = true
839      let j = i + 1
840      while (j < n && command[j] !== '"') {
841        if (command[j] === '\\' && j + 1 < n) {
842          cur += command[j + 1]
843          j += 2
844        } else if (opensSub(j)) {
845          const k = closeOf(j)
846          cur += command.slice(j, k)
847          j = k
848        } else {
849          cur += command[j]
850          j++
851        }
852      }
853      i = j + 1
854    } else if (opensSub(i)) {
855      const k = closeOf(i)
856      cur += command.slice(i, k)
857      has = true
858      i = k
859    } else if (ch === '\\') {
860      if (i + 1 < n && command[i + 1] !== '\n') {
861        cur += command[i + 1]
862        has = true
863      }
864      i += 2
865    } else if (ch === '#' && !has) {
866      while (i < n && command[i] !== '\n') i++
867    } else if (ch === '\n') {
868      push()
869      out.push({ kind: 'op', text: ';' })
870      i++
871      // Each heredoc opened on that line has its body here, up to its delimiter line.
872      for (const body of bodies) {
873        while (i < n) {
874          const eol = command.indexOf('\n', i)
875          const line = command.slice(i, eol < 0 ? n : eol)
876          i = eol < 0 ? n : eol + 1
877          if ((body.strip ? line.replace(/^\t+/, '') : line) === body.end) break
878        }
879      }
880      bodies.length = 0
881    } else if (ch === '<' || ch === '>' || (ch === '&' && command[i + 1] === '>')) {
882      // A number right before it is the file descriptor, not a word.
883      if (ch !== '&' && has && /^\d+$/.test(cur)) {
884        cur = ''
885        has = false
886      } else {
887        push()
888      }
889      if (command.startsWith('<<<', i)) {
890        out.push({ kind: 'redir', text: '<<<' })
891        i += 3
892      } else if (command.startsWith('<<', i)) {
893        const strip = command[i + 2] === '-'
894        delimiter = { strip }
895        i += strip ? 3 : 2
896      } else {
897        const op = command.slice(i).match(/^(&>>|&>|>>|>&|<&|>\||<>|>|<)/)?.[1] ?? ch
898        i += op.length
899        const fd = op.endsWith('&') ? command.slice(i).match(/^(\d+|-)/)?.[1] : undefined
900        if (fd !== undefined) {
901          i += fd.length
902          out.push({ kind: 'fd', text: op + fd })
903        } else {
904          out.push({ kind: 'redir', text: op })
905        }
906      }
907    } else if (/\s/.test(ch)) {
908      push()
909      i++
910    } else if (';&|()'.includes(ch)) {
911      push()
912      const two = command.slice(i, i + 2)
913      const op = ['&&', '||', ';;', '|&'].includes(two) ? two : ch
914      out.push({ kind: 'op', text: op })
915      i += op.length
916    } else {
917      cur += ch
918      has = true
919      i++
920    }
921  }
922  push()
923  return out
924}
925
926// A command's simple commands, each as its words, redirections left out.
927function statementsOf(command: string): string[][] {
928  const all: string[][] = [[]]
929  let isTarget = false
930  for (const tok of shellTokens(command)) {
931    if (tok.kind === 'op') all.push([])
932    else if (tok.kind === 'redir') isTarget = true
933    else if (tok.kind === 'word' && isTarget) isTarget = false
934    else if (tok.kind === 'word') all[all.length - 1]!.push(tok.text)
935  }
936  return all.filter(s => s.length > 0)
937}
938
939const ASSIGN_RE = /^([A-Za-z_]\w*)=([\s\S]*)$/
940const PYTHON_RE = /^python(\d+(\.\d+)*)?$/
941const RUNNER_RE = /(^|\/)codex_delegate\.py$/
942// Words a simple command may start with before its program.
943const LEADERS = new Set([
944  'do', 'then', 'else', 'elif', 'if', 'while', 'until', '{', '!', 'time', 'nohup', 'exec',
945  'command', 'builtin', 'export', 'local', 'readonly', 'declare',
946])
947// Python's own flags that may come before the script; -X and -W take a value.
948const PY_FLAGS = new Set(['-u', '-I', '-B', '-E', '-s', '-S', '-O', '-OO', '-q', '-b', '-bb', '-v', '-d', '-P', '-X', '-W'])
949
950// A value with the command's own variables filled in; an unknown one stays as written.
951function expandVars(raw: string, vars: Record<string, string>): string {
952  let v = raw
953  for (let pass = 0; pass < 3 && v.includes('$'); pass++) {
954    v = v.replace(/\$\{(\w+)\}|\$(\w+)/g, (all, a, b) => vars[a ?? b] ?? all)
955  }
956  return v
957}
958
959// The runner's own spelling of a label in a run folder's name (safe_label).
960function safeLabel(label: string): string {
961  return label.replace(/[^A-Za-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'delegation'
962}
963
964// The flags of the first codex_delegate.py run in a command, or undefined when
965// it runs none. A run is the runner in command position, alone or after a
966// Python interpreter: a command that only names the file (cat, grep, git add,
967// a heredoc's text) is not one, and neither are its verify, --check and --help
968// forms. The command's own assignments and cd's are kept, to read its paths.
969function codexCallOf(command: string): CodexCall | undefined {
970  const vars: Record<string, string> = {}
971  let cwd = ''
972  for (const words of statementsOf(command)) {
973    let i = 0
974    let isEnv = false
975    while (i < words.length) {
976      const w = words[i]!
977      const m = w.match(ASSIGN_RE)
978      if (m) vars[m[1]!] = m[2]!
979      else if (w === 'env') isEnv = true
980      else if (!LEADERS.has(w) && !(isEnv && w.startsWith('-'))) break
981      i++
982    }
983    const prog = words[i]
984    if (prog === undefined) continue
985    if (prog === 'cd' || prog === 'pushd') {
986      const to = words[i + 1] ?? '~'
987      if (to !== '-') cwd = !cwd || /^[/~$]/.test(to) ? to : `${cwd}/${to}`
988      continue
989    }
990    let at = i
991    if (PYTHON_RE.test(baseName(prog))) {
992      at++
993      while (at < words.length && PY_FLAGS.has(words[at]!)) at += words[at] === '-X' || words[at] === '-W' ? 2 : 1
994    }
995    if (!RUNNER_RE.test(words[at] ?? '')) continue
996    const args = words.slice(at + 1)
997    if (args[0] === 'verify' || args.some(a => a === '--check' || a === '--help' || a === '-h')) continue
998    const flag = (name: string): string => {
999      for (let k = 0; k < args.length; k++) {
1000        const a = args[k]!
1001        if (a === name) return args[k + 1] ?? ''
1002        if (a.startsWith(`${name}=`)) return a.slice(name.length + 1)
1003      }
1004      return ''
1005    }
1006    // A value the command builds from something unknown ($(uuidgen), an unset variable) is ''.
1007    const known = (raw: string) => {
1008      const v = expandVars(raw, vars)
1009      return /[$`]/.test(v) ? '' : v
1010    }
1011    const label = flag('--label')
1012    const seconds = Number(known(flag('--timeout')))
1013    // A profile the command builds from something unknown may have a fallback
1014    // when the runner has any: it keeps the longer time, since failing a live
1015    // run is the worse mistake.
1016    const profile = flag('--profile')
1017    const hasFallback = profile !== '' && CODEX_FALLBACK_PROFILES.length > 0
1018      && (known(profile) === '' || CODEX_FALLBACK_PROFILES.includes(known(profile)))
1019    return {
1020      label: label ? (known(label) && safeLabel(known(label))) : 'delegation',
1021      out: flag('--out'),
1022      cwd,
1023      repo: known(flag('--repo')),
1024      base: known(flag('--base')),
1025      head: known(flag('--head')),
1026      requestId: known(flag('--request-id')),
1027      jobType: known(flag('--job-type')),
1028      model: known(flag('--model')),
1029      limitMs: seconds > 0 ? seconds * 1000 : CODEX_DEFAULT_LIMIT_MS,
1030      hasFallback,
1031      vars,
1032    }
1033  }
1034  return undefined
1035}
1036
1037const envCache = new Map<string, string>()
1038
1039// One environment variable of this machine, '' when unset.
1040async function envOf($: EngineInterface, name: string): Promise<string> {
1041  const known = envCache.get(name)
1042  if (known !== undefined) return known
1043  let value = ''
1044  try {
1045    const r = await $.process.run(['/usr/bin/printenv', name], { timeoutMs: 5_000 })
1046    value = r.exitCode === 0 ? r.stdout.trim() : ''
1047  } catch {}
1048  envCache.set(name, value)
1049  return value
1050}
1051
1052// A path from the command, made absolute: its own variables first, then this
1053// machine's environment, a leading ~, and a relative path against the
1054// command's own cd (itself against the session's folder); '' when any part is
1055// unknown.
1056async function pathOf($: EngineInterface, raw: string, vars: Record<string, string>, cwd: string): Promise<string> {
1057  let v = expandVars(raw, vars)
1058  if (v.includes('`') || v.includes('$(')) return ''
1059  for (const m of [...v.matchAll(/\$\{(\w+)\}|\$(\w+)/g)]) {
1060    const value = await envOf($, m[1] ?? m[2] ?? '')
1061    if (!value) return ''
1062    v = v.replace(m[0], value)
1063  }
1064  if (v === '~' || v.startsWith('~/')) v = (await envOf($, 'HOME')) + v.slice(1)
1065  if (v && !v.startsWith('/')) {
1066    let base = cwd ? await pathOf($, cwd, vars, '') : ''
1067    if (cwd && !base) return ''
1068    if (!base) {
1069      try {
1070        base = await $.session.cwd()
1071      } catch {
1072        return ''
1073      }
1074    }
1075    v = `${base}/${v}`
1076  }
1077  const parts: string[] = []
1078  for (const p of v.split('/')) {
1079    if (p === '..') parts.pop()
1080    else if (p !== '' && p !== '.') parts.push(p)
1081  }
1082  return v ? `/${parts.join('/')}` : ''
1083}
1084
1085// What a run works on, in a few words: the repository folder and, for a
1086// review, base..head; never the command.
1087function targetOf(repo: string, base: string, head: string): string {
1088  const range = base && head ? `${base.slice(0, 7)}..${head.slice(0, 7)}` : ''
1089  return [repo ? baseName(repo) : '', range].filter(Boolean).join(' ')
1090}
1091
1092// What a running card says it is doing, and what it was asked, by job type.
1093function verbOf(jobType: string): [string, string] {
1094  if (jobType === 'review') return ['Reviewing', 'Reviews']
1095  if (jobType === 'plan') return ['Planning for', 'Plans for']
1096  return ['Working on', 'Works on']
1097}
1098
1099function ended(code: number, jobType: string): [AgentCard['status'], string] {
1100  if (code === 0) return ['done', jobType === 'review' || jobType === 'plan' ? `${jobType} finished` : 'finished']
1101  return CODEX_EXITS[code] ?? ['failed', `exit ${code}`]
1102}
1103
1104// A sha the command names agrees with the request's resolved one; a name
1105// (main, HEAD) is not compared.
1106function agrees(literal: string, sha: unknown): boolean {
1107  return !/^[0-9a-f]{7,40}$/i.test(literal) || str(sha).toLowerCase().startsWith(literal.toLowerCase())
1108}
1109
1110type Found = { dir: string; at: number; requestId: string }
1111type Before = { dir: string; requestId: string; request: Record<string, unknown> }
1112
1113// What a request works on. Without --request-id the runner gives each attempt
1114// a new id, so a fallback is known by these instead: the same brief (its hash)
1115// in the same checkout, which another run of the same commits does not share.
1116const WORK_FIELDS = ['job_type', 'repo_id', 'base_sha', 'head_sha', 'checkout', 'brief_sha256']
1117const sameWork = (a: Record<string, unknown>, b: Record<string, unknown>) =>
1118  WORK_FIELDS.every(k => str(a[k]) === str(b[k]))
1119
1120// The run folder a card's run writes: of its label, stamped no earlier than the
1121// second the call started, not another card's, and agreeing with every request
1122// field the command names literally (request id, base, head); the earliest
1123// such, since a later one is a later run. With `after`, the folder a profile's
1124// fallback made, stamped no earlier than the one it follows: the same request
1125// id when the command names one, else the same work.
1126async function findRunDir(
1127  $: EngineInterface, run: CodexRun, from: number, taken: Set<string>, after?: Before,
1128): Promise<Found | undefined> {
1129  if (!run.out) return undefined
1130  const dir = `${run.out.replace(/\/[^/]*$/, '') || '/'}/.codex-runs`
1131  const floor = Math.floor(from / 1000) * 1000
1132  const folders = (await $.fs.list(dir)).flatMap(e => {
1133    const m = e.name.match(RUN_DIR_RE)
1134    if (!m || e.kind !== 'dir' || (run.label && m[7] !== run.label)) return []
1135    const at = Date.UTC(+m[1]!, +m[2]! - 1, +m[3]!, +m[4]!, +m[5]!, +m[6]!)
1136    return at < floor ? [] : [{ dir: `${dir}/${e.name}`, at }]
1137  }).sort((a, b) => a.at - b.at || a.dir.localeCompare(b.dir))
1138  for (const f of folders) {
1139    if (taken.has(f.dir) || f.dir === after?.dir) continue
1140    // request.json comes just before the started receipt; a run refused that
1141    // early has only its receipt's bare request.
1142    const request = await jsonAt($, `${f.dir}/request.json`)
1143      ?? (await jsonAt($, `${f.dir}/receipt.json`))?.request as Record<string, unknown> | undefined
1144    if (!request) continue
1145    const requestId = str(request.request_id)
1146    if (after) {
1147      if (run.requestId ? requestId !== after.requestId : !sameWork(request, after.request)) continue
1148    } else if ((run.requestId && requestId !== run.requestId) || !agrees(run.base, request.base_sha) || !agrees(run.head, request.head_sha)) {
1149      continue
1150    }
1151    return { dir: f.dir, at: f.at, requestId }
1152  }
1153  return undefined
1154}
1155
1156type PastRun = { path: string; label: string; job: string; repo: string; ms: number; finishedAt: number }
1157// Each folder's succeeded runs, read once an hour and once at a time however
1158// many ticks ask: the project folders, and each run's own runs folder once it
1159// is known, so runs kept beside it count too.
1160const pastByRoot = new Map<string, { at: number; runs: PastRun[] }>()
1161const pastLoading = new Map<string, Promise<void>>()
1162
1163// Succeeded Codex runs that finished in the last HISTORY_DAYS days, newest
1164// HISTORY_MAX of them, from the project folders' run folders and this run's own.
1165async function pastRuns($: EngineInterface, t: number, runsDir: string): Promise<PastRun[]> {
1166  const home = await envOf($, 'HOME')
1167  const roots = [...new Set([home ? `${home}/.claude/projects` : '', runsDir].filter(Boolean))]
1168  const byPath = new Map<string, PastRun>()
1169  for (const root of roots) {
1170    const cached = pastByRoot.get(root)
1171    if (!cached || t - cached.at > HISTORY_TTL_MS) {
1172      if (!pastLoading.has(root)) {
1173        pastLoading.set(root, loadPast($, t, root).finally(() => pastLoading.delete(root)))
1174      }
1175      await pastLoading.get(root)
1176    }
1177    for (const run of pastByRoot.get(root)?.runs ?? []) byPath.set(run.path, run)
1178  }
1179  return [...byPath.values()]
1180    .filter(r => t - r.finishedAt <= HISTORY_DAYS * 86_400_000)
1181    .sort((a, b) => b.finishedAt - a.finishedAt)
1182    .slice(0, HISTORY_MAX)
1183}
1184
1185// One folder's succeeded runs. `find -mtime` only narrows the files read: a
1186// receipt is written when its run ends, so the finish date is checked above.
1187async function loadPast($: EngineInterface, t: number, root: string) {
1188  const runs: PastRun[] = []
1189  try {
1190    const r = await $.process.run(
1191      ['/usr/bin/find', root, '-maxdepth', '4', '-path', '*/.codex-runs/*/receipt.json', '-mtime', `-${HISTORY_DAYS + 1}`],
1192      { timeoutMs: 20_000 },
1193    )
1194    const paths = [...new Set(r.stdout.split('\n').filter(Boolean).map(p => p.replace(/\/{2,}/g, '/')))]
1195    for (const path of paths) {
1196      const d = await jsonAt($, path)
1197      if (!d || d.terminal_status !== 'succeeded') continue
1198      const finishedAt = Date.parse(str(d.finished_utc))
1199      const ms = finishedAt - Date.parse(str(d.started_utc))
1200      const m = str(d.run_id).match(RUN_DIR_RE)
types/index.d.ts 162 lines
1// One thing a loop did: a tool call's label (never a command's raw text) and when.
2export type Action = {
3  label: string
4  at: number
5}
6
7// What a loop is doing: its latest tool call, or failing that its latest words.
8export type Activity = {
9  // The latest tool call's label, '' before the first one.
10  doing: string
11  // When that call started.
12  doingAt: number
13  // Tool calls of this loop still in flight; 0 between calls.
14  active: number
15  // When the last call in flight ended (0 while one runs).
16  idleSince: number
17  // The first line of the loop's latest model text, and when it came.
18  said: string
19  saidAt: number
20  // The last five actions, newest first.
21  recent: Action[]
22}
23
24export type AgentCard = Activity & {
25  id: string
26  description: string
27  type: string
28  model: string
29  effort: string
30  status: 'running' | 'done' | 'failed' | 'stopped'
31  startedAt: number
32  endedAt: number
33  steps: number
34  tools: number
35  lastTool: string
36  tokens: number
37  outTokens: number
38  context: number
39  cost: number
40  // What the helper was asked: the Agent call's prompt, whole.
41  prompt: string
42  // The helper's own task list, when it keeps one; empty when it does not.
43  plan: PlanStep[]
44  // Set on a card that is a Codex run (codex_delegate.py) rather than a helper.
45  codex?: CodexRun
46}
47
48// A Codex run the session started through codex_delegate.py, read from its
49// command's flags and then from the files the run writes.
50export type CodexRun = {
51  // Its --label as the runner writes it into the run folder's name (default
52  // "delegation"); '' when the command builds it from something unknown.
53  label: string
54  // Its --out, with the command's own variables filled in; '' when unknown.
55  out: string
56  // Its time limit (--timeout, else the runner's 1800 seconds), in ms.
57  limitMs: number
58  // Request fields the command names literally ('' when not, or not known):
59  // they pick its run folder out of others of the same label.
60  requestId: string
61  base: string
62  head: string
63  // Its --job-type until the run's request says ('review', 'plan', 'legacy').
64  jobType: string
65  // The repository its request names (repo_id, e.g. github.com/o/r); '' until read.
66  repoId: string
67  // True when its --profile has a fallback that can run it a second time (or
68  // the profile is not known).
69  hasFallback: boolean
70  // True once the card follows that fallback's own run folder.
71  isFallback: boolean
72  // 'main', or the id of the helper that started it.
73  owner: string
74  // True when the background command ends with its helper's final answer.
75  endsWithOwner: boolean
76  // The background task's id, once the call answered with one.
77  taskId: string
78  // The runner's exit code once it ended; else -1.
79  exitCode: number
80  // The run folder (<dir of out>/.codex-runs/<run id>), its stamp and the
81  // request id it holds, once found.
82  runDir: string
83  runAt: number
84  runRequestId: string
85  // The Codex process and its session log, once found.
86  pid: number
87  rollout: string
88  // The model's context window, from its log; 0 until read.
89  window: number
90  // When the run's files were last read.
91  readAt: number
92  // When its receipt was first seen saying it ended (0 until then), and what
93  // it said: the exit code (-1 when none) and the terminal status.
94  endSeenAt: number
95  endCode: number
96  endStatus: string
97  // How long past runs like it took (the median: same repository, else same
98  // label, else same job type), and how many there were; 0 when fewer than
99  // three, and the card counts to its limit instead.
100  expectedMs: number
101  history: number
102  // Where its limit is counted from: the call, or a fallback's own start.
103  deadlineFrom: number
104}
105
106// One step of a helper's task list (TodoWrite, or TaskCreate and TaskUpdate).
107export type PlanStep = {
108  id: string
109  title: string
110  // The step's present-continuous form ("Running tests"), when given.
111  active: string
112  status: 'pending' | 'in_progress' | 'completed'
113}
114
115// The main loop of the session.
116export type MainRow = Activity & {
117  // The last prompt the owner typed (first line), and when.
118  lastPrompt: string
119  lastPromptAt: number
120}
121
122// One shell command of the session, by whoever ran it.
123export type ShellRow = {
124  // The tool_use id of the Bash or Monitor call.
125  id: string
126  // The background task's id, once the call answered with one.
127  taskId: string
128  // The call's own description; never its command.
129  description: string
130  // 'main', or the id of the helper that ran it.
131  owner: string
132  kind: 'bash' | 'monitor'
133  isBackground: boolean
134  status: 'running' | 'ok' | 'failed' | 'stopped'
135  // The exit code, when the notification says it; else -1.
136  exitCode: number
137  startedAt: number
138  endedAt: number
139  // True when the command ends with its helper's final answer.
140  endsWithOwner: boolean
141}
142
143declare module 'claude-code' {
144  interface PluginState {
145    'agents-panel': {
146      agents: Record<string, AgentCard>
147      isCollapsed: boolean
148      now: number
149      hiddenIds: string[]
150      main: MainRow
151      shells: Record<string, ShellRow>
152      isShellsCollapsed: boolean
153      // Ids of the cards whose prompt is shown whole.
154      openPrompts: string[]
155      // The conversation the cards belong to; a new id clears them.
156      sessionId: string
157      // Whether the owner has typed a prompt in this conversation yet.
158      hasPrompted: boolean
159    }
160  }
161}
162