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

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.
/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 ↑/↓.+/− line counts, tool counts, skills, recent actions, alerts and the result. b goes back.! 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./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.| Command | Does |
|---|---|
/sub | Show the panel (again: close it) |
/sub top · /sub right | Put the panel above the prompt / in the side pane |
/sub set | Open the settings page (toggle columns, switches, placement, stall threshold; w saves) |
If the host refuses /sub, the mod registers /subs instead.
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.
Open /config and pick agent-monitor, or use /sub set for the panel options.
| Setting | Default | Meaning |
|---|---|---|
| Language | auto | auto follows Claude Code's language setting, then the locale; en or zh forces one. English is the default. |
| Scope | empty | Only run in this directory (an absolute path, e.g. /home/me/work) and below; empty = everywhere. |
| Stall threshold | 3 min | Idle time before a running agent is flagged. |
| Live rows above the prompt | on | One row per running subagent. |
| Alerts block / Toasts | on | Alert sentences above the table; a toast the first time a conflict or stall is seen. |
Default /sub placement | last | last, right or top. |
| Columns | all on | Tier, Edits, Rounds, Tokens, Time, Alerts. |
| Model prices | API list prices | Compact 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.
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.
MIT
hooks/register.tsx 333 lines1// 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}
333hooks/alertlines.ts 79 lines1// 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}
79hooks/alerts.ts 133 lines1// 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}
133hooks/config.ts 37 lines1// 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}
37hooks/logic.ts 224 lines1// 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}
224hooks/nav.ts 83 lines1// 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}
83hooks/patches.ts 203 lines1// 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})
203hooks/render.tsx 170 lines1// 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}
170hooks/settings.ts 84 lines1// 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}
84hooks/strings.ts 116 lines1// 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}
116hooks/subarg.ts 11 lines1// 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}
11hooks/backfill.ts 74 lines1// 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