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…

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.
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.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./mem or a click on the gauge):~/.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.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.osascript, the name passed as an argument, so it may still ask you to save; never the Claude app itself)r) and Close (c, or Esc).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.
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 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.
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 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.
Set in the config menu, or in settings under pluginConfigs → mem-gauge:
| Option | Default | What it does |
|---|---|---|
intervalSeconds | 30 | How often the gauge re-reads memory (10 to 600). |
idleMinutes | 30 | Minutes without a turn before a session counts as idle for the wrap advice. |
contextTipTokens | 150000 | The 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. |
statusLine | false | Also show the one-line summary as a status line (the gauge row already carries it). |
footerButtons | true | Draw the desktop footer figures as buttons (grey chips) that open the panes; off, they are plain text and /mem, /disk open the panes. |
footerCompact | false | Both 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. |
| Hook | Why |
|---|---|
session.start | registers /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.measure | keeps the latest context (percent, tokens, window) and rate-limit figures |
session.end | removes this session's heartbeat file (one rm -f) |
command.run /mem | opens the pane; where no pane can be drawn (claude -p), answers with a short table |
command.run /disk | opens 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 PromptHint | adds the pressable gauge after the engine's own hint line |
ui.render Pane mem-gauge | draws 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):
claude, node or bun) and they are 10 minutes old; anything a day old goes regardless./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.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.
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:
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;/clear;/mem from each kind of origin, placed, unplaced and headless;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);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;/mem, the Ask to wrap question or a toast.vm_stat, sysctl, BSD ps).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.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.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.$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".hooks/register.tsx 1430 lines1// 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 lines1// 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}
306hooks/parse.ts 748 lines1// 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}
748types/index.d.ts 123 lines1// 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