SLOPSHOPPER

claude-load

See which Claude Code session eats your CPU and memory: per-session load, orphaned dev processes and everything else, with graphs, filters and one-click…

newpanebandcommandtoastprocess
★ 2v1.0.0MITupdated 2026-10-23Gerrux/claude-load
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-load
│ ┃ claude-load ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting for the first sample… │ ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · claude-load
Waiting for the first sample…
README

English · Русский · Deutsch · Español · Français · Português · 中文 · 日本語

claude-load

See which Claude Code session eats your CPU and memory — and clean it up in one click.

License: MIT Platforms Languages Claude Code mod

<img src="docs/screenshots/pane.png" alt="The claude-load pane in the Claude desktop app: CPU and RAM meters with graphs, sessions with their load, an orphaned dev server and other processes" width="560">

<img src="docs/screenshots/band.png" alt="The load line above the prompt in the Claude desktop app: a CPU graph, CPU and RAM, the top consumers and a /load button" width="760">

<img src="docs/screenshots/terminal.png" alt="The /load report and the load line above the prompt in a terminal" width="860">

<sub>Rendered from demo data in the mod's own design.</sub>

Install

One command; pick your shell. It adds this repository as a plugin marketplace and installs the mod for your user, so it loads in every new session, in the terminal and in the Claude desktop app alike.

macOS · Linux · Git Bash on Windows

claude plugin marketplace add Gerrux/claude-load && claude plugin install claude-load@claude-load --config language=auto

Windows — PowerShell (5.1 and 7)

claude plugin marketplace add Gerrux/claude-load; if ($?) { claude plugin install claude-load@claude-load --config language=auto }

Windows — Command Prompt (cmd)

claude plugin marketplace add Gerrux/claude-load && claude plugin install claude-load@claude-load --config language=auto

Claude desktop app only (no claude command in your terminal): click + next to the prompt box → Plugins → Add marketplace → Gerrux/claude-load → Add plugin → claude-load.

Inside a Claude Code terminal session:

/plugin marketplace add Gerrux/claude-load
/plugin install claude-load@claude-load

Then, in a session that was already open, run /reload-plugins and /load.

RequiresClaude Code with mods support, 2.1.287 or later (claude --version); Windows 10/11, macOS or Linux
Language--config language=auto follows Claude Code's language, then the system locale. Pin one with en, ru, de, es, fr, pt, zh or ja, or change it later in /config
Updateclaude plugin marketplace update claude-load then claude plugin update claude-load@claude-load, then /reload-plugins
Removeclaude plugin uninstall claude-load@claude-load (and claude plugin marketplace remove claude-load)

Why

Run a few Claude Code sessions in parallel, each with subagents, MCP servers, dev servers and test runners, and the machine crawls while Task Manager shows a wall of node and claude. claude-load splits the load by session:

📊 Per sessionCPU, memory, process count, the heaviest processes inside, a 6-minute CPU graph
⚠️ Orphansdev processes whose parent died (a vite server, a language server, a Python run left behind), placed in their repository or worktree by working directory, ended with one click after a confirmation
🧩 Everything elseother processes folded by name (chrome ×31), so you see whether the problem is Claude at all
🔎 Filters & groupsall · active · heavy · orphans · this window; by session, by repository (worktrees folded in), by process name; sort by CPU or memory
✨ Sort it out with Claudeone button: the bundled sessions-triage skill classifies idle sessions and orphans, asks once, archives or ends only what you approve
🚦 Load lineabove the prompt under high load (CPU ≥ 50% or memory ≥ 85%), with the top consumers
🌍 8 languagesEnglish, Русский, Deutsch, Español, Français, Português, 中文, 日本語

Use

CommandWhat it does
/loadOpens the pane and prints a compact report in the transcript
/load allThe same, with every idle session listed
/load band on · off · autoThe load line above the prompt: always, never, or only under high load (default)

In the pane:

  • processes expands a session into its processes by name and its heavy PIDs.
  • end on a process or an orphan ends it with its child processes after Yes, end it. The PID is checked again against a fresh sample under the same name first. Sessions' own claude processes and PIDs not in the current sample cannot be ended from here.
  • open ↗ switches to that session in the Claude desktop app.
  • ✨ Sort it out with Claude asks Claude in the current session to triage the load. Archiving sessions needs the desktop app's session tools; in a terminal Claude tells you what to close instead.

Filter, grouping and sorting are remembered across sessions.

Language: the plugin's language option (/config) takes auto, en, ru, de, es, fr, pt, zh or ja. auto follows Claude Code's language setting, then the system locale, then English.

How it works

One sampler per machine writes ~/.claude/state/load/snapshot.json; each session's mod only reads it every 5 seconds, so the cost does not grow with the number of sessions.

  • Windows: a hidden PowerShell process (sampler/load-sampler.ps1) started by the mod. It reads Get-Process every 4 seconds and the parent tree from CIM only when new processes appear, and exits 3 minutes after the last session closes. Measured on a 12-thread machine with 15 sessions: about 0.35% CPU and 130 MB of memory.
  • macOS and Linux: one session's mod holds a lease and samples with ps; another session takes over within 15 seconds if it goes away.

