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…

English · Русский · Deutsch · Español · Français · Português · 中文 · 日本語
See which Claude Code session eats your CPU and memory — and clean it up in one click.
<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>
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.
| Requires | Claude 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 |
| Update | claude plugin marketplace update claude-load then claude plugin update claude-load@claude-load, then /reload-plugins |
| Remove | claude plugin uninstall claude-load@claude-load (and claude plugin marketplace remove claude-load) |
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 session | CPU, memory, process count, the heaviest processes inside, a 6-minute CPU graph |
| ⚠️ Orphans | dev 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 else | other processes folded by name (chrome ×31), so you see whether the problem is Claude at all |
| 🔎 Filters & groups | all · active · heavy · orphans · this window; by session, by repository (worktrees folded in), by process name; sort by CPU or memory |
| ✨ Sort it out with Claude | one button: the bundled sessions-triage skill classifies idle sessions and orphans, asks once, archives or ends only what you approve |
| 🚦 Load line | above the prompt under high load (CPU ≥ 50% or memory ≥ 85%), with the top consumers |
| 🌍 8 languages | English, Русский, Deutsch, Español, Français, Português, 中文, 日本語 |
| Command | What it does |
|---|---|
/load | Opens the pane and prints a compact report in the transcript |
/load all | The same, with every idle session listed |
/load band on · off · auto | The load line above the prompt: always, never, or only under high load (default) |
In the pane:
claude processes and PIDs not in the current sample cannot be ended from here.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.
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.
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.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.
AllSigned or Constrained Language Mode). The sampler needs to run sampler/load-sampler.ps1 from the plugin folder.ps is on PATH; lsof (macOS) is needed only for orphans.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.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
Screenshots are rendered from docs/mock/index.html; see docs/mock/README.md.
hooks/register.tsx 1004 lines1import 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}
1004hooks/i18n.ts 737 lines1// 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}
737hooks/posix.ts 352 lines1// 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}
352hooks/types.ts 50 lines1// 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