SLOPSHOPPER

agent-monitor

Subagent monitor: a live band above the prompt for running agents, and /sub: this session's subagent history with alerts (file conflicts, stalls, tier…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-monitor
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet this session │ ┃ s: settings · x: close · Esc ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sub │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
No subagents yet this session s: settings · x: close · Esc
README

agent-monitor

See what your Claude Code subagents are doing: a live band above the prompt while they run, and a /sub panel with this session's subagent history, alerts, a detail page per agent and a cost estimate.

中文说明

Requires Claude Code ≥ 2.1.287.

What it does

  • Live band — one row per running subagent above the prompt: type, model and effort, task, current activity, tokens, elapsed time, alert glyphs.
  • /sub history — a table of every subagent this session (newest first): #, status, type, tier (model.effort), task, edits, rounds, tokens, time, alerts. Running agents carry their current activity on a second line. Pick a row with ↑/↓.
  • Detail page — Enter on a row opens it: instruction, timeline, tokens (cache hit / miss / output) with an estimated cost, edited files with +/− line counts, tool counts, skills, recent actions, alerts and the result. b goes back.
  • Alerts — shown as glyphs in the table and as sentences above it:
  • ! file conflict — two agents (or an agent and the main loop) edited the same file while both were running.
  • ~ stall — a running agent has had no step or tool call for the threshold (amber at the threshold, red at twice it); the toast fires once per agent.
  • ≠ tier mismatch — the model or effort an agent actually ran differs from its description prefix (see below).
  • ×N denied calls — N tool calls were refused by a hook or errored, with the reasons.
  • Placement — /sub opens the panel; /sub top keeps it in a band above the prompt, /sub right docks it as a side pane (remembered across sessions); /sub set opens the in-panel settings page.

Commands

CommandDoes
/subShow the panel (again: close it)
/sub top · /sub rightPut the panel above the prompt / in the side pane
/sub setOpen the settings page (toggle columns, switches, placement, stall threshold; w saves)

If the host refuses /sub, the mod registers /subs instead.

The tier prefix (optional)

The Tier column shows model.effort (for example sonnet.med) from what the agent really ran. If you start a subagent with a description of the form <model>.<effort> · task — model so, op, ha or fa; effort low, med or high, e.g. so.med · fix the login form — the mod also reads that prefix as the tier you asked for, and raises the ≠ alert when the agent ran a different model or effort. Without a prefix nothing is compared and the tier alert never fires.

Settings

Open /config and pick agent-monitor, or use /sub set for the panel options.

SettingDefaultMeaning
Languageautoauto follows Claude Code's language setting, then the locale; en or zh forces one. English is the default.
ScopeemptyOnly run in this directory (an absolute path, e.g. /home/me/work) and below; empty = everywhere.
Stall threshold3 minIdle time before a running agent is flagged.
Live rows above the promptonOne row per running subagent.
Alerts block / ToastsonAlert sentences above the table; a toast the first time a conflict or stall is seen.
Default /sub placementlastlast, right or top.
Columnsall onTier, Edits, Rounds, Tokens, Time, Alerts.
Model pricesAPI list pricesCompact JSON, USD per million tokens: {"sonnet":[input,cacheWrite5m,cacheRead,output],...}. A family left out keeps its default; bad JSON falls back to the defaults.

Costs are estimates from token counts and list prices (no batch, fast-mode or regional modifiers); an unpriced model shows a dash.

Install

In Claude Code (2.1.287 or later):

/plugin marketplace add nokiy/claude-code-mods
/plugin install agent-monitor@nokiy-mods

The first command adds this repository as a plugin marketplace (once); the second installs the mod from it. Start a new session afterwards.

License

MIT

