SLOPSHOPPER

agent-board

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

newpaneguardcommandtoastprompt
v0.2.0MITupdated 2026-10-2566Hamidreza/claude-code-mods/agent-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-board
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ 0 agents 0 running · 0 done │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ○ main session Opus 5.5 main · 9 tools · 1 ⏺ Update(src/auth.ts) │ ┃ idle ⎿ Added 2 lines, removed 1 line │ ┃ recent: Read · Grep · Edit · Write ×2 · B… ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [open web board] [clear finished] [clear his │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ agent-board: Agent board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
0 agents 0 running · 0 done ○ main session Opus 5.5 main · 9 tools · 1 err · 8k · 0s idle recent: Read · Grep · Edit · Write ×2 · Bash ×4! [open web board] [clear finished] [clear history] [notificat
README

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

Commands

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

Configuration and operation

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.

Usage header

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.

Always-on web board

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.

Limits

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.

Installation and development · راهنمای فارسی

Source 6 files
hooks/register.tsx 833 lines
1import { 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}
833
hooks/board.ts 387 lines
1import 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}
387
hooks/codex.ts 152 lines
1import 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}
152
hooks/t3.ts 132 lines
1import 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}
132
hooks/web.ts 646 lines
1// 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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`
646
types/index.d.ts 86 lines
1export 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