A session is a PID from ~/.claude/sessions/<pid>.json, and its load is its whole process tree. An orphan is a process tree whose parent died (on macOS and Linux: reparented to init, systemd or launchd) and that contains a dev tool. Its working directory comes from the process itself (Windows reads it from the process's memory, Linux from /proc/<pid>/cwd, macOS from lsof).

Privacy: everything stays on your machine. No network calls, no telemetry.

Troubleshooting

  • "The sampler does not start" (Windows): PowerShell scripts are blocked by policy (AllSigned or Constrained Language Mode). The sampler needs to run sampler/load-sampler.ps1 from the plugin folder.
  • Nothing shows on macOS/Linux: check that ps is on PATH; lsof (macOS) is needed only for orphans.

Status

Developed and used daily on Windows 11 with the Claude desktop app. The macOS and Linux sampler is covered by unit tests on recorded ps, vm_stat, /proc/meminfo and lsof output but has not yet run on a real Mac or Linux box. Reports and fixes are welcome.

Develop

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

Screenshots are rendered from docs/mock/index.html; see docs/mock/README.md.

License

MIT

Source 4 files
hooks/register.tsx 1004 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { STRINGS, fmtMem as fmtMemIn, lead, pickLang, plural } from './i18n'
4import type { Lang } from './i18n'
5import { buildSnapshot, freshMemory, orphanCandidates, parseLsofCwd, parseMeminfo, parsePs, parseSessionFile, parseVmStat } from './posix'
6import type { SamplerMemory, SessionMeta } from './posix'
7import type { ByName, OrphanLoad, OutsideLoad, Snapshot, Top } from './types'
8
9export type { Snapshot } from './types'
10
11// claude-load: CPU and memory per Claude Code session.
12//
13// One sampler per machine writes <home>/.claude/state/load/snapshot.json; every session's mod
14// reads it every 5 s. Windows: a hidden PowerShell process (sampler/load-sampler.ps1) that the
15// mod starts when the snapshot goes stale and that exits once no session marks itself `wanted`.
16// macOS and Linux: one session's mod is the leader (a lease file) and samples with `ps` itself.
17
18const POLL_MS = 5000
19const WANTED_EVERY_MS = 30000
20const STALE_MS = 15000
21const RESPAWN_MS = 30000
22const LEADER_STALE_MS = 15000
23const MIN_SAMPLE_MS = POLL_MS - 1000
24const PANE = 'claude-load'
25const KEY_BAND = 'band'
26const KEY_VIEW = 'view'
27const TEXT_IDLE_ROWS = 5
28const TRIAGE_PROMPT =
29  'Sort out the machine load and idle Claude Code sessions with the claude-load:sessions-triage skill: read the claude-load snapshot ' +
30  '(~/.claude/state/load/snapshot.json) and the session list, ask me once what to archive and which orphaned processes to end, ' +
31  'and do only what I approve. Answer in my language.'
32
33export type BandMode = 'auto' | 'on' | 'off'
34export type Filter = 'all' | 'active' | 'heavy' | 'orphans' | 'self'
35export type Group = 'sessions' | 'repos' | 'procs'
36export type Sort = 'cpu' | 'mem'
37type Platform = 'win32' | 'darwin' | 'linux' | 'other'
38
39// The language is set again by session.start on every (re)load.
40let lang: Lang = 'en'
41const tr = () => STRINGS[lang]
42
43export function setLang(next: Lang) {
44  lang = next
45}
46
47// --- formatting --------------------------------------------------------
48
49export function fmtMem(mb: number): string {
50  return fmtMemIn(lang, mb)
51}
52
53const pct = (x: number) => `${Math.round(x)}%`
54
55function short(name: string, max = 32): string {
56  const n = name || tr().untitled
57  return n.length > max ? n.slice(0, Math.max(1, max - 1)) + '…' : n
58}
59
60/** The Claude Desktop app's own processes are named `claude`, like the sessions: label them plainly. */
61export function outsideName(name: string): string {
62  return name.toLowerCase() === 'claude' ? 'Claude Desktop' : name
63}
64
65const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
66
67/** A bar of blocks in eighths of a cell: [filled, empty]. */
68export function bar(frac: number, width: number): [string, string] {
69  const f = Math.max(0, Math.min(1, frac)) * width
70  const full = Math.floor(f)
71  const part = full < width ? EIGHTHS[Math.floor((f - full) * 8)] : ''
72  return ['█'.repeat(full) + part, '░'.repeat(width - full - (part ? 1 : 0))]
73}
74
75const SPARKS = '▁▂▃▄▅▆▇█'
76
77/** A text sparkline: the last `width` points, scaled to their maximum (at least `floor`). */
78export function sparkText(vals: number[], width: number, floor = 5): string {
79  const v = vals.slice(-width)
80  if (!v.length) return ' '.repeat(width)
81  const m = Math.max(floor, ...v)
82  return ' '.repeat(width - v.length) + v.map(x => SPARKS[Math.min(7, Math.floor((x / m) * 7.999))]).join('')
83}
84
85type Tone = 'error' | 'warning' | 'success' | 'inactive'
86const HEX: Record<Tone, string> = { error: '#f85149', warning: '#d29922', success: '#3fb950', inactive: '#8b949e' }
87
88export function cpuTone(cpu: number): Tone {
89  return cpu >= 30 ? 'error' : cpu >= 10 ? 'warning' : cpu >= 1 ? 'success' : 'inactive'
90}
91
92function machineTone(frac: number): Tone {
93  return frac >= 0.85 ? 'error' : frac >= 0.6 ? 'warning' : 'success'
94}
95
96function memTone(mb: number): 'error' | 'warning' | undefined {
97  return mb >= 4096 ? 'error' : mb >= 2048 ? 'warning' : undefined
98}
99
100/** An SVG sparkline with the area under it filled. */
101export function sparkSvg(vals: number[], w: number, h: number, color: string, floor = 5): string {
102  const v = vals.length >= 2 ? vals : [vals[0] ?? 0, vals[0] ?? 0]
103  const m = Math.max(floor, ...v)
104  const step = w / (v.length - 1)
105  const pts = v.map((x, i) => `${(i * step).toFixed(1)},${(h - 1 - (x / m) * (h - 3)).toFixed(1)}`).join(' ')
106  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
107    `<polygon points="0,${h} ${pts} ${w},${h}" fill="${color}" fill-opacity="0.18"/>` +
108    `<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/></svg>`
109}
110
111/** An SVG bar: a rounded track and its fill. */
112export function barSvg(frac: number, w: number, h: number, color: string): string {
113  const f = Math.max(0, Math.min(1, frac))
114  const r = h / 2
115  const fill = f > 0 ? `<rect x="0" y="0" width="${Math.max(h, f * w).toFixed(1)}" height="${h}" rx="${r}" fill="${color}"/>` : ''
116  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
117    `<rect x="0" y="0" width="${w}" height="${h}" rx="${r}" fill="#8b949e" fill-opacity="0.22"/>${fill}</svg>`
118}
119
120type IconKind = 'busy' | 'working' | 'idle' | 'orphan' | 'repo' | 'proc' | 'machine'
121const ICON_TEXT: Record<IconKind, string> = { busy: '●', working: '◐', idle: '○', orphan: '⚠', repo: '▣', proc: '⚙', machine: '◆' }
122
123export function iconSvg(kind: IconKind): string {
124  const wrap = (body: string) => `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14">${body}</svg>`
125  switch (kind) {
126    case 'busy': return wrap(`<circle cx="7" cy="7" r="6" fill="${HEX.success}" fill-opacity="0.25"/><circle cx="7" cy="7" r="3.5" fill="${HEX.success}"/>`)
127    case 'working': return wrap(`<circle cx="7" cy="7" r="6" fill="${HEX.warning}" fill-opacity="0.2"/><path d="M7 3.5 A3.5 3.5 0 0 1 7 10.5 Z" fill="${HEX.warning}"/><circle cx="7" cy="7" r="3.5" fill="none" stroke="${HEX.warning}" stroke-width="1.2"/>`)
128    case 'idle': return wrap(`<circle cx="7" cy="7" r="4" fill="none" stroke="${HEX.inactive}" stroke-width="1.5"/>`)
129    case 'orphan': return wrap(`<path d="M7 1.5 L13 12.5 H1 Z" fill="${HEX.warning}"/><rect x="6.3" y="5" width="1.4" height="4" fill="#1b1b1b"/><rect x="6.3" y="10" width="1.4" height="1.4" fill="#1b1b1b"/>`)
130    case 'repo': return wrap(`<path d="M1.5 3.5 H5.5 L7 5 H12.5 V11.5 H1.5 Z" fill="none" stroke="${HEX.inactive}" stroke-width="1.3"/>`)
131    case 'proc': return wrap(`<circle cx="7" cy="7" r="2.2" fill="none" stroke="${HEX.inactive}" stroke-width="1.3"/><path d="M7 1.5 V3.5 M7 10.5 V12.5 M1.5 7 H3.5 M10.5 7 H12.5 M3.1 3.1 L4.5 4.5 M9.5 9.5 L10.9 10.9 M3.1 10.9 L4.5 9.5 M9.5 4.5 L10.9 3.1" stroke="${HEX.inactive}" stroke-width="1.3"/>`)
132    case 'machine': return wrap(`<rect x="1.5" y="2.5" width="11" height="7" rx="1" fill="none" stroke="${HEX.inactive}" stroke-width="1.3"/><path d="M4.5 12 H9.5 M7 9.5 V12" stroke="${HEX.inactive}" stroke-width="1.3"/>`)
133  }
134}
135
136function norm(p: string): string {
137  return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
138}
139
140/** A session's repository root: a worktree `.claude/worktrees/x` folds into its main checkout. */
141export function repoRoot(cwd: string): { root: string; label: string } {
142  const m = /^(.*?)[\\/]\.claude[\\/]worktrees[\\/]/i.exec(cwd)
143  const root = (m ? m[1] : cwd).replace(/[\\/]+$/, '')
144  return { root, label: root.split(/[\\/]/).pop() || root }
145}
146
147export function sessionLink(hostSessionId: string): string | null {
148  return /^local_[0-9a-f-]+$/i.test(hostSessionId) ? `claude://claude.ai/epitaxy/${hostSessionId}` : null
149}
150
151// --- view model --------------------------------------------------------
152
153export type Row = {
154  id: string; name: string; status: string; isSelf: boolean; cpu: number; memMB: number; procs: number
155  detail: string; hist: number[]; link: string | null; procsList: Top[]; byName: ByName[]
156}
157export type OrphanRow = { label: string; cpu: number; memMB: number; procs: number; detail: string; owners: string[]; hist: number[]; top: Top[] }
158export type RepoRow = { label: string; root: string; cpu: number; memMB: number; sessions: Row[]; orphans: OrphanRow[]; hist: number[] }
159export type ProcRow = { name: string; count: number; cpu: number; memMB: number; where: string[] }
160export type View = {
161  cpu: number; memUsedMB: number; memTotalMB: number; memFrac: number; cores: number; ageS: number
162  claude: { count: number; cpu: number; memMB: number }
163  active: Row[]; idle: Row[]; orphans: OrphanRow[]; outside: OutsideLoad[]
164  hist: { cpu: number[]; mem: number[] }
165  /** The busiest row: session bars scale to it, so small shares still show. */
166  scale: number
167}
168
169/** Sessions an orphan may belong to: their cwd is inside its repository root, or the other way round. */
170export function orphanOwners(snap: Snapshot, o: OrphanLoad): string[] {
171  const root = norm(o.root)
172  return snap.sessions
173    .filter(s => { const c = norm(s.cwd ?? ''); return !!c && !!root && (c === root || c.startsWith(root + '/') || root.startsWith(c + '/')) })
174    .map(s => s.name)
175}
176
177function detailOf(top: Top[]): string {
178  return top
179    .filter(t => t.cpu >= 0.5 || t.memMB >= 300)
180    .map(t => `${t.name}${t.cpu >= 0.5 ? ' ' + pct(t.cpu) : ''} ${fmtMem(t.memMB)}`)
181    .join(' · ')
182}
183
184function sumHist(list: number[][]): number[] {
185  const len = Math.max(0, ...list.map(h => h.length))
186  const out = new Array<number>(len).fill(0)
187  for (const h of list) h.forEach((x, i) => { out[len - h.length + i] += x })
188  return out
189}
190
191export function buildView(snap: Snapshot, selfId: string | null, now: number, sort: Sort = 'cpu'): View {
192  const rows: Row[] = snap.sessions.map(s => ({
193    id: s.sessionId, name: s.name || tr().untitled, status: s.status, isSelf: s.sessionId === selfId,
194    cpu: s.cpu, memMB: s.memMB, procs: s.procs, detail: detailOf(s.top),
195    hist: snap.hist?.sessions[s.sessionId] ?? [s.cpu], link: sessionLink(s.hostSessionId),
196    procsList: s.top, byName: s.byName ?? [],
197  }))
198  const by = sort === 'mem' ? (a: Row, b: Row) => b.memMB - a.memMB || b.cpu - a.cpu : (a: Row, b: Row) => b.cpu - a.cpu || b.memMB - a.memMB
199  const isActive = (r: Row) => r.status === 'busy' || r.cpu >= 1
200  const active = rows.filter(isActive).sort(by)
201  const idle = rows.filter(r => !isActive(r)).sort((a, b) => Number(b.isSelf) - Number(a.isSelf) || (sort === 'cpu' ? b.memMB - a.memMB : by(a, b)))
202  const orphans = snap.orphans.map(o => ({
203    label: o.label, cpu: o.cpu, memMB: o.memMB, procs: o.procs,
204    detail: o.top.map(t => `${t.name}#${t.pid}`).join(', '), owners: orphanOwners(snap, o),
205    hist: snap.hist?.orphans[o.label] ?? [o.cpu], top: o.top,
206  })).sort((a, b) => sort === 'mem' ? b.memMB - a.memMB : b.cpu - a.cpu)
207  return {
208    cpu: snap.cpu, memUsedMB: snap.memUsedMB, memTotalMB: snap.memTotalMB, memFrac: snap.memUsedMB / snap.memTotalMB,
209    cores: snap.cores, ageS: Math.max(0, Math.round((now - snap.ts) / 1000)),
210    claude: { count: rows.length, cpu: rows.reduce((a, r) => a + r.cpu, 0), memMB: rows.reduce((a, r) => a + r.memMB, 0) },
211    active, idle, orphans,
212    outside: [...snap.outside].sort((a, b) => sort === 'mem' ? b.memMB - a.memMB : b.cpu - a.cpu).slice(0, 6),
213    hist: { cpu: snap.hist?.cpu ?? [snap.cpu], mem: snap.hist?.mem ?? [(snap.memUsedMB / snap.memTotalMB) * 100] },
214    scale: Math.max(5, ...rows.map(r => r.cpu), ...orphans.map(o => o.cpu)),
215  }
216}
217
218/** What a filter shows: sessions, orphans and outside processes. */
219export function applyFilter(v: View, f: Filter): { sessions: Row[]; idle: Row[]; orphans: OrphanRow[]; outside: OutsideLoad[] } {
220  const heavy = (cpu: number, mem: number) => cpu >= 5 || mem >= 1024
221  switch (f) {
222    case 'active': return { sessions: v.active, idle: [], orphans: v.orphans.filter(o => o.cpu >= 1), outside: [] }
223    case 'heavy': return {
224      sessions: [...v.active, ...v.idle].filter(r => heavy(r.cpu, r.memMB)), idle: [],
225      orphans: v.orphans.filter(o => heavy(o.cpu, o.memMB)), outside: v.outside.filter(x => heavy(x.cpu, x.memMB)),
226    }
227    case 'orphans': return { sessions: [], idle: [], orphans: v.orphans, outside: [] }
228    case 'self': return { sessions: [...v.active, ...v.idle].filter(r => r.isSelf), idle: [], orphans: [], outside: [] }
229    default: return { sessions: v.active, idle: v.idle, orphans: v.orphans, outside: v.outside }
230  }
231}
232
233export function groupRepos(rows: Row[], orphans: OrphanRow[], snap: Snapshot): RepoRow[] {
234  const map = new Map<string, RepoRow>()
235  const get = (root: string, label: string) => {
236    const k = norm(root)
237    if (!map.has(k)) map.set(k, { label, root, cpu: 0, memMB: 0, sessions: [], orphans: [], hist: [] })
238    return map.get(k)!
239  }
240  for (const r of rows) {
241    const s = snap.sessions.find(x => x.sessionId === r.id)
242    const { root, label } = repoRoot(s?.cwd ?? '')
243    const g = get(root, label); g.sessions.push(r); g.cpu += r.cpu; g.memMB += r.memMB
244  }
245  for (const o of orphans) {
246    const src = snap.orphans.find(x => x.label === o.label)
247    const { root, label } = repoRoot(src?.root ?? o.label)
248    const g = get(root, label); g.orphans.push(o); g.cpu += o.cpu; g.memMB += o.memMB
249  }
250  for (const g of map.values()) g.hist = sumHist([...g.sessions.map(r => r.hist), ...g.orphans.map(o => o.hist)])
251  return [...map.values()]
252}
253
254export function groupProcs(rows: Row[], orphans: OrphanRow[], outside: OutsideLoad[]): ProcRow[] {
255  const map = new Map<string, ProcRow>()
256  const add = (name: string, count: number, cpu: number, memMB: number, where: string) => {
257    if (!map.has(name)) map.set(name, { name, count: 0, cpu: 0, memMB: 0, where: [] })
258    const p = map.get(name)!; p.count += count; p.cpu += cpu; p.memMB += memMB
259    if (!p.where.includes(where)) p.where.push(where)
260  }
261  for (const r of rows) for (const b of r.byName) add(b.name, b.count, b.cpu, b.memMB, short(r.name, 24))
262  for (const o of orphans) for (const t of o.top) add(t.name, 1, t.cpu, t.memMB, tr().orphanOf(o.label))
263  for (const x of outside) add(outsideName(x.name), x.count, x.cpu, x.memMB, tr().outsideWhere)
264  return [...map.values()]
265}
266
267/** Noticeable load: the line above the prompt shows in auto mode. */
268export function isBusy(snap: Snapshot): boolean {
269  return snap.cpu >= 50 || snap.memUsedMB / snap.memTotalMB >= 0.85
270}
271
272/** The three biggest CPU consumers: sessions, orphans and outside processes together. */
273export function topConsumers(snap: Snapshot, selfId: string | null): string[] {
274  const rows = [
275    ...snap.sessions.map(s => ({ cpu: s.cpu, text: `${s.sessionId === selfId ? '▸ ' : ''}${short(s.name, 24)} ${pct(s.cpu)}` })),
276    ...snap.orphans.map(o => ({ cpu: o.cpu, text: `⚠ ${short(o.label, 22)} ${pct(o.cpu)}` })),
277    ...snap.outside.map(x => ({ cpu: x.cpu, text: `${outsideName(x.name)}${x.count > 1 ? '×' + x.count : ''} ${pct(x.cpu)}` })),
278  ]
279  return rows.filter(r => r.cpu >= 1).sort((a, b) => b.cpu - a.cpu).slice(0, 3).map(r => r.text)
280}
281
282export function bandText(snap: Snapshot, selfId: string | null): string {
283  const top = topConsumers(snap, selfId)
284  const head = `CPU ${pct(snap.cpu)} · RAM ${pct((snap.memUsedMB / snap.memTotalMB) * 100)}`
285  return top.length ? `${head} · ${top.join(' · ')}` : head
286}
287
288// --- text report (the answer of /load in the transcript) ---------------
289
290export function textReport(v: View): string {
291  const t = tr()
292  const [cf, ce] = bar(v.cpu / 100, 12)
293  const [mf, me] = bar(v.memFrac, 12)
294  const lines = [
295    `CPU ${cf}${ce} ${pct(v.cpu)} ${sparkText(v.hist.cpu, 16, 100)}   RAM ${mf}${me} ${pct(v.memFrac * 100)} · ${t.memOf(fmtMem(v.memUsedMB), fmtMem(v.memTotalMB))}`,
296    t.reportClaude(plural(lang, v.claude.count, t.sessions), pct(v.claude.cpu), fmtMem(v.claude.memMB), v.ageS),
297  ]
298  const row = (r: Row, dot: string) =>
299    `${dot} ${short(r.name, 36).padEnd(36)} ${sparkText(r.hist, 10)} ${pct(r.cpu).padStart(4)} ${fmtMem(r.memMB).padStart(8)}${r.isSelf ? `  ▸ ${t.thisWindow}` : ''}`
300  if (v.active.length) {
301    lines.push('', t.sectionActive)
302    for (const r of v.active) {
303      lines.push(row(r, '●'))
304      if (r.detail) lines.push(`    ${r.detail}`)
305    }
306  }
307  if (v.idle.length) {
308    lines.push('', t.sectionIdle)
309    for (const r of v.idle.slice(0, TEXT_IDLE_ROWS)) lines.push(row(r, '○'))
310    const rest = v.idle.slice(TEXT_IDLE_ROWS)
311    if (rest.length) lines.push(`  ${t.moreIdle(rest.length, fmtMem(rest.reduce((a, r) => a + r.memMB, 0)))}`)
312  }
313  if (v.orphans.length) {
314    lines.push('', t.reportOrphans)
315    for (const o of v.orphans) {
316      lines.push(`⚠ ${short(o.label, 30).padEnd(30)} ${pct(o.cpu).padStart(4)} ${fmtMem(o.memMB).padStart(8)}  ${o.detail}`)
317      if (o.owners.length) lines.push(`    ${lead(t.inThisRepo)}${o.owners.map(n => short(n, 30)).join('; ')}`)
318    }
319  }
320  if (v.outside.length) {
321    lines.push('', `${lead(t.reportOutside)}${v.outside.slice(0, 4).map(x => `${outsideName(x.name)}${x.count > 1 ? ' ×' + x.count : ''} ${pct(x.cpu)} ${fmtMem(x.memMB)}`).join(' · ')}`)
322  }
323  return lines.join('\n')
324}
325
326// --- state, polling, actions -------------------------------------------
327
328type Confirm = { pid: number; name: string; where: string }
329type State = {
330  snap: Snapshot | null
331  selfId: string | null
332  band: BandMode
333  filter: Filter
334  group: Group
335  sort: Sort
336  showAll: boolean
337  expanded: Set<string>
338  confirm: Confirm | null
339  platform: Platform
340  home: string
341  sep: string
342  stateDir: string
343  script: string
344  lastWanted: number
345  lastSpawn: number
346  /** Windows: sampler starts since the last fresh snapshot; three without one means it is blocked. */
347  spawns: number
348  timer: { cancel: () => void } | null
349  /** macOS/Linux leader's sampling memory, and facts read once. */
350  posix: SamplerMemory
351  cores: number
352  memBytes: number
353  cwdCache: Map<number, string>
354  /** A macOS/Linux pass is running. */
355  sampling: boolean
356}
357
358export function freshState(): State {
359  return {
360    snap: null, selfId: null, band: 'auto', filter: 'all', group: 'sessions', sort: 'cpu', showAll: false,
361    expanded: new Set(), confirm: null, platform: 'other', home: '', sep: '/', stateDir: '', script: '',
362    lastWanted: 0, lastSpawn: 0, spawns: 0, timer: null, posix: freshMemory(), cores: 0, memBytes: 0, cwdCache: new Map(), sampling: false,
363  }
364}
365
366/** The plugin's option, else Claude Code's `language` setting, else the locale (env, then Intl). */
367async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
368  let setting: unknown
369  try {
370    setting = ((await $.settings.read()) as Record<string, unknown>).language
371  } catch {
372    // No settings: the locale decides.
373  }
374  return pickLang(option, [setting, await $.env.get('LC_ALL'), await $.env.get('LC_MESSAGES'), await $.env.get('LANG')])
375}
376
377async function detectPlatform($: EngineInterface): Promise<Platform> {
378  if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
379  try {
380    // The home folder as cwd: the session's own may be a worktree deleted under it.
381    const home = await $.env.get('HOME')
382    const r = await $.process.run(['uname', '-s'], { timeoutMs: 5000, cwd: home || undefined })
383    const os = r.stdout.trim().toLowerCase()
384    if (os === 'darwin') return 'darwin'
385    if (os === 'linux') return 'linux'
386  } catch {
387    // No uname: unsupported.
388  }
389  return 'other'
390}
391
392/** The PowerShell line that starts the sampler hidden; `'` in the path (C:\Users\O'Brien) is doubled. */
393export function samplerCommand(script: string): string {
394  const quoted = `'"${script.replace(/'/g, "''")}"'`
395  return `Start-Process -WindowStyle Hidden -FilePath powershell.exe -ArgumentList '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File',${quoted}`
396}
397
398function isBlocked(st: State): boolean {
399  return st.platform === 'win32' && st.spawns >= 3
400}
401
402async function spawnSampler($: EngineInterface, st: State) {
403  st.lastSpawn = await $.clock.now()
404  st.spawns++
405  try {
406    await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', samplerCommand(st.script)], { timeoutMs: 20000, cwd: st.home || undefined })
407  } catch (err) {
408    $.ui.log(tr().samplerFailed(err instanceof Error ? err.message : String(err)))
409  }
410}
411
412/** macOS/Linux: one session samples; it holds a lease it renews every pass. */
413async function claimLeader($: EngineInterface, st: State, now: number): Promise<boolean> {
414  const file = `${st.stateDir}/posix-leader.json`
415  try {
416    const lease = JSON.parse(String(await $.fs.read(file))) as { id?: string; ts?: number }
417    if (lease.id !== st.selfId && now - (lease.ts ?? 0) < LEADER_STALE_MS) return false
418  } catch {
419    // No lease yet: take it.
420  }
421  await $.fs.write(file, JSON.stringify({ id: st.selfId, ts: now }))
422  try {
423    const check = JSON.parse(String(await $.fs.read(file))) as { id?: string }
424    return check.id === st.selfId
425  } catch {
426    return false
427  }
428}
429
430async function samplePosix($: EngineInterface, st: State, now: number) {
431  // The home folder as cwd: the session's own may be a worktree deleted under it.
432  const run = (argv: string[]) => $.process.run(argv, { timeoutMs: 10000, cwd: st.home || undefined })
433  const psr = await run(st.platform === 'darwin' ? ['ps', '-axo', 'pid=,ppid=,time=,rss=,comm='] : ['ps', '-eo', 'pid=,ppid=,time=,rss=,comm='])
434  if (psr.exitCode !== 0) return
435  const rows = parsePs(psr.stdout)
436  if (!st.cores) {
437    // getconf is missing on some minimal systems (busybox): nproc, then one core.
438    for (const argv of [['getconf', '_NPROCESSORS_ONLN'], ['nproc']]) {
439      try {
440        st.cores = Number.parseInt((await run(argv)).stdout.trim(), 10) || 0
441      } catch {
442        // Not installed: the next one.
443      }
444      if (st.cores) break
445    }
446    st.cores ||= 1
447  }
448  let mem: { totalMB: number; usedMB: number }
449  if (st.platform === 'linux') {
450    mem = parseMeminfo(String(await $.fs.read('/proc/meminfo')))
451  } else {
452    if (!st.memBytes) st.memBytes = Number((await run(['sysctl', '-n', 'hw.memsize'])).stdout.trim()) || 0
453    mem = parseVmStat((await run(['vm_stat'])).stdout, st.memBytes)
454  }
455  const sessions: SessionMeta[] = []
456  const dir = `${st.home}/.claude/sessions`
457  try {
458    for (const f of await $.fs.list(dir)) {
459      if (!f.name.endsWith('.json')) continue
460      try {
461        const meta = parseSessionFile(String(await $.fs.read(`${dir}/${f.name}`)))
462        if (meta) sessions.push(meta)
463      } catch {
464        // A file being rewritten: next pass.
465      }
466    }
467  } catch {
468    // No sessions folder yet.
469  }
470  const alive = new Set(rows.map(r => r.pid))
471  for (const pid of [...st.cwdCache.keys()]) if (!alive.has(pid)) st.cwdCache.delete(pid)
472  const want = orphanCandidates(rows, sessions, st.posix, now, st.cores).filter(pid => !st.cwdCache.has(pid))
473  if (want.length && st.platform === 'linux') {
474    for (const pid of want) {
475      try {
476        const s = await $.fs.stat(`/proc/${pid}/cwd`, { resolve: true })
477        if (s.realPath) st.cwdCache.set(pid, s.realPath)
478      } catch {
479        // Gone, or not ours to read.
480      }
481    }
482  } else if (want.length) {
483    try {
484      const r = await run(['lsof', '-a', '-d', 'cwd', '-Fpn', '-p', want.join(',')])
485      for (const [pid, path] of parseLsofCwd(r.stdout)) st.cwdCache.set(pid, path)
486    } catch {
487      // No lsof: orphans stay among the other processes.
488    }
489  }
490  const snap = buildSnapshot({ now, intervalMs: POLL_MS, cores: st.cores, mem, rows, sessions, cwdOf: st.cwdCache }, st.posix)
491  await $.fs.write(`${st.stateDir}/snapshot.json`, JSON.stringify(snap))
492}
493
494async function tick($: EngineInterface, st: State) {
495  const now = await $.clock.now()
496  if (st.platform === 'darwin' || st.platform === 'linux') {
497    // One pass at a time, and not closer than MIN_SAMPLE_MS: the refresh button, /load and a kill
498    // also tick, and a pass a moment after the last one turns ps's whole-second CPU times into spikes.
499    if (!st.sampling) {
500      st.sampling = true
501      try {
502        if (await claimLeader($, st, now) && now - st.posix.prevAt >= MIN_SAMPLE_MS) await samplePosix($, st, now)
503      } catch (err) {
504        $.ui.log(tr().samplerFailed(err instanceof Error ? err.message : String(err)))
505      } finally {
506        st.sampling = false
507      }
508    }
509  } else if (now - st.lastWanted >= WANTED_EVERY_MS) {
510    st.lastWanted = now
511    try { await $.fs.write(`${st.stateDir}${st.sep}wanted`, String(now)) } catch { /* the next tick tries again */ }
512  }
513  let fresh: Snapshot | null = null
514  try { fresh = JSON.parse(String(await $.fs.read(`${st.stateDir}${st.sep}snapshot.json`))) as Snapshot } catch { fresh = null }
515  if (fresh && now - fresh.ts <= STALE_MS) st.spawns = 0
516  if (st.platform === 'win32' && (!fresh || now - fresh.ts > STALE_MS) && now - st.lastSpawn > RESPAWN_MS) await spawnSampler($, st)
517  if (fresh && fresh.ts !== st.snap?.ts && fresh.warm) {
518    st.snap = fresh
519    $.ui.invalidate('ui.render')
520  }
521}
522
523/**
524 * A PID that may be ended: present in the snapshot (under `name` when given), not a Claude session's
525 * own process, not a system one.
526 */
527export function canKill(snap: Snapshot | null, pid: number, name?: string): boolean {
528  if (!snap || pid <= 4) return false
529  if (snap.sessions.some(s => s.pid === pid)) return false
530  const known = [...snap.sessions.flatMap(s => s.top), ...snap.orphans.flatMap(o => o.top)]
531  const p = known.find(t => t.pid === pid)
532  return !!p && p.name.toLowerCase() !== 'claude' && (name === undefined || p.name === name)
533}
534
535/** Every descendant of `pid` in a ps listing, children before their parents' siblings. */
536export function descendants(rows: Array<{ pid: number; ppid: number }>, pid: number): number[] {
537  const kids = new Map<number, number[]>()
538  for (const r of rows) if (r.pid !== r.ppid) kids.set(r.ppid, [...(kids.get(r.ppid) ?? []), r.pid])
539  const out: number[] = []
540  const seen = new Set<number>([pid])
541  const walk = (p: number) => {
542    for (const k of kids.get(p) ?? []) {
543      if (seen.has(k)) continue
544      seen.add(k)
545      walk(k)
546      out.push(k)
547    }
548  }
549  walk(pid)
550  return out
551}
552
553async function killProcess($: EngineInterface, st: State, c: Confirm) {
554  st.confirm = null
555  const t = tr()
556  // The confirmation may have waited a while: the PID must still be the same process in a fresh sample.
557  await tick($, st)
558  const now = await $.clock.now()
559  if (!st.snap || now - st.snap.ts > STALE_MS || !canKill(st.snap, c.pid, c.name)) {
560    $.ui.toast(t.cannotKill(c.name, c.pid))
561    $.ui.invalidate('ui.render')
562    return
563  }
564  const run = (argv: string[], timeoutMs: number) => $.process.run(argv, { timeoutMs, cwd: st.home || undefined })
565  let r: { exitCode: number; stdout: string; stderr: string }
566  try {
567    if (st.platform === 'win32') {
568      r = await run(['taskkill.exe', '/PID', String(c.pid), '/T', '/F'], 15000)
569    } else {
570      // The whole tree, as taskkill /T does: descendants from a fresh ps, then the process itself.
571      const ps = await run(st.platform === 'darwin' ? ['ps', '-axo', 'pid=,ppid=,time=,rss=,comm='] : ['ps', '-eo', 'pid=,ppid=,time=,rss=,comm='], 10000)
572      const tree = [...descendants(parsePs(ps.stdout), c.pid), c.pid].map(String)
573      r = await run(['kill', '-TERM', ...tree], 5000)
574      if (r.exitCode === 0) {
575        await $.clock.sleep(3000)
576        const left: string[] = []
577        for (const pid of tree) if ((await run(['kill', '-0', pid], 5000)).exitCode === 0) left.push(pid)
578        if (left.length) r = await run(['kill', '-KILL', ...left], 5000)
579      }
580    }
581  } catch (err) {
582    r = { exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
583  }
584  $.ui.toast(r.exitCode === 0 ? t.killed(c.name, c.pid, c.where) : t.killFailed(c.name, c.pid, (r.stderr || r.stdout).trim().slice(0, 120)))
585  await tick($, st)
586  $.ui.invalidate('ui.render')
587}
588
589/** Go to a session: the app's deep link through the OS (a pane's Link only takes https). */
590async function openSession($: EngineInterface, st: State, link: string) {
591  if (!/^claude:\/\/claude\.ai\/epitaxy\/local_[0-9a-f-]+$/i.test(link)) return
592  const argv = st.platform === 'win32'
593    ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', `Start-Process '${link}'`]
594    : st.platform === 'darwin' ? ['open', link] : ['xdg-open', link]
595  try {
596    const r = await $.process.run(argv, { timeoutMs: 15000, cwd: st.home || undefined })
597    if (r.exitCode !== 0) $.ui.toast(tr().openFailed((r.stderr || r.stdout).trim().slice(0, 120)))
598  } catch (err) {
599    $.ui.toast(tr().openFailed(err instanceof Error ? err.message : String(err)))
600  }
601}
602
603async function openPane($: EngineInterface, st: State) {
604  await $.ui.open({ id: PANE, title: tr().paneTitle })
605  $.ui.invalidate('ui.render')
606}
607
608/** "Sort it out with Claude": a turn of its own in this session, by the sessions-triage skill. */
609async function triage($: EngineInterface) {
610  $.ui.toast(tr().triageToast)
611  await $.prompt.submit({ text: TRIAGE_PROMPT })
612}
613
614async function saveView($: EngineInterface, st: State) {
615  await $.store.set(KEY_VIEW, { filter: st.filter, group: st.group, sort: st.sort })
616  $.ui.invalidate('ui.render')
617}
618
619const FILTERS: Filter[] = ['all', 'active', 'heavy', 'orphans', 'self']
620const GROUPS: Group[] = ['sessions', 'repos', 'procs']
621const SORTS: Sort[] = ['cpu', 'mem']
622
623function filterLabel(f: Filter): string {
624  const t = tr()
625  return { all: t.filterAll, active: t.filterActive, heavy: t.filterHeavy, orphans: t.filterOrphans, self: t.filterSelf }[f]
626}
627function groupLabel(g: Group): string {
628  const t = tr()
629  return { sessions: t.groupSessions, repos: t.groupRepos, procs: t.groupProcs }[g]
630}
631function sortLabel(s: Sort): string {
632  return s === 'cpu' ? tr().sortCpu : tr().sortMem
633}
634
635export const register: Register = (on, options) => {
636  const st = freshState()
637
638  on('session.start', async ($, e, next) => {
639    const r = await next(e)
640    lang = await detectLang($, options?.language)
641    st.platform = await detectPlatform($)
642    if (st.platform === 'other') return r
643    st.sep = st.platform === 'win32' ? '\\' : '/'
644    st.home = (st.platform === 'win32' ? await $.env.get('USERPROFILE') : await $.env.get('HOME')) ?? ''
645    st.stateDir = [st.home, '.claude', 'state', 'load'].join(st.sep)
646    st.script = `${$.plugin.root}\\sampler\\load-sampler.ps1`.replace(/\//g, '\\')
647    st.selfId = await $.session.id()
648    const saved = await $.store.get(KEY_BAND)
649    st.band = saved === 'on' || saved === 'off' ? saved : 'auto'
650    const view = (await $.store.get(KEY_VIEW)) as { filter?: Filter; group?: Group; sort?: Sort } | undefined
651    if (view && FILTERS.includes(view.filter as Filter)) st.filter = view.filter as Filter
652    if (view && GROUPS.includes(view.group as Group)) st.group = view.group as Group
653    if (view && SORTS.includes(view.sort as Sort)) st.sort = view.sort as Sort
654    await $.command.register({
655      name: 'load',
656      description: tr().commandDescription,
657      argumentHint: '[all | band on|off|auto]',
658      immediate: true,
659    })
660    st.timer?.cancel()
661    st.timer = $.clock.every(POLL_MS, () => { void tick($, st) })
662    void tick($, st)
663    return r
664  })
665
666  on('session.end', async ($, e, next) => {
667    st.timer?.cancel()
668    st.timer = null
669    return next(e)
670  })
671
672  on('command.run', { command: 'load' }, async ($, e) => {
673    const t = tr()
674    if (st.platform === 'other') return { text: t.unsupported }
675    const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
676    if (words[0] === 'band') {
677      const mode = words[1]
678      if (mode !== 'on' && mode !== 'off' && mode !== 'auto') return { text: t.bandUsage }
679      st.band = mode
680      await $.store.set(KEY_BAND, mode)
681      $.ui.invalidate('ui.render')
682      return { text: t.bandSet(mode) }
683    }
684    st.showAll = words[0] === 'all'
685    await tick($, st)
686    await $.ui.open({ id: PANE, title: t.paneTitle })
687    $.ui.invalidate('ui.render')
688    if (!st.snap) return { text: isBlocked(st) ? t.samplerBlocked : t.samplerStarting }
689    return { text: textReport(buildView(st.snap, st.selfId, await $.clock.now(), st.sort)) }
690  })
691
692  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
693    const t = tr()
694    const els = $.ui.resolve(e)
695    const { Box, Text, Button } = els
696    const Select = (els as { Select?: typeof els.Button }).Select
697    const Svg = e.surface === 'terminal' ? null : (els as { Svg?: typeof els.Button }).Svg ?? null
698    const snap = st.snap
699    if (!snap) return isBlocked(st)
700      ? <Box><Text color="warning">{t.samplerBlocked}</Text></Box>
701      : <Box><Text dimColor>{t.waiting}</Text></Box>
702
703    const v = buildView(snap, st.selfId, await $.clock.now(), st.sort)
704    const shown = applyFilter(v, st.filter)
705    const columns = Math.max(56, e.props.bodyColumns ?? 90)
706    const nameMax = Math.max(18, columns - 24)
707    const minutes = Math.max(1, Math.round(((snap.hist?.cpu.length ?? 1) * snap.intervalMs) / 60000))
708
709    // --- primitives: SVG on the desktop, text in a terminal ---
710    const icon = (kind: IconKind) => Svg
711      ? <Box width={3}><Svg source={iconSvg(kind)} width={14} height={14} alt={kind} /></Box>
712      : <Box width={2}><Text color={kind === 'busy' ? 'success' : kind === 'orphan' || kind === 'working' ? 'warning' : 'inactive'}>{ICON_TEXT[kind]}</Text></Box>
713
714    const spark = (vals: number[], tone: Tone, w: number, floor = 5) => Svg
715      ? <Svg source={sparkSvg(vals, w * 7, 14, HEX[tone], floor)} width={w * 7} height={14} alt={t.graphAlt(minutes)} />
716      : <Text color={tone === 'inactive' ? undefined : tone} dimColor={tone === 'inactive'}>{sparkText(vals, w, floor)}</Text>
717
718    const meterBar = (frac: number, tone: Tone, w: number) => {
719      if (Svg) return <Svg source={barSvg(frac, w * 7, 8, HEX[tone])} width={w * 7} height={8} alt={pct(frac * 100)} />
720      const [f, rest] = bar(frac, w)
721      return <Text><Text color={tone}>{f}</Text><Text dimColor>{rest}</Text></Text>
722    }
723
724    const cpuCell = (cpu: number) => (
725      <Box width={14} gap={1} alignItems="center">
726        {meterBar(cpu / v.scale, cpuTone(cpu), 6)}
727        <Text color={cpu >= 1 ? cpuTone(cpu) : undefined} dimColor={cpu < 1} bold={cpu >= 10}>{pct(cpu).padStart(4)}</Text>
728      </Box>
729    )
730    const memCell = (mb: number, dim: boolean) => (
731      <Box width={9} justifyContent="flex-end"><Text color={memTone(mb)} dimColor={dim && !memTone(mb)} bold={!!memTone(mb)}>{fmtMem(mb)}</Text></Box>
732    )
733    const chip = (text: string) => <Text dimColor>  {text}</Text>
734
735    const killButton = (p: Top, where: string, key: string) => canKill(snap, p.pid) && (
736      <Button key={key} label={t.kill} plain dimColor onPress={() => { st.confirm = { pid: p.pid, name: p.name, where }; $.ui.invalidate('ui.render') }} />
737    )
738
739    // --- rows ---
740    const sessionRow = (r: Row, dim: boolean, key: string) => {
741      const isOpen = st.expanded.has(r.id) || st.filter === 'self'
742      const toggle = () => { if (st.expanded.has(r.id)) st.expanded.delete(r.id); else st.expanded.add(r.id); $.ui.invalidate('ui.render') }
743      const kind: IconKind = r.status === 'busy' ? 'busy' : r.cpu >= 1 ? 'working' : 'idle'
744      const meta = [r.isSelf ? t.thisWindow : '', plural(lang, r.procs, t.procs), r.detail].filter(Boolean).join(' · ')
745      const openButton = r.link && !r.isSelf && (
746        <Button key={`${key}-open`} label={t.open} plain dimColor onPress={() => { const l = r.link; if (l) void openSession($, st, l) }} />
747      )
748      return (
749        <Box key={key} flexDirection="column" marginTop={dim ? 0 : 1}>
750          <Box alignItems="center">
751            {icon(kind)}
752            <Box flexGrow={1}>
753              <Text bold={!dim || r.isSelf} dimColor={dim && !r.isSelf} color={r.isSelf ? 'claude' : undefined}>{short(r.name, nameMax)}</Text>
754            </Box>
755            {!dim && cpuCell(r.cpu)}
756            {memCell(r.memMB, dim)}
757          </Box>
758          {(!dim || isOpen) && (
759            <Box paddingLeft={Svg ? 3 : 2} gap={1} alignItems="center">
760              {spark(r.hist, cpuTone(Math.max(r.cpu, ...r.hist.slice(-5))), 12)}
761              <Box flexGrow={1}><Text dimColor color={r.isSelf ? 'claude' : undefined}>{short(meta, Math.max(16, columns - 40))}</Text></Box>
762              <Button key={`${key}-t`} label={isOpen ? t.hideProcs : t.showProcs} plain dimColor onPress={toggle} />
763              {openButton}
764            </Box>
765          )}
766          {dim && !isOpen && (
767            <Box paddingLeft={Svg ? 3 : 2} gap={1}>
768              <Button key={`${key}-t`} label={t.showProcs} plain dimColor onPress={toggle} />
769              {openButton}
770            </Box>
771          )}
772          {isOpen && (
773            <Box flexDirection="column" paddingLeft={Svg ? 5 : 4} marginBottom={1}>
774              {r.byName.map((b, i) => (
775                <Box key={`${key}-n${i}`}>
776                  <Box flexGrow={1}><Text dimColor>⚙ {b.name}{b.count > 1 ? ` ×${b.count}` : ''}</Text></Box>
777                  <Box width={7} justifyContent="flex-end"><Text color={b.cpu >= 1 ? cpuTone(b.cpu) : undefined} dimColor={b.cpu < 1}>{pct(b.cpu)}</Text></Box>
778                  {memCell(b.memMB, true)}
779                </Box>
780              ))}
781              {r.procsList.filter(p => canKill(snap, p.pid) && (p.cpu >= 0.5 || p.memMB >= 200)).map((p, i) => (
782                <Box key={`${key}-p${i}`} gap={1}>
783                  <Text dimColor>#{p.pid} {p.name} · {pct(p.cpu)} · {fmtMem(p.memMB)}</Text>
784                  {killButton(p, short(r.name, 24), `${key}-k${i}`)}
785                </Box>
786              ))}
787            </Box>
788          )}
789        </Box>
790      )
791    }
792
793    const orphanRow = (o: OrphanRow, key: string) => (
794      <Box key={key} flexDirection="column" marginTop={1}>
795        <Box alignItems="center">
796          {icon('orphan')}
797          <Box flexGrow={1}><Text bold>{short(o.label, nameMax)}</Text></Box>
798          {cpuCell(o.cpu)}
799          {memCell(o.memMB, false)}
800        </Box>
801        <Box paddingLeft={Svg ? 3 : 2} gap={1} flexWrap="wrap" alignItems="center">
802          {spark(o.hist, cpuTone(Math.max(o.cpu, ...o.hist.slice(-5))), 12)}
803          {o.top.map((p, i) => (
804            <Box key={`${key}-t${i}`} gap={1}>
805              <Text dimColor>#{p.pid} {p.name}</Text>
806              {killButton(p, t.orphanOf(o.label), `${key}-k${i}`)}
807            </Box>
808          ))}
809          {o.owners.length > 0 && <Text dimColor>· {lead(t.inThisRepo)}{o.owners.map(n => short(n, 22)).join('; ')}</Text>}
810        </Box>
811      </Box>
812    )
813
814    const section = (title: string, note?: string) => (
815      <Box marginTop={1}>
816        <Text bold>{title.toUpperCase()}</Text>
817        {note && chip(note)}
818      </Box>
819    )
820
821    // --- header ---
822    const meter = (label: string, frac: number, value: string, vals: number[]) => {
823      const tone = machineTone(frac)
824      return (
825        <Box alignItems="center" gap={1}>
826          <Box width={4}><Text bold>{label}</Text></Box>
827          {meterBar(frac, tone, 14)}
828          <Box width={16}><Text bold color={tone}>{value}</Text></Box>
829          <Box flexGrow={1}>{spark(vals, tone, Math.max(10, Math.min(30, columns - 42)), 100)}</Box>
830        </Box>
831      )
832    }
833
834    const head = (
835      <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
836        {meter('CPU', v.cpu / 100, pct(v.cpu), v.hist.cpu)}
837        {meter('RAM', v.memFrac, `${fmtMem(v.memUsedMB)} / ${fmtMem(v.memTotalMB)}`, v.hist.mem)}
838        <Box gap={1} marginTop={1} alignItems="center">
839          {icon('machine')}
840          <Text dimColor>{t.headLine(`Claude: ${plural(lang, v.claude.count, t.sessions)}`, pct(v.claude.cpu), fmtMem(v.claude.memMB), plural(lang, v.cores, t.threads), v.ageS)}</Text>
841        </Box>
842      </Box>
843    )
844
845    const controls = (
846      <Box flexDirection="column" marginTop={1}>
847        <Box gap={1} flexWrap="wrap">
848          {FILTERS.map(f => (
849            <Button key={`f-${f}`} label={filterLabel(f)} variant={st.filter === f ? 'primary' : 'secondary'}
850              onPress={() => { st.filter = f; void saveView($, st) }} />
851          ))}
852        </Box>
853        <Box gap={2} marginTop={1} flexWrap="wrap" alignItems="center">
854          {Select
855            ? <Select key="group" label={t.groupLabel} value={st.group} options={GROUPS.map(g => ({ value: g, label: groupLabel(g) }))} onSelect={(value: string) => { st.group = value as Group; void saveView($, st) }} />
856            : <Box gap={1}>{GROUPS.map(g => <Button key={`g-${g}`} label={groupLabel(g)} variant={st.group === g ? 'primary' : 'secondary'} onPress={() => { st.group = g; void saveView($, st) }} />)}</Box>}
857          {Select
858            ? <Select key="sort" label={t.sortLabel} value={st.sort} options={SORTS.map(s => ({ value: s, label: sortLabel(s) }))} onSelect={(value: string) => { st.sort = value as Sort; void saveView($, st) }} />
859            : <Box gap={1}>{SORTS.map(s => <Button key={`s-${s}`} label={sortLabel(s)} variant={st.sort === s ? 'primary' : 'secondary'} onPress={() => { st.sort = s; void saveView($, st) }} />)}</Box>}
860          <Box flexGrow={1} />
861          <Button key="refresh" label="↻" plain dimColor onPress={() => { void tick($, st) }} />
862          <Button key="triage" label={t.triage} variant="primary" onPress={() => { void triage($) }} />
863        </Box>
864      </Box>
865    )
866
867    const confirmBar = st.confirm && (
868      <Box marginTop={1} borderStyle="round" borderColor="warning" paddingX={1} flexDirection="column">
869        <Text color="warning" bold>{t.killTitle(st.confirm.name, st.confirm.pid)}</Text>
870        {/* Only taskkill /T ends the child processes too; kill on macOS/Linux ends the one PID. */}
871        <Text dimColor>{st.confirm.where} · {t.killNote}</Text>
872        <Box gap={2} marginTop={1}>
873          <Button key="kill-yes" label={t.killYes} variant="primary" onPress={() => { const c = st.confirm; if (c) void killProcess($, st, c) }} />
874          <Button key="kill-no" label={t.killNo} variant="secondary" onPress={() => { st.confirm = null; $.ui.invalidate('ui.render') }} />
875        </Box>
876      </Box>
877    )
878
879    // --- body by grouping ---
880    let body: unknown[] = []
881    if (st.group === 'sessions') {
882      const idleRoom = st.showAll ? shown.idle.length : 8
883      const idleShown = shown.idle.slice(0, idleRoom)
884      const idleRest = shown.idle.slice(idleRoom)
885      body = [
886        shown.sessions.length > 0 && section(st.filter === 'all' || st.filter === 'active' ? t.sectionActive : t.sectionSessions,
887          `${shown.sessions.length} · ${pct(shown.sessions.reduce((a, r) => a + r.cpu, 0))} CPU · ${fmtMem(shown.sessions.reduce((a, r) => a + r.memMB, 0))}`),
888        ...shown.sessions.map((r, i) => sessionRow(r, false, `a${i}`)),
889        shown.idle.length > 0 && section(t.sectionIdle, `${shown.idle.length} · ${fmtMem(shown.idle.reduce((a, r) => a + r.memMB, 0))}`),
890        ...idleShown.map((r, i) => sessionRow(r, true, `i${i}`)),
891        idleRest.length > 0 && (
892          <Box key="idle-more" paddingLeft={2} marginTop={1}>
893            <Button key="idle-all" label={t.moreIdle(idleRest.length, fmtMem(idleRest.reduce((a, r) => a + r.memMB, 0)))} plain dimColor onPress={() => { st.showAll = true; $.ui.invalidate('ui.render') }} />
894          </Box>
895        ),
896        shown.orphans.length > 0 && section(t.sectionOrphans, t.orphansNote),
897        ...shown.orphans.map((o, i) => orphanRow(o, `o${i}`)),
898      ]
899    } else if (st.group === 'repos') {
900      const repos = groupRepos([...shown.sessions, ...shown.idle], shown.orphans, snap)
901        .sort((a, b) => st.sort === 'mem' ? b.memMB - a.memMB : b.cpu - a.cpu || b.memMB - a.memMB)
902      body = repos.map((g, gi) => (
903        <Box key={`r${gi}`} flexDirection="column">
904          <Box marginTop={1} alignItems="center">
905            {icon('repo')}
906            <Box flexGrow={1}>
907              <Text bold>{g.label}</Text>
908              {chip([plural(lang, g.sessions.length, t.sessions), g.orphans.length ? plural(lang, g.orphans.length, t.orphans) : ''].filter(Boolean).join(' · '))}
909            </Box>
910            {cpuCell(g.cpu)}
911            {memCell(g.memMB, false)}
912          </Box>
913          {g.sessions.map((r, i) => sessionRow(r, r.status !== 'busy' && r.cpu < 1, `r${gi}s${i}`))}
914          {g.orphans.map((o, i) => orphanRow(o, `r${gi}o${i}`))}
915        </Box>
916      ))
917    } else {
918      const procs = groupProcs([...shown.sessions, ...shown.idle], shown.orphans, shown.outside)
919        .sort((a, b) => st.sort === 'mem' ? b.memMB - a.memMB : b.cpu - a.cpu || b.memMB - a.memMB)
920        .slice(0, 25)
921      body = [
922        section(t.sectionProcs, plural(lang, procs.length, t.names)),
923        ...procs.map((p, i) => (
924          <Box key={`p${i}`} flexDirection="column" marginTop={1}>
925            <Box alignItems="center">
926              {icon('proc')}
927              <Box flexGrow={1}><Text bold>{p.name}</Text>{chip(p.count > 1 ? `×${p.count}` : '')}</Box>
928              {cpuCell(p.cpu)}
929              {memCell(p.memMB, false)}
930            </Box>
931            <Box paddingLeft={Svg ? 3 : 2}><Text dimColor>{short(p.where.join(' · '), columns - 6)}</Text></Box>
932          </Box>
933        )),
934      ]
935    }
936
937    const outsideBlock = st.group !== 'procs' && shown.outside.length > 0 && [
938      section(t.sectionOutside),
939      ...shown.outside.map((x, i) => (
940        <Box key={`x${i}`} alignItems="center">
941          {icon('proc')}
942          <Box flexGrow={1}><Text dimColor>{outsideName(x.name)}</Text>{chip(x.count > 1 ? `×${x.count}` : '')}</Box>
943          {cpuCell(x.cpu)}
944          {memCell(x.memMB, true)}
945        </Box>
946      )),
947    ]
948
949    const empty = st.group === 'sessions' && !shown.sessions.length && !shown.idle.length && !shown.orphans.length
950
951    return (
952      <Box flexDirection="column">
953        {head}
954        {controls}
955        {confirmBar}
956        {body}
957        {empty && <Box marginTop={1}><Text dimColor>{t.empty}</Text></Box>}
958        {outsideBlock}
959        <Box marginTop={1}>
960          <Text dimColor>{t.footer(minutes)}</Text>
961        </Box>
962      </Box>
963    )
964  })
965
966  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
967    const rest = await next(e)
968    const snap = st.snap
969    if (!snap || e.props.hasSurvey || st.band === 'off') return rest
970    if (st.band === 'auto' && !isBusy(snap)) return rest
971    if ((await $.clock.now()) - snap.ts > 3 * STALE_MS) return rest
972    const els = $.ui.resolve(e)
973    const { Box, Text, Button } = els
974    const Svg = e.surface === 'terminal' ? null : (els as { Svg?: typeof els.Button }).Svg ?? null
975    const cpuFrac = snap.cpu / 100
976    const memFrac = snap.memUsedMB / snap.memTotalMB
977    const cpuTn = machineTone(cpuFrac)
978    // One line that never wraps: as many top consumers as the width leaves room for.
979    const room = Math.max(0, (e.props.bodyColumns ?? 100) - 34)
980    const fitted: string[] = []
981    let used = 0
982    for (const s of topConsumers(snap, st.selfId)) {
983      if (used + s.length + 3 > room) break
984      fitted.push(s)
985      used += s.length + 3
986    }
987    return (
988      <Box flexDirection="column">
989        {rest}
990        <Box alignItems="center" gap={1}>
991          {Svg
992            ? <Svg source={sparkSvg(snap.hist?.cpu ?? [snap.cpu], 64, 14, HEX[cpuTn], 100)} width={64} height={14} alt={tr().bandLabel} />
993            : <Text color={cpuTn}>{sparkText(snap.hist?.cpu ?? [snap.cpu], 8, 100)}</Text>}
994          <Text bold color={cpuTn}>CPU {pct(snap.cpu)}</Text>
995          <Text bold color={machineTone(memFrac)}>RAM {pct(memFrac * 100)}</Text>
996          {fitted.map((s, i) => <Text key={`b${i}`} dimColor>· {s}</Text>)}
997          <Box flexGrow={1} />
998          <Button key="band-open" label="/load" plain dimColor onPress={() => { void openPane($, st) }} />
999        </Box>
1000      </Box>
1001    )
1002  })
1003}
1004
hooks/i18n.ts 737 lines
1// Interface strings in eight languages. The language comes from the plugin's `language`
2// option, else Claude Code's `language` setting, else the locale (LC_ALL, LC_MESSAGES, LANG,
3// then the runtime's Intl locale); English when none of them names a supported language.
4
5export const LANGS = ['en', 'ru', 'de', 'es', 'fr', 'pt', 'zh', 'ja'] as const
6export type Lang = (typeof LANGS)[number]
7
8/** Plural forms as Intl.PluralRules names them; a language fills the categories it uses. */
9export type Forms = { one?: string; few?: string; many?: string; other: string }
10
11export type Dict = {
12  /** Decimal separator for memory figures. */
13  decimal: string
14  mb: string
15  gb: string
16  untitled: string
17  paneTitle: string
18  waiting: string
19  samplerStarting: string
20  unsupported: string
21  commandDescription: string
22  bandUsage: string
23  bandSet: (mode: 'auto' | 'on' | 'off') => string
24  samplerFailed: (why: string) => string
25  /** Windows: the sampler was started several times and never wrote a snapshot. */
26  samplerBlocked: string
27
28  filterAll: string
29  filterActive: string
30  filterHeavy: string
31  filterOrphans: string
32  filterSelf: string
33  groupLabel: string
34  groupSessions: string
35  groupRepos: string
36  groupProcs: string
37  sortLabel: string
38  sortCpu: string
39  sortMem: string
40  triage: string
41  triageToast: string
42
43  sectionActive: string
44  sectionSessions: string
45  sectionIdle: string
46  sectionOrphans: string
47  orphansNote: string
48  sectionOutside: string
49  sectionProcs: string
50  names: Forms
51  moreIdle: (n: number, mem: string) => string
52  empty: string
53
54  thisWindow: string
55  procs: Forms
56  sessions: Forms
57  orphans: Forms
58  threads: Forms
59  inThisRepo: string
60  orphanOf: (label: string) => string
61  outsideWhere: string
62  open: string
63  showProcs: string
64  hideProcs: string
65  kill: string
66  killTitle: (name: string, pid: number) => string
67  killNote: string
68  killYes: string
69  killNo: string
70  killed: (name: string, pid: number, where: string) => string
71  killFailed: (name: string, pid: number, why: string) => string
72  cannotKill: (name: string, pid: number) => string
73  openFailed: (why: string) => string
74
75  /** "15 Claude sessions · 47% CPU · 10.4 GB · 12 threads · 3 s ago" */
76  headLine: (sessions: string, cpu: string, mem: string, threads: string, ageS: number) => string
77  footer: (minutes: number) => string
78  graphAlt: (minutes: number) => string
79  bandLabel: string
80  /** Plain-text report, the answer of /load in the transcript. */
81  reportClaude: (sessions: string, cpu: string, mem: string, ageS: number) => string
82  memOf: (used: string, total: string) => string
83  reportOrphans: string
84  reportOutside: string
85}
86
87const en: Dict = {
88  decimal: '.',
89  mb: 'MB',
90  gb: 'GB',
91  untitled: '(untitled)',
92  paneTitle: 'Load by session',
93  waiting: 'Waiting for the first sample…',
94  samplerStarting: 'The sampler is starting; the first sample arrives in a few seconds.',
95  unsupported: 'claude-load does not support this platform.',
96  commandDescription: 'Load by session: CPU and memory of each Claude Code session, orphans and other processes (claude-load)',
97  bandUsage: '/load band on | off | auto — auto shows the line only at 50% CPU or 85% memory and above',
98  bandSet: m => `Load line above the prompt: ${m === 'auto' ? 'only under high load' : m === 'on' ? 'always' : 'off'}.`,
99  samplerFailed: why => `claude-load: could not start the sampler: ${why}`,
100  samplerBlocked: 'The sampler does not start. PowerShell scripts may be blocked by policy (AllSigned, Constrained Language Mode).',
101
102  filterAll: 'All',
103  filterActive: 'Active',
104  filterHeavy: 'Heavy',
105  filterOrphans: 'Orphans',
106  filterSelf: 'This window',
107  groupLabel: 'Group ',
108  groupSessions: 'by session',
109  groupRepos: 'by repository',
110  groupProcs: 'by process',
111  sortLabel: 'Sort ',
112  sortCpu: 'by CPU',
113  sortMem: 'by memory',
114  triage: '✨ Sort it out with Claude',
115  triageToast: 'Claude is sorting out the load; the answer appears in this session',
116
117  sectionActive: 'Active',
118  sectionSessions: 'Sessions',
119  sectionIdle: 'Idle',
120  sectionOrphans: 'Orphans',
121  orphansNote: 'parent gone, process still running',
122  sectionOutside: 'Outside sessions',
123  sectionProcs: 'Processes',
124  names: { one: 'name', other: 'names' },
125  moreIdle: (n, mem) => `+ ${n} more · ${mem}`,
126  empty: 'Nothing matches this filter.',
127
128  thisWindow: 'this window',
129  procs: { one: 'process', other: 'processes' },
130  sessions: { one: 'session', other: 'sessions' },
131  orphans: { one: 'orphan', other: 'orphans' },
132  threads: { one: 'thread', other: 'threads' },
133  inThisRepo: 'in this repo:',
134  orphanOf: label => `orphan ${label}`,
135  outsideWhere: 'outside sessions',
136  open: 'open ↗',
137  showProcs: 'processes',
138  hideProcs: 'collapse',
139  kill: 'end',
140  killTitle: (name, pid) => `End ${name} #${pid}?`,
141  killNote: 'together with its child processes',
142  killYes: 'Yes, end it',
143  killNo: 'Cancel',
144  killed: (name, pid, where) => `Ended ${name} #${pid} (${where})`,
145  killFailed: (name, pid, why) => `Could not end ${name} #${pid}: ${why}`,
146  cannotKill: (name, pid) => `${name} #${pid}: this process cannot be ended from here`,
147  openFailed: why => `Could not open the session: ${why}`,
148
149  headLine: (sessions, cpu, mem, threads, ageS) => `${sessions} · ${cpu} CPU · ${mem} · ${threads} · ${ageS} s ago`,
150  footer: m => `graphs show CPU over ${m} min · /load band on|off|auto controls the line above the prompt`,
151  graphAlt: m => `CPU over ${m} min`,
152  bandLabel: 'load',
153  reportClaude: (sessions, cpu, mem, ageS) => `Claude: ${sessions} · ${cpu} CPU · ${mem} · sampled ${ageS} s ago`,
154  memOf: (used, total) => `${used} of ${total}`,
155  reportOrphans: 'Orphans — parent gone, process still running',
156  reportOutside: 'Outside sessions:',
157}
158
159const ru: Dict = {
160  decimal: ',',
161  mb: 'МБ',
162  gb: 'ГБ',
163  untitled: '(без названия)',
164  paneTitle: 'Нагрузка по сессиям',
165  waiting: 'Жду первый снимок…',
166  samplerStarting: 'Сэмплер запускается, первый снимок будет через несколько секунд.',
167  unsupported: 'claude-load не поддерживает эту платформу.',
168  commandDescription: 'Нагрузка по сессиям: CPU и память каждой сессии Claude Code, сироты и чужие процессы (claude-load)',
169  bandUsage: '/load band on | off | auto — auto показывает строку только при CPU от 50% или памяти от 85%',
170  bandSet: m => `Строка нагрузки над вводом: ${m === 'auto' ? 'только при высокой нагрузке' : m === 'on' ? 'всегда' : 'выключена'}.`,
171  samplerFailed: why => `claude-load: не удалось запустить сэмплер: ${why}`,
172  samplerBlocked: 'Сэмплер не запускается. Возможно, скрипты PowerShell запрещены политикой (AllSigned, Constrained Language Mode).',
173
174  filterAll: 'Все',
175  filterActive: 'Активные',
176  filterHeavy: 'Тяжёлые',
177  filterOrphans: 'Сироты',
178  filterSelf: 'Это окно',
179  groupLabel: 'Группировка ',
180  groupSessions: 'по сессиям',
181  groupRepos: 'по репозиториям',
182  groupProcs: 'по процессам',
183  sortLabel: 'Сортировка ',
184  sortCpu: 'по CPU',
185  sortMem: 'по памяти',
186  triage: '✨ Разобрать с Claude',
187  triageToast: 'Claude разбирает нагрузку — ответ появится в этой сессии',
188
189  sectionActive: 'Активные',
190  sectionSessions: 'Сессии',
191  sectionIdle: 'Простаивают',
192  sectionOrphans: 'Сироты',
193  orphansNote: 'родитель умер, процесс живёт',
194  sectionOutside: 'Вне сессий',
195  sectionProcs: 'Процессы',
196  names: { one: 'имя', few: 'имени', many: 'имён', other: 'имени' },
197  moreIdle: (n, mem) => `+ ещё ${n} · ${mem}`,
198  empty: 'Под этот фильтр ничего не попало.',
199
200  thisWindow: 'это окно',
201  procs: { one: 'процесс', few: 'процесса', many: 'процессов', other: 'процесса' },
202  sessions: { one: 'сессия', few: 'сессии', many: 'сессий', other: 'сессии' },
203  orphans: { one: 'сирота', few: 'сироты', many: 'сирот', other: 'сироты' },
204  threads: { one: 'поток', few: 'потока', many: 'потоков', other: 'потока' },
205  inThisRepo: 'в этом репо:',
206  orphanOf: label => `сирота ${label}`,
207  outsideWhere: 'вне сессий',
208  open: 'открыть ↗',
209  showProcs: 'процессы',
210  hideProcs: 'свернуть',
211  kill: 'завершить',
212  killTitle: (name, pid) => `Завершить ${name} #${pid}?`,
213  killNote: 'вместе с дочерними процессами',
214  killYes: 'Да, завершить',
215  killNo: 'Отмена',
216  killed: (name, pid, where) => `Завершён ${name} #${pid} (${where})`,
217  killFailed: (name, pid, why) => `Не удалось завершить ${name} #${pid}: ${why}`,
218  cannotKill: (name, pid) => `${name} #${pid}: этот процесс отсюда завершать нельзя`,
219  openFailed: why => `Не удалось открыть сессию: ${why}`,
220
221  headLine: (sessions, cpu, mem, threads, ageS) => `${sessions} · ${cpu} CPU · ${mem} · ${threads} · ${ageS} с назад`,
222  footer: m => `графики — CPU за ${m} мин · /load band on|off|auto — строка над вводом`,
223  graphAlt: m => `CPU за ${m} мин`,
224  bandLabel: 'нагрузка',
225  reportClaude: (sessions, cpu, mem, ageS) => `Claude: ${sessions} · ${cpu} CPU · ${mem} · снимок ${ageS} с назад`,
226  memOf: (used, total) => `${used} из ${total}`,
227  reportOrphans: 'Сироты — родитель умер, процесс живёт',
228  reportOutside: 'Вне сессий:',
229}
230
231const de: Dict = {
232  decimal: ',',
233  mb: 'MB',
234  gb: 'GB',
235  untitled: '(ohne Titel)',
236  paneTitle: 'Last pro Sitzung',
237  waiting: 'Warte auf die erste Messung…',
238  samplerStarting: 'Der Sampler startet; die erste Messung kommt in wenigen Sekunden.',
239  unsupported: 'claude-load unterstützt diese Plattform nicht.',
240  commandDescription: 'Last pro Sitzung: CPU und Speicher jeder Claude Code-Sitzung, verwaiste und fremde Prozesse (claude-load)',
241  bandUsage: '/load band on | off | auto — auto zeigt die Zeile erst ab 50 % CPU oder 85 % Speicher',
242  bandSet: m => `Lastzeile über der Eingabe: ${m === 'auto' ? 'nur bei hoher Last' : m === 'on' ? 'immer' : 'aus'}.`,
243  samplerFailed: why => `claude-load: Sampler konnte nicht gestartet werden: ${why}`,
244  samplerBlocked: 'Der Sampler startet nicht. PowerShell-Skripte sind möglicherweise per Richtlinie gesperrt (AllSigned, Constrained Language Mode).',
245
246  filterAll: 'Alle',
247  filterActive: 'Aktiv',
248  filterHeavy: 'Schwer',
249  filterOrphans: 'Verwaist',
250  filterSelf: 'Dieses Fenster',
251  groupLabel: 'Gruppieren ',
252  groupSessions: 'nach Sitzung',
253  groupRepos: 'nach Repository',
254  groupProcs: 'nach Prozess',
255  sortLabel: 'Sortieren ',
256  sortCpu: 'nach CPU',
257  sortMem: 'nach Speicher',
258  triage: '✨ Mit Claude aufräumen',
259  triageToast: 'Claude räumt die Last auf; die Antwort erscheint in dieser Sitzung',
260
261  sectionActive: 'Aktiv',
262  sectionSessions: 'Sitzungen',
263  sectionIdle: 'Inaktiv',
264  sectionOrphans: 'Verwaist',
265  orphansNote: 'Elternprozess beendet, Prozess läuft noch',
266  sectionOutside: 'Außerhalb von Sitzungen',
267  sectionProcs: 'Prozesse',
268  names: { one: 'Name', other: 'Namen' },
269  moreIdle: (n, mem) => `+ ${n} weitere · ${mem}`,
270  empty: 'Nichts passt zu diesem Filter.',
271
272  thisWindow: 'dieses Fenster',
273  procs: { one: 'Prozess', other: 'Prozesse' },
274  sessions: { one: 'Sitzung', other: 'Sitzungen' },
275  orphans: { one: 'verwaister Prozess', other: 'verwaiste Prozesse' },
276  threads: { one: 'Thread', other: 'Threads' },
277  inThisRepo: 'in diesem Repo:',
278  orphanOf: label => `verwaist ${label}`,
279  outsideWhere: 'außerhalb von Sitzungen',
280  open: 'öffnen ↗',
281  showProcs: 'Prozesse',
282  hideProcs: 'einklappen',
283  kill: 'beenden',
284  killTitle: (name, pid) => `${name} #${pid} beenden?`,
285  killNote: 'samt seinen Kindprozessen',
286  killYes: 'Ja, beenden',
287  killNo: 'Abbrechen',
288  killed: (name, pid, where) => `${name} #${pid} beendet (${where})`,
289  killFailed: (name, pid, why) => `${name} #${pid} konnte nicht beendet werden: ${why}`,
290  cannotKill: (name, pid) => `${name} #${pid}: Dieser Prozess kann von hier nicht beendet werden`,
291  openFailed: why => `Sitzung konnte nicht geöffnet werden: ${why}`,
292
293  headLine: (sessions, cpu, mem, threads, ageS) => `${sessions} · ${cpu} CPU · ${mem} · ${threads} · vor ${ageS} s`,
294  footer: m => `Graphen zeigen CPU der letzten ${m} Min. · /load band on|off|auto steuert die Zeile über der Eingabe`,
295  graphAlt: m => `CPU der letzten ${m} Min.`,
296  bandLabel: 'Last',
297  reportClaude: (sessions, cpu, mem, ageS) => `Claude: ${sessions} · ${cpu} CPU · ${mem} · gemessen vor ${ageS} s`,
298  memOf: (used, total) => `${used} von ${total}`,
299  reportOrphans: 'Verwaist — Elternprozess beendet, Prozess läuft noch',
300  reportOutside: 'Außerhalb von Sitzungen:',
301}
302
303const es: Dict = {
304  decimal: ',',
305  mb: 'MB',
306  gb: 'GB',
307  untitled: '(sin título)',
308  paneTitle: 'Carga por sesión',
309  waiting: 'Esperando la primera muestra…',
310  samplerStarting: 'El muestreador se está iniciando; la primera muestra llega en unos segundos.',
311  unsupported: 'claude-load no es compatible con esta plataforma.',
312  commandDescription: 'Carga por sesión: CPU y memoria de cada sesión de Claude Code, huérfanos y otros procesos (claude-load)',
313  bandUsage: '/load band on | off | auto — auto muestra la línea solo con 50 % de CPU o 85 % de memoria o más',
314  bandSet: m => `Línea de carga sobre el prompt: ${m === 'auto' ? 'solo con carga alta' : m === 'on' ? 'siempre' : 'desactivada'}.`,
315  samplerFailed: why => `claude-load: no se pudo iniciar el muestreador: ${why}`,
316  samplerBlocked: 'El muestreador no arranca. Puede que una directiva bloquee los scripts de PowerShell (AllSigned, Constrained Language Mode).',
317
318  filterAll: 'Todos',
319  filterActive: 'Activos',
320  filterHeavy: 'Pesados',
321  filterOrphans: 'Huérfanos',
322  filterSelf: 'Esta ventana',
323  groupLabel: 'Agrupar ',
324  groupSessions: 'por sesión',
325  groupRepos: 'por repositorio',
326  groupProcs: 'por proceso',
327  sortLabel: 'Ordenar ',
328  sortCpu: 'por CPU',
329  sortMem: 'por memoria',
330  triage: '✨ Resolver con Claude',
331  triageToast: 'Claude está revisando la carga; la respuesta aparecerá en esta sesión',
332
333  sectionActive: 'Activos',
334  sectionSessions: 'Sesiones',
335  sectionIdle: 'Inactivos',
336  sectionOrphans: 'Huérfanos',
337  orphansNote: 'el proceso padre terminó, pero sigue en ejecución',
338  sectionOutside: 'Fuera de sesiones',
339  sectionProcs: 'Procesos',
340  names: { one: 'nombre', other: 'nombres' },
341  moreIdle: (n, mem) => `+ ${n} más · ${mem}`,
342  empty: 'Nada coincide con este filtro.',
343
344  thisWindow: 'esta ventana',
345  procs: { one: 'proceso', other: 'procesos' },
346  sessions: { one: 'sesión', other: 'sesiones' },
347  orphans: { one: 'huérfano', other: 'huérfanos' },
348  threads: { one: 'hilo', other: 'hilos' },
349  inThisRepo: 'en este repo:',
350  orphanOf: label => `huérfano ${label}`,
351  outsideWhere: 'fuera de sesiones',
352  open: 'abrir ↗',
353  showProcs: 'procesos',
354  hideProcs: 'contraer',
355  kill: 'terminar',
356  killTitle: (name, pid) => `¿Terminar ${name} #${pid}?`,
357  killNote: 'junto con sus procesos hijos',
358  killYes: 'Sí, terminar',
359  killNo: 'Cancelar',
360  killed: (name, pid, where) => `${name} #${pid} terminado (${where})`,
361  killFailed: (name, pid, why) => `No se pudo terminar ${name} #${pid}: ${why}`,
362  cannotKill: (name, pid) => `${name} #${pid}: este proceso no se puede terminar desde aquí`,
363  openFailed: why => `No se pudo abrir la sesión: ${why}`,
364
365  headLine: (sessions, cpu, mem, threads, ageS) => `${sessions} · ${cpu} CPU · ${mem} · ${threads} · hace ${ageS} s`,
366  footer: m => `los gráficos muestran la CPU de ${m} min · /load band on|off|auto controla la línea sobre el prompt`,
367  graphAlt: m => `CPU en ${m} min`,
368  bandLabel: 'carga',
369  reportClaude: (sessions, cpu, mem, ageS) => `Claude: ${sessions} · ${cpu} CPU · ${mem} · muestreado hace ${ageS} s`,
370  memOf: (used, total) => `${used} de ${total}`,
371  reportOrphans: 'Huérfanos — el proceso padre terminó, pero sigue en ejecución',
372  reportOutside: 'Fuera de sesiones:',
373}
374
375const fr: Dict = {
376  decimal: ',',
377  mb: 'Mo',
378  gb: 'Go',
379  untitled: '(sans titre)',
380  paneTitle: 'Charge par session',
381  waiting: "En attente du premier échantillon…",
382  samplerStarting: "L'échantillonneur démarre ; le premier échantillon arrive dans quelques secondes.",
383  unsupported: "claude-load ne prend pas en charge cette plateforme.",
384  commandDescription: 'Charge par session : CPU et mémoire de chaque session Claude Code, orphelins et autres processus (claude-load)',
385  bandUsage: "/load band on | off | auto — auto n'affiche la ligne qu'à partir de 50 % de CPU ou 85 % de mémoire",
386  bandSet: m => `Ligne de charge au-dessus de la saisie : ${m === 'auto' ? 'seulement en cas de forte charge' : m === 'on' ? 'toujours' : 'désactivée'}.`,
387  samplerFailed: why => `claude-load : impossible de démarrer l'échantillonneur : ${why}`,
388  samplerBlocked: "L'échantillonneur ne démarre pas. Une stratégie bloque peut-être les scripts PowerShell (AllSigned, Constrained Language Mode).",
389
390  filterAll: 'Tous',
391  filterActive: 'Actifs',
392  filterHeavy: 'Lourds',
393  filterOrphans: 'Orphelins',
394  filterSelf: 'Cette fenêtre',
395  groupLabel: 'Grouper ',
396  groupSessions: 'par session',
397  groupRepos: 'par dépôt',
398  groupProcs: 'par processus',
399  sortLabel: 'Trier ',
400  sortCpu: 'par CPU',
401  sortMem: 'par mémoire',
402  triage: '✨ Faire le tri avec Claude',
403  triageToast: 'Claude fait le tri dans la charge ; la réponse apparaîtra dans cette session',
404
405  sectionActive: 'Actifs',
406  sectionSessions: 'Sessions',
407  sectionIdle: 'Inactifs',
408  sectionOrphans: 'Orphelins',
409  orphansNote: 'parent disparu, processus toujours actif',
410  sectionOutside: 'Hors sessions',
411  sectionProcs: 'Processus',
412  names: { one: 'nom', many: 'noms', other: 'noms' },
413  moreIdle: (n, mem) => `+ ${n} de plus · ${mem}`,
414  empty: 'Aucun résultat pour ce filtre.',
415
416  thisWindow: 'cette fenêtre',
417  procs: { one: 'processus', many: 'processus', other: 'processus' },
418  sessions: { one: 'session', many: 'sessions', other: 'sessions' },
419  orphans: { one: 'orphelin', many: 'orphelins', other: 'orphelins' },
420  threads: { one: 'thread', many: 'threads', other: 'threads' },
421  inThisRepo: 'dans ce dépôt :',
422  orphanOf: label => `orphelin ${label}`,
423  outsideWhere: 'hors sessions',
424  open: 'ouvrir ↗',
425  showProcs: 'processus',
426  hideProcs: 'réduire',
427  kill: 'arrêter',
428  killTitle: (name, pid) => `Arrêter ${name} #${pid} ?`,
429  killNote: 'avec ses processus enfants',
430  killYes: 'Oui, arrêter',
431  killNo: 'Annuler',
432  killed: (name, pid, where) => `${name} #${pid} arrêté (${where})`,
433  killFailed: (name, pid, why) => `Impossible d'arrêter ${name} #${pid} : ${why}`,
434  cannotKill: (name, pid) => `${name} #${pid} : ce processus ne peut pas être arrêté d'ici`,
435  openFailed: why => `Impossible d'ouvrir la session : ${why}`,
436
437  headLine: (sessions, cpu, mem, threads, ageS) => `${sessions} · ${cpu} CPU · ${mem} · ${threads} · il y a ${ageS} s`,
438  footer: m => `les graphiques montrent le CPU sur ${m} min · /load band on|off|auto règle la ligne au-dessus de la saisie`,
439  graphAlt: m => `CPU sur ${m} min`,
440  bandLabel: 'charge',
441  reportClaude: (sessions, cpu, mem, ageS) => `Claude : ${sessions} · ${cpu} CPU · ${mem} · mesuré il y a ${ageS} s`,
442  memOf: (used, total) => `${used} sur ${total}`,
443  reportOrphans: 'Orphelins — parent disparu, processus toujours actif',
444  reportOutside: 'Hors sessions :',
445}
446
447const pt: Dict = {
448  decimal: ',',
449  mb: 'MB',
450  gb: 'GB',
451  untitled: '(sem título)',
452  paneTitle: 'Carga por sessão',
453  waiting: 'Aguardando a primeira amostra…',
454  samplerStarting: 'O amostrador está iniciando; a primeira amostra chega em alguns segundos.',
455  unsupported: 'O claude-load não é compatível com esta plataforma.',
456  commandDescription: 'Carga por sessão: CPU e memória de cada sessão do Claude Code, órfãos e outros processos (claude-load)',
457  bandUsage: '/load band on | off | auto — auto mostra a linha só com 50% de CPU ou 85% de memória, ou mais',
458  bandSet: m => `Linha de carga acima do prompt: ${m === 'auto' ? 'só sob carga alta' : m === 'on' ? 'sempre' : 'desligada'}.`,
459  samplerFailed: why => `claude-load: não foi possível iniciar o amostrador: ${why}`,
460  samplerBlocked: 'O amostrador não inicia. Uma política pode estar bloqueando scripts do PowerShell (AllSigned, Constrained Language Mode).',
461
462  filterAll: 'Todos',
463  filterActive: 'Ativos',
464  filterHeavy: 'Pesados',
465  filterOrphans: 'Órfãos',
466  filterSelf: 'Esta janela',
467  groupLabel: 'Agrupar ',
468  groupSessions: 'por sessão',
469  groupRepos: 'por repositório',
470  groupProcs: 'por processo',
471  sortLabel: 'Ordenar ',
472  sortCpu: 'por CPU',
473  sortMem: 'por memória',
474  triage: '✨ Resolver com o Claude',
475  triageToast: 'O Claude está analisando a carga; a resposta aparecerá nesta sessão',
476
477  sectionActive: 'Ativos',
478  sectionSessions: 'Sessões',
479  sectionIdle: 'Ociosos',
480  sectionOrphans: 'Órfãos',
481  orphansNote: 'processo pai encerrado, processo ainda em execução',
482  sectionOutside: 'Fora das sessões',
483  sectionProcs: 'Processos',
484  names: { one: 'nome', other: 'nomes' },
485  moreIdle: (n, mem) => `+ ${n} a mais · ${mem}`,
486  empty: 'Nada corresponde a este filtro.',
487
488  thisWindow: 'esta janela',
489  procs: { one: 'processo', other: 'processos' },
490  sessions: { one: 'sessão', other: 'sessões' },
491  orphans: { one: 'órfão', other: 'órfãos' },
492  threads: { one: 'thread', other: 'threads' },
493  inThisRepo: 'neste repo:',
494  orphanOf: label => `órfão ${label}`,
495  outsideWhere: 'fora das sessões',
496  open: 'abrir ↗',
497  showProcs: 'processos',
498  hideProcs: 'recolher',
499  kill: 'encerrar',
500  killTitle: (name, pid) => `Encerrar ${name} #${pid}?`,
501  killNote: 'junto com seus processos filhos',
502  killYes: 'Sim, encerrar',
503  killNo: 'Cancelar',
504  killed: (name, pid, where) => `${name} #${pid} encerrado (${where})`,
505  killFailed: (name, pid, why) => `Não foi possível encerrar ${name} #${pid}: ${why}`,
506  cannotKill: (name, pid) => `${name} #${pid}: este processo não pode ser encerrado daqui`,
507  openFailed: why => `Não foi possível abrir a sessão: ${why}`,
508
509  headLine: (sessions, cpu, mem, threads, ageS) => `${sessions} · ${cpu} CPU · ${mem} · ${threads} · há ${ageS} s`,
510  footer: m => `os gráficos mostram a CPU em ${m} min · /load band on|off|auto controla a linha acima do prompt`,
511  graphAlt: m => `CPU em ${m} min`,
512  bandLabel: 'carga',
513  reportClaude: (sessions, cpu, mem, ageS) => `Claude: ${sessions} · ${cpu} CPU · ${mem} · medido há ${ageS} s`,
514  memOf: (used, total) => `${used} de ${total}`,
515  reportOrphans: 'Órfãos — processo pai encerrado, processo ainda em execução',
516  reportOutside: 'Fora das sessões:',
517}
518
519const zh: Dict = {
520  decimal: '.',
521  mb: 'MB',
522  gb: 'GB',
523  untitled: '(无标题)',
524  paneTitle: '各会话负载',
525  waiting: '正在等待首次采样…',
526  samplerStarting: '采样器正在启动,几秒后将出现首次采样。',
527  unsupported: 'claude-load 不支持此平台。',
528  commandDescription: '各会话负载:每个 Claude Code 会话的 CPU 和内存、孤儿进程及其他进程 (claude-load)',
529  bandUsage: '/load band on | off | auto — auto 仅在 CPU 达到 50% 或内存达到 85% 及以上时显示该行',
530  bandSet: m => `输入框上方的负载行:${m === 'auto' ? '仅高负载时显示' : m === 'on' ? '始终显示' : '已关闭'}。`,
531  samplerFailed: why => `claude-load:无法启动采样器:${why}`,
532  samplerBlocked: '采样器无法启动。PowerShell 脚本可能被策略禁止(AllSigned、Constrained Language Mode)。',
533
534  filterAll: '全部',
535  filterActive: '活跃',
536  filterHeavy: '高负载',
537  filterOrphans: '孤儿进程',
538  filterSelf: '当前窗口',
539  groupLabel: '分组 ',
540  groupSessions: '按会话',
541  groupRepos: '按仓库',
542  groupProcs: '按进程',
543  sortLabel: '排序 ',
544  sortCpu: '按 CPU',
545  sortMem: '按内存',
546  triage: '✨ 让 Claude 来整理',
547  triageToast: 'Claude 正在整理负载,答复将显示在此会话中',
548
549  sectionActive: '活跃',
550  sectionSessions: '会话',
551  sectionIdle: '空闲',
552  sectionOrphans: '孤儿进程',
553  orphansNote: '父进程已退出,进程仍在运行',
554  sectionOutside: '会话之外',
555  sectionProcs: '进程',
556  names: { other: '个名称' },
557  moreIdle: (n, mem) => `+ 另有 ${n} 个 · ${mem}`,
558  empty: '没有符合此筛选条件的项目。',
559
560  thisWindow: '当前窗口',
561  procs: { other: '个进程' },
562  sessions: { other: '个会话' },
563  orphans: { other: '个孤儿进程' },
564  threads: { other: '个线程' },
565  inThisRepo: '本仓库中:',
566  orphanOf: label => `孤儿进程 ${label}`,
567  outsideWhere: '会话之外',
568  open: '打开 ↗',
569  showProcs: '进程',
570  hideProcs: '收起',
571  kill: '结束',
572  killTitle: (name, pid) => `结束 ${name} #${pid}?`,
573  killNote: '连同其子进程一起结束',
574  killYes: '是,结束',
575  killNo: '取消',
576  killed: (name, pid, where) => `已结束 ${name} #${pid}(${where})`,
577  killFailed: (name, pid, why) => `无法结束 ${name} #${pid}:${why}`,
578  cannotKill: (name, pid) => `${name} #${pid}:无法在此结束该进程`,
579  openFailed: why => `无法打开会话:${why}`,
580
581  headLine: (sessions, cpu, mem, threads, ageS) => `${sessions} · ${cpu} CPU · ${mem} · ${threads} · ${ageS} 秒前`,
582  footer: m => `图表显示最近 ${m} 分钟的 CPU · /load band on|off|auto 控制输入框上方的负载行`,
583  graphAlt: m => `最近 ${m} 分钟的 CPU`,
584  bandLabel: '负载',
585  reportClaude: (sessions, cpu, mem, ageS) => `Claude:${sessions} · ${cpu} CPU · ${mem} · ${ageS} 秒前采样`,
586  memOf: (used, total) => `${used} / ${total}`,
587  reportOrphans: '孤儿进程 — 父进程已退出,进程仍在运行',
588  reportOutside: '会话之外:',
589}
590
591const ja: Dict = {
592  decimal: '.',
593  mb: 'MB',
594  gb: 'GB',
595  untitled: '(無題)',
596  paneTitle: 'セッション別の負荷',
597  waiting: '最初のサンプルを待っています…',
598  samplerStarting: 'サンプラーを起動中です。最初のサンプルは数秒後に届きます。',
599  unsupported: 'claude-load はこのプラットフォームに対応していません。',
600  commandDescription: 'セッション別の負荷:各 Claude Code セッションの CPU とメモリ、孤立プロセス、その他のプロセス (claude-load)',
601  bandUsage: '/load band on | off | auto — auto は CPU 50% 以上またはメモリ 85% 以上のときだけ表示します',
602  bandSet: m => `入力欄の上の負荷行:${m === 'auto' ? '高負荷時のみ表示' : m === 'on' ? '常に表示' : 'オフ'}。`,
603  samplerFailed: why => `claude-load:サンプラーを起動できませんでした:${why}`,
604  samplerBlocked: 'サンプラーが起動しません。ポリシーで PowerShell スクリプトがブロックされている可能性があります(AllSigned、Constrained Language Mode)。',
605
606  filterAll: 'すべて',
607  filterActive: 'アクティブ',
608  filterHeavy: '高負荷',
609  filterOrphans: '孤立',
610  filterSelf: 'このウィンドウ',
611  groupLabel: 'グループ化 ',
612  groupSessions: 'セッション別',
613  groupRepos: 'リポジトリ別',
614  groupProcs: 'プロセス別',
615  sortLabel: '並べ替え ',
616  sortCpu: 'CPU 順',
617  sortMem: 'メモリ順',
618  triage: '✨ Claude に整理してもらう',
619  triageToast: 'Claude が負荷を整理しています。回答はこのセッションに表示されます',
620
621  sectionActive: 'アクティブ',
622  sectionSessions: 'セッション',
623  sectionIdle: 'アイドル',
624  sectionOrphans: '孤立プロセス',
625  orphansNote: '親プロセスは終了、プロセスは実行中',
626  sectionOutside: 'セッション外',
627  sectionProcs: 'プロセス',
628  names: { other: '種類' },
629  moreIdle: (n, mem) => `他 ${n} 件 · ${mem}`,
630  empty: 'このフィルターに一致する項目はありません。',
631
632  thisWindow: 'このウィンドウ',
633  procs: { other: 'プロセス' },
634  sessions: { other: 'セッション' },
635  orphans: { other: '孤立プロセス' },
636  threads: { other: 'スレッド' },
637  inThisRepo: 'このリポジトリ内:',
638  orphanOf: label => `孤立プロセス ${label}`,
639  outsideWhere: 'セッション外',
640  open: '開く ↗',
641  showProcs: 'プロセス',
642  hideProcs: '折りたたむ',
643  kill: '終了',
644  killTitle: (name, pid) => `${name} #${pid} を終了しますか?`,
645  killNote: '子プロセスも一緒に終了します',
646  killYes: 'はい、終了',
647  killNo: 'キャンセル',
648  killed: (name, pid, where) => `${name} #${pid} を終了しました(${where})`,
649  killFailed: (name, pid, why) => `${name} #${pid} を終了できませんでした:${why}`,
650  cannotKill: (name, pid) => `${name} #${pid}:このプロセスはここから終了できません`,
651  openFailed: why => `セッションを開けませんでした:${why}`,
652
653  headLine: (sessions, cpu, mem, threads, ageS) => `${sessions} · ${cpu} CPU · ${mem} · ${threads} · ${ageS} 秒前`,
654  footer: m => `グラフは直近 ${m} 分の CPU を表示 · /load band on|off|auto で入力欄の上の行を切り替え`,
655  graphAlt: m => `直近 ${m} 分の CPU`,
656  bandLabel: '負荷',
657  reportClaude: (sessions, cpu, mem, ageS) => `Claude:${sessions} · ${cpu} CPU · ${mem} · ${ageS} 秒前にサンプリング`,
658  memOf: (used, total) => `${used} / ${total}`,
659  reportOrphans: '孤立プロセス — 親プロセスは終了、プロセスは実行中',
660  reportOutside: 'セッション外:',
661}
662
663export const STRINGS: Record<Lang, Dict> = { en, ru, de, es, fr, pt, zh, ja }
664
665const PLURAL_FALLBACK: Record<Lang, (n: number) => keyof Forms> = {
666  en: n => (n === 1 ? 'one' : 'other'),
667  de: n => (n === 1 ? 'one' : 'other'),
668  es: n => (n === 1 ? 'one' : 'other'),
669  pt: n => (n === 1 ? 'one' : 'other'),
670  fr: n => (n === 0 || n === 1 ? 'one' : 'other'),
671  zh: () => 'other',
672  ja: () => 'other',
673  ru: n => {
674    const d = n % 10
675    const h = n % 100
676    if (d === 1 && h !== 11) return 'one'
677    if (d >= 2 && d <= 4 && (h < 12 || h > 14)) return 'few'
678    return 'many'
679  },
680}
681
682/** "15 sessions", "2 сессии": the number and the word in the plural form the language asks for. */
683export function plural(lang: Lang, n: number, forms: Forms): string {
684  let cat: keyof Forms
685  try {
686    cat = new Intl.PluralRules(lang).select(n) as keyof Forms
687  } catch {
688    cat = PLURAL_FALLBACK[lang](n)
689  }
690  return `${n} ${forms[cat] ?? forms.other}`
691}
692
693export function fmtMem(lang: Lang, mb: number): string {
694  const d = STRINGS[lang]
695  return mb >= 1024 ? `${(mb / 1024).toFixed(1).replace('.', d.decimal)} ${d.gb}` : `${mb} ${d.mb}`
696}
697
698/** Maps a language name or locale ("russian", "ru-RU", "Deutsch", "zh_CN.UTF-8") to a supported language. */
699export function matchLang(v: unknown): Lang | null {
700  if (typeof v !== 'string') return null
701  const s = v.trim().toLowerCase()
702  if (!s) return null
703  const table: Array<[Lang, RegExp]> = [
704    ['ru', /^(ru|russian|рус)/],
705    ['de', /^(de|german|deutsch)/],
706    ['es', /^(es|spanish|español|espanol|castellano)/],
707    ['fr', /^(fr|french|français|francais)/],
708    ['pt', /^(pt|portuguese|português|portugues)/],
709    ['zh', /^(zh|chinese|中文|汉语|漢語)/],
710    ['ja', /^(ja|japanese|日本語)/],
711    ['en', /^(en|english)/],
712  ]
713  for (const [lang, re] of table) if (re.test(s)) return lang
714  return null
715}
716
717/** A label followed by its value: a space, except after a full-width colon (zh, ja). */
718export function lead(label: string): string {
719  return label.endsWith(':') ? label : `${label} `
720}
721
722/** The first supported language among the candidates, in order: the option, settings, env, Intl. */
723export function pickLang(option: unknown, candidates: unknown[]): Lang {
724  if (typeof option === 'string' && (LANGS as readonly string[]).includes(option)) return option as Lang
725  for (const c of candidates) {
726    const l = matchLang(c)
727    if (l) return l
728  }
729  try {
730    const fromIntl = matchLang(Intl.DateTimeFormat().resolvedOptions().locale)
731    if (fromIntl) return fromIntl
732  } catch {
733    // A runtime without Intl: English.
734  }
735  return 'en'
736}
737
hooks/posix.ts 352 lines
1// The macOS/Linux sampler's brain: pure functions only (no engine, no I/O). The glue in register.tsx
2// runs ps / vm_stat / lsof, reads /proc/meminfo and the session files, and feeds the text in here.
3// It mirrors sampler/load-sampler.ps1: ownership by parent chain, orphan rules, folding, history.
4// An orphan is a tree whose parent died: a root (not owned by a session, parent gone or reparented
5// to init/systemd/launchd) with all its descendants; a live shell under a live terminal is not one.
6
7import type { ByName, OrphanLoad, OutsideLoad, SessionLoad, Snapshot, Top } from './types'
8
9export type PsRow = { pid: number; ppid: number; cpuSec: number; rssKB: number; name: string }
10export type SessionMeta = { pid: number; sessionId: string; hostSessionId: string; name: string; status: string; cwd: string }
11export type SamplerMemory = { prevCpu: Map<number, number>; prevAt: number; hist: Map<string, number[]> }
12
13/** Same set as the ps1 $devNames plus the usual unix shells and tools; also matches with ".exe" and python3.11. */
14export const DEV_NAMES = /^(node|python[0-9.]*|pythonw|cargo|rustc|rust-analyzer|java|esbuild|pnpm|npm|bash|sh|zsh|fish|git|adb|gradle|tsc|vite|uv|deno|bun|make|ruby|go|gopls|cmake)(\.exe)?$/i
15
16const HIST_LEN = 90
17const ORPHAN_CPU = 0.3
18const ORPHAN_RSS_KB = 150 * 1024
19/** Shells and console hosts alone never make a tree worth reporting. */
20const SHELLS = /^(bash|sh|zsh|fish|powershell|pwsh|cmd|conhost)(\.exe)?$/i
21/** A parent with such a name is a reaper: the child has lost its real parent. */
22const INIT_NAMES = /^(init|systemd|launchd)$/
23
24export function freshMemory(): SamplerMemory {
25  return { prevCpu: new Map(), prevAt: 0, hist: new Map() }
26}
27
28// ---------------------------------------------------------------- parsers
29
30/** Seconds from the ps `time` column: [[dd-]hh:]mm:ss on Linux, [hh:]mm:ss.cc on macOS. NaN when malformed. */
31export function parseCpuTime(s: string): number {
32  const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(s.trim())
33  if (!m) return NaN
34  return Number(m[1] ?? 0) * 86400 + Number(m[2] ?? 0) * 3600 + Number(m[3]) * 60 + Number(m[4])
35}
36
37function commName(comm: string): string {
38  let c = comm.trim().replace(/ \(deleted\)$/, '')
39  // Only a path is cut to its last segment: Linux kernel threads ("kworker/0:1") are not paths.
40  if (c.startsWith('/')) c = c.slice(c.lastIndexOf('/') + 1)
41  return c.replace(/^-(?=[A-Za-z])/, '') // login shells show as "-zsh"
42}
43
44/** Output of `ps -axo pid=,ppid=,time=,rss=,comm=` (macOS) or `ps -eo ...` (Linux). Malformed lines are skipped. */
45export function parsePs(text: string): PsRow[] {
46  const rows: PsRow[] = []
47  for (const line of text.split(/\r?\n/)) {
48    const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\d+)\s+(\S.*?)\s*$/.exec(line)
49    if (!m) continue
50    const cpuSec = parseCpuTime(m[3])
51    const name = commName(m[5])
52    if (Number.isNaN(cpuSec) || !name) continue
53    rows.push({ pid: Number(m[1]), ppid: Number(m[2]), cpuSec, rssKB: Number(m[4]), name })
54  }
55  return rows
56}
57
58/** Linux /proc/meminfo: used = MemTotal - MemAvailable. */
59export function parseMeminfo(text: string): { totalMB: number; usedMB: number } {
60  const kb = (key: string): number | undefined => {
61    const m = new RegExp('^' + key + ':\\s+(\\d+)', 'm').exec(text)
62    return m ? Number(m[1]) : undefined
63  }
64  const total = kb('MemTotal') ?? 0
65  const avail = kb('MemAvailable') ?? (kb('MemFree') ?? 0) + (kb('Buffers') ?? 0) + (kb('Cached') ?? 0)
66  return { totalMB: Math.round(total / 1024), usedMB: Math.round(Math.max(0, total - avail) / 1024) }
67}
68
69/** macOS vm_stat: available = free + inactive + speculative + purgeable pages. */
70export function parseVmStat(text: string, totalBytes: number): { totalMB: number; usedMB: number } {
71  const size = Number(/page size of (\d+) bytes/.exec(text)?.[1] ?? 16384)
72  const pages = new Map<string, number>()
73  for (const m of text.matchAll(/^Pages ([a-z ]+?):\s+(\d+)\./gm)) pages.set(m[1], Number(m[2]))
74  const availPages = ['free', 'inactive', 'speculative', 'purgeable'].reduce((a, k) => a + (pages.get(k) ?? 0), 0)
75  const usedBytes = Math.max(0, totalBytes - availPages * size)
76  return { totalMB: Math.round(totalBytes / 1048576), usedMB: Math.round(usedBytes / 1048576) }
77}
78
79/** Output of `lsof -a -d cwd -Fpn -p ...`: `p<pid>` then `n<path>`. */
80export function parseLsofCwd(text: string): Map<number, string> {
81  const out = new Map<number, string>()
82  let pid = 0
83  for (const line of text.split(/\r?\n/)) {
84    if (line.startsWith('p')) pid = Number(line.slice(1)) || 0
85    else if (line.startsWith('n') && pid > 0 && !out.has(pid)) out.set(pid, line.slice(1))
86  }
87  return out
88}
89
90/** ~/.claude/sessions/<pid>.json; null when it is not a session record. */
91export function parseSessionFile(text: string): SessionMeta | null {
92  let j: unknown
93  try { j = JSON.parse(text) } catch { return null }
94  if (!j || typeof j !== 'object') return null
95  const o = j as Record<string, unknown>
96  const pid = Number(o.pid)
97  if (!Number.isInteger(pid) || pid <= 0) return null
98  const str = (v: unknown) => (v === undefined || v === null ? '' : String(v))
99  return { pid, sessionId: str(o.sessionId), hostSessionId: str(o.hostSessionId), name: str(o.name), status: str(o.status), cwd: str(o.cwd) }
100}
101
102// ---------------------------------------------------------------- repository labels
103
104const SYSTEM_DIR = /^\/(usr|bin|sbin|lib|lib64|etc|dev|sys|proc|System|Library|Applications|private\/var)(\/|$)/
105const baseName = (p: string) => p.slice(p.lastIndexOf('/') + 1)
106
107/** Worktree dirs fold into their checkout ("repo/feat-a"); other dirs label themselves; system dirs give null. */
108export function repoLabel(dir: string): { root: string; label: string } | null {
109  const d = dir.replace(/\/+$/, '')
110  if (!d || SYSTEM_DIR.test(d)) return null
111  const m = /^(.*?)\/\.claude\/worktrees\/([^/]+)/.exec(d)
112  if (m) return { root: m[1], label: baseName(m[1]) + '/' + m[2] }
113  return { root: d, label: baseName(d) }
114}
115
116// ---------------------------------------------------------------- analysis
117
118type Proc = { pid: number; name: string; cpu: number; rssKB: number }
119type Folded = { name: string; count: number; cpu: number; rssKB: number }
120
121const round1 = (x: number) => Math.round(x * 10) / 10
122const toMB = (kb: number) => Math.round(kb / 1024)
123const nameCmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
124const weight = (cpu: number, rssKB: number) => cpu * 1e6 + rssKB / 1024
125
126/** Per-process CPU % of the whole machine since the previous pass, and the owning session of every process. */
127function analyze(rows: PsRow[], sessions: SessionMeta[], memory: SamplerMemory, now: number, cores: number) {
128  const dtSec = memory.prevAt > 0 ? (now - memory.prevAt) / 1000 : 0
129  const procs: Proc[] = []
130  const byPid = new Map<number, PsRow>()
131  for (const r of rows) {
132    if (byPid.has(r.pid)) continue
133    byPid.set(r.pid, r)
134    const prev = memory.prevCpu.get(r.pid)
135    const cpu = dtSec > 0 && prev !== undefined ? Math.max(0, (r.cpuSec - prev) / (dtSec * Math.max(1, cores)) * 100) : 0
136    procs.push({ pid: r.pid, name: r.name, cpu, rssKB: r.rssKB })
137  }
138  const live = new Map<number, SessionMeta>()
139  for (const s of sessions) if (byPid.has(s.pid) && !live.has(s.pid)) live.set(s.pid, s)
140
141  // Walk ppid up to a session pid (64 steps at most); memoized along the chain. 0 = no session.
142  const owner = new Map<number, number>()
143  const ownerOf = (id: number): number => {
144    const chain: number[] = []
145    let x = id
146    for (let i = 0; i < 64; i++) {
147      const known = owner.get(x)
148      if (known !== undefined) { for (const y of chain) owner.set(y, known); return known }
149      if (live.has(x)) { for (const y of chain) owner.set(y, x); owner.set(x, x); return x }
150      chain.push(x)
151      const px = byPid.get(x)?.ppid ?? 0
152      if (px === 0 || px === x || !byPid.has(px)) break
153      x = px
154    }
155    for (const y of chain) owner.set(y, 0)
156    return 0
157  }
158  // The processes above a session (the shell or terminal it runs in) are never orphans: ending
159  // one would end the session with it.
160  const hosts = new Set<number>()
161  for (const pid of live.keys()) {
162    let x = byPid.get(pid)?.ppid ?? 0
163    for (let i = 0; i < 64 && x > 1 && byPid.has(x) && !hosts.has(x); i++) {
164      hosts.add(x)
165      x = byPid.get(x)?.ppid ?? 0
166    }
167  }
168  return { procs, byPid, live, ownerOf, dtSec, hosts }
169}
170
171type Tree = { root: Proc; members: Proc[]; heaviest: Proc; cpu: number; rssKB: number }
172
173const isQualifying = (p: Proc) => DEV_NAMES.test(p.name) && !SHELLS.test(p.name)
174const heavier = (a: Proc, b: Proc) => weight(b.cpu, b.rssKB) - weight(a.cpu, a.rssKB) || a.pid - b.pid
175
176/**
177 * Orphan trees: a root outside every session (a dev-named process whose parent is dead: absent,
178 * pid 1, or named init/systemd/launchd) with all its descendants. A tree is worth reporting when
179 * it holds a qualifying process (a dev tool, not a shell) and weighs enough. Trees are disjoint:
180 * another root below a root is not descended into. Ordered as the root rows.
181 */
182function orphanTrees(a: ReturnType<typeof analyze>, selfPids: number[]): Tree[] {
183  const { procs, byPid, ownerOf, hosts } = a
184  const procOf = new Map(procs.map(p => [p.pid, p]))
185  const parentDead = (p: Proc): boolean => {
186    const pp = byPid.get(p.pid)!.ppid
187    return pp === p.pid || pp === 1 || !byPid.has(pp) || INIT_NAMES.test(byPid.get(pp)!.name)
188  }
189  const kids = new Map<number, Proc[]>()
190  for (const p of procs) {
191    if (selfPids.includes(p.pid) || ownerOf(p.pid) !== 0 || parentDead(p)) continue
192    const pp = byPid.get(p.pid)!.ppid
193    const l = kids.get(pp)
194    if (l) l.push(p); else kids.set(pp, [p])
195  }
196  const trees: Tree[] = []
197  for (const root of procs) {
198    // Only a dev-named root: the app that merely hosts the work (a terminal, an IDE, sshd) is not
199    // an orphan, and ending its tree would end every live shell below it.
200    if (root.pid <= 1 || selfPids.includes(root.pid) || ownerOf(root.pid) !== 0 || hosts.has(root.pid)) continue
201    if (!DEV_NAMES.test(root.name) || !parentDead(root)) continue
202    const members: Proc[] = []
203    const seen = new Set([root.pid])
204    const stack = [root]
205    for (let x = stack.pop(); x; x = stack.pop()) {
206      members.push(x)
207      for (const c of kids.get(x.pid) ?? []) if (!seen.has(c.pid)) { seen.add(c.pid); stack.push(c) }
208    }
209    const qual = members.filter(isQualifying).sort(heavier)
210    const cpu = members.reduce((s, m) => s + m.cpu, 0)
211    const rssKB = members.reduce((s, m) => s + m.rssKB, 0)
212    if (!qual.length || !(cpu >= ORPHAN_CPU || rssKB >= ORPHAN_RSS_KB) || members.some(m => hosts.has(m.pid))) continue
213    trees.push({ root: procOf.get(root.pid)!, members, heaviest: qual[0], cpu, rssKB })
214  }
215  return trees
216}
217
218/** PIDs whose cwd the caller must resolve: the root of every reportable orphan tree, and its heaviest qualifying member (the root may sit in a system dir). */
219export function orphanCandidates(rows: PsRow[], sessions: SessionMeta[], memory: SamplerMemory, now: number, cores: number, selfPids: number[] = []): number[] {
220  const out: number[] = []
221  for (const t of orphanTrees(analyze(rows, sessions, memory, now, cores), selfPids)) {
222    out.push(t.root.pid)
223    if (t.heaviest.pid !== t.root.pid) out.push(t.heaviest.pid)
224  }
225  return out
226}
227
228const toTop = (p: Proc): Top => ({ name: p.name, pid: p.pid, cpu: round1(p.cpu), memMB: toMB(p.rssKB) })
229const topOf = (procs: Proc[], n: number): Top[] => [...procs].sort(heavier).slice(0, n).map(toTop)
230
231function fold(procs: Proc[]): Folded[] {
232  const m = new Map<string, Folded>()
233  for (const p of procs) {
234    let f = m.get(p.name)
235    if (!f) m.set(p.name, f = { name: p.name, count: 0, cpu: 0, rssKB: 0 })
236    f.count++; f.cpu += p.cpu; f.rssKB += p.rssKB
237  }
238  return [...m.values()]
239}
240
241const topFolded = (f: Folded[], n: number): ByName[] =>
242  f.sort((a, b) => weight(b.cpu, b.rssKB) - weight(a.cpu, a.rssKB) || nameCmp(a.name, b.name)).slice(0, n)
243    .map(x => ({ name: x.name, count: x.count, cpu: round1(x.cpu), memMB: toMB(x.rssKB) }))
244
245function pushHist(hist: Map<string, number[]>, key: string, val: number) {
246  let l = hist.get(key)
247  if (!l) hist.set(key, l = [])
248  l.push(round1(val))
249  if (l.length > HIST_LEN) l.splice(0, l.length - HIST_LEN)
250}
251
252export function buildSnapshot(
253  input: {
254    now: number; intervalMs: number; cores: number; mem: { totalMB: number; usedMB: number }
255    rows: PsRow[]; sessions: SessionMeta[]; cwdOf: Map<number, string>; selfPids?: number[]
256  },
257  memory: SamplerMemory,
258): Snapshot {
259  const { now, cores, mem, cwdOf } = input
260  const selfPids = input.selfPids ?? []
261  const analysis = analyze(input.rows, input.sessions, memory, now, cores)
262  const { procs, live, ownerOf, dtSec } = analysis
263  const warm = dtSec > 0
264
265  const owned = new Map<number, Proc[]>()
266  const orphanGroups = new Map<string, { label: string; root: string; trees: Tree[] }>()
267  const reported = new Set<number>()
268  const outside: Proc[] = []
269  let total = 0
270
271  // The directory of a tree: the root's, else the heaviest qualifying member's; system dirs give nothing.
272  const dirRepo = (pid: number) => { const dir = cwdOf.get(pid); return dir ? repoLabel(dir) : null }
273  for (const t of orphanTrees(analysis, selfPids)) {
274    const repo = dirRepo(t.root.pid) ?? dirRepo(t.heaviest.pid)
275    if (!repo) continue
276    let g = orphanGroups.get(repo.label)
277    if (!g) orphanGroups.set(repo.label, g = { label: repo.label, root: repo.root, trees: [] })
278    g.trees.push(t)
279    for (const m of t.members) reported.add(m.pid)
280  }
281
282  for (const p of procs) {
283    total += p.cpu
284    const o = ownerOf(p.pid)
285    if (o !== 0) {
286      const l = owned.get(o)
287      if (l) l.push(p); else owned.set(o, [p])
288      continue
289    }
290    if (!reported.has(p.pid)) outside.push(p)
291  }
292
293  const sum = (l: Proc[], k: 'cpu' | 'rssKB') => l.reduce((a, p) => a + p[k], 0)
294
295  const sessionRows = [...live.values()].map(s => {
296    const l = owned.get(s.pid) ?? []
297    return { s, cpu: sum(l, 'cpu'), load: { ...s, cpu: 0, memMB: toMB(sum(l, 'rssKB')), procs: l.length, top: topOf(l, 4), byName: topFolded(fold(l), 8) } as SessionLoad }
298  })
299  sessionRows.sort((a, b) => b.cpu - a.cpu || a.s.pid - b.s.pid)
300  for (const r of sessionRows) r.load.cpu = round1(r.cpu)
301
302  const orphanRows = [...orphanGroups.values()].map(g => {
303    const all = g.trees.flatMap(t => t.members)
304    // Each tree's root first (ending it with its tree ends the lot), then the heaviest other members.
305    const roots = [...g.trees].sort((a, b) => weight(b.cpu, b.rssKB) - weight(a.cpu, a.rssKB) || a.root.pid - b.root.pid).map(t => t.root)
306    const rootPids = new Set(roots.map(r => r.pid))
307    const top = [...roots.slice(0, 3).map(toTop), ...topOf(all.filter(p => !rootPids.has(p.pid)), Math.max(0, 3 - roots.length))]
308    return {
309      cpu: sum(all, 'cpu'),
310      load: { label: g.label, root: g.root, cpu: 0, memMB: toMB(sum(all, 'rssKB')), procs: all.length, top } as OrphanLoad,
311    }
312  })
313  orphanRows.sort((a, b) => b.cpu - a.cpu || nameCmp(a.load.label, b.load.label))
314  for (const r of orphanRows) r.load.cpu = round1(r.cpu)
315
316  const outsideOut: OutsideLoad[] = topFolded(fold(outside), 6)
317  const machineCpu = Math.min(100, total)
318
319  if (warm) {
320    const seen = new Set(['m:cpu', 'm:mem'])
321    pushHist(memory.hist, 'm:cpu', machineCpu)
322    pushHist(memory.hist, 'm:mem', mem.totalMB > 0 ? mem.usedMB / mem.totalMB * 100 : 0)
323    for (const r of sessionRows) { const k = 's:' + r.s.sessionId; pushHist(memory.hist, k, r.cpu); seen.add(k) }
324    for (const r of orphanRows) { const k = 'o:' + r.load.label; pushHist(memory.hist, k, r.cpu); seen.add(k) }
325    for (const k of [...memory.hist.keys()]) if (!seen.has(k)) memory.hist.delete(k)
326  }
327  const hist: NonNullable<Snapshot['hist']> = { cpu: [], mem: [], sessions: {}, orphans: {} }
328  for (const [k, v] of memory.hist) {
329    if (k === 'm:cpu') hist.cpu = [...v]
330    else if (k === 'm:mem') hist.mem = [...v]
331    else if (k.startsWith('s:')) hist.sessions[k.slice(2)] = [...v]
332    else if (k.startsWith('o:')) hist.orphans[k.slice(2)] = [...v]
333  }
334
335  memory.prevCpu = new Map(input.rows.map(r => [r.pid, r.cpuSec]))
336  memory.prevAt = now
337
338  return {
339    ts: now,
340    intervalMs: input.intervalMs,
341    cores,
342    cpu: round1(machineCpu),
343    memTotalMB: Math.round(mem.totalMB),
344    memUsedMB: Math.round(mem.usedMB),
345    warm,
346    sessions: sessionRows.map(r => r.load),
347    orphans: orphanRows.map(r => r.load),
348    outside: outsideOut,
349    hist,
350  }
351}
352
hooks/types.ts 50 lines
1// The snapshot every sampler writes to <home>/.claude/state/load/snapshot.json and every
2// session's mod reads. Windows: sampler/load-sampler.ps1. macOS and Linux: hooks/posix.ts,
3// run inside one session's mod (the leader).
4
5export type Top = { name: string; pid: number; cpu: number; memMB: number }
6export type ByName = { name: string; count: number; cpu: number; memMB: number }
7
8export type SessionLoad = {
9  pid: number
10  name: string
11  status: string
12  cwd: string
13  hostSessionId: string
14  sessionId: string
15  /** % of the whole machine (all cores = 100). */
16  cpu: number
17  memMB: number
18  procs: number
19  /** The heaviest processes of the session's tree. */
20  top: Top[]
21  /** The session's processes folded by name. */
22  byName?: ByName[]
23}
24
25/** A process whose parent died, attributed to a repository or worktree by its working directory. */
26export type OrphanLoad = { label: string; root: string; cpu: number; memMB: number; procs: number; top: Top[] }
27
28/** Processes outside every session, folded by name. */
29export type OutsideLoad = { name: string; count: number; cpu: number; memMB: number }
30
31/** Recent points for the graphs, one per sample, oldest first. */
32export type History = { cpu: number[]; mem: number[]; sessions: Record<string, number[]>; orphans: Record<string, number[]> }
33
34export type Snapshot = {
35  /** Epoch milliseconds of the sample. */
36  ts: number
37  intervalMs: number
38  cores: number
39  /** % of the whole machine. */
40  cpu: number
41  memTotalMB: number
42  memUsedMB: number
43  /** False on a sampler's first pass, which has no CPU delta yet. */
44  warm: boolean
45  sessions: SessionLoad[]
46  orphans: OrphanLoad[]
47  outside: OutsideLoad[]
48  hist?: History
49}
50