Source 19 files
hooks/register.tsx 333 lines
1// The hooks module of agent-monitor: tracks subagents from the engine's events, keeps the session state, and draws the band above the prompt
2// and the /sub pane. The engine's `$` and the state atoms stay in this file (a validator follows them nowhere else); the decisions live in nav.ts, settings.ts and patches.ts.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { AgentMonitorDraft, AgentMonitorRec, MainEdit } from '../types'
7import { alertLines, dueToasts } from './alertlines'
8import { MAX_MAIN, editedPath, normPath, reasonOf } from './alerts'
9import { DEFAULTS, readConfig } from './config'
10import type { Config } from './config'
11import { inScope } from './logic'
12import { CLOSED, pageStr, parsePage, ringKeyOf, siteFlags, subEffects } from './nav'
13import type { NavPatch, Placement } from './nav'
14import { agentLaunched, blank, clean, editDelta, onAgentResult, onComplete, onSpawn, onStep, onStepEnd, onToolEnd, onToolStart } from './patches'
15import { bandTree, panel } from './render'
16import { configKey, draftFromConfig, saveEffects, toggleDraft } from './settings'
17import type { SettingKey } from './settings'
18import { pickLang, strings } from './strings'
19import type { Strings } from './strings'
20import { parseSubArg } from './subarg'
21import { mergeBackfill, recsFromMessages } from './backfill'
22import { buildViews, reconcile, seedRec, settled, withoutGhosts } from './views'
23import type { Board } from './views'
24
25const PANE = 'sub'
26
27// State in the session (`$.state`, typed by ../types/index.d.ts), so a hot reload keeps it: the records, the main loop's edits, and the panel's
28// flags, page, focus ring, settings draft and this session's placement.
29const agents = atom({ plugin: 'agent-monitor', key: 'agents' } as const, {} as Record<string, AgentMonitorRec>)
30const mainEdits = atom({ plugin: 'agent-monitor', key: 'mainEdits' } as const, [] as MainEdit[])
31const historyOpen = atom({ plugin: 'agent-monitor', key: 'historyOpen' } as const, false)
32const paneOpen = atom({ plugin: 'agent-monitor', key: 'paneOpen' } as const, false)
33const view = atom({ plugin: 'agent-monitor', key: 'view' } as const, 'list')
34const focusKey = atom({ plugin: 'agent-monitor', key: 'focusKey' } as const, null as string | null)
35const ringKey = atom({ plugin: 'agent-monitor', key: 'ringKey' } as const, null as string | null)
36const draft = atom({ plugin: 'agent-monitor', key: 'draft' } as const, null as AgentMonitorDraft | null)
37const sessionPlacement = atom({ plugin: 'agent-monitor', key: 'sessionPlacement' } as const, null as Placement | null)
38
39
40type Recs = Record<string, AgentMonitorRec>
41// The settings (plugin.json userConfig); a change in /config or on the settings page reloads the module, so register() sets it afresh.
42let cfg: Config = DEFAULTS
43// The UI strings: the language option is read once per load (it needs the session's settings and LANG), English until then.
44let langOption: unknown = 'auto'
45let t: Strings = strings('en')
46let langLoad: Promise<void> | undefined
47const ensureLang = ($: EngineInterface) => (langLoad ??= (async () => {
48  t = strings(pickLang(langOption, (await $.settings.read()).language, await $.env.get('LANG')))
49})())
50
51async function readNav($: EngineInterface) {
52  const [v, f, r, d, band, pane, session] = await Promise.all([read($, view), read($, focusKey), read($, ringKey), read($, draft), read($, historyOpen), read($, paneOpen), read($, sessionPlacement)])
53  return { page: parsePage(v), focusKey: f, ringKey: r, draft: d, band, pane, session }
54}
55
56async function writeNav($: EngineInterface, p: NavPatch) {
57  if (p.page !== undefined) await update($, view, () => p.page ?? 'list')
58  if (p.focusKey !== undefined) await update($, focusKey, () => p.focusKey ?? null)
59  if (p.ringKey !== undefined) await update($, ringKey, () => p.ringKey ?? null)
60  if (p.draft !== undefined) await update($, draft, () => p.draft ?? null)
61  if (p.band !== undefined) await update($, historyOpen, () => p.band ?? false)
62  if (p.pane !== undefined) await update($, paneOpen, () => p.pane ?? false)
63  if (p.session !== undefined) await update($, sessionPlacement, () => p.session ?? null)
64}
65
66// Read-modify-write one record. An agent with no record yet starts one only when `$.agent.list()` names it or `spawned` says the engine just
67// started it (an engine fork or workflow agent is no subagent: no row). Returns whether a record was written.
68async function track($: EngineInterface, id: string, fn: (r: AgentMonitorRec) => AgentMonitorRec, spawned = false): Promise<boolean> {
69  const known = (await read($, agents))[id] !== undefined
70  const seed = known || spawned ? undefined : seedRec((await $.agent.list()).find(a => a.id === id))
71  if (!known && !spawned && !seed) return false
72  await update($, agents, (all: Recs) => ({ ...all, [id]: clean(fn({ ...(all[id] ?? seed ?? blank()) })) }))
73  return true
74}
75
76async function loadBoard($: EngineInterface): Promise<Board> {
77  const views = buildViews(await read($, agents), await $.agent.list(), await $.clock.now(), { stallMs: cfg.stallMs, main: await read($, mainEdits), prices: cfg.prices })
78  return { views, cwd: await $.session.cwd(), cfg, t }
79}
80
81// A main-loop edit takes part in conflicts only while a subagent runs, so it is kept only then.
82async function noteMainEdit($: EngineInterface, path: string, now: number) {
83  if (!Object.values(await read($, agents)).some(r => r.status === 'running')) return
84  await update($, mainEdits, (m: MainEdit[]) => [...m, { path, at: now }].slice(-MAX_MAIN))
85}
86
87// Persist the end of records the engine no longer runs (a stale `running` would keep the timer alive); drop undescribed leftovers.
88async function settleStale($: EngineInterface) {
89  const recs = await read($, agents)
90  const stale = reconcile(recs, await $.agent.list())
91  if (stale.length === 0 && withoutGhosts(recs) === recs) return
92  const now = await $.clock.now()
93  await update($, agents, (all: Recs) => settled(all, new Map(stale), now))
94}
95
96// Agents the mod never saw spawn come back from the main conversation (its Agent tool uses and task notifications).
97async function backfill($: EngineInterface) {
98  try {
99    const found = recsFromMessages(await $.session.messages())
100    const recs = await read($, agents)
101    if (mergeBackfill(recs, found) !== recs) await update($, agents, (all: Record<string, AgentMonitorRec>) => mergeBackfill(all, found))
102  } catch { /* the conversation is not readable now: the live records still stand */ }
103}
104// One tick a second while an agent runs; one toast per agent per conflict / stall (`warned:<agentId>:<kind>` in the store, `told` its memory in this load).
105const told = new Set<string>()
106async function warn($: EngineInterface, board: Board) {
107  for (const d of cfg.toasts ? dueToasts(alertLines(board.views, board.cwd, t), told) : []) {
108    const seen = (await Promise.all(d.keys.map(k => $.store.get(k)))).every(Boolean)
109    if (!seen) await Promise.all(d.keys.map(k => $.store.set(k, true)))
110    for (const k of d.keys) told.add(k)
111    if (!seen) $.ui.toast(d.text, { timeoutMs: 8000 })
112  }
113}
114let timer: { cancel: () => void } | undefined
115function stopTimer() {
116  try { timer?.cancel() } catch { /* already gone */ }
117  timer = undefined
118}
119function ensureTimer($: EngineInterface) {
120  timer ??= $.clock.every(1000, async () => {
121    try {
122      await settleStale($)
123      const board = await loadBoard($)
124      await warn($, board)
125      if (!board.views.some(v => v.status === 'running')) stopTimer()
126      $.ui.invalidate('ui.render')
127    } catch { stopTimer() } // a later event starts a fresh timer
128  })
129}
130
131// The /sub command (`/subs` if the host refuses the first) and the panel's buttons; the decisions are nav.ts's and settings.ts's.
132let commandName: string | undefined
133async function ensureCommand($: EngineInterface) {
134  for (const name of commandName ? [] : ['sub', 'subs']) {
135    try {
136      await $.command.register({ name, description: t.commandDescription, argumentHint: 'top | right | set', immediate: true })
137      commandName = name
138      return
139    } catch { /* name refused: try the next */ }
140  }
141}
142const paneUp = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE)
143const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: t.paneTitle, focus: true, closeOnEscape: true })
144// Put the panel at `where` (`right`: the pane, `top`: the band above the prompt; null: down), taking it from the other place.
145async function setSite($: EngineInterface, where: Placement | null) {
146  await writeNav($, siteFlags(where))
147  if (where === 'right') await openPane($)
148  else if (await paneUp($)) await $.ui.close({ id: PANE })
149}
150
151// `/sub` shows the panel (or steps back to the table, or closes it), `/sub top|right` places it, `/sub set` opens the settings page.
152async function runSub($: EngineInterface, args: string): Promise<{ text?: string }> {
153  const nav = await readNav($)
154  const fx = subEffects(parseSubArg(args), { ...nav, pane: await paneUp($), last: await $.store.get('lastPlacement'), mode: cfg.placement, cfg })
155  if (fx.text) return { text: fx.text }
156  if (fx.close) await setSite($, null)
157  else {
158    await backfill($)
159    if (fx.last) await $.store.set('lastPlacement', fx.last)
160    await writeNav($, fx.nav)
161    if (fx.move) await setSite($, fx.move)
162  }
163  return {}
164}
165
166// Write the draft's changed rows to the settings in one go (the module reloads with them), the state first: the reload may cut what follows.
167async function saveSettings($: EngineInterface) {
168  const fx = saveEffects((await readNav($)).draft, cfg)
169  await writeNav($, fx.nav)
170  if (fx.writes.length === 0) return
171  const rows = await $.config.list()
172  const done = await Promise.all(fx.writes.map(w => $.config.set({ key: configKey(rows, w.field), value: w.value }).catch((e: unknown) => ({ deny: String(e) }))))
173  const denied = done.find(r => r.deny !== undefined)
174  if (denied?.deny) $.ui.toast(t.notSaved(denied.deny), { timeoutMs: 8000 })
175}
176
177function actsOf($: EngineInterface) {
178  return {
179    open: (id: string) => void writeNav($, { page: pageStr({ kind: 'detail', id }) }),
180    back: () => void writeNav($, { page: 'list', draft: null }),
181    settings: () => void writeNav($, { page: 'settings', draft: draftFromConfig(cfg) }),
182    close: () => void setSite($, null),
183    toggle: (key: SettingKey) => void update($, draft, (d: AgentMonitorDraft | null) => (d ? toggleDraft(d, key) : d)),
184    save: () => void saveSettings($),
185  }
186}
187
188// What a site draws from; a running agent keeps the timer going.
189async function screen($: EngineInterface) {
190  const nav = await readNav($)
191  const board = await loadBoard($)
192  if (board.views.some(v => v.status === 'running')) ensureTimer($)
193  return { nav, board }
194}
195
196// The mod acts everywhere, or only when the session cwd is inside the `scope` setting (session.start says, else the first hook asks).
197// Every hook goes through `active`, so the language is read before anything is drawn.
198let scoped: boolean | undefined
199const active = async ($: EngineInterface) => {
200  await ensureLang($)
201  return (scoped ??= inScope(await $.session.cwd(), cfg.scope))
202}
203let backfilled = false
204let priceToasted = false
205export const register: Register = (on, options) => {
206  cfg = readConfig(options)
207  langOption = options.language
208  langLoad = undefined
209  t = strings('en')
210  on('session.start', async ($, e, next) => {
211    await ensureLang($)
212    scoped = inScope(e.cwd, cfg.scope)
213    if (scoped) {
214      await ensureCommand($)
215      if ((await $.store.get('placement')) != null) await $.store.delete('placement') // the old remembered placement: `lastPlacement` replaces it
216      if (await read($, paneOpen)) { // a reload: the pane the state says is open comes back holding the keyboard, the ring where it was
217        await openPane($)
218        const key = await read($, focusKey)
219        if (key) await $.ui.focus({ requestId: PANE, key }).catch(() => ({}))
220      }
221    }
222    return next(e)
223  })
224  on('turn.start', async ($, e, next) => {
225    if (await active($)) {
226      await ensureCommand($) // a hot reload may have dropped it
227      if (!backfilled) await backfill($) // once per load; /sub backfills again, and an Agent result is a second way in
228      backfilled = true
229      if (cfg.pricesBad && !priceToasted) { priceToasted = true; $.ui.toast(t.pricesBad, { timeoutMs: 8000 }) }
230    }
231    return next(e)
232  })
233  on('session.end', async ($, e, next) => {
234    if (!(await active($))) return next(e)
235    stopTimer()
236    if (e.reason === 'clear') {
237      await Promise.all([update($, agents, () => ({})), update($, mainEdits, () => []), writeNav($, { ...CLOSED, session: null })])
238      told.clear()
239      for (const k of await $.store.keys()) if (k.startsWith('warned:')) await $.store.delete(k)
240    }
241    return next(e)
242  })
243  for (const command of ['sub', 'subs']) on('command.run', { command }, async ($, e, next) => ((await active($)) ? runSub($, e.args) : next(e)))
244  // The ring moved onto a row's Button: remembered (focusKey), so a page change can put it back. Every move in our pane or band, onto any
245  // element or off all of them, is written to ringKey, which drives the selected-row highlight.
246  on('ui.focus', async ($, e, next) => {
247    if (e.element?.startsWith('row:') && (await read($, focusKey)) !== e.element) await update($, focusKey, () => e.element ?? null)
248    if (e.requestId === PANE || e.component === 'AbovePrompt') {
249      const key = ringKeyOf(e)
250      if ((await read($, ringKey)) !== key) {
251        await update($, ringKey, () => key)
252        $.ui.invalidate('ui.render')
253      }
254    }
255    return next(e)
256  })
257  on('ui.close', async ($, e, next) => (e.id === PANE && e.origin.kind !== 'unload' && (await Promise.all([update($, paneOpen, () => false), update($, ringKey, () => null)])), next(e)))
258  // agent.spawn resolves with the new agentId: the id every later turn.step / tool.call carries.
259  on('agent.spawn', async ($, e, next) => {
260    const r = await next(e)
261    if (!(await active($)) || !r.agentId) return r
262    const now = await $.clock.now()
263    await track($, r.agentId, p => onSpawn(p, e, r.model, now), true)
264    ensureTimer($)
265    $.ui.invalidate('ui.render')
266    return r
267  })
268  // Model, effort and token usage come from a subagent's own steps (agentId is absent on main).
269  on('turn.step', async function* ($, e, next) {
270    if (!(await active($))) return yield* next(e)
271    const id = e.agentId
272    const now = await $.clock.now()
273    const tracked = id ? await track($, id, p => onStep(p, e, now)) : false
274    if (tracked) ensureTimer($)
275    const result = yield* next(e)
276    if (id && tracked) {
277      const u = result.usage
278      const done = await $.clock.now()
279      await track($, id, p => onStepEnd(p, u ?? undefined, done))
280    }
281    return result
282  })
283  on('tool.call', async ($, e, next) => {
284    if (!(await active($))) return next(e)
285    const id = e.agentId
286    const tool = String(e.tool)
287    const input = e as unknown as Record<string, unknown>
288    const path = editedPath(tool, input)
289    const file = path ? normPath(path, await $.session.cwd()) : undefined
290    const now = await $.clock.now()
291    if (!id && file) await noteMainEdit($, file, now)
292    // pendingTool is set before the call runs and cleared once it returns (or throws); the result says whether it was refused.
293    const tracked = id ? await track($, id, p => onToolStart(p, tool, input, file, now, t)) : false
294    let r: Awaited<ReturnType<typeof next>> | undefined
295    try {
296      r = await next(e)
297    } finally {
298      if (id && tracked) {
299        const done = await $.clock.now()
300        await track($, id, p => onToolEnd(p, tool, reasonOf(r), done, editDelta(file, r)))
301      }
302    }
303    const launched = agentLaunched(e, r as { result?: unknown; isError?: boolean })
304    if (launched) {
305      const at = await $.clock.now()
306      await track($, launched.res.agentId, p => onAgentResult(p, launched.call, launched.res, at), true)
307      $.ui.invalidate('ui.render')
308    }
309    return r
310  })
311  on('turn.complete', async ($, e, next) => {
312    if (!(await active($))) return next(e)
313    if (e.agentId) {
314      const now = await $.clock.now()
315      if (await track($, e.agentId, p => onComplete(p, e.reason, now))) $.ui.invalidate('ui.render')
316    }
317    return next(e)
318  })
319  // The band above the prompt: the panel while `/sub top` is open, else the running agents, one row each.
320  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
321    if (!(await active($)) || e.props.hasSurvey) return next(e)
322    const { nav, board } = await screen($)
323    const rest = await next(e) // whatever the rest of the chain draws (e.g. usage-band) goes under our rows
324    return bandTree($.ui.resolve(e), board, e.props, nav.band, { ...nav, esc: false }, actsOf($), rest) ?? rest
325  })
326  // The /sub pane (`/sub right`): the same panel, docked by the engine.
327  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
328    if (!(await active($))) return next(e)
329    const { nav, board } = await screen($)
330    return panel($.ui.resolve(e), board, { ...nav, esc: true }, e.props.bodyColumns, actsOf($))
331  })
332}
333
hooks/alertlines.ts 79 lines
1// The alerts as shown: width-1 glyph flags for a row's Alerts cell, and the sentences of the Alerts block (in the UI language). Pure; tested in alertlines.test.ts.
2import { idleText, shortPath } from './alerts'
3import { agentRef, cellWidth, collapse } from './logic'
4import { PALETTE, alertColor } from './palette'
5import type { AlertKind } from './palette'
6import type { Strings } from './strings'
7import type { View } from './views'
8
9export type Seg = { text: string; color: string; bold?: boolean; italic?: boolean }
10
11const ORDER: AlertKind[] = ['conflict', 'stall', 'tier', 'denied']
12
13// `!` conflict, `~` stalled, `≠` tier mismatch, `×N` denied/errored: single-cell glyphs only, separated by one blank.
14export function flagSegs(v: View): Seg[] {
15  const flags: Seg[] = []
16  if (v.clashes.length > 0) flags.push({ text: '!', color: alertColor('conflict') })
17  if (v.stall) flags.push({ text: '~', color: alertColor('stall', v.stall.level) })
18  if (v.tier) flags.push({ text: '≠', color: alertColor('tier') })
19  if (v.denied > 0) flags.push({ text: `×${v.denied}`, color: alertColor('denied') })
20  return flags.flatMap((f, i) => (i === 0 ? [f] : [{ text: ' ', color: PALETTE.fg }, f]))
21}
22
23export const segsWidth = (segs: readonly Seg[]): number => segs.reduce((n, s) => n + cellWidth(s.text), 0)
24
25export type AlertLine = { kind: AlertKind; tag: string; label: string; color: string; body: string; ids: string[]; live: boolean }
26
27export const lineText = (l: AlertLine): string => `${l.tag} ${l.label}  ${l.body}`
28
29const GLYPH: Record<AlertKind, string> = { conflict: '!', stall: '~', tier: '≠', denied: '×' }
30
31// One line per alert: conflicts (a pair and path once), stalls, tier mismatches, denials; agents still running first, then the kind order,
32// then newest first. An agent is named `type"task"` (agentRef); a conflict pair names the older agent first.
33export function alertLines(views: readonly View[], cwd: string, t: Strings): AlertLine[] {
34  const no = new Map(views.map((v, i) => [v.id, i + 1]))
35  const who = (v: View) => agentRef(v.type, v.task, t)
36  const out: AlertLine[] = []
37  const line = (kind: AlertKind, body: string, ids: string[], live: boolean, level?: 1 | 2) => {
38    out.push({ kind, tag: GLYPH[kind], label: t.tag[kind], color: alertColor(kind, level), body, ids, live })
39  }
40  const seen = new Set<string>()
41  for (const v of views) {
42    for (const c of v.clashes) {
43      const key = `${[v.id, c.other].sort().join('|')}|${c.path}`
44      if (seen.has(key)) continue
45      seen.add(key)
46      const other = views.find(x => x.id === c.other)
47      const pair = other ? (no.get(other.id)! > no.get(v.id)! ? t.and(who(other), who(v)) : t.and(who(v), who(other))) : t.and(who(v), 'main')
48      line('conflict', t.conflictLine(pair, shortPath(c.path, cwd)), other ? [v.id, other.id] : [v.id], v.status === 'running' || other?.status === 'running')
49    }
50  }
51  for (const v of views) {
52    if (v.stall) {
53      const idle = idleText(v.stall.idleMs)
54      line('stall', v.stall.tool ? t.stallTool(who(v), idle, v.stall.tool) : t.stallSilent(who(v), idle, v.activity ? collapse(v.activity) : undefined), [v.id], true, v.stall.level)
55    }
56  }
57  for (const v of views) if (v.tier) line('tier', t.tierLine(who(v), v.tier.want, v.tier.got), [v.id], v.status === 'running')
58  for (const v of views) {
59    if (v.denied === 0) continue
60    const top = v.reasons.reduce<typeof v.reasons[number] | undefined>((m, r) => (m && m.n >= r.n ? m : r), undefined)
61    line('denied', t.deniedLine(who(v), v.denied, top?.text ?? '(no reason)', v.reasons.length), [v.id], v.status === 'running')
62  }
63  return out
64    .map((l, i) => ({ l, i }))
65    .sort((a, b) => Number(b.l.live) - Number(a.l.live) || ORDER.indexOf(a.l.kind) - ORDER.indexOf(b.l.kind) || a.i - b.i)
66    .map(x => x.l)
67}
68
69// At most `max` lines; the rest is a count for the `+N more` line.
70export function capAlerts(lines: readonly AlertLine[], max = 4): { shown: AlertLine[]; hidden: number } {
71  return { shown: lines.slice(0, max), hidden: Math.max(0, lines.length - max) }
72}
73
74// The toasts still owed: a conflict or stall of an agent still running, once per agent per kind (`told` holds the `warned:<agentId>:<kind>` keys already done).
75export function dueToasts(lines: readonly AlertLine[], told: ReadonlySet<string>): { keys: string[]; text: string }[] {
76  const live = lines.filter(l => l.live && (l.kind === 'conflict' || l.kind === 'stall'))
77  return live.map(l => ({ keys: l.ids.map(id => `warned:${id}:${l.kind}`), text: lineText(l) })).filter(d => !d.keys.every(k => told.has(k)))
78}
79
hooks/alerts.ts 133 lines
1// Detectors for agent-monitor's four alerts: file conflict, stall, tier mismatch, denied/errored calls. Pure; tested in alerts.test.ts.
2import type { Denial, MainEdit } from '../types'
3import type { Strings } from './strings'
4import { collapse, effortName, modelName, parseDescription, resolveTier, tierName, truncate } from './logic'
5
6export const MAX_FILES = 50
7export const MAX_REASONS = 10
8export const MAX_MAIN = 200
9export const DEFAULT_STALL_MS = 3 * 60_000
10
11// ---- files and conflicts ----
12
13export const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
14
15// The path an edit tool call names (NotebookEdit calls it notebook_path); undefined for any other tool.
16export function editedPath(tool: string, input: Record<string, unknown>): string | undefined {
17  if (!EDIT_TOOLS.has(tool)) return undefined
18  const p = input.file_path ?? input.notebook_path
19  return typeof p === 'string' && p !== '' ? p : undefined
20}
21
22// Absolute form: a relative path joins `cwd`; `.`, `..` and doubled slashes fold away.
23export function normPath(p: string, cwd: string): string {
24  const out: string[] = []
25  for (const s of (p.startsWith('/') ? p : `${cwd.replace(/\/+$/, '')}/${p}`).split('/')) {
26    if (s === '' || s === '.') continue
27    if (s === '..') out.pop()
28    else out.push(s)
29  }
30  return `/${out.join('/')}`
31}
32
33// Relative to `cwd` when under it, else as is.
34export function shortPath(p: string, cwd: string): string {
35  const dir = cwd.replace(/\/+$/, '')
36  return dir !== '' && p.startsWith(`${dir}/`) ? p.slice(dir.length + 1) : p
37}
38
39// Add one path: deduped, and nothing past MAX_FILES.
40export function addFile(files: readonly string[] | undefined, path: string): string[] {
41  const list = files ?? []
42  return list.includes(path) || list.length >= MAX_FILES ? [...list] : [...list, path]
43}
44
45export type Span = { id: string; files: readonly string[]; start?: number; end?: number }
46export type Clash = { path: string; other: string } // other: the partner's agentId, or 'main'
47
48// Per agent: the paths it shares with another participant that was running at the same time. Two agents clash when their run intervals
49// overlap and both touched the path (sequential reuse does not); the main loop clashes with an agent that touched the path when the
50// main edit fell inside that agent's run. An agent with no start (a backfilled one, no paths either) takes part in nothing.
51export function findClashes(spans: readonly Span[], main: readonly MainEdit[]): Map<string, Clash[]> {
52  const out = new Map<string, Clash[]>()
53  const add = (id: string, c: Clash) => {
54    const list = out.get(id) ?? []
55    if (!list.some(x => x.path === c.path && x.other === c.other)) out.set(id, [...list, c])
56  }
57  const live = spans.filter(s => s.start !== undefined && s.end !== undefined && s.files.length > 0)
58  for (let i = 0; i < live.length; i++) {
59    const a = live[i]!
60    for (const m of main) if (a.files.includes(m.path) && m.at >= a.start! && m.at <= a.end!) add(a.id, { path: m.path, other: 'main' })
61    for (let j = i + 1; j < live.length; j++) {
62      const b = live[j]!
63      if (a.start! > b.end! || b.start! > a.end!) continue
64      for (const path of a.files) if (b.files.includes(path)) { add(a.id, { path, other: b.id }); add(b.id, { path, other: a.id }) }
65    }
66  }
67  return out
68}
69
70// ---- stall ----
71
72// level 1 at the threshold, 2 at twice it. `tool`: the tool call still awaiting its result, if any.
73export type Stall = { level: 1 | 2; idleMs: number; tool?: string }
74
75// Running agents only: idle since the latest turn.step or tool.call start / finish (the start when none was seen yet).
76export function stallOf(a: { status: string; lastEventAt?: number; startedAt?: number; pendingTool?: string }, now: number, thresholdMs: number): Stall | undefined {
77  if (a.status !== 'running') return undefined
78  const since = a.lastEventAt ?? a.startedAt
79  if (since === undefined) return undefined
80  const idleMs = now - since
81  if (idleMs < thresholdMs) return undefined
82  return { level: idleMs >= 2 * thresholdMs ? 2 : 1, idleMs, ...(a.pendingTool ? { tool: a.pendingTool } : {}) }
83}
84
85export const idleText = (ms: number): string => (ms >= 60_000 ? `${Math.floor(ms / 60_000)}m` : `${Math.max(0, Math.floor(ms / 1000))}s`)
86
87// `~4m (Bash not returned)` while a tool call is pending, `~4m no activity` when the agent is silent.
88export const stallText = (s: Stall, t: Pick<Strings, 'stallShort'>): string => t.stallShort(idleText(s.idleMs), s.tool)
89
90// ---- tier ----
91
92// want: the description prefix (`so.med`); got: what the step ran (`sonnet.high`); model / effort say which part differs.
93export type TierCheck = { want: string; got: string; model: boolean; effort: boolean }
94
95// The description's `so|op|ha|fa.low|med|high` prefix against the latest turn.step. No prefix, no step, an unknown model or an
96// effort the step did not report: nothing to compare, so no mismatch.
97export function tierMismatch(desc: string, model: string | undefined, effort: string | number | undefined): TierCheck | undefined {
98  const want = parseDescription(desc).tier
99  const wantR = resolveTier(undefined, undefined, desc)
100  const gotModel = modelName(model)
101  if (!want || !wantR.model || !gotModel) return undefined
102  const gotEffort = effortName(effort)
103  const badModel = gotModel !== wantR.model
104  const badEffort = gotEffort !== undefined && gotEffort !== wantR.effort
105  if (!badModel && !badEffort) return undefined
106  return { want, got: tierName(model, effort, ''), model: badModel, effort: badEffort }
107}
108
109// ---- denied / errored ----
110
111// First non-empty line of a reason, whitespace-collapsed, at most 80 cells.
112export function denialReason(text: string | undefined): string {
113  const line = (text ?? '').split('\n').find(l => l.trim() !== '') ?? ''
114  return truncate(collapse(line), 80) || '(no reason)'
115}
116
117// One more denied result: counted, its reason deduped by first line; a new reason past MAX_REASONS counts but is not listed.
118export function addDenial(p: { denied?: number; reasons?: readonly Denial[] }, text: string | undefined): { denied: number; reasons: Denial[] } {
119  const reason = denialReason(text)
120  const reasons = (p.reasons ?? []).map(r => ({ ...r }))
121  const hit = reasons.find(r => r.text === reason)
122  if (hit) hit.n++
123  else if (reasons.length < MAX_REASONS) reasons.push({ text: reason, n: 1 })
124  return { denied: (p.denied ?? 0) + 1, reasons }
125}
126
127// A tool.call result's refusal: a hook's `deny`, or the text of an errored result. undefined when the call went through.
128export function reasonOf(r: { deny?: string; isError?: boolean; text?: string } | undefined): string | undefined {
129  if (!r) return undefined
130  if (r.deny !== undefined) return r.deny
131  return r.isError ? (r.text ?? '') : undefined
132}
133
hooks/config.ts 37 lines
1// The mod's settings: plugin.json `userConfig` fields as `register(on, options)` hands them over. Pure; tested in config.test.ts.
2import { DEFAULT_STALL_MS } from './alerts'
3import { DEFAULT_PRICES, parsePrices } from './cost'
4import type { Prices } from './cost'
5
6// Table columns the user can hide (# Status Type Task are always there).
7export type Columns = { tier: boolean; edits: boolean; rounds: boolean; tokens: boolean; time: boolean; alerts: boolean }
8// `last`: /sub opens where it was last put (and remembers a /sub top|right); `right` / `top`: that place at every session start.
9export type PlacementMode = 'last' | 'right' | 'top'
10export type Config = { scope: string; stallMs: number; autoBand: boolean; alertsBlock: boolean; toasts: boolean; placement: PlacementMode; columns: Columns; prices: Prices; pricesBad: boolean }
11
12export const ALL_COLUMNS: Columns = { tier: true, edits: true, rounds: true, tokens: true, time: true, alerts: true }
13export const DEFAULTS: Config = { scope: '', stallMs: DEFAULT_STALL_MS, autoBand: true, alertsBlock: true, toasts: true, placement: 'last', columns: ALL_COLUMNS, prices: DEFAULT_PRICES, pricesBad: false }
14
15type Options = Readonly<Record<string, unknown>>
16const flag = (o: Options, key: string): boolean => {
17  const v = o[key]
18  return typeof v === 'boolean' ? v : true
19}
20
21// A value not of its declared type (a stall that is not above zero included) falls back to its default.
22export function readConfig(o: Options): Config {
23  const minutes = o.stallMinutes
24  const { prices, bad } = parsePrices(o.prices)
25  return {
26    scope: typeof o.scope === 'string' ? o.scope.trim() : '',
27    stallMs: typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : DEFAULT_STALL_MS,
28    autoBand: flag(o, 'autoBand'),
29    alertsBlock: flag(o, 'alertsBlock'),
30    toasts: flag(o, 'toasts'),
31    placement: o.defaultPlacement === 'top' || o.defaultPlacement === 'right' ? o.defaultPlacement : 'last',
32    columns: { tier: flag(o, 'colTier'), edits: flag(o, 'colEdits'), rounds: flag(o, 'colRounds'), tokens: flag(o, 'colTokens'), time: flag(o, 'colTime'), alerts: flag(o, 'colAlerts') },
33    prices,
34    pricesBad: bad,
35  }
36}
37
hooks/logic.ts 224 lines
1// Pure helpers for agent-monitor (text, numbers, tool activity): no engine access, unit-tested in logic.test.ts.
2import type { Strings } from './strings'
3
4const MODEL_ABBR: [string, string][] = [['opus', 'op'], ['sonnet', 'so'], ['haiku', 'ha'], ['fable', 'fa']]
5const EFFORT_SHORT: Record<string, string> = { low: 'low', medium: 'med', high: 'high' }
6
7// ---- scope ----
8
9// The mod acts everywhere (empty `scope`), or only inside the absolute directory `scope` and its subfolders.
10export function inScope(cwd: string, scope: string): boolean {
11  const root = scope.trim().replace(/\/+$/, '')
12  if (root === '') return true
13  const dir = cwd.replace(/\/+$/, '')
14  return dir === root || dir.startsWith(`${root}/`)
15}
16
17// ---- text ----
18
19// Newlines and runs of blanks become one space, so a one-line cell never grows a second line.
20export function collapse(text: string): string {
21  return text.replace(/\s+/g, ' ').trim()
22}
23
24const wideCell = (c: number) =>
25  (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) ||
26  (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff)
27
28const zeroCell = (c: number) =>
29  (c >= 0x300 && c <= 0x36f) || (c >= 0x1ab0 && c <= 0x1aff) || (c >= 0x1dc0 && c <= 0x1dff) || (c >= 0x200b && c <= 0x200f) ||
30  (c >= 0x2060 && c <= 0x2064) || (c >= 0x20d0 && c <= 0x20ff) || (c >= 0xfe00 && c <= 0xfe0f) || c === 0xfeff || (c >= 0xe0100 && c <= 0xe01ef)
31
32// Terminal cells of a string: CJK, fullwidth and emoji count 2; combining and zero-width marks 0.
33export function cellWidth(text: string): number {
34  let w = 0
35  for (const ch of text) {
36    const c = ch.codePointAt(0) ?? 0
37    w += zeroCell(c) ? 0 : wideCell(c) ? 2 : 1
38  }
39  return w
40}
41
42// Cut to at most `max` cells, ending in "…" when cut.
43export function truncate(text: string, max: number): string {
44  if (max <= 0) return ''
45  if (cellWidth(text) <= max) return text
46  let out = ''
47  let w = 0
48  for (const ch of text) {
49    const cw = cellWidth(ch)
50    if (w + cw > max - 1) break
51    out += ch
52    w += cw
53  }
54  return `${out}…`
55}
56
57// How alerts and the detail page name an agent: `worker "Fix login…"`, the task on one line cut to ~8 cells; no task, just the type.
58export const REF_CELLS = 8
59export function agentRef(type: string, task: string, t: Pick<Strings, 'quote'>): string {
60  const short = truncate(collapse(task), REF_CELLS)
61  return short ? `${type}${t.quote(short)}` : type
62}
63
64// At most `max` characters, `…` when cut (a stored text, not a cell count).
65export function clip(text: string, max: number): string {
66  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
67}
68
69// The first `n` non-empty lines of a text, each cut to `width` characters, joined by newlines.
70export function firstLines(text: string, n: number, width = 200): string {
71  return text.split('\n').map(l => l.trimEnd()).filter(l => l.trim() !== '').slice(0, n).map(l => clip(l, width)).join('\n')
72}
73
74export const padEnd = (text: string, width: number) => text + ' '.repeat(Math.max(0, width - cellWidth(text)))
75export const padStart = (text: string, width: number) => ' '.repeat(Math.max(0, width - cellWidth(text))) + text
76
77// ---- model / effort ----
78
79// Effort -> low | medium | high. Numbers are thinking-token budgets.
80export function effortName(effort: string | number | undefined): string | undefined {
81  if (effort === undefined) return undefined
82  if (typeof effort === 'number') return effort < 2048 ? 'low' : effort < 16000 ? 'medium' : 'high'
83  if (effort === 'low' || effort === 'medium') return effort
84  return effort === 'high' || effort === 'xhigh' || effort === 'max' ? 'high' : undefined
85}
86
87// Effort -> low | med | high.
88export function effortLabel(effort: string | number | undefined): string | undefined {
89  const n = effortName(effort)
90  return n ? EFFORT_SHORT[n] : undefined
91}
92
93// "claude-opus-4-5" -> "opus"; unknown -> undefined.
94export function modelName(model: string | undefined): string | undefined {
95  const id = (model ?? '').toLowerCase()
96  return MODEL_ABBR.find(([name]) => id.includes(name))?.[0]
97}
98
99// "claude-opus-4-5" + "high" -> "op.high"; unknown model -> undefined; no effort -> bare "op".
100export function tierFromModel(model: string | undefined, effort?: string | number): string | undefined {
101  const name = modelName(model)
102  if (!name) return undefined
103  const abbr = MODEL_ABBR.find(([n]) => n === name)?.[1]
104  const eff = effortLabel(effort)
105  return eff ? `${abbr}.${eff}` : abbr
106}
107
108// "so.med · task" -> { tier: 'so.med', task: 'task' }; no prefix -> tier undefined. Task is whitespace-collapsed.
109export function parseDescription(desc: string): { tier?: string; task: string } {
110  const m = /^\s*((?:so|op|ha|fa)\.(?:low|med|high))\s*[·•|:-]\s*(.*)$/s.exec(desc)
111  return m ? { tier: m[1], task: collapse(m[2] ?? '') } : { task: collapse(desc) }
112}
113
114// Model and effort as full words. The real step wins; the description prefix fills what the step has not said.
115export function resolveTier(model: string | undefined, effort: string | number | undefined, desc: string): { model?: string; effort?: string } {
116  const prefix = /^(so|op|ha|fa)\.(low|med|high)$/.exec(parseDescription(desc).tier ?? '')
117  const fromPrefix = prefix ? MODEL_ABBR.find(([, a]) => a === prefix[1])?.[0] : undefined
118  const effFromPrefix = prefix ? (prefix[2] === 'med' ? 'medium' : prefix[2]) : undefined
119  return { model: modelName(model) ?? fromPrefix, effort: effortName(effort) ?? effFromPrefix }
120}
121
122// Real model+effort wins; then the description prefix; else an em dash.
123export function tierLabel(model: string | undefined, effort: string | number | undefined, desc: string): string {
124  const r = resolveTier(model, effort, desc)
125  if (!r.model) return '—'
126  const abbr = MODEL_ABBR.find(([n]) => n === r.model)?.[1]
127  return r.effort ? `${abbr}.${EFFORT_SHORT[r.effort]}` : (abbr ?? '—')
128}
129
130// `sonnet.med`: full model word, a dot and the short effort; unknown effort -> bare `sonnet`; unknown model -> an em dash.
131// Real model+effort wins; the description prefix fills what the step has not said (as resolveTier).
132export function tierName(model: string | undefined, effort: string | number | undefined, desc: string): string {
133  const r = resolveTier(model, effort, desc)
134  if (!r.model) return '—'
135  return r.effort ? `${r.model}.${EFFORT_SHORT[r.effort]}` : r.model
136}
137
138// ---- numbers ----
139
140export function formatTokens(n: number): string {
141  if (n < 1000) return String(n)
142  if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
143  return `${(n / 1_000_000).toFixed(1)}M`
144}
145
146// A token figure, or an em dash when unknown or still 0.
147export function tokensOrDash(n: number | undefined): string {
148  return n && n > 0 ? formatTokens(n) : '—'
149}
150
151export function formatDuration(ms: number): string {
152  const s = Math.max(0, Math.floor(ms / 1000))
153  if (s < 60) return `${s}s`
154  const m = Math.floor(s / 60)
155  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
156  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
157}
158
159// Local wall-clock time as HH:MM:SS.
160export function formatClock(ms: number): string {
161  const d = new Date(ms)
162  return [d.getHours(), d.getMinutes(), d.getSeconds()].map(x => String(x).padStart(2, '0')).join(':')
163}
164
165export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
166
167// Context of one request: input + cache. Add that same step's output for the running figure; never sum outputs across steps.
168export function contextTokens(u: Usage): number {
169  return u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
170}
171
172// ---- tool activity ----
173
174const base = (p: unknown) => String(p ?? '').split('/').filter(Boolean).pop() ?? ''
175const host = (u: unknown) => {
176  const m = /^[a-z]+:\/\/([^/?#:]+)/i.exec(String(u ?? ''))
177  return m?.[1] ?? String(u ?? '')
178}
179const one = (v: unknown) => collapse(String(v ?? ''))
180
181// Verb (in the UI language) + short target for a tool call, on one line. `input` is the call's arguments.
182export function activityText(tool: string, input: Record<string, unknown>, t: Pick<Strings, 'verb'>): string {
183  const v = t.verb[tool === 'MultiEdit' ? 'Edit' : tool] ?? tool
184  switch (tool) {
185    case 'Read':
186    case 'Edit':
187    case 'MultiEdit':
188    case 'Write': return `${v} ${base(input.file_path)}`
189    case 'Grep': return `${v} "${truncate(one(input.pattern), 24)}"`
190    case 'Glob': return `${v} ${one(input.pattern)}`
191    case 'Bash': return `${v} ${truncate(one(input.description || input.command), 24)}`
192    case 'WebFetch': return `${v} ${host(input.url)}`
193    case 'WebSearch': return `${v} "${truncate(one(input.query), 24)}"`
194    case 'Skill': return `${v} ${one(input.skill)}`
195    case 'Agent': return `${v} ${one(input.subagent_type ?? 'general-purpose')}`
196    case 'LSP': return v
197    default: return tool
198  }
199}
200
201// An Agent result's toolStats as per-tool counts (what a backfilled agent has instead of its tool.call stream); zero counts left out.
202export type ToolStats = { readCount?: number; searchCount?: number; bashCount?: number; editFileCount?: number; otherToolCount?: number }
203export function countsFromStats(s: ToolStats): Record<string, number> {
204  const all: [string, number | undefined][] = [['Read', s.readCount], ['Search', s.searchCount], ['Bash', s.bashCount], ['Edit', s.editFileCount], ['Other', s.otherToolCount]]
205  return Object.fromEntries(all.filter((e): e is [string, number] => (e[1] ?? 0) > 0))
206}
207
208// Fit task and activity into `columns`; the fixed parts and stats are never cut.
209export function fitRow(columns: number, fixedWidth: number, task: string, activity: string): { task: string; activity: string } {
210  const room = Math.max(0, columns - fixedWidth - 3 /* gap */ - 3 /* gap */)
211  const actW = Math.min(cellWidth(activity), Math.floor(room / 2))
212  const taskW = Math.max(0, room - actW)
213  const t = truncate(task, taskW)
214  const a = truncate(activity, Math.max(0, room - cellWidth(t)))
215  return { task: t, activity: a }
216}
217
218// Band: how many agent rows fit `maxRows`; when some do not, one row is the "+N more" summary.
219export function capRows(count: number, maxRows: number): { shown: number; hidden: number } {
220  if (count <= maxRows) return { shown: count, hidden: 0 }
221  const shown = Math.max(0, maxRows - 1)
222  return { shown, hidden: count - shown }
223}
224
hooks/nav.ts 83 lines
1// Navigation of the /sub panel: which page shows, what `/sub` does from where, where the panel opens. Pure; tested in nav.test.ts.
2import type { AgentMonitorDraft } from '../types'
3import type { Config, PlacementMode } from './config'
4import { draftFor } from './settings'
5import type { SubArg } from './subarg'
6
7export type Placement = 'top' | 'right'
8export type Page = { kind: 'list' } | { kind: 'detail'; id: string } | { kind: 'settings' }
9export type NavAction = { t: 'detail'; id: string } | { t: 'settings' } | { t: 'back' }
10
11export const LIST: Page = { kind: 'list' }
12export const USAGE = 'Usage: /sub [top | right | set]'
13
14// `$.state` keeps the page as a string: `list`, `settings`, `detail:<agentId>`; anything else reads as the list.
15export function parsePage(s: string | undefined): Page {
16  if (s === 'settings') return { kind: 'settings' }
17  if (s?.startsWith('detail:') && s.length > 7) return { kind: 'detail', id: s.slice(7) }
18  return LIST
19}
20
21export const pageStr = (p: Page): string => (p.kind === 'detail' ? `detail:${p.id}` : p.kind)
22
23// A button's move: a row opens its detail, `s` the settings, `b` back to the list (from the list it stays).
24export function navReduce(p: Page, a: NavAction): Page {
25  if (a.t === 'detail') return { kind: 'detail', id: a.id }
26  if (a.t === 'settings') return { kind: 'settings' }
27  return p.kind === 'list' ? p : LIST
28}
29
30// Where `/sub` opens the panel. `last`: the place `/sub top|right` last chose (stored across sessions), `right` before any choice.
31// A fixed mode (`right` | `top`): that place, unless `/sub top|right` overrode it for this session.
32export function effectivePlacement(mode: PlacementMode, session: Placement | null | undefined, last: Placement | undefined): Placement {
33  if (mode === 'last') return last ?? 'right'
34  return session ?? mode
35}
36
37// What `/sub top|right` remembers: the stored `last` place in `last` mode, else only this session's override.
38export const placementWrite = (mode: PlacementMode, where: Placement): { session?: Placement; last?: Placement } => (mode === 'last' ? { last: where } : { session: where })
39
40// What a `/sub` run starts from: where the panel is up (the pane = right, the band = top flag), its page, the placement mode and memories.
41// `last` is the stored value as read: anything but `top` / `right` is no memory.
42export type SubCtx = { pane: boolean; band: boolean; page: Page; mode: PlacementMode; session: Placement | null; last: unknown }
43export const siteOf = (c: { pane: boolean; band: boolean }): Placement | undefined => (c.pane ? 'right' : c.band ? 'top' : undefined)
44
45// close the panel, or show `page` at `where`. `fresh`: it was closed, so the focus starts over. `session` / `last`: placement to remember.
46export type SubPlan = { kind: 'close' } | { kind: 'show'; page: Page; where: Placement; fresh: boolean; session?: Placement; last?: Placement } | { kind: 'usage'; text: string }
47
48export function planSub(a: SubArg, c: SubCtx): SubPlan {
49  if (a.kind === 'bad') return { kind: 'usage', text: USAGE }
50  const site = siteOf(c)
51  const last = c.last === 'top' || c.last === 'right' ? c.last : undefined
52  const where = site ?? effectivePlacement(c.mode, c.session, last)
53  const fresh = site === undefined
54  if (a.kind === 'toggle') {
55    if (site !== undefined && c.page.kind === 'list') return { kind: 'close' }
56    return { kind: 'show', page: fresh ? LIST : navReduce(c.page, { t: 'back' }), where, fresh }
57  }
58  if (a.kind === 'set') return { kind: 'show', page: { kind: 'settings' }, where, fresh }
59  return { kind: 'show', page: fresh ? LIST : c.page, where: a.where, fresh, ...placementWrite(c.mode, a.where) }
60}
61
62// A write to the panel's state keys (null is a value for focusKey, ringKey, draft and session; a key left out is not touched).
63export type NavPatch = { page?: string; focusKey?: string | null; ringKey?: string | null; draft?: AgentMonitorDraft | null; band?: boolean; pane?: boolean; session?: Placement | null }
64
65export const CLOSED: NavPatch = { band: false, pane: false, page: 'list', draft: null, focusKey: null, ringKey: null }
66
67// Where the ring is now, from a `ui.focus` event: the element's key when it is ours (or the site's own stop, `close`), null when it left every element.
68export const ringKeyOf = (e: { element?: string; plugin?: string }): string | null => (e.element !== undefined && (e.plugin === undefined || e.plugin === 'agent-monitor') ? e.element : null)
69
70// Which site flag is set while the panel is at `where` (none: down, the table next time, no draft, no ring).
71export const siteFlags = (where: Placement | null): NavPatch => (where ? { band: where === 'top', pane: where === 'right' } : CLOSED)
72
73// What a `/sub` run does, ready to apply: text to answer, or the panel closes, or `nav` is written, `last` stored and the panel moved to `move`.
74export type SubFx = { text?: string; close?: true; last?: Placement; move?: Placement; nav: NavPatch }
75
76export function subEffects(a: SubArg, c: SubCtx & { draft: AgentMonitorDraft | null; cfg: Config }): SubFx {
77  const plan = planSub(a, c)
78  if (plan.kind === 'usage') return { text: plan.text, nav: {} }
79  if (plan.kind === 'close') return { close: true, nav: {} }
80  const nav: NavPatch = { page: pageStr(plan.page), draft: draftFor(plan.page, c.page, c.draft, c.cfg), session: plan.session, ...(plan.fresh ? { focusKey: null, ringKey: null } : {}) }
81  return { last: plan.last, move: siteOf(c) === plan.where ? undefined : plan.where, nav }
82}
83
hooks/patches.ts 203 lines
1// How each event changes one subagent record. Pure (the hooks in register.tsx do the engine calls); tested in patches.test.ts.
2import type { AgentMonitorRec, AgentSpent } from '../types'
3import { MAX_FILES, addDenial, addFile } from './alerts'
4import type { Strings } from './strings'
5import { activityText, clip, contextTokens, countsFromStats, firstLines, parseDescription } from './logic'
6import type { ToolStats, Usage } from './logic'
7
8type Rec = AgentMonitorRec
9
10export const MAX_PROMPT = 300
11export const MAX_RECENT = 10
12export const MAX_SKILLS = 20
13export const RESULT_LINES = 5
14
15export const blank = (): Rec => ({ type: '', desc: '', task: '', steps: 0, watched: false, context: 0, output: 0, status: 'running' })
16
17// State is JSON: drop undefined keys rather than store them.
18export const clean = (r: Rec): Rec => Object.fromEntries(Object.entries(r).filter(([, v]) => v !== undefined)) as Rec
19
20// agent.spawn: a fresh run, with what the spawn says.
21export const onSpawn = (p: Rec, spawn: { subagentType: string; description: string; prompt?: string }, model: string | undefined, now: number): Rec => ({
22  ...p,
23  type: spawn.subagentType,
24  desc: spawn.description,
25  task: parseDescription(spawn.description).task,
26  prompt: spawn.prompt === undefined ? p.prompt : clip(spawn.prompt, MAX_PROMPT),
27  model: model ?? p.model,
28  watched: true,
29  status: 'running',
30  startedAt: now,
31  lastEventAt: now,
32  finishedAt: undefined,
33  durationMs: undefined,
34  tokens: undefined,
35})
36
37// turn.step: one more round. A finished agent that steps again (SendMessage) starts a new run; 'unknown' was only a
38// missing list entry, so the run goes on from its own start.
39export function onStep(p: Rec, step: { model: string; effort?: string | number }, now: number): Rec {
40  const ended = p.status === 'done' || p.status === 'failed'
41  return {
42    ...p,
43    model: step.model,
44    effort: step.effort,
45    actualModel: step.model,
46    actualEffort: step.effort,
47    lastEventAt: now,
48    stepAt: now,
49    pendingTool: undefined, // a new step proves every earlier tool call came back
50    pendingCalls: undefined,
51    steps: p.steps + 1,
52    status: 'running',
53    ...(ended ? { startedAt: now, finishedAt: undefined, durationMs: undefined, tokens: undefined, context: 0, output: 0, spent: undefined, byModel: undefined, longestStepMs: undefined } : { startedAt: p.startedAt ?? now, finishedAt: undefined, durationMs: undefined }),
54  }
55}
56
57export const addSpent = (s: AgentSpent | undefined, u: Usage): AgentSpent => ({
58  input: (s?.input ?? 0) + u.input_tokens,
59  output: (s?.output ?? 0) + u.output_tokens,
60  cacheRead: (s?.cacheRead ?? 0) + (u.cache_read_input_tokens ?? 0),
61  cacheWrite: (s?.cacheWrite ?? 0) + (u.cache_creation_input_tokens ?? 0),
62})
63
64const addByModel = (b: Record<string, AgentSpent> | undefined, model: string, u: Usage): Record<string, AgentSpent> => ({ ...b, [model]: addSpent(b?.[model], u) })
65
66// The step ended (`usage` is absent when the response carried none): its duration joins the longest-step figure; the latest request's
67// input + cache and that request's own output become the live figure (no summing of outputs there), and the usage is added to the totals.
68export function onStepEnd(p: Rec, u: Usage | undefined, now: number): Rec {
69  const took = p.stepAt === undefined ? undefined : Math.max(0, now - p.stepAt)
70  return {
71    ...p,
72    lastEventAt: now,
73    stepAt: undefined,
74    ...(took === undefined ? {} : { longestStepMs: Math.max(p.longestStepMs ?? 0, took) }),
75    ...(u ? { context: contextTokens(u), output: u.output_tokens, spent: addSpent(p.spent, u), byModel: addByModel(p.byModel, p.actualModel ?? p.model ?? '', u) } : {}),
76  }
77}
78
79// tool.call inside the agent, before its result: the live activity line and the last ten, the call counted and pending, an edited
80// path remembered, a skill named. `input` is the call's arguments.
81export function onToolStart(p: Rec, tool: string, input: Record<string, unknown>, path: string | undefined, now: number, t: Pick<Strings, 'verb'>): Rec {
82  const activity = activityText(tool, input, t)
83  const skill = tool === 'Skill' && typeof input.skill === 'string' ? input.skill : undefined
84  const skills = p.skills ?? []
85  return {
86    ...p,
87    activity,
88    recent: [...(p.recent ?? []), clip(activity, 120)].slice(-MAX_RECENT),
89    startedAt: p.startedAt ?? now,
90    lastEventAt: now,
91    pendingTool: p.pendingTool ?? tool,
92    pendingCalls: { ...p.pendingCalls, [tool]: (p.pendingCalls?.[tool] ?? 0) + 1 },
93    toolCounts: { ...p.toolCounts, [tool]: (p.toolCounts?.[tool] ?? 0) + 1 },
94    ...(path ? { files: addFile(p.files, path) } : {}),
95    ...(skill && !skills.includes(skill) && skills.length < MAX_SKILLS ? { skills: [...skills, skill] } : {}),
96  }
97}
98
99// Lines added and removed by one Edit / Write result: its patch's `+` / `-` lines (per call, unlike `gitDiff`, which is against HEAD);
100// a created file with no patch counts its lines. undefined when the result says nothing.
101export function diffStat(result: unknown): { add: number; del: number } | undefined {
102  const r = result as { type?: string; content?: unknown; structuredPatch?: { lines?: string[] }[] } | null | undefined
103  if (!r || typeof r !== 'object') return undefined
104  let add = 0
105  let del = 0
106  if (Array.isArray(r.structuredPatch)) {
107    for (const h of r.structuredPatch) {
108      for (const l of h.lines ?? []) {
109        if (l.startsWith('+')) add++
110        else if (l.startsWith('-')) del++
111      }
112    }
113  }
114  if (add + del > 0) return { add, del }
115  if (r.type === 'create' && typeof r.content === 'string') return { add: r.content === '' ? 0 : r.content.split('\n').length, del: 0 }
116  return undefined
117}
118
119// The lines an edit call changed in `file`, from its result (undefined: not an edit, errored, or no patch).
120export function editDelta(file: string | undefined, r: unknown): { path: string; add: number; del: number } | undefined {
121  const res = r as { isError?: boolean; result?: unknown } | undefined
122  const stat = file && !res?.isError ? diffStat(res?.result) : undefined
123  return file && stat ? { path: file, ...stat } : undefined
124}
125
126// The Agent tool call whose result named the agent it started: that result and the call's own arguments; undefined for any other call.
127export function agentLaunched(e: { tool: unknown }, r: { result?: unknown; isError?: boolean }): { res: AgentResult & { agentId: string }; call: { subagent_type?: string; description?: string } } | undefined {
128  const res = r.result as (AgentResult & { agentId?: string }) | undefined
129  return e.tool === 'Agent' && res && !r.isError && res.agentId ? { res: { ...res, agentId: res.agentId }, call: e as { subagent_type?: string; description?: string } } : undefined
130}
131
132// A `tool` call's result is back; `refusal` is why it was denied or errored (undefined when it went through); `edit` the lines an
133// edit changed in `path`. Whatever call is still out stays pending, named by the oldest.
134export function onToolEnd(p: Rec, tool: string, refusal: string | undefined, now: number, edit?: { path: string; add: number; del: number }): Rec {
135  const out = { ...p.pendingCalls }
136  if ((out[tool] ?? 0) > 1) out[tool]!--
137  else delete out[tool]
138  const open = Object.keys(out)[0]
139  const old = edit ? p.lines?.[edit.path] : undefined
140  const room = Object.keys(p.lines ?? {}).length < MAX_FILES
141  return {
142    ...p,
143    lastEventAt: now,
144    pendingTool: open,
145    pendingCalls: open ? out : undefined,
146    ...(refusal === undefined ? {} : addDenial(p, refusal)),
147    ...(edit && (old || room) ? { lines: { ...p.lines, [edit.path]: { add: (old?.add ?? 0) + edit.add, del: (old?.del ?? 0) + edit.del } } } : {}),
148  }
149}
150
151// The Agent tool's result names the agent it started (a second way in, should agent.spawn have been missed) and, once the
152// agent finished in the foreground, carries its authoritative totals and its report. Never overwrites what the spawn wrote.
153export type AgentResult = {
154  status?: string
155  description?: string
156  prompt?: string
157  resolvedModel?: string
158  totalTokens?: number
159  totalDurationMs?: number
160  toolStats?: ToolStats
161  usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }
162  content?: { type?: string; text?: string }[]
163}
164
165// The text of an Agent result's content blocks.
166export const contentText = (content: AgentResult['content']): string => (content ?? []).map(b => b.text ?? '').join('\n')
167
168export function onAgentResult(p: Rec, call: { subagent_type?: string; description?: string }, res: AgentResult, now: number): Rec {
169  const desc = call.description ?? res.description ?? ''
170  const u = res.usage
171  const report = firstLines(contentText(res.content), RESULT_LINES)
172  return {
173    ...p,
174    type: p.type || call.subagent_type || 'general-purpose',
175    desc: p.desc || desc,
176    task: p.task || parseDescription(desc).task,
177    prompt: p.prompt ?? (res.prompt === undefined ? undefined : clip(res.prompt, MAX_PROMPT)),
178    model: p.model ?? res.resolvedModel,
179    startedAt: p.startedAt ?? now,
180    ...(res.status === 'completed'
181      ? {
182          tokens: res.totalTokens,
183          durationMs: res.totalDurationMs,
184          ...(u ? { spent: { input: u.input_tokens ?? 0, output: u.output_tokens ?? 0, cacheRead: u.cache_read_input_tokens ?? 0, cacheWrite: u.cache_creation_input_tokens ?? 0 } } : {}),
185          ...(report ? { result: report } : {}),
186          ...(res.toolStats ? { editCount: res.toolStats.editFileCount, toolCounts: p.toolCounts ?? countsFromStats(res.toolStats) } : {}),
187          ...(p.status === 'running' || p.status === 'unknown' ? { status: 'done' as const, finishedAt: now } : {}),
188        }
189      : {}),
190  }
191}
192
193// turn.complete of the agent's own loop.
194export const onComplete = (p: Rec, reason: string, now: number): Rec => ({
195  ...p,
196  status: reason === 'answer' ? 'done' : 'failed',
197  lastEventAt: now,
198  pendingTool: undefined,
199  pendingCalls: undefined,
200  finishedAt: now,
201  durationMs: p.startedAt === undefined ? undefined : now - p.startedAt,
202})
203
hooks/render.tsx 170 lines
1// Drawing for agent-monitor: the live rows in the band above the prompt, the history table (a select Button per row, the ringed row banded) and the alerts block;
2// `panel` picks the page (table, detail, settings) for a site (band or pane). Colors come from palette.ts.
3import type { EngineInterface, RenderElement } from 'claude-code'
4
5import { alertLines, capAlerts, flagSegs, segsWidth } from './alertlines'
6import { capRows, cellWidth, collapse, fitRow, formatDuration, resolveTier, tierName, tokensOrDash } from './logic'
7import type { Strings } from './strings'
8import type { Board, View } from './views'
9import { computeLayout, headerCells, headerSegs, isSelected, markLabel, rowParts, rowText, rule } from './layout'
10import type { Cell } from './layout'
11import { detailPage, footer, segText, settingsPage } from './pages'
12import type { Acts, PageCtx } from './pages'
13import { PALETTE, effortStyle, modelColor, statusColor, tokenColor, typeStyle } from './palette'
14
15type Ui = ReturnType<EngineInterface['ui']['resolve']>
16
17const { fg, gray, red, selBg } = PALETTE
18
19// One band row per running agent, `+N more` when they do not all fit. `alerts`: end each row with its alert glyphs.
20export function bandRows(ui: Ui, running: View[], cols: number, maxRows: number, alerts: boolean, t: Strings) {
21  const { Box, Text } = ui
22  const { shown, hidden } = capRows(running.length, maxRows)
23  const rows = running.slice(0, shown).map(v => {
24    const tier = tierName(v.model, v.effort, v.desc)
25    const time = v.elapsedMs === undefined ? '—' : formatDuration(v.elapsedMs)
26    const flags = alerts ? flagSegs(v) : []
27    const stats = `${tokensOrDash(v.tokens)} tok · ${time}`
28    const fixed = cellWidth(`◐ ${v.type} · ${tier} · `) + cellWidth(stats) + (flags.length > 0 ? 3 + segsWidth(flags) : 0)
29    const fit = fitRow(cols, fixed, v.task, collapse(v.activity ?? t.starting))
30    const ts = typeStyle(v.type)
31    return (
32      <Text key={v.id} wrap="truncate-end">
33        <Text color={statusColor('running')}>◐ </Text>
34        <Text color={ts.color} bold>{v.type}</Text>
35        <Text color={gray}>{' · '}</Text>
36        <Text color={v.tier ? red : modelColor(resolveTier(v.model, v.effort, v.desc).model)}>{tier}</Text>
37        <Text color={gray}>{' · '}</Text>
38        <Text color={fg}>{fit.task}</Text>
39        <Text>{'   '}</Text>
40        <Text color={gray} italic>{fit.activity}</Text>
41        <Text>{'   '}</Text>
42        <Text color={tokenColor(v.tokens)}>{tokensOrDash(v.tokens)}</Text>
43        <Text color={gray}>{` tok · ${time}`}</Text>
44        {flags.length > 0 && <Text>{'   '}</Text>}
45        {segText(Text, flags)}
46      </Text>
47    )
48  })
49  return { rows, more: hidden > 0 ? <Text color={gray}>{`  +${hidden} more`}</Text> : null, Box }
50}
51
52const cell = (Text: Ui['Text'], c: Cell, i: number, header = false) => {
53  const key = String(i)
54  if (header) return <Text key={key} color={gray}>{c.text}</Text>
55  switch (c.kind) {
56    case 'status': return <Text key={key} color={statusColor(c.status ?? 'done')}>{c.text}</Text>
57    case 'type': { const s = typeStyle(c.key ?? ''); return <Text key={key} color={s.color} bold>{c.text}</Text> }
58    case 'model': return <Text key={key} color={c.bad ? red : modelColor(c.key)}>{c.text}</Text>
59    case 'effort': { const s = effortStyle(c.key); return <Text key={key} color={c.bad ? red : s.color} bold={c.bad ? undefined : s.bold}>{c.text}</Text> }
60    case 'task': return <Text key={key} color={c.status === 'failed' ? gray : fg}>{c.text}</Text>
61    case 'tokens': return <Text key={key} color={tokenColor(c.n)}>{c.text}</Text>
62    case 'alerts': return <Text key={key} color={c.color ?? fg}>{c.text}</Text>
63    default: return <Text key={key} color={gray}>{c.text}</Text> // index, edits, rounds, time
64  }
65}
66
67const CLOSE_W = 11 // two blanks and `[ close ]`
68
69// The Alerts block: up to four sentences, `+N more` after them; nothing when there are none.
70function alertsBlock(ui: Ui, board: Board) {
71  const { Box, Text } = ui
72  const { shown, hidden } = capAlerts(alertLines(board.views, board.cwd, board.t))
73  if (shown.length === 0) return null
74  return (
75    <Box flexDirection="column">
76      {shown.map((l, i) => (
77        <Text key={String(i)} wrap="truncate-end">
78          <Text color={l.color} bold>{` ${l.tag} ${l.label}`}</Text>
79          <Text color={fg}>{`  ${l.body}`}</Text>
80        </Text>
81      ))}
82      {hidden > 0 && <Text color={gray}>{`   +${hidden} more`}</Text>}
83    </Box>
84  )
85}
86
87// The table's footer: the hint (only with rows to pick), `s: settings`, how to close (the header's `[ close ]` is there only with rows).
88function listFooter(ui: Ui, ctx: PageCtx, acts: Acts, rows: boolean, t: Strings) {
89  const x = { hotkey: 'x', label: t.close, onPress: acts.close } as const
90  const settings = { hotkey: 's', label: t.settings, onPress: acts.settings } as const
91  return footer(ui, rows ? [t.listHint, settings, ctx.esc ? t.escClose : t.xClose] : [' ', settings, ' · ', x, ctx.esc ? ' · Esc' : ''])
92}
93
94// The history table: header with `[ close ]` (hotkey x), alerts block, rule, column header, one row per agent led by a 1-cell select Button
95// (`▸` while the ring is on it; Enter opens its detail; the Button of the row that held the focus, else the first, takes the ring). The row
96// the ring is on (ctx.ringKey) is banded with selBg, any row on hover; running ones carry their activity line. Then rule, footer.
97export function historyTable(ui: Ui, board: Board, cols: number, ctx: PageCtx, acts: Acts) {
98  const { Box, Text, Button } = ui
99  const { views, cfg, t } = board
100  if (views.length === 0) {
101    return (
102      <Box flexDirection="column">
103        <Text color={gray}>{t.noAgents}</Text>
104        {listFooter(ui, ctx, acts, false, t)}
105      </Box>
106    )
107  }
108
109  const texts = views.map(rowText)
110  const layout = computeLayout(cols, texts, cfg.columns)
111  const held = views.findIndex(v => ctx.focusKey === `row:${v.id}`)
112  const first = Math.max(0, held)
113  return (
114    <Box flexDirection="column">
115      <Box flexDirection="row">
116        <Text wrap="truncate-end">
117          {headerSegs(cols - CLOSE_W, views).map((s, i) => (
118            <Text key={String(i)} bold={s.bold} color={s.color}>{s.text}</Text>
119          ))}
120        </Text>
121        <Text>{'  '}</Text>
122        <Button key="close" hotkey="x" onPress={acts.close}>{t.close}</Button>
123      </Box>
124      {cfg.alertsBlock && alertsBlock(ui, board)}
125      <Text color={gray}>{rule(cols)}</Text>
126      <Text wrap="truncate-end">{headerCells(layout).map((c, i) => cell(Text, c, i, true))}</Text>
127      {views.map((v, i) => {
128        const p = rowParts(layout, texts[i]!)
129        const sel = isSelected(ctx.ringKey, v.id)
130        return (
131          <Box key={v.id} flexDirection="column">
132            <Box flexDirection="row" backgroundColor={sel ? selBg : undefined} hover={{ backgroundColor: selBg }}>
133              <Text>{p.lead}</Text>
134              <Button key={`row:${v.id}`} plain autoFocus={i === first ? true : undefined} onPress={() => acts.open(v.id)}>{markLabel(sel)}</Button>
135              <Text wrap="truncate-end">{p.rest.map((c, j) => cell(Text, c, j))}</Text>
136            </Box>
137            {v.status === 'running' && <Text color={gray} italic wrap="truncate-end">{`       ↳ ${collapse(v.activity ?? t.starting)}`}</Text>}
138          </Box>
139        )
140      })}
141      <Text color={gray}>{rule(cols)}</Text>
142      {listFooter(ui, ctx, acts, true, t)}
143    </Box>
144  )
145}
146
147// What a site (band or pane) shows: the settings page, the detail page of the agent `ctx.page` names while it is still listed, else the table.
148export function panel(ui: Ui, board: Board, ctx: PageCtx, cols: number, acts: Acts) {
149  const { page } = ctx
150  if (page.kind === 'settings') return settingsPage(ui, board.cfg, ctx.draft, cols, acts, ctx.esc, board.t)
151  const index = page.kind === 'detail' ? board.views.findIndex(v => v.id === page.id) : -1
152  return index >= 0 ? detailPage(ui, board, index, cols, acts, ctx.esc) : historyTable(ui, board, cols, ctx, acts)
153}
154
155// The tree of the band above the prompt, `rest` (the chain's own drawing) under it: the panel while it is open there,
156// else one row per running agent. null when there is nothing to show.
157export function bandTree(ui: Ui, board: Board, props: { bodyColumns: number; maxRows: number }, open: boolean, ctx: PageCtx, acts: Acts, rest: RenderElement) {
158  const { Box } = ui
159  const running = board.cfg.autoBand ? board.views.filter(v => v.status === 'running') : []
160  if (!open && running.length === 0) return null
161  const { rows, more } = bandRows(ui, open ? [] : running, props.bodyColumns, props.maxRows, board.cfg.columns.alerts, board.t)
162  return (
163    <Box flexDirection="column">
164      {open ? panel(ui, board, ctx, props.bodyColumns, acts) : rows}
165      {more}
166      {rest}
167    </Box>
168  )
169}
170
hooks/settings.ts 84 lines
1// The settings page: the draft of the options, its diff against the saved ones (the config writes), and the row texts. Pure; tested in settings.test.ts.
2import type { AgentMonitorDraft } from '../types'
3import type { Columns, Config, PlacementMode } from './config'
4import type { NavPatch } from './nav'
5import { padEnd } from './logic'
6import type { Strings } from './strings'
7
8export type Draft = AgentMonitorDraft
9export type SettingKey = keyof Draft
10export type Write = { field: string; value: boolean | number | string }
11
12export const COLUMNS: (keyof Columns)[] = ['tier', 'edits', 'rounds', 'tokens', 'time', 'alerts']
13export const STALL_STEPS = [1, 2, 3, 5, 10]
14export const PLACEMENTS: PlacementMode[] = ['last', 'right', 'top']
15
16// The userConfig field (plugin.json) behind each row.
17export const FIELD: Record<SettingKey, string> = {
18  tier: 'colTier', edits: 'colEdits', rounds: 'colRounds', tokens: 'colTokens', time: 'colTime', alerts: 'colAlerts',
19  alertsBlock: 'alertsBlock', autoBand: 'autoBand', toasts: 'toasts', placement: 'defaultPlacement', stallMinutes: 'stallMinutes',
20}
21
22type Row = { key: SettingKey; name: string }
23
24// Row order as the page draws it: the six columns, then the switches, then the two pickers.
25export const ROWS: Row[] = [
26  { key: 'tier', name: 'Tier' },
27  { key: 'edits', name: 'Edits' },
28  { key: 'rounds', name: 'Rounds' },
29  { key: 'tokens', name: 'Tokens' },
30  { key: 'time', name: 'Time' },
31  { key: 'alerts', name: 'Alerts' },
32  { key: 'alertsBlock', name: 'Alerts block' },
33  { key: 'autoBand', name: 'Live band' },
34  { key: 'toasts', name: 'Toasts' },
35  { key: 'placement', name: 'Placement' },
36  { key: 'stallMinutes', name: 'Stall' },
37]
38
39export const draftFromConfig = (c: Config): Draft => ({ ...c.columns, alertsBlock: c.alertsBlock, autoBand: c.autoBand, toasts: c.toasts, placement: c.placement, stallMinutes: c.stallMs / 60_000 })
40
41const next = <T>(list: readonly T[], cur: T): T => list[(list.indexOf(cur) + 1) % list.length]!
42
43// Enter on a row: a flag flips, the placement cycles last → right → top, the stall threshold steps through STALL_STEPS (a value off the list steps to the next larger one).
44export function toggleDraft(d: Draft, k: SettingKey): Draft {
45  if (k === 'placement') return { ...d, placement: next(PLACEMENTS, d.placement) }
46  if (k === 'stallMinutes') return { ...d, stallMinutes: STALL_STEPS.find(m => m > d.stallMinutes + 1e-9) ?? STALL_STEPS[0]! }
47  return { ...d, [k]: !d[k] }
48}
49
50// The config writes that take the saved options to the draft: one per changed row, in row order.
51export function diffDraft(d: Draft, c: Config): Write[] {
52  const was = draftFromConfig(c)
53  return ROWS.filter(r => (r.key === 'stallMinutes' ? Math.abs(d.stallMinutes - was.stallMinutes) > 1e-9 : d[r.key] !== was[r.key])).map(r => ({ field: FIELD[r.key], value: d[r.key] }))
54}
55
56export const isDirty = (d: Draft, c: Config): boolean => diffDraft(d, c).length > 0
57
58// The draft a page change leaves behind: the settings page keeps its own, a fresh visit starts from the saved options, any other page has none.
59export function draftFor(page: { kind: string }, prev: { kind: string }, draft: Draft | null, c: Config): Draft | null {
60  if (page.kind !== 'settings') return null
61  return prev.kind === 'settings' && draft ? draft : draftFromConfig(c)
62}
63
64// Saving: the config writes and the panel's state after them. The page goes back to the table with the draft dropped, and a changed
65// default placement ends this session's override, since the person just chose.
66export function saveEffects(d: Draft | null, c: Config): { writes: Write[]; nav: NavPatch } {
67  const writes = d ? diffDraft(d, c) : []
68  return { writes, nav: { page: 'list', draft: null, ...(writes.some(w => w.field === FIELD.placement) ? { session: null } : {}) } }
69}
70
71const NAME_W = 13
72
73// `[x] Tier          model.effort column`; a picker shows its value instead of the box: `[right] Placement ...`.
74export function rowLabel(r: Row, d: Draft, t: Pick<Strings, 'gloss'>): string {
75  const v = d[r.key]
76  const mark = typeof v === 'boolean' ? (v ? '[x]' : '[ ]') : r.key === 'stallMinutes' ? `[${v}m]` : `[${v}]`
77  return `${padEnd(mark, 7)}${padEnd(r.name, NAME_W)}${t.gloss[r.key] ?? ''}`
78}
79
80// The row key of a userConfig field among `$.config.list()`'s rows (`<plugin>.<field>`); the plain form when the list does not name it.
81export function configKey(rows: readonly { key: string }[], field: string): string {
82  return rows.find(r => r.key.startsWith('agent-monitor') && r.key.endsWith(`.${field}`))?.key ?? `agent-monitor.${field}`
83}
84
hooks/strings.ts 116 lines
1// UI strings of agent-monitor, English and Chinese; English unless the session's language says Chinese. Called by every drawing and text module.
2// Column names (Tier, Edits, ...) and the table's other headers stay English in both languages. Pure; tested in strings.test.ts.
3
4export type Lang = 'en' | 'zh'
5
6type Kind = 'conflict' | 'stall' | 'tier' | 'denied'
7const times = (n: number) => (n === 1 ? '1 time' : `${n} times`)
8
9const EN = {
10  // tool activity: verb + short target (`Read logic.ts`)
11  verb: { Read: 'Read', Edit: 'Edit', Write: 'Write', Grep: 'Search', Glob: 'Find', Bash: 'Run', WebFetch: 'Fetch', WebSearch: 'Web search', Skill: 'Skill', Agent: 'Dispatch', LSP: 'Code navigation' } as Record<string, string>,
12  starting: 'starting',
13  // an agent named in a sentence: type + quoted task
14  quote: (s: string) => `"${s}"`,
15  // alerts
16  tag: { conflict: 'conflict', stall: 'stalled', tier: 'tier', denied: 'denied' } as Record<Kind, string>,
17  and: (a: string, b: string) => `${a} and ${b}`,
18  conflictLine: (pair: string, path: string) => `${pair} edited ${path} at the same time`,
19  stallTool: (who: string, idle: string, tool: string) => `${who} idle for ${idle} (${tool} not returned)`,
20  stallSilent: (who: string, idle: string, last: string | undefined) => `${who} idle for ${idle}, no activity${last ? ` (last: ${last})` : ''}`,
21  stallShort: (idle: string, tool: string | undefined) => (tool ? `~${idle} (${tool} not returned)` : `~${idle} no activity`),
22  tierLine: (who: string, want: string, got: string) => `${who} described ${want} · ran ${got}`,
23  tierShort: (want: string, got: string) => `≠ described ${want} · ran ${got}`,
24  deniedLine: (who: string, n: number, reason: string, kinds: number) => `${who} refused ${times(n)}: ${reason}${kinds > 1 ? ` (${kinds} kinds)` : ''}`,
25  deniedShort: (n: number) => `× refused ${times(n)}`,
26  // detail page
27  sec: { prompt: 'Instruction', timeline: 'Timeline', files: 'Edited files', tools: 'Tools', skills: 'Skills', recent: 'Recent actions', alerts: 'Alerts', result: 'Result' },
28  inProgress: 'in progress',
29  took: (d: string) => `took ${d}`,
30  roundsN: (n: number) => `rounds ${n}`,
31  longestStep: (d: string) => `longest step ${d}`,
32  tokenRows: { hit: 'Input (cache hit)', miss: 'Input (cache miss)', out: 'Output', cost: 'Est. cost' },
33  clashWith: (names: string[]) => `  clashes with ${names.join(', ')}`,
34  // panel
35  noAgents: ' No subagents yet this session',
36  close: 'close',
37  back: 'back',
38  settings: 'settings',
39  listHint: ' ↑↓ select · Enter detail · ',
40  escClose: ' · Esc close',
41  xClose: ' · x close',
42  // settings page
43  settingsTitle: ' Settings',
44  settingsSub: '  Subagents panel options',
45  unsaved: '  unsaved',
46  save: 'Save',
47  settingsHint: ' ↑↓ select · Enter change · w save (b goes back without saving)',
48  gloss: {
49    tier: 'model.effort column', edits: 'files-edited column', rounds: 'model rounds column', tokens: 'token usage column', time: 'elapsed time column',
50    alerts: 'alert glyph column (also on live rows)', alertsBlock: 'alert sentences block', autoBand: 'show live rows while agents run', toasts: 'pop-up notices',
51    placement: 'default place: last = as before / right / top', stallMinutes: 'stall threshold (minutes)',
52  } as Record<string, string>,
53  // command, pane, toasts
54  commandDescription: 'Subagent history',
55  paneTitle: 'Subagents',
56  notSaved: (why: string) => `Settings not saved: ${why}`,
57  pricesBad: 'The prices setting is invalid; costs are estimated with the default prices',
58}
59
60export type Strings = typeof EN
61
62const ZH: Strings = {
63  verb: { Read: '读取', Edit: '修改', Write: '写入', Grep: '搜索', Glob: '查找', Bash: '运行', WebFetch: '抓取', WebSearch: '搜索网页', Skill: '技能', Agent: '派发', LSP: '代码导航' },
64  starting: '启动中',
65  quote: s => `「${s}」`,
66  tag: { conflict: '冲突', stall: '卡住', tier: '档位', denied: '拦截' },
67  and: (a, b) => `${a} 与 ${b}`,
68  conflictLine: (pair, path) => `${pair} 同时修改 ${path}`,
69  stallTool: (who, idle, tool) => `${who} 已 ${idle}(${tool} 未返回)`,
70  stallSilent: (who, idle, last) => `${who} 已 ${idle} 无动作${last ? `(最后:${last})` : ''}`,
71  stallShort: (idle, tool) => (tool ? `~${idle}(${tool} 未返回)` : `~${idle} 无动作`),
72  tierLine: (who, want, got) => `${who} 描述 ${want} · 实际 ${got}`,
73  tierShort: (want, got) => `≠ 描述 ${want} · 实际 ${got}`,
74  deniedLine: (who, n, reason, kinds) => `${who} 被拒 ${n} 次:${reason}${kinds > 1 ? ` 等 ${kinds} 种` : ''}`,
75  deniedShort: n => `× 拦截 ${n} 次`,
76  sec: { prompt: '指令', timeline: '时间线', files: '改过的文件', tools: '工具', skills: '技能', recent: '最近动作', alerts: '告警', result: '结果' },
77  inProgress: '进行中',
78  took: d => `用时 ${d}`,
79  roundsN: n => `轮次 ${n}`,
80  longestStep: d => `最长一步 ${d}`,
81  tokenRows: { hit: '输入(缓存命中)', miss: '输入(缓存未命中)', out: '输出', cost: '估算花费' },
82  clashWith: names => `  与 ${names.join('、')} 冲突`,
83  noAgents: ' 本会话还没有子代理',
84  close: '关闭',
85  back: '返回',
86  settings: '设置',
87  listHint: ' ↑↓ 选择 · Enter 详情 · ',
88  escClose: ' · Esc 关闭',
89  xClose: ' · x 关闭',
90  settingsTitle: ' 设置',
91  settingsSub: '  Subagents 面板选项',
92  unsaved: '  未保存',
93  save: '保存',
94  settingsHint: ' ↑↓ 选择 · Enter 切换 · w 保存(b 返回不保存)',
95  gloss: {
96    tier: '模型.档位列', edits: '改动文件数列', rounds: '模型轮次列', tokens: '令牌用量列', time: '用时列', alerts: '告警符号列(含运行条)',
97    alertsBlock: '告警说明区', autoBand: '运行时自动显示', toasts: '弹出提示', placement: '默认位置:last 沿用上次 / right / top', stallMinutes: '卡住阈值(分钟)',
98  },
99  commandDescription: '子代理历史',
100  paneTitle: 'Subagents',
101  notSaved: why => `设置未保存:${why}`,
102  pricesBad: 'prices 设置无效,已按默认价格估算',
103}
104
105export const strings = (lang: Lang): Strings => (lang === 'zh' ? ZH : EN)
106
107/**
108 * The UI language: the config option when set, else Claude Code's `language`
109 * setting, else the locale; Chinese only when one of them says so.
110 */
111export function pickLang(option: unknown, setting: unknown, locale: string | undefined): Lang {
112  if (option === 'en' || option === 'zh') return option
113  const said = `${typeof setting === 'string' ? setting : ''} ${locale ?? ''}`.toLowerCase()
114  return /chinese|中文|^zh|\szh/.test(said.trim()) || said.includes('zh_') || said.includes('zh-') ? 'zh' : 'en'
115}
116
hooks/subarg.ts 11 lines
1// The argument of `/sub`: close/back toggle, a placement, or the settings page. Pure; tested in subarg.test.ts.
2
3export type SubArg = { kind: 'toggle' } | { kind: 'place'; where: 'top' | 'right' } | { kind: 'set' } | { kind: 'bad' }
4
5export function parseSubArg(args: string): SubArg {
6  const a = args.trim().toLowerCase()
7  if (a === '') return { kind: 'toggle' }
8  if (a === 'top' || a === 'right') return { kind: 'place', where: a }
9  return a === 'set' ? { kind: 'set' } : { kind: 'bad' }
10}
11
hooks/backfill.ts 74 lines
1// Backfill for agent-monitor: agents the mod never saw spawn (dispatched before it loaded, or while a reload had the hooks down)
2// are recovered from the main conversation: each Agent tool use names the agent, each task notification says how it ended.
3// Pure; tested in backfill.test.ts.
4import type { AgentMonitorRec } from '../types'
5import { MAX_PROMPT, RESULT_LINES, clean, contentText } from './patches'
6import type { AgentResult } from './patches'
7import { clip, countsFromStats, firstLines, parseDescription } from './logic'
8
9export type MsgUse = { tool: string; input: Record<string, unknown>; result?: unknown; isError?: true; agentId?: string }
10export type Msg = { role: string; text: string; toolUses?: readonly MsgUse[] }
11type Recs = Record<string, AgentMonitorRec>
12
13const BLOCK = /<task-notification>([\s\S]*?)<\/task-notification>/g
14const tag = (s: string, t: string) => new RegExp(`<${t}>([\\s\\S]*?)</${t}>`).exec(s)?.[1]?.trim()
15const num = (s: string | undefined) => (s !== undefined && /^\d+$/.test(s) ? Number(s) : undefined)
16
17// One record per Agent tool use that started an agent, ended by the last task notification that names it.
18export function recsFromMessages(msgs: readonly Msg[]): Recs {
19  const out: Recs = {}
20  for (const m of msgs) {
21    for (const u of m.toolUses ?? []) {
22      if (u.tool !== 'Agent') continue
23      const res = (u.result && typeof u.result === 'object' ? u.result : {}) as AgentResult & { agentId?: string }
24      const id = u.agentId ?? res.agentId
25      if (!id) continue
26      const desc = String(u.input.description ?? '')
27      out[id] = clean({
28        type: String(u.input.subagent_type ?? 'general-purpose'),
29        desc,
30        task: parseDescription(desc).task,
31        prompt: typeof u.input.prompt === 'string' ? clip(u.input.prompt, MAX_PROMPT) : undefined,
32        model: res.resolvedModel,
33        steps: 0,
34        watched: false,
35        context: 0,
36        output: 0,
37        status: u.isError ? 'failed' : res.status === 'completed' ? 'done' : 'running',
38        tokens: res.totalTokens,
39        spent: res.usage ? { input: res.usage.input_tokens ?? 0, output: res.usage.output_tokens ?? 0, cacheRead: res.usage.cache_read_input_tokens ?? 0, cacheWrite: res.usage.cache_creation_input_tokens ?? 0 } : undefined,
40        durationMs: res.totalDurationMs,
41        editCount: res.toolStats?.editFileCount,
42        toolCounts: res.toolStats ? countsFromStats(res.toolStats) : undefined,
43        result: res.status === 'completed' ? firstLines(contentText(res.content), RESULT_LINES) || undefined : undefined,
44      })
45    }
46    if (m.role !== 'user') continue
47    for (const b of m.text.matchAll(BLOCK)) {
48      const block = b[1] ?? ''
49      const id = tag(block, 'task-id')
50      const rec = id ? out[id] : undefined
51      if (!id || !rec) continue
52      const st = tag(block, 'status')
53      const usage = block.slice(block.lastIndexOf('</result>')) // the result text may quote tags of its own
54      const status = st === 'completed' ? 'done' : st === 'failed' || st === 'killed' || st === 'error' ? 'failed' : undefined
55      const report = firstLines(block.slice(block.indexOf('<result>') + 8, Math.max(0, block.lastIndexOf('</result>'))), RESULT_LINES)
56      if (status) out[id] = clean({ ...rec, status, tokens: num(tag(usage, 'subagent_tokens')) ?? rec.tokens, durationMs: num(tag(usage, 'duration_ms')) ?? rec.durationMs, result: report || rec.result })
57    }
58  }
59  return out
60}
61
62// Add what the state lacks; let an end the conversation knows settle a record still running or unknown. Same object when nothing changed.
63export function mergeBackfill(recs: Recs, found: Recs): Recs {
64  let next: Recs | undefined
65  for (const [id, f] of Object.entries(found)) {
66    const r = recs[id]
67    if (!r) (next ??= { ...recs })[id] = f
68    else if ((r.status === 'running' || r.status === 'unknown') && (f.status === 'done' || f.status === 'failed')) {
69      (next ??= { ...recs })[id] = clean({ ...r, status: f.status, tokens: r.tokens ?? f.tokens, spent: r.spent ?? f.spent, durationMs: r.durationMs ?? f.durationMs, editCount: r.editCount ?? f.editCount, toolCounts: r.toolCounts ?? f.toolCounts, prompt: r.prompt ?? f.prompt, result: r.result ?? f.result })
70    }
71  }
72  return next ?? recs
73}
74