Live view of every agent, subagent and Codex job: terminal pane plus a local web board

/plugin install agent-board --marketplace 66Hamidreza/claude-code-mods
Observes the main session, subagents, workflow agents, Codex exec runs, and standalone Codex main threads driven by T3 Code. Shows model, current tool, recent calls, errors, tokens, duration, and heuristic health indicators in a terminal pane and a local web board.
/board: open the pane./board web: start the board at http://localhost:7788/ for all local sessions (static fallback server, no usage header; see Always-on web board)./board notify on / /board notify off: persist the native-notification preference; toasts remain enabled./board clear: remove finished non-main nodes and clear event history.The pane also offers controls for finished agents and history. The web board supports filtering, search, pinning and collapse, with browser-local preferences and opt-in browser alerts.
No custom environment variables; HOME locates ~/.claude/mods-data/agent-board/web and ~/.codex/sessions. Polls agent state every second and writes session snapshots for the web board. Inside T3 Code, delegated children are read from ~/.t3/userdata/statev2.sqlite (read-only, matched to the session by its provider thread id). Python 3 reads recent Codex rollout logs and serves static files bound to 127.0.0.1:7788. Explicit CX_RUN_V1 owner/run markers attribute delegated jobs; interactive Codex TUI sessions are excluded. The always-on server independently scans T3 Code Codex main rollouts, excluding CX-marked runs and native IDs mapped to T3 delegated children. Orchestration advice from prompt-router is compared with observed execution.
The top of the web page shows how much of the Claude and Codex limits is left (5-hour and weekly windows, reset countdowns) and the Codex subscription end date. server.py computes it on request from local files only: Claude from the rate_limit_event entries T3 Code logs under ~/.t3/userdata/logs/provider, Codex from the newest rollout in ~/.codex/sessions, and the plan dates from the Codex ID-token claims. No token is returned. Claude's renewal date is not stored locally and shows as unavailable; Claude data refreshes only while a T3 session is active, and the card shows when it was last updated.
Optional: serve the board without any session open. Copy agent-board-web.service to ~/.config/systemd/user/ (it runs server.py; adjust the path to where the mod is installed), then systemctl --user enable --now agent-board-web. It serves the static files plus /usage.json (Claude and Codex limits left, read from T3's provider logs, Codex rollouts and the Codex plan dates; Claude's renewal date is not stored locally). The /board web fallback server has no /usage.json. Claude session data comes from snapshots live sessions write. Standalone T3 Code Codex rows are discovered independently via /codex-sessions.json, so they appear without any Claude session or mod hook running. The server serves the installed hooks/web.ts page directly. The scanner reads only the newest two day directories, keeps rollouts modified within two hours, caches unchanged files, and shows project, model, last action and token totals. task_complete (or an aborted turn) marks done; task_started resumes running, with five minutes of log silence also treated as done. T3 child mappings are read-only and refreshed each scan; if an existing T3 database cannot be queried, standalone results are withheld until it recovers. Install server.py and hooks/codex_standalone.py alongside the updated hooks/web.ts, then restart the service. Terminal panes continue to show hook-driven sessions and children. /board web copes with the port already being served.
Health flags are heuristics (repeated calls, repeated failures, inactivity), not proof of failure. Quiet or long-running work may be flagged. Codex parsing depends on rollout formats and scans recent day folders. The fixed port must be available; another server may already own it. Automatic browser opening tries cmd.exe; on other systems open the URL manually. Desktop notifications depend on host/OS support. Snapshots and logs can include command text, paths, and summaries; the local server has no authentication and exposes files in its web directory to local clients.
hooks/register.tsx 833 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentNode, BoardEvent, CodexInfo, PeerSession } from '../types'
5import {
6 HEALTH_BADGE,
7 STATUS_GLYPH,
8 assessHealth,
9 adviceLine,
10 adviceTitle,
11 compareAdvice,
12 freshAdvice,
13 clip,
14 codexRunId,
15 codexTasks,
16 describeTool,
17 mergeCodexRun,
18 formatDuration,
19 formatTokens,
20 fromAgentStatus,
21 isCodexCommand,
22 linkDirection,
23 modelTone,
24 newestVersions,
25 parseTaskNotification,
26 peerFromSnapshot,
27 recentSummary,
28 sparkline,
29 treeOrder,
30} from './board'
31import type { OrchestrationAdvice, Snapshot } from './board'
32import { scanCodex } from './codex'
33import { scanT3, t3Nodes } from './t3'
34import type { T3Scan } from './t3'
35import { WEB_PAGE } from './web'
36
37const PANE = 'agent-board'
38const PORT = 7788
39const HISTORY = 60
40const EVENTS = 40
41// A finished agent or turn shorter than this gets a toast, not a native notification.
42const NOTIFY_AFTER_MS = 60_000
43
44const agents = atom({ plugin: 'agent-board', key: 'agents' } as const, [])
45const activity = atom({ plugin: 'agent-board', key: 'activity' } as const, [])
46const now = atom({ plugin: 'agent-board', key: 'now' } as const, 0)
47const webUrl = atom({ plugin: 'agent-board', key: 'webUrl' } as const, null)
48const events = atom({ plugin: 'agent-board', key: 'events' } as const, [])
49const peers = atom({ plugin: 'agent-board', key: 'peers' } as const, [])
50
51type Job = { id: number; prompt: string; status: 'running' | 'done' | 'failed'; startedAt: number; endedAt?: number }
52
53// Module state: rebuilt on reload, which is fine for things that only debounce announcements.
54let sessionId = ''
55let sessionStart = 0
56let tick = 0
57let notifyOn = true
58const lastSeen = new Map<string, number>()
59const announced = new Map<string, string>()
60const turnStarts = new Map<string, number>()
61let peerStates = new Map<string, PeerSession['state']>()
62// What T3 Code says about this session's thread; empty outside T3.
63let t3Thread: { threadId: string; title: string; parentThreadId?: string | null } | undefined
64
65const dataDir = async ($: EngineInterface) => `${(await $.env.get('HOME')) ?? ''}/.claude/mods-data`
66
67const readAdvice = async ($: EngineInterface, id: string, at: number) => {
68 if (!/^[A-Za-z0-9._-]+$/.test(id)) return undefined
69 return $.fs.read(`${await dataDir($)}/orchestration/${id}.json`)
70 .then(text => freshAdvice(JSON.parse(text), at)).catch(() => undefined)
71}
72let currentAdvice: OrchestrationAdvice | undefined
73let peerAdvice = new Map<string, { advice: OrchestrationAdvice; nodes: AgentNode[] }>()
74
75const node = (fields: Pick<AgentNode, 'id' | 'kind' | 'label' | 'type' | 'model' | 'status' | 'startedAt'> & Partial<AgentNode>): AgentNode => ({
76 tools: 0,
77 errors: 0,
78 steps: 0,
79 turns: 0,
80 tokens: 0,
81 recent: [],
82 health: 'ok',
83 lastEventAt: fields.startedAt,
84 ...fields,
85})
86
87const patch = (id: string, change: (one: AgentNode) => AgentNode) => (list: AgentNode[]) =>
88 list.map(one => (one.id === id ? change(one) : one))
89
90const upsert = (fresh: AgentNode) => (list: AgentNode[]) =>
91 list.some(one => one.id === fresh.id) ? patch(fresh.id, old => ({ ...old, ...fresh })) (list) : [...list, fresh]
92
93const isOver = (one: AgentNode) => one.status === 'done' || one.status === 'failed'
94
95// Says something once per key and signature: a toast always, a native notification when `loud` and notifications are on.
96// The signature is what must change to speak again; text with a running duration in it would otherwise repeat every tick.
97const announce = async ($: EngineInterface, key: string, event: Omit<BoardEvent, 'at'>, loud: boolean, signature = event.text) => {
98 if (announced.get(key) === signature) return
99 announced.set(key, signature)
100 const at = await $.clock.now()
101 await update($, events, list => [...list, { ...event, at }].slice(-EVENTS))
102 $.ui.toast(event.text, { timeoutMs: event.level === 'info' ? 4000 : 8000 })
103 if (loud && notifyOn) void $.ui.notify(event.text, { title: 'Agent board' }).catch(() => undefined)
104}
105
106// Ownership is explicit; cwd, timing and peer snapshots never attribute a run.
107const syncCodex = async ($: EngineInterface) => {
108 const rollouts = await scanCodex($, sessionStart - 60_000).catch(() => [] as CodexInfo[])
109 for (const rollout of rollouts) {
110 const id = codexRunId(rollout, sessionId)
111 if (!id) continue
112 if (!(await read($, agents)).some(one => one.id === id)) {
113 await update($, agents, upsert(node({
114 id, parentId: 'main', kind: 'codex', label: 'codex', type: 'codex', model: 'codex',
115 status: 'running', startedAt: rollout.startedAt,
116 })))
117 }
118 const before = (await read($, agents)).find(one => one.id === id)
119 const label = rollout.tasks.length > 0 ? `codex: ${rollout.tasks.join(', ')}` : before?.label ?? 'codex'
120 await update(
121 $,
122 agents,
123 patch(id, one => ({
124 ...one,
125 label,
126 codex: rollout,
127 tokens: rollout.tokens,
128 tools: rollout.commands,
129 lastEventAt: Math.max(one.lastEventAt, rollout.activityAt),
130 detail: rollout.lastAction,
131 // A foreground `cx` returns after committing; the log says Codex itself is through.
132 status: rollout.isComplete && one.status === 'running' ? 'done' : one.status,
133 endedAt: rollout.isComplete && one.status === 'running' ? rollout.activityAt : one.endedAt,
134 })),
135 )
136 if (rollout.lastMessage && before?.codex?.lastMessage !== rollout.lastMessage && !rollout.isComplete) {
137 await announce($, `${id}:said`, { level: 'info', text: `Codex: ${clip(rollout.lastMessage, 160)}`, nodeId: id }, false)
138 }
139 if (rollout.isComplete && !before?.codex?.isComplete) {
140 const took = formatDuration(rollout.activityAt - rollout.startedAt)
141 await announce(
142 $,
143 `${id}:end`,
144 { level: 'done', text: `✓ Codex finished (${took}, ${rollout.files.length} files): ${clip(rollout.summary ?? label, 200)}`, nodeId: id },
145 true,
146 )
147 }
148 }
149}
150
151// T3 Code's delegated children and the thread title, read from T3's store.
152const syncT3 = async ($: EngineInterface, at: number) => {
153 const scan: T3Scan | undefined = await scanT3($, sessionId)
154 if (!scan) return
155 t3Thread = { threadId: scan.threadId, title: scan.title, parentThreadId: scan.parentThreadId }
156 for (const fresh of t3Nodes(scan, at)) {
157 const before = (await read($, agents)).find(one => one.id === fresh.id)
158 await update($, agents, upsert(fresh))
159 if (fresh.status === 'running' || before?.status === fresh.status) continue
160 const who = `${fresh.t3?.driver === 'codex' ? 'Codex' : 'Agent'} "${clip(fresh.label, 40)}"`
161 await announce(
162 $,
163 `${fresh.id}:end`,
164 {
165 level: fresh.status === 'done' ? 'done' : 'fail',
166 text: `${fresh.status === 'done' ? '✓' : '✗'} ${who} ${fresh.status === 'done' ? 'finished' : 'failed'} (${formatDuration((fresh.endedAt ?? at) - fresh.startedAt)}): ${clip(fresh.t3?.result ?? fresh.model, 140)}`,
167 nodeId: fresh.id,
168 },
169 fresh.status === 'failed' || (fresh.endedAt ?? at) - fresh.startedAt > NOTIFY_AFTER_MS,
170 fresh.status,
171 )
172 }
173}
174
175// Other sessions' snapshots: who is working, idle, stuck, or gone quiet.
176const syncPeers = async ($: EngineInterface, at: number) => {
177 const dir = `${await dataDir($)}/agent-board/web/sessions`
178 const entries = (await $.fs.list(dir).catch(() => [])).filter(
179 entry => entry.kind === 'file' && entry.name.endsWith('.json') && entry.name !== `${sessionId}.json` && at - entry.mtimeMs < 6 * 3600_000,
180 )
181 const snapshots = (
182 await Promise.all(entries.map(entry => $.fs.read(`${dir}/${entry.name}`).then(text => JSON.parse(text) as Snapshot).catch(() => undefined)))
183 ).filter((one): one is Snapshot => one !== undefined && Array.isArray(one.agents) && !isHelperCwd(one.cwd ?? ''))
184
185 const adviceEntries = await Promise.all(snapshots.map(async one => {
186 const advice = await readAdvice($, one.sessionId, at)
187 return advice ? [one.sessionId, { advice, nodes: one.agents }] as const : undefined
188 }))
189 peerAdvice = new Map(adviceEntries.filter(one => one !== undefined))
190
191 const list = snapshots
192 .map(one => peerFromSnapshot(one, at))
193 .filter(one => one.state !== 'ended' || at - one.updatedAt < 600_000)
194 .filter(one => one.state !== 'silent' || at - one.updatedAt < 3600_000)
195 .sort((a, b) => b.updatedAt - a.updatedAt)
196 await update($, peers, () => list)
197
198 // One live session speaks for the rest, so a frozen session is reported once.
199 const live = [sessionId, ...list.filter(one => one.state !== 'silent' && one.state !== 'ended').map(one => one.sessionId)].sort()
200 const isLeader = live[0] === sessionId
201 for (const peer of list) {
202 const was = peerStates.get(peer.sessionId)
203 if (isLeader && peer.state === 'silent' && (was === 'working' || was === 'looping' || was === 'stalled')) {
204 await announce($, `peer:${peer.sessionId}`, { level: 'warn', text: `⚠ Session ${peer.name} stopped answering while it was working` }, true, 'silent')
205 }
206 if (peer.state !== 'silent') announced.delete(`peer:${peer.sessionId}`)
207 }
208 peerStates = new Map(list.map(one => [one.sessionId, one.state]))
209}
210
211// The session's title (its ai-title in the transcript), re-read every 15s so two sessions in one folder tell apart.
212let title = ''
213let titleAt = 0
214const sessionTitle = async ($: EngineInterface, cwd: string, at: number) => {
215 if (title && at - titleAt < 15_000) return title
216 titleAt = at
217 const home = (await $.env.get('HOME')) ?? ''
218 const transcript = `${home}/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${sessionId}.jsonl`
219 const found = await $.process.run(['grep', '-o', '"aiTitle":"[^"]*"', transcript]).catch(() => undefined)
220 const last = found?.stdout.trim().split('\n').pop() ?? ''
221 title = last.match(/"aiTitle":"([^"]*)"/)?.[1] ?? title
222 return title
223}
224
225// Helper sessions (the remember plugin's summarizer) are not user work.
226function isHelperCwd(cwd: string) {
227 return cwd.startsWith('/tmp/remember-summarizer-cwd')
228}
229
230const writeSnapshot = async ($: EngineInterface, at: number, isEnded = false) => {
231 const cwd = await $.session.cwd()
232 if (isHelperCwd(cwd)) return
233 currentAdvice = await readAdvice($, sessionId, at)
234 const snapshot: Snapshot & { activity: number[]; events: BoardEvent[] } = {
235 sessionId,
236 name: cwd.split('/').pop() ?? cwd,
237 title: t3Thread?.title || (await sessionTitle($, cwd, at)),
238 t3: t3Thread,
239 cwd,
240 updatedAt: at,
241 isEnded,
242 advice: currentAdvice,
243 agents: await read($, agents),
244 activity: await read($, activity),
245 events: (await read($, events)).slice(-15),
246 }
247 await $.fs.write(`${await dataDir($)}/agent-board/web/sessions/${sessionId}.json`, JSON.stringify(snapshot)).catch(() => undefined)
248}
249
250const sync = async ($: EngineInterface) => {
251 tick++
252 const at = await $.clock.now()
253 const listed = await $.agent.list().catch(() => [])
254 for (const info of listed) {
255 const status = fromAgentStatus(info.status)
256 await update($, agents, list =>
257 list.some(one => one.id === info.id)
258 ? patch(info.id, one => (one.status === status ? one : { ...one, status, endedAt: status === 'done' || status === 'failed' ? at : undefined }))(list)
259 : status === 'done' || status === 'failed'
260 ? list
261 : [
262 ...list,
263 node({
264 id: info.id,
265 parentId: info.parentId ?? 'main',
266 kind: 'agent',
267 label: info.description || info.name || info.type,
268 type: info.type,
269 model: '',
270 status,
271 startedAt: at,
272 }),
273 ],
274 )
275 }
276
277 // Codex jobs the prompt-router mod started for this session.
278 try {
279 const jobs: Job[] = JSON.parse(await $.fs.read(`${await dataDir($)}/codex-jobs/${sessionId}.json`))
280 const known = new Set((await read($, agents)).map(one => one.id))
281 for (const job of jobs) {
282 if (job.status !== 'running' && !known.has(`codex-job-${job.id}`) && at - (job.endedAt ?? 0) > 60_000) continue
283 const status = job.status === 'running' ? 'running' : job.status === 'done' ? 'done' : 'failed'
284 const id = `codex-job-${job.id}`
285 await update(
286 $,
287 agents,
288 known.has(id)
289 ? patch(id, one => ({ ...one, status: one.status === 'done' ? one.status : status, endedAt: one.endedAt ?? job.endedAt }))
290 : upsert(node({ id, parentId: 'main', kind: 'codex', label: job.prompt.slice(0, 60), type: 'codex', model: 'codex', status, startedAt: job.startedAt, endedAt: job.endedAt })),
291 )
292 }
293 } catch {}
294
295 if (tick % 2 === 0) await syncCodex($)
296 if (tick % 3 === 0) await syncPeers($, at)
297 if (tick % 3 === 1) await syncT3($, at)
298
299 // Health: fold in what the model streamed since, then judge every node.
300 await update($, agents, list => {
301 const running = new Set(list.filter(one => one.status === 'running' && one.kind !== 'main').map(one => one.parentId ?? 'main'))
302
303 return list.map(one => {
304 const seen = Math.max(one.lastEventAt, lastSeen.get(one.id) ?? 0)
305 const judged = assessHealth({ ...one, lastEventAt: seen }, at, running.has(one.id))
306
307 return seen === one.lastEventAt && judged.health === one.health && judged.note === one.healthNote
308 ? one
309 : { ...one, lastEventAt: seen, health: judged.health, healthNote: judged.note }
310 })
311 })
312
313 for (const one of await read($, agents)) {
314 const who = one.kind === 'main' ? 'Main session' : one.kind === 'codex' ? 'Codex' : `Agent "${clip(one.label, 40)}"`
315 if (one.health === 'looping' || one.health === 'stalled') {
316 await announce($, `${one.id}:health`, { level: 'warn', text: `${HEALTH_BADGE[one.health]} ${who}: ${one.healthNote ?? ''}`, nodeId: one.id }, true, one.health)
317 } else if (one.health === 'approval') {
318 await announce($, `${one.id}:health`, { level: 'warn', text: `⏸ ${who}: ${one.healthNote ?? 'waiting for approval'}`, nodeId: one.id }, false, one.health)
319 } else announced.delete(`${one.id}:health`)
320
321 if (one.kind !== 'main' && one.kind !== 'codex' && !one.t3 && isOver(one)) {
322 const took = (one.endedAt ?? at) - one.startedAt
323 await announce(
324 $,
325 `${one.id}:end`,
326 {
327 level: one.status === 'done' ? 'done' : 'fail',
328 text: `${one.status === 'done' ? '✓' : '✗'} ${who} ${one.status === 'done' ? 'finished' : 'failed'} (${formatDuration(took)}, ${one.tools} tools)`,
329 nodeId: one.id,
330 },
331 took > NOTIFY_AFTER_MS || one.status === 'failed',
332 one.status,
333 )
334 }
335 }
336
337 const list = await read($, agents)
338 const running = list.filter(one => one.status === 'running').length
339 await update($, activity, history => [...history, running].slice(-HISTORY))
340 if (tick % 2 === 0 || running > 0) await update($, now, () => at)
341 await writeSnapshot($, at)
342}
343
344const startWeb = async ($: EngineInterface) => {
345 const dir = `${await dataDir($)}/agent-board/web`
346 const url = `http://localhost:${PORT}/`
347 await update($, webUrl, () => url)
348 void (async () => {
349 try {
350 const server = $.process.spawn({
351 argv: ['python3', '-m', 'http.server', String(PORT), '--bind', '127.0.0.1', '--directory', dir],
352 })
353 for await (const piece of server) {
354 if (/Address already in use/.test(piece.text)) $.ui.log(`agent-board: port ${PORT} already served`, { to: 'debug' })
355 }
356 } catch {}
357 })()
358
359 return url
360}
361
362const setNotify = async ($: EngineInterface, on: boolean) => {
363 notifyOn = on
364 await $.store.set('notify', on)
365}
366
367const ago = (ms: number) => (ms < 5000 ? 'now' : `${formatDuration(ms)} ago`)
368
369export const register: Register = on => {
370 on('session.start', async ($, e, next) => {
371 sessionId = await $.session.id()
372 const dir = `${await dataDir($)}/agent-board/web`
373 await $.process.run(['mkdir', '-p', `${dir}/sessions`]).catch(() => undefined)
374 await $.fs.write(`${dir}/index.html`, WEB_PAGE).catch(() => undefined)
375 notifyOn = (await $.store.get('notify').catch(() => undefined)) !== false
376
377 const at = await $.clock.now()
378 const main = (await read($, agents)).find(one => one.id === 'main')
379 sessionStart = main?.startedAt ?? at
380 await update(
381 $,
382 agents,
383 list =>
384 // A reload keeps the board; nodes from before this version get the new fields.
385 main
386 ? list.map(one => node({ ...one }))
387 : upsert(node({ id: 'main', kind: 'main', label: 'main session', type: 'main', model: '', status: 'idle', startedAt: at }))(list),
388 )
389 void $.session.model().then(model => update($, agents, patch('main', one => ({ ...one, model }))))
390 await $.command.register({
391 name: 'board',
392 description: 'Live agent board: /board (pane), /board web, /board notify on|off, /board clear',
393 })
394 $.clock.every(1000, () => void sync($).catch(error => $.ui.log(`agent-board: ${String(error)}`, { to: 'debug' })))
395
396 return next(e)
397 })
398
399 on('session.end', async ($, e, next) => {
400 await writeSnapshot($, await $.clock.now(), true).catch(() => undefined)
401
402 return next(e)
403 })
404
405 on('command.run', { command: 'board' }, async ($, e) => {
406 const [verb, arg] = e.args.trim().split(/\s+/)
407 if (verb === 'web') {
408 const url = await startWeb($)
409 await $.process.run(['cmd.exe', '/c', 'start', '', url], { timeoutMs: 5000 }).catch(() => undefined)
410
411 return { text: `Agent board on ${url} (all sessions on this machine).` }
412 }
413 if (verb === 'notify') {
414 const turnOn = arg === undefined ? !notifyOn : arg !== 'off'
415 await setNotify($, turnOn)
416 const tried = turnOn ? await $.ui.notify('Agent board notifications are on', { title: 'Agent board' }).catch(() => undefined) : undefined
417
418 return {
419 text: turnOn
420 ? `Native notifications on${tried && !tried.isSent ? ` (but not delivered: ${tried.reason}; toasts still show)` : ''}.`
421 : 'Native notifications off; toasts still show.',
422 }
423 }
424 if (verb === 'clear') {
425 await update($, agents, all => all.filter(one => one.kind === 'main' || !isOver(one)))
426 await update($, events, () => [])
427
428 return { text: 'Cleared finished agents and the event list.' }
429 }
430 await $.ui.open({ id: PANE, title: 'Agents' })
431
432 return { text: 'Agent board opened.' }
433 })
434
435 on('agent.spawn', async ($, e, next) => {
436 const spawned = await next(e)
437 if (spawned.agentId) {
438 await update(
439 $,
440 agents,
441 upsert(
442 node({
443 id: spawned.agentId,
444 parentId: e.parentAgentId ?? 'main',
445 kind: e.workflow ? 'workflow' : 'agent',
446 label: e.description || e.subagentType,
447 type: e.subagentType,
448 model: spawned.model,
449 status: 'running',
450 startedAt: await $.clock.now(),
451 }),
452 ),
453 )
454 if ((await read($, agents)).filter(one => one.kind !== 'main').length === 1) {
455 void $.ui.open({ id: PANE, title: 'Agents' }).catch(() => undefined)
456 }
457 }
458
459 return spawned
460 })
461
462 // A finished background task: the Codex node it ran learns how it ended.
463 on('prompt.submit', async ($, e, next) => {
464 const note = e.origin.kind === 'task-notification' ? parseTaskNotification(e.text) : undefined
465 if (note?.toolUseId) {
466 if (note.outputFile) {
467 const output = await $.fs.read(note.outputFile).catch(() => '')
468 await update($, agents, list => mergeCodexRun(list, note.toolUseId!, output))
469 }
470 const target = (await read($, agents)).find(one => one.toolUseId === note.toolUseId)
471 const id = target?.id ?? `codex-${note.toolUseId}`
472 if (target) {
473 const exit = Number(note.summary?.match(/exit code (\d+)/)?.[1] ?? 0)
474 const failed = note.status !== 'completed' || exit !== 0
475 const at = await $.clock.now()
476 await update($, agents, patch(id, one => ({ ...one, status: failed ? 'failed' : 'done', endedAt: one.endedAt ?? at })))
477 if (failed) {
478 await announce($, `${id}:end`, { level: 'fail', text: `✗ Codex run failed: ${clip(note.summary ?? note.status ?? '', 200)}`, nodeId: id }, true)
479 } else if (!target.codex?.isComplete) {
480 await announce($, `${id}:end`, { level: 'done', text: `✓ Codex run finished: ${clip(note.summary ?? target.label, 200)}`, nodeId: id }, true)
481 }
482 }
483 }
484
485 return next(e)
486 })
487
488 on('turn.start', async ($, e, next) => {
489 const id = e.agentId ?? 'main'
490 const at = await $.clock.now()
491 turnStarts.set(id, at)
492 lastSeen.set(id, at)
493 await update(
494 $,
495 agents,
496 patch(id, one => ({ ...one, status: 'running', endedAt: undefined, turns: one.turns + 1, lastEventAt: at, approvalSince: undefined })),
497 )
498
499 return next(e)
500 })
501
502 on('turn.step', async function* ($, e, next) {
503 const id = e.agentId ?? 'main'
504 lastSeen.set(id, Date.now())
505 await update($, agents, patch(id, one => ({ ...one, steps: one.steps + 1, model: e.model || one.model })))
506 // Every streamed piece counts as a sign of life; the 1 s tick folds it into the node.
507 const stream = next(e)
508 for await (const chunk of stream) {
509 lastSeen.set(id, Date.now())
510 yield chunk
511 }
512
513 return await stream.result
514 })
515
516 on('turn.complete', async ($, e, next) => {
517 const id = e.agentId ?? 'main'
518 const usage = e.usage
519 const spent = usage ? usage.input_tokens + usage.output_tokens + (usage.cache_creation_input_tokens ?? 0) : 0
520 const at = await $.clock.now()
521 await update(
522 $,
523 agents,
524 patch(id, one => ({
525 ...one,
526 status: e.agentId === undefined ? 'idle' : one.status,
527 tool: undefined,
528 detail: undefined,
529 toolStartedAt: undefined,
530 approvalSince: undefined,
531 lastEventAt: at,
532 tokens: one.tokens + spent,
533 })),
534 )
535 const took = at - (turnStarts.get(id) ?? at)
536 if (e.agentId === undefined) {
537 if (e.reason === 'error') {
538 await announce($, `main:turn:${at}`, { level: 'fail', text: `✗ Main turn ended on an API error after ${formatDuration(took)}`, nodeId: 'main' }, true)
539 } else if (e.reason === 'answer' && took > NOTIFY_AFTER_MS) {
540 await announce($, `main:turn:${at}`, { level: 'done', text: `✓ Turn finished (${formatDuration(took)}), waiting for you`, nodeId: 'main' }, true)
541 }
542 }
543
544 return next(e)
545 })
546
547 // An ask the mode puts to the person: remembered until the call ends.
548 on('tool.check', async ($, e, next) => {
549 const verdict = await next(e)
550 if (verdict.decision === 'ask' && e.tool_use_id) {
551 const at = await $.clock.now()
552 await update($, agents, patch(e.agentId ?? 'main', one => ({ ...one, approvalSince: one.approvalSince ?? at })))
553 }
554
555 return verdict
556 })
557
558 on('tool.call', async ($, e, next) => {
559 const id = e.agentId ?? 'main'
560 const tool = String(e.tool)
561 const input = e as unknown as Record<string, unknown>
562 const detail = describeTool(tool, input)
563 const startedAt = await $.clock.now()
564 lastSeen.set(id, startedAt)
565 await update($, agents, patch(id, one => ({ ...one, tool, detail, toolStartedAt: startedAt, tools: one.tools + 1, lastEventAt: startedAt })))
566
567 const command = typeof input.command === 'string' ? input.command : ''
568 const codexId = tool === 'Bash' && isCodexCommand(command) ? `codex-${e.tool_use_id}` : undefined
569 if (codexId) {
570 const tasks = codexTasks(command)
571 await update(
572 $,
573 agents,
574 upsert(
575 node({
576 id: codexId,
577 parentId: id,
578 kind: 'codex',
579 label: tasks.length > 0 ? `codex: ${tasks.join(', ')}` : detail,
580 type: 'codex',
581 model: 'codex',
582 status: 'running',
583 startedAt,
584 toolUseId: e.tool_use_id,
585 }),
586 ),
587 )
588 await announce($, `${codexId}:start`, { level: 'info', text: `Codex started: ${clip(tasks.join(', ') || detail, 120)}`, nodeId: codexId }, false)
589 }
590
591 const ran = await next(e)
592 const endedAt = await $.clock.now()
593 lastSeen.set(id, endedAt)
594 const ok = !ran.isError && ran.deny === undefined
595 await update($, agents, list => {
596 const output = [ran.text, typeof ran.result === 'string' ? ran.result : (ran.result as { stdout?: string } | undefined)?.stdout].filter(Boolean).join('\n')
597 const linked = codexId ? mergeCodexRun(list, e.tool_use_id, output) : list
598 const runId = linked.find(one => one.toolUseId === e.tool_use_id)?.id ?? codexId
599 const cleared = patch(id, one => ({
600 ...one,
601 ...(one.tool === tool && one.detail === detail ? { tool: undefined, detail: undefined, toolStartedAt: undefined } : {}),
602 approvalSince: undefined,
603 errors: one.errors + (ok ? 0 : 1),
604 lastEventAt: endedAt,
605 recent: [...one.recent, { tool, detail, at: endedAt, ok }].slice(-12),
606 }))(linked)
607 if (!codexId) return cleared
608 // A background Bash call returns at once; its task notification ends the node.
609 const isBackground = input.run_in_background === true || (ran.result as { backgroundTaskId?: string } | undefined)?.backgroundTaskId !== undefined
610
611 return isBackground ? cleared : patch(runId!, one => ({ ...one, status: ok ? 'done' : 'failed', endedAt: one.endedAt ?? endedAt }))(cleared)
612 })
613 if (codexId && !ok && input.run_in_background !== true) {
614 const failedId = (await read($, agents)).find(one => one.toolUseId === e.tool_use_id)?.id ?? codexId
615 await announce($, `${failedId}:end`, { level: 'fail', text: `✗ Codex run failed: ${clip(ran.text ?? ran.deny ?? '', 200)}`, nodeId: failedId }, true)
616 }
617
618 return ran
619 })
620
621 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
622 const { Box, Button, Text } = $.ui.resolve(e)
623 const list = await read($, agents)
624 const at = (await read($, now)) || (await $.clock.now())
625 const width = Math.max(40, e.props.bodyColumns ?? e.viewport?.columns ?? 80)
626 const rows = treeOrder(list)
627 const best = newestVersions(list)
628 const counts = {
629 running: list.filter(one => one.status === 'running' && one.kind !== 'main').length,
630 done: list.filter(one => one.status === 'done').length,
631 failed: list.filter(one => one.status === 'failed').length,
632 flagged: list.filter(one => one.health === 'looping' || one.health === 'stalled' || one.health === 'approval').length,
633 }
634 const url = await read($, webUrl)
635 const feed = (await read($, events)).slice(-6).reverse()
636 const others = await read($, peers)
637 const urgent = (state: PeerSession['state']) => state === 'approval' || state === 'looping' || state === 'stalled' || state === 'silent'
638 const waiting = list.filter(one => one.health === 'approval').length + others.filter(peer => peer.state === 'approval').length
639 const trouble = list.filter(one => one.health === 'looping' || one.health === 'stalled' || one.status === 'failed').length + others.filter(peer => urgent(peer.state) && peer.state !== 'approval').length
640 const quiet = others.filter(peer => peer.state === 'ended').length
641 const spark = sparkline((await read($, activity)).slice(-Math.min(HISTORY, width - 12)))
642 const color = (one: AgentNode) =>
643 one.status === 'failed'
644 ? 'red'
645 : one.health === 'looping' || one.health === 'stalled'
646 ? 'red'
647 : one.health === 'approval'
648 ? 'yellow'
649 : one.status === 'done'
650 ? undefined
651 : one.kind === 'codex'
652 ? 'magenta'
653 : one.status === 'running'
654 ? 'green'
655 : 'yellow'
656 const labelRoom = Math.max(12, Math.floor(width * 0.35))
657 const eventColor = (level: BoardEvent['level']) => (level === 'fail' ? 'red' : level === 'warn' ? 'yellow' : level === 'done' ? 'green' : undefined)
658 const peerColor = (state: PeerSession['state']) =>
659 state === 'working' ? 'green' : state === 'looping' || state === 'stalled' || state === 'silent' ? 'red' : state === 'approval' ? 'yellow' : undefined
660 const peerGlyph = (state: PeerSession['state']) =>
661 state === 'working' ? '●' : state === 'idle' ? '○' : state === 'ended' ? '·' : state === 'approval' ? '⏸' : state === 'silent' ? '?' : '⚠'
662
663 const renderAdvice = (id: string, advice: OrchestrationAdvice | undefined, nodes: AgentNode[]) => {
664 if (!freshAdvice(advice, at) || !advice) return null
665 const comparison = compareAdvice(advice, nodes)
666 const line = adviceLine(advice, comparison)
667 const chunks = line.split(/(\d+ codex)/)
668 return (
669 <Box key={`advice-${id}`} flexDirection="column">
670 <Text dir="ltr" wrap="truncate">{chunks.map((chunk, i) => /\d+ codex/.test(chunk)
671 ? <Text key={`codex-${i}`} color="magenta">{chunk}</Text> : chunk)}</Text>
672 <Box position="absolute" top={1} left={0} display="none" hover={{ display: 'flex' }}>
673 <Text>{adviceTitle(advice)}</Text>
674 </Box>
675 </Box>
676 )
677 }
678
679 return (
680 <Box flexDirection="column">
681 {(waiting > 0 || trouble > 0) && (
682 <Box>
683 {waiting > 0 && <Text color="yellow">⏸ {waiting} waiting for you </Text>}
684 {trouble > 0 && <Text color="red">⚠ {trouble} in trouble</Text>}
685 </Box>
686 )}
687 <Box>
688 <Text bold>{list.length - 1} agents </Text>
689 <Text color="green">{counts.running} running </Text>
690 <Text dimColor>· {counts.done} done </Text>
691 {counts.failed > 0 && <Text color="red">· {counts.failed} failed </Text>}
692 {counts.flagged > 0 && <Text color="yellow">· {counts.flagged} need a look</Text>}
693 </Box>
694 {renderAdvice(sessionId, currentAdvice, list)}
695 <Text color="green">{spark || ' '}</Text>
696 <Text> </Text>
697 {rows.map(({ node: one, depth, isLast }) => {
698 const arrow = { send: '▶', recv: '◀', done: '✓', idle: '·' }[linkDirection(one)]
699 const indent = depth === 0 ? '' : `${'│ '.repeat(depth - 1)}${isLast ? '└' : '├'}${arrow}`
700 const tone = modelTone(one, best)
701 const pad = ' '.repeat(indent.length + 2)
702 const room = Math.max(10, width - indent.length - 4)
703 const elapsed = formatDuration((one.endedAt ?? at) - one.startedAt)
704 const meta = [
705 one.kind === 'agent' ? one.type : one.kind,
706 one.t3 ? '' : one.kind === 'codex' ? `${one.tools} cmds` : `${one.tools} tools`,
707 one.errors ? `${one.errors} err` : '',
708 one.tokens ? formatTokens(one.tokens) : '',
709 elapsed,
710 ]
711 .filter(Boolean)
712 .join(' · ')
713 const now = one.tool
714 ? `${one.tool} ${one.detail ?? ''}`.trim() + (one.toolStartedAt ? ` (${formatDuration(at - one.toolStartedAt)})` : '')
715 : one.kind === 'codex'
716 ? one.detail ?? (one.codex ? 'thinking' : 'starting')
717 : one.status === 'running'
718 ? `thinking · last sign ${ago(at - one.lastEventAt)}`
719 : one.status
720 const badge = HEALTH_BADGE[one.health]
721 const recent = one.kind === 'codex' ? '' : recentSummary(one.recent)
722 const codexLine = one.codex
723 ? [`${one.codex.files.length} files`, one.codex.tasks.length ? `tasks ${one.codex.tasks.join(', ')}` : '', one.codex.cwd.split('/').pop() ?? '']
724 .filter(Boolean)
725 .join(' · ')
726 : ''
727
728 return (
729 <Box key={one.id} flexDirection="column">
730 <Box>
731 <Text color={depth === 0 ? undefined : tone.color} dimColor={depth === 0}>{indent}</Text>
732 <Text color={color(one)}>{STATUS_GLYPH[one.status]} </Text>
733 <Text bold={one.status === 'running'} dimColor={one.status === 'done'}>
734 {one.label.slice(0, labelRoom)}
735 </Text>
736 {badge !== '' && one.health !== 'idle' && <Text color={color(one)}> {badge}</Text>}
737 {tone.label !== '' && <Text color={tone.color} bold> {tone.label}</Text>}
738 <Text dimColor> {meta}</Text>
739 {(one.kind === 'agent' || one.kind === 'workflow') && !isOver(one) && (
740 <Button key={`view-${one.id}`} plain label=" [view]" onPress={() => void $.ui.view({ agentId: one.id })} />
741 )}
742 </Box>
743 {!isOver(one) && (
744 <Text dimColor={!one.tool && one.kind !== 'codex'} wrap="truncate">
745 {pad}
746 {clip(now, room)}
747 </Text>
748 )}
749 {one.healthNote && one.health !== 'ok' && (
750 <Text color={one.health === 'idle' ? undefined : color(one)} dimColor={one.health === 'idle'} wrap="truncate">
751 {pad}
752 {clip(one.healthNote, room)}
753 </Text>
754 )}
755 {codexLine !== '' && (
756 <Text dimColor wrap="truncate">
757 {pad}
758 {clip(codexLine, room)}
759 </Text>
760 )}
761 {isOver(one) && one.codex?.summary && (
762 <Text dimColor wrap="truncate">
763 {pad}
764 {clip(one.codex.summary, room)}
765 </Text>
766 )}
767 {!isOver(one) && recent !== '' && (
768 <Text dimColor wrap="truncate">
769 {pad}recent: {clip(recent, room - 8)}
770 </Text>
771 )}
772 </Box>
773 )
774 })}
775 {feed.length > 0 && (
776 <Box flexDirection="column">
777 <Text> </Text>
778 <Text bold>Events</Text>
779 {feed.map((event, index) => (
780 <Text key={`event-${event.at}-${index}`} color={eventColor(event.level)} wrap="truncate">
781 {clip(`${ago(at - event.at).padEnd(10)} ${event.text}`, width - 2)}
782 </Text>
783 ))}
784 </Box>
785 )}
786 {others.length > 0 && (
787 <Box flexDirection="column">
788 <Text> </Text>
789 <Text bold>Other sessions</Text>
790 {others.slice(0, 8).map(peer => (
791 <Box key={`peer-${peer.sessionId}`} flexDirection="column">
792 <Text color={peerColor(peer.state)} wrap="truncate">
793 {clip(
794 `${peerGlyph(peer.state)} ${clip(peer.name, 22).padEnd(22)} ${peer.state.padEnd(8)} ${peer.note}${peer.running ? ` · ${peer.running} running` : ''} · ${formatDuration(at - peer.since)}`,
795 width - 2,
796 )}
797 </Text>
798 {renderAdvice(peer.sessionId, peerAdvice.get(peer.sessionId)?.advice, peerAdvice.get(peer.sessionId)?.nodes ?? [])}
799 </Box>
800 ))}
801 {others.length > 8 && <Text dimColor>+{others.length - 8} more sessions ({quiet} ended)</Text>}
802 </Box>
803 )}
804 <Text> </Text>
805 <Box>
806 {url ? <Text dimColor>web: {url}</Text> : <Button key="web" plain label="[open web board]" onPress={() => void startWeb($)} />}
807 <Text> </Text>
808 <Button
809 key="clear"
810 plain
811 label="[clear finished]"
812 onPress={async () => {
813 await update($, agents, all => all.filter(one => one.kind === 'main' || !isOver(one)))
814 }}
815 />
816 <Text> </Text>
817 <Button key="clear-history" plain label="[clear history]" onPress={async () => void (await update($, events, () => []))} />
818 <Text> </Text>
819 <Button
820 key="notify"
821 plain
822 label={notifyOn ? '[notifications: on]' : '[notifications: off]'}
823 onPress={async () => {
824 await setNotify($, !notifyOn)
825 await update($, now, () => Date.now())
826 }}
827 />
828 </Box>
829 </Box>
830 )
831 })
832}
833hooks/board.ts 387 lines1import type { AgentNode, CodexInfo, Health, NodeStatus, PeerSession, RecentCall } from '../types'
2
3export type OrchestrationAdvice = {
4 at: number
5 prompt: string
6 mode: 'single' | 'parallel'
7 parts: { title: string; executor: 'claude' | 'codex'; model: string; effort?: string }[]
8 reason: string
9}
10
11export type AdviceComparison = {
12 advisedParallel: number
13 advisedCodex: number
14 actualAgents: number
15 actualCodex: number
16 verdict: 'followed' | 'under' | 'over' | 'single-ok'
17}
18
19// The caller supplies the clock so freshness and comparisons stay deterministic.
20export const freshAdvice = (value: unknown, now: number): OrchestrationAdvice | undefined => {
21 if (!value || typeof value !== 'object') return undefined
22 const a = value as OrchestrationAdvice
23 if (!Number.isFinite(a.at) || now - a.at > 6 * 3600_000 || a.at > now ||
24 !['single', 'parallel'].includes(a.mode) || typeof a.prompt !== 'string' || typeof a.reason !== 'string' ||
25 !Array.isArray(a.parts) || !a.parts.every(p => p && typeof p.title === 'string' &&
26 ['claude', 'codex'].includes(p.executor) && typeof p.model === 'string' &&
27 (p.effort === undefined || typeof p.effort === 'string'))) return undefined
28 return a
29}
30
31// Nodes are already scoped to this session. Main-session work is never an extra agent.
32export const compareAdvice = (advice: OrchestrationAdvice, nodes: readonly AgentNode[]): AdviceComparison => {
33 const advisedParallel = advice.mode === 'parallel' ? advice.parts.filter(p => p.executor === 'claude').length : 0
34 const advisedCodex = advice.mode === 'parallel' ? advice.parts.filter(p => p.executor === 'codex').length : 0
35 const started = nodes.filter(n => n.startedAt >= advice.at)
36 const actualAgents = started.filter(n => n.kind === 'agent' || n.kind === 'workflow').length
37 const actualCodex = started.filter(n => n.kind === 'codex').length
38 const verdict = actualAgents > advisedParallel || actualCodex > advisedCodex ? 'over'
39 : advice.mode === 'single' ? 'single-ok'
40 : actualAgents < advisedParallel || actualCodex < advisedCodex ? 'under' : 'followed'
41 return { advisedParallel, advisedCodex, actualAgents, actualCodex, verdict }
42}
43
44export const adviceTitle = (advice: OrchestrationAdvice) => [advice.reason,
45 ...advice.parts.map(p => `${p.title}: ${p.executor} · ${p.model}${p.effort ? ` · ${p.effort}` : ''}`),
46].join('\n')
47
48export const adviceLine = (advice: OrchestrationAdvice, c: AdviceComparison) =>
49 `advice: ${advice.mode}${advice.mode === 'parallel' ? ` ×${advice.parts.length} (${c.advisedParallel} claude · ${c.advisedCodex} codex)` : ''} → ran ${c.actualAgents} agents · ${c.actualCodex} codex [${c.verdict}]`
50
51export const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
52
53// One short line saying what a tool call is doing, from its input.
54export const describeTool = (tool: string, input: Record<string, unknown>): string => {
55 const pick = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : undefined)
56 const path = pick('file_path') ?? pick('path') ?? pick('notebook_path')
57 const detail =
58 pick('command') ??
59 (path ? path.split('/').slice(-2).join('/') : undefined) ??
60 pick('pattern') ??
61 pick('query') ??
62 pick('url') ??
63 pick('description') ??
64 pick('skill') ??
65 pick('prompt') ??
66 ''
67
68 return clip(detail.replace(/\s+/g, ' ').trim(), 80)
69}
70
71// Tokenize shell words while keeping quoted separators inside their argument.
72const commandSegments = (command: string): string[][] => {
73 const segments: string[][] = [[]]
74 const tokens = command.match(/(?:[^\s;&|()'"\\]+|\\[\s\S]|"(?:\\.|[^"\\])*"|'[^']*')+|[;&|()\n]/g) ?? []
75 for (const token of tokens) {
76 if (/^[;&|()\n]$/.test(token)) segments.push([])
77 else segments[segments.length - 1]!.push(token.replace(/^(['"])(.*)\1$/, '$2'))
78 }
79 return segments.map(words => {
80 while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? '')) words.shift()
81 return words
82 })
83}
84
85export const isCodexCommand = (command: string) => commandSegments(command).some(words =>
86 (words[0] === 'cx' && !words.slice(1).some(word => ['-h', '--help', '--version'].includes(word))) ||
87 (words[0] === 'codex' && words[1] === 'exec'),
88)
89
90export const CODEX_MARKER = /^CX_RUN_V1 owner=(\S+) run=(\S+) task=(\S+)(?:\r?\n|$)/
91
92export const parseCodexMarker = (text: string) => {
93 const match = text.match(CODEX_MARKER)
94 return match ? { owner: match[1]!, run: match[2]!, tasks: match[3] === '-' ? [] : match[3]!.split(',') } : undefined
95}
96
97export const codexRunId = (rollout: CodexInfo, sessionId: string): string | undefined =>
98 rollout.owner === sessionId ? `codex-run-${rollout.run}` : undefined
99
100// The stdout marker is the only link between a Bash call and a rollout.
101export const mergeCodexRun = (list: AgentNode[], toolUseId: string, output: string): AgentNode[] => {
102 const run = output.match(/(?:^|\n)cx-run: ([^\s]+)(?:\r?\n|$)/)?.[1]
103 const bash = list.find(one => one.kind === 'codex' && one.toolUseId === toolUseId)
104 if (!run || !bash) return list
105 const id = `codex-run-${run}`
106 const scanned = list.find(one => one.id === id)
107 const merged = { ...bash, ...(scanned ?? {}), id, parentId: bash.parentId, toolUseId }
108 return list.filter(one => one.id !== bash.id && one.id !== id).concat(merged)
109}
110
111// The task names a `cx -t a,b` call runs, so a Codex node reads as the specs it works on.
112export const codexTasks = (command: string): string[] => {
113 const match = command.match(/(?:^|\s)cx\b[^|;&]*?\s-[a-z]*t\s+(?:"([^"]+)"|'([^']+)'|(\S+))/)
114 const names = match?.[1] ?? match?.[2] ?? match?.[3]
115
116 return names ? names.split(',').map(name => name.trim()).filter(Boolean) : []
117}
118
119export const formatDuration = (ms: number) => {
120 const seconds = Math.max(0, Math.round(ms / 1000))
121 if (seconds < 60) return `${seconds}s`
122 const minutes = Math.floor(seconds / 60)
123 if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
124
125 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
126}
127
128export const formatTokens = (tokens: number) =>
129 tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M` : tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : `${tokens}`
130
131const BARS = '▁▂▃▄▅▆▇█'
132
133export const sparkline = (values: readonly number[]) => {
134 const top = Math.max(1, ...values)
135
136 return values.map(value => (value === 0 ? ' ' : BARS[Math.min(7, Math.round((value / top) * 7))])).join('')
137}
138
139export const STATUS_GLYPH: Record<NodeStatus, string> = {
140 running: '●',
141 waiting: '◐',
142 idle: '○',
143 done: '✓',
144 failed: '✗',
145}
146
147export const HEALTH_BADGE: Record<Health, string> = {
148 ok: '',
149 idle: 'idle',
150 approval: '⏸ needs you',
151 stalled: '⚠ stalled',
152 looping: '↻ looping',
153}
154
155export const fromAgentStatus = (status: string): NodeStatus =>
156 status === 'running' || status === 'pending'
157 ? 'running'
158 : status === 'waiting'
159 ? 'waiting'
160 : status === 'idle'
161 ? 'idle'
162 : status === 'completed'
163 ? 'done'
164 : 'failed'
165
166// Depth-first order under the main node, so a flat list reads as a tree.
167export const treeOrder = (agents: readonly AgentNode[]): { node: AgentNode; depth: number; isLast: boolean }[] => {
168 const children = new Map<string, AgentNode[]>()
169 const ids = new Set(agents.map(node => node.id))
170 for (const node of agents) {
171 if (node.kind === 'main') continue
172 const parent = node.parentId && ids.has(node.parentId) ? node.parentId : 'main'
173 children.set(parent, [...(children.get(parent) ?? []), node])
174 }
175 const out: { node: AgentNode; depth: number; isLast: boolean }[] = []
176 const walk = (node: AgentNode, depth: number, isLast: boolean) => {
177 out.push({ node, depth, isLast })
178 const kids = children.get(node.id) ?? []
179 kids.forEach((kid, index) => walk(kid, depth + 1, index === kids.length - 1))
180 }
181 const main = agents.find(node => node.kind === 'main')
182 if (main) walk(main, 0, true)
183
184 return out
185}
186
187// "Read ×3 · Edit · Bash": the last calls, runs of one tool folded.
188export const recentSummary = (recent: readonly RecentCall[], max = 5) => {
189 const runs: { tool: string; count: number; failed: boolean }[] = []
190 for (const call of recent.slice(-12)) {
191 const last = runs[runs.length - 1]
192 if (last && last.tool === call.tool) {
193 last.count++
194 last.failed ||= call.ok === false
195 } else runs.push({ tool: call.tool, count: 1, failed: call.ok === false })
196 }
197
198 return runs
199 .slice(-max)
200 .map(run => `${run.tool}${run.count > 1 ? ` ×${run.count}` : ''}${run.failed ? '!' : ''}`)
201 .join(' · ')
202}
203
204export const LIMITS = {
205 // A call repeated this often in the last WINDOW calls is a loop.
206 repeat: 4,
207 window: 8,
208 failStreak: 3,
209 // Nothing from the model for this long, with no tool in flight.
210 silentMs: 4 * 60_000,
211 // One tool call in flight for this long.
212 longToolMs: 12 * 60_000,
213 // A Codex rollout untouched for this long while not complete.
214 codexSilentMs: 5 * 60_000,
215 approvalMs: 20_000,
216}
217
218// The most repeated entry and how often, among the last `window`.
219export const mostRepeated = (signatures: readonly string[], window = LIMITS.window) => {
220 const counts = new Map<string, number>()
221 for (const signature of signatures.slice(-window)) counts.set(signature, (counts.get(signature) ?? 0) + 1)
222 let best = { signature: '', count: 0 }
223 for (const [signature, count] of counts) if (count > best.count) best = { signature, count }
224
225 return best
226}
227
228const failStreak = (recent: readonly RecentCall[]) => {
229 let streak = 0
230 for (let index = recent.length - 1; index >= 0 && recent[index]?.ok === false; index--) streak++
231
232 return streak
233}
234
235// Whether a node needs a look, and the few words that say why.
236export const assessHealth = (node: AgentNode, now: number, hasRunningChildren = false): { health: Health; note?: string } => {
237 if (node.status === 'done' || node.status === 'failed') return { health: 'ok' }
238
239 if (node.kind === 'codex' && node.codex) {
240 const repeated = mostRepeated(node.codex.recentCommands)
241 if (repeated.count >= LIMITS.repeat) return { health: 'looping', note: `${clip(repeated.signature, 40)} ×${repeated.count}` }
242 const quiet = now - node.codex.activityAt
243 if (!node.codex.isComplete && quiet > LIMITS.codexSilentMs) return { health: 'stalled', note: `no Codex output for ${formatDuration(quiet)}` }
244
245 return { health: 'ok' }
246 }
247
248 if (node.approvalSince !== undefined && now - node.approvalSince > LIMITS.approvalMs) {
249 return { health: 'approval', note: `${node.tool ?? 'tool'} waiting for approval ${formatDuration(now - node.approvalSince)}` }
250 }
251
252 const streak = failStreak(node.recent)
253 if (streak >= LIMITS.failStreak) return { health: 'looping', note: `${streak} failed calls in a row` }
254 const repeated = mostRepeated(node.recent.map(call => `${call.tool} ${call.detail}`))
255 if (repeated.count >= LIMITS.repeat) return { health: 'looping', note: `${clip(repeated.signature, 40)} ×${repeated.count}` }
256
257 if (node.status === 'idle' || node.status === 'waiting') {
258 return hasRunningChildren ? { health: 'ok' } : { health: 'idle', note: `idle ${formatDuration(now - node.lastEventAt)}` }
259 }
260
261 if (node.toolStartedAt !== undefined) {
262 const inFlight = now - node.toolStartedAt
263 if (inFlight > LIMITS.longToolMs && !hasRunningChildren) {
264 return { health: 'stalled', note: `${node.tool ?? 'tool'} running ${formatDuration(inFlight)}` }
265 }
266
267 return { health: 'ok' }
268 }
269 const quiet = now - node.lastEventAt
270 if (quiet > LIMITS.silentMs && !hasRunningChildren) return { health: 'stalled', note: `no activity for ${formatDuration(quiet)}` }
271
272 return { health: 'ok' }
273}
274
275export type TaskNotification = { taskId?: string; toolUseId?: string; status?: string; summary?: string; outputFile?: string }
276
277// The fields of a background task's <task-notification> prompt.
278export const parseTaskNotification = (text: string): TaskNotification | undefined => {
279 if (!text.includes('<task-notification>')) return undefined
280 const tag = (name: string) => text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim()
281
282 return {
283 taskId: tag('task-id'),
284 toolUseId: tag('tool-use-id'),
285 status: tag('status'),
286 summary: tag('summary'),
287 outputFile: tag('output-file'),
288 }
289}
290
291export type Snapshot = {
292 sessionId: string
293 name: string
294 // The title Claude Code gives the session; empty until its transcript has one.
295 title?: string
296 // Set when the session is a T3 Code thread; a child thread names its parent so the board nests it.
297 t3?: { threadId: string; parentThreadId?: string | null }
298 cwd: string
299 updatedAt: number
300 isEnded?: boolean
301 agents: AgentNode[]
302 advice?: OrchestrationAdvice
303}
304
305// How another session reads from its last snapshot.
306export const peerFromSnapshot = (snapshot: Snapshot, now: number): PeerSession => {
307 const main = snapshot.agents.find(node => node.kind === 'main')
308 const running = snapshot.agents.filter(node => node.kind !== 'main' && node.status === 'running').length
309 const flagged = snapshot.agents.find(node => node.health === 'looping' || node.health === 'stalled' || node.health === 'approval')
310 const base = { sessionId: snapshot.sessionId, name: snapshot.title || snapshot.name, running, updatedAt: snapshot.updatedAt }
311 if (snapshot.isEnded) return { ...base, state: 'ended', note: 'closed', since: snapshot.updatedAt }
312 const silentFor = now - snapshot.updatedAt
313 if (silentFor > 20_000) {
314 return { ...base, state: 'silent', note: `no heartbeat for ${formatDuration(silentFor)}`, since: snapshot.updatedAt }
315 }
316 if (flagged) {
317 const label = flagged.kind === 'main' ? '' : `${clip(flagged.label, 24)}: `
318
319 return { ...base, state: flagged.health as PeerSession['state'], note: `${label}${flagged.healthNote ?? flagged.health}`, since: flagged.lastEventAt }
320 }
321 if (main?.status === 'running') return { ...base, state: 'working', note: main.tool ? `${main.tool} ${main.detail ?? ''}`.trim() : 'thinking', since: main.lastEventAt }
322
323 return { ...base, state: 'idle', note: running ? `${running} running in background` : 'waiting for you', since: main?.lastEventAt ?? snapshot.updatedAt }
324}
325
326// Model colors: one hue per family, the newest version of a family full strength, older ones faded toward the terminal ground.
327export type ModelFamily = 'opus' | 'sonnet' | 'haiku'
328
329const FAMILY_HUE: Record<ModelFamily | 'codex', string> = {
330 opus: '#6e7dff',
331 sonnet: '#19f0e0',
332 haiku: '#ff9a3d',
333 codex: '#ff3ec0',
334}
335const NO_MODEL = '#8a877f'
336
337// "claude-opus-4-1-20250805", "opus", "claude-3-5-sonnet" -> family plus version as major * 100 + minor (0 when unknown).
338export const parseModel = (raw: string): { family?: ModelFamily; version: number; label: string } => {
339 const text = raw.toLowerCase()
340 const family = (['opus', 'sonnet', 'haiku'] as const).find(name => text.includes(name))
341 if (!family) return { version: 0, label: raw }
342 const hit = text.match(new RegExp(`${family}[-_ ]?(\\d{1,2})(?:[-._](\\d{1,2})(?!\\d))?`)) ?? text.match(new RegExp(`(\\d{1,2})(?:[-._](\\d{1,2}))?[-_ ]${family}`))
343 const name = family.charAt(0).toUpperCase() + family.slice(1)
344 if (!hit) return { family, version: 0, label: name }
345 const minor = hit[2] ? Number(hit[2]) : 0
346
347 return { family, version: Number(hit[1]) * 100 + minor, label: `${name} ${hit[1]}${hit[2] ? `.${minor}` : ''}` }
348}
349
350export const newestVersions = (nodes: readonly AgentNode[]) => {
351 const best: Partial<Record<ModelFamily, number>> = {}
352 for (const node of nodes) {
353 const { family, version } = parseModel(node.model)
354 if (family && version > (best[family] ?? 0)) best[family] = version
355 }
356
357 return best
358}
359
360const fade = (hex: string, strength: number) => {
361 const ground = 0x1a
362 const channel = (at: number) => Math.round(ground + (parseInt(hex.slice(at, at + 2), 16) - ground) * strength)
363
364 return `#${[1, 3, 5].map(at => channel(at).toString(16).padStart(2, '0')).join('')}`
365}
366
367// tier 1 = newest of its family (full), 2 = same major, 3 = older. Codex is always its own magenta, faded once finished.
368export const modelTone = (node: AgentNode, best: Partial<Record<ModelFamily, number>>) => {
369 const effort = node.t3?.effort ? ` · ${node.t3.effort}` : ''
370 if (node.kind === 'codex') return { color: fade(FAMILY_HUE.codex, node.status === 'done' || node.status === 'failed' ? 0.5 : 1), label: node.t3 ? `${node.model}${effort}` : 'Codex' }
371 const { family, version, label } = parseModel(node.model)
372 if (!family) return { color: NO_MODEL, label: node.model }
373 const top = best[family] ?? version
374 const tier = !version ? 2 : version === top ? 1 : Math.floor(version / 100) === Math.floor(top / 100) ? 2 : 3
375
376 return { color: fade(FAMILY_HUE[family], tier === 1 ? 1 : tier === 2 ? 0.7 : 0.45), label: `${label}${effort}` }
377}
378
379// Which way a link is flowing: the task going down to the child, or its answer coming back up.
380export const linkDirection = (node: AgentNode): 'send' | 'recv' | 'done' | 'idle' => {
381 if (node.status === 'done' || node.status === 'failed') return 'done'
382 if (node.status !== 'running') return 'idle'
383 if (node.kind === 'codex' && node.codex && (node.codex.lastMessage || node.codex.isComplete)) return 'recv'
384
385 return 'send'
386}
387hooks/codex.ts 152 lines1import type { EngineInterface } from 'claude-code'
2
3import type { CodexInfo } from '../types'
4import { CODEX_MARKER } from './board'
5
6// Reads Codex rollout logs (one JSON event per line) and prints one summary per file.
7// Response items (reasoning, raw tool calls) are skipped unparsed: they are most of the bytes.
8export const SCANNER = String.raw`
9import json, re, sys
10out = []
11for path in sys.argv[1:]:
12 info = {"rollout": path, "threadId": "", "owner": "", "run": "", "cwd": "", "prompt": "", "tasks": [], "startedAt": 0, "commands": 0,
13 "recentCommands": [], "files": [], "tokens": 0, "isComplete": False, "lastAction": None,
14 "lastMessage": None, "summary": None, "originator": ""}
15 files = []
16 owner_conflict = False
17 def user_message(content):
18 global owner_conflict
19 text = content if isinstance(content, str) else "\n".join(c.get("text", "") for c in content if isinstance(c, dict))
20 marker = re.match(r"${CODEX_MARKER.source}", text)
21 if marker:
22 owner, run, tasks = marker.groups()
23 if info["owner"] and info["owner"] != owner:
24 owner_conflict = True
25 return
26 if not info["owner"]:
27 info["prompt"] = text[:300]
28 info["owner"], info["run"] = owner, run
29 info["tasks"] = [] if tasks == "-" else tasks.split(",")
30 try:
31 with open(path, encoding="utf-8", errors="replace") as f:
32 for n, line in enumerate(f):
33 head = line[:120]
34 if n > 0 and (('"response_item"' in head and not re.search(r'"role"\s*:\s*"user"', line)) or '"world_state"' in head or '"turn_context"' in head or '"token_usage_record"' in head):
35 continue
36 try:
37 o = json.loads(line)
38 except Exception:
39 continue
40 p = o.get("payload") or {}
41 kind = o.get("type")
42 if kind == "session_meta":
43 info["threadId"] = p.get("id", "")
44 info["cwd"] = p.get("cwd", "")
45 info["originator"] = p.get("originator", "")
46 continue
47 if kind == "response_item" and p.get("role") == "user":
48 user_message(p.get("content") or [])
49 continue
50 if kind != "event_msg":
51 continue
52 t = p.get("type")
53 if t == "user_message":
54 user_message(p.get("content") or p.get("message") or "")
55 elif t == "task_started":
56 info["isComplete"] = False
57 if not info["startedAt"]:
58 info["startedAt"] = int(p.get("started_at", 0)) * 1000
59 elif t == "task_complete":
60 info["isComplete"] = True
61 info["summary"] = (p.get("last_agent_message") or "")[:400]
62 elif t == "token_count":
63 usage = ((p.get("info") or {}).get("total_token_usage") or {})
64 info["tokens"] = usage.get("total_tokens", info["tokens"])
65 elif t == "item_completed":
66 item = p.get("item") or {}
67 it = item.get("type")
68 if it in ("UserMessage", "user_message") or item.get("role") == "user":
69 user_message(item.get("content") or [])
70 elif it == "CommandExecution":
71 parsed = [c.get("cmd", "") for c in item.get("parsed_cmd") or [] if isinstance(c, dict)]
72 cmd = " && ".join(x for x in parsed if x) or " ".join((item.get("command") or [])[-1:])
73 cmd = " ".join(cmd.split())[:120]
74 info["commands"] += 1
75 info["recentCommands"] = (info["recentCommands"] + [cmd])[-8:]
76 info["lastAction"] = "run " + cmd
77 elif it == "FileChange":
78 for changed in (item.get("changes") or {}):
79 if changed not in files:
80 files.append(changed)
81 info["lastAction"] = "edit " + "/".join(changed.split("/")[-2:])
82 elif it == "AgentMessage":
83 text = " ".join(c.get("text", "") for c in item.get("content", []) if isinstance(c, dict))
84 info["lastMessage"] = " ".join(text.split())[:300]
85 info["lastAction"] = "says " + info["lastMessage"][:100]
86 except OSError:
87 continue
88 info["files"] = files[-40:]
89 if info["owner"] and info["run"] and not owner_conflict:
90 out.append(info)
91print(json.dumps(out))
92`
93
94type Scanned = Omit<CodexInfo, 'activityAt'> & { originator: string }
95
96type Cached = { mtimeMs: number; size: number; info?: CodexInfo }
97
98const cache = new Map<string, Cached>()
99
100async function sortedDirs($: EngineInterface, path: string) {
101 return (await $.fs.list(path).catch(() => []))
102 .filter(entry => entry.kind === 'dir')
103 .map(entry => `${path}/${entry.name}`)
104 .sort()
105}
106
107// The newest two day folders of ~/.codex/sessions/YYYY/MM/DD.
108async function dayDirs($: EngineInterface, root: string) {
109 const years = (await sortedDirs($, root)).slice(-2)
110 const months = (await Promise.all(years.map(year => sortedDirs($, year)))).flat().slice(-2)
111 const days = (await Promise.all(months.map(month => sortedDirs($, month)))).flat()
112
113 return days.slice(-2)
114}
115
116// Every Codex exec run whose log moved since `since`, read again only when its file changed.
117export async function scanCodex($: EngineInterface, since: number): Promise<CodexInfo[]> {
118 const root = `${(await $.env.get('HOME')) ?? ''}/.codex/sessions`
119 const files = (
120 await Promise.all(
121 (await dayDirs($, root)).map(async dir =>
122 (await $.fs.list(dir).catch(() => []))
123 .filter(entry => entry.kind === 'file' && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl'))
124 .map(entry => ({ path: `${dir}/${entry.name}`, mtimeMs: entry.mtimeMs, size: entry.size })),
125 ),
126 )
127 )
128 .flat()
129 .filter(file => file.mtimeMs >= since)
130
131 const changed = files.filter(file => {
132 const known = cache.get(file.path)
133
134 return !known || known.mtimeMs !== file.mtimeMs || known.size !== file.size
135 })
136 if (changed.length > 0) {
137 const ran = await $.process
138 .run(['python3', '-c', SCANNER, ...changed.map(file => file.path)], { timeoutMs: 20_000 })
139 .catch(() => undefined)
140 const scanned: Scanned[] = ran?.exitCode === 0 ? JSON.parse(ran.stdout) : []
141 for (const file of changed) {
142 const found = scanned.find(one => one.rollout === file.path)
143 // Interactive Codex sessions are the person's own; the board follows exec runs.
144 const info = found && found.originator !== 'codex_tui' ? { ...found, activityAt: file.mtimeMs } : undefined
145 if (info) delete (info as Partial<Scanned>).originator
146 cache.set(file.path, { mtimeMs: file.mtimeMs, size: file.size, info })
147 }
148 }
149
150 return files.flatMap(file => cache.get(file.path)?.info ?? [])
151}
152hooks/t3.ts 132 lines1import type { EngineInterface } from 'claude-code'
2
3import type { AgentNode, NodeStatus, T3Info } from '../types'
4
5// T3 Code keeps its delegated children (delegate_task) in its own SQLite store, not in Claude hooks or Codex rollouts.
6// Read-only, one JSON object out: this session's thread, its delegated children, and anything waiting on the person.
7export const T3_SCANNER = String.raw`
8import json, os, sqlite3, sys
9sid = sys.argv[1]
10path = os.path.join(os.environ.get("HOME", ""), ".t3", "userdata", "statev2.sqlite")
11if not os.path.exists(path):
12 print("null"); sys.exit(0)
13c = sqlite3.connect("file:" + path + "?mode=ro", uri=True, timeout=2)
14def one(sql, *a):
15 return c.execute(sql, a).fetchone()
16def js(text):
17 try:
18 return json.loads(text) if text else {}
19 except Exception:
20 return {}
21row = one("select thread_id from orchestration_v2_projection_provider_threads where json_extract(payload_json, '$.nativeThreadRef.nativeId') = ? order by updated_at desc limit 1", sid)
22if not row:
23 print("null"); sys.exit(0)
24tid = row[0]
25t = one("select title, payload_json from orchestration_v2_projection_threads where thread_id = ?", tid)
26if not t:
27 print("null"); sys.exit(0)
28payload = js(t[1])
29out = {"threadId": tid, "title": t[0], "parentThreadId": (payload.get("lineage") or {}).get("parentThreadId"), "children": [], "pending": 0}
30def pending(thread):
31 r = one("select pending_approval_count + pending_user_input_count from projection_threads where thread_id = ?", thread)
32 return int(r[0] or 0) if r else 0
33out["pending"] = pending(tid)
34for sub_id, sub_payload in c.execute("select subagent_id, payload_json from orchestration_v2_projection_subagents where thread_id = ? and origin = 'app_owned' order by started_at", (tid,)):
35 p = js(sub_payload)
36 child = p.get("childThreadId")
37 effort = None
38 activity = 0
39 waiting = 0
40 if child:
41 ct = one("select payload_json from orchestration_v2_projection_threads where thread_id = ?", child)
42 for opt in (js(ct[0]).get("modelSelection") or {}).get("options") or [] if ct else []:
43 if opt.get("id") in ("effort", "reasoningEffort"):
44 effort = str(opt.get("value"))
45 last = one("select max(updated_at) from orchestration_v2_projection_turn_items where thread_id = ?", child)
46 activity = last[0] if last and last[0] else None
47 waiting = pending(child)
48 out["children"].append({
49 "id": sub_id, "title": p.get("title") or "", "driver": p.get("driver") or "", "model": p.get("model") or "",
50 "effort": effort, "status": p.get("status") or "", "startedAt": p.get("startedAt"), "completedAt": p.get("completedAt"),
51 "activityAt": activity, "result": (p.get("result") or "")[:300], "waiting": waiting,
52 })
53print(json.dumps(out))
54`
55
56export type T3Scan = {
57 threadId: string
58 title: string
59 parentThreadId?: string | null
60 pending: number
61 children: {
62 id: string
63 title: string
64 driver: string
65 model: string
66 effort?: string | null
67 status: string
68 startedAt?: string | null
69 completedAt?: string | null
70 activityAt?: string | null
71 result: string
72 waiting: number
73 }[]
74}
75
76export const t3Status = (status: string): NodeStatus =>
77 status === 'completed' ? 'done'
78 : status === 'failed' || status === 'cancelled' || status === 'canceled' || status === 'interrupted' || status === 'error' ? 'failed'
79 : 'running'
80
81const time = (value: string | null | undefined, fallback: number) => {
82 const parsed = value ? Date.parse(value) : NaN
83
84 return Number.isFinite(parsed) ? parsed : fallback
85}
86
87// Children become board nodes. A Codex-driven child is a codex node so advice and colors treat it as Codex.
88export const t3Nodes = (scan: T3Scan, now: number): AgentNode[] =>
89 scan.children.map(child => {
90 const status = t3Status(child.status)
91 const startedAt = time(child.startedAt, now)
92 const endedAt = status === 'running' ? undefined : time(child.completedAt, now)
93 const info: T3Info = { threadId: scan.threadId, driver: child.driver, effort: child.effort ?? undefined, result: child.result || undefined }
94
95 return {
96 id: `t3-${child.id}`,
97 parentId: 'main',
98 kind: child.driver === 'codex' ? 'codex' : 'agent',
99 label: child.title || child.model,
100 type: child.driver || 't3',
101 model: child.model,
102 status,
103 tools: 0,
104 errors: status === 'failed' ? 1 : 0,
105 steps: 0,
106 turns: 0,
107 tokens: 0,
108 startedAt,
109 endedAt,
110 lastEventAt: Math.max(startedAt, time(child.activityAt, startedAt)),
111 approvalSince: child.waiting > 0 ? time(child.activityAt, now) : undefined,
112 recent: [],
113 health: 'ok',
114 t3: info,
115 }
116 })
117
118// "gpt-6.1-sol · high": what routing actually picked, so it can be checked against the policy table.
119export const t3Label = (node: AgentNode) => node.t3 ? [node.model, node.t3.effort].filter(Boolean).join(' · ') : ''
120
121export async function scanT3($: EngineInterface, sessionId: string): Promise<T3Scan | undefined> {
122 const ran = await $.process.run(['python3', '-I', '-c', T3_SCANNER, sessionId], { timeoutMs: 8000 }).catch(() => undefined)
123 if (!ran || ran.exitCode !== 0) return undefined
124 try {
125 const parsed = JSON.parse(ran.stdout) as T3Scan | null
126
127 return parsed && Array.isArray(parsed.children) ? parsed : undefined
128 } catch {
129 return undefined
130 }
131}
132hooks/web.ts 646 lines1// The browser board: a static page that polls each session's snapshot under sessions/.
2export const WEB_PAGE = `<!doctype html>
3<html lang="en">
4<head>
5<meta charset="utf-8">
6<meta name="viewport" content="width=device-width, initial-scale=1">
7<title>Agent Board</title>
8<style>
9:root {
10 color-scheme: dark;
11 --bg: #0c1015; --panel: #131920; --sunk: #1b232d; --ink: #e8edf3; --muted: #9aa9ba; --line: #2b3542;
12 --run: #70d6a4; --wait: #efbb68; --done: #8998a9; --fail: #ff8d92;
13 --run-bg: #182d27; --wait-bg: #32291d; --fail-bg: #342126;
14 --opus: #929aff; --sonnet: #51d9cb; --haiku: #ffad69; --cx: #f18bda; --none: #8998a9;
15 --mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
16 --ease-out: cubic-bezier(.23, 1, .32, 1);
17}
18* { box-sizing: border-box; }
19[hidden] { display: none !important; }
20body { margin: 0; min-height: 100dvh; background: var(--bg); color: var(--ink); font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; font-variant-numeric: tabular-nums; }
21::selection { color: var(--ink); background: #344b67; }
22.wrap { max-width: 1440px; margin: 0 auto; padding: 24px 28px 56px; display: grid; gap: 20px; }
23h1 { font-size: 22px; margin: 0; font-weight: 650; letter-spacing: -.035em; }
24h2 { font-size: 11px; margin: 0 0 12px; font-weight: 650; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
25.note { color: var(--muted); font-size: 12px; margin: 0; }
26.bar, .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; }
27.bar { padding-top: 4px; }
28.bar .alerts { margin-left: auto; }
29.live { display: inline-flex; align-items: center; gap: 7px; font: 11px var(--mono); color: var(--muted); }
30.live i { width: 6px; height: 6px; border-radius: 50%; background: var(--run); }
31.live.stale { color: var(--wait); } .live.stale i { background: var(--wait); }
32.toolbar { gap: 8px; padding-bottom: 16px; border-bottom: 1px solid var(--line); }
33.filters { display: flex; flex-wrap: wrap; gap: 4px; margin-right: auto; }
34.f, .alerts, .mini, .search, .sel { font: inherit; font-size: 12px; border-radius: 5px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); min-height: 32px; }
35.f, .alerts, .mini { cursor: pointer; padding: 5px 10px; transition: background-color 140ms var(--ease-out), border-color 140ms var(--ease-out), color 140ms var(--ease-out); }
36.f { display: inline-flex; gap: 8px; align-items: center; border-color: transparent; background: transparent; color: var(--muted); }
37.f b { font: 11px var(--mono); color: var(--muted); }
38.f[aria-pressed="true"] { background: var(--sunk); color: var(--ink); border-color: #455367; }
39.f[aria-pressed="true"] b { color: var(--ink); }
40.search, .sel { padding: 6px 10px; }
41.search { width: 225px; max-width: 100%; caret-color: var(--sonnet); }
42.search::placeholder { color: var(--muted); opacity: 1; }
43.sel { max-width: 100%; }
44button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--sonnet); outline-offset: 3px; }
45button:disabled { opacity: .55; cursor: default; }
46button:active:not(:disabled) { background-color: var(--sunk); }
47.attn { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; }
48.a { all: unset; box-sizing: border-box; cursor: pointer; border: 1px solid var(--line); border-radius: 6px; padding: 12px 14px; display: grid; gap: 3px; min-width: 0; transition: border-color 140ms var(--ease-out); }
49.a:focus-visible { outline: 2px solid var(--sonnet); outline-offset: 3px; }
50.a.fail { background: var(--fail-bg); border-color: #714049; }
51.a.wait { background: var(--wait-bg); border-color: #73603b; }
52.a .k { font-size: 11px; font-weight: 650; }
53.a.fail .k { color: var(--fail); } .a.wait .k { color: var(--wait); }
54.a .who { font-weight: 600; overflow-wrap: anywhere; }
55.a .why { color: #b8c2cf; font-size: 12px; overflow-wrap: anywhere; }
56.legend { display: flex; flex-wrap: wrap; gap: 6px 20px; align-items: center; font-size: 11px; color: var(--muted); }
57.legend .g { display: inline-flex; align-items: center; gap: 5px; }
58.legend > span:last-child { margin-left: auto; max-width: 520px; }
59.sw { display: inline-block; width: 10px; height: 7px; border-radius: 1px; background: color-mix(in srgb, var(--fam) var(--mix, 100%), var(--panel)); }
60.sw.sq { width: 7px; }
61#board { display: grid; gap: 16px; min-width: 0; }
62.sess { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; min-width: 0; }
63.sess.ended { background: #10161d; }
64.sess.ended .sess-head .title { color: #b4bfcc; }
65.sess-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 20px; padding: 16px 18px 10px; width: 100%; text-align: left; font: inherit; color: inherit; background: none; border: 0; cursor: pointer; align-items: start; }
66.sess-head .title { font-size: 15px; font-weight: 620; letter-spacing: -.015em; overflow-wrap: anywhere; }
67.sess-head .cwd { grid-column: 1; padding-left: 18px; font: 11px/1.6 var(--mono); color: var(--muted); overflow-wrap: anywhere; }
68.chips { display: flex; flex-wrap: wrap; gap: 5px; justify-content: flex-end; grid-row: 1 / span 2; grid-column: 2; max-width: 600px; }
69.chip { font-size: 11px; padding: 2px 7px; border-radius: 4px; border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
70.chip.run, .chip.live-on { color: var(--run); background: var(--run-bg); border-color: transparent; }
71.chip.wait, .chip.live-idle { color: var(--wait); background: var(--wait-bg); border-color: transparent; }
72.chip.live-on, .chip.live-idle { display: inline-flex; align-items: center; gap: 6px; }
73.chip.live-on::before, .chip.live-idle::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
74.alerts.off, .chip.fail { color: var(--fail); }
75.chip.fail { background: var(--fail-bg); border-color: transparent; }
76.chev { transition: transform 160ms var(--ease-out); display: inline-block; width: 12px; margin-right: 6px; color: var(--muted); }
77.sess.closed .chev { transform: rotate(-90deg); }
78.sess.closed .inner { display: none; }
79.tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 0 18px 10px 36px; font-size: 11px; color: var(--muted); }
80.tools .id { font: 10px var(--mono); }
81.mini { font-size: 11px; min-height: 28px; padding: 3px 8px; background: transparent; }
82.mini[aria-pressed="true"] { border-color: #73603b; color: var(--wait); }
83.pulse { padding: 0 18px 14px 36px; display: grid; grid-template-columns: minmax(80px, 180px) 1fr; gap: 24px; align-items: end; }
84.sp { position: relative; height: 28px; opacity: .8; }
85.sp svg { width: 100%; height: 28px; display: block; }
86.sp i { position: absolute; width: 5px; height: 5px; border-radius: 50%; background: var(--run); right: 0; transform: translate(0, -50%); }
87.stat { display: flex; flex-wrap: wrap; gap: 4px 20px; font-size: 11px; color: var(--muted); justify-content: flex-end; }
88.stat b { color: var(--ink); font: 12px var(--mono); }
89.body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; border-top: 1px solid var(--line); }
90.tree { padding: 6px 0; min-width: 0; position: relative; }
91svg.edges { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
92.e { fill: none; stroke-width: 1.25; stroke-linecap: round; }
93.e.off { stroke: var(--line); stroke-dasharray: 2 4; }
94.e.on { stroke-dasharray: 5 4; animation: flow 1.2s linear infinite; }
95.e.hold { stroke-dasharray: none; } .e.fade { opacity: .4; stroke-dasharray: none; }
96.row { display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: 3px 10px; padding: 10px 18px 10px calc(20px + var(--d, 0) * 28px); position: relative; --mix: 100%; --fam: var(--none); transition: background-color 140ms var(--ease-out); }
97.row::after { content: ""; position: absolute; left: 0; top: 9px; bottom: 9px; width: 2px; background: color-mix(in srgb, var(--fam) var(--mix), var(--panel)); }
98.row + .row { border-top: 1px solid #202833; }
99.row.flash { background: color-mix(in srgb, var(--fam) 16%, var(--panel)); }
100.t1 { --mix: 100%; } .t2 { --mix: 66%; } .t3 { --mix: 40%; }
101.opus { --fam: var(--opus); } .sonnet { --fam: var(--sonnet); } .haiku { --fam: var(--haiku); } .codex { --fam: var(--cx); }
102.codex::after { background: repeating-linear-gradient(180deg, var(--cx) 0 4px, transparent 4px 7px) !important; }
103.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--done); margin-top: 6px; position: relative; }
104.running .dot { background: var(--run); }
105.waiting .dot, .idle .dot { background: var(--wait); } .failed .dot { background: var(--fail); }
106.codex .dot { border-radius: 1px; }
107.name { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; min-width: 0; }
108.name b { font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
109.badge { font: 500 10px var(--mono); padding: 1px 5px; border-radius: 3px; color: var(--fam); background: color-mix(in srgb, var(--fam) 9%, var(--panel)); border: 1px solid color-mix(in srgb, var(--fam) 25%, var(--line)); }
110.doing { grid-column: 2; font: 11px/1.6 var(--mono); color: var(--muted); overflow-wrap: anywhere; }
111.running .doing { color: #c6d0dd; }
112.flag { grid-column: 2; font-size: 12px; font-weight: 500; color: var(--fail); overflow-wrap: anywhere; }
113.flag.wait { color: var(--wait); }
114.nums { grid-row: 1 / span 3; grid-column: 3; text-align: right; font: 11px/1.5 var(--mono); color: var(--muted); display: grid; gap: 3px; align-content: start; }
115.nums .t { color: var(--ink); }
116.bar-tok { height: 2px; width: 50px; background: var(--sunk); overflow: hidden; justify-self: end; margin-top: 2px; }
117.bar-tok i { display: block; height: 100%; background: color-mix(in srgb, var(--fam) var(--mix), var(--panel)); }
118.over { grid-column: 2; font-size: 11px; color: var(--muted); overflow-wrap: anywhere; }
119.done-row .name b, .done-row .doing { color: var(--muted); }
120.feedcol { border-left: 1px solid var(--line); padding: 14px 16px; background: #10161d; min-width: 0; }
121.feedcol h2 { font-size: 10px; }
122.feed { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
123.feed li { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 10px; font-size: 12px; color: var(--muted); }
124.feed time { font: 10px/1.8 var(--mono); }
125.feed .done { color: var(--run); } .feed .warn { color: var(--wait); } .feed .fail { color: var(--fail); }
126.feed span { overflow-wrap: anywhere; }
127.jump { all: unset; cursor: pointer; text-align: left; overflow-wrap: anywhere; text-decoration: underline; text-decoration-color: #546171; text-underline-offset: 3px; }
128.jump:focus-visible { outline: 2px solid var(--sonnet); outline-offset: 3px; }
129.acts { margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line); display: flex; flex-wrap: wrap; gap: 8px; }
130.acts .note { font-size: 11px; line-height: 1.6; }
131.nomatch, .empty { padding: 40px 24px; text-align: center; color: var(--muted); background: var(--panel); border: 1px dashed #435065; border-radius: 8px; }
132.usage { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
133.usage-card { min-width: 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); overflow: hidden; }
134.usage-head { padding: 13px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); }
135.usage-head h2 { margin: 0; font-size: 14px; letter-spacing: -.015em; text-transform: none; color: var(--ink); }
136.provider-mark { width: 8px; height: 8px; border: 2px solid var(--haiku); border-radius: 50%; margin-right: 2px; }
137.usage-card.codex-usage .provider-mark { border-radius: 1px; border-color: var(--cx); }
138.plan { font: 10px var(--mono); padding: 2px 6px; border: 1px solid #435065; border-radius: 3px; color: #bcc7d5; text-transform: uppercase; }
139.usage-state { margin-left: auto; font-size: 11px; color: var(--muted); }
140.usage-state.good { color: var(--run); } .usage-state.danger { color: var(--fail); font-weight: 600; }
141.usage-card.limited { border-color: #8b4751; }
142.usage-card.limited .usage-head { background: var(--fail-bg); border-color: #63333d; }
143.usage-windows { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 16px 18px; gap: 14px; }
144.usage-window { min-width: 0; display: grid; gap: 5px; }
145.usage-window + .usage-window { padding-left: 16px; border-left: 1px solid var(--line); }
146.window-label { font-size: 11px; color: #c0cad7; }
147.remaining { font: 500 32px/1.15 var(--mono); letter-spacing: -.06em; color: var(--gauge, var(--muted)); }
148.remaining small { font: 11px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0; color: var(--muted); margin-left: 7px; }
149.usage-gauge { position: relative; height: 5px; margin: 3px 0; background: var(--sunk); overflow: hidden; border-radius: 1px; }
150.usage-gauge i { display: block; height: 100%; width: var(--left, 0%); background: var(--gauge, var(--done)); }
151.usage-gauge::after { content: ""; position: absolute; inset: 0; background: repeating-linear-gradient(90deg, transparent 0, transparent calc(10% - 2px), var(--panel) calc(10% - 2px), var(--panel) 10%); }
152.window-meta { display: flex; flex-wrap: wrap; gap: 3px 10px; color: var(--muted); font-size: 10px; }
153.reset-clock { color: #b6c2d1; font: 10px/1.7 var(--mono); }
154.reset-note { color: var(--run); font-size: 10px; }
155.usage-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; justify-content: space-between; padding: 9px 18px; border-top: 1px solid var(--line); color: var(--muted); font-size: 10px; }
156.usage-expiry.warning, .usage-asof.warning { color: var(--wait); } .usage-expiry.danger { color: var(--fail); }
157.usage-empty { min-height: 121px; display: flex; align-items: center; padding: 18px; color: var(--muted); font-size: 12px; }
158.usage-card.unavailable .provider-mark { border-color: var(--done); }
159.usage-card.unavailable .usage-head h2 { color: var(--muted); }
160@keyframes flow { to { stroke-dashoffset: -9; } }
161@media (hover: hover) and (pointer: fine) {
162 .mini:hover, .alerts:hover, .f:hover, .sess-head:hover { background: var(--sunk); }
163 .row:hover { background: #1a222c; }
164 .a:hover { border-color: var(--muted); }
165 .jump:hover { color: var(--ink); text-decoration-color: currentColor; }
166}
167@media (max-width: 1024px) {
168 .wrap { padding: 20px 20px 40px; }
169 .filters { flex-basis: 100%; }
170 .body { grid-template-columns: minmax(0, 1fr) 250px; }
171 .chips { max-width: 380px; }
172}
173@media (max-width: 760px) {
174 .wrap { padding: 16px 12px 36px; gap: 16px; }
175 .usage { grid-template-columns: minmax(0, 1fr); gap: 10px; }
176 .body { grid-template-columns: minmax(0, 1fr); }
177 .feedcol { border-left: 0; border-top: 1px solid var(--line); }
178 .sess-head { grid-template-columns: minmax(0, 1fr); padding: 14px; }
179 .chips { grid-row: auto; grid-column: 1; justify-content: flex-start; max-width: none; margin-top: 5px; }
180 .tools, .pulse { padding-left: 14px; padding-right: 14px; }
181 .pulse { grid-template-columns: minmax(60px, 100px) 1fr; gap: 16px; }
182 .stat { justify-content: flex-end; gap: 3px 12px; }
183 .row { grid-template-columns: 12px minmax(0, 1fr); padding-left: calc(16px + min(var(--d, 0), 6) * 18px); }
184 .nums { grid-row: auto; grid-column: 2; display: flex; gap: 10px; text-align: left; }
185 .bar-tok { display: none; }
186 .legend > span:last-child { margin-left: 0; flex-basis: 100%; }
187 .search { flex: 1; min-width: 140px; }
188 .bar { gap: 8px 12px; } .bar .alerts { margin-left: 0; }
189 .f, .alerts, .mini { min-height: 36px; }
190}
191@media (max-width: 380px) {
192 .usage-windows { padding: 14px; gap: 10px; }
193 .usage-window + .usage-window { padding-left: 10px; }
194 .remaining { font-size: 28px; }
195 .remaining small { display: block; margin-left: 0; }
196 .sel { flex: 1; }
197}
198@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } }
199</style>
200</head>
201<body>
202<main class="wrap">
203 <section class="usage" aria-label="Account usage">
204 <article class="usage-card unavailable" id="usage-claude" aria-label="Claude usage"><div class="usage-head"><span class="provider-mark" aria-hidden="true"></span><h2>Claude</h2><span class="usage-state">Connecting</span></div><div class="usage-empty">Waiting for usage data…</div></article>
205 <article class="usage-card codex-usage unavailable" id="usage-codex" aria-label="Codex usage"><div class="usage-head"><span class="provider-mark" aria-hidden="true"></span><h2>Codex</h2><span class="usage-state">Connecting</span></div><div class="usage-empty">Waiting for usage data…</div></article>
206 </section>
207 <header class="bar">
208 <h1>Agent board</h1>
209 <span class="live" id="live" role="status"><i aria-hidden="true"></i><span id="status">connecting</span></span>
210 <button class="alerts" id="alerts">Enable alerts</button>
211 </header>
212 <nav class="toolbar" aria-label="Session controls">
213 <div class="filters" id="filters" role="group" aria-label="Filter sessions"></div>
214 <input class="search" id="q" type="search" placeholder="Search title, folder, status" aria-label="Search sessions">
215 <select class="sel" id="sort" aria-label="Sort sessions"><option value="sev">Needs attention first</option><option value="recent">Most recent</option><option value="name">Title</option></select>
216 </nav>
217 <section id="attnbox" aria-label="Needs attention" hidden><h2 id="attnh"></h2><div class="attn" id="attn"></div></section>
218 <div class="legend" id="legend" aria-label="Legend"></div>
219 <div id="board"><div class="empty">Waiting for sessions with the agent-board mod…</div></div>
220 <div class="nomatch" id="nomatch" hidden>No sessions match. Clear the search or pick All.</div>
221</main>
222<script>
223// Keep this browser equivalent in sync with compareAdvice in board.ts.
224function compareAdvice(advice, nodes) {
225 var advisedParallel = advice.mode === 'parallel' ? advice.parts.filter(function (p) { return p.executor === 'claude'; }).length : 0;
226 var advisedCodex = advice.mode === 'parallel' ? advice.parts.filter(function (p) { return p.executor === 'codex'; }).length : 0;
227 var started = nodes.filter(function (n) { return n.startedAt >= advice.at; });
228 var actualAgents = started.filter(function (n) { return n.kind === 'agent' || n.kind === 'workflow'; }).length;
229 var actualCodex = started.filter(function (n) { return n.kind === 'codex'; }).length;
230 var verdict = actualAgents > advisedParallel || actualCodex > advisedCodex ? 'over' : advice.mode === 'single' ? 'single-ok' : actualAgents < advisedParallel || actualCodex < advisedCodex ? 'under' : 'followed';
231 return { advisedParallel: advisedParallel, advisedCodex: advisedCodex, actualAgents: actualAgents, actualCodex: actualCodex, verdict: verdict };
232}
233var SILENT = 20000, LIVE = 300000;
234var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); };
235var clip = function (s, n) { s = String(s == null ? '' : s); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
236var dur = function (ms) { var s = Math.max(0, Math.round(ms / 1000)); if (s < 60) return s + 's'; var m = Math.floor(s / 60); return m < 60 ? m + 'm ' + String(s % 60).padStart(2, '0') + 's' : Math.floor(m / 60) + 'h ' + String(m % 60).padStart(2, '0') + 'm'; };
237var tok = function (n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n || 0); };
238var store = {
239 get: function (k, d) { try { var v = localStorage.getItem('ab.' + k); return v ? JSON.parse(v) : d; } catch (e) { return d; } },
240 set: function (k, v) { try { localStorage.setItem('ab.' + k, JSON.stringify(v)); } catch (e) {} }
241};
242var pinned = store.get('pinned', {}), folded = store.get('closed', {}), clearedNodes = {}, clearedAt = store.get('clearedAt', {});
243var filter = 'all', all = [];
244
245
246// Usage is independent of session polling: unavailable account data never blocks the board.
247var usageData = null, usageBusy = false;
248function finiteNumber(value) { return typeof value === 'number' && Number.isFinite(value); }
249function validDate(value) { return finiteNumber(value) && !isNaN(new Date(value).getTime()); }
250function textIfChanged(el, text) { if (el && el.textContent !== text) el.textContent = text; }
251function usageCard(provider, data) {
252 var name = provider === 'claude' ? 'Claude' : 'Codex';
253 var available = data && typeof data === 'object' && Array.isArray(data.windows);
254 var limited = available && (data.status === 'rejected' || data.status === 'limited');
255 var card = document.getElementById('usage-' + provider);
256 card.classList.toggle('unavailable', !available); card.classList.toggle('limited', !!limited);
257 var state = !available ? 'Usage unavailable' : limited ? 'Limit reached' : data.status === 'allowed' ? 'Available' : 'Status unknown';
258 var html = '<div class="usage-head"><span class="provider-mark" aria-hidden="true"></span><h2>' + name + '</h2><span class="plan">' + esc(available && data.plan || 'Plan unknown') + '</span><span class="usage-state' + (limited ? ' danger' : available && data.status === 'allowed' ? ' good' : '') + '">' + state + '</span></div>';
259 if (!available) html += '<div class="usage-empty">Usage unavailable. Retrying every 15 seconds.</div>';
260 else if (!data.windows.length) html += '<div class="usage-empty">No usage windows reported.</div>';
261 else html += '<div class="usage-windows">' + data.windows.filter(function (w) { return w && typeof w === 'object'; }).map(function (w) {
262 var known = finiteNumber(w.usedPct), used = known ? Math.max(0, Math.min(100, w.usedPct)) : 0;
263 var left = w.stale === true ? 100 : known ? 100 - used : null;
264 var pct = left === null ? '?' : String(Math.round(left * 10) / 10);
265 var color = left === null ? 'var(--done)' : left > 40 ? 'var(--run)' : left >= 15 ? 'var(--wait)' : 'var(--fail)';
266 var label = w.label || (w.id === 'five_hour' ? '5-hour window' : 'Weekly');
267 var reset = finiteNumber(w.resetsAt) && validDate(w.resetsAt * 1000) ? w.resetsAt * 1000 : '';
268 return '<div class="usage-window" style="--gauge:' + color + ';--left:' + (left === null ? 0 : left) + '%"><span class="window-label">' + esc(label) + '</span><div class="remaining">' + pct + (left === null ? '' : '%') + '<small>remaining</small></div><div class="usage-gauge" role="img" aria-label="' + esc(label) + ': ' + (left === null ? 'usage unknown' : pct + ' percent remaining') + '"><i></i></div><div class="window-meta"><span>' + (w.stale === true ? '0' : known ? Math.round(used * 10) / 10 : '?') + '% used</span>' + (w.stale === true ? '<span class="reset-note">Window reset</span>' : '') + '</div><span class="reset-clock" data-reset="' + reset + '"></span></div>';
269 }).join('') + '</div>';
270 html += '<div class="usage-foot"><span class="usage-expiry" data-expiry="' + (available && validDate(data.expiresAt) ? data.expiresAt : '') + '"></span><span class="usage-asof" data-asof="' + (available && validDate(data.asOf) ? data.asOf : '') + '"></span></div>';
271 setHtml(card, html);
272}
273function tickUsage(now) {
274 document.querySelectorAll('[data-reset]').forEach(function (el) {
275 var hasAt = el.dataset.reset !== '', at = Number(el.dataset.reset), seconds = Math.max(0, Math.ceil((at - now) / 1000));
276 var days = Math.floor(seconds / 86400), hours = Math.floor(seconds / 3600) % 24, minutes = Math.floor(seconds / 60) % 60;
277 var value = days ? days + 'd ' + hours + 'h ' + minutes + 'm ' + seconds % 60 + 's' : hours ? hours + 'h ' + minutes + 'm ' + seconds % 60 + 's' : minutes ? minutes + 'm ' + seconds % 60 + 's' : seconds + 's';
278 textIfChanged(el, !hasAt ? 'Reset time unavailable' : at <= now ? 'Reset pending refresh' : 'Resets in ' + value);
279 });
280 document.querySelectorAll('[data-asof]').forEach(function (el) {
281 var hasAt = el.dataset.asof !== '', at = Number(el.dataset.asof), stale = hasAt && now - at > 1800000;
282 el.classList.toggle('warning', stale);
283 textIfChanged(el, hasAt ? 'As of ' + new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + (stale ? ' · over 30m old' : '') : 'As of: unavailable');
284 if (hasAt) el.title = new Date(at).toLocaleString();
285 });
286 document.querySelectorAll('[data-expiry]').forEach(function (el) {
287 var hasAt = el.dataset.expiry !== '', at = Number(el.dataset.expiry), remaining = at - now, days = Math.max(0, Math.ceil(remaining / 86400000));
288 el.classList.toggle('warning', hasAt && remaining < 7 * 86400000 && remaining >= 2 * 86400000);
289 el.classList.toggle('danger', hasAt && remaining < 2 * 86400000);
290 textIfChanged(el, hasAt ? new Date(at).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) + ' · ' + (remaining <= 0 ? 'Expired' : days + (days === 1 ? ' day left' : ' days left')) : 'Renewal date not available');
291 });
292}
293async function pollUsage() {
294 if (usageBusy) return;
295 usageBusy = true;
296 var controller = new AbortController(), timeout = setTimeout(function () { controller.abort(); }, 10000);
297 try {
298 var response = await fetch('usage.json', { cache: 'no-store', signal: controller.signal });
299 if (!response.ok) throw new Error('Usage unavailable');
300 var data = await response.json();
301 if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Invalid usage data');
302 usageData = data;
303 } catch (error) { usageData = null; }
304 finally {
305 clearTimeout(timeout); usageBusy = false;
306 usageCard('claude', usageData && usageData.claude); usageCard('codex', usageData && usageData.codex); tickUsage(Date.now());
307 }
308}
309// Clock text is patched in place; its HTML stays constant between snapshots.
310function clockHtml(start, end) { return '<span data-clock="' + start + '" data-clock-end="' + (end || 0) + '"></span>'; }
311function tickClocks() {
312 var now = Date.now();
313 document.querySelectorAll('[data-clock]').forEach(function (el) { textIfChanged(el, dur((Number(el.dataset.clockEnd) || now) - Number(el.dataset.clock))); });
314 tickUsage(now);
315}
316
317// Model family + version from whatever string the session reports ("claude-opus-4-1-2025…", "opus", "claude-3-5-sonnet").
318function parseModel(raw) {
319 var m = String(raw || '').toLowerCase(), fam = null, i;
320 var names = ['opus', 'sonnet', 'haiku'];
321 for (i = 0; i < names.length; i++) if (m.indexOf(names[i]) > -1) fam = names[i];
322 if (!fam) return { fam: null, ver: 0, label: '' };
323 var a = m.match(new RegExp(fam + '[-_ ]?(\\\\d{1,2})(?:[-._](\\\\d{1,2})(?!\\\\d))?')), b = m.match(new RegExp('(\\\\d{1,2})(?:[-._](\\\\d{1,2}))?[-_ ]' + fam));
324 var hit = a || b, major = hit ? +hit[1] : 0, minor = hit && hit[2] ? +hit[2] : 0;
325 return { fam: fam, ver: hit ? major * 100 + minor : 0, label: fam[0].toUpperCase() + fam.slice(1) + (hit ? ' ' + major + (hit[2] ? '.' + minor : '') : '') };
326}
327function modelClass(a, best) {
328 var eff = a.t3 && a.t3.effort ? ' · ' + a.t3.effort : '';
329 if (a.kind === 'codex') return { cls: 'codex t' + (a.status === 'done' || a.status === 'failed' ? 3 : 1), label: a.t3 ? clip(a.model, 22) + eff : 'Codex' };
330 var p = parseModel(a.model);
331 if (!p.fam) return { cls: 't1', label: a.model ? clip(a.model, 18) : '' };
332 var top = best[p.fam] || p.ver, tier = !p.ver ? 2 : p.ver === top ? 1 : Math.floor(p.ver / 100) === Math.floor(top / 100) ? 2 : 3;
333 return { cls: p.fam + ' t' + tier, label: p.label + eff };
334}
335function bestVersions(sessions) {
336 var best = {};
337 sessions.forEach(function (s) { (s.agents || []).forEach(function (a) { var p = parseModel(a.model); if (p.fam && p.ver > (best[p.fam] || 0)) best[p.fam] = p.ver; }); });
338 return best;
339}
340function order(agents) {
341 var ids = new Set(agents.map(function (a) { return a.id; })), kids = new Map(), out = [];
342 agents.forEach(function (a) { if (a.id !== 'main') { var p = ids.has(a.parentId) ? a.parentId : 'main'; kids.set(p, (kids.get(p) || []).concat([a])); } });
343 var walk = function (a, d, parent) { out.push({ a: a, d: d, parent: parent }); (kids.get(a.id) || []).forEach(function (k) { walk(k, d + 1, a.id); }); };
344 var main = agents.find(function (a) { return a.id === 'main'; }); if (main) walk(main, 0, null);
345 return out;
346}
347function edgeState(a) {
348 if (a.status === 'done' || a.status === 'failed') return 'done';
349 if (a.status !== 'running') return 'idle';
350 if (a.kind === 'codex' && a.codex && (a.codex.lastMessage || a.codex.isComplete)) return 'recv';
351 return 'send';
352}
353function stateChip(s, info) {
354 if (info.silent || info.ended) return '';
355 var main = info.agents.find(function (a) { return a.id === 'main'; });
356 return main && main.status === 'running' ? '<span class="chip live-on">working</span>' : '<span class="chip live-idle">idle, waiting for you</span>';
357}
358function flagged(a) { return a.health && a.health !== 'ok' && a.health !== 'idle'; }
359function sessionInfo(s, now) {
360 var agents = s.agents || [], stale = now - s.updatedAt > SILENT, working = agents.some(function (a) { return a.status === 'running'; });
361 var ended = !!s.isEnded || (!s.standaloneCodex && now - s.updatedAt > 15000), silent = !s.standaloneCodex && !s.isEnded && stale && working;
362 var run = agents.filter(function (a) { return a.id !== 'main' && a.status === 'running'; }).length;
363 var fail = agents.filter(function (a) { return a.status === 'failed'; }).length;
364 var warn = agents.filter(flagged);
365 var codex = agents.filter(function (a) { return a.kind === 'codex'; });
366 var sev = warn.length || fail || silent ? 3 : run || agents.some(function (a) { return a.status === 'running'; }) ? 2 : ended ? 0 : 1;
367 return { agents: agents, ended: ended, silent: silent, run: run, fail: fail, warn: warn, codex: codex, sev: sev };
368}
369
370function rowHtml(r, best, now, ended) {
371 var a = r.a, mc = modelClass(a, best), end = a.endedAt || now, over = a.status === 'done' || a.status === 'failed';
372 var doing = a.tool ? a.tool + (a.detail ? ' · ' + a.detail : '') : a.t3 ? (over ? (a.t3.result || a.status) : 'working') : a.kind === 'codex' ? (a.detail || 'starting') : a.status;
373 var fl = flagged(a), st = a.status;
374 var third = a.codex && over && a.codex.summary ? a.codex.summary : a.codex ? a.codex.files.length + ' files' + (a.codex.tasks.length ? ' · ' + a.codex.tasks.join(', ') : '') : '';
375 var line = over ? [a.kind === 'codex' && !a.codex && !a.t3 ? a.detail : '', a.t3 ? '' : a.tools + (a.kind === 'codex' ? ' cmds' : ' tools'), a.errors ? a.errors + ' err' : '', a.tokens ? tok(a.tokens) : '', dur(end - a.startedAt)].filter(Boolean).join(' · ') : doing;
376 var pct = Math.max(2, Math.min(100, Math.round((a.tokens || 0) / (a.kind === 'codex' ? 1200000 : 300000) * 100)));
377 return '<div role="listitem" class="row ' + mc.cls + ' ' + esc(st) + (over ? ' done-row' : '') + '" style="--d:' + r.d + '" data-id="' + esc(a.id) + '"' + (r.parent ? ' data-p="' + esc(r.parent) + '" data-edge="' + edgeState(a) + '"' : '') + ' title="' + esc(a.label + '\\n' + (a.healthNote || '') + (a.codex && a.codex.summary ? '\\n' + a.codex.summary : '')) + '">' +
378 '<i class="dot" aria-hidden="true"></i><span class="name"><b>' + esc(clip(a.label, 70)) + '</b>' + (mc.label ? '<span class="badge">' + esc(mc.label) + '</span>' : '') + '</span>' +
379 '<span class="doing">' + esc(clip(line, 140)) + '</span>' +
380 (fl ? '<span class="flag' + (a.health === 'approval' ? ' wait' : '') + '">' + esc(clip(a.healthNote || a.health, 120)) + '</span>' : third && !over ? '<span class="over">' + esc(clip(third, 120)) + '</span>' : over && third ? '<span class="over">' + esc(clip(third, 120)) + '</span>' : '') +
381 '<span class="nums"><span class="t">' + clockHtml(a.startedAt, a.endedAt) + '</span>' + (a.tokens ? '<span>' + tok(a.tokens) + '</span><span class="bar-tok"><i style="width:' + pct + '%"></i></span>' : '') + '</span></div>';
382}
383function spark(values) {
384 var v = values.length ? values : [0], top = Math.max(1, Math.max.apply(null, v)), n = v.length;
385 var pt = function (y, i) { return (n === 1 ? 0 : (i / (n - 1)) * 300).toFixed(1) + ',' + (32 - (y / top) * 26).toFixed(1); };
386 var pts = v.map(pt), last = v[n - 1], topPct = ((32 - (last / top) * 26) / 36 * 100).toFixed(1);
387 return '<div class="sp"><svg viewBox="0 0 300 36" preserveAspectRatio="none" role="img" aria-label="Running agents over the last ' + n + ' seconds"><defs><linearGradient id="sg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--run)" stop-opacity=".32"/><stop offset="1" stop-color="var(--run)" stop-opacity="0"/></linearGradient></defs><line x1="0" y1="35" x2="300" y2="35" stroke="var(--line)"/><path d="M0,35 L' + pts.join(' L') + ' L300,35 Z" fill="url(#sg)"/><polyline points="' + pts.join(' ') + '" fill="none" stroke="var(--run)" stroke-width="1.5" vector-effect="non-scaling-stroke"/></svg><i style="top:' + topPct + '%"></i></div>';
388}
389function eventsFor(s) {
390 var cut = clearedAt[s.sessionId] || 0;
391 return (s.events || []).filter(function (e) { return e.at > cut; }).slice(-8).reverse();
392}
393function adviceChip(s, now) {
394 var a = s.advice;
395 if (!a || now - a.at > 21600000 || a.at > now) return '';
396 var c = compareAdvice(a, s.agents || []);
397 var title = a.reason + '\\n' + a.parts.map(function (p) { return p.title + ': ' + p.executor + ' · ' + p.model + (p.effort ? ' · ' + p.effort : ''); }).join('\\n');
398 var mode = a.mode === 'parallel' ? 'parallel ×' + a.parts.length + ' (' + c.advisedParallel + ' claude · <span style="color:var(--cx)">' + c.advisedCodex + ' codex</span>)' : 'single';
399 var cls = c.verdict === 'under' || c.verdict === 'over' ? 'wait' : 'run';
400 return '<span class="chip ' + cls + '" dir="ltr" style="unicode-bidi:isolate;max-width:100%;overflow:hidden;text-overflow:ellipsis" title="' + esc(title) + '">advice: ' + mode + ' → ran ' + c.actualAgents + ' agents · <span style="color:var(--cx)">' + c.actualCodex + ' codex</span> [' + esc(c.verdict) + ']</span>';
401}
402function parts(s, info, best, now) {
403 var gone = clearedNodes[s.sessionId] || {}, agents = info.agents;
404 var rows = order(agents).filter(function (r) { return !gone[r.a.id]; });
405 var begin = Math.min.apply(null, agents.map(function (a) { return a.startedAt || now; }).concat([now]));
406 var claude = 0, cx = 0, calls = 0;
407 agents.forEach(function (a) { if (a.kind === 'codex') cx += a.tokens || 0; else claude += a.tokens || 0; calls += a.tools || 0; });
408 var title = s.title || s.name, nAgents = Math.max(0, agents.length - 1);
409 var head = '<span class="title"><span class="chev">▾</span>' + esc(title) + '</span><span class="cwd">' + esc(s.cwd) + '</span><span class="chips">' + stateChip(s, info) + adviceChip(s, now) +
410 (info.run ? '<span class="chip run">' + info.run + ' running</span>' : '') +
411 (info.warn.length ? '<span class="chip fail">' + info.warn.length + ' need a look</span>' : '') +
412 (info.fail ? '<span class="chip fail">' + info.fail + ' failed</span>' : '') +
413 '<span class="chip">' + nAgents + (nAgents === 1 ? ' agent' : ' agents') + '</span>' +
414 (info.codex.length ? '<span class="chip">' + info.codex.length + ' Codex</span>' : '') +
415 (info.silent ? '<span class="chip fail">no heartbeat ' + dur(now - s.updatedAt) + '</span>' : info.ended ? '<span class="chip">ended</span>' : '') + '</span>';
416 var tools = '<button class="mini pin" aria-pressed="' + (!!pinned[s.sessionId]) + '">' + (pinned[s.sessionId] ? '★ Pinned' : '☆ Pin') + '</button><span class="id">#' + esc(String(s.sessionId).slice(0, 4)) + '</span><button class="mini copy" data-path="' + esc(s.cwd) + '">Copy path</button>';
417 var pulse = spark(s.activity || []) + '<div class="stat"><span><b>' + clockHtml(begin, info.ended ? s.updatedAt : 0) + '</b> elapsed</span><span><b>' + tok(claude + cx) + '</b> tokens (Claude ' + tok(claude) + (cx ? ' · Codex ' + tok(cx) : '') + ')</span><span><b>' + calls + '</b> tool calls</span></div>';
418 var tree = rows.map(function (r) { return rowHtml(r, best, now, info.ended); }).join('');
419 var feed = eventsFor(s).map(function (ev) {
420 var known = ev.nodeId && rows.some(function (r) { return r.a.id === ev.nodeId; });
421 var cls = ev.level === 'info' ? '' : ev.level;
422 return '<li class="' + esc(cls) + '"><time>' + esc(new Date(ev.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })) + '</time><span>' + (known ? '<button class="jump" data-go="' + esc(ev.nodeId) + '">' + esc(ev.text) + '</button>' : esc(ev.text)) + '</span></li>';
423 }).join('') || '<li><time></time><span class="note">No events yet.</span></li>';
424 return { head: head, tools: tools, pulse: pulse, tree: tree, feed: feed };
425}
426
427function drawEdges(tree) {
428 var old = tree.querySelector('svg.edges'); if (old) old.remove();
429 var rows = {}; tree.querySelectorAll('.row[data-id]').forEach(function (r) { rows[r.dataset.id] = r; });
430 var box = tree.getBoundingClientRect(); if (!box.height) return;
431 var NS = 'http://www.w3.org/2000/svg', svg = document.createElementNS(NS, 'svg'); svg.setAttribute('class', 'edges'); svg.setAttribute('aria-hidden', 'true');
432 var mid = 'ah' + Math.random().toString(36).slice(2, 7), defs = document.createElementNS(NS, 'defs');
433 defs.innerHTML = '<marker id="' + mid + '" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto" markerUnits="userSpaceOnUse"><path d="M0 0L7 3.5L0 7z" fill="context-stroke"/></marker>';
434 svg.appendChild(defs);
435 var dot = function (r) { var b = r.querySelector('.dot').getBoundingClientRect(); return { x: b.left - box.left + b.width / 2, y: b.top - box.top + b.height / 2 }; };
436 var lit = [], quiet = [];
437 Object.keys(rows).forEach(function (id) {
438 var r = rows[id], pid = r.dataset.p; if (!pid || !rows[pid]) return;
439 var P = dot(rows[pid]), C = dot(r), st = r.dataset.edge || 'idle', col = getComputedStyle(r).getPropertyValue('--fam');
440 var dx = P.x - 2, rx = P.x + 4, ty = C.y - 3, by = C.y + 3, q = 5;
441 var down = 'M' + dx + ' ' + (P.y + 7) + 'V' + (ty - q) + 'Q' + dx + ' ' + ty + ' ' + (dx + q) + ' ' + ty + 'H' + (C.x - 8);
442 var up = 'M' + (C.x - 8) + ' ' + by + 'H' + (rx + q) + 'Q' + rx + ' ' + by + ' ' + rx + ' ' + (by - q) + 'V' + (P.y + 8);
443 var mk = function (d, cls) { var p = document.createElementNS(NS, 'path'); p.setAttribute('d', d); p.setAttribute('class', 'e ' + cls); if (cls !== 'off') { p.setAttribute('stroke', col); p.style.color = col; } p.setAttribute('marker-end', 'url(#' + mid + ')'); return p; };
444 if (st === 'send') { quiet.push(mk(up, 'off')); lit.push(mk(down, 'on')); }
445 else if (st === 'recv') { lit.push(mk(down, 'hold')); lit.push(mk(up, 'on')); }
446 else if (st === 'done') { quiet.push(mk(down, 'fade')); quiet.push(mk(up, 'fade')); }
447 else { quiet.push(mk(down, 'off')); quiet.push(mk(up, 'off')); }
448 });
449 quiet.concat(lit).forEach(function (p) { svg.appendChild(p); });
450 tree.appendChild(svg);
451}
452
453function sectionFor(s) {
454 var el = document.createElement('section'); el.id = 'sess-' + s.sessionId; el.className = 'sess'; el.dataset.sid = s.sessionId;
455 el.innerHTML = '<button class="sess-head" aria-expanded="true"></button><div class="inner"><div class="tools"></div><div class="pulse"></div><div class="body"><div class="tree" role="list"></div><aside class="feedcol" aria-label="Recent events"><h2>Timeline</h2><ul class="feed"></ul><div class="acts"><button class="mini clr-nodes">Clear finished agents</button><button class="mini clr-hist">Clear history</button><p class="note">Clearing finished agents keeps the timeline, so you still see what went wrong.</p></div></aside></div></div>';
456 return el;
457}
458function setHtml(el, html) {
459 if (el._h === html) return false;
460 var buttons = Array.from(el.querySelectorAll('button')), focusIndex = buttons.indexOf(document.activeElement);
461 el._h = html; el.innerHTML = html;
462 if (focusIndex >= 0) { var target = el.querySelectorAll('button')[focusIndex]; if (target) target.focus({ preventScroll: true }); }
463 return true;
464}
465function setTree(tree, html) {
466 if (tree._h === html) return false;
467 tree._h = html;
468 var incoming = document.createElement('div'); incoming.innerHTML = html;
469 var existing = new Map(); tree.querySelectorAll('.row').forEach(function (row) { existing.set(row.dataset.id, row); });
470 Array.from(incoming.children).forEach(function (next, index) {
471 var row = existing.get(next.dataset.id);
472 if (row) {
473 existing.delete(next.dataset.id);
474 if (row._rowHtml !== next.outerHTML) {
475 row.className = next.className;
476 Array.from(row.attributes).forEach(function (attr) { if (!next.hasAttribute(attr.name)) row.removeAttribute(attr.name); });
477 Array.from(next.attributes).forEach(function (attr) { if (row.getAttribute(attr.name) !== attr.value) row.setAttribute(attr.name, attr.value); });
478 setHtml(row, next.innerHTML); row._rowHtml = next.outerHTML;
479 }
480 } else { row = next; row._rowHtml = next.outerHTML; }
481 if (tree.children[index] !== row) tree.insertBefore(row, tree.children[index] || null);
482 });
483 existing.forEach(function (row) { row.remove(); });
484 return true;
485}
486
487function render() {
488 var now = Date.now(), live = all.filter(function (s) { return now - s.updatedAt < (s.standaloneCodex ? 7200000 : LIVE); });
489 // A T3 child thread already shows as a node under its parent thread's session.
490 var threads = {}; live.forEach(function (s) { if (s.t3) threads[s.t3.threadId] = 1; });
491 live = live.filter(function (s) { return !(s.t3 && s.t3.parentThreadId && threads[s.t3.parentThreadId]); });
492 var infos = new Map(); live.forEach(function (s) { infos.set(s.sessionId, sessionInfo(s, now)); });
493 var best = bestVersions(live), board = document.getElementById('board');
494 if (!live.length) { setHtml(board, '<div class="empty">No recent sessions. Start Claude with agent-board or a Codex thread in T3 Code.</div>'); document.getElementById('attnbox').hidden = true; document.getElementById('nomatch').hidden = true; renderFilters(infos, live); renderLegend(best); return; }
495 if (board.querySelector('.empty')) { board.innerHTML = ''; board._h = null; }
496 var q = document.getElementById('q').value.trim().toLowerCase(), mode = document.getElementById('sort').value;
497 var sorted = live.slice().sort(function (a, b) {
498 var pa = pinned[a.sessionId] ? 1 : 0, pb = pinned[b.sessionId] ? 1 : 0; if (pa !== pb) return pb - pa;
499 if (mode === 'name') return String(a.title || a.name).localeCompare(String(b.title || b.name));
500 if (mode === 'recent') return b.updatedAt - a.updatedAt;
501 return infos.get(b.sessionId).sev - infos.get(a.sessionId).sev || b.updatedAt - a.updatedAt;
502 });
503 var shown = 0, existing = {};
504 board.querySelectorAll('.sess').forEach(function (el) { existing[el.dataset.sid] = el; });
505 Object.keys(existing).forEach(function (id) { if (!infos.has(id)) existing[id].remove(); });
506 sorted.forEach(function (s, i) {
507 var info = infos.get(s.sessionId), el = existing[s.sessionId] || sectionFor(s), p = parts(s, info, best, now);
508 var isClosed = s.sessionId in folded ? folded[s.sessionId] : info.ended;
509 el.classList.toggle('closed', !!isClosed); el.classList.toggle('ended', info.ended);
510 el.querySelector('.sess-head').setAttribute('aria-expanded', String(!isClosed));
511 setHtml(el.querySelector('.sess-head'), p.head); setHtml(el.querySelector('.tools'), p.tools);
512 setHtml(el.querySelector('.pulse'), p.pulse); setHtml(el.querySelector('.feed'), p.feed);
513 var tree = el.querySelector('.tree');
514 var changedTree = setTree(tree, p.tree), wasHidden = el.hidden;
515 var hay = ((s.title || '') + ' ' + s.name + ' ' + s.cwd + ' ' + (info.warn.length ? 'attention looping stalled approval' : '') + (info.run ? ' running' : '') + (info.codex.length ? ' codex' : '') + (info.ended ? ' ended' : '')).toLowerCase();
516 var ok = (filter === 'all' || (filter === 'attn' && info.sev === 3) || (filter === 'run' && info.run > 0) || (filter === 'codex' && info.codex.length > 0) || (filter === 'ended' && info.ended)) && (!q || hay.indexOf(q) > -1);
517 el.hidden = !ok; if (ok) shown++;
518 if (board.children[i] !== el) board.insertBefore(el, board.children[i] || null);
519 if (ok && !isClosed && (changedTree || wasHidden || !tree.querySelector('svg.edges'))) drawEdges(tree);
520 });
521 document.getElementById('nomatch').hidden = shown > 0;
522 renderFilters(infos, live); renderAttention(infos, live, now); renderLegend(best); tickClocks();
523}
524function renderFilters(infos, live) {
525 var c = { all: live.length, attn: 0, run: 0, codex: 0, ended: 0 };
526 live.forEach(function (s) { var i = infos.get(s.sessionId); if (i.sev === 3) c.attn++; if (i.run) c.run++; if (i.codex.length) c.codex++; if (i.ended) c.ended++; });
527 var names = [['all', 'All'], ['attn', 'Needs attention'], ['run', 'Running'], ['codex', 'Codex'], ['ended', 'Ended']];
528 setHtml(document.getElementById('filters'), names.map(function (n) { return '<button class="f" data-f="' + n[0] + '" aria-pressed="' + (filter === n[0]) + '">' + n[1] + ' <b>' + c[n[0]] + '</b></button>'; }).join(''));
529}
530function renderAttention(infos, live, now) {
531 var items = [];
532 live.forEach(function (s) {
533 var info = infos.get(s.sessionId), title = s.title || s.name;
534 info.warn.forEach(function (a) { items.push({ cls: a.health === 'approval' ? 'wait' : 'fail', k: a.health === 'approval' ? 'Waiting for approval' : a.health === 'looping' ? 'Looping' : 'Stalled', who: a.label, why: title + (a.healthNote ? ' · ' + a.healthNote : ''), sid: s.sessionId, id: a.id }); });
535 info.agents.filter(function (a) { return a.status === 'failed'; }).forEach(function (a) { items.push({ cls: 'fail', k: 'Failed', who: a.label, why: title, sid: s.sessionId, id: a.id }); });
536 if (info.silent && now - s.updatedAt < 600000 && info.agents.some(function (a) { return a.status === 'running'; })) items.push({ cls: 'fail', k: 'No heartbeat', who: title, why: 'Silent for ' + dur(now - s.updatedAt) + ' while working', sid: s.sessionId, id: '' });
537 });
538 var box = document.getElementById('attnbox'); box.hidden = !items.length;
539 document.getElementById('attnh').textContent = 'Needs attention · ' + items.length;
540 setHtml(document.getElementById('attn'), items.map(function (x) { return '<button class="a ' + x.cls + '" data-sid="' + esc(x.sid) + '" data-go="' + esc(x.id) + '"><span class="k">' + esc(x.k) + '</span><span class="who">' + esc(clip(x.who, 80)) + '</span><span class="why">' + esc(clip(x.why, 140)) + '</span></button>'; }).join(''));
541}
542function renderLegend(best) {
543 var g = function (f, label) { return '<span class="g ' + f + '"><span class="sw t1" style="--fam:var(--' + f + ')"></span><span class="sw t2" style="--fam:var(--' + f + ');--mix:66%"></span><span class="sw t3" style="--fam:var(--' + f + ');--mix:40%"></span>' + label + '</span>'; };
544 var v = function (f) { return best[f] ? ' ' + Math.floor(best[f] / 100) + '.' + (best[f] % 100) : ''; };
545 setHtml(document.getElementById('legend'), g('opus', 'Opus' + v('opus')) + g('sonnet', 'Sonnet' + v('sonnet')) + g('haiku', 'Haiku' + v('haiku')) +
546 '<span class="g"><span class="sw sq" style="--fam:var(--cx)"></span>Codex</span><span>Newest version is strongest, older fade. Arrow down = task sent, arrow up = answer returning.</span>');
547}
548
549function jumpTo(sid, nodeId) {
550 var sec = document.getElementById('sess-' + sid); if (!sec) return;
551 if (sec.classList.contains('closed')) { folded[sid] = false; store.set('closed', folded); render(); }
552 var r = nodeId && sec.querySelector('.row[data-id="' + (window.CSS && CSS.escape ? CSS.escape(nodeId) : nodeId) + '"]') || sec;
553 r.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth', block: 'center' });
554 if (r.classList.contains('row')) { r.classList.add('flash'); setTimeout(function () { r.classList.remove('flash'); }, 1400); }
555}
556document.addEventListener('click', function (e) {
557 var t = e.target.closest('button'); if (!t) return;
558 var sec = t.closest('.sess'), sid = sec && sec.dataset.sid;
559 if (t.classList.contains('sess-head')) { var c = !sec.classList.contains('closed'); folded[sid] = c; store.set('closed', folded); sec.classList.toggle('closed', c); t.setAttribute('aria-expanded', String(!c)); if (!c) drawEdges(sec.querySelector('.tree')); return; }
560 if (t.classList.contains('pin')) { pinned[sid] = !pinned[sid]; store.set('pinned', pinned); render(); return; }
561 if (t.classList.contains('copy')) { var txt = t.dataset.path, done = function () { t.textContent = 'Copied'; setTimeout(function () { t.textContent = 'Copy path'; }, 1200); }; try { navigator.clipboard.writeText(txt).then(done, done); } catch (x) { done(); } return; }
562 if (t.classList.contains('clr-nodes')) { var s = all.find(function (x) { return x.sessionId === sid; }); clearedNodes[sid] = clearedNodes[sid] || {}; ((s && s.agents) || []).forEach(function (a) { if (a.status === 'done' || a.status === 'failed') clearedNodes[sid][a.id] = 1; }); render(); return; }
563 if (t.classList.contains('clr-hist')) { clearedAt[sid] = Date.now(); store.set('clearedAt', clearedAt); render(); return; }
564 if (t.dataset.f) { filter = t.dataset.f; render(); return; }
565 if (t.dataset.go !== undefined) jumpTo(t.dataset.sid || sid, t.dataset.go);
566});
567document.getElementById('q').addEventListener('input', render);
568document.getElementById('sort').addEventListener('change', render);
569window.addEventListener('resize', function () { document.querySelectorAll('.sess:not(.closed) .tree').forEach(drawEdges); });
570
571// Browser alerts: each new done / warn / fail event once, and a session that stops heartbeating mid-work.
572var seen = new Set(), silentSeen = new Set(), lastAlert = new Map(), primed = false;
573var alertsBtn = document.getElementById('alerts');
574function beep() {
575 try {
576 var C = window.AudioContext || window.webkitAudioContext, c = new C(), o = c.createOscillator(), g = c.createGain();
577 o.frequency.value = 880; g.gain.value = 0.08; o.connect(g); g.connect(c.destination); o.start(); o.stop(c.currentTime + 0.18);
578 } catch (e) {}
579}
580var baseTitle = document.title;
581window.addEventListener('focus', function () { document.title = baseTitle; });
582function fire(title, body, tag) {
583 if ('Notification' in window && Notification.permission === 'granted') { try { new Notification(title, { body: body, tag: tag }); return; } catch (e) {} }
584 // No OS permission: still make noise in the page so the alert is never silent.
585 beep(); if (document.hidden) document.title = '● ' + title;
586}
587function alertsLabel() {
588 if (!('Notification' in window)) { alertsBtn.textContent = 'Alerts: sound only'; alertsBtn.disabled = true; return; }
589 var p = Notification.permission;
590 alertsBtn.classList.toggle('off', p === 'denied');
591 alertsBtn.textContent = p === 'granted' ? 'Alerts on ✓ (click to test)' : p === 'denied' ? 'Alerts blocked, sound only' : 'Enable alerts';
592}
593alertsLabel();
594alertsBtn.onclick = function () {
595 if (!('Notification' in window)) return;
596 if (Notification.permission === 'granted') { fire('Agent board', 'Test alert', 'test'); return; }
597 var done = function () { alertsLabel(); if (Notification.permission !== 'granted') beep(); };
598 try { var r = Notification.requestPermission(done); if (r && r.then) r.then(done); } catch (e) { done(); }
599};
600function notifyNew(sessions) {
601 var now = Date.now();
602 sessions.forEach(function (s) {
603 (s.events || []).forEach(function (ev) {
604 var key = s.sessionId + ':' + ev.at + ':' + ev.text;
605 if (seen.has(key)) return;
606 seen.add(key);
607 // One alert per node and kind every 5 minutes; the tag makes the OS replace rather than stack.
608 var tag = s.sessionId + ':' + (ev.nodeId || '') + ':' + ev.level;
609 if (!primed || ev.level === 'info' || now - (lastAlert.get(tag) || 0) < 300000) return;
610 lastAlert.set(tag, now);
611 fire(s.title || s.name, ev.text, tag);
612 });
613 var main = (s.agents || []).find(function (a) { return a.kind === 'main'; });
614 var silent = !s.isEnded && now - s.updatedAt > SILENT && now - s.updatedAt < 600000 && main && main.status === 'running';
615 if (silent && !silentSeen.has(s.sessionId)) {
616 silentSeen.add(s.sessionId);
617 if (primed) fire(s.title || s.name, 'Session stopped answering while it was working', 'silent:' + s.sessionId);
618 } else if (!silent) silentSeen.delete(s.sessionId);
619 });
620 primed = true;
621}
622
623var polling = false;
624async function poll() {
625 if (polling) return;
626 polling = true;
627 var live = document.getElementById('live'), status = document.getElementById('status');
628 try {
629 var standalone = await fetch('codex-sessions.json', { cache: 'no-store' }).then(function (r) { return r.ok ? r.json() : []; }).catch(function () { return []; });
630 var listing = await fetch('sessions/', { cache: 'no-store' }).then(function (r) { return r.text(); }).catch(function () { return ''; });
631 var files = Array.from(new Set(Array.from(listing.matchAll(/href="([^"]+\\.json)"/g)).map(function (m) { return m[1]; })));
632 all = (await Promise.all(files.map(function (f) { return fetch('sessions/' + f, { cache: 'no-store' }).then(function (r) { return r.json(); }).catch(function () { return null; }); }))).filter(function (s) { return s && !String(s.cwd || '').startsWith('/tmp/remember-summarizer-cwd'); });
633 all = all.concat(Array.isArray(standalone) ? standalone : []);
634 render(); notifyNew(all);
635 live.classList.remove('stale'); status.textContent = 'live · ' + new Date().toLocaleTimeString();
636 } catch (error) {
637 live.classList.add('stale'); status.textContent = 'server unreachable';
638 } finally { polling = false; }
639}
640pollUsage(); setInterval(pollUsage, 15000);
641poll(); setInterval(poll, 1000); setInterval(tickClocks, 1000);
642</script>
643</body>
644</html>
645`
646types/index.d.ts 86 lines1export type NodeStatus = 'running' | 'waiting' | 'idle' | 'done' | 'failed'
2
3// ok: nothing to say; idle: waiting for the person; approval: a permission ask is open;
4// stalled: running but nothing happened for a while; looping: the same call over and over.
5export type Health = 'ok' | 'idle' | 'approval' | 'stalled' | 'looping'
6
7export type RecentCall = { tool: string; detail: string; at: number; ok?: boolean }
8
9// What a Codex run's rollout log says about it, read live from ~/.codex/sessions.
10export type CodexInfo = {
11 rollout: string
12 threadId: string
13 owner: string
14 run: string
15 cwd: string
16 prompt: string
17 tasks: string[]
18 startedAt: number
19 commands: number
20 recentCommands: string[]
21 files: string[]
22 tokens: number
23 lastAction?: string
24 lastMessage?: string
25 summary?: string
26 isComplete: boolean
27 activityAt: number
28}
29
30// A child T3 Code delegated through delegate_task, read from T3's own store.
31export type T3Info = { threadId: string; driver: string; effort?: string; result?: string }
32
33export type AgentNode = {
34 id: string
35 parentId?: string
36 kind: 'main' | 'agent' | 'workflow' | 'codex'
37 label: string
38 type: string
39 model: string
40 status: NodeStatus
41 tool?: string
42 detail?: string
43 toolStartedAt?: number
44 tools: number
45 errors: number
46 steps: number
47 turns: number
48 tokens: number
49 startedAt: number
50 endedAt?: number
51 lastEventAt: number
52 approvalSince?: number
53 recent: RecentCall[]
54 health: Health
55 healthNote?: string
56 codex?: CodexInfo
57 toolUseId?: string
58 t3?: T3Info
59}
60
61export type BoardEvent = { at: number; level: 'info' | 'done' | 'warn' | 'fail'; text: string; nodeId?: string }
62
63// Another session's heartbeat, read from its snapshot under mods-data.
64export type PeerSession = {
65 sessionId: string
66 name: string
67 state: 'working' | 'idle' | 'approval' | 'stalled' | 'looping' | 'silent' | 'ended'
68 note: string
69 since: number
70 running: number
71 updatedAt: number
72}
73
74declare module 'claude-code' {
75 interface PluginState {
76 'agent-board': {
77 agents: AgentNode[]
78 activity: number[]
79 now: number
80 webUrl: string | null
81 events: BoardEvent[]
82 peers: PeerSession[]
83 }
84 }
85}
86