Live pane listing the processes this Claude Code session started: dev servers, tool shells and MCP servers, with memory, age and sparklines

A live pane that lists every process the current Claude Code session started, so a dev server you asked Claude to run never gets lost.
<img alt="session-procs docked in the sidebar, dark theme" src="../../docs/screenshots/sidebar-dark.png" width="300"> <img alt="session-procs docked in the sidebar, light theme" src="../../docs/screenshots/sidebar-light.png" width="300">
The screenshots use demo data. The wider layout, with PID and memory-meter columns, appears when you widen the pane:
<img alt="session-procs in a wide pane, with PID, memory meter and age columns" src="../../docs/screenshots/wide-light.png" width="720">
$ npm run dev), and the processes under it (node …/bin/next dev) with PID, memory and age.procs: N running from tools under the prompt while something is running.On the desktop app the pane docks beside the session sidebar and draws cards that follow your light or dark theme. In a terminal it draws a themed text view.
A one-row band above the prompt shows how many processes run from tools and how much memory they use. Press its Show processes button (or p while the band has focus, or type /procs) to open the pane. Close the pane any time; the band comes back. Collapse the band with its [-]. Press r to refresh and h to show or hide helpers while the pane has focus.
The mod asks the operating system for the process list, finds its own Claude Code process, and shows everything below it.
%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -NonInteractive -Command, started in %SystemRoot%\System32, running a fixed, readable Get-CimInstance Win32_Process query./bin/sh -c 'echo $$; PATH=$(command -p getconf PATH 2>/dev/null || echo /usr/bin:/bin); export PATH; exec ps -A -o pid= -o ppid= -o rss= -o etime= -o args='.It refreshes every 5 seconds while the pane is visible, every 30 seconds otherwise, and shortly after each Bash or PowerShell tool call. On Windows each refresh takes about a second of PowerShell start-up.
auth in any case style (--token=..., --api-key ..., --secretKey ..., PASSWORD=..., AccountKey=...), including values left in an unclosed quote. Flags that name where a secret lives (ending in stdin, file, path, prompt or env, such as --password-file or --tokenPath) are left alone.Password=...;, ?token=...&).Authorization, Proxy-Authorization, Cookie and X-...-Key, -Token, -Secret or -Auth headers, plus Bearer, Basic and Token values.scheme://user:password@host), Slack and Discord webhook URLs, and Telegram bot tokens.mysql, mysqldump, mysqladmin and mariadb (-p...), redis-cli (-a), curl (-u, --user, --proxy-user), sqlcmd (-P), sshpass (-p), docker login (-p), openssl (-pass pass:...) and aws configure set.%SystemRoot%\System32\WindowsPowerShell\v1.0 (the system root is used only when it is a plain drive path, otherwise C:\Windows), and /bin/sh, which looks up ps in the system PATH reported by getconf, not in your own.cmd & inside a shell that exits before the next refresh can't be traced back to the session, so it won't show.claude plugin validate mods/session-procs
claude plugin test mods/session-procs
Made by Iftah Saar. More at iftah.dev.
hooks/register.tsx 398 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { HistoryPoint, ProcGroup, Snapshot } from '../types'
5import { callout, cardWidth, groupsCard, kpiStrip, layoutFor } from './svg'
6import {
7 POSIX_PROBE_ARGV,
8 buildSnapshot,
9 cleanText,
10 clip,
11 formatAge,
12 formatBytes,
13 helperProcessCount,
14 parsePosixProbe,
15 parseWindowsProbe,
16 redactSecrets,
17 safeSystemRoot,
18 toolProcessCount,
19 totalRss,
20 windowsProbeArgv,
21} from './tree'
22import type { Probe } from './tree'
23
24const PANE = 'session-procs'
25const TITLE = 'Session processes'
26const TICK_MS = 5000
27const IDLE_PROBE_MS = 30000
28const AFTER_TOOL_MS = 1500
29const HISTORY_SIZE = 40
30const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
31const SIDEBAR_COLUMNS = 38
32const SPARK = '▁▂▃▄▅▆▇█'
33const MAX_TERMINAL_ROWS = 120
34const MAX_ERROR = 300
35
36const snapshot = atom({ plugin: 'session-procs', key: 'snapshot' } as const, null)
37const history = atom({ plugin: 'session-procs', key: 'history' } as const, [])
38const showHelpers = atom({ plugin: 'session-procs', key: 'showHelpers' } as const, false)
39const error = atom({ plugin: 'session-procs', key: 'error' } as const, null)
40const isPaneOpen = atom({ plugin: 'session-procs', key: 'isPaneOpen' } as const, false)
41
42let isWindows: boolean | undefined
43let isProbing = false
44let lastProbeAt = 0
45let hasLoggedRoot = false
46let known = new Map<number, number | null>()
47let pending: Timer | undefined
48let ticker: Timer | undefined
49
50async function probe($: EngineInterface, now: number): Promise<Probe> {
51 isWindows ??= (await $.env.get('OS')) === 'Windows_NT'
52 const systemRoot = safeSystemRoot(isWindows ? await $.env.get('SystemRoot') : undefined)
53 const ran = isWindows
54 ? await $.process.run(windowsProbeArgv(systemRoot), { timeoutMs: 20000, cwd: `${systemRoot}\\System32` })
55 : await $.process.run([...POSIX_PROBE_ARGV], { timeoutMs: 20000 })
56 if (ran.exitCode !== 0) {
57 const detail = redactSecrets(cleanText(ran.stderr)).replace(/\s+/g, ' ').trim()
58 throw new Error(`Could not list processes (exit ${ran.exitCode}).${detail === '' ? '' : ` ${clip(detail, 240)}`}`)
59 }
60 if (ran.isStdoutTruncated) throw new Error('The process list was too large to read.')
61 try {
62 return isWindows ? parseWindowsProbe(ran.stdout) : parsePosixProbe(ran.stdout, now)
63 } catch {
64 throw new Error('Could not read the process list.')
65 }
66}
67
68async function refresh($: EngineInterface): Promise<void> {
69 if (isProbing) return
70 isProbing = true
71 try {
72 const startedAt = await $.clock.now()
73 lastProbeAt = startedAt
74 const built = buildSnapshot(await probe($, startedAt), startedAt, known)
75 known = built.known
76 if (!hasLoggedRoot) {
77 hasLoggedRoot = true
78 $.ui.log(`session-procs: root ${built.snapshot.rootLabel}; chain ${built.snapshot.chain}`, { to: 'debug' })
79 }
80 const point: HistoryPoint = { at: startedAt, tools: toolProcessCount(built.snapshot), rss: totalRss(built.snapshot) }
81 await update($, snapshot, () => built.snapshot)
82 await update($, history, points => [...(points ?? []), point].slice(-HISTORY_SIZE))
83 await update($, error, () => null)
84 $.ui.status(point.tools > 0 ? `procs: ${point.tools} running from tools` : undefined)
85 } catch (err) {
86 const message = clip(redactSecrets(cleanText(err instanceof Error ? err.message : String(err))), MAX_ERROR)
87 await update($, error, () => message)
88 $.ui.log(`session-procs: ${message}`, { to: 'debug' })
89 } finally {
90 isProbing = false
91 }
92}
93
94async function tick($: EngineInterface): Promise<void> {
95 const isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
96 const now = await $.clock.now()
97 if (isShown || now - lastProbeAt >= IDLE_PROBE_MS) await refresh($)
98}
99
100function startTicker($: EngineInterface): void {
101 ticker?.cancel()
102 ticker = $.clock.every(TICK_MS, () => void tick($))
103}
104
105function refreshSoon($: EngineInterface): void {
106 pending?.cancel()
107 pending = $.clock.after(AFTER_TOOL_MS, () => void refresh($))
108}
109
110async function openPane($: EngineInterface) {
111 const surfaces = await $.session.surfaces()
112 const isTerminalOnly = surfaces.length > 0 && surfaces.every(surface => surface === 'terminal')
113 return $.ui.open(isTerminalOnly ? { id: PANE, title: TITLE } : { id: PANE, title: TITLE, columns: SIDEBAR_COLUMNS })
114}
115
116async function syncPaneOpen($: EngineInterface): Promise<void> {
117 const panes = await $.ui.panes()
118 await update($, isPaneOpen, () => panes.some(pane => pane.id === PANE))
119}
120
121async function showPane($: EngineInterface) {
122 const opened = await openPane($)
123 await update($, isPaneOpen, () => true)
124 void refresh($)
125
126 return opened
127}
128
129function bandLabel(snap: Snapshot | null): string {
130 if (snap === null) return 'Looking for processes'
131 const count = toolProcessCount(snap)
132
133 return count > 0 ? `${count} running from tools` : 'Nothing running from tools'
134}
135
136function toggleHelpers($: EngineInterface): Promise<unknown> {
137 return update($, showHelpers, shown => !shown)
138}
139
140function sparkText(values: readonly number[]): string {
141 if (values.length < 2) return ''
142 const max = Math.max(...values)
143 const min = Math.min(...values)
144 return values
145 .slice(-16)
146 .map(value => SPARK[max === min ? 3 : Math.round(((value - min) / (max - min)) * (SPARK.length - 1))])
147 .join('')
148}
149
150function meterText(ratio: number): string {
151 const filled = Math.max(1, Math.round(Math.min(1, ratio) * 6))
152 return '▰'.repeat(filled) + '▱'.repeat(6 - filled)
153}
154
155function uptime(snap: Snapshot): string {
156 return formatAge(snap.rootStartedAt === null ? null : snap.takenAt - snap.rootStartedAt)
157}
158
159export const register: Register = on => {
160 on('session.start', async ($, e, next) => {
161 await $.command.register({
162 name: 'procs',
163 description: 'Show the processes this session started (tool shells, dev servers, MCP servers)',
164 })
165 void syncPaneOpen($).catch(() => undefined)
166 startTicker($)
167 void refresh($)
168
169 return next(e)
170 })
171
172 on('command.run', { command: 'procs' }, async $ => {
173 const opened = await showPane($)
174
175 return { text: opened.isPlaced ? 'Session processes pane opened.' : `Session processes pane waits: ${opened.reason}` }
176 })
177
178 on('ui.close', { id: PANE }, async ($, e, next) => {
179 const closed = await next(e)
180 await update($, isPaneOpen, () => false)
181
182 return closed
183 }).catch(($, e, next) => next(e))
184
185 on('tool.call', async ($, e, next) => {
186 const ran = await next(e)
187 if (SHELL_TOOLS.has(String(e.tool))) refreshSoon($)
188
189 return ran
190 }).catch(($, e, next) => next(e))
191
192 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
193 if (e.props.hasSurvey || (await read($, isPaneOpen))) return next(e)
194
195 const snap = await read($, snapshot)
196 const { Box, Button, Text } = $.ui.resolve(e)
197
198 return (
199 <Box flexDirection="row" gap={1}>
200 <Text color={snap !== null && toolProcessCount(snap) > 0 ? 'success' : 'subtle'}>●</Text>
201 <Box flexShrink={1}>
202 <Text wrap="truncate-end">{bandLabel(snap)}</Text>
203 </Box>
204 {snap !== null && (
205 <Box flexShrink={0}>
206 <Text dimColor>{`· ${formatBytes(totalRss(snap))}`}</Text>
207 </Box>
208 )}
209 <Box flexShrink={0}>
210 <Button key="open-procs" label="Show processes" hotkey="p" onPress={() => showPane($)} />
211 </Box>
212 </Box>
213 )
214 })
215
216 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
217 const snap = await read($, snapshot)
218 const points = await read($, history)
219 const isShowingHelpers = await read($, showHelpers)
220 const failure = await read($, error)
221
222 const toolGroups = snap?.groups.filter(group => group.kind !== 'helper') ?? []
223 const helperGroups = snap?.groups.filter(group => group.kind === 'helper') ?? []
224 const helperCount = snap ? helperProcessCount(snap) : 0
225 const helpersLabel = isShowingHelpers ? 'Hide MCP & helpers' : `Show MCP & helpers (${helperCount})`
226
227 if (e.surface === 'terminal') {
228 const { Box, Button, Text } = $.ui.resolve(e)
229 const takenAt = snap?.takenAt ?? 0
230 const age = (startedAt: number | null) => formatAge(startedAt === null ? null : takenAt - startedAt)
231 const tiles = snap
232 ? [
233 { label: 'RUNNING', value: String(toolProcessCount(snap)), spark: sparkText(points.map(point => point.tools)) },
234 { label: 'MCP & HELPERS', value: String(helperCount), spark: '' },
235 { label: 'MEMORY', value: formatBytes(totalRss(snap)), spark: sparkText(points.map(point => point.rss)) },
236 { label: 'SESSION', value: uptime(snap), spark: '' },
237 ]
238 : []
239
240 const drawGroups = (groups: readonly ProcGroup[]) => {
241 const maxRss = Math.max(1, ...groups.map(group => group.rss))
242 let budget = MAX_TERMINAL_ROWS
243 let hidden = 0
244 const drawn = groups.flatMap(whole => {
245 if (budget <= 0) {
246 hidden += 1 + whole.rows.length
247 return []
248 }
249 budget -= 1
250 const rows = whole.rows.slice(0, budget)
251 budget -= rows.length
252 hidden += whole.rows.length - rows.length
253 return [{ ...whole, rows }]
254 })
255 const cards = drawn.map(group => (
256 <Box flexDirection="column">
257 <Box flexDirection="row" gap={1}>
258 <Text color={group.kind === 'detached' ? 'warning' : 'success'}>●</Text>
259 {group.kind === 'detached' && <Text color="warning">detached</Text>}
260 <Box flexGrow={1} flexShrink={1}>
261 <Text bold wrap="truncate-end">{group.title}</Text>
262 </Box>
263 <Box flexShrink={0} gap={1}>
264 <Text color="subtle">{String(group.pid).padStart(7)}</Text>
265 <Text color="claude">{meterText(group.rss / maxRss)}</Text>
266 <Text>{formatBytes(group.rss).padStart(7)}</Text>
267 <Text color="subtle">{age(group.startedAt).padStart(7)}</Text>
268 </Box>
269 </Box>
270 {group.rows.map(row => (
271 <Box flexDirection="row" gap={1} paddingLeft={2 + row.depth * 2}>
272 <Text color="subtle">└</Text>
273 <Box flexGrow={1} flexShrink={1}>
274 <Text wrap="truncate-end">{row.label}</Text>
275 </Box>
276 <Box flexShrink={0} gap={1}>
277 <Text color="subtle">{String(row.pid).padStart(7)}</Text>
278 <Text color="claude">{meterText(row.rss / maxRss)}</Text>
279 <Text color="subtle">{formatBytes(row.rss).padStart(7)}</Text>
280 <Text color="subtle">{age(row.startedAt).padStart(7)}</Text>
281 </Box>
282 </Box>
283 ))}
284 </Box>
285 ))
286 return hidden > 0 ? [...cards, <Text color="subtle">{`+${hidden} more not shown`}</Text>] : cards
287 }
288
289 return (
290 <Box flexDirection="column" gap={1}>
291 <Box flexDirection="row" justifyContent="space-between">
292 <Box flexDirection="row" gap={1}>
293 <Text bold>Session processes</Text>
294 {snap && failure === null && <Text color="success">● live</Text>}
295 {failure !== null && <Text color="warning">● stale</Text>}
296 </Box>
297 <Text color="subtle" wrap="truncate-end">{snap ? `${snap.rootLabel} · up ${uptime(snap)}` : 'Looking for processes'}</Text>
298 </Box>
299 {tiles.length > 0 && (
300 <Box flexDirection="row" gap={3}>
301 {tiles.map(one => (
302 <Box flexDirection="column">
303 <Text color="subtle">{one.label}</Text>
304 <Box flexDirection="row" gap={1}>
305 <Text bold>{one.value}</Text>
306 {one.spark !== '' && <Text color="claude">{one.spark}</Text>}
307 </Box>
308 </Box>
309 ))}
310 </Box>
311 )}
312 <Box flexDirection="row" gap={1}>
313 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
314 <Button key="helpers" label={helpersLabel} hotkey="h" onPress={() => void toggleHelpers($)} />
315 </Box>
316 {failure !== null && <Text color="warning" wrap="truncate-end">{failure}</Text>}
317 {snap?.warning && <Text color="warning" wrap="truncate-end">{snap.warning}</Text>}
318 <Box flexDirection="column">
319 <Text color="subtle">{`STARTED BY TOOLS · ${toolGroups.length}`}</Text>
320 {snap !== null && toolGroups.length === 0 && (
321 <Text color="subtle">Nothing running from tools. Start a dev server with Bash and it shows up here.</Text>
322 )}
323 {drawGroups(toolGroups)}
324 </Box>
325 {isShowingHelpers && (
326 <Box flexDirection="column">
327 <Text color="subtle">{`MCP SERVERS & HELPERS · ${formatBytes(snap ? totalRss(snap, 'helpers') : 0)}`}</Text>
328 {helperGroups.length === 0 && <Text color="subtle">None.</Text>}
329 {drawGroups(helperGroups)}
330 </Box>
331 )}
332 </Box>
333 )
334 }
335
336 const { Box, Button, Svg, Text } = $.ui.resolve(e)
337 const width = cardWidth(e.props.bodyColumns)
338 const cards = snap
339 ? [
340 kpiStrip(snap, points, width),
341 groupsCard({
342 title: 'Started by tools',
343 groups: toolGroups,
344 width,
345 takenAt: snap.takenAt,
346 emptyTitle: 'Nothing running from tools',
347 emptyHint: 'Start a dev server with Bash and it shows up here.',
348 }),
349 ...(isShowingHelpers
350 ? [
351 groupsCard({
352 title: 'MCP servers & helpers',
353 groups: helperGroups,
354 width,
355 takenAt: snap.takenAt,
356 emptyTitle: 'No MCP servers or helpers',
357 emptyHint: 'Servers this session starts show up here.',
358 }),
359 ]
360 : []),
361 ]
362 : []
363 const notice = failure ?? snap?.warning ?? null
364 const isCompact = layoutFor(width) === 'compact'
365 const toggleLabel = isCompact ? (isShowingHelpers ? 'Hide helpers' : `Helpers (${helperCount})`) : helpersLabel
366
367 return (
368 <Box flexDirection="column" gap={1}>
369 <Box
370 flexDirection={isCompact ? 'column' : 'row'}
371 justifyContent="space-between"
372 alignItems={isCompact ? 'flex-start' : 'center'}
373 gap={1}
374 >
375 <Box flexDirection="row" gap={1}>
376 <Text bold>Session processes</Text>
377 {snap && failure === null && <Text color="success">●</Text>}
378 {snap && failure === null && <Text dimColor>Live</Text>}
379 </Box>
380 <Box flexDirection="row" gap={1}>
381 <Button key="helpers" label={toggleLabel} hotkey="h" onPress={() => void toggleHelpers($)} />
382 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
383 </Box>
384 </Box>
385 {notice !== null && <Svg {...svgProps(callout(failure !== null ? 'danger' : 'warning', notice, width))} />}
386 {snap === null && failure === null && <Text dimColor>Looking for this session's processes</Text>}
387 {cards.map(one => (
388 <Svg {...svgProps(one)} />
389 ))}
390 </Box>
391 )
392 })
393}
394
395function svgProps(one: { source: string; alt: string; width: number; height: number }) {
396 return { source: one.source, alt: one.alt }
397}
398hooks/svg.ts 411 lines1import type { HistoryPoint, ProcGroup, Snapshot } from '../types'
2import { clip, formatAge, formatBytes, helperProcessCount, toolProcessCount, totalRss } from './tree'
3
4export type SvgCard = { source: string; width: number; height: number; alt: string }
5
6export type CardLayout = 'wide' | 'medium' | 'compact'
7
8const SANS = "Inter, 'Segoe UI Variable Text', 'Segoe UI', system-ui, -apple-system, sans-serif"
9const MONO = "'JetBrains Mono', 'Cascadia Mono', Consolas, ui-monospace, Menlo, monospace"
10const SANS_EM = 0.56
11const MONO_EM = 0.6
12const LABEL_EM = 0.72
13const PX_PER_COLUMN = 7.3
14const GB = 1024 * 1024 * 1024
15const MAX_SOURCE = 131072
16const MAX_ALT = 600
17const MIN_ROWS = 8
18
19const DARK = {
20 card: '#15151a',
21 border: '#26262f',
22 borderStrong: '#3a3a46',
23 hover: '#22222b',
24 fg: '#ededf0',
25 muted: '#a6a6b0',
26 subtle: '#8e8e99',
27 accent: '#d97757',
28 accentSoft: '#2b1c16',
29 success: '#35b58a',
30 warning: '#e0a12b',
31 warningSoft: '#2a2010',
32 warningFg: '#f0b94e',
33 danger: '#f0605f',
34 dangerSoft: '#2e1616',
35 dangerFg: '#ff9090',
36}
37
38const LIGHT: typeof DARK = {
39 card: '#ffffff',
40 border: '#e4e4e0',
41 borderStrong: '#cfcfca',
42 hover: '#ececea',
43 fg: '#19191d',
44 muted: '#55555e',
45 subtle: '#696972',
46 accent: '#bd5230',
47 accentSoft: '#fbeee8',
48 success: '#0c8a60',
49 warning: '#b97400',
50 warningSoft: '#fcf2da',
51 warningFg: '#805100',
52 danger: '#cf3a3a',
53 dangerSoft: '#fdeaea',
54 dangerFg: '#ad2626',
55}
56
57function rules(p: typeof DARK): string {
58 return [
59 `.card{fill:${p.card};stroke:${p.border}}`,
60 `.track,.badge{fill:${p.hover}}`,
61 `.rule{stroke:${p.border}}`,
62 `.tree{stroke:${p.borderStrong};fill:none}`,
63 `.fg{fill:${p.fg}}`,
64 `.muted{fill:${p.muted}}`,
65 `.subtle{fill:${p.subtle}}`,
66 `.accent{fill:${p.accent}}`,
67 `.spark{stroke:${p.accent};fill:none}`,
68 `.spark-area{fill:${p.accentSoft}}`,
69 `.success{fill:${p.success}}`,
70 `.warning{fill:${p.warning}}`,
71 `.warning-soft{fill:${p.warningSoft}}`,
72 `.warning-fg{fill:${p.warningFg}}`,
73 `.danger{fill:${p.danger}}`,
74 `.danger-soft{fill:${p.dangerSoft}}`,
75 `.danger-fg{fill:${p.dangerFg}}`,
76 ].join('')
77}
78
79const STYLE = [
80 rules(DARK),
81 `.sans{font-family:${SANS}}`,
82 `.mono{font-family:${MONO}}`,
83 'text{font-variant-numeric:tabular-nums;white-space:pre}',
84 '.pulse{transform-box:fill-box;transform-origin:center;animation:pulse 2.4s ease-out infinite}',
85 '@keyframes pulse{0%{opacity:.55;transform:scale(1)}100%{opacity:0;transform:scale(2.8)}}',
86 '@media (prefers-reduced-motion: reduce){.pulse{animation:none;opacity:0}}',
87 `@media (prefers-color-scheme: light){${rules(LIGHT)}}`,
88].join('')
89
90export function cardWidth(bodyColumns: number | undefined): number {
91 return Math.max(260, Math.min(960, Math.round((bodyColumns ?? 88) * PX_PER_COLUMN)))
92}
93
94export function layoutFor(width: number): CardLayout {
95 if (width >= 520) return 'wide'
96 return width >= 400 ? 'medium' : 'compact'
97}
98
99const UNSAFE_XML_CHARS = /[\u0000-\u001f\u007f]+|[\ufffe\uffff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g
100
101export function esc(value: string): string {
102 return value
103 .replace(UNSAFE_XML_CHARS, ' ')
104 .replace(/&/g, '&')
105 .replace(/</g, '<')
106 .replace(/>/g, '>')
107 .replace(/"/g, '"')
108}
109
110export function fit(value: string, px: number, size: number, isMono = false, em = isMono ? MONO_EM : SANS_EM): string {
111 const flat = value.replace(/\s+/g, ' ').trim()
112 const max = Math.floor(px / (size * em))
113 if (max < 2) return ''
114 if (flat.length <= max) return flat
115 const chars = Array.from(flat)
116 return chars.length <= max ? flat : `${chars.slice(0, max - 1).join('')}…`
117}
118
119function num(value: number): string {
120 return String(Math.round(value * 10) / 10)
121}
122
123type TextOptions = {
124 cls: string
125 size: number
126 weight?: number
127 anchor?: 'end' | 'middle'
128 isMono?: boolean
129 spacing?: number
130}
131
132function text(x: number, y: number, value: string, o: TextOptions): string {
133 const attrs = [`x="${num(x)}"`, `y="${num(y)}"`, `class="${o.isMono ? 'mono' : 'sans'} ${o.cls}"`, `font-size="${o.size}"`]
134 if (o.weight) attrs.push(`font-weight="${o.weight}"`)
135 if (o.anchor) attrs.push(`text-anchor="${o.anchor}"`)
136 if (o.spacing) attrs.push(`letter-spacing="${o.spacing}"`)
137 return `<text ${attrs.join(' ')}>${esc(value)}</text>`
138}
139
140function textWidth(value: string, size: number, isMono = false): number {
141 return value.length * size * (isMono ? MONO_EM : SANS_EM)
142}
143
144function dot(cx: number, cy: number, cls: string, isLive: boolean): string {
145 const ring = isLive ? `<circle class="${cls} pulse" cx="${num(cx)}" cy="${num(cy)}" r="4"/>` : ''
146 return `${ring}<circle class="${cls}" cx="${num(cx)}" cy="${num(cy)}" r="4"/>`
147}
148
149function memoryClass(bytes: number): string {
150 if (bytes >= 2 * GB) return 'danger'
151 if (bytes >= GB) return 'warning'
152 return 'accent'
153}
154
155function meter(x: number, cy: number, width: number, ratio: number, cls: string): string {
156 const filled = Math.max(3, Math.round(width * Math.min(1, Math.max(0, ratio))))
157 const y = num(cy - 2)
158 return `<rect class="track" x="${num(x)}" y="${y}" width="${num(width)}" height="4" rx="2"/><rect class="${cls}" x="${num(x)}" y="${y}" width="${num(filled)}" height="4" rx="2"/>`
159}
160
161function card(x: number, y: number, width: number, height: number): string {
162 return `<rect class="card" x="${num(x + 0.5)}" y="${num(y + 0.5)}" width="${num(width - 1)}" height="${num(height - 1)}" rx="10" stroke-width="1"/>`
163}
164
165function wrap(width: number, height: number, body: string): string {
166 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${STYLE}</style>${body}</svg>`
167}
168
169export function sparkline(x: number, y: number, width: number, height: number, values: readonly number[]): string {
170 if (values.length < 2) return ''
171 const max = Math.max(...values)
172 const min = Math.min(...values)
173 const span = max - min
174 const points = values.map((value, i) => {
175 const level = span === 0 ? 0.5 : (value - min) / span
176 return [x + (width * i) / (values.length - 1), y + height - level * height] as const
177 })
178 const line = points.map(([px, py]) => `${num(px)},${num(py)}`).join(' ')
179 const area = `${num(x)},${num(y + height)} ${line} ${num(x + width)},${num(y + height)}`
180 const last = points[points.length - 1]!
181 return (
182 `<polygon class="spark-area" points="${area}"/>` +
183 `<polyline class="spark" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" points="${line}"/>` +
184 `<circle class="accent" cx="${num(last[0])}" cy="${num(last[1])}" r="2.5"/>`
185 )
186}
187
188function plural(count: number, noun: string): string {
189 return `${count} ${noun}${count === 1 ? '' : 's'}`
190}
191
192type Tile = { label: string; value: string; sub: string; series?: number[]; isLive?: boolean }
193
194type TileMetrics = { pad: number; height: number; labelY: number; valueY: number; sparkY: number; sparkH: number; subInset: number }
195
196const TILE_REGULAR: TileMetrics = { pad: 16, height: 96, labelY: 28, valueY: 60, sparkY: 42, sparkH: 20, subInset: 16 }
197const TILE_COMPACT: TileMetrics = { pad: 12, height: 82, labelY: 22, valueY: 50, sparkY: 34, sparkH: 18, subInset: 12 }
198
199function tile(t: Tile, x: number, y: number, width: number, m: TileMetrics): string {
200 const parts = [card(x, y, width, m.height)]
201 const labelRoom = width - m.pad * 2 - (t.isLive ? 14 : 0)
202 parts.push(text(x + m.pad, y + m.labelY, fit(t.label.toUpperCase(), labelRoom, 12, false, LABEL_EM), { cls: 'subtle', size: 12, weight: 500, spacing: 0.5 }))
203 if (t.isLive) parts.push(dot(x + width - m.pad - 4, y + m.labelY - 4, 'success', true))
204 const value = fit(t.value, width - m.pad * 2, 20, false, 0.62)
205 parts.push(text(x + m.pad, y + m.valueY, value, { cls: 'fg', size: 20, weight: 600 }))
206 const sparkLeft = x + m.pad + textWidth(value, 20) * 1.1 + 12
207 const sparkWidth = Math.min(120, x + width - m.pad - sparkLeft)
208 if (t.series !== undefined && t.series.length >= 2 && sparkWidth >= 28) {
209 parts.push(sparkline(x + width - m.pad - sparkWidth, y + m.sparkY, sparkWidth, m.sparkH, t.series.slice(-30)))
210 }
211 parts.push(text(x + m.pad, y + m.height - m.subInset, fit(t.sub, width - m.pad * 2, 12), { cls: 'subtle', size: 12 }))
212 return parts.join('')
213}
214
215export function kpiStrip(snap: Snapshot, history: readonly HistoryPoint[], width: number): SvgCard {
216 const toolGroups = snap.groups.filter(group => group.kind !== 'helper')
217 const detached = snap.groups.filter(group => group.kind === 'detached').length
218 const servers = snap.groups.filter(group => group.kind === 'helper').length
219 const tools = toolProcessCount(snap)
220 const commands = toolGroups.length - detached
221 const tiles: Tile[] = [
222 {
223 label: 'Running',
224 value: String(tools),
225 sub: toolGroups.length === 0 ? 'Nothing running' : [commands ? plural(commands, 'command') : '', detached ? `${detached} detached` : ''].filter(Boolean).join(', '),
226 series: history.map(point => point.tools),
227 isLive: tools > 0,
228 },
229 { label: 'Helpers', value: String(helperProcessCount(snap)), sub: plural(servers, 'server') },
230 {
231 label: 'Memory',
232 value: formatBytes(totalRss(snap)),
233 sub: `Tools ${formatBytes(totalRss(snap, 'tools'))}`,
234 series: history.map(point => point.rss),
235 },
236 {
237 label: 'Session',
238 value: formatAge(snap.rootStartedAt === null ? null : snap.takenAt - snap.rootStartedAt),
239 sub: snap.rootLabel,
240 },
241 ]
242 const isCompact = layoutFor(width) === 'compact'
243 const metrics = isCompact ? TILE_COMPACT : TILE_REGULAR
244 const cols = width >= 600 ? 4 : 2
245 const gap = isCompact ? 8 : 12
246 const tileWidth = (width - gap * (cols - 1)) / cols
247 const rows = Math.ceil(tiles.length / cols)
248 const height = rows * metrics.height + (rows - 1) * gap
249 const body = tiles
250 .map((one, i) => tile(one, (i % cols) * (tileWidth + gap), Math.floor(i / cols) * (metrics.height + gap), tileWidth, metrics))
251 .join('')
252 return {
253 source: wrap(width, height, body),
254 width,
255 height,
256 alt: tiles.map(one => `${one.label}: ${one.value}`).join(', '),
257 }
258}
259
260export type GroupsCardOptions = {
261 title: string
262 groups: readonly ProcGroup[]
263 width: number
264 takenAt: number
265 emptyTitle: string
266 emptyHint: string
267 maxRows?: number
268}
269
270type RowCells = { pid: number; rss: number; startedAt: number | null }
271
272function renderGroupsCard(o: GroupsCardOptions, maxRows: number): SvgCard {
273 const W = o.width
274 const layout = layoutFor(W)
275 const isCompact = layout === 'compact'
276 const PAD = isCompact ? 14 : 20
277 const HEAD = isCompact ? 46 : 52
278 const GROUP_H = isCompact ? 50 : 40
279 const ROW_H = isCompact ? 42 : 30
280 const R = W - PAD
281 const ageX = R
282 const memX = R - 76
283 const meterW = 56
284 const meterX = memX - 56 - meterW
285 const pidX = layout === 'wide' ? meterX - 16 : memX - 56
286 const labelRight = isCompact ? R : layout === 'wide' ? pidX - 56 : pidX - 8
287 const age = (startedAt: number | null) => formatAge(startedAt === null ? null : o.takenAt - startedAt)
288 const maxRss = Math.max(1, ...o.groups.map(group => group.rss))
289 const count = o.groups.reduce((sum, group) => sum + Math.max(1, group.rows.length), 0)
290 const titleY = isCompact ? 28 : 31
291
292 const parts: string[] = []
293 parts.push(text(PAD, titleY, o.title, { cls: 'fg', size: 14, weight: 600 }))
294 if (o.groups.length > 0) {
295 const label = String(count)
296 const badgeX = PAD + textWidth(o.title, 14) + 10
297 const badgeW = Math.max(20, textWidth(label, 12) + 14)
298 parts.push(`<rect class="badge" x="${num(badgeX)}" y="${titleY - 15}" width="${num(badgeW)}" height="20" rx="10"/>`)
299 parts.push(text(badgeX + badgeW / 2, titleY - 1, label, { cls: 'muted', size: 12, weight: 500, anchor: 'middle' }))
300 if (!isCompact) {
301 if (layout === 'wide') parts.push(text(pidX, titleY, 'PID', { cls: 'subtle', size: 12, weight: 500, anchor: 'end', spacing: 0.5 }))
302 parts.push(text(memX, titleY, 'MEMORY', { cls: 'subtle', size: 12, weight: 500, anchor: 'end', spacing: 0.5 }))
303 parts.push(text(ageX, titleY, 'AGE', { cls: 'subtle', size: 12, weight: 500, anchor: 'end', spacing: 0.5 }))
304 }
305 }
306 parts.push(`<line class="rule" x1="${PAD}" y1="${HEAD - 6}" x2="${R}" y2="${HEAD - 6}" stroke-width="1"/>`)
307
308 const columns = (cy: number, cells: RowCells, isGroup: boolean) => {
309 const cls = isGroup ? 'muted' : 'subtle'
310 const out = []
311 if (layout === 'wide') {
312 out.push(text(pidX, cy + 4, String(cells.pid), { cls: 'subtle', size: 12, anchor: 'end', isMono: true }))
313 out.push(meter(meterX, cy, meterW, cells.rss / maxRss, memoryClass(cells.rss)))
314 }
315 out.push(text(memX, cy + 4, formatBytes(cells.rss), { cls, size: 12, anchor: 'end', isMono: true }))
316 out.push(text(ageX, cy + 4, age(cells.startedAt), { cls, size: 12, anchor: 'end', isMono: true }))
317 return out.join('')
318 }
319
320 const metaLine = (x: number, baseline: number, cells: RowCells) => {
321 const compactMeterW = 40
322 const meta = `${formatBytes(cells.rss)} · ${age(cells.startedAt)} · ${cells.pid}`
323 return (
324 text(x, baseline, fit(meta, R - compactMeterW - 10 - x, 12, true), { cls: 'subtle', size: 12, isMono: true }) +
325 meter(R - compactMeterW, baseline - 4, compactMeterW, cells.rss / maxRss, memoryClass(cells.rss))
326 )
327 }
328
329 let y = HEAD
330 let drawn = 0
331 let hidden = 0
332 if (o.groups.length === 0) {
333 parts.push(text(W / 2, HEAD + 30, fit(o.emptyTitle, W - PAD * 2, 14), { cls: 'fg', size: 14, weight: 500, anchor: 'middle' }))
334 const hint = fit(o.emptyHint, W - PAD * 2, 13)
335 parts.push(text(W / 2, HEAD + 52, hint, { cls: 'muted', size: 13, anchor: 'middle' }))
336 y = HEAD + 76
337 }
338 o.groups.forEach((group, gi) => {
339 if (drawn >= maxRows) {
340 hidden += 1 + group.rows.length
341 return
342 }
343 if (gi > 0) parts.push(`<line class="rule" x1="${PAD}" y1="${num(y)}" x2="${R}" y2="${num(y)}" stroke-width="1"/>`)
344 const cy = isCompact ? y + 18 : y + GROUP_H / 2
345 const isDetached = group.kind === 'detached'
346 parts.push(dot(PAD + 4, cy, isDetached ? 'warning' : 'success', group.kind === 'tool'))
347 let labelX = PAD + 18
348 if (isDetached) {
349 parts.push(`<rect class="warning-soft" x="${labelX}" y="${num(cy - 10)}" width="68" height="20" rx="6"/>`)
350 parts.push(text(labelX + 8, cy + 4, 'Detached', { cls: 'warning-fg', size: 12, weight: 500 }))
351 labelX += 76
352 }
353 const isCommand = group.kind !== 'helper'
354 const titleSize = isCommand ? 13 : 14
355 parts.push(
356 text(labelX, cy + 4.5, fit(group.title, labelRight - labelX, titleSize, isCommand), {
357 cls: 'fg',
358 size: titleSize,
359 weight: isCommand ? undefined : 500,
360 isMono: isCommand,
361 }),
362 )
363 parts.push(isCompact ? metaLine(PAD + 18, cy + 22, group) : columns(cy, group, true))
364 y += GROUP_H
365 drawn += 1
366
367 for (const row of group.rows) {
368 if (drawn >= maxRows) {
369 hidden += 1
370 continue
371 }
372 const rowCy = isCompact ? y + 12 : y + ROW_H / 2 - 3
373 const connX = PAD + 4 + row.depth * (isCompact ? 12 : 16)
374 const reach = isCompact ? ROW_H - 2 : ROW_H - 4
375 parts.push(`<path class="tree" stroke-width="1" d="M${num(connX)} ${num(rowCy - reach)} V${num(rowCy)} H${num(connX + 9)}"/>`)
376 const rowLabelX = connX + 15
377 parts.push(text(rowLabelX, rowCy + 4, fit(row.label, labelRight - rowLabelX, 12, true), { cls: 'muted', size: 12, isMono: true }))
378 parts.push(isCompact ? metaLine(rowLabelX, rowCy + 21, row) : columns(rowCy, row, false))
379 y += ROW_H
380 drawn += 1
381 }
382 if (group.rows.length > 0) y += isCompact ? 2 : 6
383 })
384 if (hidden > 0) {
385 parts.push(text(PAD, y + 20, `+${hidden} more not shown`, { cls: 'subtle', size: 12 }))
386 y += 30
387 }
388 const height = Math.ceil(y + 10)
389 const alt = `${o.title}: ${o.groups.length === 0 ? o.emptyTitle : o.groups.map(group => (group.rows.length ? `${group.title} (${group.rows.map(row => row.label).join(', ')})` : group.title)).join('; ')}`
390 return { source: wrap(W, height, card(0, 0, W, height) + parts.join('')), width: W, height, alt: clip(alt, MAX_ALT) }
391}
392
393export function groupsCard(o: GroupsCardOptions): SvgCard {
394 let maxRows = o.maxRows ?? 120
395 let result = renderGroupsCard(o, maxRows)
396 while (result.source.length > MAX_SOURCE && maxRows > MIN_ROWS) {
397 maxRows = Math.max(MIN_ROWS, Math.floor(maxRows / 2))
398 result = renderGroupsCard(o, maxRows)
399 }
400 return result
401}
402
403export function callout(kind: 'warning' | 'danger', message: string, width: number): SvgCard {
404 const height = 40
405 const body =
406 `<rect class="${kind}-soft" x="0" y="0" width="${width}" height="${height}" rx="8"/>` +
407 dot(18, 20, kind, false) +
408 text(32, 24.5, fit(message, width - 48, 13), { cls: `${kind}-fg`, size: 13 })
409 return { source: wrap(width, height, body), width, height, alt: clip(message, MAX_ALT) }
410}
411hooks/tree.ts 453 lines1import type { ProcGroup, ProcGroupKind, ProcRow, Snapshot } from '../types'
2
3export type RawProc = {
4 pid: number
5 ppid: number
6 name: string
7 cmd: string
8 rss: number
9 startedAt: number | null
10}
11
12export type Probe = { self: number; procs: RawProc[] }
13
14export type Known = ReadonlyMap<number, number | null>
15
16const WRAPPER_NAMES = new Set([
17 'powershell.exe',
18 'pwsh.exe',
19 'pwsh',
20 'cmd.exe',
21 'conhost.exe',
22 'sh',
23 'bash',
24 'bash.exe',
25 'zsh',
26 'dash',
27 'fish',
28])
29const HIDDEN_NAMES = new Set(['conhost.exe'])
30const TOOL_SHELL = /shell-snapshots|CLAUDE_CODE_SHELL_LAUNCHER_SCRIPT/
31const DESKTOP_HOST = /WindowsApps[\\/]Claude_|\.app\/Contents\/MacOS\/|--type=/i
32const CLAUDE_CLI = /claude-code|@anthropic-ai[\\/]claude/i
33const START_TOLERANCE_MS = 2000
34const MAX_CHAIN = 64
35
36export const WINDOWS_PROBE_SCRIPT = [
37 "$ErrorActionPreference = 'Stop'",
38 '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
39 '$procs = Get-CimInstance -ClassName Win32_Process -Property ProcessId,ParentProcessId,Name,CommandLine,WorkingSetSize,CreationDate | ForEach-Object {' +
40 ' $c = $_.CommandLine;' +
41 ' if ($c -and $c.Length -gt 2000) { $c = $c.Substring(0, 2000) };' +
42 ' $t = $null;' +
43 ' if ($_.CreationDate) { $t = ([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() };' +
44 ' [pscustomobject]@{ i = [int]$_.ProcessId; p = [int]$_.ParentProcessId; n = $_.Name; c = $c; m = [int64]$_.WorkingSetSize; t = $t }' +
45 ' }',
46 '@{ self = $PID; procs = @($procs) } | ConvertTo-Json -Compress -Depth 3',
47].join('; ')
48
49export const POSIX_PROBE_ARGV = [
50 '/bin/sh',
51 '-c',
52 'echo $$; PATH=$(command -p getconf PATH 2>/dev/null || echo /usr/bin:/bin); export PATH; exec ps -A -o pid= -o ppid= -o rss= -o etime= -o args=',
53] as const
54
55const DEFAULT_SYSTEM_ROOT = 'C:\\Windows'
56const SYSTEM_ROOT_PATTERN = /^[A-Za-z]:\\[^"<>|?*\r\n]{1,200}$/
57
58export function safeSystemRoot(value: string | undefined): string {
59 const trimmed = (value ?? '').replace(/\\+$/, '')
60 return SYSTEM_ROOT_PATTERN.test(trimmed) ? trimmed : DEFAULT_SYSTEM_ROOT
61}
62
63export function windowsProbeArgv(systemRoot: string): string[] {
64 return [
65 `${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`,
66 '-NoProfile',
67 '-NonInteractive',
68 '-Command',
69 WINDOWS_PROBE_SCRIPT,
70 ]
71}
72
73const MAX_TEXT = 2000
74const MAX_LABEL = 300
75const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufffe\uffff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g
76
77export function clip(text: string, max: number): string {
78 if (text.length <= max) return text
79 const chars = Array.from(text.slice(0, max * 2))
80 return text.length <= max * 2 && chars.length <= max ? text : chars.slice(0, max).join('')
81}
82
83export function cleanText(text: string): string {
84 return clip(text, MAX_TEXT).replace(/\r/g, ' ').replace(CONTROL_CHARS, '\ufffd')
85}
86
87function safeLabel(text: string): string {
88 const flat = redactSecrets(text).replace(/\s+/g, ' ').trim()
89 return clip(flat, MAX_LABEL) === flat ? flat : `${clip(flat, MAX_LABEL - 1)}…`
90}
91
92type WindowsRow = {
93 i: number
94 p: number
95 n: string | null
96 c: string | null
97 m: number | null
98 t: number | null
99}
100
101export function parseWindowsProbe(stdout: string): Probe {
102 const data = JSON.parse(stdout.replace(/^\uFEFF/, '').trim()) as {
103 self: number
104 procs: WindowsRow[] | WindowsRow
105 }
106 const rows = Array.isArray(data.procs) ? data.procs : [data.procs]
107 return {
108 self: data.self,
109 procs: rows.map(row => ({
110 pid: row.i,
111 ppid: row.p,
112 name: cleanText(row.n ?? ''),
113 cmd: cleanText(row.c ?? row.n ?? ''),
114 rss: row.m ?? 0,
115 startedAt: row.t ?? null,
116 })),
117 }
118}
119
120export function parseElapsed(etime: string): number {
121 const dash = etime.indexOf('-')
122 const days = dash < 0 ? 0 : Number(etime.slice(0, dash))
123 const parts = (dash < 0 ? etime : etime.slice(dash + 1)).split(':').map(Number).reverse()
124 const seconds = (parts[0] ?? 0) + (parts[1] ?? 0) * 60 + (parts[2] ?? 0) * 3600 + days * 86400
125 return Math.round(seconds * 1000)
126}
127
128export function parsePosixProbe(stdout: string, now: number): Probe {
129 const [first = '', ...lines] = stdout.split('\n')
130 const procs: RawProc[] = []
131 for (const line of lines) {
132 const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+([\d:.-]+)\s+(.*)$/.exec(line)
133 if (!match) continue
134 const [, pid = '', ppid = '', rss = '', etime = '', args = ''] = match
135 const cmd = cleanText(args).trim()
136 procs.push({
137 pid: Number(pid),
138 ppid: Number(ppid),
139 name: basename(splitExe(cmd).exe).replace(/^-/, ''),
140 cmd,
141 rss: Number(rss) * 1024,
142 startedAt: now - parseElapsed(etime),
143 })
144 }
145 return { self: Number(first.trim()), procs }
146}
147
148export function basename(path: string): string {
149 const parts = path.split(/[\\/]/)
150 return parts[parts.length - 1] ?? path
151}
152
153export function splitExe(cmd: string): { exe: string; rest: string } {
154 const text = cmd.trim()
155 if (text.startsWith('"')) {
156 const end = text.indexOf('"', 1)
157 if (end > 0) return { exe: text.slice(1, end), rest: text.slice(end + 1).trim() }
158 }
159 const space = text.indexOf(' ')
160 return space < 0 ? { exe: text, rest: '' } : { exe: text.slice(0, space), rest: text.slice(space + 1).trim() }
161}
162
163function shortExe(exe: string): string {
164 return basename(exe).replace(/\.exe$/i, '')
165}
166
167function shortenPath(path: string): string {
168 const segments = path.split(/[\\/]+/).filter(Boolean)
169 return segments.length <= 3 ? path : `…/${segments.slice(-2).join('/')}`
170}
171
172function shortenPaths(text: string): string {
173 return text.replace(
174 /"([A-Za-z]:\\[^"]*|\/[^"]*)"|([A-Za-z]:\\\S+|\/\S+\/\S+)/g,
175 (_whole, quoted: string | undefined, bare: string | undefined) => shortenPath(quoted ?? bare ?? ''),
176 )
177}
178
179export function commandLabel(proc: RawProc): string {
180 if (!proc.cmd) return shortExe(proc.name)
181 const { exe, rest } = splitExe(proc.cmd)
182 return [shortExe(exe || proc.name), shortenPaths(redactSecrets(rest))].filter(Boolean).join(' ')
183}
184
185export function toolShellTitle(proc: RawProc): string {
186 const unescaped = proc.cmd.replace(/\\"/g, '"')
187 const evaluated = /eval '((?:[^']|'"'"'|'\\'')*)(?:'|$)/.exec(unescaped)?.[1]
188 if (evaluated) return `$ ${redactSecrets(evaluated.replace(/'"'"'|'\\''/g, "'").trim())}`
189 if (proc.cmd.includes('CLAUDE_CODE_SHELL_LAUNCHER_SCRIPT')) return 'PowerShell tool command'
190 return `${shortExe(proc.name)} tool command`
191}
192
193const SECRET_WORD = String.raw`(?:token|secret|password|passwd|pwd|api[-_]?key|apikey|access[-_]?key|private[-_]?key|client[-_]?secret|account[-_]?key|authorization|auth|credentials?)`.replace(
194 /[a-z]/g,
195 letter => `[${letter}${letter.toUpperCase()}]`,
196)
197const SECRET_FLAG = new RegExp(
198 String.raw`(^|\s)(-{1,2}(?!-)(?![nN][oO]-)[\w-]{0,40}?${SECRET_WORD}(?![a-z])[\w-]{0,40})(?:(=)("[^"]*"|'[^']*'|["'][^"']*|\S+)|(\s+)("[^"]*"|'[^']*'|["'][^"']*|[^\s-]\S*))`,
199 'g',
200)
201const SECRET_FLAG_REFERENCE = /(?:-|[a-z])(?:stdin|file|path|prompt|env)$/i
202const SECRET_ASSIGNMENT = new RegExp(String.raw`\b(\w{0,40}?${SECRET_WORD}(?![a-z])\w{0,40})=("[^"]*"|'[^']*'|[^\s;&"']+|["'][^"']*)`, 'g')
203const HEADER = /((?:X-[\w-]{0,40}(?:Key|Token|Secret|Auth)|Cookie|Authorization|Proxy-Authorization)\s*:\s*)[^"'\n]+/gi
204const AUTH_HEADER = /\b(Bearer|Basic|Token)\s+[\w.~+/=-]{8,}/g
205const URL_CREDENTIALS = /\b([a-z][a-z0-9+.-]{0,30}:\/\/)[^\s/@:]{0,256}:[^\s/@]{1,2000}@/gi
206const WEBHOOK_URL = /(https?:\/\/(?:hooks\.slack\.com\/services|discord(?:app)?\.com\/api\/webhooks)\/)\S+/gi
207const TELEGRAM_BOT = /(api\.telegram\.org\/bot)[^\s/]+/gi
208const KNOWN_TOKENS = new RegExp(
209 String.raw`\b(?:gh[pousr]_[A-Za-z0-9]{20,512}|github_pat_\w{20,512}|sk-ant-[\w-]{10,512}|sk-(?:proj|svcacct|admin)-[\w-]{20,512}|sk-[A-Za-z0-9]{20,512}|[sr]k_(?:live|test)_\w{16,256}|xox[abposr]-[\w-]{10,512}|npm_\w{36}|glpat-[\w-]{20,256}|hf_\w{30,256}|(?:AKIA|ASIA)[0-9A-Z]{16}|AIza[\w-]{35})`,
210 'g',
211)
212const JWT = /(?<!\w)eyJ[\w-]{8,4096}\.[\w-]{8,4096}\.[\w-]{8,4096}/g
213
214const anyCase = (word: string): string => word.replace(/[a-z]/gi, letter => `[${letter.toLowerCase()}${letter.toUpperCase()}]`)
215const TOOL_GAP = String.raw`(?=\s)[^|;&\n]{0,200}?`
216const TOOL_VALUE = String.raw`("[^"]*"|'[^']*'|[^\s"'-][^\s"']*)`
217const toolCommand = (...names: string[]): string => String.raw`\b(?:${names.map(anyCase).join('|')})(?:\.[Ee][Xx][Ee])?`
218
219const MYSQL_PASSWORD = new RegExp(String.raw`(${toolCommand('mysql', 'mysqldump', 'mysqladmin', 'mariadb', 'mariadb-dump', 'mariadb-admin')}${TOOL_GAP}\s)-p("[^"]*"|'[^']*'|\S+)`, 'g')
220const REDIS_PASSWORD = new RegExp(String.raw`(${toolCommand('redis-cli')}${TOOL_GAP}\s-a\s+)${TOOL_VALUE}`, 'g')
221const CURL_USER = new RegExp(String.raw`(${toolCommand('curl')}${TOOL_GAP}\s(?:-u\s*|--(?:proxy-)?user(?:=|\s+))["']?[^\s:"']{0,256}:)[^\s"']+`, 'g')
222const SQLCMD_PASSWORD = new RegExp(String.raw`(${toolCommand('sqlcmd')}${TOOL_GAP}\s-P)\s*${TOOL_VALUE}`, 'g')
223const SSHPASS_PASSWORD = new RegExp(String.raw`(${toolCommand('sshpass')}${TOOL_GAP}\s-p)\s*${TOOL_VALUE}`, 'g')
224const DOCKER_LOGIN_PASSWORD = new RegExp(String.raw`(\b${anyCase('docker')}(?:\.[Ee][Xx][Ee])?\s+login${TOOL_GAP}\s-p)\s*${TOOL_VALUE}`, 'g')
225const OPENSSL_PASS = new RegExp(String.raw`(${toolCommand('openssl')}${TOOL_GAP}\s-pass(?:in|out)?(?:=|\s+)["']?pass:)[^\s"']+`, 'g')
226const AWS_CONFIGURE_SECRET = new RegExp(
227 String.raw`(${toolCommand('aws')}\s+configure\s+set\s+(?:[^\s|;&]{1,100}\s+){0,3}?[^\s|;&]{0,100}(?:secret|token|key)[^\s|;&]{0,100}\s+)${TOOL_VALUE}`,
228 'gi',
229)
230
231function maskFlag(match: string, lead: string, name: string, equals: string | undefined, _equalsValue: string | undefined, space: string | undefined): string {
232 return SECRET_FLAG_REFERENCE.test(name) ? match : `${lead}${name}${equals ?? space ?? ''}***`
233}
234
235export function redactSecrets(text: string): string {
236 return text
237 .replace(WEBHOOK_URL, '$1***')
238 .replace(TELEGRAM_BOT, '$1***')
239 .replace(URL_CREDENTIALS, '$1***@')
240 .replace(HEADER, '$1***')
241 .replace(AUTH_HEADER, '$1 ***')
242 .replace(SECRET_FLAG, maskFlag)
243 .replace(SECRET_ASSIGNMENT, '$1=***')
244 .replace(MYSQL_PASSWORD, '$1-p***')
245 .replace(REDIS_PASSWORD, '$1***')
246 .replace(CURL_USER, '$1***')
247 .replace(SQLCMD_PASSWORD, '$1 ***')
248 .replace(SSHPASS_PASSWORD, '$1 ***')
249 .replace(DOCKER_LOGIN_PASSWORD, '$1 ***')
250 .replace(OPENSSL_PASS, '$1***')
251 .replace(AWS_CONFIGURE_SECRET, '$1***')
252 .replace(KNOWN_TOKENS, '***')
253 .replace(JWT, '***')
254}
255
256function isCmdShim(proc: RawProc): boolean {
257 return proc.name.toLowerCase() === 'cmd.exe' && /\s\/c\s/i.test(proc.cmd)
258}
259
260export function helperTitle(proc: RawProc): string {
261 const afterC = /\s\/c\s+(.*)$/i.exec(proc.cmd)?.[1]
262 if (isCmdShim(proc) && afterC) {
263 const { exe, rest } = splitExe(afterC.replace(/\^/g, ''))
264 return [shortExe(exe).replace(/\.(cmd|bat)$/i, ''), shortenPaths(redactSecrets(rest))].filter(Boolean).join(' ')
265 }
266 const { exe, rest } = splitExe(proc.cmd)
267 if (/^node(\.exe)?$/i.test(basename(exe)) && rest) {
268 return basename(splitExe(redactSecrets(rest)).exe)
269 }
270 return shortExe(proc.name || exe)
271}
272
273function isTransparent(proc: RawProc): boolean {
274 return HIDDEN_NAMES.has(proc.name.toLowerCase()) || TOOL_SHELL.test(proc.cmd) || isCmdShim(proc)
275}
276
277function isClaudeCli(proc: RawProc): boolean {
278 if (CLAUDE_CLI.test(proc.cmd)) return true
279 return /^claude(\.exe)?$/i.test(proc.name) && !DESKTOP_HOST.test(proc.cmd)
280}
281
282function ancestry(byPid: ReadonlyMap<number, RawProc>, self: number): RawProc[] {
283 const chain: RawProc[] = []
284 const seen = new Set<number>()
285 let current = byPid.get(self)
286 while (current && !seen.has(current.pid) && chain.length < MAX_CHAIN) {
287 chain.push(current)
288 seen.add(current.pid)
289 current = byPid.get(current.ppid)
290 }
291 return chain
292}
293
294export function findRootIndex(chain: readonly RawProc[]): number {
295 let index = chain.findIndex((proc, i) => i > 0 && !WRAPPER_NAMES.has(proc.name.toLowerCase()))
296 if (index < 0) return chain.length > 1 ? 1 : 0
297 while (index + 1 < chain.length && isClaudeCli(chain[index]!) && isClaudeCli(chain[index + 1]!)) {
298 index++
299 }
300 return index
301}
302
303function sameStart(a: number | null | undefined, b: number | null): boolean {
304 return a === null || a === undefined || b === null || Math.abs(a - b) <= START_TOLERANCE_MS
305}
306
307function byStart(a: RawProc, b: RawProc): number {
308 return (a.startedAt ?? 0) - (b.startedAt ?? 0) || a.pid - b.pid
309}
310
311export function buildSnapshot(
312 probe: Probe,
313 takenAt: number,
314 known: Known = new Map(),
315): { snapshot: Snapshot; known: Map<number, number | null> } {
316 const byPid = new Map<number, RawProc>()
317 const children = new Map<number, RawProc[]>()
318 for (const proc of probe.procs) byPid.set(proc.pid, proc)
319 for (const proc of probe.procs) {
320 if (proc.pid === proc.ppid) continue
321 const list = children.get(proc.ppid) ?? []
322 list.push(proc)
323 children.set(proc.ppid, list)
324 }
325 for (const list of children.values()) list.sort(byStart)
326
327 const chain = ancestry(byPid, probe.self)
328 const rootIndex = findRootIndex(chain)
329 const root = chain[rootIndex]
330 const excluded = new Set<number>(chain.slice(0, rootIndex).map(proc => proc.pid))
331 const visited = new Set<number>()
332 const nextKnown = new Map<number, number | null>()
333 const groups: ProcGroup[] = []
334
335 const exclude = (pid: number): void => {
336 excluded.add(pid)
337 for (const child of children.get(pid) ?? []) if (!excluded.has(child.pid)) exclude(child.pid)
338 }
339 exclude(probe.self)
340
341 const remember = (proc: RawProc): void => {
342 visited.add(proc.pid)
343 nextKnown.set(proc.pid, proc.startedAt)
344 }
345
346 const collect = (pid: number, depth: number, rows: ProcRow[], total: { rss: number }): void => {
347 for (const child of children.get(pid) ?? []) {
348 if (excluded.has(child.pid) || visited.has(child.pid)) continue
349 remember(child)
350 total.rss += child.rss
351 if (isTransparent(child)) {
352 collect(child.pid, depth, rows, total)
353 continue
354 }
355 rows.push(toRow(child, depth))
356 collect(child.pid, depth + 1, rows, total)
357 }
358 }
359
360 const addGroup = (top: RawProc, kind: ProcGroupKind, title: string): void => {
361 remember(top)
362 const rows: ProcRow[] = []
363 const total = { rss: top.rss }
364 const showsTop = kind !== 'tool' && !isTransparent(top)
365 if (showsTop) rows.push(toRow(top, 0))
366 collect(top.pid, showsTop ? 1 : 0, rows, total)
367 groups.push({ id: `${kind}-${top.pid}`, kind, title: safeLabel(title), pid: top.pid, startedAt: top.startedAt, rss: total.rss, rows })
368 }
369
370 if (root && root.pid !== probe.self) {
371 remember(root)
372 for (const child of children.get(root.pid) ?? []) {
373 if (excluded.has(child.pid) || visited.has(child.pid)) continue
374 if (HIDDEN_NAMES.has(child.name.toLowerCase())) {
375 remember(child)
376 continue
377 }
378 if (TOOL_SHELL.test(child.cmd)) addGroup(child, 'tool', toolShellTitle(child))
379 else addGroup(child, 'helper', helperTitle(child))
380 }
381
382 const lineage = new Set(chain.slice(rootIndex).map(proc => proc.pid))
383 const wasKnown = (pid: number, startedAt: number | null): boolean =>
384 known.has(pid) && sameStart(known.get(pid), startedAt)
385 const parentGone = (proc: RawProc): boolean => {
386 const parent = byPid.get(proc.ppid)
387 if (!parent) return true
388 return parent.startedAt !== null && proc.startedAt !== null && parent.startedAt > proc.startedAt + START_TOLERANCE_MS
389 }
390 const isDetached = (proc: RawProc): boolean => {
391 if (visited.has(proc.pid) || excluded.has(proc.pid) || lineage.has(proc.pid)) return false
392 if (HIDDEN_NAMES.has(proc.name.toLowerCase())) return false
393 if (wasKnown(proc.pid, proc.startedAt)) return true
394 if (!parentGone(proc) || !known.has(proc.ppid)) return false
395 const parentStart = known.get(proc.ppid)
396 return parentStart === null || parentStart === undefined || proc.startedAt === null || parentStart <= proc.startedAt + START_TOLERANCE_MS
397 }
398 for (const proc of [...probe.procs].sort(byStart)) {
399 if (isDetached(proc)) addGroup(proc, 'detached', commandLabel(proc))
400 }
401 }
402
403 const snapshot: Snapshot = {
404 takenAt,
405 rootPid: root?.pid ?? 0,
406 rootLabel: root ? `${shortExe(root.name)} #${root.pid}` : 'unknown',
407 rootStartedAt: root?.startedAt ?? null,
408 chain: chain.map(proc => `${proc.name}#${proc.pid}`).join(' < '),
409 groups,
410 warning: !root || root.pid === probe.self ? 'Could not find the session process above the probe.' : null,
411 }
412 return { snapshot, known: nextKnown }
413}
414
415function toRow(proc: RawProc, depth: number): ProcRow {
416 return { pid: proc.pid, depth, label: safeLabel(commandLabel(proc)), rss: proc.rss, startedAt: proc.startedAt }
417}
418
419export function toolProcessCount(snapshot: Snapshot): number {
420 return snapshot.groups
421 .filter(group => group.kind !== 'helper')
422 .reduce((sum, group) => sum + Math.max(1, group.rows.length), 0)
423}
424
425export function helperProcessCount(snapshot: Snapshot): number {
426 return snapshot.groups
427 .filter(group => group.kind === 'helper')
428 .reduce((sum, group) => sum + Math.max(1, group.rows.length), 0)
429}
430
431export function totalRss(snapshot: Snapshot, kind?: 'tools' | 'helpers'): number {
432 return snapshot.groups
433 .filter(group => (kind === undefined ? true : kind === 'helpers' ? group.kind === 'helper' : group.kind !== 'helper'))
434 .reduce((sum, group) => sum + group.rss, 0)
435}
436
437export function formatAge(ms: number | null): string {
438 if (ms === null || ms < 0) return '-'
439 const seconds = Math.floor(ms / 1000)
440 if (seconds < 60) return `${seconds}s`
441 const minutes = Math.floor(seconds / 60)
442 if (minutes < 60) return `${minutes}m ${seconds % 60}s`
443 const hours = Math.floor(minutes / 60)
444 if (hours < 24) return `${hours}h ${minutes % 60}m`
445 return `${Math.floor(hours / 24)}d ${hours % 24}h`
446}
447
448export function formatBytes(bytes: number): string {
449 const mb = bytes / (1024 * 1024)
450 if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`
451 return mb >= 10 ? `${Math.round(mb)} MB` : `${mb.toFixed(1)} MB`
452}
453types/index.d.ts 48 lines1export type ProcRow = {
2 pid: number
3 depth: number
4 label: string
5 rss: number
6 startedAt: number | null
7}
8
9export type ProcGroupKind = 'tool' | 'detached' | 'helper'
10
11export type ProcGroup = {
12 id: string
13 kind: ProcGroupKind
14 title: string
15 pid: number
16 startedAt: number | null
17 rss: number
18 rows: ProcRow[]
19}
20
21export type Snapshot = {
22 takenAt: number
23 rootPid: number
24 rootLabel: string
25 rootStartedAt: number | null
26 chain: string
27 groups: ProcGroup[]
28 warning: string | null
29}
30
31export type HistoryPoint = {
32 at: number
33 tools: number
34 rss: number
35}
36
37declare module 'claude-code' {
38 interface PluginState {
39 'session-procs': {
40 snapshot: Snapshot | null
41 history: HistoryPoint[]
42 showHelpers: boolean
43 isPaneOpen: boolean
44 error: string | null
45 }
46 }
47}
48