SLOPSHOPPER

session-stats

/session-stats opens a live pane with CPU, RAM and the process tree of this Claude session

panebandcommandtoastpromptprocess
v0.5.0MITupdated 2026-10-18toml0006/session-activity-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-stats
│ ┃ Session stats ✕ › fix the failing auth test and add an audit log call │ ┃ Error: no claude process above pid │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-stats │ ⎿ session-stats: Session stats pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session stats
Error: no claude process above pid
README

session-activity-monitor

A Claude Code mod that shows how hard your Claude Code sessions are working your machine: which prompt was expensive, how much battery it took, and how much all your open Claude windows draw together. It's easy to send a prompt that sets off minutes of builds, tests and subprocesses without noticing; with several windows open, that can saturate a laptop and drain the battery in an hour.

Type /session-stats and a pane opens with:

The session-stats pane: CPU, RAM, network, disk and energy with sparklines, then the process tree

  • CPU and RAM for the session's whole process tree (the claude process plus every shell, build, test runner and stdio MCP server it started), with sparklines of up to the last 4 minutes
  • network (down and up), disk (read and write) and energy (watts now, and the share of the battery this session used since you unplugged) for the same tree, each with its own sparkline
  • the session's share of your cores and memory, process count, uptime and load average
  • a process table, indented as a tree, busiest first, with each process's power draw
  • PROMPT: the running or last prompt's duration, share of your battery (and joules), CPU-seconds and peak cores. A prompt that uses 300 J or more raises a toast, such as "That prompt used 0.5% of your battery (1.2 kJ, peak 6.1 cores)"
  • ALL CLAUDE: every Claude process tree on the machine (all your windows and background sessions) totalled: watts, CPU, RAM, and this session's share of the power
  • the monitor's own cost, so you can see what watching costs

It samples while the pane, the bar or a prompt needs it, at the rate the mode sets, and stops when nothing does.

Bar mode

/session-stats bar toggles a one-line strip above the prompt:

The session-stats bar above the prompt: CPU and RAM numbers, each followed by a sparkline

After the histories the strip shows the session's power draw right now, then how much of the battery this session has used since you last unplugged:

⚡ 0.42 W  🔋 0.31% used        (on battery)
⚡ 0.42 W  🔌                   (on AC power)

The battery count covers this session's processes while on battery; plugging in pauses it and the next unplug starts it over. A session that starts on battery counts from then. /session-stats bar prompt on also shows each prompt's energy in joules (120 J now while one runs, 1.2 kJ last after). Each history is up to 30 cells wide (one minute) from the start and fills left to right, then scrolls. Click the strip to open the full pane. The setting is remembered across sessions; /session-stats bar on and /session-stats bar off set it directly.

What the monitor itself costs

The bar is meant to stay on, so the monitor's own cost is yours to set with /session-stats mode (remembered across sessions):

ModeSamplesShows on the barMonitor's own cost
lite (default)every 10 s, one cheap ps and the energy probeCPU, RAM, power, battery usedabout 1% of one core
normalevery 5 s, plus the energy probethe same, with live energy and disk for the paneabout 3%
fullevery 2 s, everythingthe same, at the finest resolutionabout 7%

In every mode:

  • Each prompt gets a full reading when it's sent and when its turn ends, so prompt energy is exact even in lite, for about 0.1 s of CPU per prompt.
  • The pane samples at full while it's open (network too) and drops back when closed.
  • A session that isn't drawing the bar (a background session, or one with no terminal attached) doesn't sample for it, even with the bar setting on.

/session-stats mode with no argument shows the current mode; the pane shows the measured cost.

Stopping it

/session-stats stop (or off, or kill) closes the pane, turns the bar off and stops all sampling, so the mod runs nothing until you call it again.

Install

At the prompt of a Claude Code terminal session:

/plugin install session-stats --marketplace toml0006/session-activity-monitor

Answer y to add the marketplace, then pick a scope. From a shell:

claude plugin install session-stats --marketplace toml0006/session-activity-monitor

Limits

  • macOS only. It reads sysctl hw.memsize and vm.loadavg and macOS ps output.
  • Subagents run inside the main claude process, so they're part of its numbers, not separate rows.
  • MCP servers reached over HTTP run outside the session and aren't counted.
  • CPU % is ps's smoothed recent average and can pass 100% on multiple cores.
  • RAM sums each process's resident memory, so shared pages count more than once.
  • Network comes from nettop, which takes about 5 seconds per sample, so it updates about every 5 seconds. A background session (claude --bg) sends its API traffic through the shared daemon, so that traffic isn't counted under the session.
  • Prompt costs are measured from submit to the end of the turn, with a full reading at each end and the mode's sampling in between, even with the bar and pane off. Subagents' work inside the session's process tree counts toward the prompt.
  • Work the session causes outside its own process tree isn't counted: the terminal drawing its output, WindowServer, and macOS services woken by things it runs (for example, a status line calling AppleScript on every refresh woke tccd, launchservicesd and runningboardd for ~45% CPU on one machine).
  • The monitor's own cost depends on the mode (see above); the pane shows the measured figure.
  • Disk and energy come from proc_pid_rusage (what Activity Monitor reads), called through the system python3. They need the Xcode Command Line Tools; without them the pane says so rather than triggering an install prompt. Energy needs macOS 13 or later.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 3 files
