SLOPSHOPPER

agent-ui

A /workers pane of this session's child runs with a live tail, and the session's pinned asks above the prompt with their context on click; a right-click drops…

newpanebandcommandprocesstimer
★ 2v0.1.0no licenseupdated 2026-10-06znut/agent-skills/plugins/agent-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-ui
│ ┃ Workers ✕ › fix the failing auth test and add an audit log call │ ┃ Workers │ ┃ set childrenDir in /config ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /workers │ ⎿ agent-ui: Workers pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workers
Workers set childrenDir in /config
README

agent-skills

These skills help one person run product and engineering work through subagents. Workers use separate git worktrees and own each task through checks, review, and push. Repos that use PRs get one small PR per task.

The skills contain no project facts or model choices. Each repo keeps its rules in .agent/orchestrate.md and its agent definitions in the active harness's project files. Claude Code and Codex can read the same repo rules while each uses its own agent file format.

Skills

Independent, invoked by the user:

SkillWork
commWriting registers and rules for everything an agent writes to a person.
orchestrateWhy the manager delegates; base, worktree, worker procedure, review, PR, and the final check.
tech-debtWeekly debt revisit: gathers friction signals, settles candidates with the user, and cuts tickets.
intakeStakeholder interview on the stakeholder's own machine, filed as issues. See intake/INSTALL.md.

Composed on top of comm and orchestrate:

SkillWork
pmSettles product choices, updates product docs, creates tickets, and gives ready work to the TL.
tlHolds the engineering bar (YAGNI, DRY, performance, OWASP), sends tasks, checks PRs, owns ADRs.

Loaded by a fresh reviewer that a worker starts, not typed by the user:

SkillWork
review-gateThe reviewer's procedure: scan, review the exact commit, return PASS, BLOCK, or ERROR.

Install

Clone this repo. Link each skill folder into the skill folder for the harness you use:

  • Claude Code: ~/.claude/skills/
  • Codex: ~/.codex/skills/

A link lets a pull in this repo update the installed skill at once.

Set up a repo

Run /orchestrate when a repo has no .agent/orchestrate.md. It reads orchestrate/bootstrap.md, checks what the repo already uses, asks the user for each missing choice, and adds .agent/orchestrate.md plus the project agent files for the chosen harness. templates/orchestrate.md lists every value a repo file states; templates/writing-rules.md seeds the repo's writing overlay. Do not copy model names between runtimes.

Setup happens once. Normal runs read .agent/orchestrate.md from the remote default branch and do not read the setup file or template.

Repo files supply values and exceptions. The shared skills own the worker and review process, so a skill update changes every repo that links these skills.

Tools

tools/ contains optional local programs: a PR status poller that turns GitHub into local files, a board snapshot, a post-merge CI driver with merge previews, a per-clone gh identity wrapper, a session boot report, a worktree hook, and Claude Code hooks (watch-guard, asks). Each documents its own setup in tools/README.md or its folder.

Plugins

plugins/ holds Claude Code plugins. plugins/agent-ui adds /workers, a pane of the session's child runs (GPT, Claude, Claude review panels) with each run's live tail on click and main-ci's verdict line, which expands to a chart of recent main-ci runs (its line is the 1-minute load average when each run started; chips switch the bars between stacked job wall time, stacked job cpu time, and one bar at the run's peak memory; hover a bar for its slowest jobs), opens itself at start in PM and TL sessions, and pins the session's asks (orchestrate §Pinned asks) above the prompt (an ask whose #N ticket is done is hidden), each expanding to its recorded context on click; a right-click on an ask drops it from the file (under the asks hook's lock), on the surfaces that draw a Client. Above its child runs it draws the session's dispatch chains (orchestrate §Dispatch chains), one line each, #4545 → #4549 → #4552: the newest issue Done on the board ticked green (older Done ones dropped), an issue a running row's label names in the accent colour, each #N linked to its issue with its board title shown on hover. Load it for one session with claude --plugin-dir <clone>/plugins/agent-ui, or for every session by adding that path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. Its paths are userConfig options in /config; each option's description in its plugin.json says what an empty value does. Set childrenDir to the folder of child run out-dirs: empty, the pane lists no child runs from disk and says to set it. The pane lists only runs whose owner-session file holds this session's id, and reads each run's kind from its provider file alone. Review panels come from the clone's git common directory (.review-panel/), the asks, gh-status, board and main-ci state from state_dir in the main checkout's .agent/local.env (session-bus.md §State directory), and ticket links from the GitHub repository of the session clone's origin remote (no GitHub remote, no link). The avatars and the main-ci chart draw as terminal pictures, which kitty and Ghostty show. Check it with claude plugin validate, claude plugin test and tsc -p on that folder.

A repo adds its own panels below the main-ci chart by listing them in .agent/pane-panels.json at the main checkout's root (no file, no panels): [{ "id": "acme", "title": "Acme", "cmd": ["bun", "scripts/pane/acme.mjs"], "refresh_s": 60 }]. The pane runs cmd as an argv (no shell) in that root, stdin closed, 10 s limit: at most once per refresh_s (default 60, at least 5), collapsed or expanded, so the collapsed summary stays fresh too; never two runs at once. The command prints one JSON object on stdout and exits 0; anything else shows panel error: <first stderr line> and keeps the last good rows. The pane runs the listed commands without asking, with the user's environment: list only commands you trust, as with a repo hook. The output:

{
  "summary": "north  alpha 78%",
  "tab": "north",
  "tabs": [{
    "id": "north", "label": "north",
    "columns": [
      { "key": "name", "label": "cell", "width": 11 },
      { "key": "n", "label": "n", "width": 3, "align": "right" },
      { "key": "pass", "label": "pass", "width": 10, "kind": "bar" }
    ],
    "rows": [{
      "id": "alpha", "dim": false,
      "cells": { "name": "alpha@med", "n": "9", "pass": { "frac": 0.78, "text": "78%", "tone": "good" } },
      "hover": ["alpha@med · north", "pass 7 / fail 2"]
    }],
    "note": "dim = few runs"
  }]
}

summary follows the title while collapsed; tab is the default tab, and the user's pick persists per panel id. Cells are strings, or for a bar column { frac 0..1, text, tone }: a bar the column wide in good, mid, bad or dim over a dark track, its text right-aligned inside it. A dim row draws dimmed; hover (up to 6 lines) is the row's hover card, as on a main-ci bar. Only tabs[].id, columns and rows are required; a row is cut to the pane width, never wrapped. Keep cells to narrow single-width text: a wide glyph (CJK, emoji) can wrap a row. A panel draws at most 12 tabs, 12 columns up to 200 wide, and 100 rows; control characters in any text draw as spaces.

Contract v1.2 adds three optional column keys. maxWidth (at least width): after the columns are laid out at their width with one space between, the cells left in the pane width go left to right to columns with a maxWidth, each up to it; width stays the minimum. "keep": "right" keeps an over-long value's right end (cut from the left); the default keeps the left end. {w} in a label becomes the column's laid-out width: { "key": "trend", "label": "{w}d", "width": 7, "maxWidth": 14, "keep": "right" } draws its header as 9d with 2 spare cells, and the header and rows use that one width. A header label longer than the column is cut at its right like any cell.

An optional filters list replaces the tab chips with one chip row per filter; together the picks name the tab, whose id is the picked option ids joined by / in filter order (worker/docs):

"filters": [
  { "id": "role", "options": [{ "id": "worker", "label": "worker" }, { "id": "reviewer", "label": "reviewer" }], "default": "worker" },
  { "id": "class", "options": [{ "id": "all", "label": "all" }, { "id": "docs", "label": "docs" }] }
]

Each filter's pick persists per panel and filter; an unset pick, or one the output no longer offers, falls back to default, else the first option. A combination with no tab draws the columns header and no data. With filters a panel takes at most 3 filters of 12 options and 48 tabs; without them, the tab chips and tab work as above.

