SLOPSHOPPER

mem-gauge

A pressable memory gauge under the composer and a /mem pane: what is using this Mac's RAM across Claude sessions, agents, models and apps, with rule-based…

newpanespinnercommandtoaststatus
v0.3.1MITupdated 2026-10-12Joseph-Ayoub/claude-mods/mem-gauge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mem-gauge
│ ┃ Memory ✕ › fix the failing auth test and add an audit log call │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -- Memory │ ┃ This se ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Used ⏺ Update(src/auth.ts) │ ┃ Free ⎿ Added 2 lines, removed 1 line │ ┃ Compressed ⏺ Bash(bun test) │ ┃ Swap ⎿ 3 pass, 1 fail │ ┃ Load │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Sessions │ ┃ Session RSS ✻ Worked for 42s · done 4:20 PM │ ┃ app ? │ ┃ › /mem │ ┃ Optimize ⎿ mem-gauge: Memory pane opened. │ ┃ Nothing to do right now. │ ┃ │ ┃ RSS is approximate (it undercounts │ ┃ compressed memory). A session without │ ┃ mem-gauge is listed from the engine's own │ ┃ record: name, memory and idle time only │ ┃ (model, context and agents read --). │ ┃ │ ┃ [ Refresh ] [ Disk ] [ Close ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ? Memory -- · Pressure ? · 1 session · Disk ? free

Draws

Pane · Memory
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -- Memory -- used · pressu This session unknown · Used Free Compressed Swap Load Sessions Session RSS Ctx Agents app ? 49% 0 Optimize Nothing to do right now. RSS is approximate (it undercounts compressed memory). A session without mem-gauge is listed from the engine's own record: name, memory and idle time only (model, context and agents read --). [ Refresh ] [ Disk ] [ Close ]
Pane · Disk
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -- Disk -- used used ? of ? · free ? Volumes Volume Free Used % df listed no volume. Big folders Claude transcripts (~/.claude/projects) -- Claude file history -- Xcode DerivedData -- iOS simulator devices -- Library caches -- Trash -- Free up safely Nothing worth freeing right now. Sizes come from df (5 s) and du (10 s each, on open and Refresh); a folder that is missing or takes longer shows --. [ Refresh ] [ Close ]
Prompt hint
⟨Claude Code's own drawing⟩ ? Memory -- · Pressure ? · 1 session · Disk ? free
README

mem-gauge

A memory gauge under the Claude Code composer, and a Memory pane that shows what is using this Mac's RAM: every Claude session (with its model, context fill and running agents), the other apps, and plain advice on what to close or wrap up. Built for running many sessions on one Mac, which has run out of RAM and disk before.

