Shows CPU, RAM, GPU and GPU RAM usage and totals in the status line or a live pane, on macOS, Windows and Linux

Shows CPU, RAM, GPU and GPU RAM usage, with the totals behind them, in Claude Code's status line, refreshed every few seconds:
CPU 12% (8 cores) · RAM 48% (7.7/16.0 GB) · GPU 30% (1 GPU) · GPU RAM 21% (5.0/24.0 GB)
Cores are logical cores; GB are GiB, as the OS tools report them.
GPU fields appear only when a GPU is found.
The Claude apps (desktop, web, mobile) don't show a plugin's status line, so /sysmon also opens a live System monitor pane with a colored bar per value. Where the client draws nothing at all (a cloud session viewed in the app), /sysmon replies with the current reading instead.
| Command | |
|---|---|
/sysmon | Open the pane (and start sampling if it was off) |
/sysmon off | Stop sampling, clear the status line, close the pane |
/sysmon on | Start sampling again, status line only |
In a terminal, sampling starts with the session. In the desktop app, VS Code and on the web it starts the first time you run /sysmon. Either way, /sysmon on and /sysmon off hold for the rest of the session, reloads included. Nothing starts by itself under claude -p.
/plugin install sysmon --marketplace claudezss/claude-tools
| CPU | RAM | GPU / GPU RAM | |
|---|---|---|---|
| Linux | /proc/stat (busy time between samples) | /proc/meminfo (MemTotal − MemAvailable) | nvidia-smi; else AMD's /sys/class/drm/card0–card3 (the first with gpu_busy_percent; shown as one GPU) |
| macOS | top -l 2 -n 0 -s 1 | vm_stat, counted as Activity Monitor does (app + wired + compressed) | ioreg IOAccelerator stats. Apple silicon shares system memory, so GPU RAM is the GPU's share of it |
| Windows | Win32_Processor.LoadPercentage | Win32_OperatingSystem | nvidia-smi; else the GPU performance counters (busiest engine, as Task Manager shows it; dedicated memory against the adapter's size) |
On Windows one PowerShell process runs for the whole session and prints a sample per interval, so the monitor doesn't start a new PowerShell every few seconds. It works under ConstrainedLanguage mode.
Multiple NVIDIA GPUs show average utilization and pooled memory; on macOS with two GPUs, the busier one's utilization and the discrete GPU's VRAM.
nvidia-smi wakes a discrete GPU that would otherwise sleep, so sampling it every few seconds costs battery. Raise intervalSeconds, or run /sysmon off on battery.top (about a second, sampling every process), vm_stat and ioreg.| Option | Default | |
|---|---|---|
intervalSeconds | 5 | How often to sample (minimum 2) |
Set it from /config.
hooks/register.tsx 105 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Power, Usage } from '../types'
5import { bar, formatUsage, paneLayout, usageRows } from './parse'
6import { detectPlatform, startSampler, type Sampler } from './sources'
7
8// Shows CPU, RAM, GPU and GPU RAM usage in the status line (terminals) and,
9// on `/sysmon`, in a live pane (every surface, the Claude apps included).
10
11const PANE = 'sysmon'
12const USAGE_TEXT = 'Usage: /sysmon (open the pane), /sysmon on, /sysmon off'
13const usage = atom({ plugin: 'sysmon', key: 'usage' } as const, {} as Usage)
14// Kept in session state, not a module variable, so a reload honours it.
15const power = atom({ plugin: 'sysmon', key: 'power' } as const, 'unset' as Power)
16
17// The running monitor. Set before the platform is detected, so a stop that
18// lands meanwhile can tell the start it overtook not to go on.
19let current: { sampler?: Sampler } | undefined
20
21async function start($: EngineInterface, intervalMs: number) {
22 if (current) return
23 const mine: { sampler?: Sampler } = {}
24 current = mine
25 // A reading from before this start (an earlier run, a reload) is not current.
26 await update($, usage, () => ({}))
27 const platform = await detectPlatform($)
28 if (current !== mine) return
29 mine.sampler = startSampler($, platform, intervalMs, reading => {
30 $.ui.status(formatUsage(reading))
31 void update($, usage, () => reading).catch(() => {})
32 })
33}
34
35async function stop($: EngineInterface) {
36 current?.sampler?.stop()
37 current = undefined
38 $.ui.status(undefined)
39 await update($, usage, () => ({}))
40 await $.ui.close({ id: PANE })
41}
42
43export const register: Register = (on, options) => {
44 const intervalSeconds = Math.max(2, Number(options.intervalSeconds) || 5)
45
46 on('session.start', async ($, e, next) => {
47 await $.command.register({
48 name: 'sysmon',
49 description: 'Show CPU/RAM/GPU usage in a pane; "off" stops the monitor',
50 argumentHint: '[on|off]',
51 })
52 // Fires again on every reload: keep what the person chose. Untouched, start
53 // where there is a status line to keep (not `claude -p` or an SDK host).
54 const wanted = await read($, power)
55 if (wanted === 'on' || (wanted === 'unset' && e.isInteractive)) await start($, intervalSeconds * 1000)
56
57 return next(e)
58 })
59
60 on('command.run', { command: 'sysmon' }, async ($, e) => {
61 const arg = e.args.trim().toLowerCase()
62 if (arg !== '' && arg !== 'on' && arg !== 'off') return { text: USAGE_TEXT }
63
64 await update($, power, () => (arg === 'off' ? 'off' : 'on'))
65 if (arg === 'off') {
66 await stop($)
67 return { text: 'System monitor off.' }
68 }
69 await start($, intervalSeconds * 1000)
70 if (arg === 'on') return { text: 'System monitor on.' }
71
72 const opened = await $.ui.open({ id: PANE, title: 'System monitor' })
73 if (opened.isPlaced) return { text: 'System monitor pane opened.' }
74 // Nothing can draw the pane or the status line here (a client with no
75 // surface attached), so the reply carries the reading itself.
76 const reading = formatUsage(await read($, usage))
77 return {
78 text: reading
79 ? `${reading} (no pane or status line in this client; run /sysmon again to refresh)`
80 : 'System monitor on; the first reading takes a second or two. Run /sysmon again to see it.',
81 }
82 })
83
84 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
85 const { Box, Text } = $.ui.resolve(e)
86 const rows = usageRows(await read($, usage))
87 const { barWidth, showDetails } = paneLayout(rows, e.props.bodyColumns)
88
89 return (
90 <Box flexDirection="column">
91 {rows.length === 0 && <Text dimColor>Sampling…</Text>}
92 {rows.map(row => (
93 <Text key={row.label} mono>
94 {row.label.padEnd(8)}
95 <Text color={row.level}>{bar(row.value, barWidth)}</Text>
96 {` ${String(Math.round(row.value)).padStart(3)}%`}
97 {showDetails && row.detail && <Text dimColor>{` ${row.detail}`}</Text>}
98 </Text>
99 ))}
100 <Text dimColor>{`every ${intervalSeconds}s · /sysmon off to stop`}</Text>
101 </Box>
102 )
103 })
104}
105hooks/parse.ts 242 lines1// Pure parsers for each platform's tool output. No `$` here, so they can be
2// tested directly against captured output.
3
4import type { Usage } from '../types'
5
6export type { Usage }
7
8type Ram = Pick<Usage, 'ram' | 'ramUsed' | 'ramTotal'>
9type Gpu = Pick<Usage, 'gpu' | 'gpuCount' | 'gpuRam' | 'gpuRamUsed' | 'gpuRamTotal'>
10
11/** Cumulative CPU time from the first line of Linux's /proc/stat. */
12export type CpuTimes = { idle: number; total: number }
13
14const KIB = 1024
15const MIB = 1024 * 1024
16
17const percent = (part: number, whole: number) =>
18 whole > 0 && Number.isFinite(part) ? Math.min(100, Math.max(0, (part / whole) * 100)) : undefined
19
20const num = (text: string | undefined) => {
21 const value = Number.parseFloat(text ?? '')
22 return Number.isFinite(value) ? value : undefined
23}
24
25export const ramOf = (used: number, total: number): Ram =>
26 total > 0 && Number.isFinite(used) ? { ram: percent(used, total), ramUsed: used, ramTotal: total } : {}
27
28export const gpuRamOf = (used: number, total: number): Gpu =>
29 total > 0 && Number.isFinite(used) ? { gpuRam: percent(used, total), gpuRamUsed: used, gpuRamTotal: total } : {}
30
31// --- Linux -----------------------------------------------------------------
32
33export function parseProcStat(text: string): CpuTimes | undefined {
34 const line = text.split('\n').find(l => l.startsWith('cpu '))
35 if (!line) return undefined
36 // user nice system idle iowait irq softirq steal (guest is already in user)
37 const fields = line.trim().split(/\s+/).slice(1, 9).map(Number)
38 if (fields.length < 4 || fields.some(n => !Number.isFinite(n))) return undefined
39 const [, , , idle = 0, iowait = 0] = fields
40 return { idle: idle + iowait, total: fields.reduce((a, b) => a + b, 0) }
41}
42
43/** Logical cores: the per-core `cpuN` lines of /proc/stat. */
44export function countProcStatCpus(text: string): number | undefined {
45 const count = text.match(/^cpu\d+\s/gm)?.length ?? 0
46 return count > 0 ? count : undefined
47}
48
49export function cpuBetween(before: CpuTimes, after: CpuTimes): number | undefined {
50 const total = after.total - before.total
51 return total > 0 ? percent(total - (after.idle - before.idle), total) : undefined
52}
53
54export function parseMeminfo(text: string): Ram {
55 const field = (name: string) => num(text.match(new RegExp(`^${name}:\\s+(\\d+)`, 'm'))?.[1])
56 const total = field('MemTotal')
57 const available = field('MemAvailable')
58 return total === undefined || available === undefined ? {} : ramOf((total - available) * KIB, total * KIB)
59}
60
61// --- NVIDIA (every platform) -------------------------------------------------
62
63/** Lines of `nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv,noheader,nounits`. */
64export function parseNvidiaSmi(text: string): Gpu | undefined {
65 const gpus = text
66 .split('\n')
67 .map(line => line.split(',').map(cell => num(cell.trim())))
68 .filter(cells => cells.length === 3)
69 if (gpus.length === 0) return undefined
70
71 const utils = gpus.map(([util]) => util).filter((u): u is number => u !== undefined)
72 let used = 0
73 let total = 0
74 for (const [, u, t] of gpus) {
75 if (u !== undefined && t !== undefined) {
76 used += u
77 total += t
78 }
79 }
80 return {
81 gpu: utils.length > 0 ? utils.reduce((a, b) => a + b, 0) / utils.length : undefined,
82 gpuCount: gpus.length,
83 ...gpuRamOf(used * MIB, total * MIB),
84 }
85}
86
87// --- macOS -------------------------------------------------------------------
88
89/** The CPU line of the last sample in `top -l 2 -n 0 -s 1` (the first sample is unreliable). */
90export function parseMacTopCpu(text: string): number | undefined {
91 const idles = [...text.matchAll(/CPU usage:.*?([\d.,]+)% idle/g)]
92 const idle = num(idles.at(-1)?.[1]?.replace(',', '.'))
93 return idle === undefined ? undefined : Math.min(100, Math.max(0, 100 - idle))
94}
95
96/** `sysctl -n hw.memsize hw.logicalcpu`: one value per line. */
97export function parseMacSysctl(text: string): { memBytes?: number; cpuCores?: number } {
98 const [memBytes, cpuCores] = text.trim().split('\n').map(line => num(line.trim()))
99 return { memBytes, cpuCores }
100}
101
102/**
103 * Memory used as Activity Monitor counts it (app + wired + compressed), from
104 * `vm_stat` and the machine's memory size.
105 */
106export function parseVmStat(text: string, memBytes: number): Ram {
107 const pageSize = num(text.match(/page size of (\d+) bytes/)?.[1])
108 const pages = (name: string) => num(text.match(new RegExp(`^${name}:\\s+(\\d+)`, 'm'))?.[1])
109 const wired = pages('Pages wired down')
110 const compressed = pages('Pages occupied by compressor') ?? 0
111 const anonymous = pages('Anonymous pages')
112 const app = anonymous !== undefined ? anonymous - (pages('Pages purgeable') ?? 0) : pages('Pages active')
113 if (pageSize === undefined || wired === undefined || app === undefined) return {}
114 return ramOf((app + wired + compressed) * pageSize, memBytes)
115}
116
117/**
118 * GPU use from `ioreg -r -d 1 -w 0 -c IOAccelerator`, one statistics block per
119 * GPU. Discrete GPUs report their own VRAM; Apple silicon shares system
120 * memory, so there GPU RAM is the GPU's share of `memBytes`.
121 */
122export function parseIoreg(text: string, memBytes: number): Gpu | undefined {
123 const blocks = [...text.matchAll(/"PerformanceStatistics" = \{([^}]*)\}/g)].map(match => {
124 const stat = (name: string) => num(match[1]!.match(new RegExp(`"${name}"=(\\d+)`))?.[1])
125 return {
126 util: stat('Device Utilization %') ?? stat('GPU Activity\\(%\\)'),
127 vramUsed: stat('vramUsedBytes'),
128 vramFree: stat('vramFreeBytes'),
129 shared: stat('In use system memory'),
130 }
131 })
132 if (blocks.length === 0) return undefined
133
134 const utils = blocks.map(b => b.util).filter((u): u is number => u !== undefined)
135 const discrete = blocks.filter(b => b.vramUsed !== undefined && b.vramFree !== undefined)
136 const shared = blocks.filter(b => b.shared !== undefined)
137 const memory =
138 discrete.length > 0
139 ? gpuRamOf(
140 discrete.reduce((sum, b) => sum + b.vramUsed!, 0),
141 discrete.reduce((sum, b) => sum + b.vramUsed! + b.vramFree!, 0),
142 )
143 : shared.length > 0
144 ? gpuRamOf(
145 shared.reduce((sum, b) => sum + b.shared!, 0),
146 memBytes,
147 )
148 : {}
149 return { gpu: utils.length > 0 ? Math.max(...utils) : undefined, gpuCount: blocks.length, ...memory }
150}
151
152// --- Windows -----------------------------------------------------------------
153
154/** One JSON line written by the PowerShell sampler in sources.ts. */
155export function parseWindowsSample(line: string): Usage | undefined {
156 let sample: Record<string, unknown>
157 try {
158 sample = JSON.parse(line)
159 } catch {
160 return undefined
161 }
162 if (typeof sample !== 'object' || sample === null) return undefined
163 const field = (key: string) => (typeof sample[key] === 'number' ? (sample[key] as number) : undefined)
164
165 const memTotal = field('memTotalKb')
166 const memFree = field('memFreeKb')
167 const usage: Usage = {
168 cpu: field('cpu'),
169 cpuCores: field('cores'),
170 ...(memTotal !== undefined && memFree !== undefined ? ramOf((memTotal - memFree) * KIB, memTotal * KIB) : {}),
171 }
172
173 const nvidia = typeof sample.nvidiaSmi === 'string' ? parseNvidiaSmi(sample.nvidiaSmi) : undefined
174 if (nvidia) return { ...usage, ...nvidia }
175
176 const gpu = field('gpuUtil')
177 const used = field('gpuMemUsed')
178 const total = field('gpuMemTotal')
179 return {
180 ...usage,
181 gpu: gpu === undefined ? undefined : Math.min(100, gpu),
182 gpuCount: field('gpuCount'),
183 ...(used !== undefined && total !== undefined ? gpuRamOf(used, total) : {}),
184 }
185}
186
187// --- Display -----------------------------------------------------------------
188
189export type Row = { label: string; value: number; level: 'success' | 'warning' | 'error'; detail?: string }
190
191/** Bytes as GiB, one decimal under 100: `15.7`, `128`. */
192export function gib(bytes: number): string {
193 const value = bytes / 1024 ** 3
194 return value < 100 ? value.toFixed(1) : value.toFixed(0)
195}
196
197const plural = (count: number | undefined, one: string, many: string) =>
198 count === undefined ? undefined : `${count} ${count === 1 ? one : many}`
199const usedOfTotal = (used: number | undefined, total: number | undefined) =>
200 used === undefined || total === undefined ? undefined : `${gib(used)}/${gib(total)} GB`
201
202/** The reported values, in display order, with a level for coloring and the totals behind them. */
203export function usageRows(usage: Usage): Row[] {
204 const fields: [string, number | undefined, string | undefined][] = [
205 ['CPU', usage.cpu, plural(usage.cpuCores, 'core', 'cores')],
206 ['RAM', usage.ram, usedOfTotal(usage.ramUsed, usage.ramTotal)],
207 ['GPU', usage.gpu, plural(usage.gpuCount, 'GPU', 'GPUs')],
208 ['GPU RAM', usage.gpuRam, usedOfTotal(usage.gpuRamUsed, usage.gpuRamTotal)],
209 ]
210 return fields.flatMap(([label, value, detail]) =>
211 value === undefined || !Number.isFinite(value)
212 ? []
213 : [{ label, value, level: value >= 90 ? 'error' : value >= 70 ? 'warning' : 'success', ...(detail ? { detail } : {}) } as const],
214 )
215}
216
217/** The status line: `CPU 12% (8 cores) · RAM 48% (7.7/16.0 GB) · GPU 30% (1 GPU) · GPU RAM 21% (5.0/24.0 GB)`. */
218export function formatUsage(usage: Usage): string | undefined {
219 const parts = usageRows(usage).map(
220 ({ label, value, detail }) => `${label} ${Math.round(value)}%${detail ? ` (${detail})` : ''}`,
221 )
222 return parts.length > 0 ? parts.join(' · ') : undefined
223}
224
225/**
226 * How a pane row fits `columns`: the label (8), the bar, " 100%" (5), and the
227 * details (" 12.0/16.0 GB") only while they leave the bar at least 8 cells.
228 */
229export function paneLayout(rows: Row[], columns: number): { barWidth: number; showDetails: boolean } {
230 const detailWidth = Math.max(0, ...rows.map(row => (row.detail ? row.detail.length + 2 : 0)))
231 const room = columns - 13
232 const showDetails = room - detailWidth >= 8
233 return { barWidth: Math.max(4, Math.min(30, showDetails ? room - detailWidth : room)), showDetails }
234}
235
236/** A bar `width` cells wide, filled in eighths: `█████▍ `. */
237export function bar(value: number, width: number): string {
238 const eighths = Math.round((Math.min(100, Math.max(0, value)) / 100) * width * 8)
239 const partial = eighths % 8 === 0 ? '' : ' ▏▎▍▌▋▊▉'[eighths % 8]!
240 return ('█'.repeat(Math.floor(eighths / 8)) + partial).padEnd(width, ' ')
241}
242hooks/sources.ts 228 lines1// Per-platform samplers: each reads usage every `intervalMs` and hands it to
2// `onUsage`. Linux and macOS poll; Windows keeps one PowerShell loop running,
3// since starting PowerShell per sample would cost more CPU than it measures.
4
5import type { EngineInterface } from 'claude-code'
6
7import {
8 countProcStatCpus,
9 cpuBetween,
10 gpuRamOf,
11 parseIoreg,
12 parseMacSysctl,
13 parseMacTopCpu,
14 parseMeminfo,
15 parseNvidiaSmi,
16 parseProcStat,
17 parseVmStat,
18 parseWindowsSample,
19 type CpuTimes,
20 type Usage,
21} from './parse'
22
23export type Platform = 'linux' | 'macos' | 'windows'
24export type Sampler = { stop: () => void }
25type OnUsage = (usage: Usage) => void
26
27const NVIDIA_SMI = ['nvidia-smi', '--query-gpu=utilization.gpu,memory.used,memory.total', '--format=csv,noheader,nounits']
28const AMD_CARDS = ['card0', 'card1', 'card2', 'card3']
29
30export async function detectPlatform($: EngineInterface): Promise<Platform> {
31 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows'
32 const uname = await $.process.run(['uname', '-s']).catch(() => undefined)
33 return uname?.stdout.trim() === 'Darwin' ? 'macos' : 'linux'
34}
35
36export function startSampler($: EngineInterface, platform: Platform, intervalMs: number, onUsage: OnUsage): Sampler {
37 return platform === 'windows' ? streamWindows($, intervalMs, onUsage) : poll(intervalMs, sampleUnix($, platform), onUsage, $)
38}
39
40/** Runs `sample` now and every `intervalMs`, skipping a tick while one is still running. */
41function poll(intervalMs: number, sample: Promise<() => Promise<Usage>>, onUsage: OnUsage, $: EngineInterface): Sampler {
42 let isStopped = false
43 let isBusy = false
44 const tick = async () => {
45 if (isStopped || isBusy) return
46 isBusy = true
47 try {
48 const usage = await (await sample)()
49 if (!isStopped) onUsage(usage)
50 } finally {
51 isBusy = false
52 }
53 }
54 // A tick still running when the module unloads rejects; nothing is left to tell.
55 const safeTick = () => void tick().catch(() => {})
56 safeTick()
57 // Linux CPU is a difference between two readings, so take the second soon.
58 const first = $.clock.after(1000, safeTick)
59 const timer = $.clock.every(intervalMs, safeTick)
60 return {
61 stop: () => {
62 isStopped = true
63 first.cancel()
64 timer.cancel()
65 },
66 }
67}
68
69async function run($: EngineInterface, argv: readonly string[]): Promise<string | undefined> {
70 // The C locale keeps `top` from writing decimal commas ("95,3% idle").
71 const result = await $.process.run(argv, { timeoutMs: 10_000, env: { LC_ALL: 'C' } }).catch(() => undefined)
72 return result?.exitCode === 0 ? result.stdout : undefined
73}
74
75async function read($: EngineInterface, path: string): Promise<string | undefined> {
76 const text = await $.fs.read(path).catch(() => undefined)
77 return typeof text === 'string' ? text : undefined
78}
79
80/** Finds the GPU source once, then returns the per-sample reader. */
81async function sampleUnix($: EngineInterface, platform: 'linux' | 'macos'): Promise<() => Promise<Usage>> {
82 const hasNvidia = (await run($, NVIDIA_SMI)) !== undefined
83 const nvidia = async () => parseNvidiaSmi((await run($, NVIDIA_SMI)) ?? '')
84
85 if (platform === 'macos') {
86 const { memBytes = Number.NaN, cpuCores } = parseMacSysctl((await run($, ['sysctl', '-n', 'hw.memsize', 'hw.logicalcpu'])) ?? '')
87 return async () => {
88 const [top, vmStat, gpu] = await Promise.all([
89 run($, ['top', '-l', '2', '-n', '0', '-s', '1']),
90 run($, ['vm_stat']),
91 hasNvidia ? nvidia() : run($, ['ioreg', '-r', '-d', '1', '-w', '0', '-c', 'IOAccelerator']).then(text => parseIoreg(text ?? '', memBytes)),
92 ])
93 return { cpu: parseMacTopCpu(top ?? ''), cpuCores, ...parseVmStat(vmStat ?? '', memBytes), ...gpu }
94 }
95 }
96
97 let amdCard: string | undefined
98 if (!hasNvidia) {
99 for (const card of AMD_CARDS) {
100 const busy = await read($, `/sys/class/drm/${card}/device/gpu_busy_percent`)
101 if (busy !== undefined && /^\s*\d+\s*$/.test(busy)) {
102 amdCard = card
103 break
104 }
105 }
106 }
107 const amd = async (card: string): Promise<Usage> => {
108 const base = `/sys/class/drm/${card}/device`
109 const [busy, used, total] = await Promise.all(
110 ['gpu_busy_percent', 'mem_info_vram_used', 'mem_info_vram_total'].map(file => read($, `${base}/${file}`)),
111 )
112 const vramUsed = Number(used)
113 const vramTotal = Number(total)
114 const gpu = Number(busy)
115 return {
116 gpu: busy !== undefined && Number.isFinite(gpu) ? gpu : undefined,
117 gpuCount: 1,
118 ...gpuRamOf(vramUsed, vramTotal),
119 }
120 }
121
122 let previous: CpuTimes | undefined
123 return async () => {
124 const [stat, meminfo, gpu] = await Promise.all([
125 read($, '/proc/stat'),
126 read($, '/proc/meminfo'),
127 hasNvidia ? nvidia() : amdCard ? amd(amdCard) : undefined,
128 ])
129 const times = parseProcStat(stat ?? '')
130 const cpu = previous && times ? cpuBetween(previous, times) : undefined
131 previous = times ?? previous
132 return { cpu, cpuCores: countProcStatCpus(stat ?? ''), ...parseMeminfo(meminfo ?? ''), ...gpu }
133 }
134}
135
136/**
137 * The PowerShell loop: one compressed JSON object per line. NVIDIA cards go
138 * through nvidia-smi; any other GPU through the GPU performance counters
139 * (Task Manager's: the busiest engine, and dedicated memory against the
140 * adapter's size in the registry).
141 */
142export function windowsScript(intervalSeconds: number): string {
143 return `
144$ErrorActionPreference = 'SilentlyContinue'
145$nvidia = [bool](Get-Command nvidia-smi)
146$cores = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
147$adapters = @(Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\0*' |
148 ForEach-Object { $_.'HardwareInformation.qwMemorySize' } | Where-Object { $_ })
149$vram = ($adapters | Measure-Object -Maximum).Maximum
150while ($true) {
151 # One statement per value: a query that fails leaves only its own value out.
152 $cpu = (Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average
153 $os = Get-CimInstance Win32_OperatingSystem
154 $s = [ordered]@{ cpu = $cpu; cores = $cores; memTotalKb = $os.TotalVisibleMemorySize; memFreeKb = $os.FreePhysicalMemory }
155 if ($nvidia) {
156 $s.nvidiaSmi = (& ${NVIDIA_SMI.join(' ')}) -join "\`n"
157 } else {
158 $engines = Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine
159 if ($engines) {
160 $s.gpuUtil = ($engines | Group-Object { $_.Name -replace '^pid_\\d+_', '' } |
161 ForEach-Object { ($_.Group | Measure-Object UtilizationPercentage -Sum).Sum } | Measure-Object -Maximum).Maximum
162 $s.gpuMemUsed = (Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUAdapterMemory |
163 Measure-Object DedicatedUsage -Maximum).Maximum
164 if ($vram) { $s.gpuMemTotal = $vram; $s.gpuCount = $adapters.Count }
165 }
166 }
167 # Plain pipeline output: [Console]::Out is refused under ConstrainedLanguage mode.
168 $s | ConvertTo-Json -Compress
169 Start-Sleep -Seconds ${intervalSeconds}
170}
171`
172}
173
174/** PowerShell's -EncodedCommand: base64 of the script as UTF-16LE, so no quoting survives the command line. */
175export function encodePowerShell(script: string): string {
176 let bytes = ''
177 for (const char of script) {
178 const code = char.charCodeAt(0)
179 bytes += String.fromCharCode(code & 0xff, code >> 8)
180 }
181 return btoa(bytes)
182}
183
184function streamWindows($: EngineInterface, intervalMs: number, onUsage: OnUsage): Sampler {
185 let isStopped = false
186 let stream: ReturnType<EngineInterface['process']['spawn']> | undefined
187 let retry: { cancel: () => void } | undefined
188 const argv = [
189 'powershell',
190 '-NoProfile',
191 '-NonInteractive',
192 '-EncodedCommand',
193 encodePowerShell(windowsScript(Math.max(1, Math.round(intervalMs / 1000)))),
194 ]
195
196 const start = async () => {
197 if (isStopped) return
198 let buffer = ''
199 try {
200 stream = $.process.spawn({ argv })
201 for await (const { stream: from, text } of stream) {
202 if (isStopped) break
203 if (from !== 'stdout') continue
204 buffer += text
205 const lines = buffer.split('\n')
206 buffer = lines.pop() ?? ''
207 for (const line of lines) {
208 const usage = parseWindowsSample(line.trim())
209 if (usage) onUsage(usage)
210 }
211 }
212 } catch {
213 // PowerShell couldn't start or died; retried below.
214 }
215 if (!isStopped) retry = $.clock.after(30_000, () => void start())
216 }
217 void start()
218
219 return {
220 stop: () => {
221 isStopped = true
222 retry?.cancel()
223 // Ends the loop, which kills PowerShell; the isStopped check covers a pending read.
224 void stream?.return?.({ code: null, signal: null })
225 },
226 }
227}
228types/index.d.ts 28 lines1/** One reading; a field is absent when the platform didn't report it. Sizes are bytes. */
2export type Usage = {
3 /** Percent busy across all logical cores. */
4 cpu?: number
5 /** Logical cores. */
6 cpuCores?: number
7 /** Percent of RAM in use. */
8 ram?: number
9 ramUsed?: number
10 ramTotal?: number
11 /** Percent busy: the average across GPUs, or the busiest engine. */
12 gpu?: number
13 gpuCount?: number
14 /** Percent of GPU memory in use (on Apple silicon, the GPU's share of system memory). */
15 gpuRam?: number
16 gpuRamUsed?: number
17 gpuRamTotal?: number
18}
19
20/** What the person last asked for with /sysmon; `unset` until they do. Survives a reload. */
21export type Power = 'on' | 'off' | 'unset'
22
23declare module 'claude-code' {
24 interface PluginState {
25 sysmon: { usage: Usage; power: Power }
26 }
27}
28