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…

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.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./thread-panel opens it too.files-panel: a live Files pane./files-panel opens the pane.The panes start over when you /clear or start a new conversation.

The diagram's source is docs/mods-flow.drawio.
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
Each mod has switches in /config:
agents-panelfiles-panelthread-panel/thread-panel says it is off.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.
MIT. See LICENSE.
hooks/register.tsx 2025 lines1import { 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 lines1// 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