What you see

  • Under the prompt: a small pressable gauge on the hint line, for example mem ◑ 62%. The ring fills by fifths (○ ◔ ◑ ◕ ●). It gets a ! in front when macOS reports memory pressure (warn or critical) or when another session has asked this one to wrap up. Click it (or type /mem) to open the pane.
  • Status line: one line under the prompt, for example mem ◑ 62% · pressure ok · this session 1.8 GB · 7 sessions · disk 41 GB free. It refreshes every 30 seconds. Turn it off with the statusLine option if the gauge alone is enough. The pressable gauge exists on the terminal and the desktop app only, so on VS Code and mobile turning the status line off leaves just /mem.
  • The Memory pane (/mem or a click on the gauge):
  • a ring (desktop, VS Code, mobile) or a bar (terminal) of memory used, and the pressure
  • totals: Used, Free, Compressed, Swap, Load, each figure flush with the pane's right edge (free disk is in the Disk pane)
  • Sessions: the ten biggest active Claude sessions (memory first). A session that runs mem-gauge shows model, memory, context %, running agents and minutes idle; one that does not is listed from the engine's own session record (~/.claude/sessions/<pid>.json) by its name with its process tree's memory and idle time (model, context and agents read --, and there is no Ask to wrap for it); View all (hotkey a) lists every active session, up to 60, and View top 10 folds it back. This session is the bold row. A session whose heartbeat is older than 2 minutes reads (stale); one whose process has exited is not listed. A Claude engine with no session record is summed on one line.
  • Other apps: the 10 largest non-Claude apps, helper processes rolled up under their app (Chrome's renderers count as Chrome).
  • Optimize: fixed rules (the rules themselves make no AI call), one block per tip with a blank line between them: the advice first, then its button row beneath it, an Apply button where there is one safe thing to do (confirmed first in the engine's own dialog; a tip with nothing safe to automate has no button row). The Disk pane's "Free up safely" tips use the same shape:
  • another session idle over 30 min and over 1 GB: wrap or archive it; Apply asks it to wrap (the request file below) when it runs the mod, no button when it does not
  • this session's context past 150k tokens (option contextTipTokens; 0 falls back to 80 % of the window): two buttons. Compact runs /compact on this session (confirmed first). Hand off runs /handoff, the handoff mod's command, which writes a handoff note and moves you to a fresh session (install it with claude plugin install handoff@joseph-mods --scope user; without it the button says so). Another session's context past the same line is advice only: compact or hand off from there. A session whose token count is not known yet (an older build's heartbeat, a fresh session) uses the 80 % rule.
  • more than 3 running agents: the largest fan-out; consider running them one after another (no button)
  • memory over 85 % used or under pressure: close the largest other app; Apply asks it to quit the normal way (osascript, the name passed as an argument, so it may still ask you to save; never the Claude app itself)
  • load over twice the core count: pause heavy test suites (no button)
  • under 10 GB of free disk: Apply opens the Disk pane, which says what is safe to free
  • Refresh (r) and Close (c, or Esc).

Compact or hand off?

Compact summarizes the conversation in place and keeps the session; Hand off writes a note and starts fresh from it. What Hand off does, in order, and why nothing is lost if a step fails, is in mods/handoff/README.md: the handoff mod owns the dialog, the note, /clear and the pre-filled prompt. This mod only offers the button: it refuses the press while a turn is running (a toast says to wait), runs /handoff once per press, and dims the button, and Compact beside it, until the command has been handed over.

The tip's threshold is an absolute token count, not a share of the window, because a 1M window is capacity for reading a lot at once, not a target: per-turn cost, cache-miss cost and latency grow with the prefix and quality thins, and Anthropic's own compaction defaults to 150k.

Ask to wrap

The button asks you to confirm, then leaves a small request file for that session. That session's own copy of mem-gauge shows its person a toast and a ! in its status line. Nothing runs by itself: no message reaches the other session's model, so it is never woken into a turn (which would spend memory) and nothing could read it as an order to push or merge. Each session can be asked at most once per 10 minutes, and this session is never offered for itself.

On the receiving side, only the time and the size are read from the file (anything else in it is ignored). While a request is already showing, a newer one only updates the size, so several sessions asking at once raise one toast. A request older than 30 minutes, or stamped in the future, is dropped. Opening the pane counts as having seen it and clears the !.

Install and load

Install it from the marketplace (terminal session):

/plugin install mem-gauge --marketplace Joseph-Ayoub/claude-mods

or at user scope from the local marketplace (claude plugin install mem-gauge@joseph-mods --scope user) so every session loads it (see mods/README.md). The Hand off button also needs the handoff mod: claude plugin install handoff@joseph-mods --scope user.

While developing, load it from this folder:

claude --plugin-dir /absolute/path/to/claude-mods/mem-gauge

or set CLAUDE_CODE_PLUGIN_DIRS to that absolute path (in the shell, or in the env block of ~/.claude/settings.json) so sessions the desktop app starts load it too. To see every session in one pane, every session needs to load it.

The gauge icon

On the desktop the gauge sits in the prompt footer's mode row (the dim labels at the right of the composer, the one site under the composer the desktop app raises to plugins). That strip clips whatever does not fit, draws no vector element, draws every button before every text whatever the order, and a button (a small native chip) is the only pressable thing it has. It also ignores a Box's hover and display. So the row is two buttons, each a glyph icon with its figure: the memory ring glyph (○ ◔ ◑ ◕ ●, filling with use) with the used percent, for example ◔ 60%, which opens the Memory pane, and the disk glyph (⛁) with the free space, ⛁ 901 GB, which opens the Disk pane; then the engine's own mode labels. With footerCompact on, both figures sit in one tight chip as fullness percents (◔60% ⛁59%) that opens the Memory pane, whose Disk button (d) opens the Disk pane; use it when other mods' chips share the strip. With the footerButtons option off the row is plain text (the ring glyph in red) and /mem and /disk open the panes. Under memory pressure, or while another session's wrap request is showing, the memory figure reads ◔ ! 60%. The terminal, which has no vector element, draws the quintile glyph in red and the one-line text Memory 60% · Pressure ✓ · 7 sessions · Disk 41 GB free in the hint line under the prompt; a press there opens the Memory pane. The separate status line is off by default (option statusLine); turn it on for VS Code or mobile where neither row is drawn. The Memory pane header draws the red ring with the percent inside at 88 px.

The Disk pane

The Disk button or /disk (and nothing on the tick) opens a second pane with the main volume's use (a ring on the desktop, a bar on the terminal; used 1.1 TB of 1.9 TB · free 830 GB), then Volumes: every real volume from df -kP with Size, Used, Free and Used %: the APFS system and data volumes merged into one bold Macintosh HD row (they share one container, so df lists the same size and free space twice; used is the two together), external disks, iOS simulator runtimes; devfs, the swap and preboot helpers and automounter maps are left out; then Big folders: the usual disk hogs measured with du -sk when the pane opens, each in parallel with a 10 s timeout: Claude transcripts (~/.claude/projects), Claude file history, Xcode DerivedData, iOS simulator devices, Library caches and the Trash. A folder that is missing or takes longer than 10 s shows --. Then Free up safely, rule-based: Library caches from 512 MB (apps rebuild them), Xcode DerivedData (the next build is a full one), iOS simulator devices (delete the ones you no longer test on; xcrun simctl delete unavailable removes the stale ones), more than one iOS simulator runtime (keep the one you test on), the Trash, and Claude file history (edit snapshots for rewinding; prune once those sessions are closed). Each of those tips has an Apply button (the runtimes tip is manual): Apply asks first in the engine's own dialog, then runs one fixed command with no shell (find <folder> -mindepth 1 -maxdepth 1 -exec rm -rf {} + for caches, DerivedData and the Trash, under a HOME that must be a plain absolute path; a target folder that is itself a symlink is left alone; xcrun simctl delete unavailable; the file-history find with -mtime +30), says how it went in a toast, and re-reads the sizes; while it runs the button reads Applying. Only things that can be cleared are listed: the Claude transcripts (~/.claude/projects) are the session history and never appear (a test holds every tip and every command away from that folder). A volume name is cleaned to one line of printable text before it is drawn, like every other program's text here. The reading is kept for a minute; Refresh (hotkey r) reads again, Close (hotkey c) closes.

Options

Set in the config menu, or in settings under pluginConfigs → mem-gauge:

OptionDefaultWhat it does
intervalSeconds30How often the gauge re-reads memory (10 to 600).
idleMinutes30Minutes without a turn before a session counts as idle for the wrap advice.
contextTipTokens150000The Memory pane suggests Compact or Hand off once a session's context passes this many tokens (20000 to 1000000). 0 falls back to 80 % of the window.
statusLinefalseAlso show the one-line summary as a status line (the gauge row already carries it).
footerButtonstrueDraw the desktop footer figures as buttons (grey chips) that open the panes; off, they are plain text and /mem, /disk open the panes.
footerCompactfalseBoth figures as fullness percents in one tight chip (◔60% ⛁59%) that opens the Memory pane, where Disk (d) opens the Disk pane; with footerButtons off, the same figures as plain text, which takes the least room. For footers that other mods' chips share: the desktop strip clips once the chips overflow it.

How it works

HookWhy
session.startregisters /mem and /disk, starts the 30 s timer, takes the first reading
turn.start, turn.complete (main loop only)marks the session active (a Hand off press is refused until the turn ends); refreshes the repo · branch label after a turn
session.measurekeeps the latest context (percent, tokens, window) and rate-limit figures
session.endremoves this session's heartbeat file (one rm -f)
command.run /memopens the pane; where no pane can be drawn (claude -p), answers with a short table
command.run /diskopens the Disk pane; where no pane can be drawn, answers with one line (used, total, free)
any origin may run it (it only reads); only your own origins (typed at the prompt, the Remote Control bridge, the SDK host behind the desktop app) open the focused pane, and get the short table where the pane cannot be placed. A peer session, a plugin, a scheduled task or an unknown origin gets one summary line (used %, pressure, live sessions) in a session that draws, so nothing else can pop a pane in front of you or copy every session's labels into this transcript; only where nothing draws (claude -p) does it get the table. If /mem overruns its time budget, it answers from the last reading
ui.render PromptHintadds the pressable gauge after the engine's own hint line
ui.render Pane mem-gaugedraws the pane from the last reading only

Each tick runs, with argv only (no shell) and a 5 s timeout each, all in parallel: one sysctl (pressure level, swap, load; memory size and cores are read on the first call only, they never change), vm_stat, df -k / and ps -axo pid=,ppid=,rss=,comm=. A collector that fails leaves only its own figures blank (-- or ?); the rest still draws. Ticks never overlap: a slow one is joined, not doubled. Sessions share the work: whoever finds ~/.claude/mem-gauge/machine.json older than about 25 seconds collects and writes it, the rest read it. "Used" is (active + wired + compressed) pages, the same as Activity Monitor's "Memory Used". Pressure comes from kern.memorystatus_level (percent free; under 20 warns, under 10 is critical).

The mod makes no model call, writes no handoff note and never fills the prompt box. The Hand off button runs one command, /handoff ($.command.run), which the handoff mod answers; the note, /clear and the pre-filled prompt are that mod's.

Files it writes, all under ~/.claude/mem-gauge/:

  • machine.json: the shared reading (numbers and process names, no command lines)
  • sessions/<sessionId>.json: one heartbeat per session: label (repo · branch), working folder, model, memory, context % (with the token count and window), rate-limit %, running agents, last activity. Never prompt text, never secrets.
  • requests/<sessionId>.json: an Ask to wrap waiting to be seen; removed once seen.

Clean-up never depends on session.end (a crash or a kill skips it):

  • Heartbeats of crashed sessions are removed by any session's tick once their process is gone (or its pid now belongs to something that is not claude, node or bun) and they are 10 minutes old; anything a day old goes regardless.
  • After /clear the session goes on under a new id; its first tick removes the old id's heartbeat, so one process is never counted twice.
  • Request files nobody picked up are removed after 30 minutes.
  • At most 10 files are removed per tick.

The prompt text of a turn is never read or stored; turn.start is used only as a timestamp. The short /mem table goes into the transcript the model reads, so it carries repo · branch labels but never a working folder. Other sessions wrote those labels (and the model names and the app names in machine.json), so every one is read as a single printable line: control characters, escape codes and line breaks become spaces, and invisible format characters (bidi overrides, zero-width, tag characters) are dropped, so nothing written there can add lines to the table, the Ask to wrap question or a toast.

Tests

claude plugin validate mods/mem-gauge and claude plugin test mods/mem-gauge (108 tests in parse.test.ts and register.test.ts). The tests cover:

  • pure parsing of vm_stat, sysctl, df and ps from fixtures, process trees, helper roll-up, the used % formula, pressure thresholds, glyph quintiles, every Optimize rule (and no false positives), registry status and garbage collection, torn files;
  • the tick end to end against a fake Mac (every command and file answered beneath the plugin, the clock mocked): the status line, the heartbeat, shared readings, other sessions, /clear;
  • the pane on terminal (text bar, no Svg) and desktop (Svg ring with alt text), plus narrow panes on all four surfaces, and the pressable gauge on the hint line (mounted and pressed on terminal and desktop);
  • /mem from each kind of origin, placed, unplaced and headless;
  • Ask to wrap: confirm, cancel, dismiss, the 10-minute limit, and the receiving side's toast, marker and de-duplication;
  • Compact and Hand off: the tip fires at 150k tokens whatever the window, contextTipTokens lowers it and 0 falls back to 80 % of the window; this session's tip draws both buttons and another session's none; a Hand off press runs /handoff exactly once with this plugin as its origin and nothing else (no dialog, process or file of its own); without the handoff mod the toast names its install line; a running turn refuses the press with a toast; while /handoff is being handed over the button and Compact are dim and a second press of either is ignored, and the reverse while Compact runs (the note itself is tested in the handoff mod);
  • fail-safe paths: every process and file refused, an untrusted parent pid, a transient pid-probe failure (retried, at most five tries), the statusLine option off, usage unreadable (figures come from session.measure), the timer re-armed by the first main-loop turn after a hot reload, a denied Close press, session.end not waiting on its rm, /mem during a tick in flight;
  • hostile text: a heartbeat label or model, or an app name, holding newlines, escape codes or bidi characters cannot forge lines in /mem, the Ask to wrap question or a toast.

Limits

  • macOS only (vm_stat, sysctl, BSD ps).
  • Memory per session is approximate. It is the resident size (RSS) of the session's process and everything it started (MCP servers, shells, test runs). RSS undercounts memory that macOS has compressed.
  • There is no API for the engine's process id. mem-gauge asks a sh for its parent and trusts it only if ps names it claude, node or bun; otherwise it shows "this session unknown" rather than a wrong number.
  • Agents counts this session's pending, running and waiting subagents. Workflow agents are not listed by the engine, so they are not counted.
  • Where the gauge can sit. The terminal draws it on the prompt's hint line. The desktop app (2.31226, CLI 2.1.293) never raises the hint line or the band above the prompt to plugins, only the prompt footer's mode row, the spinner, the transcript rows and panes, so the desktop gauge lives in the mode row. VS Code and mobile raise neither: turn the statusLine option on there, and use /mem. That a press reaches the Button is proven by the test kit; confirm it with a live click before relying on it.
  • Never clear the status line with undefined. On the desktop host that call stops every later render from reaching the plugin (the gauge, the pane, everything), so when statusLine is off the mod makes no status call at all. A line pinned by an earlier load of the mod (hot reload, or the option switched off) stays until the session restarts.
  • The engine pid comes from $PPID of a spawned sh, which depends on how the host starts processes and may change between Claude Code releases; the check falls back to "unknown".
  • Advice sizes are estimates. "Frees N GB" is that session's RSS, which can read low under compression.
  • Only the memory half of keeping many sessions healthy is covered; ordering sessions' PR / CI / merge work is out of scope.
  • Writes are not atomic: a half-written heartbeat is left out of the table for one tick (it is not drawn as stale), and a half-written shared reading is simply re-collected.
Source 4 files
hooks/register.tsx 1430 lines
1// mem-gauge: a memory gauge under the composer and a Memory pane that shows
2// what is eating this Mac's RAM across Claude sessions, agents, models and
3// other apps, with rule-based advice. Every session running the mod publishes
4// a small heartbeat file under ~/.claude/mem-gauge/sessions/, so any one
5// session's pane sees them all. Drawings read $.state only; processes run from
6// the timer, a command or a press, never while drawing.
7//
8// The engine requires `$` to be handed only to functions declared at the top of
9// this file, so every function that touches `$` lives here; the parsing and the
10// advice rules are pure and live in parse.ts and advice.ts.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { DiskReport, SessionRow, Snapshot, WrapRequest } from '../types'
16import { advise, applyHome, compactReport, diskActionArgv, diskActionLabel, diskActionQuestion, diskAdvice, quitAppArgv, summaryLine } from './advice'
17import type { Advice, AdviceAction, AdviceOptions, DiskAdvice } from './advice'
18import {
19  bar,
20  basename,
21  buildMachine,
22  claimUnregistered,
23  cleanBranch,
24  decodeEngineSession,
25  decodeMachine,
26  diskLine,
27  engineRow,
28  decodeWrapRequest,
29  descendants,
30  encodeMachine,
31  footerParts,
32  formatBytes,
33  glyph,
34  formatMB,
35  isCollectable,
36  isEngineName,
37  isSessionId,
38  parseDfAll,
39  parseDu,
40  oneLine,
41  parseEntry,
42  parsePs,
43  ringSvg,
44  rowOf,
45  shortModel,
46  sortRows,
47  statusLine,
48  summaryParts,
49  statusOf,
50  topApps,
51  treeMB,
52} from './parse'
53import type { MachineFile, Proc, RegistryEntry } from './parse'
54
55type Dollar = EngineInterface
56
57// $.state values, one literal plugin/key reference each, so `claude plugin
58// validate` holds them to types/index.d.ts. Written only from handlers, timers
59// and events, never while drawing.
60const snapshotRef = atom({ plugin: 'mem-gauge', key: 'snapshot' } as const, null)
61const enginePidRef = atom({ plugin: 'mem-gauge', key: 'enginePid' } as const, null)
62const pidCheckedRef = atom({ plugin: 'mem-gauge', key: 'pidChecked' } as const, false)
63const lastActivityRef = atom({ plugin: 'mem-gauge', key: 'lastActivityAt' } as const, 0)
64const askedRef = atom({ plugin: 'mem-gauge', key: 'asked' } as const, {})
65const labelRef = atom({ plugin: 'mem-gauge', key: 'label' } as const, null)
66const figuresRef = atom({ plugin: 'mem-gauge', key: 'figures' } as const, null)
67const wrapRef = atom({ plugin: 'mem-gauge', key: 'wrapRequest' } as const, null)
68const showAllRef = atom({ plugin: 'mem-gauge', key: 'showAllSessions' } as const, false)
69const diskRef = atom({ plugin: 'mem-gauge', key: 'disk' } as const, null)
70const applyingRef = atom({ plugin: 'mem-gauge', key: 'applying' } as const, [] as string[])
71
72const PANE = 'mem-gauge'
73const DISK_PANE = 'mem-gauge-disk'
74/** The disk pane re-reads the volumes and folders when its report is older than this. */
75const DISK_REFRESH_AFTER_MS = 60_000
76/** The folders that usually eat a Mac developer's disk, measured with `du -sk` when the disk pane opens. */
77const DISK_FOLDERS: readonly { rel: string; label: string }[] = [
78  { rel: '.claude/projects', label: 'Claude transcripts (~/.claude/projects)' },
79  { rel: '.claude/file-history', label: 'Claude file history' },
80  { rel: 'Library/Developer/Xcode/DerivedData', label: 'Xcode DerivedData' },
81  { rel: 'Library/Developer/CoreSimulator/Devices', label: 'iOS simulator devices' },
82  { rel: 'Library/Caches', label: 'Library caches' },
83  { rel: '.Trash', label: 'Trash' },
84]
85const ASK = 'Ask to wrap'
86const APPLY = 'Apply'
87const COMPACT = 'Compact'
88const HAND_OFF = 'Hand off'
89/** An Apply's command (a find over a big folder, simctl) may take a while. */
90const APPLY_TIMEOUT_MS = 120_000
91/** An app asked to quit may first show a save sheet; the request is delivered either way. */
92const QUIT_TIMEOUT_MS = 30_000
93/** The disk icon on surfaces with no vector element: stacked discs (U+26C1). */
94const DISK_GLYPH = '\u26C1'
95/** The pane lists this many active sessions, biggest first, until View all is pressed. */
96const TOP_SESSIONS = 10
97const ASK_EVERY_MS = 10 * 60_000
98/** /mem and a press of the gauge re-read when the snapshot is older than this. */
99const REFRESH_AFTER_MS = 10_000
100const TIMEOUT_MS = 5000
101/** du over a big folder (the simulator devices run past 5 s on a full Mac); the pane says it is reading meanwhile. */
102const DU_TIMEOUT_MS = 10_000
103/** A shared machine reading younger than this (plus jitter) is reused, not re-collected. */
104const FRESH_MS = 25_000
105const JITTER_MS = 5000
106/** A wrap request older than this is dropped unseen. */
107const WRAP_TTL_MS = 30 * 60_000
108const MAX_FILES = 200
109const MAX_GC_PER_TICK = 10
110/** Engine session records read per tick for sessions that do not run the mod. */
111const MAX_ENGINE_ROWS = 60
112const ACTIVE_AGENT = new Set(['pending', 'running', 'waiting'])
113/** A request stamped further ahead than this is refused: a future `at` would never expire. */
114const CLOCK_SKEW_MS = 60_000
115/**
116 * The origins that count as the person: their own Enter, the Remote Control
117 * bridge, and the SDK host (the desktop Code tab drives the engine through
118 * it). Only these open (and focus) the pane. Every other origin, and any kind
119 * this list does not know, gets one summary line in a session that draws (no
120 * other session's label lands in this transcript on a peer's or a schedule's
121 * say-so), and the table only where nothing can draw (-p).
122 */
123const PANE_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
124/**
125 * Inconclusive pid probes (sh or ps failed or timed out) retried before giving
126 * up; a clear answer (an engine, or ps naming something else) is cached at once.
127 */
128const MAX_PID_PROBES = 5
129/** sysctl names read on every collection; memory size and cores never change, so they are read once. */
130const DYNAMIC_SYSCTL = ['kern.memorystatus_level', 'vm.swapusage', 'vm.loadavg']
131
132type TickResult = { snapshot: Snapshot; wrap: WrapRequest | null; newRequest: WrapRequest | null }
133
134// Per-load plumbing, not state: register() sets the options, and a hot reload
135// starts these over together with the timers it cancels; session.start (or the
136// next turn) arms again.
137let config = { intervalMs: 30_000, advice: { idleMinutes: 30, contextTokens: 150_000 } as AdviceOptions, showStatus: true, footerButtons: true, footerCompact: false }
138let timer: { cancel: () => void } | null = null
139let running: Promise<TickResult | null> | null = null
140let diskScan: Promise<void> | null = null
141// True while a main-loop turn runs (turn.start to turn.complete): a hand-off then waits, so
142// the note is written over the whole turn and /clear does not cut it short.
143let turnInFlight = false
144// Apply presses in flight, by tip key, each under a token so only the press that claimed releases
145// (a reload resets the map; a claim from before it can no longer clear a new one). Mirrored to $.state for drawing.
146let applying = new Map<string, number>()
147let applyToken = 0
148let dirCache: string | null = null
149let hardware: { totalBytes: number; cores: number } | null = null
150// Inconclusive pid probes this load; a hot reload allows a few more, which is harmless.
151let pidProbes = 0
152
153const clamp = (value: unknown, fallback: number, min: number, max: number): number =>
154  typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback
155
156/** An error's kind only: a message could carry a path or command output. */
157const kindOf = (err: unknown): string => (err instanceof Error && err.name ? err.name : 'error')
158
159/** `HOME` from the environment, as an absolute path; never '~'. */
160async function dataDir($: Dollar): Promise<string | null> {
161  if (dirCache) return dirCache
162  try {
163    const home = await $.env.get('HOME')
164    dirCache = home && home.startsWith('/') ? `${home.replace(/\/+$/, '')}/.claude/mem-gauge` : null
165  } catch {
166    dirCache = null
167  }
168  return dirCache
169}
170
171/** stdout of one argv, or null when it failed to start, timed out, or said nothing. */
172async function runText($: Dollar, argv: string[], cwd: string | undefined, timeoutMs = TIMEOUT_MS): Promise<string | null> {
173  try {
174    const r = await $.process.run(argv, { cwd, timeoutMs })
175    return r.stdout.trim() === '' ? null : r.stdout
176  } catch {
177    return null
178  }
179}
180
181async function readText($: Dollar, path: string): Promise<string | null> {
182  try {
183    const text = await $.fs.read(path)
184    return typeof text === 'string' ? text : null
185  } catch {
186    return null
187  }
188}
189
190async function writeText($: Dollar, path: string, text: string): Promise<boolean> {
191  try {
192    await $.fs.write(path, text)
193    return true
194  } catch {
195    return false
196  }
197}
198
199/** Removes one of mem-gauge's own files ($.fs has no remove). */
200async function removeFile($: Dollar, path: string): Promise<void> {
201  try {
202    await $.process.run(['rm', '-f', path], { timeoutMs: TIMEOUT_MS })
203  } catch {
204    // A file left behind is collected by a later tick.
205  }
206}
207
208/** One sysctl for every name, vm_stat, df and ps, in parallel. */
209async function collectMachine($: Dollar, now: number, cwd: string | undefined): Promise<MachineFile> {
210  const known = hardware
211  const [sysctl, vmStat, df, ps] = await Promise.all([
212    runText($, ['sysctl', ...(known ? [] : ['hw.memsize', 'hw.ncpu']), ...DYNAMIC_SYSCTL], cwd),
213    runText($, ['vm_stat'], cwd),
214    runText($, ['df', '-k', '/'], cwd),
215    runText($, ['ps', '-axo', 'pid=,ppid=,rss=,comm='], cwd),
216  ])
217  const procs = ps === null ? [] : parsePs(ps)
218  // The cached constants are handed to the same parser as sysctl's own lines.
219  const constants = known ? `hw.memsize: ${known.totalBytes}\nhw.ncpu: ${known.cores}\n` : ''
220  const stats = buildMachine(now, { sysctl: constants + (sysctl ?? '') || null, vmStat, df })
221  if (!known && stats.totalBytes !== null && stats.cores !== null) hardware = { totalBytes: stats.totalBytes, cores: stats.cores }
222  return { v: 1, at: now, stats, procs: procs.length > 0 ? procs : null }
223}
224
225/**
226 * The shared reading when another session took it recently; otherwise this
227 * session collects and shares it. Two sessions collecting at once is the only
228 * race, and it is harmless, so there is no lock.
229 */
230async function machineReading($: Dollar, dir: string | null, now: number, cwd: string | undefined): Promise<MachineFile> {
231  if (dir) {
232    const text = await readText($, `${dir}/machine.json`)
233    const shared = text === null ? null : decodeMachine(text)
234    if (shared && shared.at <= now && now - shared.at < FRESH_MS + Math.random() * JITTER_MS) return shared
235  }
236  const fresh = await collectMachine($, now, cwd)
237  if (dir) await writeText($, `${dir}/machine.json`, encodeMachine(fresh))
238  return fresh
239}
240
241/**
242 * The engine's pid: the parent of a `sh` this plugin starts, accepted only when
243 * ps names it claude/node/bun. There is no pid API, so an unverified answer
244 * stays null and the pane says "unknown" rather than show a wrong number.
245 */
246async function enginePid($: Dollar, cwd: string | undefined): Promise<number | null> {
247  if (await read($, pidCheckedRef)) return read($, enginePidRef)
248  // A clear answer is cached for the session; anything else (a failed or
249  // timed-out sh or ps, an empty answer) is retried on later ticks, so one
250  // transient failure does not leave "this session unknown" for good.
251  let answer: { pid: number | null } | null = null
252  try {
253    const echoed = await $.process.run(['sh', '-c', 'echo $PPID'], { cwd, timeoutMs: TIMEOUT_MS })
254    const candidate = Number(echoed.stdout.trim())
255    if (echoed.exitCode === 0 && Number.isSafeInteger(candidate) && candidate > 1) {
256      const comm = await $.process.run(['ps', '-o', 'comm=', '-p', String(candidate)], { cwd, timeoutMs: TIMEOUT_MS })
257      const name = basename(comm.stdout.trim())
258      if (comm.exitCode === 0 && name !== '') answer = { pid: isEngineName(name) ? candidate : null }
259    }
260  } catch {
261    // Could not start or timed out: inconclusive.
262  }
263  if (!answer) {
264    pidProbes += 1
265    if (pidProbes < MAX_PID_PROBES) return null
266    answer = { pid: null }
267  }
268  const pid = answer.pid
269  await update($, enginePidRef, () => pid)
270  await update($, pidCheckedRef, () => true)
271  return pid
272}
273
274/** "<repo basename> · <branch>" from the session's root and its cwd's checked-out branch. */
275async function refreshLabel($: Dollar): Promise<void> {
276  try {
277    const root = await $.session.root()
278    const cwd = await $.session.cwd()
279    const out = await runText($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd)
280    const branch = out === null ? null : cleanBranch(out)
281    const name = oneLine(basename(root), 100) || 'session'
282    await update($, labelRef, () => (branch ? `${name} · ${branch}` : name))
283  } catch {
284    // The label falls back to the cwd's basename.
285  }
286}
287
288/** This session's registry entry: figures only, never prompt text or a secret. */
289async function ownEntry($: Dollar, sessionId: string, pid: number | null, rssMB: number | null, cwd: string, now: number): Promise<RegistryEntry> {
290  const [usage, model, agents] = await Promise.all([
291    $.session.usage().catch(() => null),
292    $.session.model().catch(() => ''),
293    $.agent.list().catch(() => []),
294  ])
295  const label = await read($, labelRef)
296  const lastActivityAt = await read($, lastActivityRef)
297  const figures = await read($, figuresRef)
298  const rateLimit = usage && usage.rateLimits.length > 0 ? Math.max(...usage.rateLimits.map(r => r.percentUsed)) : null
299  const startedAt = usage?.startedAt ?? now
300  return {
301    v: 1,
302    sessionId,
303    pid,
304    label: label ?? (oneLine(basename(cwd), 100) || 'session'),
305    cwd,
306    model,
307    rssMB,
308    contextPercent: usage?.context.percent ?? figures?.contextPercent ?? null,
309    contextTokens: usage?.context.tokens ?? figures?.contextTokens ?? null,
310    contextWindow: usage?.context.window ?? figures?.contextWindow ?? null,
311    rateLimitPercent: rateLimit ?? figures?.rateLimitPercent ?? null,
312    // list() keeps finished agents until the engine drops them.
313    agents: agents.filter(a => ACTIVE_AGENT.has(a.status)).length,
314    // Before any turn the session has been idle since it started.
315    lastActivityAt: Math.max(lastActivityAt, startedAt),
316    startedAt,
317    updatedAt: now,
318  }
319}
320
321type RegistryFile = { path: string; entry: RegistryEntry | null; mtimeMs: number }
322
323/** Every other session's entry; an unreadable file is kept as null for garbage collection. */
324async function readRegistry($: Dollar, dir: string, thisId: string): Promise<RegistryFile[]> {
325  let list: { name: string; kind: string; mtimeMs: number }[] = []
326  try {
327    list = await $.fs.list(`${dir}/sessions`)
328  } catch {
329    return [] // no folder yet
330  }
331  const files = list
332    .filter(f => f.kind === 'file' && f.name.endsWith('.json') && isSessionId(f.name.slice(0, -5)) && f.name !== `${thisId}.json`)
333    .slice(0, MAX_FILES)
334  const texts = await Promise.all(files.map(f => readText($, `${dir}/sessions/${f.name}`)))
335  return files.map((f, i) => {
336    const text = texts[i] ?? null
337    const entry = text === null ? null : parseEntry(text)
338    // A file whose content names another id is treated as unreadable.
339    return { path: `${dir}/sessions/${f.name}`, entry: entry && `${entry.sessionId}.json` === f.name ? entry : null, mtimeMs: f.mtimeMs }
340  })
341}
342
343/** Picks up (and removes) a wrap request another session left for this one. */
344async function takeWrapRequest($: Dollar, dir: string, sessionId: string, now: number): Promise<WrapRequest | null> {
345  const path = `${dir}/requests/${sessionId}.json`
346  const text = await readText($, path)
347  if (text === null) return null
348  await removeFile($, path)
349  const request = decodeWrapRequest(text)
350  return request && isCurrent(request, now) ? request : null
351}
352
353/** A wrap request still worth showing: not expired, and not stamped in the future. */
354const isCurrent = (request: WrapRequest, now: number): boolean =>
355  request.at <= now + CLOCK_SKEW_MS && now - request.at < WRAP_TTL_MS
356
357/**
358 * Removes request files nobody picked up (the target crashed or never ran
359 * mem-gauge), so the folder does not grow. Only session-id-named files are
360 * touched; the cap keeps one tick's work bounded.
361 */
362async function collectRequests($: Dollar, dir: string, now: number): Promise<void> {
363  let list: { name: string; kind: string; mtimeMs: number }[] = []
364  try {
365    list = await $.fs.list(`${dir}/requests`)
366  } catch {
367    return // no folder yet
368  }
369  const old = list
370    .filter(f => f.kind === 'file' && f.name.endsWith('.json') && isSessionId(f.name.slice(0, -5)) && now - f.mtimeMs > WRAP_TTL_MS)
371    .slice(0, MAX_GC_PER_TICK)
372  for (const f of old) await removeFile($, `${dir}/requests/${f.name}`)
373}
374
375/** One full tick; runTick keeps two from overlapping. */
376async function tick($: Dollar): Promise<TickResult> {
377  const now = await $.clock.now()
378  // Re-read every tick: /clear moves the process to a new id with no session.start.
379  const sessionId = await $.session.id()
380  const cwd = await $.session.cwd().catch(() => '')
381  const runCwd = cwd || undefined
382  const dir = await dataDir($)
383
384  const [machine, pid] = await Promise.all([machineReading($, dir, now, runCwd), enginePid($, runCwd)])
385  const procs: Proc[] = machine.procs ?? []
386  const thisMB = pid === null ? null : treeMB(procs, pid)
387
388  const own = await ownEntry($, sessionId, pid, thisMB, cwd, now)
389  // After /clear the old id's heartbeat would read as a second live session on
390  // the same pid. session.end removes it, but that hook can lose its 1.5 s
391  // bound, so the first tick under the new id removes it too.
392  const previous = (await read($, snapshotRef))?.sessionId
393  const clearedId = previous && previous !== sessionId && isSessionId(previous) ? previous : null
394  if (dir && clearedId) await removeFile($, `${dir}/sessions/${clearedId}.json`)
395  const registry = dir ? await readRegistry($, dir, sessionId) : []
396  const others = registry.filter(f => !clearedId || !f.path.endsWith(`/${clearedId}.json`))
397  if (dir && isSessionId(sessionId)) await writeText($, `${dir}/sessions/${sessionId}.json`, JSON.stringify(own))
398
399  const index = machine.procs ? { at: machine.at, byPid: new Map(procs.map(p => [p.pid, p])) } : null
400  const rows = [rowOf(own, 'live', now, sessionId)]
401  const claude = new Set<number>(pid === null ? [] : descendants(procs, pid))
402  let collected = 0
403  for (const { path, entry, mtimeMs } of others) {
404    const status = entry ? statusOf(entry, now, index) : null
405    if (collected < MAX_GC_PER_TICK && isCollectable(entry, status, now, mtimeMs)) {
406      collected += 1
407      await removeFile($, path)
408      continue
409    }
410    if (!entry || !status) continue
411    rows.push(rowOf(entry, status, now, sessionId))
412    if (status !== 'gone' && entry.pid !== null) for (const p of descendants(procs, entry.pid)) claude.add(p)
413  }
414
415  // Several sessions asking at once (or one asking again) overwrite one file and
416  // raise one toast: while a request is already showing, a new one only
417  // refreshes it.
418  const before = await read($, wrapRef)
419  const wasShowing = before !== null && isCurrent(before, now)
420  const taken = dir && isSessionId(sessionId) ? await takeWrapRequest($, dir, sessionId, now) : null
421  if (taken) await update($, wrapRef, () => taken)
422  // Derived, not re-read: every read of one dispatch may see the moment it
423  // began, so a read here could miss the write above (and then clear it).
424  const held = taken ?? before
425  const wrap = held && isCurrent(held, now) ? held : null
426  if (!taken && before && !wrap) await update($, wrapRef, () => null)
427  const newRequest = taken && !wasShowing ? taken : null
428  if (dir) await collectRequests($, dir, now)
429
430  // Claims the unregistered engines' trees too, so neither they nor their MCP
431  // servers are listed as "other apps". Each one with a session record of the
432  // engine's own (~/.claude/sessions/<pid>.json) is listed by name with its
433  // memory; the rest are summed on one line.
434  const claimed = machine.procs ? claimUnregistered(procs, claude) : null
435  let unregistered: { count: number; rssMB: number } | null = null
436  if (claimed) {
437    unregistered = { count: 0, rssMB: 0 }
438    const home = await homeDir($)
439    const listed = new Set(rows.map(r => r.sessionId))
440    for (const root of claimed.roots.slice(0, MAX_ENGINE_ROWS)) {
441      const text = home ? await readText($, `${home}/.claude/sessions/${root.pid}.json`) : null
442      const session = text ? decodeEngineSession(text, root.pid, now) : null
443      if (session && !listed.has(session.sessionId)) {
444        listed.add(session.sessionId)
445        rows.push(engineRow(root.pid, root.rssMB, session))
446      } else {
447        unregistered.count += 1
448        unregistered.rssMB += root.rssMB
449      }
450    }
451    for (const root of claimed.roots.slice(MAX_ENGINE_ROWS)) {
452      unregistered.count += 1
453      unregistered.rssMB += root.rssMB
454    }
455  }
456  const snapshot: Snapshot = {
457    at: now,
458    sessionId,
459    machine: machine.stats,
460    thisSessionMB: thisMB,
461    sessions: sortRows(rows).slice(0, 60),
462    unregistered,
463    topApps: topApps(procs, claude, 10),
464  }
465  await update($, snapshotRef, () => snapshot)
466  return { snapshot, wrap, newRequest }
467}
468
469function publishStatus($: Dollar, snapshot: Snapshot, wrap: WrapRequest | null): void {
470  // Never clear with `undefined`: on the desktop host that call stops every
471  // later render from reaching this plugin (seen on app 2.31226 / CLI 2.1.293),
472  // so a line pinned by an earlier load stays until the session restarts.
473  if (config.showStatus) $.ui.status(statusLine(snapshot, wrap))
474}
475
476/** One tick at a time: a tick that outlasts the period, or a /mem during one, joins it. */
477function runTick($: Dollar): Promise<TickResult | null> {
478  if (running) return running
479  let mine: Promise<TickResult | null> | null = null
480  mine = (async () => {
481    try {
482      const result = await tick($)
483      if (result.newRequest) {
484        const frees = result.newRequest.freesMB === null ? '' : ` (frees about ${formatMB(result.newRequest.freesMB)})`
485        $.ui.toast(`Memory: another session asked this one to wrap up${frees}. Nothing runs on its own.`, { timeoutMs: 10_000 })
486      }
487      publishStatus($, result.snapshot, result.wrap)
488      return result
489    } catch (err) {
490      $.ui.log(`mem-gauge: tick failed (${kindOf(err)})`, { to: 'debug' })
491      return null
492    } finally {
493      // Only the tick that holds the slot clears it (a hot reload resets the slot under a running tick).
494      if (running === mine) running = null
495    }
496  })()
497  running = mine
498  return mine
499}
500
501function arm($: Dollar): void {
502  if (timer) return
503  timer = $.clock.every(config.intervalMs, () => {
504    void runTick($)
505  })
506}
507
508async function markActive($: Dollar): Promise<void> {
509  const now = await $.clock.now()
510  await update($, lastActivityRef, () => now)
511}
512
513/** Opens the pane: from /mem or a press of the gauge, so it is placed at any width. */
514async function openPane($: Dollar) {
515  const wrap = await read($, wrapRef)
516  const opened = await $.ui.open({ id: PANE, title: 'Memory', focus: true, closeOnEscape: true })
517  // Only a placed pane counts as seen: an unplaced one keeps the reminder.
518  if (wrap && opened.isPlaced) {
519    await update($, wrapRef, () => null)
520    const shown = await read($, snapshotRef)
521    if (shown) publishStatus($, shown, null)
522  }
523  const snapshot = await read($, snapshotRef)
524  if (!snapshot || (await $.clock.now()) - snapshot.at > REFRESH_AFTER_MS) void runTick($)
525  return opened
526}
527
528/** `HOME`, derived from the data dir (which is `$HOME/.claude/mem-gauge`). */
529async function homeDir($: Dollar): Promise<string | null> {
530  const dir = await dataDir($)
531  return dir ? dir.replace(/\/\.claude\/mem-gauge$/, '') : null
532}
533
534/** Reads every volume (`df -kP`, 5 s) and the usual big folders (`du -sk` each, in parallel, 10 s each) into the disk report. */
535async function diskReading($: Dollar): Promise<void> {
536  const now = await $.clock.now()
537  const cwd = (await $.session.cwd().catch(() => '')) || undefined
538  const home = await homeDir($)
539  const paths = DISK_FOLDERS.map(f => (home ? `${home}/${f.rel}` : null))
540  const [df, ...dus] = await Promise.all([
541    runText($, ['df', '-kP'], cwd),
542    ...paths.map(path => (path ? runText($, ['du', '-sk', path], cwd, DU_TIMEOUT_MS) : Promise.resolve(null))),
543  ])
544  const report: DiskReport = {
545    at: now,
546    volumes: df ? parseDfAll(df) : [],
547    folders: DISK_FOLDERS.map((f, i) => ({ path: paths[i] ?? f.rel, label: f.label, bytes: dus[i] ? parseDu(dus[i]) : null })),
548  }
549  await update($, diskRef, () => report)
550}
551
552/** One disk reading at a time: a Refresh during one joins it; `fresh` waits for it and then reads again (after an Apply). */
553async function runDiskScan($: Dollar, fresh = false): Promise<void> {
554  if (diskScan) {
555    if (!fresh) return diskScan
556    await diskScan.catch(() => undefined)
557  }
558  let mine: Promise<void> | null = null
559  mine = diskReading($)
560    .catch(err => {
561      $.ui.log(`mem-gauge: disk reading failed (${kindOf(err)})`, { to: 'debug' })
562    })
563    .finally(() => {
564      // Only the scan that holds the slot clears it: a hot reload resets the slot under a running scan.
565      if (diskScan === mine) diskScan = null
566    })
567  diskScan = mine
568  return mine
569}
570
571async function openDiskPane($: Dollar) {
572  const opened = await $.ui.open({ id: DISK_PANE, title: 'Disk', focus: true, closeOnEscape: true })
573  const now = await $.clock.now()
574  const report = await read($, diskRef)
575  if (!report || now - report.at > DISK_REFRESH_AFTER_MS) void runDiskScan($)
576  const snapshot = await read($, snapshotRef)
577  if (!snapshot || now - snapshot.at > REFRESH_AFTER_MS) void runTick($)
578  return opened
579}
580
581/**
582 * Claims a tip's Apply from the press until it is over (dialog included): a
583 * second press meanwhile is ignored. The module set is the truth (a reload
584 * resets it); $.state mirrors it so the pane draws Applying.
585 */
586async function beginApply($: Dollar, key: string): Promise<number | null> {
587  if (applying.has(key)) return null
588  const token = ++applyToken
589  applying.set(key, token)
590  try {
591    await update($, applyingRef, held => (held.includes(key) ? held : [...held, key]))
592  } catch (err) {
593    // The claim is only as good as its mirror: a press that cannot draw Applying is let go.
594    if (applying.get(key) === token) applying.delete(key)
595    $.ui.log(`mem-gauge: apply state (${kindOf(err)})`, { to: 'debug' })
596    return null
597  }
598  return token
599}
600
601async function endApply($: Dollar, key: string, token: number): Promise<void> {
602  if (applying.get(key) !== token) return
603  applying.delete(key)
604  await update($, applyingRef, held => held.filter(k => k !== key)).catch(() => undefined)
605}
606
607/**
608 * Apply on a Disk tip: confirms in the engine's own dialog, then runs the
609 * one fixed command for that action (argv, no shell, paths from $HOME and a
610 * fixed list) with a long timeout, says how it went, and re-reads the sizes.
611 * A press while it runs, a dismissed dialog, or Cancel does nothing.
612 */
613async function applyDisk($: Dollar, tip: DiskAdvice): Promise<void> {
614  if (!tip.action) return
615  const key = `disk:${tip.key}`
616  const token = await beginApply($, key)
617  if (token === null) return
618  let ran = false
619  try {
620    let choice: string
621    try {
622      choice = await $.ui.ask(diskActionQuestion(tip.action), { options: [APPLY, 'Cancel'], header: 'Disk' })
623    } catch {
624      return
625    }
626    if (choice !== APPLY) return
627    const home = applyHome(await homeDir($))
628    if (!home) {
629      $.ui.toast('Disk: cannot apply, HOME is unknown or not a plain absolute path.')
630      return
631    }
632    const label = diskActionLabel(tip.action)
633    try {
634      const r = await $.process.run(diskActionArgv(tip.action, home), { timeoutMs: APPLY_TIMEOUT_MS })
635      ran = true
636      $.ui.toast(
637        r.exitCode === 0
638          ? `Disk: done, ${label}. Re-reading sizes.`
639          : `Disk: ${label} finished with errors (exit ${r.exitCode}); some entries may be protected. Re-reading sizes.`,
640      )
641    } catch (err) {
642      // A timeout may have removed part of the folder: re-read either way.
643      ran = true
644      $.ui.toast(`Disk: could not finish ${label} (${kindOf(err)}); some entries may be gone. Re-reading sizes.`)
645    }
646  } finally {
647    await endApply($, key, token)
648  }
649  if (ran) void runDiskScan($, true)
650}
651
652/** A Memory tip button's label, by what it does: the context tip names its two ways out, every other kind reads Apply. */
653const actionLabel = (action: AdviceAction): string => (action.kind === 'compact' ? 'Compact' : action.kind === 'hand-off' ? HAND_OFF : APPLY)
654
655/**
656 * A press on a Memory tip's button (`action`, the tip's own by default; the
657 * Hand off button passes `tip.secondary`): Ask to wrap (the existing flow, with
658 * its own confirmation), compact this session, run /handoff (the handoff mod),
659 * quit an app gracefully (confirmed), or open the Disk pane. A tip with no
660 * action has no button.
661 */
662async function applyMemory($: Dollar, tip: Advice, rowsById: ReadonlyMap<string, SessionRow>, action = tip.action): Promise<void> {
663  if (!action) return
664  if (action.kind === 'ask-wrap') {
665    const target = rowsById.get(action.sessionId)
666    if (target) await askToWrap($, target)
667    return
668  }
669  if (action.kind === 'open-disk') {
670    await openDiskPane($)
671    return
672  }
673  if (action.kind === 'hand-off') {
674    await runHandoff($, tip)
675    return
676  }
677  // A compact queued behind a hand-off's /clear (or the reverse) would act on the wrong conversation: one at a time per tip.
678  if (applying.has(`handoff:${tip.key}`)) return
679  const key = `memory:${tip.key}`
680  const token = await beginApply($, key)
681  if (token === null) return
682  try {
683    if (action.kind === 'compact') {
684      let choice: string
685      try {
686        choice = await $.ui.ask('Compact this session now (the conversation so far is summarized to free context)?', { options: [COMPACT, 'Cancel'], header: 'Memory' })
687      } catch {
688        return
689      }
690      if (choice !== COMPACT) return
691      try {
692        await $.command.run({ command: 'compact' })
693        $.ui.toast('Memory: /compact queued; it runs when the session is idle.')
694      } catch (err) {
695        $.ui.toast(`Memory: could not run /compact (${kindOf(err)}).`)
696      }
697      return
698    }
699    // quit-app
700    const argv = quitAppArgv(action.app)
701    if (!argv) {
702      $.ui.toast('Memory: that app name cannot be passed to osascript safely; quit it yourself.')
703      return
704    }
705    let choice: string
706    try {
707      choice = await $.ui.ask(`Quit ${action.app} the normal way (it may ask you to save first)?`, { options: [APPLY, 'Cancel'], header: 'Memory' })
708    } catch {
709      return
710    }
711    if (choice !== APPLY) return
712    try {
713      const r = await $.process.run(argv, { timeoutMs: QUIT_TIMEOUT_MS })
714      $.ui.toast(r.exitCode === 0 ? `Memory: ${action.app} was asked to quit.` : `Memory: ${action.app} did not take the quit (exit ${r.exitCode}).`)
715    } catch (err) {
716      $.ui.toast(
717        kindOf(err) === 'TimeoutError'
718          ? `Memory: ${action.app} was asked to quit; it may be waiting on a save dialog.`
719          : `Memory: could not ask ${action.app} to quit (${kindOf(err)}).`,
720      )
721    }
722    void runTick($)
723  } finally {
724    await endApply($, key, token)
725  }
726}
727
728/** What the Hand off button says when /handoff is refused: the mod that owns the flow is not installed. */
729const HANDOFF_MISSING = 'Memory: the Hand off button needs the handoff mod (claude plugin install handoff@joseph-mods --scope user).'
730
731/**
732 * The Hand off button: runs `/handoff`, the handoff mod's command, which owns
733 * the whole flow (its own dialog, the note, `/clear`, the pre-filled prompt).
734 * This mod writes no note and makes no model call; it only decides when the
735 * button may fire.
736 *
737 * Two guards stay here because only this mod sees the button: mid-turn the
738 * press is refused with a toast (the handoff mod refuses too, but its answer
739 * lands in the transcript, away from the pane that was pressed), and a claim
740 * under `handoff:<key>` is held for the length of the `$.command.run` promise,
741 * so a double press runs the command once and this tip's Compact waits
742 * meanwhile. Re-entry past that (a second /handoff while its dialog is open)
743 * is the handoff mod's own guard. A rejection (the engine refuses a name no
744 * plugin registered) is read as the mod being absent, and the toast names its
745 * install line.
746 */
747async function runHandoff($: Dollar, tip: Advice): Promise<void> {
748  // A hand-off while this tip's compact is in flight would clear what the compact is about to summarize.
749  if (applying.has(`memory:${tip.key}`)) return
750  // Mid-turn, the note would miss the turn's work and /clear would cut it off: wait for the end.
751  if (turnInFlight) {
752    $.ui.toast('Memory: a turn is running; hand off once it has finished, so the note covers it.')
753    return
754  }
755  const key = `handoff:${tip.key}`
756  const token = await beginApply($, key)
757  if (token === null) return
758  try {
759    await $.command.run({ command: 'handoff' })
760  } catch {
761    $.ui.toast(HANDOFF_MISSING)
762  } finally {
763    await endApply($, key, token)
764  }
765}
766
767/**
768 * Leaves a request file for another session's copy of this mod, which shows
769 * its person a toast and a marker. No model reads it and nothing runs on its
770 * own: a peer message would wake that session into a turn (spending the very
771 * memory this frees) and could be read as an instruction to ship.
772 */
773async function askToWrap($: Dollar, row: SessionRow): Promise<void> {
774  const now = await $.clock.now()
775  const asked = await read($, askedRef)
776  if (now - (asked[row.sessionId] ?? 0) < ASK_EVERY_MS) {
777    $.ui.toast('Memory: that session was asked less than 10 minutes ago.')
778    return
779  }
780  let choice: string
781  try {
782    choice = await $.ui.ask(
783      `It has been idle ${row.idleMinutes ?? '?'} min and holds ${formatMB(row.rssMB)}. Ask "${row.label}" to wrap up?`,
784      { options: [ASK, 'Cancel'], header: 'Memory' },
785    )
786  } catch {
787    return // dismissed, or nobody to ask
788  }
789  if (choice !== ASK) return
790  const dir = await dataDir($)
791  const from = await $.session.id().catch(() => '')
792  const isSent =
793    dir !== null &&
794    isSessionId(row.sessionId) &&
795    isSessionId(from) &&
796    (await writeText($, `${dir}/requests/${row.sessionId}.json`, JSON.stringify({ from, at: now, freesMB: row.rssMB })))
797  if (!isSent) {
798    $.ui.toast('Memory: could not leave the request (the mem-gauge folder is not writable).')
799    return
800  }
801  await update($, askedRef, map => {
802    const kept: Record<string, number> = {}
803    for (const [id, at] of Object.entries(map ?? {})) if (now - at < ASK_EVERY_MS) kept[id] = at
804    kept[row.sessionId] = now
805    return kept
806  })
807  $.ui.toast(`Memory: asked "${row.label}" to wrap up. It sees a notice; nothing runs on its own.`)
808}
809
810export const register: Register = (on, options) => {
811  config = {
812    intervalMs: clamp(options['intervalSeconds'], 30, 10, 600) * 1000,
813    advice: {
814      idleMinutes: clamp(options['idleMinutes'], 30, 1, 24 * 60),
815      // 0 is a real choice (the percent-of-window rule), so it is kept rather than clamped up to the minimum.
816      contextTokens: options['contextTipTokens'] === 0 ? 0 : clamp(options['contextTipTokens'], 150_000, 20_000, 1_000_000),
817    },
818    showStatus: options['statusLine'] === true,
819    footerButtons: options['footerButtons'] !== false,
820    // One chip for both figures: the desktop's footer strip clips past two or three chips (option footerCompact).
821    footerCompact: options['footerCompact'] === true,
822  }
823  timer = null
824  running = null
825  diskScan = null
826  applying = new Map()
827  dirCache = null
828  hardware = null
829  pidProbes = 0
830
831  on('session.start', async ($, e, next) => {
832    try {
833      await $.command.register({
834        name: 'mem',
835        description: "Show what is using this Mac's memory across Claude sessions, agents and apps",
836        immediate: true,
837      })
838    } catch (err) {
839      $.ui.log(`mem-gauge: /mem not registered (${kindOf(err)})`, { to: 'debug' })
840    }
841    try {
842      await $.command.register({
843        name: 'disk',
844        description: "Show this Mac's disk: the volume's use, every mounted volume, the usual big folders",
845        immediate: true,
846      })
847    } catch (err) {
848      $.ui.log(`mem-gauge: /disk not registered (${kindOf(err)})`, { to: 'debug' })
849    }
850    arm($)
851    // A load starts with no Apply in flight: a reload mid-Apply must not leave a button at Applying.
852    await update($, applyingRef, () => []).catch(() => undefined)
853    // The first reading runs once the start has settled, never holding up the first prompt.
854    $.clock.after(0, () => {
855      void refreshLabel($).then(() => runTick($))
856    })
857    return next(e)
858  })
859
860  // Activity marks the session busy; turn.start fires for the main loop only.
861  // Its prompt text is never read.
862  on('turn.start', async ($, e, next) => {
863    turnInFlight = true
864    let result: Awaited<ReturnType<typeof next>>
865    try {
866      result = await next(e)
867    } catch (err) {
868      // A chain beneath that rejects may never see its turn.complete: do not leave the flag set.
869      turnInFlight = false
870      throw err
871    }
872    try {
873      await markActive($)
874      arm($)
875    } catch {
876      // Observing only.
877    }
878    return result
879  })
880
881  // The flag is cleared in a finally: a hook beneath that fails rejects next(e),
882  // and a stuck flag would refuse every hand-off until a reload.
883  on('turn.complete', async ($, e, next) => {
884    let result: Awaited<ReturnType<typeof next>>
885    try {
886      result = await next(e)
887    } finally {
888      if (e.agentId === undefined) turnInFlight = false
889    }
890    if (e.agentId === undefined) {
891      try {
892        await markActive($)
893        arm($)
894        // The branch may have changed during the turn; git runs off the turn's path.
895        $.clock.after(0, () => {
896          void refreshLabel($)
897        })
898      } catch {
899        // Observing only.
900      }
901    }
902    return result
903  })
904
905  on('session.measure', async ($, e, next) => {
906    const result = await next(e)
907    try {
908      const rate = e.rateLimits.length > 0 ? Math.max(...e.rateLimits.map(r => r.percentUsed)) : null
909      await update($, figuresRef, () => ({
910        contextPercent: e.context.percent ?? null,
911        contextTokens: e.context.tokens ?? null,
912        contextWindow: e.context.window ?? null,
913        rateLimitPercent: rate,
914      }))
915    } catch {
916      // Observing only.
917    }
918    return result
919  })
920
921  // One rm inside the shared 1.5 s bound, started beside core's end step rather
922  // than before it, so a slow spawn never eats core's share; a crash never gets
923  // here, so other sessions' ticks collect what is left.
924  on('session.end', async ($, e, next) => {
925    const [result] = await Promise.all([
926      next(e),
927      (async () => {
928        const dir = await dataDir($)
929        if (dir && isSessionId(e.sessionId)) await removeFile($, `${dir}/sessions/${e.sessionId}.json`)
930      })(),
931    ])
932    return result
933  })
934
935  // Read-only, so any origin may run it, but only the person's own origins
936  // (PANE_ORIGINS) open the focused pane. When the pane is placed the row says
937  // only that (the row is read by the model and kept in the transcript). The
938  // per-session table is the answer only to the person where the pane is not
939  // placed, or where nothing draws; any other origin gets one summary line.
940  on('command.run', { command: 'mem' }, async ($, e) => {
941    let snapshot = await read($, snapshotRef)
942    // A tick already in flight (the timer's) is not joined when a reading is
943    // cached: its process waits are not this hook's, and /mem answers at once.
944    if (!snapshot || (!running && (await $.clock.now()) - snapshot.at > REFRESH_AFTER_MS)) {
945      snapshot = (await runTick($))?.snapshot ?? snapshot
946    }
947    // A plain -p run draws on no surface yet "places" every pane: answer it with the table.
948    const surfaces = await $.session.surfaces().catch(() => [])
949    const isPerson = PANE_ORIGINS.has(e.origin.kind)
950    if (surfaces.length > 0 && isPerson) {
951      let isPlaced = false
952      try {
953        isPlaced = (await openPane($)).isPlaced
954      } catch {
955        isPlaced = false
956      }
957      if (isPlaced) return { text: 'Memory pane opened.' }
958    }
959    if (!snapshot) return { text: 'mem-gauge: no reading yet (the collectors could not run).' }
960    return { text: surfaces.length > 0 && !isPerson ? summaryLine(snapshot) : compactReport(snapshot, config.advice) }
961  }).catch(async ($, e) => {
962    // Past its budget or failed: answer from the last reading rather than leave
963    // a registered command with no output.
964    const snapshot = await read($, snapshotRef).catch(() => null)
965    if (!snapshot) return { text: 'mem-gauge: no reading yet.' }
966    return { text: PANE_ORIGINS.has(e.origin.kind) ? compactReport(snapshot, config.advice) : summaryLine(snapshot) }
967  })
968
969  on('command.run', { command: 'disk' }, async ($, e) => {
970    const surfaces = await $.session.surfaces().catch(() => [])
971    if (surfaces.length > 0 && PANE_ORIGINS.has(e.origin.kind)) {
972      let isPlaced = false
973      try {
974        isPlaced = (await openDiskPane($)).isPlaced
975      } catch {
976        isPlaced = false
977      }
978      if (isPlaced) return { text: 'Disk pane opened.' }
979    }
980    const snapshot = await read($, snapshotRef)
981    if (!snapshot) return { text: 'mem-gauge: no reading yet.' }
982    return { text: diskLine(snapshot) }
983  }).catch(async ($) => {
984    const snapshot = await read($, snapshotRef).catch(() => null)
985    return { text: snapshot ? diskLine(snapshot) : 'mem-gauge: no reading yet.' }
986  })
987
988  // The gauge on the terminal: the hint line under the prompt, after the engine's
989  // own line so its pills stay live. Only the terminal raises the hint line to
990  // plugins (the desktop host does not), so every other surface passes.
991  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
992    const engine = await next(e)
993    if (e.surface !== 'terminal') return engine
994    const snapshot = await read($, snapshotRef)
995    if (!snapshot) return engine
996    const wrap = await read($, wrapRef)
997    const open = () => void openPane($).catch(() => undefined)
998    // No vector element here: the quintile glyph in red stands in for the ring, and "Disk" for the icon.
999    const { Box, Text, Button } = $.ui.resolve(e)
1000    return (
1001      <Box flexDirection="row">
1002        {engine}
1003        <Box marginLeft={2} flexDirection="row" gap={1}>
1004          <Text color="red" bold>{glyph(snapshot.machine.usedPercent)}</Text>
1005          <Button key="mem-gauge" plain dimColor label={statusLine(snapshot, wrap)} onPress={open} />
1006        </Box>
1007      </Box>
1008    )
1009  })
1010
1011  // The gauge on the desktop: the prompt footer's mode row, the one site under
1012  // the composer the desktop raises. That strip clips what does not fit, draws
1013  // no vector element (an Svg there is simply absent), ignores a Box's hover
1014  // and display, draws every Button before every Text whatever the tree's
1015  // order, and a Button is the only thing it draws pressable, always as a
1016  // native chip; it draws no Client either. Joseph chose the chips: by
1017  // default the two figures are Buttons that open the panes, the memory
1018  // quintile ring with the used percent and the disk glyph with the free
1019  // space. With footerButtons off the row is plain text and /mem and /disk
1020  // open the panes. Pressure is read in the pane; under
1021  // pressure or a wrap request the memory figure reads "! 60%".
1022  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
1023    const engine = await next(e)
1024    if (e.surface !== 'desktop') return engine
1025    const snapshot = await read($, snapshotRef)
1026    if (!snapshot) return engine
1027    const wrap = await read($, wrapRef)
1028    const m = snapshot.machine
1029    const { Box, Text, Button } = $.ui.resolve(e)
1030    const { used, disk, diskPct, alarm } = footerParts(snapshot, wrap)
1031    const openMemory = () => void openPane($).catch(() => undefined)
1032    const openDisk = () => void openDiskPane($).catch(() => undefined)
1033    if (config.footerButtons && config.footerCompact) {
1034      // Both figures in one tight chip (memory used %, disk used %), so the strip holds other mods' chips too; Disk is a button in the Memory pane.
1035      return (
1036        <Box flexDirection="row" alignItems="center" gap={1}>
1037          <Button key="mem-gauge" plain label={`${glyph(m.usedPercent)}${alarm ? '!' : ''}${used} ${DISK_GLYPH}${diskPct}`} onPress={openMemory} />
1038          {engine}
1039        </Box>
1040      )
1041    }
1042    if (config.footerButtons) {
1043      return (
1044        <Box flexDirection="row" alignItems="center" gap={1}>
1045          <Button key="mem-gauge" plain label={`${glyph(m.usedPercent)} ${alarm ? '! ' : ''}${used}`} onPress={openMemory} />
1046          <Button key="mem-gauge-disk" plain label={`${DISK_GLYPH} ${disk}`} onPress={openDisk} />
1047          {engine}
1048        </Box>
1049      )
1050    }
1051    if (config.footerCompact) {
1052      // Plain text, as tight as the one-chip label: both fullness percents, nothing pressable; /mem and /disk open the panes.
1053      return (
1054        <Box flexDirection="row" alignItems="center" gap={1}>
1055          <Text color="red" bold>
1056            {glyph(m.usedPercent)}
1057          </Text>
1058          {alarm ? <Text color="red" bold>{`!${used} ${DISK_GLYPH}${diskPct}`}</Text> : <Text>{`${used} ${DISK_GLYPH}${diskPct}`}</Text>}
1059          {engine}
1060        </Box>
1061      )
1062    }
1063    return (
1064      <Box flexDirection="row" alignItems="center" gap={1}>
1065        <Text color="red" bold>
1066          {glyph(m.usedPercent)}
1067        </Text>
1068        {alarm ? <Text color="red" bold>{`! ${used}`}</Text> : <Text>{used}</Text>}
1069        <Text dimColor>{`${DISK_GLYPH} ${disk}`}</Text>
1070        {engine}
1071      </Box>
1072    )
1073  })
1074
1075  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1076    const { Box, Text, Button } = $.ui.resolve(e)
1077    const snapshot = await read($, snapshotRef)
1078    const asked = await read($, askedRef)
1079    const wrap = await read($, wrapRef)
1080    const showAll = await read($, showAllRef)
1081    const applying = await read($, applyingRef)
1082    const now = await $.clock.now()
1083    const cols = Math.max(24, Math.floor(e.props.bodyColumns))
1084
1085    const controls = (
1086      <Box flexDirection="row" gap={2} marginTop={1}>
1087        <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void runTick($)} />
1088        <Button key="disk" label="Disk" hotkey="d" onPress={() => void openDiskPane($).catch(() => undefined)} />
1089        <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => void $.ui.close({ id: PANE }).catch(() => undefined)} />
1090      </Box>
1091    )
1092    if (!snapshot) {
1093      return (
1094        <Box flexDirection="column">
1095          <Text dimColor>Reading memory…</Text>
1096          {controls}
1097        </Box>
1098      )
1099    }
1100
1101    const m = snapshot.machine
1102    const used = m.usedPercent === null ? '--' : `${Math.round(m.usedPercent)}%`
1103    let gauge
1104    if (e.surface === 'terminal') {
1105      gauge = <Text bold>{bar(m.usedPercent, Math.min(32, cols - 14))}</Text>
1106    } else {
1107      const { Svg } = $.ui.resolve(e)
1108      gauge = (
1109        <Svg source={ringSvg(m.usedPercent, m.pressure)} alt={`Memory ${used} used, pressure ${m.pressure}`} width={88} height={88} />
1110      )
1111    }
1112
1113    // Label left, figure right-aligned in one column.
1114    const totals: readonly [string, string][] = [
1115      ['Used', `${formatBytes(m.usedBytes)} of ${formatBytes(m.totalBytes)}`],
1116      ['Free', formatBytes(m.freeBytes)],
1117      ['Compressed', formatBytes(m.compressedBytes)],
1118      ['Swap', `${formatBytes(m.swapUsedBytes)} of ${formatBytes(m.swapTotalBytes)}`],
1119      ['Load', `${m.load1 === null ? '?' : m.load1.toFixed(1)} on ${m.cores ?? '?'} cores`],
1120    ]
1121    // Label left in a fixed column, figure flush with the pane's right edge.
1122    const totalsLabelWidth = 12
1123
1124    // Columns sized to the body; model and idle drop out first on a narrow pane.
1125    const isWide = cols >= 70
1126    const width = { rss: 9, ctx: 6, agents: 7, idle: isWide ? 9 : 0, model: isWide ? 14 : 0 }
1127    const labelWidth = Math.max(8, cols - width.rss - width.ctx - width.agents - width.idle - width.model)
1128    // Text columns (session, model) read from the left; figure columns
1129    // (RSS, context, agents, idle) sit at the right of their column, and the
1130    // session column takes whatever the row has left, so the figures line up
1131    // on the pane's right edge on every surface and font. This session is the
1132    // bold row (no mark column).
1133    const cell = (w: number, text: string, dim: boolean, bold: boolean, align: 'left' | 'right' = 'left', grows = false) => (
1134      <Box width={w} flexGrow={grows ? 1 : 0} justifyContent={align === 'right' ? 'flex-end' : 'flex-start'}>
1135        <Text wrap="truncate" dimColor={dim} bold={bold}>
1136          {text}
1137        </Text>
1138      </Box>
1139    )
1140    const tableRow = (key: string, values: string[], dim: boolean, bold: boolean) => (
1141      <Box key={key} flexDirection="row">
1142        {cell(labelWidth, values[0] ?? '', dim, bold, 'left', true)}
1143        {isWide && cell(width.model, values[1] ?? '', dim, bold)}
1144        {cell(width.rss, values[2] ?? '', dim, bold, 'right')}
1145        {cell(width.ctx, values[3] ?? '', dim, bold, 'right')}
1146        {cell(width.agents, values[4] ?? '', dim, bold, 'right')}
1147        {isWide && cell(width.idle, values[5] ?? '', dim, bold, 'right')}
1148      </Box>
1149    )
1150    const live = snapshot.sessions.filter(s => s.status === 'live')
1151    // Active (not exited) sessions, biggest first; the top ten until View all.
1152    const active = snapshot.sessions.filter(s => s.status !== 'gone').sort((a, b) => (b.rssMB ?? -1) - (a.rssMB ?? -1))
1153    const listed = showAll ? active : active.slice(0, TOP_SESSIONS)
1154    const hidden = active.length - listed.length
1155    const toggle = (value: boolean) => void update($, showAllRef, () => value)
1156    const tips = advise(snapshot, config.advice)
1157    const rowsById = new Map(snapshot.sessions.map(s => [s.sessionId, s]))
1158
1159    return (
1160      <Box flexDirection="column">
1161        {wrap && (
1162          <Text bold>
1163            ! Another session asked this one to wrap up
1164            {wrap.freesMB === null ? '' : ` (frees about ${formatMB(wrap.freesMB)})`}. Nothing runs on its own.
1165          </Text>
1166        )}
1167        <Box key="gauge" flexDirection="row" gap={2} alignItems="center">
1168          {gauge}
1169          <Box flexDirection="column">
1170            <Text bold>
1171              Memory {used} used · pressure {m.pressure}
1172            </Text>
1173            <Text dimColor>
1174              This session {snapshot.thisSessionMB === null ? 'unknown' : formatMB(snapshot.thisSessionMB)} · {live.length} live
1175              session{live.length === 1 ? '' : 's'}
1176            </Text>
1177          </Box>
1178        </Box>
1179        <Box flexDirection="column" marginTop={1}>
1180          {totals.map(([label, value]) => (
1181            <Box key={`total:${label}`} flexDirection="row">
1182              <Box width={totalsLabelWidth}>
1183                <Text dimColor>{label}</Text>
1184              </Box>
1185              <Box flexGrow={1} justifyContent="flex-end">
1186                <Text wrap="truncate">{value}</Text>
1187              </Box>
1188            </Box>
1189          ))}
1190        </Box>
1191
1192        <Box key="sessions" flexDirection="column" marginTop={1}>
1193          <Text bold>
1194            Sessions{active.length > TOP_SESSIONS ? (showAll ? ` (all ${active.length} active)` : ` (top ${TOP_SESSIONS} of ${active.length} active)`) : ''}
1195          </Text>
1196          {tableRow('head', ['Session', 'Model', 'RSS', 'Ctx', 'Agents', 'Idle'], true, false)}
1197          {listed.map(s =>
1198            tableRow(
1199              `s:${s.sessionId}`,
1200              [
hooks/advice.ts 306 lines
1// The OPTIMIZE section: fixed rules over a snapshot, no model call. Each rule
2// names the session or app it is about and the memory a step would free, so
3// the person can act on it without opening Activity Monitor.
4
5import type { DiskReport, Snapshot } from '../types'
6
7import { formatBytes, formatMB, formatTokens, pressureText, shortModel } from './parse'
8
9/**
10 * What a Memory pane tip's button does. Every kind confirms first and runs a
11 * fixed command or an existing flow; none takes text from a process or a file
12 * into a shell (there is no shell: argv only). `hand-off` runs /handoff, the
13 * handoff mod's command, whose own dialog confirms before it writes a note and
14 * clears the conversation (register.tsx `runHandoff`).
15 */
16export type AdviceAction =
17  | { kind: 'ask-wrap'; sessionId: string }
18  | { kind: 'compact' }
19  | { kind: 'hand-off' }
20  | { kind: 'quit-app'; app: string }
21  | { kind: 'open-disk' }
22
23export type Advice = {
24  /** Stable key per rule and target, for list keys and tests. */
25  key: string
26  text: string
27  /** The session this advice targets, when the pane may offer "Ask to wrap". */
28  sessionId?: string
29  /** What the tip's button does; absent, the tip is advice only (sequence agents, pause suites, a session with no mod). */
30  action?: AdviceAction
31  /** A second button beside it, where a tip offers two ways out (this session's context: Compact, or Hand off). */
32  secondary?: AdviceAction
33}
34
35/** What the Disk pane's Apply button does for a tip; each a fixed argv (diskActionArgv). */
36export type DiskAction = { kind: 'clear-caches' | 'clear-derived-data' | 'delete-stale-simulators' | 'empty-trash' | 'prune-file-history' }
37
38export type DiskAdvice = { key: string; text: string; action?: DiskAction }
39
40export type AdviceOptions = {
41  idleMinutes: number
42  /** The context tip fires at this many tokens; 0 falls back to CONTEXT_HIGH percent of the window. */
43  contextTokens: number
44}
45
46const IDLE_BIG_MB = 1024
47// The percent rule is the fallback for a session whose token count is unknown, or an option of 0.
48const CONTEXT_HIGH = 80
49const AGENTS_HIGH = 3
50const USED_HIGH = 85
51const DISK_LOW_BYTES = 10 * 1024 ** 3
52
53/** The sessions the pane offers "Ask to wrap" for: other live sessions idle past the threshold and over 1 GB. */
54export function idleSessions(snapshot: Snapshot, options: AdviceOptions) {
55  return snapshot.sessions.filter(
56    s =>
57      !s.isThis &&
58      s.status === 'live' &&
59      s.idleMinutes !== null &&
60      s.idleMinutes > options.idleMinutes &&
61      (s.rssMB ?? 0) > IDLE_BIG_MB,
62  )
63}
64
65export function advise(snapshot: Snapshot, options: AdviceOptions): Advice[] {
66  const out: Advice[] = []
67  const live = snapshot.sessions.filter(s => s.status === 'live')
68  const m = snapshot.machine
69
70  for (const s of idleSessions(snapshot, options)) {
71    out.push({
72      key: `idle:${s.sessionId}`,
73      sessionId: s.sessionId,
74      ...(s.hasMod === false ? {} : { action: { kind: 'ask-wrap' as const, sessionId: s.sessionId } }),
75      // A row an earlier build kept in $.state has no hasMod; only an explicit false means no mod to ask.
76      text:
77        s.hasMod === false
78          ? `${s.label}: idle ${s.idleMinutes} min, archive it from the sidebar (frees ${formatMB(s.rssMB)})`
79          : `${s.label}: idle ${s.idleMinutes} min, wrap or archive (frees ${formatMB(s.rssMB)})`,
80    })
81  }
82
83  for (const s of live) {
84    const tokens = s.contextTokens
85    const high =
86      options.contextTokens > 0 && tokens !== null
87        ? tokens >= options.contextTokens
88        : s.contextPercent !== null && s.contextPercent > CONTEXT_HIGH
89    if (high) {
90      const pct = s.contextPercent === null ? null : Math.round(s.contextPercent)
91      const ctx =
92        tokens !== null && pct !== null ? `${formatTokens(tokens)} (${pct}%)` : pct !== null ? `${pct}%` : formatTokens(tokens)
93      // Only this session can be compacted or handed off from here; another's tip is advice only.
94      out.push(
95        s.isThis
96          ? {
97              key: `context:${s.sessionId}`,
98              action: { kind: 'compact' },
99              secondary: { kind: 'hand-off' },
100              text: `${s.label}: context ${ctx}. Compact to keep going mid-task, or hand off at the next milestone (a note is written for the fresh session).`,
101            }
102          : { key: `context:${s.sessionId}`, text: `${s.label}: context ${ctx}, compact or hand off from there` },
103      )
104    }
105  }
106
107  const fanOut = live.filter(s => s.agents > AGENTS_HIGH).sort((a, b) => b.agents - a.agents)[0]
108  if (fanOut) {
109    out.push({
110      key: `agents:${fanOut.sessionId}`,
111      text: `${fanOut.label}: ${fanOut.agents} agents running, the largest fan-out; consider sequencing`,
112    })
113  }
114
115  const isTight = (m.usedPercent !== null && m.usedPercent > USED_HIGH) || m.pressure === 'warn' || m.pressure === 'critical'
116  const topApp = snapshot.topApps[0]
117  if (isTight && topApp) {
118    // No quit for the host of this very session (the Claude desktop app) or a bare engine: advice only.
119    const isHost = /^claude(\.exe)?$/i.test(topApp.name)
120    out.push({ key: 'app', ...(isHost ? {} : { action: { kind: 'quit-app' as const, app: topApp.name } }), text: `memory is tight: close ${topApp.name} (${formatMB(topApp.rssMB)})` })
121  }
122
123  if (m.load1 !== null && m.cores !== null && m.cores > 0 && m.load1 > 2 * m.cores) {
124    out.push({ key: 'load', text: `load ${m.load1.toFixed(1)} on ${m.cores} cores: pause heavy test suites` })
125  }
126
127  if (m.diskFreeBytes !== null && m.diskFreeBytes < DISK_LOW_BYTES) {
128    out.push({ key: 'disk', action: { kind: 'open-disk' }, text: `disk ${formatBytes(m.diskFreeBytes)} free: see the Disk pane for what is safe to free` })
129  }
130
131  return out
132}
133
134/**
135 * The /mem answer to an origin that is not the person, in a session that can
136 * draw: machine figures and a count only, so no other session's label, model
137 * or size lands in this transcript on a peer's, a plugin's or a schedule's say-so.
138 */
139export function summaryLine(snapshot: Snapshot): string {
140  const m = snapshot.machine
141  const used = m.usedPercent === null ? '--' : `${Math.round(m.usedPercent)}%`
142  const live = snapshot.sessions.filter(s => s.status === 'live').length
143  return `Memory ${used} used · Pressure ${pressureText(m.pressure)} · ${live} live session${live === 1 ? '' : 's'}`
144}
145
146/**
147 * The /mem answer where no pane can be drawn (a -p run, an unplaced pane). It
148 * becomes a transcript row the model reads, so it carries labels (repo and
149 * branch) but never a cwd, and at most ten sessions.
150 */
151export function compactReport(snapshot: Snapshot, options: AdviceOptions): string {
152  const m = snapshot.machine
153  const used = m.usedPercent === null ? '--' : `${Math.round(m.usedPercent)}%`
154  const lines = [
155    `Memory ${used} used (${formatBytes(m.usedBytes)} of ${formatBytes(m.totalBytes)}) · ` +
156      `Pressure ${pressureText(m.pressure)} · compressed ${formatBytes(m.compressedBytes)} · ` +
157      `swap ${formatBytes(m.swapUsedBytes)} of ${formatBytes(m.swapTotalBytes)} · ` +
158      `load ${m.load1 === null ? '?' : m.load1.toFixed(1)} on ${m.cores ?? '?'} cores · disk ${formatBytes(m.diskFreeBytes)} free`,
159    `This session: ${snapshot.thisSessionMB === null ? 'unknown' : formatMB(snapshot.thisSessionMB)} (approximate RSS)`,
160  ]
161  const live = snapshot.sessions.filter(s => s.status === 'live')
162  lines.push(`Sessions (${live.length} live):`)
163  for (const s of snapshot.sessions.slice(0, 10)) {
164    const ctx = s.contextPercent === null ? 'ctx --' : `ctx ${Math.round(s.contextPercent)}%`
165    const idle = s.idleMinutes === null ? 'idle --' : `idle ${s.idleMinutes}m`
166    const tag = s.status === 'live' ? '' : ` (${s.status})`
167    lines.push(
168      `  ${s.isThis ? '*' : '-'} ${s.label} · ${shortModel(s.model)} · ${formatMB(s.rssMB)} · ${ctx} · ${s.agents} agents · ${idle}${tag}`,
169    )
170  }
171  const other = snapshot.unregistered
172  if (other && other.count > 0) {
173    lines.push(`  + ${other.count} Claude session${other.count === 1 ? '' : 's'} without mem-gauge: ${formatMB(other.rssMB)}`)
174  }
175  if (snapshot.topApps.length > 0) {
176    lines.push(`Top apps: ${snapshot.topApps.map(a => `${a.name} ${formatMB(a.rssMB)}`).join(', ')}`)
177  }
178  const tips = advise(snapshot, options)
179  lines.push(tips.length === 0 ? 'Optimize: nothing to do.' : 'Optimize:')
180  for (const tip of tips) lines.push(`  - ${tip.text}`)
181  return lines.join('\n')
182}
183
184/** A folder counts as worth mentioning in the disk advice from this size. */
185const DISK_TIP_MIN_BYTES = 512 * 1024 * 1024
186
187/**
188 * What is safe to free on the disk, from the Disk pane's folder sizes and
189 * volumes: caches, build products, stale simulators, the Trash, old file
190 * history. Only things that can be cleared are listed; the Claude transcripts
191 * are the session history and never appear. Pure; drawn as plain lines.
192 */
193export function diskAdvice(report: DiskReport): DiskAdvice[] {
194  const out: DiskAdvice[] = []
195  const size = (suffix: string): number | null => {
196    const f = report.folders.find(folder => folder.path.endsWith(suffix))
197    return f && f.bytes !== null ? f.bytes : null
198  }
199  const big = (suffix: string): number | null => {
200    const b = size(suffix)
201    return b !== null && b >= DISK_TIP_MIN_BYTES ? b : null
202  }
203  const caches = big('/Library/Caches')
204  if (caches !== null) out.push({ key: 'caches', action: { kind: 'clear-caches' }, text: `Library caches ${formatBytes(caches)}: safe to clear; apps rebuild what they need` })
205  const derived = big('/Library/Developer/Xcode/DerivedData')
206  if (derived !== null) out.push({ key: 'derived', action: { kind: 'clear-derived-data' }, text: `Xcode DerivedData ${formatBytes(derived)}: safe to delete; the next build of each project is a full one` })
207  const devices = big('/Library/Developer/CoreSimulator/Devices')
208  if (devices !== null) {
209    out.push({
210      key: 'simulators',
211      action: { kind: 'delete-stale-simulators' },
212      text: `iOS simulator devices ${formatBytes(devices)}: delete the simulators you no longer test on (Apply removes the unavailable ones, xcrun simctl delete unavailable)`,
213    })
214  }
215  const runtimes = report.volumes.filter(v => v.name.startsWith('Simulator runtime '))
216  if (runtimes.length > 1) {
217    const total = runtimes.reduce((sum, v) => sum + v.usedBytes, 0)
218    out.push({ key: 'runtimes', text: `${runtimes.length} iOS simulator runtimes ${formatBytes(total)}: keep the one you test on; remove the rest in Xcode > Settings > Components` })
219  }
220  const trash = big('/.Trash')
221  if (trash !== null) out.push({ key: 'trash', action: { kind: 'empty-trash' }, text: `Trash ${formatBytes(trash)}: empty it` })
222  const history = big('/.claude/file-history')
223  if (history !== null) {
224    out.push({
225      key: 'file-history',
226      action: { kind: 'prune-file-history' },
227      text: `Claude file history ${formatBytes(history)}: file snapshots for rewinding edits; safe to prune once those sessions are closed (Apply removes entries older than 30 days)`,
228    })
229  }
230  // The Claude transcripts (~/.claude/projects) are the session history: never a candidate, so never listed here.
231  return out
232}
233
234/** The one command an Apply runs for a disk action, as argv (no shell): fixed paths under `home`, nothing from a file or a process. */
235export function diskActionArgv(action: DiskAction, home: string): string[] {
236  // No -H: a target folder that is itself a symlink is not followed (find lists nothing, rm removes nothing),
237  // so a relocated Caches is left alone rather than a link to anywhere being emptied; entries that are links are removed as links.
238  const clear = (dir: string) => ['find', dir, '-mindepth', '1', '-maxdepth', '1', '-exec', 'rm', '-rf', '{}', '+']
239  switch (action.kind) {
240    case 'clear-caches':
241      return clear(`${home}/Library/Caches`)
242    case 'clear-derived-data':
243      return clear(`${home}/Library/Developer/Xcode/DerivedData`)
244    case 'delete-stale-simulators':
245      return ['xcrun', 'simctl', 'delete', 'unavailable']
246    case 'empty-trash':
247      return clear(`${home}/.Trash`)
248    case 'prune-file-history':
249      return ['find', `${home}/.claude/file-history`, '-mindepth', '1', '-maxdepth', '1', '-mtime', '+30', '-exec', 'rm', '-rf', '{}', '+']
250  }
251}
252
253/** What the confirmation asks before a disk action runs; ends with what cannot be undone. */
254export function diskActionQuestion(action: DiskAction): string {
255  switch (action.kind) {
256    case 'clear-caches':
257      return 'Clear everything in ~/Library/Caches (apps rebuild their caches; this cannot be undone)?'
258    case 'clear-derived-data':
259      return 'Delete everything in Xcode DerivedData (the next build of each project is a full one; this cannot be undone)?'
260    case 'delete-stale-simulators':
261      return 'Delete the unavailable iOS simulators, those whose runtime is gone (the ones you still test on are kept)?'
262    case 'empty-trash':
263      return 'Empty the Trash (everything in it is deleted for good)?'
264    case 'prune-file-history':
265      return 'Delete Claude file-history entries older than 30 days (rewinding edits in those sessions will no longer be possible)?'
266  }
267}
268
269/** A short name for a disk action, for toasts. */
270export function diskActionLabel(action: DiskAction): string {
271  switch (action.kind) {
272    case 'clear-caches':
273      return 'clear Library caches'
274    case 'clear-derived-data':
275      return 'delete Xcode DerivedData'
276    case 'delete-stale-simulators':
277      return 'delete unavailable simulators'
278    case 'empty-trash':
279      return 'empty the Trash'
280    case 'prune-file-history':
281      return 'prune old Claude file history'
282  }
283}
284
285/**
286 * A graceful quit of one app by name through osascript, the name handed as an
287 * argument (never spliced into the script): the app may still ask to save.
288 * Null for a name osascript could read as an option or that is not one line.
289 */
290export function quitAppArgv(app: string): string[] | null {
291  if (!app || app.startsWith('-') || /[\u0000-\u001f\u007f]/.test(app) || app.length > 120) return null
292  return ['osascript', '-e', 'on run argv', '-e', 'tell application (item 1 of argv) to quit', '-e', 'end run', app]
293}
294
295/**
296 * A HOME an Apply may build paths under: absolute, no `.` or `..` segment
297 * (so it cannot spell the disk root as `/.` or `/Users/..`), at least one
298 * segment, no control characters. Null otherwise: the Apply says so and stops.
299 */
300export function applyHome(home: string | null): string | null {
301  if (!home || !home.startsWith('/') || /[\u0000-\u001f\u007f]/.test(home)) return null
302  const segments = home.split('/').filter(part => part !== '')
303  if (segments.length === 0 || segments.some(part => part === '.' || part === '..')) return null
304  return `/${segments.join('/')}`
305}
306
hooks/parse.ts 748 lines
1// Pure parsers and arithmetic for mem-gauge: every macOS command's text goes in,
2// plain numbers come out. Nothing here touches `$`, so each function is tested
3// from fixtures alone (hooks/parse.test.ts) and a parse failure is a null field,
4// never a thrown tick.
5
6import type { AppUsage, MachineStats, Pressure, SessionRow, SessionStatus, Snapshot, VolumeRow, WrapRequest } from '../types'
7
8const MB = 1024 * 1024
9const GB = 1024 * MB
10
11/** A heartbeat older than this is stale and left out of totals. */
12export const STALE_MS = 120_000
13/** A gone or stale entry older than this is garbage-collected (a crash never fires session.end). */
14export const GC_MS = 10 * 60_000
15/** Any entry this old is collected whatever its pid says. */
16export const MAX_AGE_MS = 24 * 60 * 60_000
17
18/** `sysctl a b c` (no -n) into name -> value; keyed so a missing name cannot shift the others. */
19export function parseSysctl(stdout: string): Record<string, string> {
20  const out: Record<string, string> = {}
21  for (const line of stdout.split('\n')) {
22    const at = line.indexOf(':')
23    if (at <= 0) continue
24    const name = line.slice(0, at).trim()
25    // sysctl names are dotted (hw.memsize); this also skips a stray "sysctl: unknown oid" line.
26    if (!/^[a-z0-9_]+(\.[a-z0-9_]+)+$/i.test(name)) continue
27    out[name] = line.slice(at + 1).trim()
28  }
29  return out
30}
31
32const toInt = (text: string | undefined): number | null => {
33  if (text === undefined || !/^\d+$/.test(text.trim())) return null
34  const n = Number(text.trim())
35  return Number.isSafeInteger(n) ? n : null
36}
37
38export type VmStat = { pageSize: number; pages: Record<string, number> }
39
40/**
41 * `vm_stat`: the page size from its header and every "<label>: <n>." row, keyed
42 * by the label lower-cased with a leading "Pages " dropped ("free", "wired down",
43 * "occupied by compressor"). Null when the header is missing.
44 */
45export function parseVmStat(stdout: string): VmStat | null {
46  const header = /page size of (\d+) bytes/.exec(stdout)
47  const pageSize = header ? Number(header[1]) : NaN
48  if (!Number.isSafeInteger(pageSize) || pageSize <= 0) return null
49  const pages: Record<string, number> = {}
50  for (const line of stdout.split('\n')) {
51    const row = /^\s*"?([^:"]+)"?:\s+(\d+)\.?\s*$/.exec(line)
52    if (!row) continue
53    const label = row[1]!.trim().toLowerCase().replace(/^pages\s+/, '')
54    pages[label] = Number(row[2])
55  }
56  return { pageSize, pages }
57}
58
59const UNIT: Record<string, number> = { K: 1024, M: MB, G: GB, T: GB * 1024 }
60
61/** `vm.swapusage`: "total = 1024.00M  used = 202.00M  free = 822.00M  (encrypted)". */
62export function parseSwap(value: string | undefined): { totalBytes: number; usedBytes: number } | null {
63  if (!value) return null
64  const read = (name: string) => {
65    const m = new RegExp(`${name}\\s*=\\s*([\\d.]+)([KMGT])`).exec(value)
66    return m ? Math.round(Number(m[1]) * UNIT[m[2]!]!) : null
67  }
68  const totalBytes = read('total')
69  const usedBytes = read('used')
70  return totalBytes === null || usedBytes === null ? null : { totalBytes, usedBytes }
71}
72
73/** `vm.loadavg`: "{ 1.52 1.80 1.95 }" -> the 1-minute figure. */
74export function parseLoad(value: string | undefined): number | null {
75  const m = value ? /^\{?\s*([\d.]+)/.exec(value) : null
76  const n = m ? Number(m[1]) : NaN
77  return Number.isFinite(n) ? n : null
78}
79
80/** `df -k /`: the data row's 1024-blocks and Available columns, in bytes. */
81export function parseDf(stdout: string): { totalBytes: number; freeBytes: number } | null {
82  for (const line of stdout.split('\n').slice(1)) {
83    // The filesystem name may hold spaces, so anchor on the numeric run and the capacity's %.
84    const m = /^(.*?)\s+(\d+)\s+(\d+)\s+(\d+)\s+\d+%/.exec(line)
85    if (m) return { totalBytes: Number(m[2]) * 1024, freeBytes: Number(m[4]) * 1024 }
86  }
87  return null
88}
89
90/** Mounts `df -kP` lists that are no volume of the person's: the sealed-system helpers and the swap space. */
91const HELPER_MOUNTS = new Set([
92  '/dev',
93  '/System/Volumes/VM',
94  '/System/Volumes/Preboot',
95  '/System/Volumes/Update',
96  '/System/Volumes/xarts',
97  '/System/Volumes/iSCPreboot',
98  '/System/Volumes/Hardware',
99])
100
101/** A readable name for a mount point: the APFS system and data volumes, an external disk, a simulator runtime. */
102export function volumeName(mount: string): string {
103  if (mount === '/') return 'Macintosh HD (system)'
104  if (mount === '/System/Volumes/Data') return 'Macintosh HD (data)'
105  const sim = /^\/Library\/Developer\/CoreSimulator\/Volumes\/(.+)$/.exec(mount)
106  if (sim) return `Simulator runtime ${sim[1]}`
107  const vol = /^\/Volumes\/(.+)$/.exec(mount)
108  if (vol) return vol[1]!
109  return mount
110}
111
112/**
113 * Every real volume in `df -kP` (POSIX columns: blocks, used, available,
114 * capacity, mount), the pseudo filesystems (devfs, automounter maps) and the
115 * helper mounts left out. The mount point may hold spaces.
116 */
117export function parseDfAll(stdout: string): VolumeRow[] {
118  const rows: VolumeRow[] = []
119  for (const line of stdout.split('\n').slice(1)) {
120    const m = /^(.*?)\s+(\d+)\s+(\d+)\s+(\d+)\s+\d+%\s+(\/.*?)\s*$/.exec(line)
121    if (!m) continue
122    const filesystem = m[1]!
123    const mount = m[5]!
124    if (filesystem === 'devfs' || filesystem.startsWith('map ') || HELPER_MOUNTS.has(mount) || mount.startsWith('/System/Volumes/Data/')) continue
125    const totalBytes = Number(m[2]) * 1024
126    if (!(totalBytes > 0)) continue
127    // A mount point is another program's text (a disk named elsewhere, a network share): one line,
128    // no control characters, like every ps name and heartbeat label, or the pane is refused whole.
129    const cleanMount = oneLine(mount.slice(0, 1024), 256)
130    if (!cleanMount) continue
131    const name = oneLine(volumeName(mount).slice(0, 512), 120) || cleanMount
132    rows.push({ mount: cleanMount, name, totalBytes, usedBytes: Number(m[3]) * 1024, freeBytes: Number(m[4]) * 1024 })
133  }
134  return mergeSystemVolume(rows)
135}
136
137/**
138 * The APFS system volume (`/`) and data volume (`/System/Volumes/Data`) share
139 * one container, so df lists the same size and free space twice: one
140 * "Macintosh HD" row, its used space the two volumes' together.
141 */
142function mergeSystemVolume(rows: VolumeRow[]): VolumeRow[] {
143  const system = rows.find(r => r.mount === '/')
144  const data = rows.find(r => r.mount === '/System/Volumes/Data')
145  const main = data ?? system
146  if (!main) return rows
147  const merged: VolumeRow = {
148    mount: '/',
149    name: 'Macintosh HD',
150    totalBytes: main.totalBytes,
151    usedBytes: (system?.usedBytes ?? 0) + (data?.usedBytes ?? 0),
152    freeBytes: main.freeBytes,
153  }
154  return [merged, ...rows.filter(r => r !== system && r !== data)]
155}
156
157/** One line for /disk where no pane can be drawn: 'Disk 50% used · used 931 GB of 1858 GB · free 927 GB'. */
158export function diskLine(snapshot: Snapshot): string {
159  const m = snapshot.machine
160  const usedBytes = m.diskTotalBytes !== null && m.diskFreeBytes !== null ? Math.max(0, m.diskTotalBytes - m.diskFreeBytes) : null
161  const percent = usedBytes !== null && m.diskTotalBytes ? `${Math.round((usedBytes / m.diskTotalBytes) * 100)}%` : '--'
162  return `Disk ${percent} used · used ${formatCompact(usedBytes)} of ${formatCompact(m.diskTotalBytes)} · free ${formatCompact(m.diskFreeBytes)}`
163}
164
165/** `du -sk <path>` as bytes; null when du said nothing readable. */
166export function parseDu(stdout: string): number | null {
167  const m = /^\s*(\d+)\s/.exec(stdout)
168  return m ? Number(m[1]) * 1024 : null
169}
170
171/**
172 * Pressure from kern.memorystatus_level (percent of memory free as the kernel
173 * judges it): under 20 warn, under 10 critical. Falls back to the share of
174 * reclaimable pages from vm_stat when the sysctl is missing.
175 */
176export function pressureOf(level: number | null, vm: VmStat | null, totalBytes: number | null): Pressure {
177  let freePercent = level
178  if (freePercent === null && vm && totalBytes) {
179    const p = vm.pages
180    const reclaimable = (p['free'] ?? 0) + (p['inactive'] ?? 0) + (p['speculative'] ?? 0)
181    freePercent = ((reclaimable * vm.pageSize) / totalBytes) * 100
182  }
183  if (freePercent === null || !Number.isFinite(freePercent)) return 'unknown'
184  if (freePercent < 10) return 'critical'
185  if (freePercent < 20) return 'warn'
186  return 'ok'
187}
188
189/** One machine reading from the raw command outputs (any of them null when its command failed). */
190export function buildMachine(
191  at: number,
192  raw: { sysctl: string | null; vmStat: string | null; df: string | null },
193): MachineStats {
194  const ctl = raw.sysctl ? parseSysctl(raw.sysctl) : {}
195  const vm = raw.vmStat ? parseVmStat(raw.vmStat) : null
196  const disk = raw.df ? parseDf(raw.df) : null
197  const swap = parseSwap(ctl['vm.swapusage'])
198  const totalBytes = toInt(ctl['hw.memsize'])
199  const pagesOf = (...names: string[]) => {
200    if (!vm || names.some(n => vm.pages[n] === undefined)) return null
201    return names.reduce((sum, n) => sum + vm.pages[n]!, 0) * vm.pageSize
202  }
203  const usedBytes = pagesOf('active', 'wired down', 'occupied by compressor')
204  const usedPercent =
205    usedBytes !== null && totalBytes ? Math.min(100, Math.max(0, (usedBytes / totalBytes) * 100)) : null
206  return {
207    at,
208    totalBytes,
209    cores: toInt(ctl['hw.ncpu']),
210    usedBytes,
211    freeBytes: pagesOf('free', 'speculative'),
212    compressedBytes: pagesOf('occupied by compressor'),
213    usedPercent,
214    pressure: pressureOf(toInt(ctl['kern.memorystatus_level']), vm, totalBytes),
215    swapUsedBytes: swap?.usedBytes ?? null,
216    swapTotalBytes: swap?.totalBytes ?? null,
217    load1: parseLoad(ctl['vm.loadavg']),
218    diskFreeBytes: disk?.freeBytes ?? null,
219    diskTotalBytes: disk?.totalBytes ?? null,
220  }
221}
222
223/** One process: its executable's basename and the app it rolls up under. */
224export type Proc = { pid: number; ppid: number; rssKB: number; name: string; app: string }
225
226/**
227 * The app a process belongs to: the outermost `<Name>.app` in its executable
228 * path (so "Google Chrome Helper (Renderer)" rolls up under "Google Chrome"),
229 * else the basename. macOS `ps -o comm=` prints the full path.
230 */
231export function appOf(path: string): string {
232  const bundle = /(?:^|\/)([^/]+)\.app(?:\/|$)/.exec(path)
233  return bundle ? bundle[1]! : basename(path)
234}
235
236/**
237 * Text another process wrote (a heartbeat's label or model, a ps app name),
238 * squashed to one printable line before it is drawn or written into the /mem
239 * row the model reads: C0/C1 controls (ESC, newlines) and line separators
240 * become spaces; format characters (bidi overrides, zero-width, Unicode tags)
241 * are dropped; runs of whitespace collapse; the result is cut to `max` code
242 * points. Without this a crafted label could forge extra lines (a fake
243 * "Optimize:" section) in the table the model reads.
244 */
245export const oneLine = (text: string, max: number): string =>
246  [
247    ...text
248      .replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ')
249      .replace(/[\p{Cf}\p{Cs}\p{Co}]/gu, '')
250      .replace(/\s+/g, ' ')
251      .trim(),
252  ]
253    .slice(0, max)
254    .join('')
255    .trim()
256
257export const basename = (path: string): string => {
258  const trimmed = path.replace(/\/+$/, '')
259  return trimmed.slice(trimmed.lastIndexOf('/') + 1) || trimmed
260}
261
262/** `ps -axo pid=,ppid=,rss=,comm=` rows (comm last, so it may hold spaces). */
263export function parsePs(stdout: string): Proc[] {
264  const out: Proc[] = []
265  for (const line of stdout.split('\n')) {
266    const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
267    if (!m) continue
268    const path = m[4]!
269    out.push({ pid: Number(m[1]), ppid: Number(m[2]), rssKB: Number(m[3]), name: oneLine(basename(path), 256), app: oneLine(appOf(path), 120) || '?' })
270  }
271  return out
272}
273
274/** Executable names a Claude Code engine runs under (native build, Homebrew's .exe, node or bun). */
275export const isEngineName = (name: string): boolean => /^(claude|claude\.exe|node|bun)$/.test(name)
276
277/** `root` and every descendant, by walking ppid links; cycles and a missing root are safe. */
278export function descendants(procs: readonly Proc[], root: number): Set<number> {
279  const children = new Map<number, number[]>()
280  for (const p of procs) {
281    if (p.pid === p.ppid) continue
282    const list = children.get(p.ppid)
283    if (list) list.push(p.pid)
284    else children.set(p.ppid, [p.pid])
285  }
286  const seen = new Set<number>()
287  if (!procs.some(p => p.pid === root)) return seen
288  const queue = [root]
289  while (queue.length > 0) {
290    const pid = queue.pop()!
291    if (seen.has(pid)) continue
292    seen.add(pid)
293    for (const child of children.get(pid) ?? []) queue.push(child)
294  }
295  return seen
296}
297
298/**
299 * RSS of a process tree in MB: the engine plus its MCP servers, shells and test
300 * engines. In-process agents are inside the engine's own RSS. Null when the
301 * root is not in the table. Approximate: RSS undercounts compressed pages.
302 */
303export function treeMB(procs: readonly Proc[], root: number): number | null {
304  const tree = descendants(procs, root)
305  if (tree.size === 0) return null
306  let kb = 0
307  for (const p of procs) if (tree.has(p.pid)) kb += p.rssKB
308  return Math.round(kb / 1024)
309}
310
311/** Claude Code engines that run without mem-gauge, summed so their memory is not hidden or blamed on an app. */
312export type Unregistered = { count: number; rssMB: number }
313/** The unregistered engines one by one (their process trees' memory), with the totals. */
314export type UnregisteredRoots = Unregistered & { roots: { pid: number; rssMB: number }[] }
315
316const isClaudeEngine = (name: string) => name === 'claude' || name === 'claude.exe'
317
318/**
319 * Finds Claude Code engines outside `covered` (the registered sessions' trees),
320 * counts each outermost one with its descendants (a nested `claude -p` or test
321 * engine counts inside its parent), and adds every pid it counted to `covered`.
322 */
323export function claimUnregistered(procs: readonly Proc[], covered: Set<number>): UnregisteredRoots {
324  const byPid = new Map(procs.map(p => [p.pid, p]))
325  const engines = procs.filter(p => isClaudeEngine(p.name) && !covered.has(p.pid))
326  const enginePids = new Set(engines.map(p => p.pid))
327  const isRoot = (p: Proc) => {
328    let parent = byPid.get(p.ppid)
329    for (let hops = 0; parent && hops < 64; hops += 1) {
330      if (enginePids.has(parent.pid) || covered.has(parent.pid)) return false
331      if (parent.pid === parent.ppid) break
332      parent = byPid.get(parent.ppid)
333    }
334    return true
335  }
336  let count = 0
337  let kb = 0
338  const roots: { pid: number; rssMB: number }[] = []
339  for (const root of engines.filter(isRoot)) {
340    count += 1
341    let rootKB = 0
342    for (const pid of descendants(procs, root.pid)) {
343      if (covered.has(pid)) continue
344      covered.add(pid)
345      rootKB += byPid.get(pid)?.rssKB ?? 0
346    }
347    kb += rootKB
348    roots.push({ pid: root.pid, rssMB: rootKB / 1024 })
349  }
350  return { count, rssMB: Math.round(kb / 1024), roots }
351}
352
353/** The top `n` apps by RSS, leaving out every pid in `exclude` (the Claude session trees). */
354export function topApps(procs: readonly Proc[], exclude: ReadonlySet<number>, n = 8): AppUsage[] {
355  const byApp = new Map<string, { kb: number; count: number }>()
356  for (const p of procs) {
357    if (exclude.has(p.pid) || p.rssKB <= 0) continue
358    const row = byApp.get(p.app) ?? { kb: 0, count: 0 }
359    row.kb += p.rssKB
360    row.count += 1
361    byApp.set(p.app, row)
362  }
363  return [...byApp.entries()]
364    .map(([name, row]) => ({ name, rssMB: Math.round(row.kb / 1024), processes: row.count }))
365    .sort((a, b) => b.rssMB - a.rssMB || a.name.localeCompare(b.name))
366    .slice(0, n)
367}
368
369/** The registry file one session's copy writes; no prompt text, no secrets. */
370export type RegistryEntry = {
371  v: 1
372  sessionId: string
373  pid: number | null
374  label: string
375  cwd: string
376  model: string
377  rssMB: number | null
378  contextPercent: number | null
379  contextTokens: number | null
380  contextWindow: number | null
381  rateLimitPercent: number | null
382  agents: number
383  lastActivityAt: number
384  startedAt: number
385  updatedAt: number
386}
387
388const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
389const numOrNull = (v: unknown): number | null => (isNum(v) ? v : null)
390const str = (v: unknown, max: number): string => (typeof v === 'string' ? v.slice(0, max) : '')
391/** A field drawn or reported as text: one printable line (see oneLine). */
392const line = (v: unknown, max: number): string => (typeof v === 'string' ? oneLine(v.slice(0, max * 4), max) : '')
393
394/** Session ids become file names, so only a plain id shape is accepted. */
395export const isSessionId = (id: string): boolean => /^[A-Za-z0-9_-]{1,128}$/.test(id)
396
397/**
398 * A registry file's text, validated field by field. Writes are not atomic, so a
399 * half-written or foreign file is null (left out of the table until a later tick
400 * reads it whole, or collected), never a thrown tick. Another session wrote
401 * it, so every drawn text field is squashed to one printable line.
402 */
403export function parseEntry(text: string): RegistryEntry | null {
404  let raw: unknown
405  try {
406    raw = JSON.parse(text)
407  } catch {
408    return null
409  }
410  if (!raw || typeof raw !== 'object') return null
411  const o = raw as Record<string, unknown>
412  const sessionId = str(o['sessionId'], 128)
413  if (!isSessionId(sessionId) || !isNum(o['updatedAt'])) return null
414  const pid = numOrNull(o['pid'])
415  return {
416    v: 1,
417    sessionId,
418    pid: pid !== null && Number.isSafeInteger(pid) && pid > 1 ? pid : null,
419    label: line(o['label'], 120) || sessionId.slice(0, 8),
420    cwd: str(o['cwd'], 1024),
421    model: line(o['model'], 80),
422    rssMB: numOrNull(o['rssMB']),
423    contextPercent: numOrNull(o['contextPercent']),
424    // An entry an older build wrote has neither; null makes the tip fall back to the percent.
425    contextTokens: numOrNull(o['contextTokens']),
426    contextWindow: numOrNull(o['contextWindow']),
427    rateLimitPercent: numOrNull(o['rateLimitPercent']),
428    agents: isNum(o['agents']) ? Math.max(0, Math.round(o['agents'])) : 0,
429    lastActivityAt: isNum(o['lastActivityAt']) ? o['lastActivityAt'] : 0,
430    startedAt: isNum(o['startedAt']) ? o['startedAt'] : 0,
431    updatedAt: o['updatedAt'],
432  }
433}
434
435/**
436 * live, stale or gone. Gone needs proof: the ps table was taken after the
437 * session started and its pid is missing, or now belongs to something that is
438 * not an engine (pid reuse). Without a ps table or a pid, age alone decides.
439 */
440export function statusOf(
441  entry: RegistryEntry,
442  now: number,
443  procs: { at: number; byPid: ReadonlyMap<number, Proc> } | null,
444): SessionStatus {
445  if (entry.pid !== null && procs && procs.at > entry.startedAt) {
446    const proc = procs.byPid.get(entry.pid)
447    if (!proc || !isEngineName(proc.name)) return 'gone'
448  }
449  return now - entry.updatedAt > STALE_MS ? 'stale' : 'live'
450}
451
452/** Whether a registry file should be removed by this tick's garbage collection. */
453export function isCollectable(entry: RegistryEntry | null, status: SessionStatus | null, now: number, mtimeMs: number): boolean {
454  if (entry === null) return now - mtimeMs > GC_MS
455  if (now - entry.updatedAt > MAX_AGE_MS) return true
456  return status === 'gone' && now - entry.updatedAt > GC_MS
457}
458
459/** A registry entry as a drawable row. */
460export function rowOf(entry: RegistryEntry, status: SessionStatus, now: number, thisId: string): SessionRow {
461  return {
462    sessionId: entry.sessionId,
463    label: entry.label,
464    model: entry.model,
465    rssMB: entry.rssMB,
466    contextPercent: entry.contextPercent,
467    contextTokens: entry.contextTokens,
468    contextWindow: entry.contextWindow,
469    rateLimitPercent: entry.rateLimitPercent,
470    agents: entry.agents,
471    idleMinutes: entry.lastActivityAt > 0 ? Math.max(0, Math.floor((now - entry.lastActivityAt) / 60_000)) : null,
472    status,
473    isThis: entry.sessionId === thisId,
474    hasMod: true,
475  }
476}
477
478/** What the engine's own session record (~/.claude/sessions/<pid>.json) says that the pane can use. */
479export type EngineSession = { sessionId: string; label: string; idleMinutes: number | null }
480
481/**
482 * The engine's record of a running session, written by the CLI itself for
483 * every session (mod or not): its id, its name (or the folder it runs in)
484 * and when it last changed. Null for a torn or foreign file, or one that is
485 * not about `pid`. Every text is cleaned to one line.
486 */
487export function decodeEngineSession(text: string, pid: number, now: number): EngineSession | null {
488  let raw: unknown
489  try {
490    raw = JSON.parse(text)
491  } catch {
492    return null
493  }
494  if (!raw || typeof raw !== 'object') return null
495  const o = raw as Record<string, unknown>
496  if (o['pid'] !== pid) return null
497  const id = typeof o['sessionId'] === 'string' && isSessionId(o['sessionId']) ? o['sessionId'] : `engine-${pid}`
498  const name = typeof o['name'] === 'string' ? oneLine(o['name'].slice(0, 1024), 100) : ''
499  const cwd = typeof o['cwd'] === 'string' ? oneLine(basename(o['cwd'].slice(0, 4096)), 100) : ''
500  const updatedAt = isNum(o['updatedAt']) ? o['updatedAt'] : null
501  return {
502    sessionId: id,
503    label: name || cwd || `claude (pid ${pid})`,
504    idleMinutes: updatedAt === null ? null : Math.max(0, Math.floor((now - updatedAt) / 60_000)),
505  }
506}
507
508/** A row for a session that does not run mem-gauge, from the engine's record and its process tree. */
509export function engineRow(pid: number, rssMB: number, session: EngineSession): SessionRow {
510  return {
511    sessionId: session.sessionId,
512    label: session.label,
513    model: '',
514    rssMB,
515    contextPercent: null,
516    contextTokens: null,
517    contextWindow: null,
518    rateLimitPercent: null,
519    agents: 0,
520    idleMinutes: session.idleMinutes,
521    status: 'live',
522    isThis: false,
523    hasMod: false,
524  }
525}
526
527/** Live first by RSS (largest first), then stale, then gone. */
528export function sortRows(rows: readonly SessionRow[]): SessionRow[] {
529  const rank: Record<SessionStatus, number> = { live: 0, stale: 1, gone: 2 }
530  return [...rows].sort((a, b) => rank[a.status] - rank[b.status] || (b.rssMB ?? -1) - (a.rssMB ?? -1))
531}
532
533/** The used-memory quintile as a ring glyph: ○ ◔ ◑ ◕ ●. */
534export function glyph(usedPercent: number | null): string {
535  if (usedPercent === null || !Number.isFinite(usedPercent)) return '?'
536  const rings = ['○', '◔', '◑', '◕', '●']
537  return rings[Math.min(4, Math.max(0, Math.floor(usedPercent / 20)))]!
538}
539
540/** "1.8 GB" from 1.0 GB up, "512 MB" below. */
541export function formatMB(mb: number | null): string {
542  if (mb === null || !Number.isFinite(mb)) return '?'
543  return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${Math.round(mb)} MB`
544}
545
546/** "572", "12.3k", "184k" (whole k from 100k), "1.2M": a token count short enough for a tip's text. */
547export function formatTokens(n: number | null): string {
548  if (n === null || !Number.isFinite(n) || n < 0) return '?'
549  const tenth = (v: number) => String(Math.round(v * 10) / 10)
550  if (Math.round(n) < 1000) return String(Math.round(n))
551  if (Math.round(n / 1000) >= 1000) return `${tenth(n / 1_000_000)}M`
552  return n < 100_000 && Math.round((n / 1000) * 10) / 10 < 100 ? `${tenth(n / 1000)}k` : `${Math.round(n / 1000)}k`
553}
554
555export const formatBytes = (bytes: number | null): string => formatMB(bytes === null ? null : bytes / MB)
556
557const isAlarm = (p: Pressure) => p === 'warn' || p === 'critical'
558
559/** "✓" when memory pressure is fine, "! warn" / "! critical" under pressure, "?" when unread. */
560export function pressureText(p: Pressure): string {
561  return p === 'ok' ? '✓' : p === 'unknown' ? '?' : `! ${p}`
562}
563
564/**
565 * The gauge row's text in two parts: `main` ('Memory 60% · Pressure ✓ · 7 sessions',
566 * "! " in front under pressure, '! Wrap requested (2.0 GB)' first when asked) and
567 * `disk` ('41 GB free'), which the desktop draws behind a disk icon.
568 */
569export function summaryParts(snapshot: Snapshot, wrap: WrapRequest | null): { main: string; disk: string } {
570  const m = snapshot.machine
571  const live = snapshot.sessions.filter(s => s.status === 'live').length
572  const parts = [
573    `${isAlarm(m.pressure) ? '! ' : ''}Memory ${m.usedPercent === null ? '--' : `${Math.round(m.usedPercent)}%`}`,
574    `Pressure ${pressureText(m.pressure)}`,
575    `${live} session${live === 1 ? '' : 's'}`,
576  ]
577  if (wrap) parts.unshift(`! Wrap requested (${wrap.freesMB === null ? '?' : formatMB(wrap.freesMB)})`)
578  return { main: parts.join(' · '), disk: `${formatBytes(m.diskFreeBytes)} free` }
579}
580
581/** The one-line form (terminal row and the optional status line): 'Memory 60% · Pressure ✓ · 7 sessions · Disk 41 GB free'. */
582export function statusLine(snapshot: Snapshot, wrap: WrapRequest | null): string {
583  const { main, disk } = summaryParts(snapshot, wrap)
584  return `${main} · Disk ${disk}`
585}
586
587/** "901 GB" from 10 GB up, "9.5 GB" below, "512 MB" under 1 GB; "--" unread. The footer is narrow. */
588export function formatCompact(bytes: number | null): string {
589  if (bytes === null || !Number.isFinite(bytes)) return '--'
590  const gb = bytes / (1024 * MB)
591  if (gb >= 10) return `${Math.round(gb)} GB`
592  if (gb >= 1) return `${gb.toFixed(1)} GB`
593  return `${Math.round(bytes / MB)} MB`
594}
595
596/**
597 * The desktop footer row, kept short because the mode strip clips what does
598 * not fit: the used percent, the free disk space, and whether something needs
599 * attention (pressure, or a wrap request). Pressure itself is read in the pane.
600 */
601export function footerParts(snapshot: Snapshot, wrap: WrapRequest | null): { used: string; disk: string; diskPct: string; alarm: boolean } {
602  const m = snapshot.machine
603  const full = m.diskTotalBytes !== null && m.diskFreeBytes !== null && m.diskTotalBytes > 0
604    ? `${Math.round((100 * (m.diskTotalBytes - m.diskFreeBytes)) / m.diskTotalBytes)}%`
605    : '--'
606  return {
607    used: m.usedPercent === null ? '--' : `${Math.round(m.usedPercent)}%`,
608    disk: formatCompact(m.diskFreeBytes),
609    // The disk's fullness, for the one-chip footer: the same reading as the memory percent.
610    diskPct: full,
611    alarm: isAlarm(m.pressure) || wrap !== null,
612  }
613}
614
615/** A monochrome disk-drive icon (no script, no external refs) for the gauge row. */
616export function diskSvg(size = 14): string {
617  return (
618    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="${size}" height="${size}" role="img" aria-label="Disk">` +
619    `<rect x="1.5" y="4" width="13" height="8" rx="2" fill="none" stroke="${RING_TRACK}" stroke-width="1.5"/>` +
620    `<circle cx="11.5" cy="8" r="1.1" fill="${RING_TRACK}"/><line x1="4" y1="8" x2="8.5" y2="8" stroke="${RING_TRACK}" stroke-width="1.5" stroke-linecap="round"/></svg>`
621  )
622}
623
624/** A text gauge for surfaces with no Svg: "███████░░░░░ 62%". */
625export function bar(usedPercent: number | null, width: number): string {
626  const cells = Math.max(4, Math.min(40, Math.floor(width)))
627  if (usedPercent === null) return `${'░'.repeat(cells)} --`
628  const filled = Math.round((Math.min(100, Math.max(0, usedPercent)) / 100) * cells)
629  return `${'█'.repeat(filled)}${'░'.repeat(cells - filled)} ${Math.round(usedPercent)}%`
630}
631
632/** Apple system red: the arc that fills with used memory, the same on light and dark. */
633const RING_RED = '#FF3B30'
634const RING_TRACK = '#8E8E93'
635
636/**
637 * A gauge icon: a muted track, a red arc that fills clockwise from twelve with
638 * used percent, and the percent centered inside, as an SVG document (no
639 * script, no external refs). `size` is the drawn box in CSS pixels; the type
640 * scales up for the small icon under the prompt so "60%" stays legible.
641 */
642export function ringSvg(usedPercent: number | null, pressure: Pressure, size = 88, withLabel = true, title?: string): string {
643  const pct = usedPercent === null ? 0 : Math.min(100, Math.max(0, usedPercent))
644  const r = 15.915 // circumference 100, so the dash array is the percent
645  const label = usedPercent === null ? '--' : `${Math.round(usedPercent)}%`
646  const small = size <= 40
647  const stroke = small ? 4.2 : 3.4
648  const font = small ? (label.length > 3 ? 9.5 : 10.5) : 8
649  const y = small ? 21.7 : 20.8
650  const label2 = title ?? `Memory ${label} used, pressure ${pressure}`
651  return (
652    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36" width="${size}" height="${size}" role="img" aria-label="${label2}">` +
653    `<circle cx="18" cy="18" r="${r}" fill="none" stroke="${RING_TRACK}" stroke-opacity="0.28" stroke-width="${stroke}"/>` +
654    `<circle cx="18" cy="18" r="${r}" fill="none" stroke="${RING_RED}" stroke-width="${stroke}" ` +
655    `stroke-linecap="round" stroke-dasharray="${pct.toFixed(1)} ${(100 - pct).toFixed(1)}" transform="rotate(-90 18 18)"/>` +
656    (withLabel
657      ? `<text x="18" y="${y}" text-anchor="middle" font-family="-apple-system, system-ui, sans-serif" ` +
658        `font-size="${font}" font-weight="700" fill="${RING_TRACK}">${label}</text>`
659      : '') +
660    '</svg>'
661  )
662}
663
664/** Pads or cuts `text` to exactly `width` columns (with an ellipsis when cut). */
665export function fit(text: string, width: number, align: 'left' | 'right' = 'left'): string {
666  if (width <= 0) return ''
667  const chars = [...text]
668  if (chars.length > width) return width === 1 ? '…' : `${chars.slice(0, width - 1).join('')}…`
669  const pad = ' '.repeat(width - chars.length)
670  return align === 'left' ? text + pad : pad + text
671}
672
673/** machine.json: one collector's reading, shared so N sessions do not each run vm_stat, df and ps. */
674export type MachineFile = { v: 1; at: number; stats: MachineStats; procs: Proc[] | null }
675
676type ProcTuple = [number, number, number, string, string]
677
678export function encodeMachine(file: MachineFile): string {
679  const procs = file.procs?.map((p): ProcTuple => [p.pid, p.ppid, p.rssKB, p.name, p.app]) ?? null
680  return JSON.stringify({ v: 1, at: file.at, stats: file.stats, procs })
681}
682
683const PRESSURES: readonly Pressure[] = ['ok', 'warn', 'critical', 'unknown']
684
685/** machine.json back, every field checked; null for a half-written or foreign file. */
686export function decodeMachine(text: string): MachineFile | null {
687  let raw: unknown
688  try {
689    raw = JSON.parse(text)
690  } catch {
691    return null
692  }
693  if (!raw || typeof raw !== 'object') return null
694  const o = raw as Record<string, unknown>
695  const s = o['stats'] as Record<string, unknown> | null
696  if (o['v'] !== 1 || !isNum(o['at']) || !s || typeof s !== 'object') return null
697  const pressure = PRESSURES.includes(s['pressure'] as Pressure) ? (s['pressure'] as Pressure) : 'unknown'
698  const stats: MachineStats = {
699    at: o['at'],
700    totalBytes: numOrNull(s['totalBytes']),
701    cores: numOrNull(s['cores']),
702    usedBytes: numOrNull(s['usedBytes']),
703    freeBytes: numOrNull(s['freeBytes']),
704    compressedBytes: numOrNull(s['compressedBytes']),
705    usedPercent: numOrNull(s['usedPercent']),
706    pressure,
707    swapUsedBytes: numOrNull(s['swapUsedBytes']),
708    swapTotalBytes: numOrNull(s['swapTotalBytes']),
709    load1: numOrNull(s['load1']),
710    diskFreeBytes: numOrNull(s['diskFreeBytes']),
711    diskTotalBytes: numOrNull(s['diskTotalBytes']),
712  }
713  let procs: Proc[] | null = null
714  if (Array.isArray(o['procs'])) {
715    procs = []
716    for (const t of o['procs'] as unknown[]) {
717      if (!Array.isArray(t) || t.length !== 5) return null
718      const [pid, ppid, rssKB, name, app] = t as unknown[]
719      if (!isNum(pid) || !isNum(ppid) || !isNum(rssKB) || typeof name !== 'string' || typeof app !== 'string') return null
720      // Another session wrote this file: names are cleaned as if ps had just printed them.
721      procs.push({ pid, ppid, rssKB, name: oneLine(name.slice(0, 1024), 256), app: oneLine(app.slice(0, 1024), 120) })
722    }
723  }
724  return { v: 1, at: o['at'], stats, procs }
725}
726
727/** A wrap request file another session wrote for this one. */
728export function decodeWrapRequest(text: string): WrapRequest | null {
729  try {
730    const o = JSON.parse(text) as Record<string, unknown>
731    if (!o || typeof o !== 'object' || !isNum(o['at'])) return null
732    // Another session wrote this file: a figure out of range is dropped, not drawn.
733    const frees = numOrNull(o['freesMB'])
734    return { at: o['at'], freesMB: frees !== null && frees >= 0 && frees < 16 * 1024 * 1024 ? frees : null }
735  } catch {
736    return null
737  }
738}
739
740/** The model id without the vendor prefix, for narrow columns: "fable-5-1". */
741export const shortModel = (model: string): string => model.replace(/^claude-/, '') || '?'
742
743/** A git branch name safe to draw: printable, one line, bounded. */
744export const cleanBranch = (text: string): string | null => {
745  const line = text.trim().split('\n')[0] ?? ''
746  return /^[\x21-\x7e]{1,100}$/.test(line) ? line : null
747}
748
types/index.d.ts 123 lines
1// The $.state contract of mem-gauge. Every key here is named by a literal
2// plugin/key reference in hooks/register.tsx, and every value is JSON-safe
3// (null, never undefined). The file exports types so it is a module: without a
4// top-level export, `declare module` below would declare a new ambient module
5// instead of augmenting 'claude-code'.
6
7/** macOS memory pressure, from kern.memorystatus_level (percent free). */
8export type Pressure = 'ok' | 'warn' | 'critical' | 'unknown'
9
10/** One machine-wide reading; a field the collector could not read is null. */
11export type MachineStats = {
12  /** When the reading was taken (ms since the epoch). */
13  at: number
14  totalBytes: number | null
15  cores: number | null
16  /** (active + wired + compressor-occupied) pages x page size: Activity Monitor's "Memory Used". */
17  usedBytes: number | null
18  /** (free + speculative) pages x page size. */
19  freeBytes: number | null
20  /** Pages occupied by the compressor x page size. */
21  compressedBytes: number | null
22  usedPercent: number | null
23  pressure: Pressure
24  swapUsedBytes: number | null
25  swapTotalBytes: number | null
26  load1: number | null
27  diskFreeBytes: number | null
28  diskTotalBytes: number | null
29}
30
31/**
32 * live: fresh heartbeat; stale: heartbeat older than 120 s; gone: its process
33 * has exited. An unreadable (half-written) heartbeat has no row at all until a
34 * later tick reads it whole, or it is collected.
35 */
36export type SessionStatus = 'live' | 'stale' | 'gone'
37
38/** One Claude session as the registry describes it, ready to draw. */
39export type SessionRow = {
40  sessionId: string
41  label: string
42  model: string
43  rssMB: number | null
44  contextPercent: number | null
45  /** Input tokens the last response was answered over, and the model's window; null when unknown (an older build's heartbeat, a fresh session). */
46  contextTokens: number | null
47  contextWindow: number | null
48  rateLimitPercent: number | null
49  agents: number
50  idleMinutes: number | null
51  status: SessionStatus
52  isThis: boolean
53  /**
54   * False for a session listed from the engine's own record
55   * (~/.claude/sessions/<pid>.json): its name, memory and idle time only; no
56   * model, context, agents, and no Ask to wrap. True, or absent in a row an
57   * earlier build kept in $.state, for a session that runs mem-gauge.
58   */
59  hasMod?: boolean
60}
61
62/** A non-Claude app, its helper processes rolled up under it. */
63export type AppUsage = { name: string; rssMB: number; processes: number }
64
65/** What the tick last saw; render reads only this, never a process. */
66export type Snapshot = {
67  at: number
68  sessionId: string
69  machine: MachineStats
70  /** This session's engine plus all its descendants; null when the engine pid could not be verified. */
71  thisSessionMB: number | null
72  sessions: SessionRow[]
73  /** Claude Code engines running without mem-gauge, summed; null when ps could not be read. */
74  unregistered: { count: number; rssMB: number } | null
75  /** The largest non-Claude apps; every Claude session's process tree is left out. */
76  topApps: AppUsage[]
77}
78
79/** One mounted volume from `df -kP`; the pseudo filesystems and the sealed-system helpers are left out. */
80export type VolumeRow = { mount: string; name: string; totalBytes: number; usedBytes: number; freeBytes: number }
81
82/** One of the usual disk hogs, measured with `du -sk` when the disk pane opens; null when missing or too slow. */
83export type FolderSize = { path: string; label: string; bytes: number | null }
84
85/** What the disk pane shows; read on open and on Refresh, never on the tick. */
86export type DiskReport = { at: number; volumes: VolumeRow[]; folders: FolderSize[] }
87
88/** Another session's operator asked this one to wrap up (Fix A: a file, never a model message). */
89export type WrapRequest = { at: number; freesMB: number | null }
90
91/** This session's context and rate-limit figures, pushed by session.measure. */
92export type Figures = {
93  contextPercent: number | null
94  contextTokens: number | null
95  contextWindow: number | null
96  rateLimitPercent: number | null
97}
98
99declare module 'claude-code' {
100  interface PluginState {
101    'mem-gauge': {
102      snapshot: Snapshot | null
103      /** The verified engine pid; null until checked or when the check failed. */
104      enginePid: number | null
105      /** The engine pid has been probed once (whatever the outcome), so ticks do not re-probe. */
106      pidChecked: boolean
107      /** Last main-loop activity (turn.start / main turn.complete), ms since the epoch; 0 before any. */
108      lastActivityAt: number
109      /** sessionId -> when this session last sent it an "Ask to wrap" (ms). */
110      asked: Record<string, number>
111      /** "<repo basename> · <branch>", refreshed on session.start and main turn.complete. */
112      label: string | null
113      figures: Figures | null
114      wrapRequest: WrapRequest | null
115      /** The pane lists every active session instead of the top ten (View all pressed). */
116      showAllSessions: boolean
117      disk: DiskReport | null
118      /** Apply actions running now, by tip key: the pane draws their button as Applying and ignores a second press. */
119      applying: string[]
120    }
121  }
122}
123