SLOPSHOPPER

strays

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

panerowscommandtoaststatusprocess
v0.1.1MITupdated 2026-10-18huynamboz/strays
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · strays
│ ┃ Strays ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────╮ ╭─────────────────╮ ╭─── │ ┃ │ 👻 0 │ │ 🌙 0 │ │ 💾 ⏺ Read(src/auth.ts) │ ┃ │ left behind │ │ in background │ │ me ⎿ Read 6 lines │ ┃ ╰─────────────────╯ ╰─────────────────╯ ╰─── ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ ✨ All clean │ ⎿ 3 pass, 1 fail │ ┃ │ Nothing Claude Code started is still │ │ ┃ │ running. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [ Refresh ] [ MCP ] [ Close ] │ ┃ › /strays │ ┃ click a row for details · click End to end │ ┃ it · ↑↓ ⏎ work too │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Strays
╭─────────────────╮ ╭─────────────────╮ ╭─────────────────╮ │ 👻 0 │ │ 🌙 0 │ │ 💾 1 MB │ │ left behind │ │ in background │ │ memory held │ ╰─────────────────╯ ╰─────────────────╯ ╰─────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ✨ All clean │ │ Nothing Claude Code started is still running. │ ╰──────────────────────────────────────────────────────────╯ [ Refresh ] [ MCP ] [ Close ] click a row for details · click End to end it · ↑↓ ⏎ work too
README

strays

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.

A Claude Code session opens and strays says three processes from earlier sessions are still running. /strays ls lists them with their ports and memory; /strays ui opens the panel, a click on the vite row shows it in full, and End takes it off the list.

❯ /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

Install

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

Use

| 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.

The panel

/strays ui opens a panel beside the transcript (or above the prompt in a narrower window). It is made to be clicked:

  • click a row to see that process in full, with buttons to end it, open its folder, open its port in the browser and copy its command
  • click End on a row to end it: the button asks once (Yes, end it), and takes the question back after four seconds
  • End all left behind ends every process whose session is closed

The 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.

How it finds them

bin/scan.sh reads the process table and keeps a process when one of five things ties it to Claude Code:

  1. Claude Code's mark. Claude Code puts 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.
  2. The Bash tool's shell. Every command the Bash tool runs is wrapped in a shell whose command line names ~/.claude/shell-snapshots.
  3. Claude Code's own search. Claude Code carries its own 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.
  4. The process tree. Whatever hangs under a running session, or under such a shell. macOS shows no environment for its own programs (sh, tail, nc, ssh), so their place in the tree stands in.
  5. The process group. One Bash call starts one process group, and a 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.

What it does not see

  • A system program cut loose alone. A 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 containers: the docker command exits and the container runs under the daemon.
  • Services handed to launchd, and programs that clear their environment on start.
  • Other users' processes.
  • Linux and Windows: not yet. Linux needs /proc/<pid>/environ in place of ps -E.

Privacy

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.

Develop

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.

License

MIT

Source 2 files
hooks/register.tsx 922 lines
1// 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}
922
hooks/procs.ts 627 lines
1// 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