hooks/register.tsx 568 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Io, Mode, Snapshot, Stats, Turn } from '../types'
5import type { Counters } from './stats'
6import {
7  BATTERY_PROBE,
8  NET_PROBE,
9  POWER_SOURCE_PROBE,
10  PROBE,
11  SLIM_PROBE,
12  bytes,
13  gb,
14  ioProbe,
15  joules,
16  parseBattery,
17  parseNettop,
18  parsePowerSource,
19  parseProbe,
20  parseRusage,
21  parseSlim,
22  parseTimes,
23  rates,
24  sparkline,
25  watts,
26} from './stats'
27
28const PANE = 'session-stats'
29// What each mode costs, measured on an M-series Mac: the full ps is ~86 ms of CPU,
30// the nameless one ~44 ms, the python energy probe ~55 ms, nettop ~128 ms
31const MODES: Record<Mode, { ms: number; note: string }> = {
32  lite: { ms: 10000, note: 'every 10 s, CPU, RAM and power, about 1% of one core' },
33  normal: { ms: 5000, note: 'every 5 s, with live energy and disk, about 3% of one core' },
34  full: { ms: 2000, note: 'every 2 s, everything, about 7% of one core (plus nettop with the pane open)' },
35}
36const MODE_KEY = 'mode'
37const BAR_PROMPT_KEY = 'barPrompt'
38const POWER_CHECK_MS = 60_000
39const HISTORY = 120
40// Each history in the bar is at most this many cells (one minute of samples)
41const BAR_SPAN = 30
42const BAR_KEY = 'bar'
43// A prompt whose tree used at least this much energy raises a toast
44const TOAST_JOULES = 300
45const EMPTY: Stats = {
46  snapshot: null,
47  error: null,
48  cpuHistory: [],
49  ramHistory: [],
50  io: null,
51  isRusageAvailable: true,
52  net: null,
53  netHistory: [],
54  diskHistory: [],
55  energyHistory: [],
56  allWatts: null,
57  cpuSeconds: 0,
58  turn: null,
59  lastTurn: null,
60  batteryWh: null,
61  ownCpuSeconds: 0,
62  ownSince: null,
63  isOnBattery: null,
64  batteryJoules: 0,
65  batterySince: null,
66}
67const stats = atom({ plugin: 'session-stats', key: 'stats' } as const, EMPTY)
68const isBarOn = atom({ plugin: 'session-stats', key: 'isBarOn' } as const, false)
69const modeAtom = atom({ plugin: 'session-stats', key: 'mode' } as const, 'lite' as Mode)
70// Per-prompt joules on the bar: off by default, the bar leads with watts and battery
71const isBarPromptOn = atom({ plugin: 'session-stats', key: 'isBarPromptOn' } as const, false)
72
73// "<0.01%" for a trace, two decimals under 1%, one above
74const batteryShare = (j: number, wh: number) => {
75  const pct = (j / (wh * 3600)) * 100
76  return pct > 0 && pct < 0.01 ? '<0.01%' : `${pct.toFixed(pct >= 1 ? 1 : 2)}%`
77}
78
79const wattsColor = (w: number) => (w >= 5 ? 'red' : w >= 1 ? 'yellow' : 'green')
80const batteryColor = (j: number, wh: number | null) => {
81  const pct = wh ? (j / (wh * 3600)) * 100 : 0
82  return pct >= 5 ? 'red' : pct >= 2 ? 'yellow' : 'green'
83}
84const clockTime = (ms: number) => new Date(ms).toTimeString().slice(0, 5)
85
86// "0.31% of the battery used by this session since 08:12" or "on AC power"
87function batterySummary(st: Stats): string {
88  if (st.isOnBattery === null) return 'battery: unknown'
89  if (!st.isOnBattery) return 'on AC power'
90  const used = st.batteryWh ? batteryShare(st.batteryJoules, st.batteryWh) : joules(st.batteryJoules)
91  return `${used} of the battery used by this session${st.batterySince ? ` since ${clockTime(st.batterySince)}` : ''}`
92}
93
94const joulesColor = (j: number) => (j >= TOAST_JOULES ? 'red' : j >= TOAST_JOULES / 3 ? 'yellow' : 'green')
95const duration = (sec: number) => (sec >= 60 ? `${Math.floor(sec / 60)}m${String(Math.round(sec % 60)).padStart(2, '0')}s` : `${sec.toFixed(0)}s`)
96
97const cpuColor = (cpu: number) => (cpu >= 100 ? 'red' : cpu >= 50 ? 'yellow' : 'green')
98const ramColor = (kb: number) => (kb >= 4194304 ? 'red' : kb >= 2097152 ? 'yellow' : 'green')
99
100// One sampler serves the bar, the pane and a running prompt, and runs only
101// while one of them needs it. A reload resets these; session.start restarts it.
102let timer: Timer | undefined
103let isPaneOpen = false
104let isTurnRunning = false
105let prevSampleAt: number | undefined
106let timerMs = 0
107let mode: Mode = 'lite'
108// When this session last drew the bar: a session that doesn't draw it (a
109// background spare, no surface attached) has no reason to sample for it
110let lastBarDrawnAt: number | undefined
111let lastPowerCheckAt: number | undefined
112
113// The pane shows everything, so it samples at full while it's open
114const effectiveMode = (): Mode => (isPaneOpen ? 'full' : mode)
115
116// The previous cumulative counters, to turn the next ones into rates
117let prevRusage: { at: number; counters: Counters } | undefined
118let prevNet: { at: number; counters: Counters } | undefined
119let isNetBusy = false
120
121const push = (list: readonly number[], v: number) => [...list, v].slice(-HISTORY)
122
123// The host keeps $.state across reloads and updates, so a value written by an
124// older version can lack fields this one reads: fill them in on every read.
125async function readStats($: EngineInterface): Promise<Stats> {
126  return { ...EMPTY, ...(await read($, stats)) }
127}
128
129async function updateStats($: EngineInterface, fn: (s: Stats) => Stats) {
130  await update($, stats, s => fn({ ...EMPTY, ...s }))
131}
132
133// "running 45s · 120 J (0.1% of battery) · 30 CPU-s · peak 3.2 cores"
134function promptSummary(st: Stats): string {
135  const t = st.turn ?? st.lastTurn
136  if (!t) return 'no prompt measured yet'
137  const energy = st.batteryWh ? `${batteryShare(t.joules, st.batteryWh)} of battery (${joules(t.joules)})` : joules(t.joules)
138  return `${st.turn ? 'running' : 'last'} ${duration(t.seconds)} · ${energy} · ${t.cpuSeconds.toFixed(0)} CPU-s · peak ${(t.peakCpu / 100).toFixed(1)} cores`
139}
140
141// A periodic tick: skipped when only an undrawn bar wants it, the nameless ps
142// in lite, the full probe otherwise. `force` (a prompt's start and end, the
143// sampler starting) always takes a full reading with energy.
144async function sample($: EngineInterface, force = false) {
145  try {
146    const now = await $.clock.now()
147    const isBarUnseen = lastBarDrawnAt === undefined || now - lastBarDrawnAt > 3 * MODES[effectiveMode()].ms + 2000
148    if (!force && !isPaneOpen && !isTurnRunning && isBarUnseen) return
149    await checkPower($, now)
150    const { snapshot } = await readStats($)
151    if (!force && effectiveMode() === 'lite' && snapshot && (await sampleSlim($, snapshot))) return
152
153    const ps = await $.process.run(PROBE, { env: { LC_ALL: 'C' }, timeoutMs: 5000 })
154    const snap = parseProbe(ps.stdout)
155    const pids = snap.procs.map(p => p.pid)
156
157    // One rusage call covers every Claude tree, so this session's share is known
158    const probe = ioProbe(snap.allClaude.pids)
159    const io$ = await $.process.run(probe.argv, { env: probe.env, timeoutMs: 5000 })
160    const usage = parseRusage(io$.stdout)
161    const at = await $.clock.now()
162    const sinceLast = prevSampleAt === undefined ? 0 : (at - prevSampleAt) / 1000
163    prevSampleAt = at
164
165    let io: Io | null = null
166    let allWatts: number | null = null
167    let seconds = 0
168    if (prevRusage && usage.size > 0) {
169      seconds = (at - prevRusage.at) / 1000
170      const { total, perPid } = rates(prevRusage.counters, usage, seconds, pids)
171      const hasEnergy = [...usage.values()].every(c => (c[2] ?? -1) >= 0)
172      for (const p of snap.procs) {
173        const r = perPid.get(p.pid)
174        p.watts = r && hasEnergy ? (r[2] ?? 0) / 1e9 : null
175      }
176      const watts = hasEnergy ? (total[2] ?? 0) / 1e9 : 0
177      io = { diskRead: total[0] ?? 0, diskWrite: total[1] ?? 0, watts, hasEnergy, joules: 0 }
178      if (hasEnergy) allWatts = (rates(prevRusage.counters, usage, seconds, snap.allClaude.pids).total[2] ?? 0) / 1e9
179    }
180    prevRusage = usage.size > 0 ? { at, counters: usage } : undefined
181    const cpuDelta = (snap.cpu / 100) * sinceLast
182    const ownDelta = parseTimes(ps.stdout) + parseTimes(io$.stdout)
183
184    await updateStats($, s => {
185      const energy = io ? io.watts * seconds : 0
186      const turn: Turn | null = s.turn && {
187        ...s.turn,
188        seconds: (at - s.turn.startedAt) / 1000,
189        joules: s.turn.joules + energy,
190        cpuSeconds: s.turn.cpuSeconds + cpuDelta,
191        peakCpu: Math.max(s.turn.peakCpu, snap.cpu),
192      }
193      return {
194        ...s,
195        snapshot: snap,
196        error: null,
197        cpuHistory: push(s.cpuHistory, snap.cpu),
198        ramHistory: push(s.ramHistory, snap.rssKb),
199        isRusageAvailable: usage.size > 0,
200        io: io ? { ...io, joules: (s.io?.joules ?? 0) + energy } : s.io,
201        diskHistory: io ? push(s.diskHistory, io.diskRead + io.diskWrite) : s.diskHistory,
202        energyHistory: io?.hasEnergy ? push(s.energyHistory, io.watts) : s.energyHistory,
203        allWatts: allWatts ?? s.allWatts,
204        batteryJoules: s.batteryJoules + (s.isOnBattery ? energy : 0),
205        cpuSeconds: s.cpuSeconds + cpuDelta,
206        turn,
207        ownCpuSeconds: s.ownCpuSeconds + ownDelta,
208        ownSince: s.ownSince ?? at,
209      }
210    })
211    if (isPaneOpen && !isNetBusy) void sampleNet($, pids)
212  } catch (err) {
213    await updateStats($, s => ({ ...s, error: String(err) }))
214  }
215}
216
217// Lite: CPU, RAM and power of the tree under the known root (no names, no
218// other sessions, no disk history). False when the root is gone, so the caller
219// takes a full reading instead.
220async function sampleSlim($: EngineInterface, snap: Snapshot): Promise<boolean> {
221  const { stdout } = await $.process.run(SLIM_PROBE, { env: { LC_ALL: 'C' }, timeoutMs: 5000 })
222  const tree = parseSlim(stdout, snap.rootPid)
223  if (!tree) return false
224  const probe = ioProbe(tree.pids)
225  const io$ = await $.process.run(probe.argv, { env: probe.env, timeoutMs: 5000 })
226  const usage = parseRusage(io$.stdout)
227  const at = await $.clock.now()
228  const sinceLast = prevSampleAt === undefined ? 0 : (at - prevSampleAt) / 1000
229  prevSampleAt = at
230  let io: Io | null = null
231  let seconds = 0
232  if (prevRusage && usage.size > 0) {
233    seconds = (at - prevRusage.at) / 1000
234    const { total } = rates(prevRusage.counters, usage, seconds, tree.pids)
235    const hasEnergy = [...usage.values()].every(c => (c[2] ?? -1) >= 0)
236    io = { diskRead: total[0] ?? 0, diskWrite: total[1] ?? 0, watts: hasEnergy ? (total[2] ?? 0) / 1e9 : 0, hasEnergy, joules: 0 }
237  }
238  if (usage.size > 0) prevRusage = { at, counters: usage }
239  const cpuDelta = (tree.cpu / 100) * sinceLast
240  const own = parseTimes(stdout) + parseTimes(io$.stdout)
241  const energy = io ? io.watts * seconds : 0
242  await updateStats($, s => ({
243    ...s,
244    io: io ? { ...io, joules: (s.io?.joules ?? 0) + energy } : s.io,
245    energyHistory: io?.hasEnergy ? push(s.energyHistory, io.watts) : s.energyHistory,
246    batteryJoules: s.batteryJoules + (s.isOnBattery ? energy : 0),
247    snapshot: s.snapshot && { ...s.snapshot, cpu: tree.cpu, rssKb: tree.rssKb },
248    error: null,
249    cpuHistory: push(s.cpuHistory, tree.cpu),
250    ramHistory: push(s.ramHistory, tree.rssKb),
251    cpuSeconds: s.cpuSeconds + cpuDelta,
252    turn: s.turn && {
253      ...s.turn,
254      seconds: (at - s.turn.startedAt) / 1000,
255      joules: s.turn.joules + energy,
256      cpuSeconds: s.turn.cpuSeconds + cpuDelta,
257      peakCpu: Math.max(s.turn.peakCpu, tree.cpu),
258    },
259    ownCpuSeconds: s.ownCpuSeconds + own,
260    ownSince: s.ownSince ?? at,
261  }))
262  return true
263}
264
265// On battery or AC, about once a minute. Unplugging (or the first look finding
266// the Mac on battery) starts the battery count from zero; plugging in pauses it.
267async function checkPower($: EngineInterface, at: number) {
268  if (lastPowerCheckAt !== undefined && at - lastPowerCheckAt < POWER_CHECK_MS) return
269  lastPowerCheckAt = at
270  const onBattery = await $.process
271    .run(POWER_SOURCE_PROBE, { timeoutMs: 5000 })
272    .then(r => parsePowerSource(r.stdout))
273    .catch(() => null)
274  await updateStats($, s => {
275    if (onBattery === s.isOnBattery) return s
276    if (onBattery) return { ...s, isOnBattery: true, batteryJoules: 0, batterySince: at }
277    return { ...s, isOnBattery: onBattery }
278  })
279}
280
281async function sampleNet($: EngineInterface, pids: readonly number[]) {
282  isNetBusy = true
283  try {
284    const { stdout } = await $.process.run(NET_PROBE, { timeoutMs: 15000 })
285    const counters = parseNettop(stdout)
286    const at = await $.clock.now()
287    const own = parseTimes(stdout)
288    await updateStats($, s => ({ ...s, ownCpuSeconds: s.ownCpuSeconds + own }))
289    if (prevNet) {
290      const { total } = rates(prevNet.counters, counters, (at - prevNet.at) / 1000, pids)
291      const net = { bytesIn: total[0] ?? 0, bytesOut: total[1] ?? 0 }
292      await updateStats($, s => ({ ...s, net, netHistory: push(s.netHistory, net.bytesIn + net.bytesOut) }))
293    }
294    prevNet = { at, counters }
295  } catch {
296    // Network is a nice-to-have: a failed nettop leaves the last reading
297  } finally {
298    isNetBusy = false
299  }
300}
301
302async function syncSampler($: EngineInterface) {
303  const isNeeded = isPaneOpen || isTurnRunning || (await read($, isBarOn))
304  const ms = MODES[effectiveMode()].ms
305  if (isNeeded && timer && timerMs !== ms) {
306    timer.cancel()
307    timer = undefined
308  }
309  if (isNeeded && !timer) {
310    await sample($, true)
311    timer = $.clock.every(ms, () => void sample($))
312    timerMs = ms
313  } else if (!isNeeded && timer) {
314    timer.cancel()
315    timer = undefined
316    prevSampleAt = undefined
317  }
318}
319
320// Closes the pane, turns the bar off (remembered), and stops every sampler
321async function stopAll($: EngineInterface) {
322  isPaneOpen = false
323  await $.store.set(BAR_KEY, false)
324  await update($, isBarOn, () => false)
325  await syncSampler($)
326  prevRusage = undefined
327  prevNet = undefined
328  await $.ui.close({ id: PANE }).catch(() => undefined)
329  return 'Session stats stopped: pane closed, bar off, nothing sampling. /session-stats starts it again.'
330}
331
332async function openPane($: EngineInterface) {
333  const opened = await $.ui.open({ id: PANE, title: 'Session stats' })
334  if (!opened.isPlaced) return `Session stats pane not shown: ${opened.reason}`
335  isPaneOpen = true
336  await syncSampler($)
337  return 'Session stats pane opened.'
338}
339
340export const register: Register = on => {
341  on('session.start', async ($, e, next) => {
342    await $.command.register({
343      name: 'session-stats',
344      description:
345        'Show live CPU, RAM and processes of this session; "bar" toggles the strip above the prompt, "mode" sets how much the monitor itself costs, "stop" shuts everything off',
346      argumentHint: '[bar [on|off|prompt [on|off]] | mode [lite|normal|full] | stop]',
347    })
348    const isOn = (await $.store.get(BAR_KEY)) === true
349    await update($, isBarOn, () => isOn)
350    const stored = await $.store.get(MODE_KEY)
351    mode = stored === 'normal' || stored === 'full' ? stored : 'lite'
352    await update($, modeAtom, () => mode)
353    const isPromptOn = (await $.store.get(BAR_PROMPT_KEY)) === true
354    await update($, isBarPromptOn, () => isPromptOn)
355    const batteryWh = await $.process
356      .run(BATTERY_PROBE, { timeoutMs: 5000 })
357      .then(r => parseBattery(r.stdout))
358      .catch(() => null)
359    await updateStats($, s => ({ ...s, batteryWh }))
360    // A reload keeps an open pane open but forgets it here; ask, so it keeps updating
361    isPaneOpen = await $.ui
362      .panes()
363      .then(panes => panes.some(pane => pane.id === PANE))
364      .catch(() => false)
365    await syncSampler($)
366    return next(e)
367  })
368
369  // A prompt's cost: sample from its submit to its turn's end, whatever is showing
370  on('prompt.submit', async ($, e, next) => {
371    // A full reading first is the prompt's baseline, so earlier energy isn't counted
372    isTurnRunning = true
373    if (timer) await sample($, true)
374    await syncSampler($)
375    const startedAt = await $.clock.now()
376    await updateStats($, s => ({ ...s, turn: { startedAt, seconds: 0, joules: 0, cpuSeconds: 0, peakCpu: 0 } }))
377    return next(e)
378  })
379
380  on('turn.complete', async ($, e, next) => {
381    // A subagent's turn ends inside the prompt's; only the main loop's ends it
382    if (e.agentId !== undefined) return next(e)
383    // A full reading at the end, so even lite counts the prompt's whole energy
384    await sample($, true)
385    const { turn, batteryWh } = await readStats($)
386    isTurnRunning = false
387    if (turn) {
388      const done = { ...turn, seconds: ((await $.clock.now()) - turn.startedAt) / 1000 }
389      await updateStats($, s => ({ ...s, turn: null, lastTurn: done }))
390      if (done.joules >= TOAST_JOULES) {
391        const peak = `peak ${(done.peakCpu / 100).toFixed(1)} cores`
392        $.ui.toast(
393          batteryWh
394            ? `That prompt used ${batteryShare(done.joules, batteryWh)} of your battery (${joules(done.joules)}, ${peak})`
395            : `That prompt used ${joules(done.joules)} (${peak})`,
396        )
397      }
398    }
399    await syncSampler($)
400    return next(e)
401  })
402
403  on('command.run', { command: 'session-stats' }, async ($, e) => {
404    const [sub, arg] = e.args.trim().split(/\s+/)
405    if (sub === 'stop' || sub === 'off' || sub === 'kill') return { text: await stopAll($) }
406    if (sub === 'mode') {
407      const list = (Object.keys(MODES) as Mode[]).map(m => `${m}: ${MODES[m].note}`).join('; ')
408      if (!arg) return { text: `Mode ${mode}. ${list}. The pane runs full while it's open.` }
409      if (!(Object.keys(MODES) as string[]).includes(arg)) return { text: `Unknown mode "${arg}". ${list}.` }
410      mode = arg as Mode
411      await $.store.set(MODE_KEY, mode)
412      await update($, modeAtom, () => mode)
413      await syncSampler($)
414      return { text: `Session stats mode ${mode}: ${MODES[mode].note}.` }
415    }
416    if (sub !== 'bar') return { text: await openPane($) }
417    if (arg === 'prompt') {
418      const choice = e.args.trim().split(/\s+/)[2]
419      const isOn = choice === 'on' ? true : choice === 'off' ? false : !(await read($, isBarPromptOn))
420      await $.store.set(BAR_PROMPT_KEY, isOn)
421      await update($, isBarPromptOn, () => isOn)
422      return { text: `Prompt energy on the bar ${isOn ? 'on' : 'off'}.` }
423    }
424
425    const isOn = arg === 'on' ? true : arg === 'off' ? false : !(await read($, isBarOn))
426    await $.store.set(BAR_KEY, isOn)
427    await update($, isBarOn, () => isOn)
428    await syncSampler($)
429    return { text: `Session stats bar ${isOn ? 'on' : 'off'}.` }
430  })
431
432  on('ui.close', { id: PANE }, async ($, e, next) => {
433    isPaneOpen = false
434    await syncSampler($)
435    return next(e)
436  })
437
438  // The strip above the prompt: numbers first, then each history at its full
439  // width from the first sample, filling left to right and then scrolling. One
440  // Button, so a click anywhere on it opens the pane.
441  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
442    if (e.props.hasSurvey || !(await read($, isBarOn))) return next(e)
443    lastBarDrawnAt = await $.clock.now()
444
445    const { Button, Text } = $.ui.resolve(e)
446    const st = await readStats($)
447    const { snapshot: s, cpuHistory, ramHistory, turn, lastTurn, io } = st
448    const width = e.props.bodyColumns ?? e.viewport?.columns ?? 80
449    // Power now, then the battery this session used since the last unplug
450    const power = `  ⚡ ${io?.hasEnergy ? watts(io.watts) : '…'}`
451    const battery =
452      st.isOnBattery && st.batteryWh
453        ? `  🔋 ${batteryShare(st.batteryJoules, st.batteryWh)} used`
454        : st.isOnBattery === false
455          ? '  🔌'
456          : ''
457    // Optional: the prompt's energy, the one running or else the last one
458    const shown = (await read($, isBarPromptOn)) ? (turn ?? lastTurn) : null
459    const prompt = shown ? `  ${joules(shown.joules)} ${turn ? 'now' : 'last'}` : ''
460    // Emoji draw two cells wide: one more each than their length counts
461    const extra = power.length + battery.length + prompt.length + (battery ? 2 : 1)
462    // "⚙ CPU 100% " + " " + " RAM 10.0G " + " " is 26 cells around the two histories
463    const span = Math.max(6, Math.min(BAR_SPAN, Math.floor((width - 26 - extra) / 2)))
464    const cpu = sparkline(cpuHistory.slice(-span))
465    const ram = sparkline(ramHistory.slice(-span))
466
467    return (
468      <Button key="open-pane" plain onPress={() => void openPane($)}>
469        <Text dimColor>⚙ CPU </Text>
470        <Text color={cpuColor(s?.cpu ?? 0)}>{(s ? `${s.cpu.toFixed(0)}%` : '…').padStart(4)} </Text>
471        <Text color="cyan">{cpu}</Text>
472        <Text dimColor>{'·'.repeat(span - cpu.length)}  RAM </Text>
473        <Text color={ramColor(s?.rssKb ?? 0)}>{(s ? `${gb(s.rssKb)}G` : '…').padStart(5)} </Text>
474        <Text color="magenta">{ram}</Text>
475        <Text dimColor>{'·'.repeat(span - ram.length)}</Text>
476        <Text color={io?.hasEnergy ? wattsColor(io.watts) : undefined} dimColor={!io?.hasEnergy}>{power}</Text>
477        {battery && <Text color={st.isOnBattery ? batteryColor(st.batteryJoules, st.batteryWh) : undefined} dimColor={!st.isOnBattery}>{battery}</Text>}
478        {shown && <Text color={joulesColor(shown.joules)}>{prompt}</Text>}
479      </Button>
480    )
481  })
482
483  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
484    const { Box, Text } = $.ui.resolve(e)
485    const st = await readStats($)
486    const { snapshot: s, error, cpuHistory, ramHistory, io, net, netHistory, diskHistory, energyHistory, isRusageAvailable } = st
487    const now = await $.clock.now()
488    const chosen = await read($, modeAtom)
489    const width = e.props.bodyColumns ?? e.viewport?.columns ?? 80
490    const noRusage = isRusageAvailable ? null : 'needs the Xcode Command Line Tools'
491
492    if (!s) return <Text dimColor>{error ?? 'Sampling…'}</Text>
493
494    const machinePct = (s.cpu / s.cores).toFixed(0)
495    const ramPct = ((s.rssKb * 1024 * 100) / s.memBytes).toFixed(1)
496    const spark = Math.max(10, Math.min(HISTORY, width - 14))
497    const room = Math.max(3, (e.viewport?.rows ?? 30) - 21)
498    const nameCols = Math.max(10, width - 31)
499    const pad = (v: string, n: number) => v.padStart(n)
500
501    return (
502      <Box flexDirection="column">
503        <Text>
504          <Text bold>CPU </Text>
505          <Text color={cpuColor(s.cpu)}>{s.cpu.toFixed(0)}%</Text>
506          <Text dimColor> ({machinePct}% of {s.cores} cores)</Text>
507        </Text>
508        <Text color="cyan">{sparkline(cpuHistory.slice(-spark))}</Text>
509        <Text>
510          <Text bold>RAM </Text>
511          <Text color={ramColor(s.rssKb)}>{gb(s.rssKb)} GB</Text>
512          <Text dimColor> ({ramPct}% of {gb(s.memBytes / 1024)} GB)</Text>
513        </Text>
514        <Text color="magenta">{sparkline(ramHistory.slice(-spark))}</Text>
515        <Text>
516          <Text bold>NET </Text>
517          {net ? `↓ ${bytes(net.bytesIn)}/s  ↑ ${bytes(net.bytesOut)}/s` : <Text dimColor>measuring…</Text>}
518        </Text>
519        <Text color="blue">{sparkline(netHistory.slice(-spark))}</Text>
520        <Text>
521          <Text bold>DISK </Text>
522          {io ? `read ${bytes(io.diskRead)}/s  write ${bytes(io.diskWrite)}/s` : <Text dimColor>{noRusage ?? 'measuring…'}</Text>}
523        </Text>
524        <Text color="yellow">{sparkline(diskHistory.slice(-spark))}</Text>
525        <Text>
526          <Text bold>ENERGY </Text>
527          {io?.hasEnergy ? `${watts(io.watts)} now` : <Text dimColor>{io ? 'needs macOS 13 or later' : noRusage ?? 'measuring…'}</Text>}
528          <Text dimColor> · {batterySummary(st)}</Text>
529        </Text>
530        <Text color="red">{sparkline(energyHistory.slice(-spark))}</Text>
531        <Text>
532          <Text bold>PROMPT </Text>
533          {promptSummary(st)}
534        </Text>
535        <Text>
536          <Text bold>ALL CLAUDE </Text>
537          {s.allClaude
538            ? `${s.allClaude.trees} trees · ${st.allWatts === null ? '…' : st.allWatts.toFixed(2)} W · ${s.allClaude.cpu.toFixed(0)}% CPU · ${gb(s.allClaude.rssKb)} GB`
539            : '…'}
540          {s.allClaude && st.allWatts && io?.hasEnergy ? (
541            <Text dimColor> · this session {((io.watts / st.allWatts) * 100).toFixed(0)}% of the power</Text>
542          ) : null}
543        </Text>
544        <Text dimColor>
545          mode {chosen}
546          {chosen === 'full' ? '' : ' (full while this pane is open)'}
547          {st.ownSince !== null && now > st.ownSince
548            ? ` · monitor itself: ${((st.ownCpuSeconds / ((now - st.ownSince) / 1000)) * 100).toFixed(1)}% of one core`
549            : ''}
550        </Text>
551        <Text dimColor>
552          {s.procs.length} processes · session pid {s.rootPid} up {s.procs[0]?.elapsed} · load {s.load.map(l => l.toFixed(2)).join(' ')}
553        </Text>
554        <Text> </Text>
555        <Text bold>{'  PID  CPU%   MEM     W NAME'}</Text>
556        {s.procs.slice(0, room).map(p => (
557          <Text>
558            {pad(String(p.pid), 5)} {pad(p.cpu.toFixed(1), 5)} {pad(gb(p.rssKb) + 'G', 5)} {pad(p.watts == null ? '–' : p.watts.toFixed(2), 5)}{' '}
559            <Text dimColor={p.cpu < 1}>{('  '.repeat(p.depth) + p.name).slice(0, nameCols)}</Text>
560          </Text>
561        ))}
562        {s.procs.length > room && <Text dimColor>…{s.procs.length - room} more</Text>}
563        {error && <Text color="red">last sample failed: {error}</Text>}
564      </Box>
565    )
566  })
567}
568
hooks/stats.ts 278 lines
1import type { Proc, Snapshot } from '../types'
2
3// One shell call gathers everything: the spawner's pid (the engine, or a child
4// of it), machine facts, then every process. LC_ALL=C keeps ps's decimals dots.
5export const PROBE = [
6  '/bin/sh',
7  '-c',
8  'echo $PPID; sysctl -n hw.ncpu hw.memsize vm.loadavg; ps -axo pid=,ppid=,pcpu=,rss=,etime=,comm=; times',
9] as const
10
11/**
12 * CPU seconds a probe's own commands used, from the \`times\` its shell prints
13 * last (children's user and system time), so the monitor can report its cost.
14 */
15export function parseTimes(stdout: string): number {
16  const last = stdout.trim().split('\n').pop() ?? ''
17  const parts = [...last.matchAll(/(\d+)m([\d.]+)s/g)].map(m => Number(m[1]) * 60 + Number(m[2]))
18  return parts.length === 2 ? (parts[0] ?? 0) + (parts[1] ?? 0) : 0
19}
20
21const ROW = /^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.*)$/
22
23const PROBE_COMMANDS = ['ps', 'nettop', 'Python', 'python3']
24
25// "claude bg-spare" and "/Users/x/.local/bin/claude" both name a claude process
26const isClaude = (name: string) => name.split(' ')[0]?.split('/').pop() === 'claude'
27
28// A full path may hold spaces ("Application Support"); a renamed title holds words
29const shortName = (name: string) => {
30  if (name.startsWith('/')) return name.split('/').pop() ?? name
31  const [head = '', ...rest] = name.split(' ')
32  return [head.split('/').pop(), ...rest].join(' ')
33}
34
35export function parseProbe(stdout: string): Snapshot {
36  const [spawner = '', ncpu, memsize, loadavg = '', ...rows] = stdout.trim().split('\n')
37
38  const all = new Map<number, Omit<Proc, 'depth' | 'watts'>>()
39  for (const row of rows) {
40    const m = ROW.exec(row)
41    if (!m) continue
42    const pid = Number(m[1])
43    all.set(pid, {
44      pid,
45      ppid: Number(m[2]),
46      cpu: Number(m[3]),
47      rssKb: Number(m[4]),
48      elapsed: m[5] ?? '',
49      name: (m[6] ?? '').trim(),
50    })
51  }
52
53  // The session is the nearest claude ancestor of whatever ran the probe
54  let rootPid = Number(spawner)
55  while (rootPid > 1 && !isClaude(all.get(rootPid)?.name ?? '')) {
56    rootPid = all.get(rootPid)?.ppid ?? 1
57  }
58  if (rootPid <= 1) throw new Error(`no claude process above pid ${spawner}`)
59
60  const children = new Map<number, Omit<Proc, 'depth' | 'watts'>[]>()
61  for (const p of all.values()) {
62    if (!children.has(p.ppid)) children.set(p.ppid, [])
63    children.get(p.ppid)!.push(p)
64  }
65
66  // Depth-first so the table reads as a tree; busiest siblings first. The probe's
67  // own sh and ps are left out, since they exist only because we asked.
68  // The mod's own probes: an sh under the spawner or the session running ps,
69  // nettop or python, and those children. They exist only because we asked.
70  const probePids = new Set<number>()
71  for (const sh of [...(children.get(Number(spawner)) ?? []), ...(children.get(rootPid) ?? [])]) {
72    const kids = children.get(sh.pid) ?? []
73    if (shortName(sh.name) === 'sh' && kids.some(k => PROBE_COMMANDS.includes(shortName(k.name)))) {
74      probePids.add(sh.pid)
75      for (const k of kids) probePids.add(k.pid)
76    }
77  }
78  const procs: Proc[] = []
79  // The io probes (nettop, and python under sh) are the session's direct children
80  const isIoProbe = (p: Omit<Proc, 'depth' | 'watts'>, depth: number) =>
81    depth === 1 && ['nettop', 'Python', 'python3'].includes(shortName(p.name))
82  const walk = (p: Omit<Proc, 'depth' | 'watts'>, depth: number) => {
83    if (probePids.has(p.pid) || isIoProbe(p, depth)) return
84    procs.push({ ...p, depth, name: shortName(p.name), watts: null })
85    const kids = [...(children.get(p.pid) ?? [])].sort((a, b) => b.cpu - a.cpu || b.rssKb - a.rssKb)
86    for (const k of kids) walk(k, depth + 1)
87  }
88  walk(all.get(rootPid)!, 0)
89
90  // Every Claude process tree on the machine: each process counts toward its
91  // nearest claude ancestor, so nested sessions (a background session under the
92  // daemon) are counted once, under the session that owns them.
93  const owner = new Map<number, number | null>()
94  const ownerOf = (pid: number): number | null => {
95    if (owner.has(pid)) return owner.get(pid)!
96    const p = all.get(pid)
97    const o = !p ? null : isClaude(p.name) ? pid : p.ppid > 1 ? ownerOf(p.ppid) : null
98    owner.set(pid, o)
99    return o
100  }
101  const trees = new Set<number>()
102  const claudePids: number[] = []
103  let allCpu = 0
104  let allRss = 0
105  for (const p of all.values()) {
106    const o = ownerOf(p.pid)
107    if (o === null || probePids.has(p.pid)) continue
108    trees.add(o)
109    claudePids.push(p.pid)
110    allCpu += p.cpu
111    allRss += p.rssKb
112  }
113
114  return {
115    rootPid,
116    rootName: shortName(all.get(rootPid)!.name),
117    allClaude: { trees: trees.size, cpu: allCpu, rssKb: allRss, pids: claudePids },
118    cores: Number(ncpu),
119    memBytes: Number(memsize),
120    load: loadavg.replace(/[{}]/g, '').trim().split(/\s+/).map(Number),
121    cpu: procs.reduce((s, p) => s + p.cpu, 0),
122    rssKb: procs.reduce((s, p) => s + p.rssKb, 0),
123    procs,
124  }
125}
126
127const BARS = '▁▂▃▄▅▆▇█'
128
129export function sparkline(values: readonly number[], max?: number): string {
130  const top = max ?? Math.max(1, ...values)
131  return values.map(v => BARS[Math.min(7, Math.max(0, Math.round((v / top) * 7)))]).join('')
132}
133
134export const gb = (kb: number) => (kb / 1048576).toFixed(kb >= 10485760 ? 0 : 1)
135
136// Disk and energy per process come from proc_pid_rusage (what Activity Monitor
137// reads), called through the system python. It runs only where the Command
138// Line Tools are installed, so a bare Mac never sees an install prompt; there
139// the probe prints nothing and the pane says why.
140const RUSAGE_PY = `
141import ctypes, sys
142lib = ctypes.CDLL('/usr/lib/libproc.dylib')
143class RU(ctypes.Structure):
144    _fields_ = [('uuid', ctypes.c_uint8 * 16)] + [('f%d' % i, ctypes.c_uint64) for i in range(56)]
145for pid in map(int, sys.argv[1:]):
146    r = RU()
147    if lib.proc_pid_rusage(pid, 6, ctypes.byref(r)) == 0:
148        print(pid, r.f16, r.f17, r.f40)
149    elif lib.proc_pid_rusage(pid, 4, ctypes.byref(r)) == 0:
150        print(pid, r.f16, r.f17, -1)
151`
152// Fields after the uuid: 16 diskio_bytesread, 17 diskio_byteswritten, and in
153// rusage_info_v6 (macOS 13+) 40 energy_nj. v4's billed_energy reads 0 on Apple
154// silicon, so before v6 the energy column is -1, unknown.
155
156export const ioProbe = (pids: readonly number[]) => ({
157  argv: ['/bin/sh', '-c', 'xcode-select -p >/dev/null 2>&1 && /usr/bin/python3 -I -c "$PY" "$@"; times', 'sh', ...pids.map(String)],
158  env: { PY: RUSAGE_PY },
159})
160
161// One nettop sample takes about 5 s, so it runs beside the 2 s sampler, not in it
162export const NET_PROBE = ['/bin/sh', '-c', '/usr/bin/nettop -P -L 1 -x -J bytes_in,bytes_out; times'] as const
163
164// The battery's full charge in watt-hours, to put a prompt's energy in battery terms
165export const BATTERY_PROBE = ['/usr/sbin/ioreg', '-rn', 'AppleSmartBattery'] as const
166
167/**
168 * Full-charge capacity in Wh from ioreg, or null on a Mac without a battery.
169 * Capacity is in mAh in the nested battery data (NominalChargeCapacity, else
170 * AppleRawMaxCapacity); the top-level MaxCapacity is a percentage, never used.
171 */
172export function parseBattery(stdout: string): number | null {
173  const field = (k: string) => new RegExp(`"${k}"\\s*=\\s*(\\d+)`).exec(stdout)?.[1]
174  const mah = field('NominalChargeCapacity') ?? field('AppleRawMaxCapacity')
175  const mv = field('Voltage')
176  if (!mah || !mv) return null
177  return (Number(mah) * Number(mv)) / 1e6
178}
179
180/** "340 J", "1.2 kJ" */
181export const joules = (j: number) => (j >= 1000 ? `${(j / 1000).toFixed(1)} kJ` : `${j.toFixed(0)} J`)
182
183export type Counters = Map<number, number[]>
184
185/** "pid read written energyNj" lines: cumulative disk bytes and energy (-1 when unknown). */
186export function parseRusage(stdout: string): Counters {
187  const out: Counters = new Map()
188  for (const line of stdout.trim().split('\n')) {
189    const [pid, ...rest] = line.trim().split(/\s+/).map(Number)
190    if (pid && rest.length === 3) out.set(pid, rest)
191  }
192  return out
193}
194
195/** nettop's "name.pid,bytes_in,bytes_out," lines: cumulative bytes per process. */
196export function parseNettop(stdout: string): Counters {
197  const out: Counters = new Map()
198  for (const line of stdout.split('\n')) {
199    const m = /\.(\d+),(\d+),(\d+),$/.exec(line.trim())
200    if (m) out.set(Number(m[1]), [Number(m[2]), Number(m[3])])
201  }
202  return out
203}
204
205/**
206 * Per-second rates between two counter samples, over the pids in `pids` that
207 * both samples hold (a process that started or ended in between is skipped,
208 * as is a counter that went backwards). Returns the summed rates and each pid's.
209 */
210export function rates(prev: Counters, cur: Counters, seconds: number, pids: Iterable<number>) {
211  const perPid = new Map<number, number[]>()
212  let total: number[] = []
213  for (const pid of pids) {
214    const a = prev.get(pid)
215    const b = cur.get(pid)
216    if (!a || !b || seconds <= 0) continue
217    const r = b.map((v, i) => Math.max(0, v - (a[i] ?? v)) / seconds)
218    perPid.set(pid, r)
219    total = r.map((v, i) => v + (total[i] ?? 0))
220  }
221  return { total, perPid }
222}
223
224export function bytes(n: number): string {
225  const units = ['B', 'KB', 'MB', 'GB']
226  let i = 0
227  while (n >= 1000 && i < units.length - 1) {
228    n /= 1024
229    i++
230  }
231  return `${n >= 100 || i === 0 ? n.toFixed(0) : n.toFixed(1)} ${units[i]}`
232}
233
234// The lite probe: no command names (half the CPU of the full ps), so the tree
235// hangs off a session root the last full probe found.
236export const SLIM_PROBE = ['/bin/sh', '-c', 'ps -axo pid=,ppid=,pcpu=,rss=; times'] as const
237
238/** CPU and RSS of the tree under `rootPid` from the lite probe; null when the root is gone. */
239export function parseSlim(stdout: string, rootPid: number): { cpu: number; rssKb: number; pids: number[] } | null {
240  const rows = new Map<number, number[]>()
241  const kids = new Map<number, number[]>()
242  for (const line of stdout.split('\n')) {
243    const m = /^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s*$/.exec(line)
244    if (!m) continue
245    const [pid, ppid, cpu, rss] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
246    rows.set(pid, [cpu, rss])
247    kids.set(ppid, [...(kids.get(ppid) ?? []), pid])
248  }
249  if (!rows.has(rootPid)) return null
250  const pids: number[] = []
251  const stack = [rootPid]
252  while (stack.length) {
253    const pid = stack.pop()!
254    pids.push(pid)
255    stack.push(...(kids.get(pid) ?? []))
256  }
257  // Without names the probe's own sh and ps can't be told apart and are counted;
258  // they read ~0% CPU (just started) and ~2 MB, too little to matter on the bar
259  return {
260    cpu: pids.reduce((s, p) => s + (rows.get(p)?.[0] ?? 0), 0),
261    rssKb: pids.reduce((s, p) => s + (rows.get(p)?.[1] ?? 0), 0),
262    pids,
263  }
264}
265
266// Whether the Mac runs on battery: checked about once a minute (~9 ms of CPU)
267export const POWER_SOURCE_PROBE = ['/usr/bin/pmset', '-g', 'batt'] as const
268
269/** On battery or AC from `pmset -g batt`; null on a Mac that doesn't say (no battery). */
270export function parsePowerSource(stdout: string): boolean | null {
271  if (stdout.includes("'Battery Power'")) return true
272  if (stdout.includes("'AC Power'") || stdout.includes("'UPS Power'")) return false
273  return null
274}
275
276/** "0.42 W", "12 W" */
277export const watts = (w: number) => `${w >= 10 ? w.toFixed(0) : w.toFixed(2)} W`
278
types/index.d.ts 95 lines
1export type Proc = {
2  pid: number
3  ppid: number
4  depth: number
5  cpu: number
6  rssKb: number
7  elapsed: string
8  name: string
9  /** Average power over the last sample, in watts; null until two samples */
10  watts: number | null
11}
12
13/** Every Claude process tree on the machine, this session's included */
14export type AllClaude = { trees: number; cpu: number; rssKb: number; pids: number[] }
15
16export type Snapshot = {
17  rootPid: number
18  rootName: string
19  allClaude: AllClaude
20  cores: number
21  memBytes: number
22  load: number[]
23  cpu: number
24  rssKb: number
25  procs: Proc[]
26}
27
28/** Rates over the last sample, per second, for the whole tree */
29export type Io = {
30  /** Disk bytes read and written per second, and watts; null without rusage */
31  diskRead: number
32  diskWrite: number
33  watts: number
34  /** False before macOS 13, where per-process energy is not reported */
35  hasEnergy: boolean
36  /** Energy since the pane or bar started sampling, in joules */
37  joules: number
38}
39
40export type Net = { bytesIn: number; bytesOut: number }
41
42/** One prompt, from its submit to its turn's end */
43export type Turn = {
44  /** Clock time it started, in ms */
45  startedAt: number
46  seconds: number
47  /** Energy the session's tree used, in joules */
48  joules: number
49  /** CPU time the session's tree used, in seconds */
50  cpuSeconds: number
51  /** Highest CPU % sampled during it (100 = one core) */
52  peakCpu: number
53}
54
55export type Stats = {
56  snapshot: Snapshot | null
57  error: string | null
58  cpuHistory: number[]
59  ramHistory: number[]
60  /** null until two rusage samples; also null where rusage is unavailable */
61  io: Io | null
62  isRusageAvailable: boolean
63  /** null until two nettop samples */
64  net: Net | null
65  netHistory: number[]
66  diskHistory: number[]
67  energyHistory: number[]
68  /** Power of every Claude tree on the machine, in watts; null until two samples */
69  allWatts: number | null
70  /** Running totals for the session's tree since sampling began */
71  cpuSeconds: number
72  /** The prompt running now (its totals so far) and the last one that finished */
73  turn: Turn | null
74  lastTurn: Turn | null
75  /** Full battery in Wh; null on a Mac without one */
76  batteryWh: number | null
77  /** CPU seconds the monitor's own probes used, and since when (clock ms) */
78  ownCpuSeconds: number
79  ownSince: number | null
80  /** Whether the Mac is on battery; null until checked, or on a Mac without one */
81  isOnBattery: boolean | null
82  /** The session tree's energy while on battery since the last unplug (J), and since when (clock ms) */
83  batteryJoules: number
84  batterySince: number | null
85}
86
87/** How much the monitor itself may cost: lite every 10 s, normal 5 s, full 2 s */
88export type Mode = 'lite' | 'normal' | 'full'
89
90declare module 'claude-code' {
91  interface PluginState {
92    'session-stats': { stats: Stats; isBarOn: boolean; mode: Mode; isBarPromptOn: boolean }
93  }
94}
95