See every process Claude Code started and left running, in any session, with its port, and end it with one key

Claude Code starts a dev server, a watcher or a headless browser, moves on, and forgets it. The process keeps running after the session is closed, holding port 3000 and a gigabyte of memory until you hunt it down with lsof or restart your Mac.
strays is a Claude Code mod that finds every process Claude Code started and left running, in any session, with its port and memory, and ends it for you.

❯ /strays
👻 Left behind session closed
1 :3000 :24678 🟩 node vite --host 67 MB 1h 11m shop
2 :8000 🐍 Python manage.py runserver 8000 9 MB 1h 11m api
3 · 🟩 node browser.mjs 654 MB 1h 8m site
🌙 In the background session still open
4 :5432 🐚 nc -lk 5432 2 MB 8m blog
5 · 👀 tail -f build.log 1 MB 8m blog
/strays 1 shows one in full · /strays kill 1 ends it · /strays ui opens the panel
Needs macOS and Claude Code 2.1.287 or later (check with claude --version).
In a Claude Code session in your terminal, type:
/plugin marketplace add huynamboz/strays
/plugin install strays@strays
Pick the user scope, and it runs in every session from then on. Type /strays.
Or from a shell:
claude plugin marketplace add huynamboz/strays
claude plugin install strays@strays
In the Claude desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with huynamboz/strays, then Add plugin.
To update, then restart Claude Code:
claude plugin marketplace update strays && claude plugin update strays@strays
To remove:
claude plugin uninstall strays@strays
| Command | Does | | :- | :- | | /strays | in the terminal and the desktop app, opens the panel; in VS Code and claude -p, prints the list | | /strays ls | the list, each row numbered, wherever you are (also l, list) | | /strays 2 | row 2 in full: folder, command, program, start time, memory, its processes, the session that started it (also i 2, info 2, show 2) | | /strays :3000 | the same, for whatever listens on port 3000 | | /strays kill 2 | ends row 2 and everything it started (also k, end, stop; a pid or :port names one too) | | /strays kill all | ends every left-behind one | | /strays copy 2 | copies row 2's whole command to the clipboard (also c, cp) | | /strays ui | the panel, in the terminal and the desktop app (also u, open) | | /strays help | this table |
/ps is the same command, shorter: /ps, /ps 2, /ps k 2.
In the VS Code panel, where text is not monospace and padded columns would not line up, the list, one process in full and the help are drawn as tables: a port is a link that opens it in the browser, memory is a bar as well as a number, and the transcript of the session that started a process opens in the editor on a click. The panel, the line under the prompt and the notice at a session's start are not drawn there: VS Code shows a mod's text and nothing else.
A number names a row of the last list you printed, so /strays ls then /strays kill 3 ends what you saw as row 3.
When a server left behind still holds a port and a session started a newer copy of it in the same folder (vite moves to :3001 when :3000 is taken, and the browser tab on :3000 keeps showing the old build), the old one is marked as an older copy in the list, in full and in the panel, with /strays kill <n> or End to end it; a session says so once in a toast.
/strays ui opens a panel beside the transcript (or above the prompt in a narrower window). It is made to be clicked:
Yes, end it), and takes the question back after four secondsThe keyboard works too: the arrows move, Enter presses, o twice ends all left behind, r rescans, m shows the MCP servers and helpers of open sessions, x closes. In the full view: b back, e end, f folder, c copy.
While anything is stray, a line under the prompt says how many and on which ports; a session that starts while processes from earlier sessions still run says so once.
bin/scan.sh reads the process table and keeps a process when one of five things ties it to Claude Code:
CLAUDECODE=1, its session's id and its own pid in the environment of every process it starts, and a process keeps its environment for life: after the session is gone, after nohup.~/.claude/shell-snapshots.rg, bfs and ugrep and runs itself under their names. In a folder that is not a git repository, a session lists the files with that rg directly, not through the Bash tool, so the rg carries no mark; in a folder as large as your home it does not finish. A process named for one of the three whose program is another is Claude Code's. It shows under its session once it has run for a minute, and as left behind when the session is gone.sh, tail, nc, ssh), so their place in the tree stands in.nohup tail -f log & stays in it after the shell that started it exits: a process that shares a group with a marked one, and whose group leader is gone or marked too, came from the same call.Whether a session still runs is read from Claude Code's own registry, ~/.claude/sessions/<pid>.json, which also gives the session's name and folder for the full view. lsof gives the listening ports and the folders. Nothing is recorded as you work, so processes started before the mod was installed show up too.
bin/kill.sh sends TERM, waits two seconds and sends KILL to what remains. A pid can be handed to another program between the scan and the keypress, so it ends a process only if it still started at the moment the scan saw, belongs to you, and is not a Claude Code session.
nohup tail -f log & that is the only thing its Bash call started shares its group with nothing marked: once the session is gone it cannot be told from anyone else's.docker command exits and the container runs under the daemon./proc/<pid>/environ in place of ps -E.strays reads the process table with ps and lsof, on your machine, when you look. It makes no network calls, keeps no log, and ends nothing you did not choose.
git clone https://github.com/huynamboz/strays
cd strays
claude --plugin-dir .
claude plugin validate .
claude plugin test
npx -p typescript tsc -p .
hooks/register.tsx is the mod (commands, panel, status line), hooks/procs.ts the logic it draws from, and bin/ the shell scripts that scan, describe and end processes. Linux support needs bin/scan.sh to read /proc/<pid>/environ where it now reads ps -E; pull requests are welcome.
MIT
hooks/register.tsx 922 lines1// strays: the processes Claude Code started and left running, in any session.
2// bin/scan.sh finds them (by Claude Code's mark, the Bash tool's shell, the
3// process tree and the process group); /strays lists them, numbered, wherever
4// Claude Code runs; `/strays 2` shows one in full and `/strays kill 2` ends it;
5// `/strays ui` opens a panel in the terminal and the desktop app where a click
6// on a row shows it in full and a click on End ends it. A server left behind
7// beside a newer copy of it (vite on :3000, the new one on :3001) is marked as
8// an older copy everywhere it shows, and told of once in a toast.
9import type { EngineInterface, Register } from 'claude-code'
10
11import {
12 HELP,
13 HELP_MARKDOWN,
14 SECTIONS,
15 age,
16 basename,
17 clashNote,
18 clashToast,
19 clashes,
20 detailMarkdown,
21 detailText,
22 facts,
23 fit,
24 group,
25 listMarkdown,
26 listText,
27 megabytes,
28 parse,
29 parseArgs,
30 parseInfo,
31 plural,
32 portsOf,
33 portsText,
34 resolve,
35 shown,
36 visible,
37} from './procs'
38import type { Clash, Group, Info, Kind } from './procs'
39
40const PANE = 'strays'
41const COMMANDS = ['strays', 'ps']
42/** The color each kind is drawn in, everywhere it shows. */
43const ACCENT: Record<Kind, string> = { orphan: 'yellow', detached: 'cyan', running: 'green', mcp: 'gray' }
44const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
45// How many frames a bar takes to grow to its length when the panel opens.
46const GROW = 6
47
48let groups: Group[] = []
49let me = ''
50let isPaneOpen = false
51let isScanning = false
52let showsMcp = false
53// The row asked to end and waiting for a second press; `all` is the end-all button.
54let armed: number | 'all' | null = null
55let arming = 0
56let ticks = 0
57let statusText: string | undefined
58let note = ''
59// The key of the control that holds the panel's focus ring.
60let focused = ''
61// The pids of the last numbered list, in order: what `/strays 2` names.
62let order: number[] = []
63// The group the panel shows in full, and what bin/info.sh said of it.
64let detail: number | null = null
65let info: { pid: number; data: Info } | null = null
66// Animation: a frame counter that runs only while something moves.
67let frame = 0
68let runUntil = 0
69let timer: { cancel: () => void } | null = null
70let openedAt = 0
71let spinUntil = 0
72const ending = new Set<number>()
73// The lists printed in this session, by their text: the transcript draws them from these.
74const printed = new Map<string, { groups: Group[]; order: number[]; clashes: Clash[] }>()
75// The old copies a toast has told of, each by its pid and start: told once.
76const told = new Set<string>()
77// Whether the session draws yet: a toast sent before then is dropped.
78let isDrawn = false
79
80const redraw = ($: EngineInterface) => $.ui.invalidate('ui.render')
81const strays = () => groups.filter(g => g.kind === 'orphan' || g.kind === 'detached')
82const orphans = () => groups.filter(g => g.kind === 'orphan')
83const clashOf = (g: Group) => clashes(groups).find(c => c.old.pid === g.pid)
84
85/** A toast for each old copy of a server that was not told of yet: once each. */
86function tell($: EngineInterface) {
87 for (const clash of clashes(groups)) {
88 const key = `${clash.old.pid}@${clash.old.start}`
89 if (told.has(key)) continue
90 told.add(key)
91 $.ui.toast(clashToast(clash))
92 }
93}
94
95/** Keeps the frames coming for `frames` more, and stops by itself when nothing moves. */
96function animate($: EngineInterface, frames: number) {
97 runUntil = Math.max(runUntil, frame + frames)
98 if (timer) return
99 timer = $.clock.every(90, () => {
100 frame += 1
101 if (isPaneOpen) redraw($)
102 if (frame >= runUntil && ending.size === 0) {
103 timer?.cancel()
104 timer = null
105 }
106 })
107}
108
109// The scan running now, and the one asked for while it ran: whoever awaits a
110// scan gets one that started after it asked, so a command typed while the
111// first scan of a session still runs does not answer from an empty list.
112let running: Promise<void> | null = null
113let queued: Promise<void> | null = null
114
115function scan($: EngineInterface): Promise<void> {
116 if (running) {
117 queued ??= running.then(() => {
118 queued = null
119 return scan($)
120 })
121 return queued
122 }
123 running = scanNow($).finally(() => {
124 running = null
125 })
126 return running
127}
128
129async function scanNow($: EngineInterface) {
130 isScanning = true
131 try {
132 const ran = await $.process.run(['sh', `${$.plugin.root}/bin/scan.sh`], { timeoutMs: 20_000 })
133 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `scan exited ${ran.exitCode}`)
134 groups = group(parse(ran.stdout))
135 if (detail !== null && !groups.some(g => g.pid === detail)) detail = null
136 const left = strays()
137 const text =
138 left.length === 0
139 ? undefined
140 : `${plural(left.length, 'stray process')}${portsOf(left) ? ` on ${portsOf(left)}` : ''} · /strays`
141 if (text !== statusText) {
142 statusText = text
143 $.ui.status(text)
144 }
145 if (isDrawn) tell($)
146 } catch (err) {
147 note = `scan failed: ${String(err)}`
148 } finally {
149 isScanning = false
150 }
151 redraw($)
152}
153
154async function stop($: EngineInterface, list: Group[]) {
155 const targets = list.flatMap(g => g.members.map(m => `${m.pid}@${m.start}`))
156 if (targets.length === 0) return 0
157 for (const g of list) ending.add(g.pid)
158 animate($, 4)
159 redraw($)
160 let ended = 0
161 try {
162 const ran = await $.process.run(['sh', `${$.plugin.root}/bin/kill.sh`, ...targets], { timeoutMs: 20_000 })
163 ended = ran.stdout.trim().split(/\s+/).filter(Boolean).length
164 note =
165 ended > 0
166 ? `ended ${list.length === 1 ? (list[0]?.label ?? '') : plural(ended, 'process')}`
167 : 'nothing was ended: it had already gone or changed'
168 } finally {
169 for (const g of list) ending.delete(g.pid)
170 }
171 await scan($)
172
173 return ended
174}
175
176async function loadInfo($: EngineInterface, g: Group) {
177 try {
178 const ran = await $.process.run(['sh', `${$.plugin.root}/bin/info.sh`, String(g.pid), g.sid, g.claudePid], { timeoutMs: 10_000 })
179 const data = parseInfo(ran.stdout)
180 info = { pid: g.pid, data }
181
182 return data
183 } catch {
184 return null
185 }
186}
187
188/**
189 * Puts `text` on the clipboard: through the app that draws the session, else
190 * through macOS itself, since the editor's panel takes no copy from a mod.
191 */
192async function clip($: EngineInterface, text: string) {
193 const copied = await $.ui.copy({ text }).catch(() => ({ isCopied: false }))
194 if (copied.isCopied) return true
195 const ran = await $.process.run(['pbcopy'], { stdin: text, timeoutMs: 5000 }).catch(() => null)
196
197 return ran?.exitCode === 0
198}
199
200/** Shows one group in full in the panel, or the list again. */
201async function view($: EngineInterface, pid: number | null) {
202 detail = pid
203 armed = null
204 note = ''
205 openedAt = frame
206 animate($, GROW + 2)
207 const g = groups.find(x => x.pid === pid)
208 if (g) await loadInfo($, g)
209 redraw($)
210 const key = pid === null ? `row-${shown(groups, showsMcp)[0]?.pid ?? ''}` : 'back'
211 const moved = await $.ui.focus({ requestId: PANE, key }).catch(err => ({ deny: String(err) }))
212 if (!moved.deny) focused = key
213}
214
215/**
216 * The first press asks, the second one within four seconds ends: `what` is a
217 * group's pid or `all`. A press elsewhere, or the wait, takes the question back.
218 */
219async function press($: EngineInterface, what: number | 'all', list: () => Group[]) {
220 if (armed !== what) {
221 armed = what
222 const mine = ++arming
223 redraw($)
224 $.clock.after(4000, () => {
225 if (arming !== mine || armed !== what) return
226 armed = null
227 redraw($)
228 })
229
230 return
231 }
232 armed = null
233 await stop($, list())
234 if (detail !== null && !groups.some(g => g.pid === detail)) detail = null
235 // The row that held the ring is gone: hand the ring to the first one left.
236 const next = shown(groups, showsMcp)[0]
237 if (!next || !isPaneOpen) return
238 $.clock.after(250, async () => {
239 const key = `row-${next.pid}`
240 const moved = await $.ui.focus({ requestId: PANE, key }).catch(err => ({ deny: String(err) }))
241 if (moved.deny) return
242 // a move this module asked for does not come back through its own ui.focus hook
243 focused = key
244 redraw($)
245 })
246}
247
248/**
249 * The numbered list, remembered so a number can name a row and, where it is
250 * text, so the transcript can draw it.
251 */
252function listing(isMarkdown: boolean) {
253 if (isMarkdown) {
254 const table = listMarkdown(groups, me)
255 order = table.order
256
257 return table.text
258 }
259 const list = listText(groups, me)
260 order = list.order
261 printed.set(list.text, { groups: shown(groups, false), order: list.order, clashes: clashes(groups) })
262 if (printed.size > 12) printed.delete(printed.keys().next().value ?? '')
263
264 return list.text
265}
266
267/**
268 * Whether a command's answer is drawn as markdown here, in a font that is not
269 * monospace: the editor's panel, which draws none of a mod's own trees.
270 */
271async function drawsMarkdown($: EngineInterface) {
272 const entry = await $.env.get('CLAUDE_CODE_ENTRYPOINT').catch(() => undefined)
273 if (entry === 'claude-vscode') return true
274 const surfaces = await $.session.surfaces()
275
276 return surfaces.length > 0 && surfaces.every(surface => surface === 'vscode' || surface === 'mobile')
277}
278
279/**
280 * Calls `fn` once the session draws somewhere: in the terminal, once its screen
281 * is up and has bound the session. A toast or a status sent before then is
282 * dropped, and the session's first scan may end sooner. A look every quarter
283 * second; past `tries` of them `fn` runs anyway, as it did before it waited.
284 */
285function whenDrawn($: EngineInterface, fn: () => void, tries = 120) {
286 void $.session.surfaces().then(surfaces => {
287 if (surfaces.length > 0 || tries <= 1) fn()
288 else $.clock.after(250, () => whenDrawn($, fn, tries - 1))
289 })
290}
291
292export const register: Register = on => {
293 on('session.start', async ($, e, next) => {
294 for (const name of COMMANDS) {
295 try {
296 await $.command.register({
297 name,
298 description:
299 name === 'strays'
300 ? 'Processes Claude Code started and left running: list them, see one in full, end it'
301 : 'Short for /strays: processes Claude Code left running',
302 argumentHint: '[number | :port | kill <number|all> | copy <number> | ui | help]',
303 immediate: true,
304 })
305 } catch (err) {
306 $.ui.log(`/${name} was not added: ${String(err)}`, { to: 'debug' })
307 }
308 }
309 me = await $.session.id()
310 // Off the session's start: look once now, then every half minute, or every five seconds while the panel is open.
311 $.clock.after(0, async () => {
312 await scan($)
313 whenDrawn($, () => {
314 isDrawn = true
315 // The scan's status may have gone out before the screen was up: again, now that it is.
316 if (statusText !== undefined) $.ui.status(statusText)
317 const left = orphans()
318 if (left.length > 0) {
319 const size = megabytes(left.reduce((sum, g) => sum + g.rssKb, 0))
320 $.ui.toast(`👻 ${plural(left.length, 'process')} from earlier Claude Code sessions still running (${size}): /strays`)
321 }
322 tell($)
323 })
324 })
325 $.clock.every(5000, () => {
326 ticks += 1
327 if (isPaneOpen || ticks % 6 === 0) void scan($)
328 })
329
330 return next(e)
331 })
332
333 on('command.run', { command: ['strays', 'ps'] }, async ($, e) => {
334 const ask = parseArgs(e.args)
335 const isMarkdown = await drawsMarkdown($)
336 if (ask.verb === 'help') return { text: isMarkdown ? HELP_MARKDOWN : HELP }
337 await scan($)
338 // A number names a row of the last list; before any list was printed here, of the list as it stands.
339 if (order.length === 0) order = listText(groups, me).order
340
341 if (ask.verb === 'ui' || ask.verb === 'auto') {
342 // The panel draws in the terminal and the desktop app. Anywhere else (the
343 // editor's panel, claude -p) the bare command prints the list instead.
344 const surfaces = await $.session.surfaces()
345 if (isMarkdown || !surfaces.some(surface => surface === 'terminal' || surface === 'desktop')) {
346 if (ask.verb === 'auto') return { text: listing(isMarkdown) }
347
348 return { text: `The panel draws in the terminal and the desktop app. Here it is as a list:\n\n${listing(isMarkdown)}` }
349 }
350 isPaneOpen = true
351 note = ''
352 armed = null
353 detail = null
354 openedAt = frame
355 await $.ui.open({ id: PANE, title: 'Strays', focus: true, rows: 16, columns: 90 })
356 animate($, GROW + 2)
357
358 return {}
359 }
360
361 if (ask.verb === 'info') {
362 const g = resolve(ask.target, groups, order)
363 if (!g) return { text: `Nothing matches ${ask.target || 'that'}.\n\n${listing(isMarkdown)}` }
364
365 const data = await loadInfo($, g)
366 const index = order.indexOf(g.pid) + 1
367
368 const clash = clashOf(g)
369
370 return { text: isMarkdown ? detailMarkdown(g, data, index, clash) : detailText(g, data, index, clash) }
371 }
372
373 if (ask.verb === 'copy') {
374 if (ask.target === '') return { text: `Which one? /strays copy 2 copies row 2's command.\n\n${listing(isMarkdown)}` }
375 const g = resolve(ask.target, groups, order)
376 if (!g) return { text: `Nothing matches ${ask.target}.\n\n${listing(isMarkdown)}` }
377 // as the machine has it, whole: the scan's own copy may be cut
378 const command = (await loadInfo($, g))?.fields.command || g.command
379 const name = isMarkdown ? `${g.icon} \`${g.label.replace(/`/g, "'")}\`` : `${g.icon} ${g.label}`
380
381 return {
382 text: (await clip($, command))
383 ? `Copied the command of ${name} to the clipboard.`
384 : `The clipboard did not take it. The command of ${name}:\n\n${isMarkdown ? `\`\`\`\n${command}\n\`\`\`` : command}`,
385 }
386 }
387
388 if (ask.verb === 'kill') {
389 if (ask.target === '') {
390 return { text: `Which one? /strays kill 2 ends row 2, /strays kill all ends every left-behind one.\n\n${listing(isMarkdown)}` }
391 }
392 const list =
393 ask.target === 'all' ? orphans() : [resolve(ask.target, groups, order)].filter((g): g is Group => g !== undefined)
394 if (list.length === 0) {
395 return { text: `${ask.target === 'all' ? 'Nothing is left behind.' : `Nothing matches ${ask.target}.`}\n\n${listing(isMarkdown)}` }
396 }
397 const names = list.map(g => `${g.icon} ${g.label}${portsText(g) ? ` (${portsText(g)})` : ''}`)
398 const ended = await stop($, list)
399
400 return {
401 text:
402 ended > 0
403 ? `Ended ${names.join(', ')}: ${plural(ended, 'process')}.\n\n${listing(isMarkdown)}`
404 : `Nothing was ended: ${names.join(', ')} had already gone or changed.\n\n${listing(isMarkdown)}`,
405 }
406 }
407
408 return { text: listing(isMarkdown) }
409 })
410
411 on('ui.close', { id: PANE }, async ($, e, next) => {
412 isPaneOpen = false
413 armed = null
414 detail = null
415
416 return next(e)
417 })
418
419 // The ring moved: the control that holds it is the one drawn as chosen.
420 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
421 const moved = await next(e)
422 if (!moved.deny && (e.element ?? '') !== focused) {
423 focused = e.element ?? ''
424 redraw($)
425 }
426
427 return moved
428 })
429
430 // The list a command printed, drawn in the transcript in color: the same rows, numbered.
431 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
432 // The output may carry words before the list ("Ended …"), and the plugin's name before those.
433 const text = COMMANDS.includes(e.props.command) ? e.props.text : ''
434 const key = [...printed.keys()].reverse().find(known => text.includes(known))
435 const list = key === undefined ? undefined : printed.get(key)
436 if (key === undefined || !list || list.groups.length === 0) return next(e)
437 const before = text
438 .slice(0, text.indexOf(key))
439 .replace(/^strays:\s*/, '')
440 .trim()
441 const { Box, Text } = $.ui.resolve(e)
442 const portWidth = Math.min(12, Math.max(5, ...list.groups.map(g => portsText(g).length)))
443 const nameWidth = Math.min(48, Math.max(16, ...list.groups.map(g => g.label.length)))
444 const digits = String(list.groups.length).length
445
446 return (
447 <Box flexDirection="column">
448 {before !== '' && <Text>{before}</Text>}
449 {SECTIONS.map(section => ({ ...section, rows: list.groups.filter(g => g.kind === section.kind) }))
450 .filter(section => section.rows.length > 0)
451 .map(section => (
452 <Box flexDirection="column">
453 <Box flexDirection="row" columnGap={1}>
454 <Text bold color={ACCENT[section.kind]}>
455 {`${section.icon} ${section.title}`}
456 </Text>
457 <Text dimColor>{section.why}</Text>
458 </Box>
459 {section.rows.map(g => {
460 const n = list.order.indexOf(g.pid) + 1
461 const clash = list.clashes.find(c => c.old.pid === g.pid)
462
463 return (
464 <Box flexDirection="column">
465 <Box flexDirection="row" columnGap={1}>
466 <Text dimColor>{` ${String(n).padStart(digits)}`}</Text>
467 {portsText(g) ? (
468 <Text backgroundColor={ACCENT[g.kind]} color="black" bold>
469 {` ${fit(portsText(g), portWidth)} `}
470 </Text>
471 ) : (
472 <Text dimColor>{` ${fit('·', portWidth)} `}</Text>
473 )}
474 <Text bold={g.kind === 'orphan'}>{`${g.icon} ${fit(g.label, nameWidth)}`}</Text>
475 <Text dimColor>{`${megabytes(g.rssKb).padStart(7)} ${age(g.seconds).padStart(6)} ${basename(g.cwd) || '/'}`}</Text>
476 </Box>
477 {clash && (
478 <Text color="yellow">
479 {` ${' '.repeat(digits)} ⚠️ ${clashNote(clash)} · /strays kill ${n} ends it and frees :${clash.oldPort}`}
480 </Text>
481 )}
482 </Box>
483 )
484 })}
485 </Box>
486 ))}
487 <Text dimColor>/strays 1 shows one in full · /strays kill 1 ends it · /strays ui opens the panel</Text>
488 </Box>
489 )
490 })
491
492 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
493 const { Box, Text, Button } = $.ui.resolve(e)
494 const columns = e.props.bodyColumns
495 const isDocked = e.props.placement === 'dock'
496 const grow = Math.min(1, (frame - openedAt + 1) / GROW)
497 const spinner = frame < spinUntil || isScanning || ending.size > 0 ? (SPIN[frame % SPIN.length] ?? '') : ' '
498
499 // Memory as a bar: eight cells against the heaviest here (256 MB at least), growing in as the panel opens.
500 const meter = (g: Group, biggest: number) => {
501 const filled = Math.max(1, Math.round((g.rssKb / biggest) * 8 * grow))
502 const color = g.rssKb > 1024 * 1024 ? 'red' : g.rssKb > 300 * 1024 ? 'yellow' : 'green'
503
504 return (
505 <Box flexDirection="row" flexShrink={0}>
506 <Text color={color} wrap="truncate">
507 {'█'.repeat(filled)}
508 </Text>
509 <Text dimColor wrap="truncate">
510 {'░'.repeat(8 - filled)}
511 </Text>
512 <Text dimColor wrap="truncate">
513 {megabytes(g.rssKb).padStart(7)}
514 </Text>
515 </Box>
516 )
517 }
518
519 const chosen = detail === null ? undefined : groups.find(g => g.pid === detail)
520 if (chosen) {
521 // One group in full.
522 const color = ACCENT[chosen.kind]
523 const data = info?.pid === chosen.pid ? info.data : null
524 const clash = clashOf(chosen)
525 const isAsked = armed === chosen.pid
526 const isEnding = ending.has(chosen.pid)
527 const port = chosen.ports[0]
528 const labelWidth = 11
529 const cardWidth = Math.min(columns, 100)
530 const valueWidth = Math.max(20, cardWidth - labelWidth - 4)
531
532 const back = <Button key="back" label="← Back" hotkey="b" dimColor onPress={() => view($, null)} />
533 const heading = (
534 <Box flexDirection="row" columnGap={1}>
535 {portsText(chosen) !== '' && (
536 <Text backgroundColor={color} color="black" bold>
537 {` ${portsText(chosen)} `}
538 </Text>
539 )}
540 <Text bold wrap="truncate-end">{`${chosen.icon} ${chosen.label}`}</Text>
541 </Box>
542 )
543 const card = (
544 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={cardWidth}>
545 {facts(chosen, data, clash).map(([label, value]) => (
546 <Box flexDirection="row">
547 <Box flexShrink={0} width={labelWidth}>
548 <Text dimColor>{label}</Text>
549 </Box>
550 <Box flexDirection="column" width={valueWidth}>
551 {label === 'Memory' ? (
552 <Box flexDirection="row" columnGap={1}>
553 {meter(chosen, Math.max(256 * 1024, chosen.rssKb))}
554 <Text dimColor wrap="truncate-end">
555 {value.slice(megabytes(chosen.rssKb).length)}
556 </Text>
557 </Box>
558 ) : (
559 value.split('\n').map(text => (
560 <Text wrap="wrap" color={label === 'State' ? color : label === 'Clash' ? 'yellow' : undefined}>
561 {text}
562 </Text>
563 ))
564 )}
565 </Box>
566 </Box>
567 ))}
568 </Box>
569 )
570 const done = note !== '' && <Text color="green">{`✓ ${note}`}</Text>
571 const buttons = (
572 <Box flexDirection="row" columnGap={2} flexWrap="wrap">
573 {isEnding ? (
574 <Text color="red">{`${spinner} ending…`}</Text>
575 ) : (
576 <Button
577 key="end"
578 label={isAsked ? 'Yes, end it' : 'End process'}
579 hotkey="e"
580 variant="primary"
581 onPress={() => press($, chosen.pid, () => groups.filter(g => g.pid === chosen.pid))}
582 />
583 )}
584 <Button
585 key="folder"
586 label="Open folder"
587 hotkey="f"
588 onPress={async () => {
589 await $.process.run(['open', data?.fields.cwd || chosen.cwd])
590 }}
591 />
592 {port !== undefined && (
593 <Button
594 key="browser"
595 label={`Open :${port}`}
596 hotkey="o"
597 onPress={async () => {
598 await $.process.run(['open', `http://localhost:${port}`])
599 }}
600 />
601 )}
602 <Button
603 key="copy"
604 label="Copy command"
605 hotkey="c"
606 onPress={async pressed => {
607 await $.ui.copy({ text: data?.fields.command || chosen.command, surface: pressed.surface })
608 note = 'command copied'
609 redraw($)
610 }}
611 />
612 </Box>
613 )
614 const hint = <Text dimColor>click a button · b back · e end · f folder · c copy · Esc to the prompt</Text>
615
616 // Above the prompt a panel shows about ten rows: the buttons go under the
617 // name, over the facts, where they are never cut off.
618 if (!isDocked) {
619 return (
620 <Box flexDirection="column">
621 <Box flexDirection="row" columnGap={1}>
622 {back}
623 <Text color={color}>{spinner}</Text>
624 {heading}
625 </Box>
626 {buttons}
627 {done}
628 {card}
629 {hint}
630 </Box>
631 )
632 }
633
634 return (
635 <Box flexDirection="column" rowGap={1}>
636 <Box flexDirection="row" columnGap={1}>
637 {back}
638 <Text color={color}>{spinner}</Text>
639 </Box>
640 {heading}
641 {card}
642 {done}
643 {buttons}
644 {hint}
645 </Box>
646 )
647 }
648
649 // The list.
650 const sections = SECTIONS.filter(s => s.kind !== 'mcp' || showsMcp)
651 .map(section => ({ ...section, rows: visible(groups, section.kind) }))
652 .filter(section => section.rows.length > 0)
653 const rows = sections.flatMap(section => section.rows)
654 const gone = orphans()
655 const loose = visible(groups, 'detached')
656 const left = strays()
657 const hidden = showsMcp ? 0 : visible(groups, 'mcp').length
658 const first = rows[0]?.pid
659 const isWide = columns >= 72
660 const portWidth = Math.min(12, Math.max(5, ...rows.map(g => portsText(g).length)))
661 const biggest = Math.max(256 * 1024, ...rows.map(g => g.rssKb))
662 const folderWidth = Math.min(10, Math.max(3, ...rows.map(g => (basename(g.cwd) || '/').length)))
663 // Beside the transcript the panel is as wide as its content asks, so the
664 // list keeps to about ninety columns there whatever room there is.
665 const fixed = 1 + (portWidth + 2) + 15 + (8 + folderWidth) + 7 + 5 + 4
666 const nameWidth = isDocked ? 46 - portWidth : Math.min(64, Math.max(14, columns - fixed))
667 // A frame hugs its rows (the widest, with the question's longer button) and stops at the panel's edge.
668 const frameWidth = Math.min(columns, 1 + (portWidth + 2) + nameWidth + 15 + (8 + folderWidth) + 15 + 5 + 4)
669 const memory = megabytes(left.reduce((sum, g) => sum + g.rssKb, 0))
670 const found = clashes(groups)
671
672 // A number over its name, in a frame the color of what it counts.
673 const tile = (icon: string, value: string, name: string, color: string, isLit: boolean) => (
674 <Box flexDirection="column" borderStyle="round" borderColor={color} borderDimColor={!isLit} paddingX={1} width={19}>
675 <Text bold color={isLit ? color : undefined} dimColor={!isLit}>
676 {`${icon} ${value}`}
677 </Text>
678 <Text dimColor>{name}</Text>
679 </Box>
680 )
681
682 const item = (g: Group) => {
683 const color = ACCENT[g.kind]
684 const rowKey = `row-${g.pid}`
685 const endKey = `end-${g.pid}`
686 const isChosen = focused === rowKey || focused === endKey
687 const isAsked = armed === g.pid
688 const isEnding = ending.has(g.pid)
689 const port = portsText(g)
690 const clash = found.find(c => c.old.pid === g.pid)
691 // Under an older copy of a server an open session runs: what it is, and that End frees its port.
692 const warning = clash && (
693 <Box paddingLeft={2}>
694 <Text color="yellow" wrap="truncate-end">
695 {`⚠️ ${clashNote(clash)} · End frees :${clash.oldPort}`}
696 </Text>
697 </Box>
698 )
699 // Everything but the command keeps its width: the command is cut with an ellipsis.
700 const mark = (
701 <Box flexShrink={0}>
702 <Text bold color={color}>
703 {isEnding ? (SPIN[frame % SPIN.length] ?? ' ') : isChosen ? '❯' : ' '}
704 </Text>
705 </Box>
706 )
707 const pill = (
708 <Box flexShrink={0}>
709 {port ? (
710 <Text backgroundColor={color} color="black" bold wrap="truncate">
711 {` ${fit(port, portWidth)} `}
712 </Text>
713 ) : (
714 <Text dimColor wrap="truncate">
715 {` ${fit('·', portWidth)} `}
716 </Text>
717 )}
718 </Box>
719 )
720 const width = isWide ? nameWidth : Math.max(14, columns - portWidth - 10)
721 // The row itself is the control: a click, or Enter, shows the process in full.
722 const name = (
723 <Box flexShrink={0}>
724 <Button
725 key={rowKey}
726 plain
727 label={`${g.icon} ${fit(isAsked ? `End ${g.label}?` : g.label, width - 3)}`}
728 hover={{ underline: true, bold: true }}
729 dimColor={isEnding || g.kind === 'mcp'}
730 {...(g.pid === first ? { autoFocus: true as const } : {})}
731 onPress={() => view($, g.pid)}
732 />
733 </Box>
734 )
735 const about = (
736 <Box flexShrink={0}>
737 <Text dimColor wrap="truncate">
738 {`${age(g.seconds).padStart(6)} ${fit(basename(g.cwd) || '/', folderWidth)}`}
739 </Text>
740 </Box>
741 )
742 const button = (
743 <Box flexShrink={0}>
744 {isEnding ? (
745 <Text color="red">ending…</Text>
746 ) : (
747 <Button
748 key={endKey}
749 label={isAsked ? 'Yes, end it' : 'End'}
750 {...(isAsked ? { variant: 'primary' as const } : {})}
751 onPress={() => press($, g.pid, () => groups.filter(x => x.pid === g.pid))}
752 />
753 )}
754 </Box>
755 )
756 // The row's Box has a key, so the pointer anywhere on the row lights its name.
757 if (isWide) {
758 const row = (
759 <Box key={`item-${g.pid}`} flexDirection="row" columnGap={1}>
760 {mark}
761 {pill}
762 {name}
763 {meter(g, biggest)}
764 {about}
765 {button}
766 </Box>
767 )
768
769 return warning ? (
770 <Box flexDirection="column">
771 {row}
772 {warning}
773 </Box>
774 ) : (
775 row
776 )
777 }
778
779 // A narrow panel: the same row on two lines.
780 return (
781 <Box key={`item-${g.pid}`} flexDirection="column">
782 <Box flexDirection="row" columnGap={1}>
783 {mark}
784 {pill}
785 {name}
786 </Box>
787 <Box flexDirection="row" columnGap={1} paddingLeft={2}>
788 {meter(g, biggest)}
789 {about}
790 {button}
791 </Box>
792 {warning}
793 </Box>
794 )
795 }
796
797 const title = (section: (typeof sections)[number]) => (
798 <Box flexDirection="row" columnGap={1}>
799 <Text bold color={ACCENT[section.kind]}>
800 {`${section.icon} ${section.title}`}
801 </Text>
802 <Text dimColor>{section.why}</Text>
803 </Box>
804 )
805 const actions = (
806 <Box flexDirection="row" columnGap={2}>
807 {gone.length > 0 && (
808 <Button
809 key="end-orphans"
810 label={armed === 'all' ? `Press o again to end ${gone.length}` : `End all left behind (${gone.length})`}
811 hotkey="o"
812 variant="primary"
813 onPress={() => press($, 'all', orphans)}
814 />
815 )}
816 <Button
817 key="refresh"
818 label="Refresh"
819 hotkey="r"
820 dimColor
821 onPress={async () => {
822 spinUntil = frame + 6
823 animate($, 6)
824 await scan($)
825 }}
826 />
827 <Button
828 key="mcp"
829 label={showsMcp ? 'Hide MCP' : `MCP${hidden > 0 ? ` (${hidden})` : ''}`}
830 hotkey="m"
831 dimColor
832 onPress={() => {
833 showsMcp = !showsMcp
834 redraw($)
835 }}
836 />
837 <Button key="close" label="Close" hotkey="x" dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
838 <Text color="cyan">{spinner}</Text>
839 </Box>
840 )
841 const hint = (
842 <Text dimColor>{`click a row for details · click End to end it · ↑↓ ⏎ work too${gone.length > 0 ? ' · o ends all left behind' : ''}`}</Text>
843 )
844 const done = note !== '' && <Text color="green">{`✓ ${note}`}</Text>
845
846 // Above the prompt a panel is wide and short: the counts become one line of
847 // labels, the actions sit under them where they are never cut off, and the
848 // groups lose their frames.
849 if (!isDocked) {
850 const badge = (text: string, color: string, isLit: boolean) =>
851 isLit ? (
852 <Text backgroundColor={color} color="black" bold>
853 {` ${text} `}
854 </Text>
855 ) : (
856 <Text dimColor>{` ${text} `}</Text>
857 )
858
859 return (
860 <Box flexDirection="column">
861 <Box flexDirection="row" columnGap={1}>
862 {rows.length === 0 && left.length === 0 ? (
863 <Text bold color="green">
864 ✨ All clean: nothing Claude Code started is still running
865 </Text>
866 ) : (
867 <Box flexDirection="row" columnGap={1}>
868 {badge(`👻 ${gone.length} left behind`, ACCENT.orphan, gone.length > 0)}
869 {badge(`🌙 ${loose.length} in background`, ACCENT.detached, loose.length > 0)}
870 {badge(`💾 ${memory} held`, 'magenta', left.length > 0)}
871 </Box>
872 )}
873 {done}
874 </Box>
875 {actions}
876 {sections.map(section => (
877 <Box flexDirection="column">
878 {title(section)}
879 {section.rows.map(item)}
880 </Box>
881 ))}
882 {hint}
883 </Box>
884 )
885 }
886
887 return (
888 <Box flexDirection="column" rowGap={1}>
889 <Box flexDirection="row" columnGap={1}>
890 {tile('👻', String(gone.length), 'left behind', ACCENT.orphan, gone.length > 0)}
891 {tile('🌙', String(loose.length), 'in background', ACCENT.detached, loose.length > 0)}
892 {tile('💾', memory, 'memory held', 'magenta', left.length > 0)}
893 </Box>
894 {rows.length === 0 && (
895 <Box borderStyle="round" borderColor="green" paddingX={2} flexDirection="column">
896 <Text bold color="green">
897 ✨ All clean
898 </Text>
899 <Text dimColor>Nothing Claude Code started is still running.</Text>
900 </Box>
901 )}
902 {sections.map(section => (
903 <Box
904 flexDirection="column"
905 borderStyle="round"
906 borderColor={ACCENT[section.kind]}
907 borderDimColor={section.kind === 'running' || section.kind === 'mcp'}
908 paddingX={1}
909 width={frameWidth}
910 >
911 {title(section)}
912 {section.rows.map(item)}
913 </Box>
914 ))}
915 {done}
916 {actions}
917 {hint}
918 </Box>
919 )
920 })
921}
922hooks/procs.ts 627 lines1// strays: what the scan found, grouped and put into words. No `$` here: every
2// function is plain data in, plain data out, so the tests call them directly.
3
4/** How a process was tied to Claude Code: bin/scan.sh says what each letter stands for. */
5export type How = 'e' | 'w' | 's' | 't' | 'g'
6
7/** One process, as bin/scan.sh prints it. */
8export type Proc = {
9 pid: number
10 ppid: number
11 seconds: number
12 rssKb: number
13 sid: string
14 claudePid: string
15 isAlive: boolean
16 isAttached: boolean
17 ports: number[]
18 cwd: string
19 how: How
20 pgid: number
21 cpu: number
22 start: number
23 command: string
24}
25
26/**
27 * Why a process is here: its session is gone (`orphan`); its session runs but
28 * it was cut loose from it, as with nohup or & (`detached`); it still hangs
29 * under a running session (`running`); it is a session's MCP server or helper (`mcp`).
30 */
31export type Kind = 'orphan' | 'detached' | 'running' | 'mcp'
32
33/** One process of a group: `ports` and `cwd` are its own, so a group's server can be told from its helpers. */
34export type Member = { pid: number; start: number; label: string; rssKb: number; ports: number[]; cwd: string }
35
36/** A process and everything it started, shown and ended as one. */
37export type Group = {
38 pid: number
39 kind: Kind
40 label: string
41 icon: string
42 command: string
43 members: Member[]
44 ports: number[]
45 rssKb: number
46 cpu: number
47 seconds: number
48 start: number
49 cwd: string
50 sid: string
51 claudePid: string
52 how: How
53}
54
55export const SECTIONS: { kind: Kind; icon: string; title: string; why: string }[] = [
56 { kind: 'orphan', icon: '👻', title: 'Left behind', why: 'session closed' },
57 { kind: 'detached', icon: '🌙', title: 'In the background', why: 'session still open' },
58 { kind: 'running', icon: '🏃', title: 'Still running', why: 'under an open session, for over a minute' },
59 { kind: 'mcp', icon: '🔌', title: 'MCP servers and helpers', why: 'ending one breaks its session' },
60]
61
62const STATE: Record<Kind, string> = {
63 orphan: 'left behind: the session that started it is closed',
64 detached: 'in the background: its session is still open, and it runs outside it',
65 running: 'running under an open session',
66 mcp: 'an MCP server or helper of an open session',
67}
68
69const FOUND: Record<How, string> = {
70 e: "Claude Code's mark in its environment (CLAUDECODE=1)",
71 w: 'it is the shell the Bash tool ran a command in',
72 s: 'it is Claude Code itself, run as its own search tool (rg, bfs, ugrep)',
73 t: 'its place in the process tree, under a Claude Code session',
74 g: 'its process group, shared with a process Claude Code marked',
75}
76
77export function parse(stdout: string): Proc[] {
78 const procs: Proc[] = []
79 for (const line of stdout.split('\n')) {
80 const f = line.split('\t')
81 if (f.length < 15) continue
82 procs.push({
83 pid: Number(f[0]),
84 ppid: Number(f[1]),
85 seconds: Number(f[2]),
86 rssKb: Number(f[3]),
87 sid: f[4] ?? '',
88 claudePid: f[5] ?? '',
89 isAlive: f[6] === '1',
90 isAttached: f[7] === '1',
91 ports: (f[8] ?? '').split(',').filter(Boolean).map(Number),
92 cwd: f[9] ?? '',
93 how: (['e', 'w', 's', 't', 'g'].includes(f[10] ?? '') ? f[10] : 'e') as How,
94 pgid: Number(f[11]),
95 cpu: Number((f[12] ?? '0').replace(',', '.')) || 0,
96 start: Number(f[13]),
97 command: f.slice(14).join(' '),
98 })
99 }
100
101 return procs
102}
103
104export const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1)
105const isSession = (command: string) => basename(command.split(' ')[0] ?? '') === 'claude'
106// A browser's crash reporter runs beside it and ends with it: not worth a row.
107const isNoise = (command: string) => /crashpad[_-]handler/.test(command)
108
109/** The command as a person would recognize it: what the Bash tool was asked to run, every path cut to its name. */
110export function describe(command: string) {
111 let text = command
112 // The Bash tool runs a command as `zsh -c source <snapshot> … && eval '<command>' …`
113 const at = text.indexOf("eval '")
114 if (text.includes('/shell-snapshots/') && at >= 0) {
115 text = text.slice(at + 6)
116 const quote = text.lastIndexOf("'")
117 if (quote > 0) text = text.slice(0, quote)
118 }
119 text = text.replace(/\s+/g, ' ').trim()
120 // An app's program sits in a bundle whose path may hold spaces: name it for
121 // the bundle, and go on with what follows the program.
122 let name = ''
123 const bundle = text.lastIndexOf('.app/Contents/MacOS/')
124 if (text.startsWith('/') && bundle >= 0) {
125 name = basename(text.slice(0, bundle))
126 const rest = text.slice(bundle + '.app/Contents/MacOS/'.length)
127 text = rest.startsWith(name) ? rest.slice(name.length) : rest.slice(Math.max(0, rest.indexOf(' ')))
128 }
129 // Every path is cut to its last name: node_modules/.bin/vite reads vite.
130 const isPath = (word: string) =>
131 word.includes('/') && !word.startsWith('-') && !word.includes('://') && !word.endsWith('/') && !word.startsWith('/dev/')
132 const words = text
133 .split(' ')
134 .filter(Boolean)
135 .map(word => (isPath(word) ? basename(word) : word))
136
137 return [name, ...words].filter(Boolean).join(' ')
138}
139
140// What a process is, at a glance. Each icon is one emoji two cells wide, so rows line up.
141const ICONS: [RegExp, string][] = [
142 [/chrome|chromium|firefox|webkit|playwright|puppeteer/i, '🌐'],
143 [/\b(jest|vitest|pytest|mocha|playwright test|cargo test|go test)\b|\b(npm|pnpm|yarn|bun) (run )?test\b/i, '🧪'],
144 [/\b(postgres|psql|mysqld?|redis(-server)?|mongod?|sqlite3?)\b/i, '💾'],
145 [/\bdocker\b|\bpodman\b/i, '🐳'],
146 [/\b(tail|less|watch|fswatch|chokidar|nodemon)\b|--watch\b/i, '👀'],
147 [/^(rg|bfs|ugrep|grep|find|fd|ag)(\s|$)/, '🔎'],
148 [/^(npm|pnpm|yarn|bun|npx|bunx)\b/i, '📦'],
149 [/^(node|deno|tsx|ts-node)(\s|$)|\b(vite|next|webpack|esbuild|astro|nuxt)\b/i, '🟩'],
150 [/^python[\d.]*(\s|$)|\b(uvicorn|gunicorn|flask|django|manage\.py)\b/i, '🐍'],
151 [/^(ruby|rails|bundle|rake)\b/i, '💎'],
152 [/^(cargo|rustc)\b|target\/(debug|release)\//i, '🦀'],
153 [/^(go|air)\b/i, '🐹'],
154 [/^(java|gradle|mvn)\b/i, '🍵'],
155 [/^(sh|bash|zsh|fish|nc|ssh|socat)\b|&&|\bwhile\b/i, '🐚'],
156]
157
158export function iconOf(label: string, command: string) {
159 for (const [pattern, icon] of ICONS) if (pattern.test(label) || pattern.test(command)) return icon
160
161 return '🔧'
162}
163
164export function group(procs: Proc[]): Group[] {
165 const shown = procs.filter(p => !isSession(p.command) && !isNoise(p.command))
166 const pids = new Set(shown.map(p => p.pid))
167 const children = new Map<number, Proc[]>()
168 for (const p of shown) children.set(p.ppid, [...(children.get(p.ppid) ?? []), p])
169 const out: Group[] = []
170 for (const root of shown) {
171 if (pids.has(root.ppid)) continue
172 const members: Proc[] = []
173 for (const queue = [root]; queue.length > 0; ) {
174 const next = queue.shift()
175 if (!next) break
176 members.push(next)
177 queue.push(...(children.get(next.pid) ?? []))
178 }
179 const kind: Kind = !root.isAlive ? 'orphan' : root.claudePid === '' ? 'mcp' : root.isAttached ? 'running' : 'detached'
180 const label = describe(root.command)
181 out.push({
182 pid: root.pid,
183 kind,
184 label,
185 icon: iconOf(label, root.command),
186 command: root.command.replace(/\s+/g, ' ').trim(),
187 // children first, so a parent does not restart what was just ended
188 members: members
189 .map(m => ({ pid: m.pid, start: m.start, label: describe(m.command), rssKb: m.rssKb, ports: m.ports, cwd: m.cwd }))
190 .reverse(),
191 ports: [...new Set(members.flatMap(m => m.ports))].sort((a, b) => a - b),
192 rssKb: members.reduce((sum, m) => sum + m.rssKb, 0),
193 cpu: Math.round(members.reduce((sum, m) => sum + m.cpu, 0) * 10) / 10,
194 seconds: root.seconds,
195 start: root.start,
196 cwd: root.cwd,
197 sid: root.sid,
198 claudePid: root.claudePid,
199 how: root.how,
200 })
201 }
202
203 return out.sort((a, b) => b.seconds - a.seconds)
204}
205
206export function age(seconds: number) {
207 if (seconds < 60) return `${seconds}s`
208 if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
209 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
210
211 return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`
212}
213
214export const megabytes = (kb: number) =>
215 kb >= 1024 * 1024 ? `${(kb / 1024 / 1024).toFixed(1)} GB` : `${Math.max(1, Math.round(kb / 1024))} MB`
216export const portsText = (g: Group) => g.ports.map(p => `:${p}`).join(' ')
217export const portsOf = (list: Group[]) => {
218 const ports = [...new Set(list.flatMap(g => g.ports))].map(p => `:${p}`)
219
220 return ports.length > 4 ? `${ports.slice(0, 4).join(' ')} +${ports.length - 4}` : ports.join(' ')
221}
222export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : word.endsWith('s') ? 'es' : 's'}`
223export const fit = (text: string, width: number) =>
224 text.length > width ? text.slice(0, Math.max(1, width - 1)) + '…' : text.padEnd(width)
225
226// A command the Bash tool is running right now is not worth a row: under an open
227// session, only what has run for a minute is shown.
228export const visible = (groups: Group[], kind: Kind) =>
229 groups.filter(g => g.kind === kind && (kind !== 'running' || g.seconds >= 60))
230
231/** The groups in the order they are shown, MCP servers last and only on request. */
232export const shown = (groups: Group[], withMcp: boolean) =>
233 SECTIONS.filter(s => withMcp || s.kind !== 'mcp').flatMap(s => visible(groups, s.kind))
234
235// Words that start a program rather than name it: `node vite` and `npx vite` both run vite.
236const LAUNCHER = /^(node|nodejs|deno|tsx|ts-node|npx|bunx|pnpx|python[\d.]*|ruby|sh|bash|zsh|env|exec|nohup|uv|uvx)$/i
237const MANAGER = /^(npm|pnpm|yarn|bun)$/i
238
239/**
240 * The program a command runs, the same for two copies of it whatever port each
241 * was given: `node vite --port 3001` and `npx vite` are `vite`; a package
242 * manager is named with its script, `npm run dev`.
243 */
244export function programOf(label: string) {
245 // of a line of commands, the last one: `cd shop && npm run dev`
246 const commands = label.split(/&&|\|\||;/)
247 const words = (commands[commands.length - 1] ?? '')
248 .replace(/['"]/g, '')
249 .split(' ')
250 .filter(word => word !== '' && !/^\w+=/.test(word))
251 const at = words.findIndex(word => !LAUNCHER.test(word) && !word.startsWith('-') && !/^\d+$/.test(word))
252 const first = words[at] ?? ''
253 if (!MANAGER.test(first)) return first
254 const rest = words.slice(at + 1)
255
256 return [first, ...rest.slice(0, rest[0] === 'run' || rest[0] === 'run-script' ? 2 : 1)].join(' ')
257}
258
259/**
260 * A server left behind beside a newer copy of it from an open session: the same
261 * program, in the same folder, each listening. The old one keeps its port, so
262 * the new one took another (vite moves from :3000 to :3001), and the browser
263 * tab on the old port still shows the old build. `name` is the program both
264 * run, `oldPort` what ending the old one frees, `port` where the new one is.
265 */
266export type Clash = { old: Group; current: Group; name: string; oldPort: number; port: number }
267
268// What listens in a group: each member with a port, by its folder and program.
269const servers = (g: Group) =>
270 g.members
271 .filter(m => m.ports.length > 0 && m.cwd !== '')
272 .map(m => ({ cwd: m.cwd, name: programOf(m.label), port: m.ports[0] ?? 0 }))
273 .filter(s => s.name !== '')
274
275export function clashes(groups: Group[]): Clash[] {
276 // the newest first, so a stale copy is set against the latest
277 const open = groups.filter(g => g.kind === 'running' || g.kind === 'detached').sort((a, b) => b.start - a.start)
278 const out: Clash[] = []
279 for (const old of groups) {
280 if (old.kind !== 'orphan' && old.kind !== 'detached') continue
281 for (const current of open) {
282 if (current.pid === old.pid || current.start <= old.start) continue
283 // An old one whose session is still open may still be that session's: it
284 // is stale only when the same session started a newer copy.
285 if (old.kind === 'detached' && (old.claudePid === '' || current.claudePid !== old.claudePid)) continue
286 const theirs = servers(current)
287 const pair = servers(old)
288 .map(mine => [mine, theirs.find(s => s.cwd === mine.cwd && s.name === mine.name)] as const)
289 .find(([, other]) => other !== undefined)
290 if (!pair?.[1]) continue
291 out.push({ old, current, name: pair[0].name, oldPort: pair[0].port, port: pair[1].port })
292 break
293 }
294 }
295
296 return out
297}
298
299/** What is said beside the old one: `older copy of the vite on :3001`. */
300export const clashNote = (c: Clash) => `older copy of the ${c.name} on :${c.port}`
301
302/** The same in full, for the view of the old one; `port` draws a port, as a link where links work. */
303export const clashFact = (c: Clash, port = (p: number) => `:${p}`) =>
304 `older copy of the ${c.name} on ${port(c.port)}, which an open session started ${age(c.current.seconds)} ago: ending this one frees :${c.oldPort}`
305
306/** The toast a session shows once for each clash: a port names the old one, whatever the list's numbers are. */
307export const clashToast = (c: Clash) =>
308 `⚠️ An older ${c.name} still holds :${c.oldPort} and a newer one runs on :${c.port}: /strays kill :${c.oldPort} ends the old one`
309
310/** How wide each column is, so every row of the list lines up. */
311export function widths(list: Group[], columns: number) {
312 const port = Math.min(14, Math.max(5, ...list.map(g => portsText(g).length)))
313 const folder = Math.min(16, Math.max(4, ...list.map(g => (basename(g.cwd) || '/').length)))
314 // age and memory are short and right-aligned; the command gets what it needs, up to what is left
315 const longest = Math.max(16, ...list.map(g => g.label.length))
316 const name = Math.min(longest, Math.max(16, columns - port - folder - 6 - 8 - 4 * 2 - 10))
317
318 return { port, name, folder }
319}
320
321/** One process as one line: port, what it is, folder, age, memory. */
322export function line(g: Group, w: ReturnType<typeof widths>) {
323 return [
324 fit(portsText(g) || '—', w.port),
325 `${g.icon} ${fit(g.label, w.name)}`,
326 fit(basename(g.cwd) || '/', w.folder),
327 age(g.seconds).padStart(6),
328 megabytes(g.rssKb).padStart(8),
329 ].join(' ')
330}
331
332const CLEAN = '✨ All clean: nothing Claude Code started is still running.'
333
334/** The list as text, each row numbered: the number is what `/strays 2` and `/strays kill 2` name. */
335export function listText(groups: Group[], me: string) {
336 const rows = shown(groups, false)
337 const w = widths(rows, 100)
338 const order = rows.map(g => g.pid)
339 if (rows.length === 0) return { text: CLEAN, order }
340 const digits = String(rows.length).length
341 const found = clashes(groups)
342 const blocks = SECTIONS.filter(s => s.kind !== 'mcp')
343 .map(section => ({ ...section, rows: visible(groups, section.kind) }))
344 .filter(section => section.rows.length > 0)
345 .map(section =>
346 [
347 `${section.icon} ${section.title} (${section.why})`,
348 ...section.rows.flatMap(g => {
349 const n = order.indexOf(g.pid) + 1
350 const clash = found.find(c => c.old.pid === g.pid)
351 const row = ` ${String(n).padStart(digits)} ${line(g, w).trimEnd()}${g.sid === me && g.kind !== 'orphan' ? ' this session' : ''}`
352
353 // under the old copy, what it is and how to end it
354 return clash
355 ? [row, ` ${' '.repeat(digits)} ⚠️ ${clashNote(clash)} · /strays kill ${n} ends it and frees :${clash.oldPort}`]
356 : [row]
357 }),
358 ].join('\n'),
359 )
360 const hasOrphans = rows.some(g => g.kind === 'orphan')
361 const hint = `/strays 1 shows one in full · /strays kill 1 ends it${hasOrphans ? ' · /strays kill all ends the left-behind ones' : ''} · /strays ui opens the panel`
362
363 return { text: [...blocks, hint].join('\n\n'), order }
364}
365
366// Markdown, for where a command's answer is drawn as a reply is, in a font that
367// is not monospace (the editor's panel): columns padded with spaces do not line
368// up there, a table does.
369const cut = (text: string, width: number) => (text.length > width ? text.slice(0, width - 1) + '…' : text)
370// Words that stay on one line when a narrow table folds its cells.
371const together = (text: string) => text.replace(/ /g, ' ')
372// A table cell: a pipe would end it, and markdown's marks would be read as marks.
373const plain = (text: string) => text.replace(/[\\`*_[\]<>|~]/g, '\\$&')
374const code = (text: string) => {
375 const ticks = '`'.repeat(Math.max(0, ...(text.match(/`+/g) ?? []).map(run => run.length)) + 1)
376 const pad = text.startsWith('`') || text.endsWith('`') ? ' ' : ''
377
378 return `${ticks}${pad}${text.replace(/\|/g, '\\|')}${pad}${ticks}`
379}
380
381// A port as a link: a click opens what listens there in the browser.
382const portLink = (port: number) => `[:${port}](http://localhost:${port})`
383// A file as a link the editor opens on a click; a path it would not take stays code.
384const fileLink = (path: string) =>
385 /[:\\]/.test(path) || !path.startsWith('/')
386 ? code(path)
387 : `[${code(path)}](${path.split('/').map(encodeURIComponent).join('/').replace(/\(/g, '%28').replace(/\)/g, '%29')})`
388/** Memory as a bar of eight cells against `biggest`, as the panel draws it, in characters. */
389export const bar = (rssKb: number, biggest: number) => {
390 const filled = Math.min(8, Math.max(1, Math.round((rssKb / biggest) * 8)))
391
392 return '█'.repeat(filled) + '░'.repeat(8 - filled)
393}
394
395/**
396 * The list as markdown: the same rows and numbers as `listText`, in one table,
397 * each group named on its first row, each port a link, memory as a bar too.
398 * The first line is prose, since the transcript puts the plugin's name before
399 * the answer.
400 */
401export function listMarkdown(groups: Group[], me: string) {
402 const rows = shown(groups, false)
403 const order = rows.map(g => g.pid)
404 if (rows.length === 0) return { text: CLEAN, order }
405 const sections = SECTIONS.filter(s => s.kind !== 'mcp')
406 .map(section => ({ ...section, rows: visible(groups, section.kind) }))
407 .filter(section => section.rows.length > 0)
408 const held = rows.filter(g => g.kind === 'orphan' || g.kind === 'detached').reduce((sum, g) => sum + g.rssKb, 0)
409 const counts = sections.map(section => `**${section.rows.length}** ${section.title.toLowerCase()}`)
410 if (held > 0) counts.push(`**${megabytes(held)}** held`)
411 // against the heaviest here, 256 MB at least: the panel's measure
412 const biggest = Math.max(256 * 1024, ...rows.map(g => g.rssKb))
413 const found = clashes(groups)
414 const lines = sections.flatMap(section =>
415 section.rows.map((g, i) => {
416 const ports = g.ports.slice(0, 3).map(portLink)
417 if (g.ports.length > 3) ports.push(`+${g.ports.length - 3}`)
418 const size = together(megabytes(g.rssKb))
419 const cells = [
420 i === 0 ? together(`${section.icon} **${section.title}**`) : '',
421 String(order.indexOf(g.pid) + 1),
422 ports.join(' '),
423 `${g.icon} ${code(cut(g.label, 56))}${g.sid === me && g.kind !== 'orphan' ? ' _this session_' : ''}${found.some(c => c.old.pid === g.pid) ? ' ⚠️ _older copy_' : ''}`,
424 plain(basename(g.cwd) || '/'),
425 together(age(g.seconds)),
426 // what the panel's memory bar draws in yellow or red
427 g.rssKb > 300 * 1024 ? `**${size}**` : size,
428 bar(g.rssKb, biggest),
429 ]
430
431 return `| ${cells.join(' | ')} |`
432 }),
433 )
434 const hasOrphans = rows.some(g => g.kind === 'orphan')
435 const hint = `\`/strays 1\` shows one in full · \`/strays kill 1\` ends it${hasOrphans ? ' · `/strays kill all` ends the left-behind ones' : ''}`
436 // each old copy said in a sentence under the table, where a cell would not hold it
437 const warnings = found
438 .filter(c => order.includes(c.old.pid))
439 .map(c => {
440 const n = order.indexOf(c.old.pid) + 1
441
442 return `⚠️ #${n} is an older copy of the ${plain(c.name)} on ${portLink(c.port)}: \`/strays kill ${n}\` ends it and frees :${c.oldPort}.`
443 })
444
445 return {
446 text: [
447 counts.join(' · '),
448 '',
449 '| | # | Port | Command | Folder | Running | Memory | |',
450 '| :- | -: | :- | :- | :- | -: | -: | :- |',
451 ...lines,
452 '',
453 ...warnings.flatMap(warning => [warning, '']),
454 hint,
455 ].join('\n'),
456 order,
457 }
458}
459
460/** What bin/info.sh printed: one value a key, and every listening address. */
461export type Info = { fields: Record<string, string>; listen: string[] }
462
463export function parseInfo(stdout: string): Info {
464 const info: Info = { fields: {}, listen: [] }
465 for (const row of stdout.split('\n')) {
466 const tab = row.indexOf('\t')
467 if (tab < 0) continue
468 const key = row.slice(0, tab)
469 const value = row.slice(tab + 1).trim()
470 if (key === 'listen') info.listen.push(value)
471 else info.fields[key] = value
472 }
473
474 return info
475}
476
477/**
478 * Everything known about one group, as label and value pairs in the order they
479 * are shown; `clash`, when it is an older copy of a server an open session runs.
480 */
481export function facts(g: Group, info: Info | null, clash?: Clash): [string, string][] {
482 const f = info?.fields ?? {}
483 const session = f.session_alive
484 ? `${f.session_name || 'open'} · ${f.session_kind === 'claude-vscode' ? 'VS Code' : f.session_kind === 'cli' ? 'terminal' : f.session_kind || 'Claude Code'} · ${f.session_status || 'open'}`
485 : `closed${g.sid ? ` · ${g.sid.slice(0, 8)}` : ''}`
486 const project = f.session_cwd || f.transcript_cwd || ''
487 const rows: [string, string][] = [
488 ['State', STATE[g.kind]],
489 ['Clash', clash ? clashFact(clash) : ''],
490 ['Ports', g.ports.length > 0 ? `${portsText(g)} → http://localhost:${g.ports[0]}` : ''],
491 ['Folder', f.cwd || g.cwd],
492 ['Command', f.command || g.command],
493 ['Program', f.program ?? ''],
494 ['Started', f.started ? `${f.started} (${age(g.seconds)} ago)` : `${age(g.seconds)} ago`],
495 ['Memory', `${megabytes(g.rssKb)}${g.members.length > 1 ? ` in ${g.members.length} processes` : ''} · CPU ${g.cpu}%`],
496 ['Processes', g.members.map(m => `${m.pid} ${m.label}`).reverse().join('\n')],
497 ['Session', session],
498 ['Project', project],
499 ['Transcript', f.transcript ?? ''],
500 ['Found by', FOUND[g.how]],
501 ]
502
503 return rows.filter(([, value]) => value !== '')
504}
505
506// What the last line of one in full says: how to end it, and what that frees.
507const ends = (clash?: Clash) => `ends it${clash ? ` and frees :${clash.oldPort}` : ''}`
508
509/** One group in full, as text. */
510export function detailText(g: Group, info: Info | null, index: number, clash?: Clash) {
511 const pad = 11
512 const body = facts(g, info, clash).map(([label, value]) => {
513 const [first, ...rest] = value.split('\n')
514
515 return [` ${label.padEnd(pad)}${first}`, ...rest.map(more => ` ${' '.repeat(pad)}${more}`)].join('\n')
516 })
517 const name = index > 0 ? `#${index} ` : ''
518
519 const n = index > 0 ? index : g.pid
520
521 return [`${name}${g.icon} ${g.label}`, ...body, '', `/strays kill ${n} ${ends(clash)} · /strays copy ${n} copies its command`].join('\n')
522}
523
524// The values that are paths or commands: drawn as code, so nothing in them is read as markdown.
525const LITERAL = ['Folder', 'Command', 'Program', 'Project']
526
527/**
528 * One group in full, as markdown: its state said first, then a table headed by
529 * its number and name. Each port is a link, and so is the transcript of the
530 * session that started it.
531 */
532export function detailMarkdown(g: Group, info: Info | null, index: number, clash?: Clash) {
533 const state = STATE[g.kind]
534 const icon = SECTIONS.find(section => section.kind === g.kind)?.icon ?? ''
535 const cell = (label: string, value: string) => {
536 if (LITERAL.includes(label)) return code(value)
537 if (label === 'Transcript') return fileLink(value)
538 if (label === 'Clash' && clash) return `⚠️ ${clashFact({ ...clash, name: plain(clash.name) }, portLink)}`
539 if (label === 'Ports') return g.ports.map(portLink).join(' ')
540 if (label !== 'Processes') return plain(value)
541
542 return value
543 .split('\n')
544 .map(row => `${row.slice(0, row.indexOf(' '))} ${code(row.slice(row.indexOf(' ') + 1))}`)
545 .join(' · ')
546 }
547 const rows = facts(g, info, clash)
548 .filter(([label]) => label !== 'State')
549 .map(([label, value]) => `| ${label} | ${cell(label, value)} |`)
550 const n = index > 0 ? index : g.pid
551
552 return [
553 `${icon} ${state.slice(0, 1).toUpperCase()}${state.slice(1)}`,
554 '',
555 `| ${index > 0 ? `#${index}` : ''} | ${g.icon} ${code(g.label)} |`,
556 '| :- | :- |',
557 ...rows,
558 '',
559 `\`/strays kill ${n}\` ${ends(clash)} · \`/strays copy ${n}\` copies its command`,
560 ].join('\n')
561}
562
563/** What a word after /strays asks for; `auto` is the bare command, which each app answers its own way. */
564export type Ask = { verb: 'auto' | 'list' | 'ui' | 'info' | 'kill' | 'copy' | 'help'; target: string }
565
566const VERBS: Record<string, Ask['verb']> = {
567 list: 'list', ls: 'list', l: 'list',
568 ui: 'ui', u: 'ui', panel: 'ui', pane: 'ui', p: 'ui', open: 'ui', o: 'ui',
569 info: 'info', i: 'info', show: 'info', s: 'info', detail: 'info', details: 'info', d: 'info',
570 kill: 'kill', k: 'kill', end: 'kill', e: 'kill', stop: 'kill', x: 'kill',
571 copy: 'copy', c: 'copy', cp: 'copy',
572 help: 'help', h: 'help', '?': 'help',
573}
574
575export function parseArgs(args: string): Ask {
576 const [first = '', ...rest] = args.trim().split(/\s+/)
577 if (first === '') return { verb: 'auto', target: '' }
578 const verb = VERBS[first.toLowerCase()]
579 if (verb) return { verb, target: rest.join(' ') }
580 // A bare number, pid or :port asks for that one in full.
581 if (/^[:#]?\d+$/.test(first)) return { verb: 'info', target: first }
582
583 return { verb: 'help', target: first }
584}
585
586/** The group a word names: `2` (its number in the last list, else a pid), `#2`, or `:3000` (a port). */
587export function resolve(target: string, groups: Group[], order: number[]): Group | undefined {
588 const word = target.trim()
589 if (/^:\d+$/.test(word)) return groups.find(g => g.ports.includes(Number(word.slice(1))))
590 const n = Number(word.replace(/^#/, ''))
591 if (!Number.isInteger(n) || n <= 0) return undefined
592 const listed = order[n - 1]
593 const byNumber = listed === undefined ? undefined : groups.find(g => g.pid === listed)
594
595 return byNumber ?? groups.find(g => g.pid === n || g.members.some(m => m.pid === n))
596}
597
598// What to type, what it does, and the other words for it.
599const USAGE: [string, string, string][] = [
600 ['/strays', 'the panel in the terminal and the desktop app, the list everywhere else', ''],
601 ['/strays ls', 'the list, each row numbered', 'l, list'],
602 ['/strays 2', 'row 2 in full: folder, command, start time, memory, session', 'i, info, show'],
603 ['/strays :3000', 'the same, for whatever listens on port 3000', ''],
604 ['/strays kill 2', 'end row 2 and what it started', 'k, end, stop; a pid or :port works too'],
605 ['/strays kill all', 'end every left-behind one', ''],
606 ['/strays copy 2', "copy row 2's command to the clipboard", 'c, cp'],
607 ['/strays ui', 'the panel: click a row for details, click End to end it', 'u, open'],
608]
609
610export const HELP = [
611 'strays: processes Claude Code started and left running, in any session',
612 '',
613 ...USAGE.map(([type, does, also]) => ` ${type.padEnd(21)}${does}${also ? ` (also: ${also})` : ''}`),
614 '',
615 ' /ps is the same command, shorter.',
616].join('\n')
617
618export const HELP_MARKDOWN = [
619 'Processes Claude Code started and left running, in any session.',
620 '',
621 '| Type | To get |',
622 '| :- | :- |',
623 ...USAGE.map(([type, does, also]) => `| ${code(type)} | ${plain(does)}${also ? ` (also: ${plain(also)})` : ''} |`),
624 '',
625 '`/ps` is the same command, shorter.',
626].join('\n')
627