SLOPSHOPPER

mod-monitor

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…

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-08joeldg/claude-mods/mod-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-monitor
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ 0 mods loaded · 0 covered · 0 failing today │ ┃ ⏺ Read(src/auth.ts) │ ┃ No mod has been seen yet in this session. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No mod has been seen yet this session. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Logs: ~/.claude/mods/monitor/2025-10-09/ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mods │ ⎿ mod-monitor: 0 mods loaded · 0 covered · 0 failing today │ ⎿ mod-monitor: No mod has been seen yet this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
0 mods loaded · 0 covered · 0 failing today No mod has been seen yet in this session. No mod has been seen yet this session. Logs: ~/.claude/mods/monitor/2025-10-09/
README

claude-mods

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

dev-servers

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:
  • running servers whose working folder is in this repo: name, port, pid and uptime, with Restart, Stop and Log
  • known start commands that aren't running, with Start: package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile
  • a count of other listeners on the Mac
  • Only button presses start or stop anything:
  • Start runs the command detached, logging to ~/.claude/dev-servers/<project>/.
  • Stop sends SIGTERM. If the server ignores it, pressing again within 10s force-stops it.
  • Restart stops the server, waits for the port to free up, then starts it.
  • Before any signal, it checks the pid still runs the same command.
  • When a command fails with "address already in use", Claude gets a note (and you a toast) naming the holder, e.g. Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).
  • Status line: servers: :4000 :5173.
  • Makes no model calls: lsof and ps every 15s.

downloads-drop

Puts files you just downloaded into your prompt with one click.

  • Watches ~/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].
  • Attach puts @"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files.
  • Waits until a file has finished downloading (skips partial downloads and files still growing), and ignores hidden and zero-byte files.
  • /downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.
  • Stacks with other mods' bands (repo-brief, standing-orders, secret-guard) instead of hiding them.
  • Makes no model calls.

Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).

effort-router

Sets effort per message, so you don't have to switch it by hand.

  • Git chores ("merged", "#219 merged", "commit and push", "push it", "open a PR", "close the issue") run at low effort and come back faster.
  • Deep asks (audit, review, plan, design, investigate, root cause, "why does…", "figure out") run at max.
  • Everything else, including approvals like "yes", "go ahead" and "continue" and anything that starts new work ("merged 219, go ahead with #214"), keeps your session's own effort.
  • Prompt cache: changing effort makes the whole conversation get cached again. So it never switches mid-turn, raises effort at once, and over a large, warm cache lowers it only after 2 routine turns in a row. In small contexts, or once the cache has lapsed, it switches right away.
  • Model guard: set 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.
  • Status line while a turn is routed: effort: low (routine).
  • Makes no model calls.

Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).

job-watch

