Watches how your other mods really behave: hook failures and slow hooks (read off the chain's own trace), what each one did (toasts, status lines, commands…

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|done|all> removes jobs.tail, checks processes with ps, and runs df.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.
Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./busy.When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.
/busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast./second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits.
Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.gh pr checks and the tail of the failed log attached, so you don't paste it./prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo.~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed./routine shows the routine's name, how long it has run, its waits, and the settings.A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.
/second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters./second-opinion commits 5/second-opinion diff (uncommitted changes)/second-opinion file docs/ADR-007.md/second-opinion <question>: adds a question for Fable to answer first.second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message./orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./secrets test <text> output is masked too./secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.Settings:
closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.fullSpectrumPattern (the regex above)checkContents (on)The quit request goes out when Claude issues the command, before any permission prompt for it.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.
claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)hooks/register.tsx 1340 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CountsLine, EventLine, MonitorBackup, SeenVia, SlowStat, TokenUsage } from '../types'
5import { aggregate, failuresText, paneView, reportText } from './aggregate'
6import type { PaneView, RowMark, SessionFile } from './aggregate'
7import {
8 dayOf,
9 daysBetween,
10 expiredDays,
11 isDayName,
12 monitorRoot,
13 parseLines,
14 rangeOf,
15 serialize,
16 sessionFileName,
17 sessionPath,
18 startOfDay,
19 trimEntries,
20} from './log'
21import type { Entry } from './log'
22import { basename, commandKey, dirCategory, firstLine, homeRelative, keep } from './mask'
23import { deepestRejected, didRun, failureOf, isWatched, p95, roundMs, sample } from './trace'
24import type { Failure, Link, Reservoir } from './trace'
25
26type Engine = EngineInterface
27
28const SELF = 'mod-monitor'
29const PANE = 'mods'
30const TITLE = 'Mods'
31/** Drawing a pane or the band is slow from here (or from `slowMs` when that is lower). */
32const RENDER_SLOW_MS = 250
33/** A process that runs longer than this is logged as slow. */
34const PROC_SLOW_MS = 20_000
35/** This many process failures of one mod within the window is one toast. */
36const PROC_BURST = 5
37const PROC_WINDOW_MS = 10 * 60_000
38/** A mod's own log line that reports a failure ($.ui.log is where mods without a toast say so). */
39const LOG_ERROR = /\b(failed|error|exception|threw|could not|couldn't|did not register|timed out|withheld)\b/i
40/** Error lines in an hour that mark a mod failing and raise one toast. */
41const LOG_BURST = 3
42const LOG_WINDOW_MS = 60 * 60_000
43/** Repeats of one thing (a toast, a failure, a status change) within this long fold into one line. */
44const FOLD_MS = 60_000
45/** Event lines one mod may add between two writes; past it, things are only counted. */
46const LINES_PER_WINDOW = 200
47/** How often an open pane is drawn again while its figures change. */
48const LIVE_MS = 2_000
49const HELP = [
50 '/mods — the Mods pane: each mod, its health, what it did today; Details per mod',
51 '/mods report [24h|7d|30d] — what each mod did over the range (default 7d); also written to ~/.claude/mods/monitor/report-latest.md',
52 '/mods failures [24h|7d|30d] — only hook failures and process errors (default 7d)',
53 '/mods help — this list',
54].join('\n')
55
56const tick = atom({ plugin: 'mod-monitor', key: 'tick' } as const, 0)
57const expanded = atom({ plugin: 'mod-monitor', key: 'expanded' } as const, [])
58const EMPTY_BACKUP: MonitorBackup = {
59 sessionId: null,
60 mods: [],
61 commands: [],
62 tools: [],
63 alerted: [],
64 procAlerted: [],
65 failures: [],
66 statuses: [],
67 noted: [],
68}
69const backup = atom({ plugin: 'mod-monitor', key: 'backup' } as const, EMPTY_BACKUP)
70
71type Config = {
72 alerts: boolean
73 slowMs: number
74 renderSlowMs: number
75 watchRender: boolean
76 watchCommands: boolean
77 watchAppend: boolean
78 retentionDays: number
79 flushMs: number
80}
81
82/** A mod seen this session: how, and whether it ever ran beneath the monitor. */
83type Mod = { name: string; via: Set<SeenVia>; covered: boolean; tier?: string; version?: string; provenance?: string }
84
85/** What a mod did since the last write. */
86type Delta = {
87 runs: Record<string, number>
88 procs: number
89 procFails: number
90 writes: number
91 toasts: number
92 models: number
93 fails: number
94 cmds: number
95 tools: number
96 logErrors: number
97 slow: Record<string, { n: number; max: number }>
98}
99
100const DEFAULTS: Config = {
101 alerts: true,
102 slowMs: 1500,
103 renderSlowMs: RENDER_SLOW_MS,
104 watchRender: true,
105 watchCommands: true,
106 watchAppend: true,
107 retentionDays: 30,
108 flushMs: 60_000,
109}
110
111let config: Config = DEFAULTS
112let home: string | null = null
113let sessionId: string | null = null
114/** `$.clock.now()` less `Date.now()`, so a hot path reads the time without a call on `$`. */
115let clockOffset = 0
116let expected: string[] = []
117let mods = new Map<string, Mod>()
118let commandOwner = new Map<string, string>()
119let toolOwner = new Map<string, string>()
120let deltas = new Map<string, Delta>()
121let reservoirs = new Map<string, Reservoir>()
122let alerted = new Set<string>()
123let procAlerted = new Set<string>()
124let logAlerted = new Set<string>()
125let logErrorTimes = new Map<string, number[]>()
126let failureCounts = new Map<string, number>()
127let procFailTimes = new Map<string, number[]>()
128let statuses = new Map<string, string>()
129/** Things logged once per session: a mod's write folders and registrations. */
130let noted = new Set<string>()
131/** The line a repeat folds into, by what it is, while its minute lasts. */
132let folding = new Map<string, { entry: Entry; until: number }>()
133let windowLines = new Map<string, number>()
134/** The day this session's buffer belongs to, and the buffer: every line of its file. */
135let day: string | null = null
136let entries: Entry[] = []
137let isDirty = false
138/** Today's lines of the other sessions, read when the pane opens and as it stays open. */
139let others: SessionFile[] = []
140let othersDay: string | null = null
141let isPaneOpen = false
142let hasChanged = false
143let starting: Promise<void> | null = null
144let flushTimer: { cancel: () => void } | null = null
145let liveTimer: { cancel: () => void } | null = null
146let writing: Promise<void> = Promise.resolve()
147/** The session a /clear ended: its id is never written to again. */
148let endedSession: string | null = null
149
150const now = () => Date.now() + clockOffset
151
152function safely(work: () => void): void {
153 try {
154 work()
155 } catch {
156 // The monitor never changes what it watches, its own trouble included.
157 }
158}
159
160async function debug($: Engine, line: string) {
161 try {
162 await $.ui.log(`mod-monitor: ${line}`, { to: 'debug' })
163 } catch {
164 // The debug log is best effort.
165 }
166}
167
168function toast($: Engine, text: string) {
169 try {
170 $.ui.toast(text, { timeoutMs: 8_000 })
171 } catch {
172 // A toast that cannot show is no reason to fail the hook that raised it.
173 }
174}
175
176async function syncClock($: Engine) {
177 try {
178 clockOffset = (await $.clock.now()) - Date.now()
179 } catch {
180 // Keep the last offset.
181 }
182}
183
184// ---------------------------------------------------------------------------
185// Recording: synchronous, in memory, never a call on `$` but an alert's toast.
186
187function deltaOf(plugin: string): Delta {
188 let delta = deltas.get(plugin)
189 if (!delta) {
190 delta = { runs: {}, procs: 0, procFails: 0, writes: 0, toasts: 0, models: 0, fails: 0, cmds: 0, tools: 0, logErrors: 0, slow: {} }
191 deltas.set(plugin, delta)
192 }
193 hasChanged = true
194 return delta
195}
196
197function reservoirOf(plugin: string, event: string): Reservoir {
198 const key = `${plugin}\u0000${event}`
199 let reservoir = reservoirs.get(key)
200 if (!reservoir) {
201 reservoir = { seen: 0, values: [] }
202 reservoirs.set(key, reservoir)
203 }
204 return reservoir
205}
206
207function push(line: EventLine | CountsLine): Entry {
208 const entry: Entry = { line, json: null }
209 entries.push(entry)
210 isDirty = true
211 hasChanged = true
212 return entry
213}
214
215/**
216 * Adds an event line, or folds it into the open line of the same `foldKey`
217 * (its `n` and `last` grow, `merge` takes what the newer one says).
218 */
219function logEvent(line: EventLine, foldKey?: string, merge?: (open: EventLine, newer: EventLine) => void) {
220 if (foldKey) {
221 const open = folding.get(foldKey)
222 if (open && open.until >= line.ts) {
223 const held = open.entry.line as EventLine
224 held.n = (held.n ?? 1) + 1
225 held.last = line.ts
226 merge?.(held, line)
227 open.entry.json = null
228 isDirty = true
229 hasChanged = true
230 return
231 }
232 }
233 if (line.kind !== 'seen') {
234 const count = windowLines.get(line.plugin) ?? 0
235 if (count >= LINES_PER_WINDOW) {
236 return
237 }
238 windowLines.set(line.plugin, count + 1)
239 }
240 const entry = push(line)
241 if (foldKey) {
242 folding.set(foldKey, { entry, until: line.ts + FOLD_MS })
243 }
244}
245
246function seenLine(mod: Mod, via: SeenVia, at: number): EventLine {
247 return {
248 t: 'event',
249 ts: at,
250 plugin: mod.name,
251 kind: 'seen',
252 via,
253 ...(mod.tier ? { tier: mod.tier } : {}),
254 ...(mod.version ? { version: keep(mod.version, 40) } : {}),
255 ...(mod.provenance ? { provenance: keep(mod.provenance, 120) } : {}),
256 }
257}
258
259type Admission = { tier?: string; version?: string; provenance?: string }
260
261/** Marks a mod loaded; the first sign of it this session is logged. */
262function noteSeen(plugin: string, via: SeenVia, at: number, admission?: Admission): Mod {
263 let mod = mods.get(plugin)
264 if (!mod) {
265 mod = { name: plugin, via: new Set(), covered: false, ...admission }
266 mods.set(plugin, mod)
267 mod.via.add(via)
268 logEvent(seenLine(mod, via, at))
269 return mod
270 }
271 if (admission) {
272 Object.assign(mod, admission)
273 }
274 mod.via.add(via)
275 return mod
276}
277
278function recordFailure($: Engine, plugin: string, event: string, failure: Failure, ms: number, reason: string | undefined, at: number) {
279 deltaOf(plugin).fails += 1
280 const count = (failureCounts.get(plugin) ?? 0) + 1
281 failureCounts.set(plugin, count)
282 logEvent(
283 {
284 t: 'event',
285 ts: at,
286 plugin,
287 kind: 'failure',
288 event,
289 outcome: failure.outcome,
290 ms: roundMs(ms),
291 what: failure.what,
292 ...(reason ? { reason: keep(reason, 160) } : {}),
293 },
294 `failure|${plugin}|${event}|${failure.outcome}`,
295 (open, newer) => {
296 if (open.kind === 'failure' && newer.kind === 'failure') {
297 open.ms = Math.max(open.ms, newer.ms)
298 if (newer.reason) {
299 open.reason = newer.reason
300 }
301 }
302 },
303 )
304 if (config.alerts && !alerted.has(plugin)) {
305 alerted.add(plugin)
306 toast($, `mod-monitor: ${plugin}'s ${event} hook ${failure.what} (${count}×) — /mods for details`)
307 }
308}
309
310function recordSlow(plugin: string, event: string, ms: number, at: number) {
311 const stat = (deltaOf(plugin).slow[event] ??= { n: 0, max: 0 })
312 stat.n += 1
313 stat.max = Math.max(stat.max, ms)
314 logEvent({ t: 'event', ts: at, plugin, kind: 'slow', event, ms: roundMs(ms) }, `slow|${plugin}|${event}`, (open, newer) => {
315 if (open.kind === 'slow' && newer.kind === 'slow') {
316 open.ms = Math.max(open.ms, newer.ms)
317 }
318 })
319}
320
321/** The dispatch a trace belongs to: its event, whether it was a drawing, and when it settled. */
322type Dispatch = { event: string; isRender: boolean; at: number }
323
324/** One link beneath the monitor: the mod is loaded and covered; its run, its time and its failure are noted. */
325function recordLink($: Engine, link: Link, isDeepestRejected: boolean, { event, isRender, at }: Dispatch) {
326 const mod = noteSeen(link.plugin, 'trace', at)
327 mod.covered = true
328 if (!didRun(link)) {
329 return
330 }
331 const ms = Number.isFinite(link.ms) ? link.ms : 0
332 const isSlow = ms > (isRender ? config.renderSlowMs : config.slowMs)
333 if (!isRender) {
334 const delta = deltaOf(link.plugin)
335 delta.runs[event] = (delta.runs[event] ?? 0) + 1
336 }
337 // Drawing keeps only its slow times; every other event, all of them.
338 if (!isRender || isSlow) {
339 sample(reservoirOf(link.plugin, event), ms)
340 }
341 if (isSlow) {
342 recordSlow(link.plugin, event, ms, at)
343 }
344 const failure = failureOf(link, isDeepestRejected)
345 if (failure) {
346 recordFailure($, link.plugin, event, failure, ms, link.reason, at)
347 }
348}
349
350/**
351 * What one dispatch's trace says about the mods beneath the monitor: each
352 * one's run (drawing is never counted), its time, and its failure.
353 */
354function recordTrace($: Engine, event: string, trace: readonly Link[], isRender: boolean) {
355 if (trace.length === 0) {
356 return
357 }
358 const dispatch: Dispatch = { event, isRender, at: now() }
359 const deepest = deepestRejected(trace)
360 trace.forEach((link, i) => {
361 if (isWatched(link, SELF)) {
362 recordLink($, link, i === deepest, dispatch)
363 }
364 })
365}
366
367/**
368 * Runs the chain beneath (`run`, the hook's own `next(e)`), reads what its
369 * trace says, and answers exactly what the chain answered: a result as it
370 * came, a rejection as it came.
371 */
372async function watch<R>(
373 $: Engine,
374 event: string,
375 next: { readonly trace: readonly Link[] },
376 run: () => Promise<R>,
377 isRender = false,
378 after?: () => void,
379): Promise<R> {
380 let result: R
381 try {
382 result = await run()
383 } catch (error) {
384 safely(() => recordTrace($, event, next.trace, isRender))
385 if (after) {
386 safely(after)
387 }
388 throw error
389 }
390 safely(() => recordTrace($, event, next.trace, isRender))
391 if (after) {
392 safely(after)
393 }
394 return result
395}
396
397/** Times a `$` call another plugin made and hands its outcome to `record`, then answers exactly what came back. */
398async function observe<R>(run: () => Promise<R>, record: (result: R | undefined, error: unknown, ms: number) => void): Promise<R> {
399 const started = Date.now()
400 let result: R
401 try {
402 result = await run()
403 } catch (error) {
404 safely(() => record(undefined, error, Date.now() - started))
405 throw error
406 }
407 safely(() => record(result, undefined, Date.now() - started))
408 return result
409}
410
411/** The plugin behind a `$` call, when it is one the monitor reports on; marks it loaded. */
412function callerOf(origin: { readonly plugin: string; readonly tier: string }): string | null {
413 if (!isWatched(origin, SELF) || origin.plugin === 'client') {
414 return null
415 }
416 noteSeen(origin.plugin, 'origin', now())
417 return origin.plugin
418}
419
420function onLog($: Engine, plugin: string, text: string) {
421 const shown = keep(text, 200)
422 const isError = LOG_ERROR.test(text)
423 logEvent({ t: 'event', ts: now(), plugin, kind: 'log', text: shown, isError }, `log|${plugin}|${shown}`)
424 if (!isError) {
425 return
426 }
427 deltaOf(plugin).logErrors += 1
428 const at = now()
429 const times = (logErrorTimes.get(plugin) ?? []).filter(time => at - time < LOG_WINDOW_MS)
430 times.push(at)
431 logErrorTimes.set(plugin, times.slice(-50))
432 if (times.length >= LOG_BURST && config.alerts && !logAlerted.has(plugin)) {
433 logAlerted.add(plugin)
434 toast($, `mod-monitor: ${plugin} logged ${times.length} errors in the last hour (last: ${keep(text, 80)}) — /mods for details`)
435 }
436}
437
438function onToast(plugin: string, text: string) {
439 deltaOf(plugin).toasts += 1
440 const shown = keep(text, 160)
441 logEvent({ t: 'event', ts: now(), plugin, kind: 'toast', text: shown }, `toast|${plugin}|${shown}`)
442}
443
444function onStatus(plugin: string, text: string | undefined) {
445 const shown = text === undefined ? '' : keep(text, 120)
446 if (statuses.get(plugin) === shown) {
447 return
448 }
449 statuses.set(plugin, shown)
450 hasChanged = true
451 logEvent({ t: 'event', ts: now(), plugin, kind: 'status', text: shown }, `status|${plugin}`, (open, newer) => {
452 if (open.kind === 'status' && newer.kind === 'status') {
453 open.text = newer.text
454 }
455 })
456}
457
458/** Why a process failure is expected and not worth counting, or null: git probing a folder that is no repository. */
459export function expectedFailure(text: string): string | null {
460 return /not a git repository/i.test(text) ? 'not in a git repository' : null
461}
462
463type ProcOutcome = { value?: { exitCode: number; stderr: string }; deny?: string } | undefined
464
465function onProcess($: Engine, plugin: string, argv: readonly string[], outcome: ProcOutcome, error: unknown, ms: number) {
466 const delta = deltaOf(plugin)
467 delta.procs += 1
468 const at = now()
469 const cmd = commandKey(argv)
470 if (ms > PROC_SLOW_MS) {
471 logEvent({ t: 'event', ts: at, plugin, kind: 'proc-slow', cmd, ms: Math.round(ms) })
472 }
473 const exit = outcome?.value ? outcome.value.exitCode : null
474 if (exit === 0) {
475 return
476 }
477 const why = outcome?.deny ?? (error !== undefined ? String(error) : (outcome?.value?.stderr ?? ''))
478 const expected = expectedFailure(why)
479 if (expected) {
480 // A probe that fails by design (git asked about a folder that is no repository): logged, never counted.
481 logEvent({ t: 'event', ts: at, plugin, kind: 'proc-expected', cmd, exit, why: expected }, `procx|${plugin}|${cmd}|${expected}`)
482 return
483 }
484 delta.procFails += 1
485 const err = keep(firstLine(why), 160)
486 logEvent(
487 { t: 'event', ts: at, plugin, kind: 'proc-fail', cmd, exit, ms: Math.round(ms), ...(err ? { err } : {}) },
488 `proc|${plugin}|${cmd}|${exit}`,
489 (open, newer) => {
490 if (open.kind === 'proc-fail' && newer.kind === 'proc-fail' && newer.err) {
491 open.err = newer.err
492 }
493 },
494 )
495 const times = (procFailTimes.get(plugin) ?? []).filter(time => at - time < PROC_WINDOW_MS)
496 times.push(at)
497 procFailTimes.set(plugin, times.slice(-50))
498 if (times.length >= PROC_BURST && config.alerts && !procAlerted.has(plugin)) {
499 procAlerted.add(plugin)
500 const last = exit === null ? cmd : `${cmd}, exit ${exit}`
501 toast($, `mod-monitor: ${plugin}'s processes failed ${times.length}× in 10 min (last: ${last}) — /mods for details`)
502 }
503}
504
505type ModelOutcome =
506 | {
507 value?:
508 | { isAnswered: true; usage: ModelUsageLike }
509 | { isAnswered: false; reason: string; status?: number | null; error?: string; usage?: ModelUsageLike }
510 deny?: string
511 }
512 | undefined
513
514type ModelUsageLike = {
515 input_tokens: number
516 output_tokens: number
517 cache_read_input_tokens: number
518 cache_creation_input_tokens: number
519}
520
521function onModel(plugin: string, model: string, outcome: ModelOutcome, error: unknown, ms: number) {
522 deltaOf(plugin).models += 1
523 const value = outcome?.value
524 let said: string
525 if (outcome?.deny !== undefined) {
526 said = 'refused'
527 } else if (error !== undefined || !value) {
528 said = 'failed'
529 } else if (value.isAnswered) {
530 said = 'answered'
531 } else {
532 said = [value.reason, value.status ?? '', value.error ?? ''].filter(part => part !== '').join(' ')
533 }
534 const raw = value?.usage
535 const usage: TokenUsage | undefined = raw
536 ? {
537 in: raw.input_tokens || 0,
538 out: raw.output_tokens || 0,
539 cacheRead: raw.cache_read_input_tokens || 0,
540 cacheWrite: raw.cache_creation_input_tokens || 0,
541 }
542 : undefined
543 logEvent({
544 t: 'event',
545 ts: now(),
546 plugin,
547 kind: 'model',
548 model: keep(model, 60),
549 outcome: keep(said, 60),
550 ms: Math.round(ms),
551 ...(usage ? { usage } : {}),
552 })
553}
554
555function onWrite(plugin: string, path: string) {
556 deltaOf(plugin).writes += 1
557 const dir = dirCategory(path, home)
558 const key = `write|${plugin}|${dir}`
559 if (!noted.has(key)) {
560 noted.add(key)
561 logEvent({ t: 'event', ts: now(), plugin, kind: 'write', dir })
562 }
563}
564
565function onRegister(plugin: string, what: 'command' | 'tool', name: string, full: string | undefined) {
566 noteSeen(plugin, what === 'command' ? 'command.register' : 'tool.register', now())
567 if (what === 'command') {
568 commandOwner.set(name, plugin)
569 } else {
570 toolOwner.set(full ?? `mcp__${plugin}__${name}`, plugin)
571 toolOwner.set(`mcp__${plugin}__${name}`, plugin)
572 }
573 const key = `register|${plugin}|${what}|${name}`
574 if (!noted.has(key)) {
575 noted.add(key)
576 logEvent({ t: 'event', ts: now(), plugin, kind: 'register', what, name: keep(name, 64) })
577 }
578}
579
580/** A slash command's run, credited to the mod it belongs to: its registrar, else the link that answered it. */
581function onCommand(command: string, args: string, by: string, trace: readonly Link[]) {
582 let owner = commandOwner.get(command)
583 const last = trace[trace.length - 1]
584 if (!owner && last && isWatched(last, SELF) && last.outcome === 'returned') {
585 owner = last.plugin
586 }
587 if (!owner || owner === SELF) {
588 return
589 }
590 deltaOf(owner).cmds += 1
591 logEvent({ t: 'event', ts: now(), plugin: owner, kind: 'command', command: keep(command, 64), hasArgs: args.trim() !== '', by })
592}
593
594/** A call of a tool a mod registered (`mcp__<mod>__<name>`). */
595function onToolCall(tool: string) {
596 let owner = toolOwner.get(tool)
597 if (!owner) {
598 const match = /^mcp__(.+?)__/.exec(tool)
599 owner = match?.[1] && mods.has(match[1]) ? match[1] : undefined
600 }
601 if (owner && owner !== SELF) {
602 deltaOf(owner).tools += 1
603 }
604}
605
606// ---------------------------------------------------------------------------
607// The day file: written whole from memory, every `flushSeconds` and at the end.
608
609function isEmpty(delta: Delta): boolean {
610 return (
611 Object.keys(delta.runs).length === 0 &&
612 Object.keys(delta.slow).length === 0 &&
613 delta.procs + delta.procFails + delta.writes + delta.toasts + delta.models + delta.fails + delta.cmds + delta.tools + delta.logErrors === 0
614 )
615}
616
617function countsLine(plugin: string, delta: Delta, at: number): CountsLine {
618 const slow: Record<string, SlowStat> = {}
619 for (const [event, stat] of Object.entries(delta.slow)) {
620 slow[event] = { n: stat.n, max: roundMs(stat.max), p95: roundMs(p95(reservoirOf(plugin, event).values)) }
621 }
622 return {
623 t: 'counts',
624 ts: at,
625 plugin,
626 runs: { ...delta.runs },
627 procs: delta.procs,
628 procFails: delta.procFails,
629 writes: delta.writes,
630 toasts: delta.toasts,
631 models: delta.models,
632 slow,
633 fails: delta.fails,
634 cmds: delta.cmds,
635 tools: delta.tools,
636 ...(delta.logErrors > 0 ? { logErrors: delta.logErrors } : {}),
637 }
638}
639
640/** The counts since the last write, as the lines the next write will add (the pane reads them live). */
641function pendingCounts(at: number): CountsLine[] {
642 return [...deltas.entries()].filter(([, delta]) => !isEmpty(delta)).map(([plugin, delta]) => countsLine(plugin, delta, at))
643}
644
645function emitCounts(at: number) {
646 for (const line of pendingCounts(at)) {
647 push(line)
648 }
649 deltas = new Map()
650}
651
652/** Starts this session's file over (a new day, or a new session after /clear): every loaded mod is seen in it again. */
653function startBuffer(at: number) {
654 entries = []
655 folding = new Map()
656 windowLines = new Map()
657 isDirty = false
658 for (const mod of mods.values()) {
659 push(seenLine(mod, [...mod.via][0] ?? 'origin', at))
660 }
661}
662
663/** Keeps the buffer under the file limit, written or not; a line trimmed away takes no more repeats. */
664function trimBuffer() {
665 const kept = trimEntries(entries)
666 if (kept.length !== entries.length) {
667 const still = new Set(kept)
668 for (const [key, open] of folding) {
669 if (!still.has(open.entry)) {
670 folding.delete(key)
671 }
672 }
673 }
674 entries = kept
675}
676
677async function writeFile($: Engine, which: string) {
678 trimBuffer()
679 if (!home || !sessionId || !isDirty) {
680 return
681 }
682 const text = serialize(entries)
683 isDirty = false
684 try {
685 await $.fs.write(sessionPath(home, which, sessionId), text)
686 } catch (error) {
687 isDirty = true
688 await debug($, `could not write the log: ${String(error)}`)
689 }
690}
691
692function backupOf(): MonitorBackup {
693 return {
694 sessionId,
695 mods: [...mods.values()].map(mod => ({
696 name: mod.name,
697 via: [...mod.via],
698 covered: mod.covered,
699 ...(mod.tier ? { tier: mod.tier } : {}),
700 ...(mod.version ? { version: mod.version } : {}),
701 ...(mod.provenance ? { provenance: mod.provenance } : {}),
702 })),
703 commands: [...commandOwner.entries()],
704 tools: [...toolOwner.entries()],
705 alerted: [...alerted],
706 procAlerted: [...procAlerted],
707 failures: [...failureCounts.entries()],
708 statuses: [...statuses.entries()],
709 noted: [...noted],
710 }
711}
712
713function restoreFrom(saved: MonitorBackup) {
714 for (const one of saved.mods) {
715 const mod = mods.get(one.name) ?? { name: one.name, via: new Set<SeenVia>(), covered: false }
716 for (const via of one.via) {
717 mod.via.add(via)
718 }
719 mod.covered ||= one.covered
720 mod.tier ??= one.tier
721 mod.version ??= one.version
722 mod.provenance ??= one.provenance
723 mods.set(one.name, mod)
724 }
725 for (const [name, plugin] of saved.commands) {
726 if (!commandOwner.has(name)) {
727 commandOwner.set(name, plugin)
728 }
729 }
730 for (const [name, plugin] of saved.tools) {
731 if (!toolOwner.has(name)) {
732 toolOwner.set(name, plugin)
733 }
734 }
735 saved.alerted.forEach(name => alerted.add(name))
736 saved.procAlerted.forEach(name => procAlerted.add(name))
737 for (const [name, count] of saved.failures) {
738 failureCounts.set(name, Math.max(count, failureCounts.get(name) ?? 0))
739 }
740 for (const [name, text] of saved.statuses) {
741 if (!statuses.has(name)) {
742 statuses.set(name, text)
743 }
744 }
745 saved.noted.forEach(key => noted.add(key))
746}
747
748async function flushNow($: Engine, isFinal: boolean) {
749 await ensureStarted($)
750 if (!isFinal) {
751 await syncClock($)
752 }
753 const at = now()
754 const today = dayOf(at)
755 day ??= today
756 emitCounts(at)
757 if (!sessionId) {
758 const id = await $.session.id().catch(() => null)
759 // Right after a /clear the old id may still answer: its file is complete, never overwritten.
760 sessionId = id && id !== endedSession ? id : null
761 }
762 if (today !== day) {
763 await writeFile($, day)
764 day = today
765 startBuffer(at)
766 }
767 for (const [key, open] of folding) {
768 if (open.until < at) {
769 folding.delete(key)
770 }
771 }
772 windowLines = new Map()
773 await writeFile($, day)
774 if (isFinal) {
775 return
776 }
777 try {
778 await update($, backup, () => backupOf())
779 } catch (error) {
780 await debug($, `could not keep the inventory: ${String(error)}`)
781 }
782 if (isPaneOpen) {
783 await readOthers($, today)
784 await bump($)
785 }
786}
787
788/** Writes this session's file; flushes run one at a time. */
789function flush($: Engine, isFinal = false): Promise<void> {
790 const run = writing.then(() => flushNow($, isFinal))
791 writing = run.catch(() => undefined)
792 return run.catch(error => debug($, `flush failed: ${String(error)}`))
793}
794
795// ---------------------------------------------------------------------------
796// Start: who is expected, what a reload left, and retention.
797
798/** The mods the plugin folders name, by their manifests' names (the folder's name when it has none). */
799async function loadExpected($: Engine): Promise<string[]> {
800 const raw = (await $.env.get('CLAUDE_CODE_PLUGIN_DIRS').catch(() => undefined)) ?? ''
801 const dirs = raw
802 .split(':')
803 .map(dir => dir.trim())
804 .filter(Boolean)
805 .map(dir => (home && (dir === '~' || dir.startsWith('~/')) ? `${home}${dir.slice(1)}` : dir).replace(/\/+$/, ''))
806 const names = await Promise.all(
807 dirs.map(async dir => {
808 try {
809 const manifest: unknown = JSON.parse(await $.fs.read(`${dir}/.claude-plugin/plugin.json`))
810 const name = (manifest as { name?: unknown } | null)?.name
811 return typeof name === 'string' && name ? name : basename(dir)
812 } catch {
813 return basename(dir)
814 }
815 }),
816 )
817 return names.filter((name, i) => name && name !== SELF && names.indexOf(name) === i)
818}
819
820/** Removes day folders past `retentionDays`, and only those: dated folders directly inside the monitor's folder. */
821async function prune($: Engine) {
822 if (!home || !home.startsWith('/')) {
823 return
824 }
825 const root = monitorRoot(home)
826 const listed = await $.fs.list(root).catch(() => [])
827 const days = listed.filter(entry => entry.kind === 'dir' && !entry.isLink).map(entry => entry.name)
828 for (const name of expiredDays(days, now(), config.retentionDays)) {
829 const dir = `${root}/${name}`
830 if (!isDayName(name) || !dir.startsWith(`${root}/`) || dir.includes('/../')) {
831 continue
832 }
833 const out = await $.process.run(['/bin/rm', '-rf', '--', dir], { timeoutMs: 30_000 }).catch(() => null)
834 if (!out || out.exitCode !== 0) {
835 await debug($, `could not remove ${homeRelative(dir, home)}`)
836 }
837 }
838}
839
840async function readHome($: Engine) {
841 home ??= (await $.env.get('HOME').catch(() => undefined)) || null
842}
843
844async function start($: Engine) {
845 try {
846 await readHome($)
847 sessionId ??= await $.session.id().catch(() => null)
848 const saved = await read($, backup).catch(() => EMPTY_BACKUP)
849 if (saved.sessionId && saved.sessionId === sessionId) {
850 restoreFrom(saved)
851 }
852 expected = await loadExpected($)
853 if (home && sessionId) {
854 day ??= dayOf(now())
855 // A reload of the monitor: this session's file already holds its earlier lines.
856 const before = await $.fs.read(sessionPath(home, day, sessionId)).catch(() => null)
857 if (before) {
858 entries = [...parseLines(before).map(line => ({ line, json: null })), ...entries]
859 }
860 }
861 isPaneOpen ||= await $.ui
862 .panes()
863 .then(panes => panes.some(pane => pane.id === PANE))
864 .catch(() => false)
865 if (isPaneOpen) {
866 startLive($)
867 }
868 void prune($)
869 } catch (error) {
870 await debug($, `could not start: ${String(error)}`)
871 }
872}
873
874function ensureStarted($: Engine): Promise<void> {
875 starting ??= start($)
876 return starting
877}
878
879function ensureTimer($: Engine) {
880 try {
881 flushTimer ??= $.clock.every(config.flushMs, () => void flush($))
882 } catch {
883 // No timer: the log is still written at the end and by /mods report.
884 }
885}
886
887/** A /clear: the old session's file is complete; the next lines go to the new session's. */
888function newSession(ended: string, at: number) {
889 endedSession = ended
890 sessionId = null
891 deltas = new Map()
892 reservoirs = new Map()
893 alerted = new Set()
894 procAlerted = new Set()
895 failureCounts = new Map()
896 procFailTimes = new Map()
897 logErrorTimes = new Map()
898 logAlerted = new Set()
899 statuses = new Map()
900 noted = new Set()
901 day = dayOf(at)
902 startBuffer(at)
903}
904
905// ---------------------------------------------------------------------------
906// The pane and the commands.
907
908async function bump($: Engine) {
909 hasChanged = false
910 try {
911 await update($, tick, n => n + 1)
912 } catch {
913 // Nothing reads it yet.
914 }
915}
916
917function startLive($: Engine) {
918 try {
919 liveTimer ??= $.clock.every(LIVE_MS, () => {
920 if (!isPaneOpen) {
921 liveTimer?.cancel()
922 liveTimer = null
923 } else if (hasChanged) {
924 void bump($)
925 }
926 })
927 } catch {
928 // The pane still redraws at each flush.
929 }
930}
931
932/** One day's session files, every session's (this one's left out when asked). */
933async function readDay($: Engine, which: string, skip?: string): Promise<SessionFile[]> {
934 if (!home) {
935 return []
936 }
937 const dir = `${monitorRoot(home)}/${which}`
938 const listed = await $.fs.list(dir).catch(() => [])
939 const files = listed.filter(entry => entry.kind === 'file' && entry.name.endsWith('.jsonl') && entry.name !== skip)
940 const found = await Promise.all(
941 files.map(async (entry): Promise<SessionFile | null> => {
942 const text = await $.fs.read(`${dir}/${entry.name}`).catch(() => null)
943 return text === null ? null : { session: entry.name.replace(/\.jsonl$/, ''), day: which, lines: parseLines(text) }
944 }),
945 )
946 return found.filter((file): file is SessionFile => file !== null)
947}
948
949async function readOthers($: Engine, today: string) {
950 others = await readDay($, today, sessionId ? sessionFileName(sessionId) : undefined)
951 othersDay = today
952}
953
954function procBursts(at: number): Set<string> {
955 const bursting = new Set<string>()
956 for (const [plugin, times] of procFailTimes) {
957 if (times.filter(time => at - time < PROC_WINDOW_MS).length >= PROC_BURST) {
958 bursting.add(plugin)
959 }
960 }
961 // A mod that keeps logging its own errors is failing too, though no hook threw.
962 for (const [plugin, times] of logErrorTimes) {
963 if (times.filter(time => at - time < LOG_WINDOW_MS).length >= LOG_BURST) {
964 bursting.add(plugin)
965 }
966 }
967 return bursting
968}
969
970function loadedNames(): Set<string> {
971 return new Set([...mods.keys()].filter(name => name !== SELF))
972}
973
974/** The pane's figures: today's other sessions as last read, and this session live. */
975function currentView(): PaneView {
976 const at = now()
977 const today = dayOf(at)
978 const own: SessionFile = {
979 session: sessionId ? sessionFileName(sessionId).replace(/\.jsonl$/, '') : 'this',
980 day: today,
981 lines: [...entries.map(entry => entry.line), ...pendingCounts(at)],
982 }
983 const files = othersDay === today ? [...others, own] : [own]
984 const loaded = loadedNames()
985 const covered = new Set([...mods.values()].filter(mod => mod.covered).map(mod => mod.name))
986 const folder = home ? `${homeRelative(monitorRoot(home), home)}/${today}/` : 'not written: HOME is not set'
987 return paneView({
988 now: at,
989 expected,
990 loaded,
991 covered,
992 procBursts: procBursts(at),
993 today: aggregate(files, startOfDay(at)),
994 folder,
995 })
996}
997
998function summaryOf(view: PaneView): string {
999 const lines = [view.header]
1000 for (const row of view.rows.filter(one => one.mark === '⚠')) {
1001 lines.push(`⚠ ${row.name}: ${row.last}`)
1002 }
1003 const missing = view.rows.filter(row => row.mark === '✗').map(row => row.name)
1004 if (missing.length > 0) {
1005 lines.push(`✗ not seen: ${missing.join(', ')} (not loaded, or silent so far)`)
1006 }
1007 lines.push(view.coverage)
1008 return lines.join('\n')
1009}
1010
1011async function openPane($: Engine): Promise<string> {
1012 await ensureStarted($)
1013 await syncClock($)
1014 isPaneOpen = true
1015 await readOthers($, dayOf(now()))
1016 startLive($)
1017 const opened = await $.ui.open({ id: PANE, title: TITLE }).catch(() => null)
1018 await bump($)
1019 const text = summaryOf(currentView())
1020 return opened && !opened.isPlaced ? `${text}\n(The Mods pane is waiting for room: ${opened.reason}.)` : text
1021}
1022
1023/** Every session's lines over a range, read from the day folders it touches. */
1024async function readRange($: Engine, since: number, at: number): Promise<SessionFile[]> {
1025 return (await Promise.all(daysBetween(since, at).map(which => readDay($, which)))).flat()
1026}
1027
1028/** Keeps the latest report where a scheduled review (or Claude) can read it. */
1029async function writeLatest($: Engine, root: string, text: string): Promise<string> {
1030 const path = `${root}/report-latest.md`
1031 const shown = homeRelative(path, home)
1032 try {
1033 await $.fs.write(path, text)
1034 return `Written to ${shown}`
1035 } catch (error) {
1036 return `Could not write ${shown}: ${keep(String(error), 120)}`
1037 }
1038}
1039
1040async function report($: Engine, arg: string | undefined, kind: 'report' | 'failures'): Promise<string> {
1041 const range = rangeOf(arg, '7d')
1042 if (!range) {
1043 return `mod-monitor: "${arg ?? ''}" is not a range; use 24h, 7d or 30d.`
1044 }
1045 await flush($)
1046 if (!home) {
1047 return 'mod-monitor: HOME is not set, so there are no logs to read.'
1048 }
1049 const at = now()
1050 const since = at - range.ms
1051 const files = await readRange($, since, at)
1052 const sessions = new Set(files.map(file => file.session)).size
1053 const input = { now: at, since, range: range.label, sessions, expected, mods: aggregate(files, since) }
1054 if (kind === 'failures') {
1055 return failuresText(input)
1056 }
1057 const text = reportText(input)
1058 return `${text}\n${await writeLatest($, monitorRoot(home), text)}`
1059}
1060
1061async function runMods($: Engine, args: string): Promise<string> {
1062 const [sub = '', arg] = args.trim().split(/\s+/)
1063 switch (sub.toLowerCase()) {
1064 case '':
1065 return openPane($)
1066 case 'report':
1067 return report($, arg, 'report')
1068 case 'failures':
1069 return report($, arg, 'failures')
1070 case 'help':
1071 return HELP
1072 default:
1073 return `mod-monitor: no /mods ${sub}.\n${HELP}`
1074 }
1075}
1076
1077const COLORS: Record<RowMark, string | undefined> = { '✓': 'green', '⚠': 'yellow', '✗': 'red', '·': undefined }
1078
1079export const register: Register = (on, options) => {
1080 const slowMs = Math.max(1, Number(options.slowMs ?? DEFAULTS.slowMs) || DEFAULTS.slowMs)
1081 config = {
1082 alerts: options.alerts !== false,
1083 slowMs,
1084 renderSlowMs: Math.min(RENDER_SLOW_MS, slowMs),
1085 watchRender: options.watchRender !== false,
1086 watchCommands: options.watchCommands !== false,
1087 watchAppend: options.watchAppend !== false,
1088 retentionDays: Math.max(1, Math.round(Number(options.retentionDays ?? DEFAULTS.retentionDays) || DEFAULTS.retentionDays)),
1089 flushMs: Math.min(3_600, Math.max(5, Number(options.flushSeconds ?? 60) || 60)) * 1000,
1090 }
1091 home = null
1092 sessionId = null
1093 clockOffset = 0
1094 expected = []
1095 mods = new Map()
1096 commandOwner = new Map()
1097 toolOwner = new Map()
1098 deltas = new Map()
1099 reservoirs = new Map()
1100 alerted = new Set()
1101 procAlerted = new Set()
1102 failureCounts = new Map()
1103 procFailTimes = new Map()
1104 logErrorTimes = new Map()
1105 logAlerted = new Set()
1106 statuses = new Map()
1107 noted = new Set()
1108 folding = new Map()
1109 windowLines = new Map()
1110 day = null
1111 entries = []
1112 isDirty = false
1113 others = []
1114 othersDay = null
1115 isPaneOpen = false
1116 hasChanged = false
1117 starting = null
1118 flushTimer = null
1119 liveTimer = null
1120 writing = Promise.resolve()
1121 endedSession = null
1122
1123 // The dispatches, each by name (a glob would take turn.step's stream and per-keystroke events with it).
1124 // Registered first, so they stand above the monitor's own hooks on the same events.
1125 on('session.start', async ($, e, next) => {
1126 // The time and HOME first: the mods beneath may write while their session starts.
1127 await Promise.all([syncClock($), readHome($)])
1128 try {
1129 return await watch($, 'session.start', next, () => next(e))
1130 } finally {
1131 try {
1132 await $.command.register({
1133 name: 'mods',
1134 description: 'Your mods: health, failures, slow hooks and what each did today (report, failures, help)',
1135 argumentHint: '[report|failures [24h|7d|30d]|help]',
1136 immediate: true,
1137 })
1138 } catch (error) {
1139 await debug($, `could not register /mods: ${String(error)}`)
1140 }
1141 ensureTimer($)
1142 void ensureStarted($)
1143 }
1144 })
1145
1146 on('session.end', async ($, e, next) => {
1147 // Written first: the exit's short bound is shared by every plugin's hook.
1148 if (next.budget.remainingMs > 500) {
1149 await flush($, true)
1150 }
1151 try {
1152 return await watch($, 'session.end', next, () => next(e))
1153 } finally {
1154 if (isDirty && next.budget.remainingMs > 200) {
1155 await flush($, true)
1156 }
1157 if (e.reason === 'clear') {
1158 safely(() => newSession(e.sessionId, now()))
1159 }
1160 }
1161 })
1162
1163 on('prompt.submit', ($, e, next) => watch($, 'prompt.submit', next, () => next(e)))
1164 on('prompt.context', ($, e, next) => watch($, 'prompt.context', next, () => next(e)))
1165 if (config.watchCommands) {
1166 // Hooking every command also lists the monitor beside a mod's name on its command output.
1167 on('command.run', ($, e, next) =>
1168 watch($, 'command.run', next, () => next(e), false, () => onCommand(e.command, e.args, e.origin.kind, next.trace)),
1169 )
1170 }
1171 on('tool.call', ($, e, next) => watch($, 'tool.call', next, () => next(e), false, () => onToolCall(e.tool)))
1172 on('tool.check', ($, e, next) => watch($, 'tool.check', next, () => next(e)))
1173 on('turn.start', ($, e, next) => watch($, 'turn.start', next, () => next(e)))
1174 on('turn.complete', ($, e, next) => watch($, 'turn.complete', next, () => next(e)))
1175 on('session.compact', ($, e, next) => watch($, 'session.compact', next, () => next(e)))
1176 if (config.watchAppend) {
1177 // Every transcript row: failures and slow hooks only, no per-run counts (secret-guard masks rows here).
1178 on('session.append', ($, e, next) => watch($, 'session.append', next, () => next(e), true))
1179 }
1180 if (config.watchRender) {
1181 // Only the components mods draw; transcript rows are left alone.
1182 on('ui.render', { component: ['Pane', 'AbovePrompt'] }, ($, e, next) => watch($, 'ui.render', next, () => next(e), true))
1183 }
1184
1185 // The `$` calls the mods make, by who made them. A toast, a status line and a write are
1186 // noted as they are raised (in the order they were made), and handed on untouched.
1187 on('ui.toast', ($, e, next) => {
1188 safely(() => {
1189 const plugin = callerOf(next.origin)
1190 if (plugin) {
1191 onToast(plugin, e.text)
1192 }
1193 })
1194 return next(e)
1195 })
1196 on('ui.status', ($, e, next) => {
1197 safely(() => {
1198 const plugin = callerOf(next.origin)
1199 if (plugin) {
1200 onStatus(plugin, e.text)hooks/aggregate.ts 581 lines1/**
2 * Folding day-file lines into one summary per mod, and the three ways it is
3 * shown: the pane's rows, `/mods report` and `/mods failures`. Pure.
4 */
5
6import type { CountsLine, EventLine, FailureOutcome, Line, TokenUsage } from '../types'
7import { clockOf, stampOf } from './log'
8import { ageText, clip, countText, msText } from './mask'
9
10/** One session's lines from one day folder (or this session's, live). */
11export type SessionFile = { session: string; day: string; lines: readonly Line[] }
12
13export type FailureGroup = {
14 event: string
15 outcome: FailureOutcome
16 what: string
17 n: number
18 lastTs: number
19 lastReason?: string
20 maxMs: number
21}
22
23export type ProcGroup = { cmd: string; n: number; lastTs: number; lastExit: number | null; lastErr?: string }
24
25export type SlowGroup = { event: string; n: number; max: number; p95: number }
26
27export type ModSummary = {
28 name: string
29 /** Sessions it was loaded in (a `seen` line in that session's file). */
30 sessions: Set<string>
31 runs: Record<string, number>
32 runsTotal: number
33 /** Runs on events other than a session's start and end, which every loaded mod gets. */
34 activeRuns: number
35 toasts: number
36 toastTexts: Map<string, number>
37 cmds: number
38 commands: Map<string, { n: number; bare: number }>
39 tools: number
40 procs: number
41 procFails: number
42 /** Error lines the mod logged itself. */
43 logErrors: number
44 procGroups: Map<string, ProcGroup>
45 writes: number
46 models: number
47 modelNames: Map<string, number>
48 /** Model calls that came back with no answer, by outcome. */
49 modelMisses: Map<string, number>
50 tokens: TokenUsage
51 fails: number
52 failureGroups: Map<string, FailureGroup>
53 slow: Map<string, SlowGroup>
54 /** Everything it did, as logged (not `seen`). */
55 events: EventLine[]
56 /** When it last did anything; 0 when it never did. */
57 lastActivity: number
58}
59
60/** The events every loaded mod's hooks run on, which say nothing about it being in use. */
61const STARTUP = new Set(['session.start', 'session.end'])
62
63const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
64
65function blank(name: string): ModSummary {
66 return {
67 name,
68 sessions: new Set(),
69 runs: {},
70 runsTotal: 0,
71 activeRuns: 0,
72 toasts: 0,
73 toastTexts: new Map(),
74 cmds: 0,
75 commands: new Map(),
76 tools: 0,
77 procs: 0,
78 procFails: 0,
79 logErrors: 0,
80 procGroups: new Map(),
81 writes: 0,
82 models: 0,
83 modelNames: new Map(),
84 modelMisses: new Map(),
85 tokens: { in: 0, out: 0, cacheRead: 0, cacheWrite: 0 },
86 fails: 0,
87 failureGroups: new Map(),
88 slow: new Map(),
89 events: [],
90 lastActivity: 0,
91 }
92}
93
94function addCounts(mod: ModSummary, line: CountsLine) {
95 let active = false
96 for (const [event, value] of Object.entries(line.runs ?? {})) {
97 const n = num(value)
98 mod.runs[event] = (mod.runs[event] ?? 0) + n
99 mod.runsTotal += n
100 if (!STARTUP.has(event) && n > 0) {
101 mod.activeRuns += n
102 active = true
103 }
104 }
105 const procs = num(line.procs)
106 const procFails = num(line.procFails)
107 const writes = num(line.writes)
108 const toasts = num(line.toasts)
109 const models = num(line.models)
110 const fails = num(line.fails)
111 const cmds = num(line.cmds)
112 const tools = num(line.tools)
113 mod.procs += procs
114 mod.procFails += procFails
115 mod.logErrors += num(line.logErrors)
116 mod.writes += writes
117 mod.toasts += toasts
118 mod.models += models
119 mod.fails += fails
120 mod.cmds += cmds
121 mod.tools += tools
122 for (const [event, stat] of Object.entries(line.slow ?? {})) {
123 const known = mod.slow.get(event)
124 const n = num(stat?.n)
125 const max = num(stat?.max)
126 const p95 = num(stat?.p95)
127 mod.slow.set(event, {
128 event,
129 n: (known?.n ?? 0) + n,
130 max: Math.max(known?.max ?? 0, max),
131 // A rough figure: the highest p95 any window reported.
132 p95: Math.max(known?.p95 ?? 0, p95),
133 })
134 }
135 if (active || procs + writes + toasts + models + fails + cmds + tools > 0) {
136 mod.lastActivity = Math.max(mod.lastActivity, line.ts)
137 }
138}
139
140function addEvent(mod: ModSummary, line: EventLine, session: string) {
141 const n = Math.max(1, num(line.n) || 1)
142 const at = Math.max(line.ts, num(line.last))
143 switch (line.kind) {
144 case 'seen':
145 mod.sessions.add(session)
146 return
147 case 'failure': {
148 const key = `${line.event}|${line.outcome}`
149 const known = mod.failureGroups.get(key)
150 const isLater = !known || at >= known.lastTs
151 mod.failureGroups.set(key, {
152 event: line.event,
153 outcome: line.outcome,
154 what: line.what,
155 n: (known?.n ?? 0) + n,
156 lastTs: Math.max(known?.lastTs ?? 0, at),
157 lastReason: isLater ? (line.reason ?? known?.lastReason) : known?.lastReason,
158 maxMs: Math.max(known?.maxMs ?? 0, num(line.ms)),
159 })
160 break
161 }
162 case 'toast':
163 mod.toastTexts.set(line.text, (mod.toastTexts.get(line.text) ?? 0) + n)
164 break
165 case 'proc-fail': {
166 const known = mod.procGroups.get(line.cmd)
167 const isLater = !known || at >= known.lastTs
168 mod.procGroups.set(line.cmd, {
169 cmd: line.cmd,
170 n: (known?.n ?? 0) + n,
171 lastTs: Math.max(known?.lastTs ?? 0, at),
172 lastExit: isLater ? line.exit : (known?.lastExit ?? null),
173 lastErr: isLater ? (line.err ?? known?.lastErr) : known?.lastErr,
174 })
175 break
176 }
177 case 'model':
178 mod.modelNames.set(line.model, (mod.modelNames.get(line.model) ?? 0) + 1)
179 if (line.outcome !== 'answered') {
180 mod.modelMisses.set(line.outcome, (mod.modelMisses.get(line.outcome) ?? 0) + 1)
181 }
182 if (line.usage) {
183 mod.tokens.in += num(line.usage.in)
184 mod.tokens.out += num(line.usage.out)
185 mod.tokens.cacheRead += num(line.usage.cacheRead)
186 mod.tokens.cacheWrite += num(line.usage.cacheWrite)
187 }
188 break
189 case 'command': {
190 const known = mod.commands.get(line.command) ?? { n: 0, bare: 0 }
191 mod.commands.set(line.command, { n: known.n + n, bare: known.bare + (line.hasArgs ? 0 : n) })
192 break
193 }
194 default:
195 break
196 }
197 mod.events.push(line)
198 if (line.kind !== 'register') {
199 mod.lastActivity = Math.max(mod.lastActivity, at)
200 }
201}
202
203/** One summary per mod named in the files, from `since` on. */
204export function aggregate(files: readonly SessionFile[], since = -Infinity): Map<string, ModSummary> {
205 const mods = new Map<string, ModSummary>()
206 for (const file of files) {
207 for (const line of file.lines) {
208 if (line.ts < since && num(line.t === 'event' ? line.last : 0) < since) {
209 continue
210 }
211 let mod = mods.get(line.plugin)
212 if (!mod) {
213 mod = blank(line.plugin)
214 mods.set(line.plugin, mod)
215 }
216 if (line.t === 'counts') {
217 addCounts(mod, line)
218 } else {
219 addEvent(mod, line, file.session)
220 }
221 }
222 }
223 return mods
224}
225
226/** Whether a mod did anything at all (beyond being loaded). */
227export function isActive(mod: ModSummary | undefined): boolean {
228 return mod !== undefined && mod.lastActivity > 0
229}
230
231const times = (n: number) => (n > 1 ? ` ×${countText(n)}` : '')
232
233/** One logged event as the pane's Details and the reports say it. */
234export function eventText(line: EventLine): string {
235 const n = times(num(line.n))
236 switch (line.kind) {
237 case 'seen':
238 return `loaded (${line.via}${line.version ? `, v${line.version}` : ''})`
239 case 'failure':
240 return `✗ ${line.event} hook ${line.what}${line.reason ? `: ${line.reason}` : ''} (${msText(line.ms)})${n}`
241 case 'slow':
242 return `slow ${line.event} hook: ${msText(line.ms)}${n}`
243 case 'toast':
244 return `toast: ${line.text}${n}`
245 case 'status':
246 return `status: ${line.text || '(cleared)'}${n}`
247 case 'proc-fail':
248 return `✗ ${line.cmd}: ${line.exit === null ? 'did not run' : `exit ${line.exit}`}${line.err ? ` — ${line.err}` : ''}${n}`
249 case 'proc-slow':
250 return `slow process ${line.cmd}: ${msText(line.ms)}`
251 case 'proc-expected':
252 return `· ${line.cmd}: ${line.why} (expected)${n}`
253 case 'log':
254 return `${line.isError ? '✗ logged' : 'logged'}: ${line.text}${n}`
255 case 'model': {
256 const usage = line.usage
257 const tokens = usage ? `, ${countText(usage.in + usage.cacheRead + usage.cacheWrite)} in / ${countText(usage.out)} out` : ''
258 return `model ${line.model}: ${line.outcome}${tokens}`
259 }
260 case 'write':
261 return `wrote to ${line.dir}`
262 case 'command':
263 return `/${line.command}${line.hasArgs ? ' …' : ''}${line.by === 'composer' ? '' : ` (from ${line.by})`}${n}`
264 case 'register':
265 return `registered ${line.what === 'command' ? `/${line.name}` : `tool ${line.name}`}`
266 }
267}
268
269/** The newest event of the kinds given. */
270function newest(mod: ModSummary, kinds: readonly string[]): EventLine | undefined {
271 let best: EventLine | undefined
272 for (const line of mod.events) {
273 if (kinds.includes(line.kind) && (!best || Math.max(line.ts, num(line.last)) >= Math.max(best.ts, num(best.last)))) {
274 best = line
275 }
276 }
277 return best
278}
279
280/** What a mod last did, in a few words: its last toast or command, else its last other sign of life. */
281export function lastNote(mod: ModSummary): string {
282 const line = newest(mod, ['toast', 'command']) ?? newest(mod, ['failure', 'proc-fail', 'status', 'model', 'slow', 'write'])
283 return line ? eventText(line) : ''
284}
285
286// ---------------------------------------------------------------------------
287// The pane.
288
289export type RowMark = '✓' | '⚠' | '✗' | '·'
290
291export type PaneRow = {
292 name: string
293 mark: RowMark
294 /** `2m ago · toast: …`, or what is known when it did nothing. */
295 last: string
296 /** Today's counts, the ones that are not zero. */
297 counts: string
298 /** Its events today, newest first, 20 at most. */
299 details: string[]
300}
301
302export type PaneView = { header: string; rows: PaneRow[]; coverage: string; folder: string }
303
304export type PaneInput = {
305 now: number
306 /** The mods the plugin folders name (the monitor left out); empty when none are named. */
307 expected: readonly string[]
308 /** The mods seen this session. */
309 loaded: ReadonlySet<string>
310 /** The mods seen beneath the monitor in a trace this session. */
311 covered: ReadonlySet<string>
312 /** The mods whose processes failed 5 times in the last 10 minutes. */
313 procBursts: ReadonlySet<string>
314 /** Today's summaries, every session's. */
315 today: ReadonlyMap<string, ModSummary>
316 /** Where today's logs are, as shown. */
317 folder: string
318}
319
320export const DETAILS_MAX = 20
321
322/** The health mark: ✗ expected but not seen this session, ⚠ failing, ✓ active, · seen but idle today. */
323export function markOf(name: string, input: PaneInput): RowMark {
324 const mod = input.today.get(name)
325 if (!input.loaded.has(name)) {
326 return input.expected.includes(name) ? '✗' : mod && mod.fails > 0 ? '⚠' : '·'
327 }
328 if ((mod && (mod.fails > 0 || mod.failureGroups.size > 0)) || input.procBursts.has(name)) {
329 return '⚠'
330 }
331 return isActive(mod) ? '✓' : '·'
332}
333
334/** Today's counts in a line, zeros left out. */
335export function countsText(mod: ModSummary | undefined): string {
336 if (!mod) {
337 return ''
338 }
339 const parts: [string, number][] = [
340 ['hooks', mod.runsTotal],
341 ['toasts', mod.toasts],
342 ['commands', mod.cmds],
343 ['tool calls', mod.tools],
344 ['process failures', mod.procFails],
345 ['errors logged', mod.logErrors],
346 ['model calls', mod.models],
347 ]
348 return parts
349 .filter(([, n]) => n > 0)
350 .map(([label, n]) => `${label} ${countText(n)}`)
351 .join(' · ')
352}
353
354function rowOf(name: string, input: PaneInput): PaneRow {
355 const mod = input.today.get(name)
356 const mark = markOf(name, input)
357 let last: string
358 if (mark === '✗') {
359 last = 'not seen in this session (not loaded, or silent so far)'
360 } else if (mod && mod.lastActivity > 0) {
361 const note = lastNote(mod)
362 const age = ageText(input.now - mod.lastActivity)
363 last = `${age === 'now' ? 'just now' : `${age} ago`}${note ? ` · ${note}` : ''}`
364 } else {
365 last = 'loaded, nothing done today'
366 }
367 const details = mod
368 ? [...mod.events]
369 .filter(line => line.kind !== 'seen')
370 .sort((a, b) => Math.max(b.ts, num(b.last)) - Math.max(a.ts, num(a.last)))
371 .slice(0, DETAILS_MAX)
372 .map(line => `${clockOf(Math.max(line.ts, num(line.last)))} ${eventText(line)}`)
373 : []
374 return { name, mark, last, counts: countsText(mod), details }
375}
376
377/** Every row the pane shows: the expected mods first, in their order, then any other mod loaded now. */
378export function paneView(input: PaneInput): PaneView {
379 const names = [...input.expected]
380 const extra = [...input.loaded].filter(name => !names.includes(name)).sort()
381 if (input.expected.length === 0) {
382 for (const name of [...input.today.keys()].sort()) {
383 if (!extra.includes(name)) {
384 extra.push(name)
385 }
386 }
387 }
388 names.push(...extra)
389 const rows = names.map(name => rowOf(name, input))
390 const failing = rows.filter(row => row.mark === '⚠').length
391 const loadedCount = input.expected.length > 0 ? input.expected.filter(name => input.loaded.has(name)).length : input.loaded.size
392 const coveredCount = [...input.loaded].filter(name => input.covered.has(name)).length
393 const loadedText =
394 input.expected.length > 0 ? `${loadedCount} of ${input.expected.length} mods loaded` : `${loadedCount} mods loaded`
395 const header = `${loadedText} · ${coveredCount} covered · ${failing} failing today`
396 const above = [...input.loaded].filter(name => !input.covered.has(name)).sort()
397 const coverage =
398 input.loaded.size === 0
399 ? 'No mod has been seen yet this session.'
400 : above.length === 0
401 ? 'Every loaded mod has run beneath the monitor.'
402 : `Not seen beneath the monitor yet: ${above.join(', ')} (loaded above it, or idle). It sees failures only in the mods beneath it.`
403 return { header, rows, coverage, folder: input.folder }
404}
405
406// ---------------------------------------------------------------------------
407// The reports.
408
409export type ReportInput = {
410 now: number
411 since: number
412 /** `24h`, `7d`, `30d`. */
413 range: string
414 /** How many session files the range held. */
415 sessions: number
416 expected: readonly string[]
417 mods: ReadonlyMap<string, ModSummary>
418}
419
420const top = <K>(map: ReadonlyMap<K, number>, count: number): [K, number][] =>
421 [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, count)
422
423/** The mods a report covers: the expected ones in order, then any other that left a line. */
424function reportNames(input: ReportInput): string[] {
425 const names = [...input.expected]
426 for (const name of [...input.mods.keys()].sort()) {
427 if (!names.includes(name)) {
428 names.push(name)
429 }
430 }
431 return names
432}
433
434const wasSeen = (mod: ModSummary | undefined) => mod !== undefined && (mod.sessions.size > 0 || mod.lastActivity > 0 || mod.runsTotal > 0)
435
436function hookFailureLines(mod: ModSummary): string[] {
437 if (mod.failureGroups.size === 0 && mod.fails === 0) {
438 return []
439 }
440 const groups = [...mod.failureGroups.values()].sort((a, b) => b.n - a.n)
441 const total = Math.max(mod.fails, groups.reduce((sum, group) => sum + group.n, 0))
442 return [
443 ` hook failures ${countText(total)}:`,
444 ...groups.map(group => {
445 const reason = group.lastReason ? ` — ${group.lastReason}` : ''
446 return ` ${group.event} ${group.what} (${group.outcome}) ×${countText(group.n)}, last ${stampOf(group.lastTs)}, up to ${msText(group.maxMs)}${reason}`
447 }),
448 ]
449}
450
451function processFailureLines(mod: ModSummary): string[] {
452 if (mod.procGroups.size === 0 && mod.procFails === 0) {
453 return []
454 }
455 const groups = [...mod.procGroups.values()].sort((a, b) => b.n - a.n)
456 const total = Math.max(mod.procFails, groups.reduce((sum, group) => sum + group.n, 0))
457 return [
458 ` process failures ${countText(total)}:`,
459 ...groups.map(group => {
460 const exit = group.lastExit === null ? 'did not run' : `exit ${group.lastExit}`
461 return ` ${group.cmd} ×${countText(group.n)}, last ${stampOf(group.lastTs)} (${exit})${group.lastErr ? `: ${group.lastErr}` : ''}`
462 }),
463 ]
464}
465
466/** A mod's hook failures by event and outcome, then its process failures by command. */
467function failureLines(mod: ModSummary): string[] {
468 return [...hookFailureLines(mod), ...processFailureLines(mod)]
469}
470
471function runsLine(mod: ModSummary): string {
472 const runs = Object.entries(mod.runs)
473 .filter(([, n]) => n > 0)
474 .sort((a, b) => b[1] - a[1])
475 .map(([event, n]) => `${event} ${countText(n)}`)
476 return ` hook runs ${countText(mod.runsTotal)}${runs.length > 0 ? `: ${runs.join(' · ')}` : ''}`
477}
478
479function toastsLine(mod: ModSummary): string | null {
480 if (mod.toasts === 0 && mod.toastTexts.size === 0) {
481 return null
482 }
483 const shown = top(mod.toastTexts, 3).map(([text, n]) => `"${clip(text, 80)}" ×${countText(n)}`)
484 return ` toasts ${countText(Math.max(mod.toasts, mod.toastTexts.size))}${shown.length > 0 ? `: ${shown.join(' · ')}` : ''}`
485}
486
487function commandsLine(mod: ModSummary): string | null {
488 if (mod.commands.size === 0 && mod.cmds === 0) {
489 return null
490 }
491 const used = [...mod.commands.entries()]
492 .sort((a, b) => b[1].n - a[1].n)
493 .map(([name, use]) => `/${name} ×${countText(use.n)}${use.bare > 0 && use.bare < use.n ? ` (${countText(use.bare)} bare)` : ''}`)
494 return ` commands used: ${used.length > 0 ? used.join(' · ') : countText(mod.cmds)}`
495}
496
497const countLine = (label: string, n: number): string | null => (n > 0 ? ` ${label} ${countText(n)}` : null)
498
499function slowLine(mod: ModSummary): string | null {
500 if (mod.slow.size === 0) {
501 return null
502 }
503 const slow = [...mod.slow.values()]
504 .sort((a, b) => b.max - a.max)
505 .map(stat => `${stat.event} max ${msText(stat.max)}, p95 ${msText(stat.p95)} (${countText(stat.n)} slow)`)
506 return ` slowest hooks: ${slow.join(' · ')}`
507}
508
509function modelsLine(mod: ModSummary): string | null {
510 if (mod.models === 0 && mod.modelNames.size === 0) {
511 return null
512 }
513 const names = top(mod.modelNames, 5).map(([name, n]) => `${name} ×${countText(n)}`)
514 const misses = top(mod.modelMisses, 5).map(([outcome, n]) => `${outcome} ×${countText(n)}`)
515 const t = mod.tokens
516 return (
517 ` model calls ${countText(Math.max(mod.models, mod.modelNames.size))}: ${names.join(' · ')}` +
518 ` — ${countText(t.in)} in / ${countText(t.out)} out tokens (cache ${countText(t.cacheRead)} read / ${countText(t.cacheWrite)} written)` +
519 (misses.length > 0 ? `; unanswered: ${misses.join(' · ')}` : '')
520 )
521}
522
523function modSection(mod: ModSummary): string[] {
524 const sessions = mod.sessions.size
525 const lines: (string | null)[] = [
526 `${mod.name} — loaded in ${countText(sessions)} session${sessions === 1 ? '' : 's'}`,
527 runsLine(mod),
528 toastsLine(mod),
529 commandsLine(mod),
530 countLine('tool calls', mod.tools),
531 countLine('processes run', mod.procs),
532 countLine('files written', mod.writes),
533 ...failureLines(mod),
534 slowLine(mod),
535 modelsLine(mod),
536 ]
537 return lines.filter((line): line is string => line !== null)
538}
539
540/** `/mods report`: per mod what it did over the range, then the mods never seen. */
541export function reportText(input: ReportInput): string {
542 const names = reportNames(input)
543 const seen = names.filter(name => wasSeen(input.mods.get(name)))
544 const never = names.filter(name => !wasSeen(input.mods.get(name)))
545 const failing = seen.filter(name => {
546 const mod = input.mods.get(name)
547 return mod !== undefined && (mod.fails > 0 || mod.failureGroups.size > 0 || mod.procFails > 0 || mod.logErrors > 0)
548 })
549 const lines = [
550 `# Mods report: last ${input.range} (${stampOf(input.since)} to ${stampOf(input.now)})`,
551 '',
552 `${countText(input.sessions)} session${input.sessions === 1 ? '' : 's'} · ${seen.length} mod${seen.length === 1 ? '' : 's'} seen · ${failing.length} with failures`,
553 ]
554 for (const name of seen) {
555 const mod = input.mods.get(name)
556 if (mod) {
557 lines.push('', ...modSection(mod))
558 }
559 }
560 lines.push('', never.length > 0 ? `Never seen: ${never.join(', ')}` : 'Every expected mod was seen.')
561 return `${lines.join('\n')}\n`
562}
563
564/** `/mods failures`: hook failures and process errors alone. */
565export function failuresText(input: ReportInput): string {
566 const lines = [`# Mod failures: last ${input.range} (${stampOf(input.since)} to ${stampOf(input.now)})`]
567 let any = false
568 for (const name of reportNames(input)) {
569 const mod = input.mods.get(name)
570 const found = mod ? failureLines(mod) : []
571 if (found.length > 0) {
572 any = true
573 lines.push('', name, ...found)
574 }
575 }
576 if (!any) {
577 lines.push('', 'No hook failures or process errors.')
578 }
579 return `${lines.join('\n')}\n`
580}
581hooks/log.ts 180 lines1/**
2 * The day files: where they live, how a line is written and read back, which
3 * folders retention removes, and the ranges a report covers. Pure.
4 */
5
6import type { CountsLine, EventLine, Line } from '../types'
7
8export const DAY_MS = 86_400_000
9
10/** The monitor's folder under the home folder; nothing outside it is ever removed. */
11export function monitorRoot(home: string): string {
12 return `${home.replace(/\/+$/, '')}/.claude/mods/monitor`
13}
14
15const pad = (n: number) => String(n).padStart(2, '0')
16
17/** The local day of a time: `2026-10-08`. */
18export function dayOf(ms: number): string {
19 const d = new Date(ms)
20 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
21}
22
23/** Local midnight of the day a time falls on. */
24export function startOfDay(ms: number): number {
25 const d = new Date(ms)
26 return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
27}
28
29/** The local time of day: `14:03`. */
30export function clockOf(ms: number): string {
31 const d = new Date(ms)
32 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
33}
34
35/** `10-07 14:03`: a time within the last weeks, as a report names it. */
36export function stampOf(ms: number): string {
37 const d = new Date(ms)
38 return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${clockOf(ms)}`
39}
40
41const DAY_NAME = /^\d{4}-\d{2}-\d{2}$/
42
43/** Whether a folder name is a day folder the monitor made. */
44export function isDayName(name: string): boolean {
45 return DAY_NAME.test(name)
46}
47
48/** A session's file name: the first 8 characters of its id, letters and digits only. */
49export function sessionFileName(sessionId: string): string {
50 const short = sessionId.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8)
51 return `${short || 'session'}.jsonl`
52}
53
54export function sessionPath(home: string, day: string, sessionId: string): string {
55 return `${monitorRoot(home)}/${day}/${sessionFileName(sessionId)}`
56}
57
58/** The day folders (by name) that retention removes: day folders older than `retentionDays` days. */
59export function expiredDays(names: readonly string[], now: number, retentionDays: number): string[] {
60 const oldestKept = dayOf(now - Math.max(1, retentionDays) * DAY_MS)
61 return names.filter(name => isDayName(name) && name < oldestKept)
62}
63
64/** One line of a file, read back; anything else is skipped. */
65function asLine(value: unknown): Line | null {
66 if (typeof value !== 'object' || value === null) {
67 return null
68 }
69 const line = value as Record<string, unknown>
70 if (typeof line.plugin !== 'string' || typeof line.ts !== 'number') {
71 return null
72 }
73 if (line.t === 'event' && typeof line.kind === 'string') {
74 return line as EventLine
75 }
76 if (line.t === 'counts' && typeof line.runs === 'object' && line.runs !== null) {
77 return line as CountsLine
78 }
79 return null
80}
81
82/** The lines of a JSONL file; a line that does not parse is skipped. */
83export function parseLines(text: string): Line[] {
84 const lines: Line[] = []
85 for (const raw of text.split('\n')) {
86 if (!raw.trim()) {
87 continue
88 }
89 try {
90 const line = asLine(JSON.parse(raw))
91 if (line) {
92 lines.push(line)
93 }
94 } catch {
95 // A torn or foreign line: skipped.
96 }
97 }
98 return lines
99}
100
101/** A line held in memory with its JSON, kept until the line changes (a repeat folded into it). */
102export type Entry = { line: Line; json: string | null }
103
104export function jsonOf(entry: Entry): string {
105 entry.json ??= JSON.stringify(entry.line)
106 return entry.json
107}
108
109/** The whole file: one JSON line per entry. */
110export function serialize(entries: readonly Entry[]): string {
111 return entries.length === 0 ? '' : `${entries.map(jsonOf).join('\n')}\n`
112}
113
114/** What `$.fs.write` takes at most is 4 MiB; a file is trimmed well before. */
115export const MAX_FILE_BYTES = 3_500_000
116export const TRIMMED_FILE_BYTES = 3_000_000
117
118/**
119 * Keeps a file under `maxBytes`: when it is over, the oldest event lines go
120 * first (the `seen` lines and the counts stay), then the oldest counts, until
121 * it is under `targetBytes`. Returns the entries kept.
122 */
123export function trimEntries(entries: readonly Entry[], maxBytes = MAX_FILE_BYTES, targetBytes = TRIMMED_FILE_BYTES): Entry[] {
124 let bytes = entries.reduce((sum, entry) => sum + jsonOf(entry).length + 1, 0)
125 if (bytes <= maxBytes) {
126 return [...entries]
127 }
128 const drop = new Set<Entry>()
129 const passes: ((entry: Entry) => boolean)[] = [
130 entry => entry.line.t === 'event' && entry.line.kind !== 'seen',
131 entry => entry.line.t === 'counts',
132 ]
133 for (const isDroppable of passes) {
134 for (const entry of entries) {
135 if (bytes <= targetBytes) {
136 break
137 }
138 if (!drop.has(entry) && isDroppable(entry)) {
139 drop.add(entry)
140 bytes -= jsonOf(entry).length + 1
141 }
142 }
143 }
144 return entries.filter(entry => !drop.has(entry))
145}
146
147/** A report's range: `24h`, `7d`, `30d` (any whole number of hours or days, up to a year). */
148export type Range = { label: string; ms: number }
149
150export function rangeOf(arg: string | undefined, fallback: string): Range | null {
151 const text = (arg ?? '').trim().toLowerCase() || fallback
152 const match = /^(\d{1,4})\s*(h|d)$/.exec(text)
153 if (!match) {
154 return null
155 }
156 const amount = Number(match[1])
157 const ms = amount * (match[2] === 'h' ? 3_600_000 : DAY_MS)
158 if (amount < 1 || ms > 366 * DAY_MS) {
159 return null
160 }
161 return { label: `${amount}${match[2]}`, ms }
162}
163
164/** The day folders a range touches, oldest first: from the day `since` falls on to today. */
165export function daysBetween(since: number, now: number): string[] {
166 const days: string[] = []
167 const last = dayOf(now)
168 // Step by half a day so a day of 23 or 25 hours (a clock change) is never skipped.
169 for (let at = since; ; at += DAY_MS / 2) {
170 const day = dayOf(Math.min(at, now))
171 if (days[days.length - 1] !== day) {
172 days.push(day)
173 }
174 if (day === last || at >= now) {
175 break
176 }
177 }
178 return days
179}
180hooks/mask.ts 145 lines1/**
2 * The light secret mask applied to everything the monitor stores or shows,
3 * and the small text helpers around it. Pure: no `$`.
4 */
5
6const MASK = '[masked]'
7
8/** Known token shapes, most specific first; each is replaced whole. */
9const SHAPES: readonly RegExp[] = [
10 // A PEM block (a private key above all), to its END line or the end of the text.
11 /-----BEGIN [A-Z0-9 ]+-----[\s\S]*?(?:-----END [A-Z0-9 ]+-----|$)/g,
12 /(?<![A-Za-z0-9])(?:AKIA|ASIA)[A-Z0-9]{16}(?![A-Za-z0-9])/g,
13 /(?<![A-Za-z0-9_])github_pat_[A-Za-z0-9_]{20,}/g,
14 /(?<![A-Za-z0-9_])gh[pousr]_[A-Za-z0-9]{20,}/g,
15 /(?<![A-Za-z0-9_-])sk-ant-[A-Za-z0-9_-]{10,}/g,
16 /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{16,}/g,
17 /(?<![A-Za-z0-9_-])xox[a-z]-[A-Za-z0-9-]{10,}/g,
18 /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{30,}/g,
19 /(?<![A-Za-z0-9_])hf_[A-Za-z0-9]{20,}/g,
20 /(?<![A-Za-z0-9_-])glpat-[A-Za-z0-9_-]{16,}/g,
21 /(?<![A-Za-z0-9_])npm_[A-Za-z0-9]{30,}/g,
22]
23
24/** `Bearer <token>`: the word stays, the token goes. */
25const BEARER = /\b(Bearer)[ \t]+[A-Za-z0-9._~+/=-]{8,}/g
26
27/** `password: x`, `"passwd": "x"`: the label stays, the value goes. */
28const PASSWORD = /\b(passw(?:or)?d|pwd|passphrase)(["']?[ \t]*[:=][ \t]*)("[^"\n]*"|'[^'\n]*'|[^\s,;]+)/gi
29
30/** `token=x`, `API_KEY=x`, `secret=x` (an assignment or a query string, never prose with a colon). */
31const ASSIGNED = /\b([A-Za-z_]*(?:secret|token|api[_-]?key|access[_-]?key))([ \t]*=[ \t]*)("[^"\n]*"|'[^'\n]*'|[^\s&,;]+)/gi
32
33/** A password inside a URL: `scheme://user:password@host`. */
34const URL_PASSWORD = /([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s/@]+@/gi
35
36/** Replaces every known secret shape in `text`; text without one comes back as it was. */
37export function mask(text: string): string {
38 let out = text
39 for (const shape of SHAPES) {
40 out = out.replace(shape, MASK)
41 }
42 const labelled = (_all: string, label: string, sep: string) => `${label}${sep}${MASK}`
43 return out
44 .replace(BEARER, `$1 ${MASK}`)
45 .replace(PASSWORD, labelled)
46 .replace(ASSIGNED, labelled)
47 .replace(URL_PASSWORD, `$1${MASK}@`)
48}
49
50/** `text` on one line, cut to `max` characters with an ellipsis. */
51export function clip(text: string, max: number): string {
52 const flat = text.replace(/\s+/g, ' ').trim()
53 return flat.length <= max ? flat : `${flat.slice(0, Math.max(0, max - 1))}…`
54}
55
56/** Masked and cut: how any outside text is kept. */
57export function keep(text: string, max: number): string {
58 return clip(mask(text), max)
59}
60
61/** The first line with something on it. */
62export function firstLine(text: string): string {
63 for (const line of text.split(/\r?\n/)) {
64 if (line.trim()) {
65 return line.trim()
66 }
67 }
68 return ''
69}
70
71/** The last part of a path. */
72export function basename(path: string): string {
73 const trimmed = path.replace(/\/+$/, '')
74 const cut = trimmed.lastIndexOf('/')
75 return cut < 0 ? trimmed : trimmed.slice(cut + 1)
76}
77
78/** `path` with the home folder written `~`. */
79export function homeRelative(path: string, home: string | null): string {
80 if (home && home !== '/' && (path === home || path.startsWith(`${home}/`))) {
81 return `~${path.slice(home.length)}`
82 }
83 return path
84}
85
86/**
87 * What a write is logged as: the folder it went to, home-relative; a relative
88 * path is under the session's folder (`./`). Never the file's contents.
89 */
90export function dirCategory(path: string, home: string | null): string {
91 const cut = path.lastIndexOf('/')
92 const dir = cut < 0 ? '.' : cut === 0 ? '/' : path.slice(0, cut)
93 const shown = dir.startsWith('/') ? homeRelative(dir, home) : dir === '.' ? '.' : `./${dir.replace(/^\.\//, '')}`
94 return keep(shown, 120)
95}
96
97/**
98 * A command line as the logs name it: argv[0] and its first argument that is
99 * not a flag, each cut to its last path part (`gh pr`, `python3 recall.py`).
100 */
101export function commandKey(argv: readonly string[]): string {
102 const [first = '', ...rest] = argv
103 const arg = rest.find(one => one !== '' && !one.startsWith('-'))
104 const parts = [basename(first), arg === undefined ? '' : arg.includes('/') ? basename(arg) : arg]
105 return keep(parts.filter(Boolean).join(' '), 60)
106}
107
108/** A short age: `now`, `40s`, `12m`, `3h`, `2d`. */
109export function ageText(ms: number): string {
110 const s = Math.max(0, Math.round(ms / 1000))
111 if (s < 5) {
112 return 'now'
113 }
114 if (s < 60) {
115 return `${s}s`
116 }
117 const m = Math.round(s / 60)
118 if (m < 60) {
119 return `${m}m`
120 }
121 const h = Math.round(m / 60)
122 return h < 48 ? `${h}h` : `${Math.round(h / 24)}d`
123}
124
125/** Milliseconds as people read them: `840 ms`, `2.4 s`, `3.1 min`. */
126export function msText(ms: number): string {
127 if (ms < 1) {
128 return '<1 ms'
129 }
130 if (ms < 1000) {
131 return `${Math.round(ms)} ms`
132 }
133 if (ms < 60_000) {
134 return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`
135 }
136 return `${(ms / 60_000).toFixed(1)} min`
137}
138
139/** A count with thousands separators: `12,345`. */
140export function countText(n: number): string {
141 const digits = String(Math.abs(Math.round(n)))
142 const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
143 return n < 0 ? `-${grouped}` : grouped
144}
145hooks/trace.ts 111 lines1/**
2 * Reading `next.trace`: which links are mods the monitor reports on, which of
3 * them failed and how, and the small reservoir a hook's p95 comes from. Pure.
4 */
5
6import type { FailureOutcome } from '../types'
7
8/** What the monitor reads of one trace entry (TraceEntry's own fields). */
9export type Link = {
10 readonly plugin: string
11 readonly tier: string
12 readonly outcome: string
13 readonly reason?: string
14 readonly ms: number
15}
16
17export type Failure = { outcome: FailureOutcome; what: string }
18
19/** Each failure outcome in plain words, as the toasts and reports say it. */
20export const WHAT: Record<FailureOutcome, string> = {
21 skipped: 'threw',
22 kept: 'failed after next()',
23 caught: 'failed (its .catch answered)',
24 expired: 'ran out of time',
25 rejected: 'rejected',
26}
27
28/**
29 * Whether a link is one the monitor reports on: a plugin of the person's or
30 * the organization's, never the engine, a plugin bundled with Claude Code
31 * (`builtin`) or the monitor itself.
32 */
33export function isWatched(link: { readonly plugin: string; readonly tier: string }, self: string): boolean {
34 return link.plugin !== 'engine' && link.plugin !== self && link.tier !== 'builtin' && link.tier !== 'core'
35}
36
37/**
38 * The deepest link that rejected, or -1. The trace lists the nearest link
39 * first, so the deepest is the last: where the rejection came from; the
40 * rejected ones above it only let it pass.
41 */
42export function deepestRejected(trace: readonly Link[]): number {
43 for (let i = trace.length - 1; i >= 0; i--) {
44 if (trace[i]?.outcome === 'rejected') {
45 return i
46 }
47 }
48 return -1
49}
50
51/**
52 * The failure a link's outcome records, or null:
53 * - `skipped` with no reason: it threw (or answered what the site refuses) before `next`;
54 * with a reason it was bypassed by a `next.to` above, which is no failure;
55 * - `kept`: it failed after `next` (threw, or returned nothing), and that run's result stands;
56 * - `caught`: it failed and its `.catch` handler answered;
57 * - `expired`: its budget ran out;
58 * - `rejected`: only the deepest rejected link, where the rejection came from.
59 */
60export function failureOf(link: Link, isDeepestRejected: boolean): Failure | null {
61 switch (link.outcome) {
62 case 'skipped':
63 return link.reason ? null : { outcome: 'skipped', what: WHAT.skipped }
64 case 'kept':
65 case 'caught':
66 case 'expired':
67 return { outcome: link.outcome, what: WHAT[link.outcome] }
68 case 'rejected':
69 return isDeepestRejected ? { outcome: 'rejected', what: WHAT.rejected } : null
70 default:
71 return null
72 }
73}
74
75/** Whether the link ran: every outcome but a skip a `next.to` above made (which carries a reason). */
76export function didRun(link: Link): boolean {
77 return !(link.outcome === 'skipped' && link.reason)
78}
79
80/** A uniform sample of a hook's times: the first `RESERVOIR_SIZE` kept, then each replacing at random. */
81export type Reservoir = { seen: number; values: number[] }
82
83export const RESERVOIR_SIZE = 64
84
85export function sample(reservoir: Reservoir, value: number, random: () => number = Math.random): void {
86 reservoir.seen += 1
87 if (reservoir.values.length < RESERVOIR_SIZE) {
88 reservoir.values.push(value)
89 return
90 }
91 const at = Math.floor(random() * reservoir.seen)
92 if (at < RESERVOIR_SIZE) {
93 reservoir.values[at] = value
94 }
95}
96
97/** The 95th percentile (nearest rank) of the values; 0 for none. */
98export function p95(values: readonly number[]): number {
99 if (values.length === 0) {
100 return 0
101 }
102 const sorted = [...values].sort((a, b) => a - b)
103 const rank = Math.max(0, Math.ceil(sorted.length * 0.95) - 1)
104 return sorted[rank] ?? 0
105}
106
107/** Milliseconds rounded for a log line. */
108export function roundMs(ms: number): number {
109 return ms >= 100 ? Math.round(ms) : Math.round(ms * 10) / 10
110}
111types/index.d.ts 102 lines1/**
2 * What a failed link's trace entry said: `skipped` with no reason (it threw
3 * before `next`), `kept` (it failed after `next`, that run's result stands),
4 * `caught` (its `.catch` answered), `expired` (its budget ran out) or
5 * `rejected` (the deepest link that rejected: where the rejection came from).
6 */
7export type FailureOutcome = 'skipped' | 'kept' | 'caught' | 'expired' | 'rejected'
8
9/** How a mod was first seen this session. */
10export type SeenVia = 'plugin.register' | 'command.register' | 'tool.register' | 'trace' | 'origin'
11
12/** Token counts of one model call, as `ModelUsage` reports them. */
13export type TokenUsage = { in: number; out: number; cacheRead: number; cacheWrite: number }
14
15export type EventBase = {
16 t: 'event'
17 /** Milliseconds since the epoch. */
18 ts: number
19 /** The mod the line is about. */
20 plugin: string
21 /** How many occurrences the line stands for when repeats within a minute were folded into it (absent: 1). */
22 n?: number
23 /** When the last folded occurrence happened. */
24 last?: number
25}
26
27/** One line of a day file about something a mod did or suffered. */
28export type EventLine = EventBase &
29 (
30 | { kind: 'seen'; via: SeenVia; tier?: string; version?: string; provenance?: string }
31 | { kind: 'failure'; event: string; outcome: FailureOutcome; ms: number; what: string; reason?: string }
32 | { kind: 'slow'; event: string; ms: number }
33 | { kind: 'toast'; text: string }
34 | { kind: 'status'; text: string }
35 | { kind: 'proc-fail'; cmd: string; exit: number | null; ms: number; err?: string }
36 | { kind: 'proc-slow'; cmd: string; ms: number }
37 | { kind: 'proc-expected'; cmd: string; exit: number | null; why: string }
38 | { kind: 'log'; text: string; isError: boolean }
39 | { kind: 'model'; model: string; outcome: string; ms: number; usage?: TokenUsage }
40 | { kind: 'write'; dir: string }
41 | { kind: 'command'; command: string; hasArgs: boolean; by: string }
42 | { kind: 'register'; what: 'command' | 'tool'; name: string }
43 )
44
45export type EventKind = EventLine['kind']
46
47/** Slow runs of one event's hook in a flush window, with the session's rough p95 of that hook's time. */
48export type SlowStat = { n: number; max: number; p95: number }
49
50/** Per mod, what happened since the previous flush (deltas). */
51export type CountsLine = {
52 t: 'counts'
53 ts: number
54 plugin: string
55 /** Hook runs per event (drawing is never counted). */
56 runs: Record<string, number>
57 /** Processes it ran, and how many failed. */
58 procs: number
59 procFails: number
60 /** Files it wrote. */
61 writes: number
62 toasts: number
63 /** Model calls it made. */
64 models: number
65 slow: Record<string, SlowStat>
66 /** Hook failures. */
67 fails: number
68 /** Runs of its slash commands. */
69 cmds: number
70 /** Calls of the tools it registered. */
71 tools: number
72 /** Error lines it logged itself ($.ui.log with failure wording); absent in older logs. */
73 logErrors?: number
74}
75
76export type Line = EventLine | CountsLine
77
78/** What a session keeps in `$.state`, so a reload of the monitor finds its inventory and alerts again. */
79export type MonitorBackup = {
80 sessionId: string | null
81 mods: { name: string; via: SeenVia[]; covered: boolean; tier?: string; version?: string; provenance?: string }[]
82 commands: [string, string][]
83 tools: [string, string][]
84 alerted: string[]
85 procAlerted: string[]
86 failures: [string, number][]
87 statuses: [string, string][]
88 noted: string[]
89}
90
91declare module 'claude-code' {
92 interface PluginState {
93 'mod-monitor': {
94 /** Bumped when the pane's figures changed; the pane reads it to redraw. */
95 tick: number
96 /** The mods whose Details are open in the pane. */
97 expanded: string[]
98 backup: MonitorBackup
99 }
100 }
101}
102