Live pane that watches agy, cursor-agent and opencode delegate runs while they work

A live pane for Claude Code that shows what your delegated coding agents are doing while they work, instead of leaving you blind until they finish.
It watches runs started by relay-style delegate scripts for agy (Google Antigravity), cursor-agent and opencode, and works in both the terminal CLI and the desktop app.
It is built around the run folders written by the delegate-skills relay scripts (/agy-delegate, /cursor-delegate, /opencode-delegate). Install those skills to get runs to watch; this plugin only reads what they write.

(01:38), a typed badge (READ, EDIT, SHELL, GREP, TURN, THINK...), and the file or command (folder dimmed, name bold).| Agent | Source | What is shown |
|---|---|---|
| cursor-agent | events.jsonl (stream-json) | every tool call, message and thought |
| opencode | events.jsonl | every tool call, message, error, and cost |
| agy | agy.log, stderr.txt, git, ps | model turns, files it edited since start, idle/blocked state, commands running under agy |
agy writes no event stream, so for agy the pane infers progress from the outside: each streamGenerateContent request in its log is a model turn, files changed in the working tree since the run started are its edits, and ps shows what it is waiting on. It cannot show agy's tool calls or reasoning.
/plugin install delegate-watch --marketplace EslamHanafy/delegate-watch
Or from a shell:
claude plugin marketplace add EslamHanafy/delegate-watch
claude plugin install delegate-watch@delegate-watch --scope user
/delegate-watch./delegate-watch pause and /delegate-watch resume stop and restart all polling. The choice is remembered across sessions. The Pause / Resume button in the pane does the same.Run folders live in different places depending on how you dispatch delegates, so the search folders are settings, not hard-coded. The defaults match the delegate relay scripts:
| Setting | Default | Meaning |
|---|---|---|
watchPaths | $TMPDIR/delegate-relay, .orchestrator, .superpowers | Folders to search for run folders. Absolute, or relative to the folder Claude Code was started in. $TMPDIR and ~ are expanded. |
searchDepth | 6 | How many folder levels below each watched folder to search. |
maxAgeHours | 6 | Only runs whose log changed within this many hours are shown. |
maxSessions | 3 | How many of the most recent runs get a chip at the top of the pane (1 to 12). |
timelineRows | 8 | How many timeline events show at once. 0 fits the pane height. The − / + buttons in the pane change it live and remember the choice. More rows cost a little more drawing per refresh, nothing else. |
In the pane itself, the − / + buttons next to the timeline filters change the number of timeline rows by 5 (down to auto, which fits the pane height). That choice is remembered across sessions and takes priority over timelineRows. Long thinking and message rows end in ▸; press it to read the full text, ▾ to fold it again. The tool-mix bar always spans the full pane width, with bar widths square-root scaled so small groups stay visible (the legend shows exact counts and percentages).
Change them any of these ways:
/config menu in Claude Code (each setting is a row under delegate-watch), or the prompt shown when you install the plugin.claude plugin configure delegate-watch@delegate-watch.~/.claude/settings.json:{
"pluginConfigs": {
"delegate-watch": {
"watchPaths": ["$TMPDIR/delegate-relay", ".my-runs", "~/agent-runs"],
"searchDepth": 4,
"maxAgeHours": 24,
"timelineRows": 30
}
}
}
Changes apply on the next /reload-plugins (or new session).
Any folder under a watched path that contains events.jsonl (cursor-agent, opencode) or agy.log (agy). The pane also reads result.json, final.txt, brief.txt and stderr.txt in that folder when present. If your delegate scripts write somewhere else, point watchPaths at it. Start Claude Code in the project whose delegates you want to watch, so relative paths resolve there.
No model calls and no tokens. It polls every 2 seconds (10 seconds when nothing is running), reads only the bytes added since the last poll of the selected run, and caps memory at 4,000 events per run. The find, git and ps calls run every 6 to 8 seconds, git and ps only for a running agy run.
Everything is local. It reads log files and runs find, tail, head, stat, git ls-files, pgrep and ps. It makes no network requests and sends nothing anywhere. The only thing it stores is the pause flag. The pane shows your delegates' output, which can contain code, paths and prompts, so treat screenshots accordingly. As with any plugin, read the code before installing; it is delegate-watch/hooks/.
macOS and Linux. Windows is not supported (it relies on sh, find, xargs, ps).
claude plugin validate delegate-watch
claude plugin test delegate-watch
After editing, run /reload-plugins. Bump the version in delegate-watch/.claude-plugin/plugin.json and .claude-plugin/marketplace.json for each release so installed copies update.
MIT
hooks/register.tsx 606 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Beat, Ev, Filter, Run } from '../types'
5import { FRESH_BEAT, nextBeat, orElse, withTimeout } from './guard'
6import {
7 MAX_EVENTS, agentOf, ago, beatLabel, blockedOf, childrenOf, clock, editsSince, glyph, mixOf, parseAgyLines, parseLines, parseStat, rel, expandCap, fitRows, fitText, spark, splitPath, startOf, tintOf, topLines, weightOf,
8} from './summarize'
9import type { Proc, Snap, Totals } from './summarize'
10
11const PANE = 'delegate-watch'
12const POLL_MS = 2000
13const SLOW_EVERY = 4
14const FIND_EVERY = 3
15const IDLE_EVERY = 5
16const ROWS_STEP = 5
17const ROWS_MAX = 200
18const CALL_MS = 10_000
19const PROC_GUARD_MS = 3_000
20const REDRAW_MS = 5_000
21const MAX_READ = 8 * 1024 * 1024
22const STALE_MS = 15 * 60 * 1000
23const STAT = "if [ \"$(uname)\" = Darwin ]; then xargs -0 stat -f '%m %N'; else xargs -0 stat -c '%Y %n'; fi"
24const GOOD = ['success', 'completed', 'done', 'finished']
25
26const runs = atom({ plugin: 'delegate-watch', key: 'runs' } as const, [] as Run[])
27const selected = atom({ plugin: 'delegate-watch', key: 'selected' } as const, null as string | null)
28const events = atom({ plugin: 'delegate-watch', key: 'events' } as const, [] as Ev[])
29// scroll: 0 follows the newest event (live); n > 0 pins the view to end at event n, so new events cannot move it
30const scroll = atom({ plugin: 'delegate-watch', key: 'scroll' } as const, 0)
31const paused = atom({ plugin: 'delegate-watch', key: 'paused' } as const, false)
32const filter = atom({ plugin: 'delegate-watch', key: 'filter' } as const, 'all' as Filter)
33const expanded = atom({ plugin: 'delegate-watch', key: 'expanded' } as const, [] as string[])
34const rowsPref = atom({ plugin: 'delegate-watch', key: 'rowsPref' } as const, -1)
35const beat = atom({ plugin: 'delegate-watch', key: 'beat' } as const, FRESH_BEAT as Beat)
36
37type Data = {
38 offset: number
39 carry: string
40 evs: Ev[]
41 totals: Totals
42 snap: Snap | null
43 note: string
44 blocked: string
45 procs: Proc[]
46}
47
48export const register: Register = (on, options) => {
49 const asList = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : typeof v === 'string' ? v.split(/[\n,]/) : [])
50 const watch = asList(options.watchPaths).map(x => x.trim()).filter(Boolean)
51 const depth = Math.min(12, Math.max(1, Math.floor(Number(options.searchDepth) || 6)))
52 const clampRows = (n: number) => Math.min(ROWS_MAX, Math.max(0, Math.floor(n) || 0))
53 const configRows = clampRows(Number(options.timelineRows ?? 8))
54 const maxRuns = Math.min(12, Math.max(1, Math.floor(Number(options.maxSessions) || 3)))
55 const maxMin = Math.max(1, Math.floor((Number(options.maxAgeHours) || 6) * 60))
56 const data = new Map<string, Data>()
57 const agents = new Map<string, Run['agent']>()
58 const results = new Map<string, { status: string; touched: number | null; model: string; started: number; finishedAt: number }>()
59 const finished = new Set<string>()
60 const announced = new Set<string>()
61 let isPolling = false
62 let hasOpened = false
63 let isPaneOpen = false
64 let tick = 0
65 let lastSig = ''
66 let cands: { dir: string; name: string; mtime: number }[] = []
67
68 on('session.start', async ($, e, next) => {
69 await $.command.register({
70 name: 'delegate-watch',
71 description: 'Open the live delegate pane, or pause / resume the watcher',
72 argumentHint: 'pause | resume',
73 })
74 await update($, runs, () => [])
75 await update($, events, () => [])
76 await update($, selected, () => null)
77 await update($, scroll, () => 0)
78 await update($, paused, () => false)
79 await update($, expanded, () => [])
80 await update($, beat, () => FRESH_BEAT)
81 if ((await $.store.get('paused')) === true) await update($, paused, () => true)
82 const savedRows = await $.store.get('timelineRows')
83 await update($, rowsPref, () => (typeof savedRows === 'number' ? clampRows(savedRows) : configRows))
84
85 // every host call goes through here: one that never answers rejects after `ms` instead of holding the poll forever
86 const call = <T,>(label: string, work: Promise<T>, ms = CALL_MS): Promise<T> => withTimeout((n, fn) => $.clock.after(n, fn), work, ms, label)
87 // a process gets its own timeout first, the guard only catches a call that does not even honour that
88 const exec = (label: string, argv: string[], init: { cwd?: string; timeoutMs: number }) =>
89 call(label, $.process.run(argv, init), init.timeoutMs + PROC_GUARD_MS)
90
91 // records how a poll ended; best effort, it must never throw or it would defeat the point of a heartbeat
92 const recordBeat = async (failure: unknown): Promise<void> => {
93 try {
94 const at = await call('clock', $.clock.now()).catch(() => Date.now())
95 await call('heartbeat', update($, beat, prev => nextBeat({ ...FRESH_BEAT, ...prev }, failure, at)))
96 } catch {
97 // nothing left to report to
98 }
99 }
100
101 // while the pane is open its age label has to keep counting even if the poll is the thing that stopped
102 $.clock.every(REDRAW_MS, () => {
103 if (isPaneOpen) $.ui.invalidate('ui.render')
104 })
105
106 $.clock.every(POLL_MS, async () => {
107 if (isPolling) return
108 isPolling = true
109 tick += 1
110 let failure: unknown = null
111 let isCounted = true
112 try {
113 if (await call('paused', read($, paused))) {
114 isCounted = false
115 return
116 }
117 const cwd = await call('cwd', $.session.cwd())
118 const tmp = ((await call('env', $.env.get('TMPDIR'))) ?? '/tmp').replace(/\/$/, '')
119 const wasLive = (await call('runs', read($, runs))).some(x => !x.isDone)
120 if (!wasLive && tick % IDLE_EVERY !== 0) return
121 const now = await call('clock', $.clock.now())
122 const isSlow = tick % SLOW_EVERY === 0
123
124 // discovery: one `find` for every run folder touched in the last 6 hours, refreshed every few ticks
125 if (cands.length === 0 || tick % FIND_EVERY === 0 || tick <= 1) {
126 const home = (await call('env', $.env.get('HOME'))) ?? ''
127 const roots = (watch.length > 0 ? watch : ['$TMPDIR/delegate-relay', '.orchestrator', '.superpowers']).map(x => {
128 const abs = x.replace(/^\$TMPDIR/, tmp).replace(/^~/, home)
129 return abs.startsWith('/') ? abs : `${cwd}/${abs}`
130 })
131 // a failing `find` is not "nothing running": it throws so the heartbeat shows it
132 const ran = await exec(
133 'find',
134 ['sh', '-c', `find ${roots.map(x => `'${x.replace(/'/g, '')}'`).join(' ')} -maxdepth ${depth} \\( -name events.jsonl -o -name agy.log \\) -mmin -${maxMin} -print0 2>/dev/null | ${STAT} 2>/dev/null`],
135 { timeoutMs: 8000 },
136 )
137 const seenDirs = new Set<string>()
138 cands = []
139 for (const l of ran.stdout.split('\n')) {
140 const sp = l.indexOf(' ')
141 const path = l.slice(sp + 1)
142 const dir = path.slice(0, path.lastIndexOf('/'))
143 if (sp < 1 || dir === '' || seenDirs.has(dir)) continue
144 seenDirs.add(dir)
145 cands.push({ dir, name: dir.slice(dir.lastIndexOf('/') + 1), mtime: Number(l.slice(0, sp)) * 1000 })
146 }
147 cands.sort((x, y) => y.mtime - x.mtime)
148 }
149
150 const found: Run[] = []
151 for (const c of cands.slice(0, maxRuns)) {
152 const files = await orElse(call('list', $.fs.list(c.dir)), [])
153 const names = files.map(f => f.name)
154 const logName = names.includes('agy.log') ? 'agy.log' : names.includes('events.jsonl') ? 'events.jsonl' : null
155 if (logName === null) continue
156 const log = files.find(f => f.name === logName)!
157 const brief = files.find(f => f.name === 'brief.txt')
158 let agent: Run['agent'] = agents.get(c.dir) ?? (logName === 'agy.log' ? 'agy' : 'unknown')
159 if (agent === 'unknown' && log.size > 0) {
160 const head = await exec('head', ['head', '-c', '1500', `${c.dir}/events.jsonl`], { timeoutMs: 3000 }).catch(() => null)
161 agent = agentOf(names, head?.stdout ?? '')
162 if (agent !== 'unknown') agents.set(c.dir, agent)
163 }
164 let status = 'running'
165 let model = ''
166 let started = 0
167 let finishedAt = 0
168 let touched: number | null = null
169 let isDone = false
170 if (names.includes('result.json')) {
171 isDone = true
172 let hit = results.get(c.dir)
173 if (!hit) {
174 try {
175 const result = JSON.parse(await call('read result', $.fs.read(`${c.dir}/result.json`)))
176 hit = {
177 status: String(result.status ?? 'done'),
178 touched: Array.isArray(result.touchedFiles) ? result.touchedFiles.length : null,
179 model: String(result.resolvedModel ?? result.model ?? ''),
180 started: Date.parse(result.startedAt ?? '') || 0,
181 finishedAt: Date.parse(result.finishedAt ?? '') || 0,
182 }
183 results.set(c.dir, hit)
184 } catch {
185 hit = { status: 'done', touched: null, model: '', started: 0, finishedAt: 0 }
186 }
187 }
188 status = hit.status
189 touched = hit.touched
190 model = hit.model
191 started = hit.started
192 finishedAt = hit.finishedAt
193 } else if (names.includes('final.txt')) {
194 isDone = true
195 status = 'finished'
196 } else if (now - log.mtimeMs > STALE_MS && agent !== 'agy') {
197 status = `stalled ${ago(now - log.mtimeMs)}`
198 }
199 const d = data.get(c.dir) ?? {
200 offset: 0, carry: '', evs: [], totals: { tools: 0, cost: 0, turns: 0, lastTurn: 0, think: '' }, snap: null, note: '', blocked: '', procs: [],
201 }
202 data.set(c.dir, d)
203 found.push({
204 dir: c.dir,
205 name: c.name,
206 agent,
207 model,
208 finishedAt,
209 startedAt: started || (brief?.mtimeMs ?? startOf(c.name) ?? log.mtimeMs),
210 updatedAt: log.mtimeMs,
211 status,
212 isDone,
213 touched,
214 tools: d.totals.tools,
215 cost: d.totals.cost,
216 note: d.note,
217 turns: d.totals.turns,
218 lastTurn: d.totals.lastTurn,
219 blocked: d.blocked,
220 procs: d.procs,
221 })
222 }
223
224 // pick the run to show: the user's choice, else the newest running, else the newest
225 let sel = await call('selected', read($, selected))
226 if (!found.some(r => r.dir === sel)) {
227 sel = (found.find(r => !r.isDone) ?? found[0])?.dir ?? null
228 await call('selected', update($, selected, () => sel))
229 await call('scroll', update($, scroll, () => 0))
230 }
231
232 // only the shown run is read, and only the bytes added since the last tick
233 const run = found.find(r => r.dir === sel)
234 if (run) {
235 const d = data.get(run.dir)!
236 const logFile = run.agent === 'agy' ? 'agy.log' : 'events.jsonl'
237 const files = await orElse(call('list', $.fs.list(run.dir)), [])
238 const size = files.find(f => f.name === logFile)?.size ?? 0
239 if (size > d.offset) {
240 const from = Math.max(d.offset, size - MAX_READ)
241 try {
242 const ran = await exec('tail', ['tail', '-c', `+${from + 1}`, `${run.dir}/${logFile}`], { timeoutMs: 8000 })
243 let text = d.carry + ran.stdout
244 if (d.offset === 0 && from > 0) text = text.slice(text.indexOf('\n') + 1)
245 const cut = text.lastIndexOf('\n')
246 d.carry = cut >= 0 ? text.slice(cut + 1) : text
247 const lines = cut >= 0 ? text.slice(0, cut).split('\n') : []
248 if (run.agent === 'agy') parseAgyLines(lines, d.evs, d.totals, new Date(now).getFullYear())
249 else parseLines(lines, d.evs, d.totals)
250 d.offset = size
251 d.note = ''
252 } catch {
253 d.note = 'cannot read the log here'
254 }
255 }
256 if (run.agent === 'agy' && (isSlow || d.snap === null)) {
257 const at = run.dir.indexOf('/.orchestrator/')
258 const repo = at > 0 ? run.dir.slice(0, at) : cwd
259 try {
260 if (d.snap === null || !run.isDone) {
261 const ran = await exec(
262 'git',
263 ['sh', '-c', `git ls-files -m -o --exclude-standard -z | ${STAT} 2>/dev/null`],
264 { cwd: repo, timeoutMs: 8000 },
265 )
266 const snap = parseStat(ran.stdout)
267 d.evs.push(...editsSince(d.snap, snap, run.startedAt, ''))
268 d.snap = snap
269 }
270 if (!run.isDone && !run.dir.includes("'")) {
271 const ps = await exec(
272 'ps',
273 ['sh', '-c', `pgrep -f '${run.dir}/[a]gy.log'; echo @@; ps -axo pid=,ppid=,etime=,command=`],
274 { timeoutMs: 5000 },
275 )
276 d.procs = childrenOf(ps.stdout)
277 d.blocked = blockedOf(await orElse(call('read stderr', $.fs.read(`${run.dir}/stderr.txt`)), ''))
278 } else {
279 d.procs = []
280 d.blocked = ''
281 }
282 } catch {
283 d.note = 'git or ps unavailable here'
284 }
285 }
286 if (d.evs.length > MAX_EVENTS) d.evs.splice(0, d.evs.length - MAX_EVENTS)
287 run.tools = run.agent === 'agy' ? new Set(d.evs.filter(x => x.kind === 'file').map(x => x.text)).size : d.totals.tools
288 run.cost = d.totals.cost
289 run.note = d.note
290 run.turns = d.totals.turns
291 run.lastTurn = d.totals.lastTurn
292 run.blocked = d.blocked
293 run.procs = d.procs
294 }
295
296 const sig = `${sel}|${run ? data.get(run.dir)!.evs.length : 0}|${run?.blocked}|${run?.procs.map(x => x.age + x.cmd).join(',')}|${found.map(r => r.dir + r.status + r.updatedAt).join(',')}`
297 if (sig !== lastSig) {
298 await call('write runs', update($, runs, () => found))
299 if (run) {
300 const list = data.get(run.dir)!.evs.slice().sort((a, b) => a.t - b.t)
301 await call('write events', update($, events, () => list))
302 }
303 // remembered only once both writes landed, so a failed write is retried on the next tick
304 lastSig = sig
305 }
306
307 for (const r of found) {
308 if (!r.isDone && !finished.has(r.dir) && !announced.has(r.dir)) {
309 announced.add(r.dir)
310 hasOpened = true
311 isPaneOpen = true
312 void $.ui.open({ id: PANE, title: 'Delegates' }).then(o => {
313 if (!o.isPlaced) $.ui.toast(`delegate-watch: run started, type /delegate-watch to open the pane (${o.reason})`)
314 })
315 }
316 if (r.isDone && !finished.has(r.dir)) {
317 finished.add(r.dir)
318 if (hasOpened) $.ui.toast(`${r.agent} ${r.name}: ${r.status}`)
319 }
320 }
321 const live = found.filter(r => !r.isDone).length
322 $.ui.status(live > 0 ? `${live} delegate${live > 1 ? 's' : ''} running` : undefined)
323 } catch (err) {
324 // whatever broke (a hung host call, a bad read) ends this poll only; the next tick starts clean
325 failure = err
326 } finally {
327 isPolling = false
328 if (isCounted) await recordBeat(failure)
329 }
330 })
331 return next(e)
332 })
333
334 on('command.run', { command: 'delegate-watch' }, async ($, e) => {
335 const arg = e.args.trim().toLowerCase()
336 if (arg === 'pause' || arg === 'stop') {
337 await update($, paused, () => true)
338 await $.store.set('paused', true)
339 $.ui.status(undefined)
340 return { text: 'Delegate watcher paused. It reads no files until you run /delegate-watch resume.' }
341 }
342 if (arg === 'resume' || arg === 'start') {
343 await update($, paused, () => false)
344 await $.store.set('paused', false)
345 lastSig = ''
346 cands = []
347 isPaneOpen = true
348 await $.ui.open({ id: PANE, title: 'Delegates' })
349 return { text: 'Delegate watcher resumed.' }
350 }
351 isPaneOpen = true
352 const opened = await $.ui.open({ id: PANE, title: 'Delegates' })
353 return { text: opened.isPlaced ? 'Delegates pane opened.' : `Delegates pane is open but not drawn: ${opened.reason}` }
354 })
355
356 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
357 const { Box, Text, Button } = $.ui.resolve(e)
358 const list: Run[] = (await read($, runs)).map((x: Partial<Run>) => ({
359 dir: String(x.dir ?? ''), name: String(x.name ?? ''), agent: x.agent ?? 'unknown', model: x.model ?? '',
360 startedAt: x.startedAt ?? 0, finishedAt: x.finishedAt ?? 0, updatedAt: x.updatedAt ?? 0,
361 status: String(x.status ?? 'running'), isDone: x.isDone ?? false, touched: x.touched ?? null,
362 tools: x.tools ?? 0, cost: x.cost ?? 0, note: x.note ?? '', turns: x.turns ?? 0, lastTurn: x.lastTurn ?? 0,
363 blocked: x.blocked ?? '',
364 procs: (Array.isArray(x.procs) ? x.procs : []).filter(
365 (q): q is Proc => typeof q === 'object' && q !== null && typeof q.age === 'string' && typeof q.cmd === 'string',
366 ),
367 }))
368 const sel = await read($, selected)
369 const all: Ev[] = (await read($, events))
370 .filter((x: Partial<Ev>) => typeof x === 'object' && x !== null && typeof x.text === 'string')
371 .map((x: Ev) => ({ ...x, tag: typeof x.tag === 'string' ? x.tag : x.kind }))
372 const back = await read($, scroll)
373 const f = await read($, filter)
374 const isPaused = await read($, paused)
375 const now = await $.clock.now()
376 const pulse = beatLabel({ ...FRESH_BEAT, ...(await read($, beat)) }, now)
377 const run = list.find(r => r.dir === sel)
378 const isAgy = run?.agent === 'agy'
379 const open = new Set(await read($, expanded))
380 const flip = (id: string) => update($, expanded, l => (l.includes(id) ? [] : [id]))
381 const stored = await read($, rowsPref)
382 const timelineRows = stored < 0 ? configRows : stored
383 const setRows = (n: number) => {
384 const v = clampRows(n)
385 void update($, rowsPref, () => v)
386 void $.store.set('timelineRows', v)
387 }
388 const keep = (x: Ev) =>
389 f === 'all' || (f === 'tools' ? x.kind === 'tool' || x.kind === 'file' || x.kind === 'err' : f === 'say' ? x.kind === 'say' || x.kind === 'think' : x.kind === 'turn')
390 const shown = all.filter(keep)
391 const isLive = back === 0
392 const end = isLive ? shown.length : Math.min(back, shown.length)
393 const mix = mixOf(all)
394 const idOf = (x: Ev) => `${x.t}|${x.kind}|${x.text.slice(0, 48)}`
395 const used = topLines({
396 hasRuns: list.length > 0, hasRun: run !== undefined, isDone: run?.isDone ?? true, isAgy, hasBlocked: Boolean(run?.blocked),
397 activeProcs: run?.procs.filter(x => !x.isHelper).length ?? 0, helperProcs: run?.procs.filter(x => x.isHelper).length ?? 0,
398 hasNote: Boolean(run?.note), mixTags: mix.length, hasSpark: all.length > 1,
399 })
400 const cols = e.viewport?.columns ?? 60
401 const budget = (e.viewport?.rows ?? 40) - used - 1
402 const textCols = cols - 44
403 const rows = timelineRows > 0 ? timelineRows : fitRows(shown, end, budget, open, idOf, textCols)
404 const view = shown.slice(Math.max(0, end - rows), end)
405 const step = Math.max(1, Math.floor(rows / 2))
406 const brand = { agy: '#60a5fa', cursor: '#94a3b8', opencode: '#34d399', unknown: 'gray' }
407 const tone = !run
408 ? 'gray'
409 : run.status === 'running' || run.status.startsWith('stalled')
410 ? 'yellow'
411 : GOOD.includes(run.status)
412 ? 'green'
413 : 'red'
414 const mark = (r: Run) => (!r.isDone ? '●' : GOOD.includes(r.status) ? '✓' : '✗')
415 const pick = (r: Run) => {
416 void update($, selected, () => r.dir)
417 void update($, scroll, () => 0)
418 void update($, events, () => [])
419 void update($, expanded, () => [])
420 }
421 const tab = (label: string, value: Filter) => (
422 <Button key={value} variant={f === value ? 'primary' : 'secondary'} onPress={() => {
423 void update($, scroll, () => 0)
424 void update($, filter, () => value)
425 }}>
426 {label}
427 </Button>
428 )
429 const tile = (label: string, value: string, color?: string) => (
430 <Box key={label} flexDirection="column" borderStyle="round" borderColor="gray" borderDimColor paddingX={1} minWidth={11}>
431 <Text dimColor>{label}</Text>
432 <Text bold color={color}>{value}</Text>
433 </Box>
434 )
435 const badge = (tag: string, color: string) => (
436 <Text bold inverse color={color}>{` ${tag.toUpperCase().slice(0, 6).padEnd(6)} `}</Text>
437 )
438 const toggle = async () => {
439 const next = !isPaused
440 await update($, paused, () => next)
441 await $.store.set('paused', next)
442 if (next) $.ui.status(undefined)
443 else {
444 lastSig = ''
445 cands = []
446 }
447 }
448 const mixTotal = mix.reduce((n, [, c]) => n + c, 0)
449 const idleFor = run && run.lastTurn > 0 ? ago(now - run.lastTurn) : null
450 const span = (run ? (run.isDone && run.finishedAt > 0 ? run.finishedAt : run.isDone ? run.updatedAt : now) : now) - (run?.startedAt ?? now)
451
452 return (
453 <Box flexDirection="column" gap={1}>
454 <Box justifyContent="space-between">
455 <Text bold color={isPaused ? 'yellow' : 'green'}>{isPaused ? '⏸ Watcher paused, not reading any files' : '● Watching delegates'}</Text>
456 <Box gap={1} flexShrink={1} minWidth={0}>
457 {isPaused ? null : <Text color={pulse.color} bold={pulse.isWarn} wrap="truncate-end">{pulse.text}</Text>}
458 <Button key="pause" variant={isPaused ? 'primary' : 'secondary'} onPress={toggle}>{isPaused ? 'Resume' : 'Pause'}</Button>
459 </Box>
460 </Box>
461
462 {list.length === 0 && (
463 <Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
464 <Text bold>No delegate runs yet</Text>
465 <Text dimColor>Dispatch one with /agy-delegate, /cursor-delegate or /opencode-delegate and it shows up here.</Text>
466 </Box>
467 )}
468
469 <Box gap={1} flexWrap="wrap">
470 {list.map(r => (
471 <Button key={r.dir} variant={r.dir === sel ? 'primary' : 'secondary'} onPress={() => pick(r)}>
472 {`${mark(r)} ${r.agent} · ${r.name}`}
473 </Button>
474 ))}
475 </Box>
476
477 {run && (
478 <Box flexDirection="column" borderStyle="round" borderColor={tone} paddingX={2} paddingY={1} gap={1}>
479 <Box justifyContent="space-between">
480 <Box gap={1}>
481 {badge(run.agent, brand[run.agent])}
482 <Text bold>{run.name}</Text>
483 {run.model ? <Text dimColor>{run.model}</Text> : null}
484 </Box>
485 <Text bold inverse color={tone}>{` ${mark(run)} ${run.status.toUpperCase()} `}</Text>
486 </Box>
487
488 <Box gap={1} flexWrap="wrap">
489 {tile('ELAPSED', ago(span))}
490 {isAgy ? tile('MODEL TURNS', String(run.turns)) : tile('TOOL CALLS', String(run.tools))}
491 {isAgy ? tile('FILES EDITED', String(run.tools)) : tile('COST', run.cost > 0 ? `$${run.cost.toFixed(2)}` : '—')}
492 {run.touched !== null ? tile('IN DIFF', String(run.touched)) : null}
493 </Box>
494
495 {!run.isDone && isAgy && (
496 <Box flexDirection="column">
497 {run.blocked ? (
498 <Text color="yellow" bold>⏸ Idle · blocked on a background command</Text>
499 ) : (
500 <Text color="green" bold>{idleFor ? `● Model active ${idleFor} ago` : '○ Waiting for the first model turn'}</Text>
501 )}
502 {run.blocked ? <Text dimColor>{run.blocked}</Text> : null}
503 {run.procs.filter(x => !x.isHelper).length === 0 && !run.blocked ? (
504 <Text dimColor>{' '}no command running, the model is thinking</Text>
505 ) : null}
506 {run.procs.filter(x => !x.isHelper).map((p, i) => (
507 <Box key={`p${i}`} gap={1}>
508 <Text color="green">{' ▸'}</Text>
509 <Text dimColor>{p.age.padStart(6)}</Text>
510 <Text wrap="truncate-end">{p.cmd}</Text>
511 </Box>
512 ))}
513 {run.procs.filter(x => x.isHelper).map((p, i) => (
514 <Box key={`h${i}`} gap={1}>
515 <Text dimColor>{' ·'}</Text>
516 <Text dimColor>{p.age.padStart(6)}</Text>
517 <Text dimColor wrap="truncate-end">{p.cmd} (helper)</Text>
518 </Box>
519 ))}
520 </Box>
521 )}
522 {!run.isDone && !isAgy && <Text color="green" bold>● Log updated {ago(now - run.updatedAt)} ago</Text>}
523 {run.note ? <Text color="red">{run.note}</Text> : null}
524
525 {mixTotal > 0 && (
526 <Box flexDirection="column">
527 <Box width="100%" height={1} overflow="hidden">
528 {mix.map(([tag, n]) => (
529 <Box key={tag} flexGrow={weightOf(n)} minWidth={1} height={1} backgroundColor={tintOf(tag)} />
530 ))}
531 </Box>
532 <Box gap={2} flexWrap="wrap">
533 {mix.map(([tag, n]) => (
534 <Text key={tag}><Text color={tintOf(tag)}>■</Text> {tag} <Text dimColor>{n} · {Math.round((n / mixTotal) * 100)}%</Text></Text>
535 ))}
536 </Box>
537 </Box>
538 )}
539 {all.length > 1 && (
540 <Box gap={1}>
541 <Text dimColor>activity, last 30 min</Text>
542 <Text color={tone}>{spark(all, now, 30)}</Text>
543 </Box>
544 )}
545 </Box>
546 )}
547
548 <Box gap={1} flexWrap="wrap">
549 {tab('All', 'all')}
550 {isAgy ? tab('Turns', 'turns') : tab('Tools', 'tools')}
551 {isAgy ? tab('Edits', 'tools') : tab('Thoughts', 'say')}
552 <Button key="older" onPress={() => update($, scroll, () => Math.max(Math.min(rows, shown.length), end - step))}>↑</Button>
553 <Button key="newer" onPress={() => update($, scroll, () => (isLive || end + step >= shown.length ? 0 : end + step))}>↓</Button>
554 <Button key="live" variant={isLive ? 'primary' : 'secondary'} onPress={() => update($, scroll, () => 0)}>
555 {isLive ? '● Live' : shown.length > end ? `Jump to live (+${shown.length - end})` : 'Jump to live'}
556 </Button>
557 <Box gap={1}>
558 <Button key="rows-less" onPress={() => setRows(timelineRows - ROWS_STEP)}>−</Button>
559 <Text dimColor>{timelineRows > 0 ? `${timelineRows} rows` : 'auto rows'}</Text>
560 <Button key="rows-more" onPress={() => setRows(timelineRows + ROWS_STEP)}>+</Button>
561 </Box>
562 </Box>
563
564 <Box flexDirection="column">
565 <Box marginBottom={1}>
566 <Text bold dimColor>
567 TIMELINE{shown.length === 0 ? ' · no events yet' : ` · ${Math.max(1, end - view.length + 1)}–${end} of ${shown.length}`}
568 </Text>
569 </Box>
570 {view.map((x, i) => {
571 const isPath = x.kind === 'tool' || x.kind === 'file'
572 const { dir, name } = isPath ? splitPath(x.text) : { dir: '', name: x.text }
573 const color = x.kind === 'err' ? 'red' : tintOf(x.tag)
574 const isProse = x.kind === 'say' || x.kind === 'think' || x.kind === 'turn'
575 const id = idOf(x)
576 const isOpen = open.has(id)
577 const isLong = isProse && (x.text.length > 90 || x.text.includes('\n'))
578 return (
579 <Box key={`e${i}`} gap={1} marginTop={i === 0 ? 0 : 0}>
580 <Box width={e.surface === 'terminal' ? 18 : 13} flexShrink={0}>
581 <Text dimColor>{x.t > 0 ? clock(x.t) : '--:--:--'}{' '}</Text>
582 <Text color={color}>({run && x.t > 0 ? rel(x.t - run.startedAt) : '--:--'})</Text>
583 </Box>
584 <Text color={color} bold>▌</Text>
585 <Box width={10} flexShrink={0}>
586 <Text color={color} bold>{glyph(x.kind)} </Text>
587 <Text bold inverse color={color}>{` ${x.tag.toUpperCase().slice(0, 6).padEnd(6)} `}</Text>
588 </Box>
589 <Box width={3} flexShrink={0}>
590 {isLong ? <Button key={`x${i}`} onPress={() => flip(id)}>{isOpen ? '▾' : '▸'}</Button> : null}
591 </Box>
592 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
593 <Text wrap={isProse && (isOpen || !isLong) ? 'wrap' : 'truncate-end'} bold={isPath} italic={x.kind === 'think'} dimColor={isProse} color={x.kind === 'err' ? 'red' : undefined}>
594 {isOpen && timelineRows === 0 ? fitText(name, expandCap(budget), textCols) : name}
595 </Text>
596 {dir ? <Text dimColor wrap="truncate-middle">{dir}</Text> : null}
597 </Box>
598 </Box>
599 )
600 })}
601 </Box>
602 </Box>
603 )
604 })
605}
606hooks/guard.ts 52 lines1import type { Beat } from '../types'
2
3// the one timer shape the guard needs, so it can be tested without the engine (`$.clock.after` fits)
4export type After = (ms: number, fn: () => void) => { cancel: () => void }
5
6const TIMEOUT = 'TimeoutError'
7const ERROR_WIDTH = 120
8
9// settles like `work`, but rejects with a TimeoutError once `ms` pass first, so one host call that never answers cannot hold the poll
10export const withTimeout = <T>(after: After, work: Promise<T>, ms: number, label: string): Promise<T> =>
11 new Promise<T>((resolve, reject) => {
12 const timer = after(ms, () => {
13 const err = new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)
14 err.name = TIMEOUT
15 reject(err)
16 })
17 work.then(
18 v => {
19 timer.cancel()
20 resolve(v)
21 },
22 err => {
23 timer.cancel()
24 reject(err)
25 },
26 )
27 })
28
29export const isTimeout = (err: unknown): boolean => err instanceof Error && err.name === TIMEOUT
30
31// a fallback for a call that may fail (a missing file, a folder that vanished), but never for a call that hung: that must reach the heartbeat
32export const orElse = <T>(work: Promise<T>, fallback: T): Promise<T> =>
33 work.catch(err => {
34 if (isTimeout(err)) throw err
35 return fallback
36 })
37
38// one line of text for any thrown value
39export const errorText = (err: unknown): string => {
40 const raw = err instanceof Error ? err.message : String(err)
41 const one = raw.replace(/\s+/g, ' ').trim()
42 return one.length > ERROR_WIDTH ? `${one.slice(0, ERROR_WIDTH - 1)}…` : one
43}
44
45export const FRESH_BEAT: Beat = { okAt: 0, ticks: 0, errors: 0, error: '', errorAt: 0 }
46
47// the heartbeat after one poll ended at `at`: `failure` is what it threw, or null when it completed
48export const nextBeat = (prev: Beat, failure: unknown, at: number): Beat =>
49 failure === null
50 ? { ...prev, ticks: prev.ticks + 1, okAt: at }
51 : { ...prev, ticks: prev.ticks + 1, errors: prev.errors + 1, errorAt: at, error: errorText(failure) }
52hooks/summarize.ts 328 lines1import type { Beat, Ev, Run } from '../types'
2
3const WIDTH = 200
4export const MAX_EVENTS = 4000
5
6const clip = (text: string): string => {
7 const one = text.replace(/\s+/g, ' ').trim()
8 return one.length > WIDTH ? `${one.slice(0, WIDTH - 1)}…` : one
9}
10
11// messages keep their full text (bounded) so the pane can expand them; the row itself truncates
12const clipLong = (text: string): string => {
13 const one = text.replace(/\s+/g, ' ').trim()
14 return one.length > 2000 ? `${one.slice(0, 1999)}…` : one
15}
16
17const detailOf = (input: unknown): string => {
18 if (typeof input !== 'object' || input === null) return ''
19 const o = input as Record<string, any>
20 const pick = o.filePath ?? o.path ?? o.file_path ?? o.command ?? o.pattern ?? o.query ?? o.description ?? o.taskId
21 return typeof pick === 'string' ? pick : ''
22}
23
24export type Totals = { tools: number; cost: number; turns: number; lastTurn: number; think: string }
25
26const shorten = (p: string): string => {
27 const rel = p.replace(/^\/Users\/[^/]+\/(?:[^/]+\/)*?(?=(?:lib|test|src|backend|frontend|docs|app|packages|assets|scripts|tests)\/)/, '')
28 if (rel !== p) return rel
29 const parts = p.split('/')
30 return parts.length > 4 ? `…/${parts.slice(-3).join('/')}` : p
31}
32
33const firstLine = (text: string): string => clip((text.split('\n')[0] ?? '').trim())
34
35export const parseLines = (lines: string[], out: Ev[], totals: Totals): void => {
36 for (const raw of lines) {
37 if (!raw.startsWith('{')) continue
38 let ev: any
39 try {
40 ev = JSON.parse(raw)
41 } catch {
42 continue
43 }
44 const t = Number(ev.timestamp_ms ?? ev.timestamp ?? 0)
45 if (ev.type === 'step_finish' && typeof ev.part?.cost === 'number') {
46 totals.cost += ev.part.cost
47 } else if (ev.type === 'tool_use' && ev.part) {
48 const st = ev.part.state
49 const detail = detailOf(st?.input)
50 if (st?.status === 'error') {
51 out.push({ t, kind: 'err', tag: String(ev.part.tool), text: clip(`${shorten(detail)} ${String(st.error ?? '')}`) })
52 } else if (st?.status === 'completed') {
53 totals.tools += 1
54 out.push({ t, kind: 'tool', tag: String(ev.part.tool), text: shorten(firstLine(detail)) })
55 }
56 } else if (ev.type === 'text' && typeof ev.part?.text === 'string' && ev.part.text.trim()) {
57 out.push({ t, kind: 'say', tag: 'say', text: clipLong(ev.part.text) })
58 } else if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
59 const text = ev.message.content.find((p: any) => p?.type === 'text')?.text
60 if (typeof text === 'string' && text.trim()) out.push({ t, kind: 'say', tag: 'say', text: clipLong(text) })
61 } else if (ev.type === 'thinking') {
62 if (ev.subtype === 'delta' && typeof ev.text === 'string') totals.think += ev.text
63 if (ev.subtype === 'completed') {
64 if (totals.think.trim()) out.push({ t, kind: 'think', tag: 'think', text: clipLong(totals.think) })
65 totals.think = ''
66 }
67 } else if (ev.type === 'tool_call' && ev.subtype === 'started' && ev.tool_call) {
68 const key = Object.keys(ev.tool_call)[0] ?? 'tool'
69 const args = ev.tool_call[key]?.args
70 const detail = detailOf(args) || (typeof args?.description === 'string' ? args.description : '')
71 totals.tools += 1
72 out.push({ t, kind: 'tool', tag: key.replace(/ToolCall$/, ''), text: shorten(firstLine(detail)) })
73 }
74 }
75}
76
77export const agentOf = (names: string[], head: string): Run['agent'] => {
78 if (names.includes('agy.log')) return 'agy'
79 if (head.includes('"session_id"')) return 'cursor'
80 if (head.includes('"sessionID"')) return 'opencode'
81 return 'unknown'
82}
83
84const START = /-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/
85
86export const startOf = (name: string): number | null => {
87 const m = START.exec(name)
88 return m ? Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`) : null
89}
90
91export const ago = (ms: number): string => {
92 const s = Math.max(0, Math.round(ms / 1000))
93 if (s < 60) return `${s}s`
94 const m = Math.floor(s / 60)
95 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
96}
97
98// longer than this since the last completed poll and the watcher looks stalled (a poll normally ends every 2s, every 10s when idle)
99export const STALL_MS = 30_000
100
101export type BeatLabel = { text: string; color: string; isWarn: boolean }
102
103// the watcher's pulse as one line for the pane header
104export const beatLabel = (b: Beat, now: number): BeatLabel => {
105 if (b.errorAt > b.okAt) return { text: `⚠ poll failed ${ago(now - b.errorAt)} ago: ${b.error}`, color: 'red', isWarn: true }
106 if (b.ticks === 0 || b.okAt === 0) return { text: 'starting…', color: 'gray', isWarn: false }
107 const age = now - b.okAt
108 return age > STALL_MS ? { text: `⚠ stalled, last refresh ${ago(age)} ago`, color: 'yellow', isWarn: true } : { text: `refreshed ${ago(age)} ago`, color: 'gray', isWarn: false }
109}
110
111export const clock = (ms: number): string => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : ' ')
112
113export type Snap = Map<string, string>
114
115// `mtime path` lines (seconds since the epoch) of the files git reports modified or untracked
116export const parseStat = (out: string): Snap => {
117 const snap: Snap = new Map()
118 for (const l of out.split('\n')) {
119 const sp = l.indexOf(' ')
120 if (sp > 0) snap.set(l.slice(sp + 1), l.slice(0, sp))
121 }
122 return snap
123}
124
125export const editsSince = (prev: Snap | null, next: Snap, since: number, root: string): Ev[] => {
126 const out: Ev[] = []
127 for (const [p, m] of next) {
128 const t = Number(m) * 1000
129 if (t >= since && prev?.get(p) !== m) out.push({ t, kind: 'file', tag: 'edit', text: p.startsWith(root) ? p.slice(root.length + 1) : p })
130 }
131 return out.sort((a, b) => a.t - b.t)
132}
133
134const GLOG = /^[IWEF](\d{2})(\d{2}) (\d{2}):(\d{2}):(\d{2})\.(\d{3})/
135
136// agy's own log: each model turn is one streamGenerateContent request
137export const parseAgyLines = (lines: string[], out: Ev[], totals: Totals, year: number): void => {
138 for (const l of lines) {
139 if (!l.includes('streamGenerateContent')) continue
140 const m = GLOG.exec(l)
141 if (!m) continue
142 const t = new Date(year, Number(m[1]) - 1, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6])).getTime()
143 totals.turns += 1
144 totals.lastTurn = t
145 out.push({ t, kind: 'turn', tag: 'turn', text: `model turn ${totals.turns}` })
146 }
147}
148
149const WAITING = /(idle; waiting.*|waiting up to.*background task.*)$/
150
151export const blockedOf = (stderr: string): string => {
152 const lines = stderr.split('\n').filter(l => l.trim())
153 const last = lines[lines.length - 1] ?? ''
154 const m = WAITING.exec(last)
155 return m ? clip(m[1] ?? '') : ''
156}
157
158export type Proc = { age: string; cmd: string; isHelper: boolean }
159
160const HELPER = /(mcp-server|language[-_]server|analysis[_-]server|VBCSCompiler|dartaotruntime|kotlin-daemon|gradle.*daemon|nodeReuse)/i
161const SHELL = /^(?:\S*\/)?(?:bash|sh|zsh|dash)(?=\s|$)/
162const PREAMBLE = [
163 /^source\s+\S+(?:\s+2>\/dev\/null)?(?:\s*\|\|\s*true)?\s*&&\s*/,
164 /^setopt\s[^&;]*?(?:\|\|\s*true)?\s*&&\s*/,
165 /^\{[^}]*\}\s*(?:>\/dev\/null\s*2>&1)?\s*(?:\|\|\s*true)?\s*&&\s*/,
166 /^eval\s+'?/,
167 /^set\s+-[a-z]+(?:\s+pipefail)?\s*[;&]+\s*/,
168 /^cd\s+\S+\s*&&\s*/,
169 /^export\s+\S+\s*[;&]+\s*/,
170]
171
172const etimeSec = (e: string): number => {
173 const [d, rest] = e.includes('-') ? e.split('-') : ['0', e]
174 const t = (rest ?? '').split(':').map(Number)
175 while (t.length < 3) t.unshift(0)
176 return Number(d) * 86400 + t[0]! * 3600 + t[1]! * 60 + t[2]!
177}
178
179const tidy = (raw: string): string => {
180 let c = raw.trim()
181 const sh = SHELL.exec(c)
182 if (sh) {
183 const rest = c.slice(sh[0].length).trim()
184 const dash = /^(?:-\w+\s+)*-c\s+([\s\S]*)$/.exec(rest)
185 if (dash) {
186 c = dash[1]!
187 for (let i = 0, prev = ''; prev !== c && i < 12; i++) {
188 prev = c
189 for (const re of PREAMBLE) c = c.replace(re, '')
190 }
191 } else if (rest) {
192 const file = rest.split(/\s+/)[0]!
193 c = `script ${file.slice(file.lastIndexOf('/') + 1)}`
194 }
195 }
196 c = c.replace(/^\/\S*\/(?=[^\s/]+(?:\s|$))/, '').replace(/\/Users\/[^/\s]+\//g, '~/')
197 return clip(c).slice(0, 110)
198}
199
200// what is running beneath the agy process, from `pgrep` pids and a `ps` listing: real commands first, long-lived helpers last
201export const childrenOf = (out: string): Proc[] => {
202 const [pidPart = '', psPart = ''] = out.split('@@\n')
203 const roots = new Set(pidPart.split('\n').map(x => x.trim()).filter(Boolean))
204 const rows = psPart.split('\n').map(l => /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(l)).filter((m): m is RegExpExecArray => m !== null)
205 const keep = new Set(roots)
206 for (let grew = true; grew; ) {
207 grew = false
208 for (const m of rows) {
209 if (keep.has(m[2]!) && !keep.has(m[1]!)) {
210 keep.add(m[1]!)
211 grew = true
212 }
213 }
214 }
215 const found = rows
216 .filter(m => keep.has(m[1]!) && !roots.has(m[1]!) && !/^\(.*\)$/.test(m[4]!.trim()))
217 .map(m => ({ sec: etimeSec(m[3]!), raw: m[4]! }))
218 .map(x => ({ ...x, cmd: tidy(x.raw) }))
219 .filter(x => x.cmd !== '' && !/^(ps|pgrep|find|sleep)\b/.test(x.cmd) && !x.raw.includes('pgrep -f'))
220 .map(x => ({ sec: x.sec, cmd: x.cmd, isHelper: HELPER.test(x.raw) }))
221 const act = found.filter(x => !x.isHelper).sort((a, b) => b.sec - a.sec).slice(0, 5)
222 const help = found.filter(x => x.isHelper).sort((a, b) => b.sec - a.sec).slice(0, 3)
223 return [...act, ...help].map(x => ({ age: ago(x.sec * 1000), cmd: x.cmd, isHelper: x.isHelper }))
224}
225
226const BARS = '▁▂▃▄▅▆▇█'
227
228// events per minute over the last `minutes`, as block characters
229export const spark = (evs: Ev[], now: number, minutes: number): string => {
230 const b = new Array<number>(minutes).fill(0)
231 for (const e of evs) {
232 const i = minutes - 1 - Math.floor((now - e.t) / 60000)
233 if (e.t > 0 && i >= 0 && i < minutes) b[i]! += 1
234 }
235 const max = Math.max(1, ...b)
236 return b.map(n => (n === 0 ? '·' : BARS[Math.min(7, Math.ceil((n / max) * 7))]!)).join('')
237}
238
239export const rel = (ms: number): string => {
240 const s = Math.max(0, Math.round(ms / 1000))
241 const mm = String(Math.floor(s / 60) % 60).padStart(2, '0')
242 const ss = String(s % 60).padStart(2, '0')
243 return s >= 3600 ? `${Math.floor(s / 3600)}:${mm}:${ss}` : `${mm}:${ss}`
244}
245
246export const glyph = (kind: string): string => ({ tool: '▸', file: '✎', turn: '◆', say: '”', think: '✦', err: '✗' })[kind] ?? '·'
247
248export const splitPath = (text: string): { dir: string; name: string } => {
249 const cut = text.lastIndexOf('/')
250 return cut > 0 ? { dir: text.slice(0, cut + 1), name: text.slice(cut + 1) } : { dir: '', name: text }
251}
252
253export const mixOf = (evs: Ev[]): [string, number][] => {
254 const c = new Map<string, number>()
255 for (const e of evs) if (e.kind === 'tool' || e.kind === 'file' || e.kind === 'turn') c.set(e.tag, (c.get(e.tag) ?? 0) + 1)
256 return [...c.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)
257}
258
259const TINT: Record<string, string> = {
260 read: 'cyan', grep: '#60a5fa', glob: '#a78bfa', ls: '#38bdf8', edit: 'yellow', write: 'yellow', patch: 'yellow', apply_patch: 'yellow',
261 shell: '#fb923c', bash: '#fb923c', turn: '#60a5fa', think: 'gray', say: 'green', await: 'gray',
262}
263export const tintOf = (tag: string): string => TINT[tag.toLowerCase()] ?? 'white'
264
265// bar weights: square-root scaled so a few huge groups do not squeeze the small ones to nothing
266export const weightOf = (n: number): number => Math.sqrt(Math.max(0, n))
267
268export type Layout = {
269 hasRuns: boolean
270 hasRun: boolean
271 isDone: boolean
272 isAgy: boolean
273 hasBlocked: boolean
274 activeProcs: number
275 helperProcs: number
276 hasNote: boolean
277 mixTags: number
278 hasSpark: boolean
279}
280
281// lines the views above the timeline occupy (root gap 1 between children), so auto rows can use exactly what is left
282export const topLines = (l: Layout): number => {
283 const parts: number[] = [1]
284 if (!l.hasRuns) parts.push(4)
285 parts.push(1)
286 if (l.hasRun) {
287 const inner: number[] = [1, 4]
288 if (!l.isDone && l.isAgy) inner.push(1 + (l.hasBlocked ? 1 : 0) + Math.max(l.activeProcs, l.hasBlocked || l.activeProcs > 0 ? 0 : 1) + l.helperProcs)
289 if (!l.isDone && !l.isAgy) inner.push(1)
290 if (l.hasNote) inner.push(1)
291 if (l.mixTags > 0) inner.push(1 + (l.mixTags > 4 ? 2 : 1))
292 if (l.hasSpark) inner.push(1)
293 parts.push(4 + inner.reduce((n, x) => n + x, 0) + inner.length - 1)
294 }
295 parts.push(1, 2)
296 return parts.reduce((n, x) => n + x, 0) + parts.length - 1 - 1
297}
298
299// an expanded message never takes more than this share of the timeline lines, so the other rows keep their place
300export const expandCap = (budget: number): number => Math.max(3, Math.floor(budget * 0.6))
301
302// the text an expanded row shows: all of it unless it would not fit in `maxLines`, then cut with a marker
303export const fitText = (text: string, maxLines: number, textCols: number): string => {
304 const room = Math.max(10, textCols) * maxLines
305 return text.length > room ? `${text.slice(0, room - 2)} …` : text
306}
307
308// terminal lines one timeline row takes: path rows have a folder line, prose wraps only when expanded
309export const rowLines = (x: Ev, isOpen: boolean, textCols: number, maxLines: number): number => {
310 if (x.kind === 'tool' || x.kind === 'file') return splitPath(x.text).dir ? 2 : 1
311 if (!isOpen) return 1
312 return Math.min(maxLines, Math.max(1, Math.ceil(x.text.length / Math.max(10, textCols))))
313}
314
315// how many of the newest rows (ending at `end`) fit in `budget` lines, at least 3
316export const fitRows = (shown: Ev[], end: number, budget: number, open: Set<string>, idOf: (x: Ev) => string, textCols: number): number => {
317 const maxLines = expandCap(budget)
318 let used = open.size > 0 ? 1 : 0
319 let n = 0
320 for (let i = end - 1; i >= 0; i--) {
321 const h = rowLines(shown[i]!, open.has(idOf(shown[i]!)), textCols, maxLines)
322 if (used + h > budget) break
323 used += h
324 n += 1
325 }
326 return Math.max(3, n)
327}
328types/index.d.ts 54 lines1export type Ev = {
2 t: number
3 kind: 'tool' | 'say' | 'think' | 'err' | 'file' | 'turn'
4 tag: string
5 text: string
6}
7
8export type Run = {
9 dir: string
10 name: string
11 agent: 'agy' | 'cursor' | 'opencode' | 'unknown'
12 model: string
13 startedAt: number
14 finishedAt: number
15 updatedAt: number
16 status: string
17 isDone: boolean
18 touched: number | null
19 tools: number
20 cost: number
21 note: string
22 turns: number
23 lastTurn: number
24 blocked: string
25 procs: { age: string; cmd: string; isHelper: boolean }[]
26}
27
28// the poll loop's pulse: when it last completed, how often it has run, and the last failure
29export type Beat = {
30 okAt: number
31 ticks: number
32 errors: number
33 error: string
34 errorAt: number
35}
36
37export type Filter = 'all' | 'tools' | 'say' | 'turns'
38
39declare module 'claude-code' {
40 interface PluginState {
41 'delegate-watch': {
42 runs: Run[]
43 selected: string | null
44 events: Ev[]
45 scroll: number
46 paused: boolean
47 filter: Filter
48 rowsPref: number
49 expanded: string[]
50 beat: Beat
51 }
52 }
53}
54