A Jobs pane for long-running work: training runs, downloads, extractions.

  • Picks up background Bash tasks and detached nohup … > log & launches by itself.
  • /watch <log> [label] adds any other log file.
  • Shows progress, ETA and the last log line, and flags a job as stalled when its log goes quiet.
  • Shows free space on / and /Volumes/* (the NAS).
  • Toasts when a job finishes or stalls. The status line shows jobs: 2 running · 1 stalled.
  • /jobs opens the pane, /unwatch <label|done|all> removes jobs.
  • Makes no model calls: it reads logs with 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.

machine-guard

Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.

  • Status line: RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure, or
  • the Mac is reserved with /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.
  • Remote runs (modal run, ssh), tests (pytest) and installs are never treated as heavy.
  • Add your own heavy commands with the "Also heavy" setting (a regex), e.g. overnight_|nightly_run\.sh.

mod-monitor

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.

  • Failures: any mod hook that throws, times out or rejects, read from the hook chain's results (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.
  • What each mod did: its toasts ("#219 merged → …", "Blocked: …"), status-line changes, the mod commands you used (never their arguments), and failed subprocesses (a burst of 5 in 10 minutes raises a toast). Git checks run outside a repository are logged as expected, not as failures. It also records model calls (the only usage the mods cost: /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.
  • Logs: ~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.
  • Makes no model calls and adds no measurable latency.
  • Error lines mods log themselves ($.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.
  • Transcript-row hooks (secret-guard masks /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).

modal-meter

Keeps an eye on Modal so idle GPU containers don't burn credits.

  • Status line while containers run: Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.
  • A toast when an app has had containers up longer than 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.
  • Shows today's spend and alerts on a budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.
  • Finds the CLI as 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.
  • Makes no model calls: only the Modal CLI, every 60s.

pr-autopilot

Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.

  • Watches your open PRs in the session's repo: it adopts them at session start, and picks up every gh pr create Claude runs. It polls gh pr view every 60s.
  • Status line: PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.
  • When a PR merges, it cleans up with plain local git, then toasts the outcome and suggests carrying on (Tab to accept):
  • git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.
  • Never with uncommitted changes, never --force, never other branches.
  • Deletes the local branch only if it points at exactly the commit GitHub merged, so nothing local is lost. It also leaves a branch checked out in another worktree alone.
  • A merge seen mid-turn is cleaned up when the turn ends, so git never races Claude.
  • When you mention failing CI ("#258 is failing", "CI failed, fix it"), your message goes to Claude with 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.
  • Makes no model calls: only 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.

recall

Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.

  • What it searches: Claude Code sessions, Codex sessions, subagent and workflow runs, Claude's memory files, standing orders, second-opinion reviews, and your /remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.
  • What it keeps: prompts, answers, compaction summaries, session titles, commands, files touched, commits, PRs, issues, URLs, tasks and decisions (what you approved or ruled out). Read-only look-ups like grep and cat are kept but ranked low.
  • History survives cleanup: extracts stay searchable after Claude Code deletes old transcripts.
  • Claude searches it itself with four read-only tools, 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.
  • Commands:
  • /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>.
  • Bands:
  • Once per session: Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].
  • When a prompt mentions #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.
  • Query syntax: words must all match. 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.
  • Privacy:
  • The index lives at ~/.claude/recall/index.db, readable only by you, and never goes in a repo.
  • Secrets are masked before anything is stored: known token shapes, labelled values ("password: …"), the values of secret-named exports in ~/.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.
  • Cost: no model calls except /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.
  • Requires macOS's /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).

repo-brief

Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".

  • Gathers in the background at session start:
  • branch, ahead/behind and uncommitted files
  • the last 8 commits
  • open PRs with CI ✓/✗/…
  • issues labelled owner, todo, P0 or blocked
  • stale branches (merged, or upstream gone)
  • A one-line band above the prompt, e.g. main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.
  • Claude gets the same summary once, in its first message, so the prompt cache stays warm. It refreshes after compaction.
  • /brief re-gathers now and prints the full summary.
  • Makes no model calls: only git and gh. The band refreshes after a turn at most every 2 minutes.

Settings: focus labels, refresh minutes, and whether to brief Claude.

routine-watch

Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.

  • Knows a session is a routine from its scheduled-task prompt, and does nothing in your other sessions.
  • When a routine stops to wait for your OK on a permission prompt or an AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.
  • When a turn ends in an error, or the routine finishes, you get a notification: Routine daily-report finished after 23m · waited on you 2 times.
  • Phone push (optional): 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.
  • Makes no model calls.

second-opinion

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.
  • Other forms:
  • /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.
  • The command returns at once, and the status line shows 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.
  • Send to Claude attaches the review to your next prompt (once) and drafts "What do you agree with, and what would you act on?".
  • Reviews are saved in ~/.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).

standing-orders

Keeps your "always / never / don't / from now on" instructions alive across compaction.

  • When you write an instruction like "never open bambu with full spectrum files", a band asks: Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.
  • Project orders live in ~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session.
  • Claude gets them at the start of every conversation and again after each compaction or /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).
  • Makes no model calls.

secret-guard

Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.

  • Catches known token shapes: AWS, GitHub, Anthropic, OpenAI, Slack, Google, Hugging Face, GitLab, npm, Stripe, private keys and bearer tokens.
  • Also catches labelled values ("password: …", "api key = …", "the wifi password is …") and the two-line "Access Key ID / Secret Access Key" paste.
  • Leaves alone $NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.
  • On a hit, the prompt isn't sent and goes back in the box. A band shows the secret masked (…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:
  • Save as env var appends export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME.
  • Send anyway lets exactly that text through once.
  • Edit dismisses the band.
  • The value is never shown in toasts, status, state or the transcript, and /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.
  • Makes no model calls.

Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).

slicer-handoff

Makes Claude's open commands hand 3D files to the right slicer.

  • Full-spectrum files go to Snapmaker Orca. Bambu Studio and OrcaSlicer can't open them. A file counts as full-spectrum when:
  • its name or folder matches full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], or
  • its 3MF names a Full Spectrum filament profile.

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

  • Earlier windows close first. Before opening a file, it asks the running slicer to quit (a normal quit, never forced), so windows don't pile up. If one won't close, for example because it's waiting on a save prompt, it stops trying and tells Claude to leave it alone.
  • /slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.
  • Recognizes 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.
  • Makes no model calls.

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.

Loading

  • One session from a terminal: pass --plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot
  • Every session, including the desktop app: add to ~/.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" } } ``

Checking

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)
Source 6 files
hooks/register.tsx 1340 lines
1import { 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 lines
1/**
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}
581
hooks/log.ts 180 lines
1/**
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}
180
hooks/mask.ts 145 lines
1/**
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}
145
hooks/trace.ts 111 lines
1/**
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}
111
types/index.d.ts 102 lines
1/**
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