SLOPSHOPPER

session-procs

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

panebandguardcommandstatusprocess
v0.1.0MITupdated 2026-10-26iftahs/claude-mods/mods/session-procs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-procs
│ ┃ Session processes ✕ › fix the failing auth test and add an audit log call │ ┃ Session processes ● live unknown · up - │ ┃ ● session-procs: session-procs: root unknown; chain │ ┃ RUNNING MCP & HELPERS MEMORY SESSIO ⏺ Read(src/auth.ts) │ ┃ 0 ▄▄ 0 0.0 MB ▄▄ - ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Refresh ] [ Show MCP & helpers (0) ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Could not find the session process above th… ⎿ 3 pass, 1 fail │ ┃ │ ┃ STARTED BY TOOLS · 0 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Nothing running from tools. Start a dev │ ┃ server with Bash and it shows up here. ✻ Worked for 42s · done 4:20 PM │ │ › /procs │ ⎿ session-procs: Session processes pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session processes
Session processes ● live unknown · up - RUNNING MCP & HELPERS MEMORY SESSION 0 ▄▄ 0 0.0 MB ▄▄ - [ Refresh ] [ Show MCP & helpers (0) ] Could not find the session process above the probe. STARTED BY TOOLS · 0 Nothing running from tools. Start a dev server with Bash and it shows up here.
README

session-procs

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"> &nbsp; <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">

What it shows

  • Started by tools: each Bash or PowerShell command Claude ran, titled with the command itself ($ npm run dev), and the processes under it (node …/bin/next dev) with PID, memory and age.
  • Detached: processes that kept running after the shell that started them exited.
  • MCP servers & helpers: the session's MCP servers and other helpers, behind a toggle.
  • Stat tiles for running processes, helpers, total memory and session uptime, with sparklines.
  • Band above the prompt: one row with the number of processes running from tools and their total memory, and a button that opens the pane. It steps aside while the pane is open.
  • Status line: 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.

Use

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.

How it works

The mod asks the operating system for the process list, finds its own Claude Code process, and shows everything below it.

  • Windows: %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -NonInteractive -Command, started in %SystemRoot%\System32, running a fixed, readable Get-CimInstance Win32_Process query.
  • macOS and Linux: /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.

Privacy and safety

  • Runs entirely on your machine. No network calls, no telemetry.
  • The process list is never sent to the model or over the network, and it is never added to the conversation. It does live in a few places you should know about: the session's plugin state holds the latest snapshot (other plugins installed in the same session can read it), the session's process ancestry (process names and PIDs, no command lines) and any error messages, redacted, are written to Claude Code's debug log, and on remote surfaces such as mobile, Claude Code carries the drawn pane to that device.
  • Command lines often carry secrets. Before a snapshot is stored or drawn, the mod masks the following. Masking is best effort; treat the pane like any terminal that shows command lines.
  • Values of flags and variables named like tokens, secrets, passwords, API or access keys, account keys, credentials and 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.
  • Connection-string and query-string parameters (Password=...;, ?token=...&).
  • Credential headers: Authorization, Proxy-Authorization, Cookie and X-...-Key, -Token, -Secret or -Auth headers, plus Bearer, Basic and Token values.
  • Passwords in URLs (scheme://user:password@host), Slack and Discord webhook URLs, and Telegram bot tokens.
  • Token formats: GitHub, Anthropic, OpenAI, Stripe, Slack, npm, GitLab, Hugging Face, AWS access key IDs, Google API keys and JWTs.
  • Password options of 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.
  • Command lines are cleaned of control and direction-override characters and capped (2,000 characters read per process, 300 shown per row), and a card is capped in size.
  • It only reads processes. It never stops or changes one.
  • Executables are run by absolute path: PowerShell from %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.
  • Both probe commands are fixed strings with nothing from the session interpolated into them, and the Windows probe avoids encoded commands and execution-policy bypasses.

Limits

  • A process started with cmd & inside a shell that exits before the next refresh can't be traced back to the session, so it won't show.
  • The macOS and Linux probe is covered by tests but has had less real-world use than the Windows one.
  • Tested on Claude Code 2.1.295. The function-hooks API is in early access.

Develop

claude plugin validate mods/session-procs
claude plugin test mods/session-procs

Author

Made by Iftah Saar. More at iftah.dev.

Source 4 files
hooks/register.tsx 398 lines
1import { 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}
398
hooks/svg.ts 411 lines
1import 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, '&amp;')
105    .replace(/</g, '&lt;')
106    .replace(/>/g, '&gt;')
107    .replace(/"/g, '&quot;')
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}
411
hooks/tree.ts 453 lines
1import 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}
453
types/index.d.ts 48 lines
1export 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