SLOPSHOPPER

delegate-watch

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

newpanecommandtoaststatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delegate-watch
│ ┃ Delegates ✕ › fix the failing auth test and add an audit log call │ ┃ ● Watching delegates starting… [ Pause ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ │ ⏺ Update(src/auth.ts) │ ┃ │ No delegate runs yet │ ⎿ Added 2 lines, removed 1 line │ ┃ │ Dispatch one with /agy-delegate, │ ⏺ Bash(bun test) │ ┃ │ /cursor-delegate or /opencode-delegate │ ⎿ 3 pass, 1 fail │ ┃ │ and it shows up here. │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ All ] [ Tools ] [ Thoughts ] [ ↑ ] [ ↓ ] [ › /delegate-watch │ ┃ ⎿ delegate-watch: Delegates pane opened. │ ┃ TIMELINE · no events yet │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Delegates
● Watching delegates starting… [ Pause ] ╭──────────────────────────────────────────────────────────╮ │ │ │ No delegate runs yet │ │ Dispatch one with /agy-delegate, /cursor-delegate or │ │ /opencode-delegate and it shows up here. │ │ │ ╰──────────────────────────────────────────────────────────╯ [ All ] [ Tools ] [ Thoughts ] [ ↑ ] [ ↓ ] [ ● Live ] [ − ] TIMELINE · no events yet
README

delegate-watch

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.

What you see

The delegate-watch pane showing a running agent

  • A chip per recent run (● running, ✓ done, ✗ failed), newest first.
  • A status card: agent badge, model, elapsed time, tool calls or model turns, files edited, cost.
  • A tool-mix bar and a 30-minute activity strip.
  • A timeline of every event: clock time, time since the run started (01:38), a typed badge (READ, EDIT, SHELL, GREP, TURN, THINK...), and the file or command (folder dimmed, name bold).
  • Filters (All / Tools / Thoughts, or Turns / Edits for agy), scroll buttons and a Live toggle.
  • A toast when a run finishes and a status-line count of running delegates.

Per agent

AgentSourceWhat is shown
cursor-agentevents.jsonl (stream-json)every tool call, message and thought
opencodeevents.jsonlevery tool call, message, error, and cost
agyagy.log, stderr.txt, git, psmodel 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.

Install

/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

Use

  • The pane opens by itself when a new run starts. Reopen it with /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.
  • Closing the pane only hides it; polling continues until you pause.

Configure where it looks

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:

SettingDefaultMeaning
watchPaths$TMPDIR/delegate-relay, .orchestrator, .superpowersFolders to search for run folders. Absolute, or relative to the folder Claude Code was started in. $TMPDIR and ~ are expanded.
searchDepth6How many folder levels below each watched folder to search.
maxAgeHours6Only runs whose log changed within this many hours are shown.
maxSessions3How many of the most recent runs get a chip at the top of the pane (1 to 12).
timelineRows8How 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:

  • The /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.
  • Directly in ~/.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).

What counts as a run

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.

Cost and performance

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.

Safety and privacy

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

Platforms

macOS and Linux. Windows is not supported (it relies on sh, find, xargs, ps).

Development

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.

License

MIT

Source 4 files
hooks/register.tsx 606 lines
1import { 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}
606
hooks/guard.ts 52 lines
1import 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) }
52
hooks/summarize.ts 328 lines
1import 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}
328
types/index.d.ts 54 lines
1export 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