Source 7 files
hooks/register.tsx 1282 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, FsEntry, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { OpenAsk, Run } from '../types'
5import { type Avatar, SPRITE_COLS, avatarOf, avatarPicture } from './sprites'
6import {
7  AXIS_COLS,
8  CHART_ROWS,
9  type Cell,
10  type CiRun,
11  METRICS,
12  type MainState,
13  type Metric,
14  type MetricRow,
15  axisLabels,
16  barMax,
17  barTotal,
18  cardPlace,
19  cardTable,
20  chartCells,
21  chartTiles,
22  fnv1a,
23  jobColor,
24  markCancelled,
25  parseRows,
26  placeBeside,
27  runsFit,
28  summarize,
29  tileCells,
30  withPending,
31} from './ci'
32import {
33  TAIL_LINES,
34  codexTail,
35  elapsed,
36  epochMs,
37  kindOf,
38  labelOf,
39  lines,
40  localEnvValue,
41  applyRunLog,
42  chainLinks,
43  freshRunLog,
44  previewQueue,
45  type RunLog,
46  resultJsonMessage,
47  statusOf,
48} from './lib'
49import {
50  PANEL_TIMEOUT_MS,
51  type PanelData,
52  type PanelRow,
53  type PanelSpec,
54  type PanelTab,
55  type Segment,
56  clip,
57  firstLine,
58  headerSegments,
59  parsePanelOutput,
60  parseSpecs,
61  pickOptions,
62  pickTab,
63  rowSegments,
64} from './panels'
65
66type $ = EngineInterface
67type Root = { dir: string; isPanel: boolean }
68type Tail = { lines: string[]; lastMessage: string }
69type Cached = { key: string; mtimeMs: number; done: boolean; probe: Probe | null }
70type RunDir = { dir: string; mtimeMs: number }
71type Probe = { run: Run; pid: string; hasDone: boolean; exitCode: string }
72type NativeRun = { id: string; label: string; kind: string; status: Run['status'] }
73type Row = {
74  key: string
75  kind: string
76  label: string
77  status: Run['status']
78  avatar: Avatar | null
79  model?: string
80  duration?: string
81  diskRun?: Run
82  agentId?: string
83  parent?: string
84  children: Row[]
85}
86type PrRow = { number: number; title: string; head: string }
87type PrFile = { mtimeMs: number; pr: PrRow | null; done: number | null }
88type PrCache = { key: string; files: Map<string, PrFile>; open: Map<number, PrRow>; done: Set<number> }
89
90const REFRESH_MS = 3000
91const RECENT_DONE = 5
92const LIVE_WINDOW_MS = 24 * 3_600_000
93const MAX_ROWS = 30
94const TAIL_BYTES = 262_144
95const ASK_ACCENT = '#CBA6F7'
96
97// Probes keyed by the out-dir's listing (names + newest file mtime): a resume that
98// removes `done`, or a new owner stamp, changes the key and forces a re-probe.
99// A run with `done` is immutable: it is not re-listed while its parent's entry mtime holds.
100const probes = new Map<string, Cached>()
101const shaRuns = new Map<string, { mtimeMs: number; runs: RunDir[] }>()
102let prCache: PrCache = { key: '', files: new Map(), open: new Map(), done: new Set() }
103type Board = { key: string; done: Set<number>; titles: Map<number, string> }
104const NO_BOARD: Board = { key: '', done: new Set(), titles: new Map() }
105let boardCache = NO_BOARD
106
107async function readText($: $, path: string): Promise<string> {
108  return $.fs.read(path).then(
109    text => (typeof text === 'string' ? text.trim() : ''),
110    () => '',
111  )
112}
113
114async function listDir($: $, path: string): Promise<FsEntry[]> {
115  return $.fs.list(path).catch(() => [])
116}
117
118async function tailBytes($: $, path: string, bytes = TAIL_BYTES): Promise<string> {
119  const ran = await $.process.run(['tail', '-c', String(bytes), path]).catch(() => null)
120  return ran?.exitCode === 0 ? ran.stdout : ''
121}
122
123// One answer per session project root, hit or miss: a shell `cd` never moves it, while `/cd`, a
124// host directory change or a worktree move does. The promise is cached, so concurrent callers share one run.
125const byRoot = new Map<string, { root: string; value: Promise<unknown> }>()
126
127async function perRoot<T>($: $, name: string, compute: (root: string) => Promise<T>): Promise<T> {
128  const root = await $.session.root()
129  const hit = byRoot.get(name)
130  if (hit?.root === root) return hit.value as Promise<T>
131  const value = compute(root)
132  byRoot.set(name, { root, value })
133  return value
134}
135
136async function gitCommonDir($: $): Promise<string | null> {
137  return perRoot($, 'git', async root => {
138    const ran = await $.process
139      .run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root })
140      .catch(() => null)
141    return ran?.exitCode === 0 ? ran.stdout.trim() : null
142  })
143}
144
145const childrenDirOf = (options: PluginOptions) => String(options.childrenDir ?? '').replace(/\/+$/, '')
146
147async function resolveRoots($: $, options: PluginOptions): Promise<Root[]> {
148  return perRoot($, 'roots', async () => {
149    const children = childrenDirOf(options)
150    const common = await gitCommonDir($)
151    return [...(children ? [{ dir: children, isPanel: false }] : []), ...(common ? [{ dir: `${common}/.review-panel`, isPanel: true }] : [])]
152  })
153}
154
155const subDirs = (entries: FsEntry[], parent: string): RunDir[] =>
156  entries.filter(entry => entry.kind === 'dir').map(entry => ({ dir: `${parent}/${entry.name}`, mtimeMs: entry.mtimeMs }))
157
158// A sha dir is re-listed only when its own mtime moves (a new panel run dir appears).
159async function runDirs($: $, root: Root): Promise<RunDir[]> {
160  const top = subDirs(await listDir($, root.dir), root.dir)
161  if (!root.isPanel) return top
162  const nested = await Promise.all(
163    top.map(async sha => {
164      let cached = shaRuns.get(sha.dir)
165      if (cached?.mtimeMs !== sha.mtimeMs) {
166        cached = { mtimeMs: sha.mtimeMs, runs: subDirs(await listDir($, sha.dir), sha.dir) }
167        shaRuns.set(sha.dir, cached)
168      }
169      return cached.runs
170    }),
171  )
172  return nested.flat()
173}
174
175async function probe($: $, dir: string, entries: FsEntry[], isPanel: boolean, sid: string): Promise<Probe | null> {
176  const names = new Map(entries.map(entry => [entry.name, entry]))
177  if (!names.has('pid')) return null
178  const field = (name: string) => (names.has(name) ? readText($, `${dir}/${name}`) : Promise.resolve(''))
179  const [owner, pid, provider, model, start, end, exitCode, cwd, agent] = await Promise.all([
180    field('owner-session'),
181    field('pid'),
182    field('provider'),
183    field('model'),
184    field('start-epoch'),
185    field('end-epoch'),
186    field('exit-code'),
187    field('cwd'),
188    field('agent'),
189  ])
190  if (sid === '' || owner !== sid) return null
191  const lastMtime = Math.max(0, ...entries.map(entry => entry.mtimeMs))
192  return {
193    pid,
194    hasDone: names.has('done'),
195    exitCode,
196    run: {
197      dir,
198      kind: kindOf(provider, isPanel),
199      label: labelOf(dir, isPanel),
200      cwd,
201      isReviewer: isPanel || agent === 'reviewer',
202      model: [provider, model].filter(Boolean).join('/'),
203      status: 'running',
204      startedAt: epochMs(start),
205      endedAt: epochMs(end) ?? (lastMtime || null),
206    },
207  }
208}
209
210async function alivePids($: $, pids: string[]): Promise<Set<string>> {
211  const valid = pids.filter(pid => /^\d+$/.test(pid))
212  if (valid.length === 0) return new Set()
213  const ran = await $.process.run(['ps', '-o', 'pid=', '-p', valid.join(',')]).catch(() => null)
214  return new Set((ran?.stdout ?? '').split(/\s+/).filter(Boolean))
215}
216
217async function scanRuns($: $, options: PluginOptions, now: number): Promise<Run[]> {
218  const sid = await $.session.id()
219  const found: Probe[] = []
220  const seen = new Set<string>()
221  for (const root of await resolveRoots($, options)) {
222    for (const { dir, mtimeMs } of await runDirs($, root)) {
223      seen.add(dir)
224      let cached = probes.get(dir)
225      if (!(cached?.done && cached.mtimeMs === mtimeMs)) {
226        const entries = await listDir($, dir)
227        const key = entries
228          .map(entry => entry.name)
229          .sort()
230          .concat(String(Math.max(0, ...entries.map(entry => entry.mtimeMs))))
231          .join('/')
232        if (cached?.key !== key) {
233          const done = entries.some(entry => entry.name === 'done')
234          cached = { key, mtimeMs, done, probe: await probe($, dir, entries, root.isPanel, sid) }
235        } else cached.mtimeMs = mtimeMs
236        probes.set(dir, cached)
237      }
238      if (cached.probe) found.push(cached.probe)
239    }
240  }
241  for (const dir of probes.keys()) if (!seen.has(dir)) probes.delete(dir)
242  // older runs without `done` read as dead: bounds the ps list and ignores reused pids
243  const recent = (one: Probe) => now - (one.run.startedAt ?? 0) <= LIVE_WINDOW_MS
244  const alive = await alivePids($, found.filter(one => !one.hasDone && recent(one)).map(one => one.pid))
245  const running: Run[] = []
246  const done: Run[] = []
247  for (const one of found) {
248    const run = { ...one.run, status: statusOf(one.hasDone, one.exitCode, recent(one) && alive.has(one.pid)) }
249    ;(run.status === 'running' ? running : done).push(run)
250  }
251  const when = (run: Run) => run.endedAt ?? run.startedAt ?? 0
252  done.sort((a, b) => when(b) - when(a))
253  running.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0))
254  return [...running, ...done]
255}
256
257function runElapsed(run: Run, now: number): string {
258  return run.startedAt === null ? '?' : elapsed(((run.status === 'running' ? now : run.endedAt) ?? now) - run.startedAt)
259}
260
261// GPT runs stream codex events to result.jsonl; Claude runs only stderr.log.
262async function readTail($: $, run: Run): Promise<Tail> {
263  const names = new Set((await listDir($, run.dir)).map(entry => entry.name))
264  const tail = names.has('result.jsonl')
265    ? codexTail(await tailBytes($, `${run.dir}/result.jsonl`))
266    : names.has('stderr.log')
267      ? lines(await tailBytes($, `${run.dir}/stderr.log`)).slice(-TAIL_LINES)
268      : []
269  let lastMessage = ''
270  if (run.status !== 'running') {
271    lastMessage = names.has('last-message')
272      ? await readText($, `${run.dir}/last-message`)
273      : resultJsonMessage(names.has('result.json') ? await readText($, `${run.dir}/result.json`) : '')
274  }
275  return { lines: tail, lastMessage }
276}
277
278async function readNativeTail($: $, agentId: string): Promise<Tail> {
279  const messages = await $.session.messages({ agentId }).catch(() => [])
280  if (!Array.isArray(messages)) return { lines: [], lastMessage: '' }
281  const linesOut = messages.flatMap(message => lines(message.text)).slice(-TAIL_LINES)
282  return { lines: linesOut, lastMessage: '' }
283}
284
285type PrState = { prs: PrRow[]; gates: Map<number, Record<string, unknown> | null> }
286
287// The gh-status files, re-read only where a file's mtime moved: the open PRs, and the numbers of
288// PRs that are MERGED or CLOSED.
289async function refreshPrCache($: $, dir: string): Promise<PrCache> {
290  const entries = (await listDir($, `${dir}/status`)).filter(entry => entry.kind === 'file' && /^pr-\d+\.json$/.test(entry.name))
291  const key = entries.map(entry => `${entry.name}:${entry.mtimeMs}`).sort().join('|')
292  const cache = prCache
293  if (cache.key !== key) {
294    const current = new Set(entries.map(entry => entry.name))
295    for (const name of cache.files.keys()) if (!current.has(name)) cache.files.delete(name)
296    const changed = entries.filter(entry => cache.files.get(entry.name)?.mtimeMs !== entry.mtimeMs)
297    const parsed = await Promise.all(changed.map(async entry => {
298      const value = jsonObject(await readText($, `${dir}/status/${entry.name}`))
299      const pr = value?.state === 'OPEN' && value.isDraft === false && typeof value.number === 'number'
300        ? { number: value.number, title: typeof value.title === 'string' ? value.title : '', head: typeof value.headOid === 'string' ? value.headOid : '' }
301        : null
302      const done = (value?.state === 'MERGED' || value?.state === 'CLOSED') && typeof value.number === 'number' ? value.number : null
303      return [entry.name, { mtimeMs: entry.mtimeMs, pr, done }] as const
304    }))
305    for (const [name, file] of parsed) cache.files.set(name, file)
306    cache.open = new Map([...cache.files.values()].flatMap(file => file.pr ? [[file.pr.number, file.pr] as const] : []))
307    cache.done = new Set([...cache.files.values()].flatMap(file => file.done === null ? [] : [file.done]))
308    cache.key = key
309  }
310  return cache
311}
312
313async function readReadyPrs($: $, mainSha: string): Promise<PrState> {
314  const state = await resolveStateDir($)
315  if (!state) return { prs: [], gates: new Map() }
316  const cache = await refreshPrCache($, `${state}/gh-status`)
317  const prs = [...cache.open.values()]
318  const gates = new Map<number, Record<string, unknown> | null>()
319  await Promise.all(prs.map(async pr => {
320    const path = `${state}/gate/pr-${pr.number}/${pr.head.slice(0, 8)}-${mainSha.slice(0, 8)}.json`
321    gates.set(pr.number, jsonObject(await readText($, path)))
322  }))
323  const rank = (pr: PrRow) => {
324    const gate = gates.get(pr.number)
325    return gate?.green === true ? 0 : gate === null ? 1 : 2
326  }
327  prs.sort((a, b) => rank(a) - rank(b) || a.number - b.number)
328  return { prs, gates }
329}
330
331type MainStrip = { sha: string; state: string; queue: { running: number; queued: number }; ciDir: string; ci: MainState; metrics: FsEntry | null; log: RunLog | null; logSize: number }
332
333// A main-ci file's last MiB, parsed again whenever its size changes, a first line the window cut
334// into dropped. main-ci only appends, and a whole re-parse costs about a millisecond a tick.
335const MAIN_CI_WINDOW = 1_048_576
336const parsedTails = new Map<string, { size: number; value: Promise<unknown> }>()
337
338async function parsedTail<T>($: $, path: string, size: number, parse: (text: string, isCut: boolean) => T): Promise<T> {
339  const hit = parsedTails.get(path)
340  if (hit?.size === size) return hit.value as Promise<T>
341  const value = tailBytes($, path, MAIN_CI_WINDOW).then(text => {
342    // a failed read is tried again next tick
343    if (text === '' && size > 0) parsedTails.delete(path)
344    const isCut = size > MAIN_CI_WINDOW
345    return parse(isCut ? text.slice(text.indexOf('\n') + 1) : text, isCut)
346  })
347  parsedTails.set(path, { size, value })
348  return value
349}
350
351const parseRunLog = (text: string): RunLog => {
352  const log = freshRunLog()
353  applyRunLog(log, text)
354  return log
355}
356
357// Runs in file order; a cut window can begin inside its oldest run, which is dropped.
358const parseCiRuns = (text: string, isCut: boolean): CiRun[] => {
359  const byRun = new Map<string, MetricRow[]>()
360  for (const row of parseRows(text)) {
361    const list = byRun.get(row.run)
362    if (list) list.push(row)
363    else byRun.set(row.run, [row])
364  }
365  const names = [...byRun.keys()].sort()
366  if (isCut) names.shift()
367  return names.map(name => summarize(name, byRun.get(name) ?? []))
368}
369
370async function readMainStrip($: $): Promise<MainStrip | null> {
371  const root = await resolveStateDir($)
372  if (!root) return null
373  const stateText = await readText($, `${root}/main-ci/state.json`)
374  const state = jsonObject(stateText)
375  if (!state || typeof state.sha !== 'string') return null
376  const main8 = state.sha.slice(0, 8)
377  const listing = await listDir($, `${root}/main-ci`)
378  const logStat = listing.find(entry => entry.name === 'run.log' && entry.kind === 'file')
379  const log = logStat ? await parsedTail($, `${root}/main-ci/run.log`, logStat.size, parseRunLog) : null
380  return {
381    sha: main8,
382    state: state.green === true ? 'green' : state.phase === 'done' ? 'failed' : String(state.phase ?? 'running'),
383    queue: log ? previewQueue(log, main8) : { running: 0, queued: 0 },
384    log,
385    logSize: logStat?.size ?? -1,
386    ciDir: `${root}/main-ci`,
387    ci: { sha: state.sha, phase: String(state.phase ?? '') },
388    metrics: listing.find(entry => entry.name === 'metrics.jsonl' && entry.kind === 'file') ?? null,
389  }
390}
391
392type CiCard = ReturnType<typeof cardTable> & { left: number }
393type CiChart = { key: string; metric: Metric; runs: CiRun[]; max: number; tiles: ReturnType<typeof chartTiles>; cards: CiCard[]; fallback?: Cell[][][] }
394let ciChart: CiChart | null = null
395
396// Encoded again only when a main-ci file grows, main-ci's state moves, or the width or the metric
397// does.
398async function readCiChart($: $, main: MainStrip, bodyColumns: number, metric: Metric): Promise<CiChart | null> {
399  const fit = runsFit(bodyColumns)
400  if (fit <= 0 || !main.metrics) return null
401  const parsed = await parsedTail($, `${main.ciDir}/metrics.jsonl`, main.metrics.size, parseCiRuns)
402  const key = `${main.ciDir}|${main.metrics.size}|${main.logSize}|${main.ci.sha}|${main.ci.phase}|${bodyColumns}|${metric}`
403  if (ciChart?.key === key) return ciChart
404  const runs = markCancelled(withPending(parsed.slice(-fit), main.ci), main.log?.cancels ?? []).slice(-fit)
405  if (runs.length === 0) return null
406  // a card's border and padding take 4 columns beside its content
407  const cards = runs.map((run, i) => {
408    const place = cardPlace(i, cardTable(run, bodyColumns - 4).width + 4, bodyColumns)
409    return { ...cardTable(run, place.width - 4), left: place.x }
410  })
411  ciChart = { key, metric, runs, max: barMax(runs, metric), tiles: chartTiles(runs, metric), cards }
412  return ciChart
413}
414
415// Block-glyph cells per run, built only on a surface without Image.
416function fallbackOf(chart: CiChart): Cell[][][] {
417  if (!chart.fallback) {
418    const grid = chartCells(chart.runs, chart.metric)
419    chart.fallback = chart.runs.map((_, i) => tileCells(grid, i))
420  }
421  return chart.fallback
422}
423
424// Repo panels: the list re-read when its file changes; each panel's last good output and last
425// error kept per root, id and argv.
426type PanelRun = { data: PanelData | null; error: string | null; startedAt: number | null; running: boolean }
427let panelList = { key: '', specs: [] as PanelSpec[] }
428const panelRuns = new Map<string, PanelRun>()
429const panelKey = (root: string, spec: PanelSpec) => [root, spec.id, ...spec.cmd].join('\0')
430
431// A re-read drops the kept output of every panel the list no longer has, in any root.
432async function readPanelSpecs($: $, root: string): Promise<PanelSpec[]> {
433  const stat = (await listDir($, `${root}/.agent`)).find(entry => entry.name === 'pane-panels.json' && entry.kind === 'file')
434  if (!stat) return []
435  const key = `${root}|${stat.mtimeMs}|${stat.size}`
436  if (panelList.key !== key) {
437    panelList = { key, specs: parseSpecs(await readText($, `${root}/.agent/pane-panels.json`)) }
438    const kept = new Set(panelList.specs.map(spec => panelKey(root, spec)))
439    for (const runKey of panelRuns.keys()) if (!kept.has(runKey)) panelRuns.delete(runKey)
440  }
441  return panelList.specs
442}
443
444const panelRunOf = (root: string, spec: PanelSpec): PanelRun => {
445  const key = panelKey(root, spec)
446  let run = panelRuns.get(key)
447  if (!run) panelRuns.set(key, (run = { data: null, error: null, startedAt: null, running: false }))
448  return run
449}
450
451async function runPanel($: $, root: string, spec: PanelSpec, run: PanelRun, now: number): Promise<void> {
452  run.running = true
453  run.startedAt = now
454  try {
455    const ran = await $.process.run(spec.cmd, { cwd: root, stdin: '', timeoutMs: PANEL_TIMEOUT_MS })
456    const data = ran.exitCode === 0 ? parsePanelOutput(ran.stdout) : null
457    if (data) {
458      run.data = data
459      run.error = null
460    } else run.error = firstLine(ran.stderr) || (ran.exitCode === 0 ? 'stdout is not one panel JSON object' : `exit ${ran.exitCode}`)
461  } catch (error) {
462    run.error = firstLine(error instanceof Error ? error.message : String(error)) || 'did not run'
463  } finally {
464    run.running = false
465    $.ui.invalidate('ui.render')
466  }
467}
468
469// Each panel, collapsed or expanded, runs at most once per refresh, and never twice at once.
470const isDue = (run: PanelRun, spec: PanelSpec, now: number) => !run.running && (run.startedAt === null || now - run.startedAt >= spec.refreshMs)
471
472// The render only schedules: a run it started itself would be cut when its dispatch ends, as
473// every `$` call in flight with a dispatch is ("Work that outlives a dispatch", plugin-authoring
474// reference), so the due panels start on a module timer. The render draws the last data; a
475// run's end draws the pane again. Each render that finds a panel due sets a timer; `isDue` and
476// the `running` flag, set as a run starts, let only the first timer start it.
477function schedulePanels($: $, root: string, specs: PanelSpec[], now: number): void {
478  if (!specs.some(spec => isDue(panelRunOf(root, spec), spec, now))) return
479  $.clock.after(1, () => {
480    for (const spec of specs) {
481      const run = panelRunOf(root, spec)
482      if (isDue(run, spec, now)) void runPanel($, root, spec, run, now)
483    }
484  })
485}
486
487type PanelRowView = { row: PanelRow; key: string; scope: string; segments: Segment[]; card: { top: number; left: number; width: number; lines: string[] } | null }
488// One row of chips: the tabs (`filter` null), or one filter's options.
489type ChipRow = { filter: string | null; chips: { id: string; label: string; selected: boolean }[] }
490type PanelView = {
491  spec: PanelSpec
492  isOpen: boolean
493  summary: string
494  error: string
495  data: PanelData | null
496  tab: PanelTab | null
497  chipRows: ChipRow[]
498  header: Segment[]
499  noData: boolean
500  rows: PanelRowView[]
501}
502const PANEL_INDENT = 2
503
504// The panels as drawn from pane row `top` down, one line each; a row's card goes below it, else
505// above it, never over it.
506function panelViews(
507  root: string,
508  specs: PanelSpec[],
509  open: string[],
510  tabs: Record<string, string>,
511  filterPicks: Record<string, Record<string, string>>,
512  top: number,
513  bodyColumns: number,
514  paneRows: number,
515): PanelView[] {
516  let y = top
517  const width = bodyColumns - PANEL_INDENT
518  return specs.map(spec => {
519    const run = panelRunOf(root, spec)
520    const isOpen = open.includes(spec.id)
521    const data = isOpen ? run.data : null
522    // with filters, their picks name the tab; a combination the output lacks has no rows
523    const filters = data?.filters ?? []
524    const picked = pickOptions(filters, filterPicks[spec.id])
525    const tab = !data ? null : filters.length > 0 ? (data.tabs.find(one => one.id === picked.join('/')) ?? null) : pickTab(data, tabs[spec.id])
526    const noData = !!data && filters.length > 0 && !tab
527    const columnsOf = tab ?? (noData ? (data.tabs[0] ?? null) : null)
528    const chipRows: ChipRow[] = !data
529      ? []
530      : filters.length > 0
531        ? filters.map((filter, k) => ({ filter: filter.id, chips: filter.options.map(option => ({ ...option, selected: option.id === picked[k] })) }))
532        : data.tabs.length > 1
533          ? [{ filter: null, chips: data.tabs.map(one => ({ id: one.id, label: one.label, selected: one.id === tab?.id })) }]
534          : []
535    const header = columnsOf ? headerSegments(columnsOf, width) : []
536    y += 1 + (run.error ? 1 : 0) + chipRows.length + (header.length > 0 ? 1 : 0)
537    const seen = new Set<string>()
538    const rows = (tab?.rows ?? []).map((row, i) => {
539      const key = seen.has(row.id) ? `${row.id}#${i}` : row.id
540      seen.add(key)
541      const at = y++
542      const cardWidth = Math.min(width, Math.max(0, ...row.hover.map(line => line.length)) + 4)
543      const place = placeBeside(at, 1, row.hover.length + 2, paneRows)
544      return {
545        row,
546        key,
547        scope: `panel:${fnv1a(`${spec.id}\0${key}`).toString(36)}`,
548        segments: rowSegments(tab as PanelTab, row, width),
549        card: row.hover.length === 0 ? null : { top: place.x, left: PANEL_INDENT, width: cardWidth, lines: row.hover.map(line => line.slice(0, Math.max(1, cardWidth - 4))) },
550      }
551    })
552    if (noData) y++
553    if (tab?.note) y++
554    const title = `▸ ${spec.title}`
555    return {
556      spec,
557      isOpen,
558      summary: !isOpen && run.data?.summary ? clip([{ text: `  ${run.data.summary}` }], bodyColumns - title.length)[0]?.text ?? '' : '',
559      error: run.error ? clip([{ text: `panel error: ${run.error}` }], width)[0]?.text ?? '' : '',
560      data,
561      tab,
562      chipRows,
563      header,
564      noData,
565      rows,
566    }
567  })
568}
569
570const startOf = (row: Row) => row.diskRun?.startedAt ?? 0
571
572function rowsFor(runs: Run[], agents: AgentInfo[], now: number): Row[] {
573  const disk: Row[] = runs.map(run => ({ key: `disk:${run.dir}`, kind: run.kind, label: run.label, status: run.status, avatar: avatarOf(run.kind), model: run.model, duration: runElapsed(run, now), diskRun: run, children: [] }))
574  const native: Row[] = agents.map(agent => {
575    const run = asNative(agent)
576    const parent = agents.some(other => other.id === agent.parentId) ? `native:${agent.parentId}` : undefined
577    return { key: `native:${run.id}`, kind: run.kind, label: run.label, status: run.status, avatar: 'claude', agentId: run.id, parent, children: [] }
578  })
579  // A disk reviewer joins the newest worker that started before it in the same worktree.
580  const workers = disk.filter(row => row.diskRun && !row.diskRun.isReviewer && row.diskRun.cwd.startsWith('/'))
581  for (const row of disk) {
582    const run = row.diskRun
583    if (!run?.isReviewer || !run.cwd.startsWith('/')) continue
584    row.parent = workers.filter(w => w.diskRun?.cwd === run.cwd && startOf(w) <= (run.startedAt ?? Infinity)).sort((a, b) => startOf(b) - startOf(a))[0]?.key
585  }
586  const all = [...disk, ...native]
587  const byKey = new Map(all.map(row => [row.key, row]))
588  const rootOf = (row: Row) => {
589    let root = row
590    for (let hops = 0; root.parent && hops < 8; hops++) root = byKey.get(root.parent) ?? root
591    return root
592  }
593  const nested = new Set<Row>()
594  for (const row of all) {
595    const root = rootOf(row)
596    if (root !== row) { root.children.push(row); nested.add(row) }
597  }
598  const top = all.filter(row => !nested.has(row))
599  const running = top.filter(row => row.status === 'running')
600  const finishedDisk = top.filter(row => row.diskRun && row.status !== 'running').slice(0, RECENT_DONE)
601  const finishedNative = top.filter(row => !row.diskRun && row.status !== 'running').slice(-RECENT_DONE).reverse()
602  // MAX_ROWS caps the finished rows only: a running row is never dropped
603  const shown = [...running, ...[...finishedDisk, ...finishedNative].slice(0, Math.max(0, MAX_ROWS - running.length))]
604  // a running child whose worker rolled off the list stays visible at the top
605  const orphans = all.filter(row => nested.has(row) && row.status === 'running' && !shown.includes(rootOf(row)))
606  return [...shown, ...orphans]
607}
608
609// The main checkout of the session's clone: its git common dir's parent.
610async function repoRoot($: $): Promise<string | null> {
611  return (await gitCommonDir($))?.replace(/\/\.git$/, '') ?? null
612}
613
614// `state_dir` from the main checkout's `.agent/local.env`; every state path is a fixed name under
615// it. No file or no key: null, and the state-scoped sections draw nothing.
616async function resolveStateDir($: $): Promise<string | null> {
617  return perRoot($, 'state', async () => {
618    const main = await repoRoot($)
619    if (!main) return null
620    return localEnvValue(await readText($, `${main}/.agent/local.env`), 'state_dir', (await $.env.get('HOME')) ?? '')
621  })
622}
623
624// board-snapshot.md's issue rows, cached on its mtime and size: the Done set and each title (a
625// `\|` in a cell is a literal pipe; the Status cell has five cells after it).
626async function readBoard($: $): Promise<Board> {
627  const state = await resolveStateDir($)
628  const stat = state ? (await listDir($, state)).find(entry => entry.name === 'board-snapshot.md' && entry.kind === 'file') : undefined
629  if (!state || !stat) return NO_BOARD
630  const key = `${state}|${stat.mtimeMs}:${stat.size}`
631  if (boardCache.key !== key) {
632    const board: Board = { key, done: new Set(), titles: new Map() }
633    for (const line of (await readText($, `${state}/board-snapshot.md`)).split('\n')) {
634      const row = /^\| #(\d+) \| (.*) \| ([^|]*) \|(?:[^|]*\|){5}$/.exec(line)
635      if (!row) continue
636      board.titles.set(Number(row[1]), (row[2] ?? '').replaceAll('\\|', '|').trim())
637      if (row[3]?.trim() === 'Done') board.done.add(Number(row[1]))
638    }
639    boardCache = board
640  }
641  return boardCache
642}
643
644// Tickets that are done, from local files only: a PR MERGED or CLOSED in the gh-status files, or an
645// issue whose board-snapshot row has Status Done.
646async function readDoneTickets($: $): Promise<Set<number>> {
647  const state = await resolveStateDir($)
648  const [prs, board] = await Promise.all([state ? refreshPrCache($, `${state}/gh-status`) : null, readBoard($)])
649  return new Set([...(prs?.done ?? []), ...board.done])
650}
651
652// `<state>/chains/<session-id>`: one chain a line, issue numbers in dispatch order.
653async function readChains($: $): Promise<number[][]> {
654  const [state, sid] = await Promise.all([resolveStateDir($), $.session.id()])
655  if (!state || !sid) return []
656  return (await readText($, `${state}/chains/${sid}`))
657    .split('\n')
658    .map(line => line.split(/\s+/).flatMap(token => (/^#?\d+$/.test(token) ? [Number(token.replace('#', ''))] : [])))
659    .filter(chain => chain.length > 0)
660}
661
662type Ask = { n: number; text: string }
663
664function jsonObject(text: string): Record<string, unknown> | null {
665  try {
666    const value: unknown = JSON.parse(text)
667    return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
668  } catch {
669    return null
670  }
671}
672
673function issueNumber(text: string): string | null {
674  return /#(\d+)/.exec(text)?.[1] ?? /\/issues\/(\d+)/.exec(text)?.[1] ?? /\/pull\/(\d+)/.exec(text)?.[1] ?? null
675}
676
677function trailingLink(text: string): string | null {
678  return /https?:\/\/\S+$/.exec(text)?.[0] ?? null
679}
680
681function linkLabel(href: string): string {
682  const issue = issueNumber(href)
683  if (issue) return `#${issue}`
684  try {
685    const url = new URL(href)
686    return url.pathname.split('/').filter(Boolean).at(-1) || url.host
687  } catch {
688    return href
689  }
690}
691
692function labeledLine(line: string): { label: string; value: string } | null {
693  if (/^https?:\/\//i.test(line.trim())) return null
694  const match = /^([\p{L}][\p{L} /_-]*):\s*(.*)$/u.exec(line)
695  return match ? { label: match[1] ?? '', value: match[2] ?? '' } : null
696}
697
698function safeHref(href: string): string | null {
699  if (href.length > 2048 || /[^\x20-\x7E]/.test(href)) return null
700  if (!href.startsWith('https://') && !/^http:\/\/localhost(?::\d+)?(?:\/|$|[?#])/i.test(href)) return null
701  try {
702    const url = new URL(href)
703    return url.href === href && (url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost')) ? href : null
704  } catch {
705    return null
706  }
707}
708
709// `owner/repo` of a GitHub remote URL (https, ssh or scp form), else null.
710function githubSlug(url: string): string | null {
711  const match = /^(?:https:\/\/|ssh:\/\/git@|git@)github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/.exec(url.trim())
712  return match?.[1] ?? null
713}
714
715// The session repo's `origin` remote, read from the git config file: null when it names no
716// GitHub repo, and ticket refs stay text.
717async function resolveRepo($: $): Promise<string | null> {
718  return perRoot($, 'repo', async () => {
719    const common = await gitCommonDir($)
720    if (!common) return null
721    let inOrigin = false
722    for (const line of (await readText($, `${common}/config`)).split('\n')) {
723      const section = /^\s*\[(.*)\]\s*$/.exec(line)
724      if (section) inOrigin = section[1] === 'remote "origin"'
725      const url = inOrigin ? /^\s*url\s*=\s*(\S+)/.exec(line)?.[1] : undefined
726      if (url) return githubSlug(url)
727    }
728    return null
729  })
730}
731
732function asNative(agent: AgentInfo): NativeRun {
733  const status = agent.status === 'completed' ? 'done' : agent.status === 'failed' ? 'failed' : agent.status === 'killed' ? 'dead' : 'running'
734  return { id: agent.id, label: agent.description || agent.type, kind: agent.type, status }
735}
736
737// The ask's sentence, minus the linked `#issue` at its very start or end (the link beside it
738// carries the number); a mid-sentence `#issue` and every other `#N` stay.
739function askBody(text: string, issue: string | null, dropUrl: boolean): string {
740  const body = dropUrl ? text.replace(/\s*https?:\/\/\S+$/, '') : text
741  return issue ? body.replace(new RegExp(`^#${issue}(?!\\d)\\s*`), '').replace(new RegExp(`\\s*#${issue}$`), '') : body
742}
743
744function askOptions(detail: string): string[] {
745  const line = detail.split('\n').find(text => /^options?:/i.test(text.trim()))
746  return line ? line.replace(/^options?:/i, '').split(/\s*[|;]\s*/).map(text => text.trim()).filter(Boolean).slice(0, 4) : []
747}
748
749// `n` is the ask's 1-based line number in the file: its detail file is `<n>.md`.
750async function readAsks($: $): Promise<{ asks: Ask[]; detailDir: string }> {
751  const [state, sid] = await Promise.all([resolveStateDir($), $.session.id()])
752  if (!state || !sid) return { asks: [], detailDir: '' }
753  const file = `${state}/asks/${sid}`
754  const all = (await readText($, file))
755    .split('\n')
756    .map((text, i) => ({ n: i + 1, text: text.trim() }))
757    .filter(ask => ask.text !== '')
758  // An ask keyed by a done ticket is hidden at once; the hook deletes it on its next write.
759  const ticketOf = (ask: Ask) => /#(\d+)/.exec(ask.text)?.[1]
760  const done = all.some(ticketOf) ? await readDoneTickets($) : new Set<number>()
761  const asks = all.filter(ask => {
762    const ticket = ticketOf(ask)
763    return ticket === undefined || !done.has(Number(ticket))
764  })
765  return { asks, detailDir: `${file}.d` }
766}
767
768async function toggleAsk($: $, ask: Ask): Promise<void> {
769  await update($, openAsk, was => (isSameAsk(was, ask) ? null : { n: ask.n, text: ask.text }))
770}
771
772// Drops the ask's line and its detail file under the asks hook's own lock (scripts/dismiss-ask.pl),
773// then redraws the band at once rather than on the next tick.
774async function dismissAsk($: $, ask: Ask): Promise<void> {
775  const [state, sid] = await Promise.all([resolveStateDir($), $.session.id()])
776  if (!state || !sid || /[/]|\.\./.test(sid)) return
777  await $.process.run(['perl', `${$.plugin.root}/scripts/dismiss-ask.pl`, `${state}/asks/${sid}`, ask.text]).catch(() => undefined)
778  if (isSameAsk(await read($, openAsk), ask)) await update($, openAsk, () => null)
779  $.ui.invalidate('ui.render')
780}
781
782async function readAskDetail($: $, detailDir: string, n: number): Promise<string> {
783  return readText($, `${detailDir}/${n}.md`)
784}
785
786const PANE = 'workers'
787const expanded = atom({ plugin: 'agent-ui', key: 'expanded' } as const, [] as string[])
788const selectedRun = atom({ plugin: 'agent-ui', key: 'selectedRun' } as const, null)
789const openAsk = atom({ plugin: 'agent-ui', key: 'openAsk' } as const, null)
790const ciOpen = atom({ plugin: 'agent-ui', key: 'ciOpen' } as const, false)
791const ciMetric = atom({ plugin: 'agent-ui', key: 'ciMetric' } as const, 'wall' as Metric)
792const panelsOpen = atom({ plugin: 'agent-ui', key: 'panelsOpen' } as const, [] as string[])
793const panelTabs = atom({ plugin: 'agent-ui', key: 'panelTabs' } as const, {} as Record<string, string>)
794const panelFilters = atom({ plugin: 'agent-ui', key: 'panelFilters' } as const, {} as Record<string, Record<string, string>>)
795
796const FRAME_MS = 250
797const MAX_ANIMATED = 20
798const CHILD_CAP = 8
799
800// Only running rows animate: one timer, one keyed blit per row a frame (no pane redraw).
801// A render lists the rows to animate; the timer cancels itself once the list is empty.
802let frameTimer: Timer | null = null
803let frameTick = 0
804let animating: { key: string; avatar: Avatar }[] = []
805
806function syncFrames($: $): void {
807  if (animating.length === 0) {
808    frameTimer?.cancel()
809    frameTimer = null
810    return
811  }
812  if (frameTimer) return
813  frameTimer = $.clock.every(FRAME_MS, async () => {
814    frameTick++
815    const results = await Promise.all(
816      animating.map(row =>
817        $.ui
818          .blit({ requestId: PANE, key: `avatar:${row.key}`, source: avatarPicture(row.avatar, 'running', frameTick) })
819          .catch(() => ({ deny: 'blit failed' })),
820      ),
821    )
822    // nothing of ours is mounted any more (the pane closed): wait for the next render
823    if (results.every(result => result.deny)) animating = []
824    syncFrames($)
825  })
826}
827
828// A hovered run column's background, behind its bar's transparent pixels.
829const COLUMN_LIT = '#3a3d50'
830// A bar's alt text where a picture cannot be drawn: its height as a block glyph, twice.
831const barGlyphs = (run: CiRun, chart: CiChart) => String.fromCharCode(0x2581 + Math.min(7, Math.floor((barTotal(run, chart.metric) / chart.max) * 8))).repeat(2)
832
833// The card draws on its own dark background, so its text colors are fixed, not the theme's.
834const CARD_TEXT = '#e1e1e6'
835const CARD_DIM = '#9a9cab'
836const hex = (rgb: number) => `#${rgb.toString(16).padStart(6, '0')}`
837// A hover card, the chart's and a panel row's: hidden until its scope is hovered, painted over
838// the pane at its place.
839const CARD = { position: 'absolute', display: 'none', flexDirection: 'column', borderStyle: 'round', backgroundColor: '#2c2e3c', paddingX: 1 } as const
840
841const MARK = { running: '◐', done: '✓', failed: '✗', dead: '†' } as const
842
843const isSameAsk = (open: OpenAsk | null, ask: Ask) => open?.n === ask.n && open.text === ask.text
844
845// boot-report writes the role marker after the session starts, so the pane waits for it a few ticks.
846const MANAGER_ROLE = /^(pm|tl-[a-z0-9-]+)$/
847const ROLE_DIR = '/tmp/cc-session-roles'
848const ROLE_WAIT_TICKS = 60
849let roleTicksLeft = 0
850
851async function openForManager($: $): Promise<void> {
852  if (roleTicksLeft <= 0) return
853  roleTicksLeft--
854  const sid = await $.session.id()
855  if (!sid || /[/]|\.\./.test(sid)) return
856  if (!MANAGER_ROLE.test(await readText($, `${ROLE_DIR}/${sid}`))) return
857  roleTicksLeft = 0
858  await $.ui.open({ id: PANE, title: 'Workers' })
859}
860
861export const register: Register = (on, options) => {
862  on('session.start', async ($, e, next) => {
863    await $.command.register({
864      name: 'workers',
865      description: "Open a pane of this session's child runs; click one for its live tail",
866    })
867    // One tick redraws the pane and the band (each re-reads its files while drawn)
868    // and drops an expanded ask whose line is gone, so a later ask at that line opens closed.
869    roleTicksLeft = e.isInteractive ? ROLE_WAIT_TICKS : 0
870    await openForManager($)
871    $.clock.every(REFRESH_MS, async () => {
872      await openForManager($)
873      $.ui.invalidate('ui.render')
874      const open = await read($, openAsk)
875      if (open === null) return
876      const { asks } = await readAsks($)
877      if (!asks.some(ask => isSameAsk(open, ask))) await update($, openAsk, () => null)
878    })
879
880    return next(e)
881  })
882
883  on('command.run', { command: 'workers' }, async $ => {
884    await $.ui.open({ id: PANE, title: 'Workers' })
885
886    return { text: 'Workers pane opened.' }
887  })
888
889  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
890    const { Box, Text, Button, Markdown, Link } = $.ui.resolve(e)
891    const Image = e.surface === 'terminal' ? $.ui.resolve(e).Image : null
892    const now = await $.clock.now()
893    const [runs, agents, main] = await Promise.all([scanRuns($, options, now), $.agent.list().catch(() => []), readMainStrip($)])
894    const rows = rowsFor(runs, agents, now)
895    const prState = await readReadyPrs($, main?.sha ?? '')
896    const [chosen, open, isCiOpen, metric] = await Promise.all([read($, selectedRun), read($, expanded), read($, ciOpen), read($, ciMetric)])
897    const chart = main && isCiOpen ? await readCiChart($, main, e.props.bodyColumns, metric) : null
898    const axis = chart ? axisLabels(chart.max, chart.metric) : null
899    const visible = rows.flatMap(row => [
900      { row, isChild: false },
901      ...(open.includes(row.key) ? row.children.slice(0, CHILD_CAP).map(child => ({ row: child, isChild: true })) : []),
902    ])
903    const shown = rows.flatMap(row => [row, ...row.children]).find(row => row.key === chosen) ?? null
904    animating = Image
905      ? visible.flatMap(({ row }) => (row.status === 'running' && row.avatar ? [{ key: row.key, avatar: row.avatar }] : [])).slice(0, MAX_ANIMATED)
906      : []
907    syncFrames($)
908    const tail = shown ? (shown.diskRun ? await readTail($, shown.diskRun) : await readNativeTail($, shown.agentId ?? '')) : null
909    const repo = await resolveRepo($)
910    const chainFile = await readChains($)
911    const board = chainFile.length > 0 ? await readBoard($) : NO_BOARD
912    // an issue is in progress while a running row's label names its #N
913    const active = new Set(rows.flatMap(row => [row, ...row.children]).flatMap(row => {
914      const n = row.status === 'running' ? issueNumber(row.label) : null
915      return n ? [Number(n)] : []
916    }))
917    const chains = chainFile.map(chain => chainLinks(chain, board.done, active))
918    const root = await repoRoot($)
919    const specs = root ? await readPanelSpecs($, root) : []
920    const [openPanels, tabPicks, filterPicks] = await Promise.all([read($, panelsOpen), read($, panelTabs), read($, panelFilters)])
921    if (root && specs.length > 0) schedulePanels($, root, specs, now)
922    // a card's top counts pane rows: the panels start below the main strip (1 row) and the
923    // open chart (CHART_ROWS bars, its metric chips and its legend line), the only rows above them
924    const panelTop = (main ? 1 : 0) + (chart ? CHART_ROWS + 2 : 0)
925    const panels = root ? panelViews(root, specs, openPanels, tabPicks, filterPicks, panelTop, e.props.bodyColumns, e.props.scroll.offset + e.props.scroll.bodyRows) : []
926    const segmentTexts = (segments: Segment[], dim: boolean, prefix: string) =>
927      segments.map((segment, k) => (
928        <Text
929          key={`${prefix}-${k}`}
930          color={segment.color === undefined ? undefined : hex(segment.color)}
931          backgroundColor={segment.background === undefined ? undefined : hex(segment.background)}
932          dimColor={dim && segment.background === undefined}
933        >
934          {segment.text}
935        </Text>
936      ))
937    const stateColor = main?.state === 'green' ? 'green' : main?.state === 'failed' || main?.state === 'red' ? 'red' : undefined
938
939    return (
940      <Box flexDirection="column">
941        {main && (
942          <Box key="main-strip" flexDirection="row">
943            <Button key="ci-toggle" plain dimColor label={`${isCiOpen ? '▾' : '▸'} main ${main.sha} · `} onPress={() => update($, ciOpen, was => !was)} />
944            <Text color={stateColor} dimColor={!stateColor}>{main.state}</Text>
945            <Text dimColor>{' · preview '}</Text>
946            <Text color={main.queue.running > 0 ? 'green' : undefined} dimColor={main.queue.running === 0}>{main.queue.running}</Text>
947            <Text dimColor>/</Text>
948            <Text color={main.queue.queued > 0 ? 'red' : undefined} dimColor={main.queue.queued === 0}>{main.queue.queued}</Text>
949          </Box>
950        )}
951        {chart && (
952          <Box key="ci" flexDirection="column">
953            <Box flexDirection="row" height={CHART_ROWS}>
954              <Box width={AXIS_COLS} height={CHART_ROWS} flexDirection="column" justifyContent="space-between">
955                <Text dimColor>{axis?.top}</Text>
956                <Text dimColor>{axis?.bottom}</Text>
957              </Box>
958              {/* each run column holds its own bar: the hover zone and the bar are one box (runCell) */}
959              {chart.runs.map((run, i) => (
960                <Box key={`ci-run:${run.run}`} width={2} height={CHART_ROWS} flexDirection="column" hover={{ scope: `ci:${run.run}`, backgroundColor: COLUMN_LIT }}>
961                  {Image ? (
962                    <Image key={`ci-bar:${run.run}`} source={chart.tiles[i] as CiChart['tiles'][number]} columns={2} rows={CHART_ROWS} alt={barGlyphs(run, chart)} />
963                  ) : (
964                    (fallbackOf(chart)[i] ?? []).map((row, y) => (
965                      <Text key={`ci-cell-${i}-${y}`} color={hex(row[0]?.color ?? 0)}>{row.map(cell => String.fromCharCode(cell.glyph)).join('')}</Text>
966                    ))
967                  )}
968                </Box>
969              ))}
970            </Box>
971            <Box key="ci-metrics" flexDirection="row">
972              {METRICS.map((one, k) => (
973                <Box key={`ci-metric-box:${one}`} flexDirection="row">
974                  {k > 0 && <Text dimColor>{' · '}</Text>}
975                  <Button key={`ci-metric:${one}`} plain dimColor={one !== chart.metric} label={one} onPress={() => update($, ciMetric, () => one)} />
976                </Box>
977              ))}
978            </Box>
979            <Text dimColor>{`last ${chart.runs.length} runs · line = load · red = retry`}</Text>
980          </Box>
981        )}
982        {panels.map(({ spec, isOpen, summary, error, data, tab, chipRows, header, noData, rows: panelRows }) => (
983          <Box key={`panel:${spec.id}`} flexDirection="column">
984            <Box key="panel-head" flexDirection="row">
985              <Button
986                key={`panel-toggle:${spec.id}`}
987                plain
988                label={`${isOpen ? '▾' : '▸'} ${spec.title}`}
989                onPress={() => update($, panelsOpen, ids => (ids.includes(spec.id) ? ids.filter(id => id !== spec.id) : [...ids, spec.id]))}
990              />
991              {summary && <Text dimColor>{summary}</Text>}
992            </Box>
993            {error && (
994              <Box key="panel-error" marginLeft={PANEL_INDENT}>
995                <Text dimColor>{error}</Text>
996              </Box>
997            )}
998            {data && (
999              <Box key="panel-body" flexDirection="column" marginLeft={PANEL_INDENT}>
1000                {chipRows.map(({ filter, chips }) => (
1001                  <Box key={filter === null ? 'panel-tabs' : `panel-filter:${filter}`} flexDirection="row">
1002                    {chips.map((chip, k) => (
1003                      <Box key={`panel-chip-box:${chip.id}`} flexDirection="row">
1004                        {k > 0 && <Text>{'  '}</Text>}
1005                        <Button
1006                          key={filter === null ? `panel-tab:${spec.id}:${chip.id}` : `panel-filter:${spec.id}:${filter}:${chip.id}`}
1007                          plain
1008                          dimColor={!chip.selected}
1009                          label={chip.label}
1010                          onPress={() =>
1011                            filter === null
1012                              ? update($, panelTabs, picks => ({ ...picks, [spec.id]: chip.id }))
1013                              : update($, panelFilters, picks => ({ ...picks, [spec.id]: { ...picks[spec.id], [filter]: chip.id } }))
1014                          }
1015                        />
1016                      </Box>
1017                    ))}
1018                  </Box>
1019                ))}
1020                {header.length > 0 && (
1021                  <Box key="panel-columns" flexDirection="row">
1022                    {segmentTexts(header, true, 'panel-col')}
1023                  </Box>
1024                )}
1025                {noData && (
1026                  <Box key="panel-no-data">
1027                    <Text dimColor>no data</Text>
1028                  </Box>
1029                )}
1030                {panelRows.map(({ row, key, scope, segments }) => (
1031                  <Box key={`panel-row:${spec.id}:${key}`} flexDirection="row" hover={{ scope, backgroundColor: COLUMN_LIT }}>
1032                    {segmentTexts(segments, row.dim, 'panel-cell')}
1033                  </Box>
1034                ))}
1035                {tab?.note && <Text dimColor>{clip([{ text: tab.note }], e.props.bodyColumns - PANEL_INDENT)[0]?.text ?? ''}</Text>}
1036              </Box>
1037            )}
1038          </Box>
1039        ))}
1040        {prState.prs.length > 0 && (
1041          <Box key="needs-you" flexDirection="column" marginBottom={1}>
1042            <Text bold>Needs you</Text>
1043            {prState.prs.map(pr => {
1044              const gate = prState.gates.get(pr.number) ?? null
1045              const result = gate == null ? '…' : gate.green === true ? '✓' : '✗'
1046              const href = repo ? safeHref(`https://github.com/${repo}/pull/${pr.number}`) : null
1047              return (
1048                <Box key={`pr:${pr.number}`}>
1049                  {href ? <Text color={ASK_ACCENT}><Link key={`pr-link-${pr.number}`} href={href} label={`#${pr.number}`} /></Text> : <Text color={ASK_ACCENT}>{`#${pr.number}`}</Text>}
1050                  <Text>{` ${pr.title} ${result}${gate?.conflict === true ? ' ⚡ conflict' : ''}`}</Text>
1051                </Box>
1052              )
1053            })}
1054          </Box>
1055        )}
1056        {chains.length > 0 && (
1057          <Box key="chains" flexDirection="column" marginBottom={1}>
1058            {chains.map((chain, i) => (
1059              <Box key={`chain:${i}`} flexDirection="row">
1060                {chain.map(({ n, mark }, k) => {
1061                  const href = repo ? safeHref(`https://github.com/${repo}/issues/${n}`) : null
1062                  return (
1063                    <Box key={`chain-issue:${i}:${k}`} flexDirection="row" hover={{ scope: `chain:${i}:${n}`, backgroundColor: COLUMN_LIT }}>
1064                      {k > 0 && <Text dimColor>{' → '}</Text>}
1065                      <Text color={mark === 'merged' ? 'green' : mark === 'active' ? ASK_ACCENT : undefined} dimColor={mark === 'merged'}>
1066                        {href ? <Link href={href} label={`#${n}`} /> : `#${n}`}
1067                        {mark === 'merged' ? ' ✓' : ''}
1068                      </Text>
1069                    </Box>
1070                  )
1071                })}
1072                {/* a hovered #N shows its board title at the line's end */}
1073                {chain.map(({ n }, k) => board.titles.has(n) && (
1074                  <Box key={`chain-title:${i}:${k}`} display="none" hover={{ display: 'flex', scope: `chain:${i}:${n}` }}>
1075                    <Text dimColor wrap="truncate-end">{`  ${board.titles.get(n)}`}</Text>
1076                  </Box>
1077                ))}
1078              </Box>
1079            ))}
1080          </Box>
1081        )}
1082        <Text bold>Workers</Text>
1083        {!childrenDirOf(options) ? (
1084          <Box key="no-children-dir">
1085            <Text dimColor>set childrenDir in /config</Text>
1086          </Box>
1087        ) : (
1088          rows.length === 0 && <Text dimColor>No child runs for this session.</Text>
1089        )}
1090        {visible.map(({ row, isChild }) => (
1091          <Box key={`row:${row.key}`} flexDirection="row">
1092            {isChild && <Text dimColor>{'  └ '}</Text>}
1093            {Image && row.avatar ? (
1094              <Image key={`avatar:${row.key}`} columns={SPRITE_COLS} rows={1} source={avatarPicture(row.avatar, row.status, frameTick)} alt={MARK[row.status]} />
1095            ) : (
1096              <Box key={`mark:${row.key}`}>
1097                <Text dimColor={row.status !== 'running'}>{`${MARK[row.status]} `}</Text>
1098              </Box>
1099            )}
1100            <Text>{' '}</Text>
1101            <Button
1102              key={row.key}
1103              plain
1104              dimColor={row.status !== 'running'}
1105              label={`${row.kind ? `${row.kind} ` : ''}${row.label}${row.model ? `  ${row.model}` : ''}${row.duration ? `  ${row.duration}` : ''}  ${row.status}${row.children.length > 0 ? `  ${open.includes(row.key) ? '▾' : '▸'}${row.children.length}` : ''}`}
1106              onPress={async () => {
1107                const wasShown = (await read($, selectedRun)) === row.key
1108                await update($, selectedRun, () => (wasShown ? null : row.key))
1109                if (row.children.length > 0) await update($, expanded, keys => (wasShown ? keys.filter(key => key !== row.key) : [...keys, row.key]))
1110              }}
1111            />
1112          </Box>
1113        ))}
1114        {shown && tail && (
1115          <Box key="tail" flexDirection="column" marginTop={1}>
1116            <Text bold>{`${shown.label} (${shown.status})`}</Text>
1117            {tail.lines.length === 0 && <Text dimColor>No output yet.</Text>}
1118            {tail.lines.map(line => (
1119              <Text wrap="truncate-end">{line}</Text>
1120            ))}
1121            {tail.lastMessage !== '' && (
1122              <Box key="last" flexDirection="column" marginTop={1}>
1123                <Text bold>last message</Text>
1124                <Markdown key="last-message" text={tail.lastMessage} />
1125              </Box>
1126            )}
1127          </Box>
1128        )}
1129        {/* drawn last so each card paints over the rows below the chart; its run column, in
1130            the same hover scope, reveals it */}
1131        {chart?.runs.map((run, i) => {
1132          const card = chart.cards[i] as CiCard
1133          return (
1134            <Box key={`ci-card:${run.run}`} {...CARD} top={2} left={card.left} width={card.width + 4} hover={{ display: 'flex', scope: `ci:${run.run}` }}>
1135              <Box key="ci-line-0" flexDirection="row" flexWrap="nowrap">
1136                <Text color={CARD_TEXT} wrap="truncate-end">{card.head}</Text>
1137              </Box>
1138              {/* each table row is one line: a row Box of the name cell and the numbers cell */}
1139              {[card.header, ...card.rows].map((row, k) => (
1140                <Box key={`ci-line-${k + 1}`} flexDirection="row" flexWrap="nowrap">
1141                  <Text color={k === 0 ? CARD_DIM : hex(jobColor(row.job))} wrap="truncate-end">{row.name}</Text>
1142                  <Text color={k === 0 ? CARD_DIM : CARD_TEXT} wrap="truncate-end">{row.nums}</Text>
1143                </Box>
1144              ))}
1145            </Box>
1146          )
1147        })}
1148        {panels.flatMap(({ spec, rows: panelRows }) =>
1149          panelRows.flatMap(({ key, scope, card }) =>
1150            card
1151              ? [
1152                  <Box key={`panel-card:${spec.id}:${key}`} {...CARD} top={card.top} left={card.left} width={card.width} hover={{ display: 'flex', scope }}>
1153                    {card.lines.map((line, k) => (
1154                      <Text key={`panel-line-${k}`} color={CARD_TEXT}>{line}</Text>
1155                    ))}
1156                  </Box>,
1157                ]
1158              : [],
1159          ),
1160        )}
1161      </Box>
1162    )
1163  })
1164
1165  // An ask line's Client posts each button press with the ask's text: left toggles its context,
1166  // right dismisses it.
1167  on('ui.message', async ($, e, next) => {
1168    if (e.module !== 'hooks/ask-line.tsx') return next(e)
1169    const data = e.data as { button?: unknown; text?: unknown } | null
1170    const ask = (await readAsks($)).asks.find(one => one.text === data?.text)
1171    if (!ask) return {}
1172    if (data?.button === 'left') await toggleAsk($, ask)
1173    if (data?.button === 'right') await dismissAsk($, ask)
1174    return {}
1175  })
1176
1177  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1178    if (e.props.hasSurvey) return next(e)
1179    const { asks, detailDir } = await readAsks($)
1180    if (asks.length === 0) return next(e)
1181    const elements = $.ui.resolve(e)
1182    const { Box, Text, Button, Markdown, Link } = elements
1183    // A right-click reaches a plugin only through a Client's pointer listener, where one is drawn.
1184    const Client = 'Client' in elements ? elements.Client : null
1185    const repo = await resolveRepo($)
1186    const open = await read($, openAsk)
1187    const shown = asks.find(ask => isSameAsk(open, ask))
1188    const lineLabel = (ask: Ask, issue: string | null, dropUrl: boolean) => `${ask === shown ? '▾' : '▸'} ${askBody(ask.text, issue, dropUrl)}`
1189    const detail = shown ? await readAskDetail($, detailDir, shown.n) : ''
1190    // Three text lines (options: among them) plus one link: line, whatever the file holds.
1191    const rows = detail.split('\n').map(line => line.trim()).filter(Boolean)
1192    const isLink = (line: string) => /^link:/i.test(line)
1193    const textLines = rows.filter(line => !isLink(line)).slice(0, 3)
1194    const detailLines = [...textLines.filter(line => !/^options?:/i.test(line)), ...rows.filter(isLink).slice(0, 1)]
1195    const askViews = asks.map(ask => {
1196      const issue = issueNumber(ask.text)
1197      const trailing = trailingLink(ask.text)
1198      return {
1199        ask,
1200        issue,
hooks/sprites.ts 221 lines
1import { toBase64 } from './lib'
2
3// Provider avatars: 2 terminal cells wide, one row tall (a square on screen), a square RGBA
4// sprite (its frame's size, 16 or 32 pixels) filling the box. Every frame is encoded once, here.
5export type Avatar = 'claude' | 'gpt'
6export type Status = 'running' | 'done' | 'failed' | 'dead'
7export type Picture = { rgba: string; width: number; height: number }
8type Look = { run: Picture[]; done: Picture; failed: Picture }
9
10export const SPRITE_COLS = 2
11const FAILED = 0xe06c75
12const EYE = 0x1a1a1a
13const COLORS: Record<Avatar, number> = { claude: 0xd97757, gpt: 0xf2f2f2 }
14
15// Sprite frames, square, N rows of N pixels: 'X' the color, 'E' eye, '.' transparent.
16// CADENCE plays them in order.
17const SPRITES: Record<Avatar, string[][]> = {
18  // body, two eyes, arms out; the legs walk
19  claude: [
20    [
21      '................', '................', '...XXXXXXXXXX...', '...XXXXXXXXXX...',
22      '...XXEXXXXEXX...', '...XXEXXXXEXX...', '.XXXXXXXXXXXXXX.', '.XXXXXXXXXXXXXX.',
23      '...XXXXXXXXXX...', '...XXXXXXXXXX...', '...XXXXXXXXXX...', '...XXXXXXXXXX...',
24      '....XX....XX....', '....XX....XX....', '....XX..........', '................',
25    ],
26    [
27      '................', '................', '...XXXXXXXXXX...', '...XXXXXXXXXX...',
28      '...XXEXXXXEXX...', '...XXEXXXXEXX...', '.XXXXXXXXXXXXXX.', '.XXXXXXXXXXXXXX.',
29      '...XXXXXXXXXX...', '...XXXXXXXXXX...', '...XXXXXXXXXX...', '...XXXXXXXXXX...',
30      '....XX....XX....', '....XX....XX....', '..........XX....', '................',
31    ],
32  ],
33  // the six-link knot, spun 15° a frame (it repeats every 60°); generated by
34  // scripts/gpt-knot.ts, rerun it to change the shape
35  gpt: [
36    [
37      '................................',
38      '............XXX.................',
39      '..........XXXXXXX...............',
40      '.........XXXXXXXX...............',
41      '.........XXX...XXX..XXXXXX......',
42      '........XXX....XXX.XXXXXXXX.....',
43      '........XXX....XXX.XXXXXXXXX....',
44      '........XXX....XXX.XX....XXX....',
45      '.....XX..XX..X.XXX.......XXX....',
46      '...XXXXXX...XXXXXX.X.....XXX....',
47      '..XXXXXXXXX.XXXXXXXXXX..XXXX....',
48      '..XXX..XXXXXXX.XXXXXX..XXXX.....',
49      '.XXX.....XXXXXX.XX...XXXXX......',
50      '.XXX......XXXXX.XXXXXXXXX.......',
51      '.XXX....XX..X.X....XXXX..XXXX...',
52      '..XXX...XXX.X.....XXXXX..XXXXX..',
53      '..XXXXX..XXXXX.....X.XXX...XXX..',
54      '...XXXX..XXXX....X.X..XX....XXX.',
55      '.......XXXXXXXXX.XXXXX......XXX.',
56      '......XXXXX...XX.XXXXXX.....XXX.',
57      '.....XXXX..XXXXXX.XXXXXXX..XXX..',
58      '....XXXX..XXXXXXXXXX.XXXXXXXXX..',
59      '....XXX.....X.XXXXXX...XXXXXX...',
60      '....XXX.......XXX.X..XX..XX.....',
61      '....XXX....XX.XXX....XXX........',
62      '....XXXXXXXXX.XXX....XXX........',
63      '.....XXXXXXXX.XXX....XXX........',
64      '......XXXXXX..XXX...XXX.........',
65      '...............XXXXXXXX.........',
66      '...............XXXXXXX..........',
67      '.................XXX............',
68      '................................',
69    ],
70    [
71      '................................',
72      '.............XXXXX..............',
73      '............XXXXXXXX............',
74      '...........XXXXXXXXX............',
75      '...........XXX....XXX...........',
76      '...........XXX....XXX...........',
77      '.....XXXX..XX.....XXX.XXXXX.....',
78      '....XXXXXXX.X.....XXX.XXXXXX....',
79      '...XXXXXXXXX..XX.XXX.XXXXXXXX...',
80      '..XXXX...XXXX.XX.XXX......XXXX..',
81      '..XXX.....XXXXXX.XXX.......XXX..',
82      '..XXX......XXXX..XXXXX.....XXX..',
83      '..XXXX...X..XXXX.XXXXXX...XXX...',
84      '...XXX...XX.X.XX.XX....XXXXXX...',
85      '....XXX..XX.X....X.XXXXXXXXX....',
86      '.........XXXXX....XXXXXXXX......',
87      '......XXXXXXXX....XXXXX.........',
88      '....XXXXXXXXX.X....X.XX..XXX....',
89      '...XXXXXX....XX.XX.X.XX...XXX...',
90      '...XXX...XXXXXX.XXXX..X...XXXX..',
91      '..XXX.....XXXXX..XXXX......XXX..',
92      '..XXX.......XXX.XXXXXX.....XXX..',
93      '..XXXX......XXX.XX.XXXX...XXXX..',
94      '...XXXXXXXX.XXX.XX..XXXXXXXXX...',
95      '....XXXXXX.XXX.....X.XXXXXXX....',
96      '.....XXXXX.XXX.....XX..XXXX.....',
97      '...........XXX....XXX...........',
98      '...........XXX....XXX...........',
99      '............XXXXXXXXX...........',
100      '............XXXXXXXX............',
101      '..............XXXXX.............',
102      '................................',
103    ],
104    [
105      '................................',
106      '.................XXX............',
107      '...............XXXXXXX..........',
108      '..............XXXXXXXXX.........',
109      '......XXXXX...XXX...XXX.........',
110      '.....XXXXXXX..XX.....XXX........',
111      '....XXXXXXXXX.XX.....XXX........',
112      '....XXX...XXXX......XXX.........',
113      '....XXX....XXX..XX..XXX.XXX.....',
114      '....XXX.....XXXXXX.XXX.XXXXXX...',
115      '....XXX...X.XXXXX.XXXX.XXXXXXX..',
116      '....XXXX..XX.XXX..XXX......XXX..',
117      '.....XXX.XXX.XXXXXXXXXX.....XXX.',
118      '..........XX.X.X..XXXXXX....XXX.',
119      '....XXXXXXXXXX...XXX.XX.....XXX.',
120      '..XXXXXXXXXXXX......XXXXXXXXXX..',
121      '..XXXXXXXXXX......XXXXXXXXXXXX..',
122      '.XXX.....XX.XXX...XXXXXXXXXX....',
123      '.XXX....XXXXXX..X.X.XX..........',
124      '.XXX.....XXXXXXXXXX.XXX.XXX.....',
125      '..XXX......XXX..XXX.XX..XXXX....',
126      '..XXXXXXX.XXXX.XXXXX.X...XXX....',
127      '...XXXXXX.XXX.XXXXXX.....XXX....',
128      '.....XXX.XXX..XX..XXX....XXX....',
129      '.........XXX......XXXX...XXX....',
130      '........XXX.....XX.XXXXXXXXX....',
131      '........XXX.....XX..XXXXXXX.....',
132      '.........XXX...XXX...XXXXX......',
133      '.........XXXXXXXXX..............',
134      '..........XXXXXXX...............',
135      '............XXX.................',
136      '................................',
137    ],
138    [
139      '................................',
140      '................................',
141      '.........XXX.......XXXX.........',
142      '........XXXXXX....XXXXXX........',
143      '.......XXXXXXXX..XXXXXXXX.......',
144      '......XXXX..XXX..XXX..XXXX......',
145      '......XXX....XXX.X.....XXX......',
146      '......XXX....XXX.......XXX......',
147      '......XXX....XXX.......XXX......',
148      '......XXX...X.XXXXXX..XXX.......',
149      '........X..XX.XXXXX..XXXX.......',
150      '....XXXX...XX.XXX...XXXX.XXXX...',
151      '..XXXXXXXXXXX.XXXXXXXXX.XXXXXX..',
152      '..XXXXXXXXXXXX.XX..XXX....XXXXX.',
153      '.XXX....XXXXXXX...XXXXXX....XXX.',
154      '.XXX..............XX.XXX....XXX.',
155      '.XXX....XXX.XX..............XXX.',
156      '.XXX....XXXXXX...XXXXXXX....XXX.',
157      '.XXXXX....XXX..XX.XXXXXXXXXXXX..',
158      '..XXXXXX.XXXXXXXXX.XXXXXXXXXXX..',
159      '...XXXX.XXXX...XXX.XX...XXXX....',
160      '.......XXXX..XXXXX.XX..X........',
161      '.......XXX..XXXXXX.X...XXX......',
162      '......XXX.......XXX....XXX......',
163      '......XXX.......XXX....XXX......',
164      '......XXX.....X.XXX....XXX......',
165      '......XXXX..XXX..XXX..XXXX......',
166      '.......XXXXXXXX..XXXXXXXX.......',
167      '........XXXXXX....XXXXXX........',
168      '.........XXXX.......XXX.........',
169      '................................',
170      '................................',
171    ],
172  ],
173}
174const CADENCE: Record<Avatar, number[]> = { claude: [0, 1], gpt: [0, 1, 2, 3] }
175
176function encodeSprite(sprite: string[], color: number): Picture {
177  const size = sprite.length
178  if (size === 0 || sprite.some(row => !new RegExp(`^[XE.]{${size}}$`).test(row))) {
179    throw new Error('agent-ui: a sprite frame must be N rows of N of X, E or .')
180  }
181  const bytes = new Uint8Array(size * size * 4)
182  for (let y = 0; y < size; y++) {
183    for (let x = 0; x < size; x++) {
184      const pixel = sprite[y]?.[x]
185      const rgb = pixel === 'X' ? color : pixel === 'E' ? EYE : null
186      if (rgb !== null) bytes.set([(rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255, 255], (y * size + x) * 4)
187    }
188  }
189  return { rgba: toBase64(bytes), width: size, height: size }
190}
191
192function encodePicture(avatar: Avatar, step: number, color: number): Picture {
193  const sprite = SPRITES[avatar][CADENCE[avatar][step] ?? -1]
194  if (!sprite) throw new Error(`agent-ui: ${avatar} has no sprite frame at step ${step}`)
195  return encodeSprite(sprite, color)
196}
197
198export const dim = (rgb: number) => ((((rgb >> 16) & 255) * 0.4) << 16) | ((((rgb >> 8) & 255) * 0.4) << 8) | ((rgb & 255) * 0.4)
199
200// Done and failed show step 0.
201const PICTURES = Object.fromEntries(
202  (Object.keys(SPRITES) as Avatar[]).map(avatar => {
203    const color = COLORS[avatar]
204    return [avatar, {
205      run: CADENCE[avatar].map((_, step) => encodePicture(avatar, step, color)),
206      done: encodePicture(avatar, 0, dim(color)),
207      failed: encodePicture(avatar, 0, FAILED),
208    }]
209  }),
210) as Record<Avatar, Look>
211
212export function avatarPicture(avatar: Avatar, status: Status, tick: number): Picture {
213  const look = PICTURES[avatar]
214  if (status === 'running') return look.run[tick % look.run.length] as Picture
215  return status === 'done' ? look.done : look.failed
216}
217
218export function avatarOf(kind: string): Avatar | null {
219  return kind === 'gpt' ? kind : kind === 'claude' || kind === 'claude-panel' ? 'claude' : null
220}
221
hooks/ci.ts 296 lines
1import { toBase64 } from './lib'
2
3// main-ci's run history, from its metrics.jsonl (one row per job attempt): runs grouped, the
4// stacked-bar chart encoded as one RGBA picture or as block-glyph cells, and a run's hover card.
5export type MetricRow = {
6  run: string
7  sha: string
8  job: string
9  attempt: number
10  wall: number
11  cpu: number
12  mb: number
13  exit: number | null
14  load: number | null
15}
16export type CiJob = { job: string; wall: number; cpu: number; mb: number; retries: number }
17export type CiRun = { run: string; sha8: string; jobs: CiJob[]; green: boolean; retries: number; load: number | null; peak: number; peakJob: string; pending: boolean; cancelled: boolean }
18export type MainState = { sha: string; phase: string }
19export type Cell = { glyph: number; color: number }
20
21export const AXIS_COLS = 6
22export const CHART_ROWS = 6
23const PX_COL = 6 // picture pixels per terminal column
24const PX_ROW = 12 // and per row: a cell is about 1:2
25const BAR_PAD = 2 // pixels clear on each side of a bar
26const TOP = 8 // room above the tallest bar for a retry count
27const UNDER = 3 // room below the bars for a red run's underline
28const RED = 0xe65a50
29const LOAD = 0x8caad2
30const DIM = 0x787a87
31const PALETTE = [
32  0xb25248, 0xb08c50, 0xc4c45a, 0x8caa5a, 0x5aa0a0, 0x6e82c8, 0x5a5ac8,
33  0x965ac8, 0xb45ab4, 0xc878a0, 0x82bed2, 0xdcaa78, 0x787878, 0xaa6e5a,
34]
35const num = (value: unknown): number | null => (typeof value === 'number' && Number.isFinite(value) ? value : null)
36
37// One row per line; a preview's rows and lines that are not a job attempt are skipped.
38export function parseRows(text: string): MetricRow[] {
39  const rows: MetricRow[] = []
40  for (const line of text.split('\n')) {
41    let value: Record<string, unknown>
42    try {
43      value = JSON.parse(line) as Record<string, unknown>
44    } catch {
45      continue
46    }
47    if (value?.kind === 'preview' || typeof value?.run !== 'string' || typeof value.job !== 'string') continue
48    rows.push({
49      run: value.run,
50      sha: typeof value.sha === 'string' ? value.sha : '',
51      job: value.job,
52      attempt: num(value.attempt) ?? 1,
53      wall: num(value.wall_s) ?? 0,
54      cpu: (num(value.cpu_user_s) ?? 0) + (num(value.cpu_sys_s) ?? 0),
55      mb: num(value.max_rss_mb) ?? 0,
56      exit: num(value.exit),
57      load: num(value.load1_start),
58    })
59  }
60  return rows
61}
62
63// A run's jobs at their final (highest) attempt; retries count every attempt after the first.
64export function summarize(run: string, rows: MetricRow[]): CiRun {
65  const final = new Map<string, MetricRow>()
66  for (const row of rows) if (row.attempt >= (final.get(row.job)?.attempt ?? 0)) final.set(row.job, row)
67  const jobs = [...final.values()].map(row => ({ job: row.job, wall: row.wall, cpu: row.cpu, mb: row.mb, retries: row.attempt - 1 }))
68  // any attempt's row, a retried one's included: that memory was in use
69  const peak = rows.reduce<MetricRow | null>((top, row) => (top && top.mb >= row.mb ? top : row), null)
70  return {
71    run,
72    sha8: (rows[0]?.sha ?? '').slice(0, 8),
73    jobs: jobs.sort((a, b) => a.job.localeCompare(b.job)),
74    green: [...final.values()].every(row => row.exit === 0),
75    retries: rows.filter(row => row.attempt > 1).length,
76    load: rows.find(row => row.load !== null)?.load ?? null,
77    peak: Math.max(0, peak?.mb ?? 0),
78    peakJob: peak?.job ?? '',
79    pending: false,
80    cancelled: false,
81  }
82}
83
84// A cancel (run.log: the tip moved) ends the latest run of its sha that started before it; the
85// run name starts with its start time, `YYYYMMDDTHHMMSS`, as `at` does.
86export function markCancelled(runs: CiRun[], cancels: { sha8: string; at: string }[]): CiRun[] {
87  const hit = new Set<string>()
88  for (const cancel of cancels) {
89    let latest: CiRun | undefined
90    for (const run of runs) if (run.sha8 === cancel.sha8 && run.run.slice(0, 15) <= cancel.at && (!latest || run.run > latest.run)) latest = run
91    if (latest) hit.add(latest.run)
92  }
93  return hit.size === 0 ? runs : runs.map(run => (hit.has(run.run) ? { ...run, cancelled: true, pending: false } : run))
94}
95
96// The run main-ci is on: state.json's run while its lanes build, or a run newer than state.json's
97// (its core has not finished). A building run with no rows yet is an empty slot at the end.
98export function withPending(runs: CiRun[], state: MainState | null): CiRun[] {
99  if (!state) return runs
100  const sha8 = state.sha.slice(0, 8)
101  const at = runs.findLastIndex(run => run.sha8 === sha8)
102  const last = runs.at(-1)
103  const pendLast = () => [...runs.slice(0, -1), { ...(last as CiRun), pending: true }]
104  if (last && at >= 0 && at < runs.length - 1) return pendLast()
105  if (state.phase !== 'builds') return runs
106  if (at >= 0) return pendLast()
107  return [...runs, { run: `pending-${sha8}`, sha8, jobs: [], green: false, retries: 0, load: null, peak: 0, peakJob: '', pending: true, cancelled: false }]
108}
109
110// Run i's first cell, counted from the chart's left edge (the axis included): its bar, its
111// hover column and its card's placement all start here.
112export const runCell = (i: number) => AXIS_COLS + i * 2
113
114export const runsFit = (bodyColumns: number) => Math.max(0, Math.floor((bodyColumns - AXIS_COLS) / 2))
115
116export function fnv1a(text: string): number {
117  let hash = 0x811c9dc5
118  for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193)
119  return hash >>> 0
120}
121
122// Hashed from the name: a job keeps its color whatever other jobs exist.
123export const jobColor = (job: string): number => PALETTE[fnv1a(job) % PALETTE.length] as number
124
125// A repo panel's bar tones, in the chart's colors.
126export const TONES: Record<string, number> = { good: PALETTE[3] as number, mid: PALETTE[2] as number, bad: RED, dim: DIM }
127
128// What a bar measures: each job's wall or cpu seconds stacked, or the run's peak memory.
129export type Metric = 'wall' | 'cpu' | 'mem'
130export const METRICS: readonly Metric[] = ['wall', 'cpu', 'mem']
131
132// A run's bar, bottom up. mem is one part, the run's peak in the colour of the job that hit it:
133// jobs run one after another, so a sum of their peaks is memory never in use at once.
134export function barParts(run: CiRun, metric: Metric): { job: string; value: number }[] {
135  if (metric !== 'mem') return run.jobs.map(job => ({ job: job.job, value: metric === 'cpu' ? job.cpu : job.wall }))
136  return run.peakJob ? [{ job: run.peakJob, value: run.peak }] : []
137}
138
139export const barTotal = (run: CiRun, metric: Metric) => barParts(run, metric).reduce((sum, part) => sum + part.value, 0)
140export const barMax = (runs: CiRun[], metric: Metric) => Math.max(1, ...runs.map(run => barTotal(run, metric)))
141
142// The axis's top and bottom labels; the selected chip names the metric.
143export function axisLabels(max: number, metric: Metric): { top: string; bottom: string } {
144  if (metric === 'mem') return { top: max >= 1024 ? `${(max / 1024).toFixed(1)}G` : `${Math.round(max)}M`, bottom: '0' }
145  return { top: `${Math.round(max)}s`, bottom: '0s' }
146}
147
148// 3x5 digits for a retry count; 9+ reads "+".
149const DIGITS: Record<string, string> = {
150  '1': '.X.XX..X..X.XXX', '2': 'XX...X.X.X..XXX', '3': 'XX...X.X...XXX.', '4': 'X.XX.XXXX..X..X',
151  '5': 'XXXX..XX...XXX.', '6': '.XXX..XX.X.X.X.', '7': 'XXX..X.X..X..X.', '8': '.X.X.X.X.X.X.X.',
152  '9': '.X.X.X.XX..X.X.', '+': '....X.XXX.X....',
153}
154
155type Picture = { rgba: string; width: number; height: number }
156
157// One picture per run, 2 cells by CHART_ROWS, cut from the whole chart so the load line runs on
158// across them. A terminal fits a picture to its cell box keeping its aspect ratio, so one wide
159// picture drifts off the cell grid wherever the cells are not exactly 1:2; one per run column
160// keeps each bar inside its own hover column.
161export function chartTiles(runs: CiRun[], metric: Metric = 'wall'): Picture[] {
162  const whole = chartBytes(runs, metric)
163  const tile = 2 * PX_COL
164  return runs.map((_, i) => {
165    const bytes = new Uint8Array(tile * whole.height * 4)
166    for (let y = 0; y < whole.height; y++) bytes.set(whole.bytes.subarray((y * whole.width + i * tile) * 4, (y * whole.width + (i + 1) * tile) * 4), y * tile * 4)
167    return { rgba: toBase64(bytes), width: tile, height: whole.height }
168  })
169}
170
171export function chartBytes(runs: CiRun[], metric: Metric = 'wall'): { bytes: Uint8Array; width: number; height: number } {
172  const width = Math.max(1, runs.length) * 2 * PX_COL
173  const height = CHART_ROWS * PX_ROW
174  const bytes = new Uint8Array(width * height * 4)
175  const put = (x: number, y: number, rgb: number, alpha = 255) => {
176    if (x < 0 || y < 0 || x >= width || y >= height) return
177    bytes.set([(rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255, alpha], (y * width + x) * 4)
178  }
179  const base = height - UNDER - 1
180  const span = base - TOP
181  const scale = span / barMax(runs, metric)
182  const loadMax = Math.max(1, ...runs.map(run => run.load ?? 0))
183  const points: [number, number][] = []
184  runs.forEach((run, i) => {
185    const x0 = (runCell(i) - AXIS_COLS) * PX_COL + BAR_PAD
186    const x1 = (runCell(i) - AXIS_COLS + 2) * PX_COL - BAR_PAD
187    let sum = 0
188    let top = base + 1
189    const faded = run.pending || run.cancelled
190    for (const part of barParts(run, metric)) {
191      const from = base - Math.round((sum + part.value) * scale) + 1
192      for (let y = from; y < top; y++) for (let x = x0; x < x1; x++) put(x, y, jobColor(part.job), faded ? 128 : 255)
193      sum += part.value
194      top = Math.min(top, from)
195    }
196    if (faded) {
197      for (let y = TOP; y <= base; y += 2) {
198        put(x0, y, DIM)
199        put(x1 - 1, y, DIM)
200      }
201    }
202    if (run.retries > 0) {
203      const glyph = DIGITS[run.retries > 9 ? '+' : String(run.retries)] ?? ''
204      const gx = Math.floor((x0 + x1) / 2) - 1
205      for (let k = 0; k < 15; k++) if (glyph[k] === 'X') put(gx + (k % 3), top - 6 + Math.floor(k / 3), RED)
206    }
207    if (!run.green && !faded) for (let y = base + 2; y < height; y++) for (let x = x0; x < x1; x++) put(x, y, RED)
208    if (run.load !== null) points.push([Math.floor((x0 + x1) / 2), base - Math.round((run.load / loadMax) * span)])
209  })
210  for (let k = 1; k < points.length; k++) {
211    const [ax, ay] = points[k - 1] as [number, number]
212    const [bx, by] = points[k] as [number, number]
213    const steps = Math.max(Math.abs(bx - ax), Math.abs(by - ay), 1)
214    for (let s = 0; s <= steps; s++) put(Math.round(ax + ((bx - ax) * s) / steps), Math.round(ay + ((by - ay) * s) / steps), LOAD)
215  }
216  return { bytes, width, height }
217}
218
219// Where no Image draws: rows x (2 per run) cells of ▁..█, colored by the bar's largest part; a retry
220// count in red above the bar.
221export function chartCells(runs: CiRun[], metric: Metric = 'wall'): Cell[][] {
222  const eighths = CHART_ROWS * 8
223  const scale = (eighths - 8) / barMax(runs, metric)
224  const grid: Cell[][] = Array.from({ length: CHART_ROWS }, () => Array.from({ length: runs.length * 2 }, () => ({ glyph: 0x20, color: DIM })))
225  runs.forEach((run, i) => {
226    const parts = barParts(run, metric)
227    const color = parts.reduce<(typeof parts)[number] | null>((top, part) => (top && top.value >= part.value ? top : part), null)
228    const level = Math.round(barTotal(run, metric) * scale)
229    for (let row = 0; row < CHART_ROWS; row++) {
230      const fill = Math.min(8, Math.max(0, level - (CHART_ROWS - 1 - row) * 8))
231      const glyph = fill === 0 ? 0x20 : 0x2580 + fill
232      for (const dx of [0, 1]) (grid[row] as Cell[])[i * 2 + dx] = { glyph, color: color ? jobColor(color.job) : DIM }
233    }
234    const above = CHART_ROWS - 1 - Math.ceil(level / 8)
235    if (run.retries > 0 && above >= 0) (grid[above] as Cell[])[i * 2] = { glyph: (run.retries > 9 ? '+' : String(run.retries)).charCodeAt(0), color: RED }
236  })
237  return grid
238}
239
240// Run i's 2 columns of the fallback grid.
241export const tileCells = (grid: Cell[][], i: number): Cell[][] => grid.map(row => row.slice(i * 2, i * 2 + 2))
242
243const pad = (text: string, width: number, right = false) => (right ? text.padStart(width) : text.padEnd(width))
244
245export type CardRow = { job: string; name: string; nums: string }
246export type CardTable = { head: string; header: CardRow; rows: CardRow[]; nameWidth: number; width: number }
247
248const CARD_COLUMNS: { label: string; width: number; of: (job: CiJob) => string }[] = [
249  { label: 'wall', width: 6, of: job => job.wall.toFixed(1) },
250  { label: 'cpu', width: 6, of: job => job.cpu.toFixed(1) },
251  { label: 'MB', width: 6, of: job => String(Math.round(job.mb)) },
252  { label: 're', width: 3, of: job => String(job.retries) },
253]
254const NAME_MIN = 8
255const NAME_MAX = 16
256
257// The hover card: a heading, then job / wall / cpu / MB / re for the run's 8 slowest jobs, each
258// row a name cell and a numbers cell, never wider than `width`: the name shortens first (to
259// NAME_MIN), then `re` goes, then `cpu`. `width` is the card's content width.
260export function cardTable(run: CiRun, maxWidth = Infinity): CardTable {
261  const head = `${run.sha8}${run.cancelled ? ' · cancelled (tip moved)' : ''} · load ${run.load === null ? '-' : run.load.toFixed(1)} · peak ${Math.round(run.peak)} MB${run.pending ? ' · running' : ''}`
262  const slowest = [...run.jobs].sort((a, b) => b.wall - a.wall).slice(0, 8)
263  const natural = Math.min(NAME_MAX, Math.max('job'.length, ...slowest.map(job => job.job.length)) + 1)
264  let columns = CARD_COLUMNS
265  const numsWidth = () => columns.reduce((sum, column) => sum + column.width, 0)
266  const nameFor = () => Math.max(Math.min(natural, NAME_MIN), Math.min(natural, maxWidth - numsWidth()))
267  for (const drop of ['re', 'cpu']) if (nameFor() + numsWidth() > maxWidth) columns = columns.filter(column => column.label !== drop)
268  const nameWidth = Math.min(nameFor(), Math.max(1, maxWidth))
269  const width = Math.max(1, Math.min(maxWidth, Math.max(head.length, nameWidth + numsWidth())))
270  const row = (job: string, cells: string[]): CardRow => ({
271    job,
272    name: pad(job.slice(0, nameWidth - 1), nameWidth),
273    nums: cells.map((text, k) => pad(text, (columns[k] as (typeof columns)[number]).width, true)).join('').slice(0, width - nameWidth),
274  })
275  return {
276    head: head.slice(0, width),
277    header: row('job', columns.map(column => column.label)),
278    rows: slowest.map(job => row(job.job, columns.map(column => column.of(job)))),
279    nameWidth,
280    width,
281  }
282}
283
284// Where a card goes along one axis of `span` cells, beside the `size` cells it describes at `at`:
285// after them, else before them, never over them; when it fits on neither side whole, on the
286// roomier side, cut to fit.
287export function placeBeside(at: number, size: number, card: number, span: number): { x: number; width: number } {
288  const after = span - (at + size)
289  if (card <= after) return { x: at + size, width: card }
290  if (card <= at) return { x: at - card, width: card }
291  return after >= at ? { x: at + size, width: after } : { x: 0, width: at }
292}
293
294// A run's card: right of its column, else left of it.
295export const cardPlace = (column: number, cardWidth: number, bodyColumns: number) => placeBeside(runCell(column), 2, cardWidth, bodyColumns)
296
hooks/lib.ts 140 lines
1import type { RunStatus } from '../types'
2
3export const TAIL_LINES = 40
4
5// Base64 in 8 KiB slices: one spread of a large array overflows the argument limit.
6export function toBase64(bytes: Uint8Array): string {
7  let text = ''
8  for (let i = 0; i < bytes.length; i += 8192) text += String.fromCharCode(...bytes.subarray(i, i + 8192))
9  return btoa(text)
10}
11
12export function baseName(path: string): string {
13  return path.replace(/\/+$/, '').split('/').pop() ?? path
14}
15
16export function parentDir(path: string): string {
17  const trimmed = path.replace(/\/+$/, '')
18  const cut = trimmed.lastIndexOf('/')
19  return cut <= 0 ? '/' : trimmed.slice(0, cut)
20}
21
22// The run's `provider` file: openai and codex are gpt, claude is claude; any other value as written.
23export function kindOf(provider: string, isPanel: boolean): string {
24  if (provider === 'openai' || provider === 'codex') return 'gpt'
25  if (provider === 'claude') return isPanel ? 'claude-panel' : 'claude'
26  return provider
27}
28
29export function labelOf(dir: string, isPanel: boolean): string {
30  if (isPanel) return `rev ${baseName(parentDir(dir)).slice(0, 8)}/${baseName(dir)}`
31  return baseName(dir)
32}
33
34export function statusOf(hasDone: boolean, exitCode: string, isAlive: boolean): RunStatus {
35  if (hasDone) return exitCode === '' || exitCode === '0' ? 'done' : 'failed'
36  return isAlive ? 'running' : 'dead'
37}
38
39export function epochMs(text: string): number | null {
40  const seconds = Number.parseInt(text, 10)
41  return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
42}
43
44export function elapsed(ms: number): string {
45  const minutes = Math.max(0, Math.floor(ms / 60_000))
46  if (minutes < 60) return `${minutes}m`
47  const hours = Math.floor(minutes / 60)
48  if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
49  return `${Math.floor(hours / 24)}d${String(hours % 24).padStart(2, '0')}h`
50}
51
52export function lines(text: string): string[] {
53  return text.split('\n').filter(line => line.trim() !== '')
54}
55
56function oneLine(text: string, width = 160): string {
57  const flat = text.replace(/\s+/g, ' ').trim()
58  return flat.length > width ? `${flat.slice(0, width - 1)}…` : flat
59}
60
61function parseJson(line: string): Record<string, unknown> | null {
62  try {
63    const value: unknown = JSON.parse(line)
64    return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
65  } catch {
66    return null
67  }
68}
69
70// codex exec --json events: agent messages, finished commands, errors.
71export function codexTail(jsonl: string): string[] {
72  const out: string[] = []
73  for (const line of jsonl.split('\n')) {
74    const event = parseJson(line)
75    const item = event?.item as Record<string, unknown> | undefined
76    if (event?.type === 'item.completed' && item?.type === 'agent_message') {
77      out.push(...lines(String(item.text ?? '')))
78    } else if (event?.type === 'item.completed' && item?.type === 'command_execution') {
79      const command = String(item.command ?? '').replace(/^\S*bash -lc ['"]?/, '')
80      out.push(`$ ${oneLine(command, 120)}  (exit ${String(item.exit_code ?? '?')})`)
81    } else if (event?.type === 'error' || event?.type === 'turn.failed') {
82      out.push(`error: ${oneLine(JSON.stringify(event.error ?? event.message ?? ''))}`)
83    }
84  }
85  return out.slice(-TAIL_LINES)
86}
87
88// claude -p --output-format json writes one object; its `result` is the final message.
89export function resultJsonMessage(text: string): string {
90  const result = parseJson(text)?.result
91  return typeof result === 'string' ? result : ''
92}
93
94// One key of `.agent/local.env`: `key=value` lines (a `#` line never starts with a key), the
95// value everything after the first `=`, a leading `~/` read as $HOME; null when absent or empty.
96export function localEnvValue(text: string, key: string, home: string): string | null {
97  const line = text.split('\n').find(one => one.startsWith(`${key}=`))
98  const value = line?.slice(key.length + 1).trim().replace(/^~(?=\/)/, home).replace(/(.)\/+$/, '$1')
99  return value || null
100}
101
102// main-ci's run.log, replayed a chunk of lines at a time: the previews still queued or running
103// (a PR's entry ends with its result, a cancel, or a head move; the last two carry no sha
104// pair), and each run cancelled because the tip moved (`<ts> cancel run sha=<sha>`).
105export type RunLog = { previews: Map<number, { main: string; state: 'queued' | 'running' }>; cancels: { sha8: string; at: string }[] }
106
107export const freshRunLog = (): RunLog => ({ previews: new Map(), cancels: [] })
108
109export function applyRunLog(log: RunLog, text: string): void {
110  for (const line of text.split('\n')) {
111    const cancel = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)\S* cancel run sha=([0-9a-f]{8})/.exec(line)
112    if (cancel) {
113      const [, y, mo, d, h, mi, s, sha8 = ''] = cancel
114      log.cancels.push({ sha8, at: `${y}${mo}${d}T${h}${mi}${s}` })
115      continue
116    }
117    const match = /preview #(\d+)(?: [0-9a-f]+-([0-9a-f]+))?: (queued|start|green|red|conflict|cancelled|head moved)/i.exec(line)
118    if (!match) continue
119    const [, pr = '', main, event = ''] = match
120    if (/^(queued|start)$/i.test(event)) {
121      if (main) log.previews.set(Number(pr), { main, state: /^start$/i.test(event) ? 'running' : 'queued' })
122    } else log.previews.delete(Number(pr))
123  }
124}
125
126export function previewQueue(log: RunLog, main8: string): { running: number; queued: number } {
127  const current = [...log.previews.values()].filter(entry => entry.main === main8)
128  return { running: current.filter(entry => entry.state === 'running').length, queued: current.filter(entry => entry.state === 'queued').length }
129}
130
131// A chain from its newest merged issue on: the last merged one before the first open one stays,
132// the merged ones before it go.
133export type ChainLink = { n: number; mark: 'merged' | 'active' | 'next' }
134
135export function chainLinks(chain: number[], merged: Set<number>, active: Set<number>): ChainLink[] {
136  const open = chain.findIndex(n => !merged.has(n))
137  const from = open < 0 ? chain.length - 1 : Math.max(0, open - 1)
138  return chain.slice(from).map(n => ({ n, mark: merged.has(n) ? 'merged' : active.has(n) ? 'active' : 'next' }))
139}
140
hooks/panels.ts 206 lines
1import { TONES } from './ci'
2
3// Repo panels: `.agent/pane-panels.json` lists commands, each prints one JSON object of tabs of
4// rows (README, "Repo panels"). This file parses both and lays a row out as colored segments.
5export type PanelSpec = { id: string; title: string; cmd: string[]; refreshMs: number }
6export type Bar = { frac: number; text: string; tone: string }
7// v1.2: `width` is the minimum; spare pane cells widen a column up to `maxWidth`. `keepRight`
8// cuts an over-long value from the left.
9export type Column = { key: string; label: string; width: number; maxWidth: number; align: 'left' | 'right'; isBar: boolean; keepRight: boolean }
10export type PanelRow = { id: string; dim: boolean; cells: Record<string, string | Bar>; hover: string[] }
11export type PanelTab = { id: string; label: string; columns: Column[]; rows: PanelRow[]; note: string }
12export type FilterOption = { id: string; label: string }
13export type Filter = { id: string; options: FilterOption[]; default: string }
14export type PanelData = { summary: string; tab: string; tabs: PanelTab[]; filters: Filter[] }
15export type Segment = { text: string; color?: number; background?: number }
16
17export const PANEL_TIMEOUT_MS = 10_000
18const REFRESH_DEFAULT_S = 60
19// above the pane's 3 s tick, so a panel never runs on every tick
20const REFRESH_MIN_S = 5
21// what one panel may draw, whatever its command prints
22const HOVER_LINES = 6
23const MAX_WIDTH = 200
24const MAX_COLUMNS = 12
25const MAX_TABS = 12
26// with filters, one tab per option combination
27const MAX_FILTERS = 3
28const MAX_OPTIONS = 12
29const MAX_FILTER_TABS = 48
30const MAX_ROWS = 100
31// one space between columns
32const GAP = 1
33// a bar's unfilled cells, and its text's ink over the fill and over the track
34export const BAR_TRACK = 0x3a3c4e
35export const INK_ON_FILL = 0x1e1f28
36export const INK_ON_TRACK = 0xe1e1e6
37
38const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
39// A control character (a newline, an escape sequence's ESC) would break the row or restyle the
40// terminal: each becomes a space.
41export const clean = (text: string): string => text.replace(/[\u0000-\u001f\u007f]/g, ' ')
42const str = (value: unknown, fallback = ''): string => clean(typeof value === 'string' ? value : typeof value === 'number' ? String(value) : fallback)
43
44function parseJson(text: string): unknown {
45  try {
46    return JSON.parse(text)
47  } catch {
48    return undefined
49  }
50}
51
52// Entries without an id or an argv are skipped; a file that is no list lists nothing.
53export function parseSpecs(text: string): PanelSpec[] {
54  const list = parseJson(text)
55  if (!Array.isArray(list)) return []
56  const seen = new Set<string>()
57  return list.flatMap(entry => {
58    if (!isObject(entry) || typeof entry.id !== 'string' || entry.id === '' || seen.has(entry.id)) return []
59    const cmd = entry.cmd
60    if (!Array.isArray(cmd) || cmd.length === 0 || !cmd.every(arg => typeof arg === 'string') || cmd[0] === '') return []
61    seen.add(entry.id)
62    const refresh = typeof entry.refresh_s === 'number' && Number.isFinite(entry.refresh_s) ? entry.refresh_s : REFRESH_DEFAULT_S
63    return [{ id: entry.id, title: str(entry.title, entry.id), cmd: cmd as string[], refreshMs: Math.max(REFRESH_MIN_S, refresh) * 1000 }]
64  })
65}
66
67function parseCell(value: unknown): string | Bar {
68  if (!isObject(value)) return str(value)
69  const frac = typeof value.frac === 'number' && Number.isFinite(value.frac) ? Math.min(1, Math.max(0, value.frac)) : 0
70  return { frac, text: str(value.text), tone: str(value.tone, 'dim') }
71}
72
73// One JSON object with a list of tabs, else null; a tab without an id, columns or rows is dropped.
74// v1.1 filters: chip rows that together pick a tab, its id their picks joined by `/`. A filter
75// without an id or an option, and a filter or option whose id was seen before, is dropped; the
76// caps count what is left.
77function parseFilters(value: unknown): Filter[] {
78  if (!Array.isArray(value)) return []
79  const ids = new Set<string>()
80  const filters = value.flatMap((filter): Filter[] => {
81    if (!isObject(filter) || typeof filter.id !== 'string' || filter.id === '' || ids.has(filter.id) || !Array.isArray(filter.options)) return []
82    const seen = new Set<string>()
83    const options = filter.options.flatMap((option): FilterOption[] => {
84      if (!isObject(option) || typeof option.id !== 'string' || option.id === '' || seen.has(option.id)) return []
85      seen.add(option.id)
86      return [{ id: option.id, label: str(option.label, option.id) }]
87    })
88    if (options.length === 0) return []
89    ids.add(filter.id)
90    return [{ id: filter.id, options: options.slice(0, MAX_OPTIONS), default: typeof filter.default === 'string' ? filter.default : '' }]
91  })
92  return filters.slice(0, MAX_FILTERS)
93}
94
95export function parsePanelOutput(stdout: string): PanelData | null {
96  const value = parseJson(stdout.trim())
97  if (!isObject(value) || !Array.isArray(value.tabs)) return null
98  const filters = parseFilters(value.filters)
99  const tabs = value.tabs.slice(0, filters.length > 0 ? MAX_FILTER_TABS : MAX_TABS).flatMap((tab): PanelTab[] => {
100    if (!isObject(tab) || typeof tab.id !== 'string' || !Array.isArray(tab.columns) || !Array.isArray(tab.rows)) return []
101    const columns = tab.columns.slice(0, MAX_COLUMNS).filter(isObject).map(column => {
102      const width = Math.min(MAX_WIDTH, typeof column.width === 'number' && column.width > 0 ? Math.floor(column.width) : str(column.label, str(column.key)).length)
103      return {
104        key: str(column.key),
105        label: str(column.label, str(column.key)),
106        width,
107        maxWidth: typeof column.maxWidth === 'number' && Number.isFinite(column.maxWidth) ? Math.min(MAX_WIDTH, Math.max(width, Math.floor(column.maxWidth))) : width,
108        align: column.align === 'right' ? ('right' as const) : ('left' as const),
109        isBar: column.kind === 'bar',
110        keepRight: column.keep === 'right',
111      }
112    })
113    const rows = tab.rows.slice(0, MAX_ROWS).filter(isObject).map((row, i) => ({
114      id: str(row.id, String(i)),
115      dim: row.dim === true,
116      cells: Object.fromEntries(Object.entries(isObject(row.cells) ? row.cells : {}).map(([key, cell]) => [key, parseCell(cell)])),
117      hover: (Array.isArray(row.hover) ? row.hover : []).slice(0, HOVER_LINES).map(line => str(line)),
118    }))
119    return [{ id: tab.id, label: str(tab.label, tab.id), columns, rows, note: str(tab.note) }]
120  })
121  return { summary: str(value.summary), tab: str(value.tab), tabs, filters }
122}
123
124// Each filter's option: the user's pick while still offered, else its default, else its first.
125export function pickOptions(filters: Filter[], picks: Record<string, string> | undefined): string[] {
126  const offered = (filter: Filter, id: string | undefined) => filter.options.some(option => option.id === id)
127  return filters.map(filter => {
128    const pick = picks?.[filter.id]
129    return offered(filter, pick) ? (pick as string) : offered(filter, filter.default) ? filter.default : (filter.options[0] as FilterOption).id
130  })
131}
132
133// The user's pick if the output still has it, else the output's default, else the first tab.
134export function pickTab(data: PanelData, chosen: string | undefined): PanelTab | null {
135  return data.tabs.find(tab => tab.id === chosen) ?? data.tabs.find(tab => tab.id === data.tab) ?? data.tabs[0] ?? null
136}
137
138const fit = (text: string, width: number, right: boolean, keepRight = false) => {
139  const kept = keepRight && text.length > width ? text.slice(text.length - width) : text.slice(0, width)
140  return right ? kept.padStart(width) : kept.padEnd(width)
141}
142
143// v1.2: the columns at their minimum widths, one gap apart, leave `width - used` cells; those go
144// left to right to columns with a `maxWidth`, each up to it. Header and rows share the result.
145function layout(columns: Column[], width: number): Column[] {
146  let spare = Math.max(0, width - columns.reduce((sum, column) => sum + column.width, 0) - GAP * Math.max(0, columns.length - 1))
147  return columns.map(column => {
148    const grow = Math.min(spare, column.maxWidth - column.width)
149    spare -= grow
150    return { ...column, width: column.width + grow }
151  })
152}
153
154// A bar the whole cell wide: its first `frac` of the cells on the tone's background, the rest
155// on the track; the text right-aligned over it, one cell clear of the right edge when it fits,
156// each character inked dark over the fill and light over the track. One segment per run of
157// characters alike.
158export function barSegments(bar: Bar, width: number, dim: boolean): Segment[] {
159  // any work done shows: a non-zero fraction fills at least one cell
160  const fill = bar.frac > 0 ? Math.max(1, Math.round(bar.frac * width)) : 0
161  // own keys only: an inherited name ("constructor") is no tone
162  const tone = (!dim && Object.hasOwn(TONES, bar.tone) ? TONES[bar.tone] : TONES.dim) as number
163  const text = bar.text.slice(0, width)
164  const line = (text.length + 2 <= width ? `${text} ` : text).padStart(width)
165  const out: Segment[] = []
166  for (let i = 0; i < width; i++) {
167    const filled = i < fill
168    const last = out.at(-1)
169    if (last && last.background === (filled ? tone : BAR_TRACK)) last.text += line[i]
170    else out.push({ text: line[i] as string, color: filled ? INK_ON_FILL : INK_ON_TRACK, background: filled ? tone : BAR_TRACK })
171  }
172  return out
173}
174
175function cellSegments(column: Column, value: string | Bar | undefined, dim: boolean): Segment[] {
176  if (column.isBar && typeof value === 'object') return barSegments(value, column.width, dim)
177  const text = typeof value === 'object' ? value.text : (value ?? '')
178  return [{ text: fit(text, column.width, column.align === 'right', column.keepRight) }]
179}
180
181// Cut to `width` columns: a row never wraps.
182export function clip(segments: Segment[], width: number): Segment[] {
183  const out: Segment[] = []
184  let left = Math.max(0, width)
185  for (const segment of segments) {
186    if (left === 0) break
187    const text = segment.text.slice(0, left)
188    left -= text.length
189    if (text !== '') out.push({ ...segment, text })
190  }
191  return out
192}
193
194const join = (cells: Segment[][]): Segment[] => cells.flatMap((cell, i) => (i === 0 ? cell : [{ text: ' '.repeat(GAP) }, ...cell]))
195
196export const headerSegments = (tab: PanelTab, width: number): Segment[] =>
197  clip(join(layout(tab.columns, width).map(column => [{ text: fit(column.label.replaceAll('{w}', String(column.width)), column.width, column.align === 'right' && !column.isBar) }])), width)
198
199export const rowSegments = (tab: PanelTab, row: PanelRow, width: number): Segment[] =>
200  clip(join(layout(tab.columns, width).map(column => cellSegments(column, row.cells[column.key], row.dim))), width)
201
202// stderr's first line with text, its control characters spaces
203export function firstLine(text: string): string {
204  return text.split('\n').map(line => clean(line).trim()).find(Boolean) ?? ''
205}
206
hooks/ask-line.tsx 23 lines
1import type { ClientModule } from 'claude-code'
2
3export type AskLineProps = { key: string; label: string; text: string }
4
5// One ask's toggle line, drawn as Text, never a Button: the terminal's hit test stops at the
6// first pressable (onClick or tabIndex) above the cell and never reaches this Client's pointer
7// listener past it, so a Button here swallowed the right-click. Each button press posts once;
8// the hooks module decides (left toggles the context, right dismisses), naming the ask by its
9// text, so a line that moved since this drew is still the one acted on.
10const AskLine: ClientModule<AskLineProps> = (props, surface) => {
11  const { Box, Text } = surface.elements
12  surface.onPointer(event => {
13    if (event.type === 'down') surface.post({ button: event.button ?? null, text: props.text })
14  })
15  return (
16    <Box key={props.key}>
17      <Text>{props.label}</Text>
18    </Box>
19  )
20}
21
22export default AskLine
23
types/index.d.ts 31 lines
1export type RunStatus = 'running' | 'done' | 'failed' | 'dead'
2
3export type Run = {
4  dir: string
5  kind: string
6  label: string
7  cwd: string
8  isReviewer: boolean
9  model: string
10  status: RunStatus
11  startedAt: number | null
12  endedAt: number | null
13}
14
15export type OpenAsk = { n: number; text: string }
16
17declare module 'claude-code' {
18  interface PluginState {
19    'agent-ui': {
20      selectedRun: string | null
21      openAsk: OpenAsk | null
22      expanded: string[]
23      ciOpen: boolean
24      ciMetric: 'wall' | 'cpu' | 'mem'
25      panelsOpen: string[]
26      panelTabs: Record<string, string>
27      panelFilters: Record<string, Record<string, string>>
28    }
29  }
30}
31