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…

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.
Independent, invoked by the user:
| Skill | Work |
|---|---|
comm | Writing registers and rules for everything an agent writes to a person. |
orchestrate | Why the manager delegates; base, worktree, worker procedure, review, PR, and the final check. |
tech-debt | Weekly debt revisit: gathers friction signals, settles candidates with the user, and cuts tickets. |
intake | Stakeholder interview on the stakeholder's own machine, filed as issues. See intake/INSTALL.md. |
Composed on top of comm and orchestrate:
| Skill | Work |
|---|---|
pm | Settles product choices, updates product docs, creates tickets, and gives ready work to the TL. |
tl | Holds 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:
| Skill | Work |
|---|---|
review-gate | The reviewer's procedure: scan, review the exact commit, return PASS, BLOCK, or ERROR. |
Clone this repo. Link each skill folder into the skill folder for the harness you use:
~/.claude/skills/~/.codex/skills/A link lets a pull in this repo update the installed skill at once.
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/ 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/ 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.
hooks/register.tsx 1282 lines1import { 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 lines1import { 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}
221hooks/ci.ts 296 lines1import { 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)
296hooks/lib.ts 140 lines1import 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}
140hooks/panels.ts 206 lines1import { 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}
206hooks/ask-line.tsx 23 lines1import 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
23types/index.d.ts 31 lines1export 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