SLOPSHOPPER

devservers

/devservers: every dev server on your Mac, what started it, its memory and last use, and a way to stop the ones you forgot

panecommandtoastprocess
v0.2.0MITupdated 2026-10-19MicoBlanc/devservers
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · devservers
│ ┃ devservers ✕ › fix the failing auth test and add an audit log call │ ┃ Run /devservers to look. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /devservers │ ⎿ devservers: devservers reads the macOS versions of ps, lsof and │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · devservers
Run /devservers to look.
README

devservers

Every dev server running on your Mac, what it holds in memory and when you last used it, and a way to stop the ones you forgot, without leaving Claude Code.

devservers in a Claude Code sidebar: two orphaned servers stopped, 6.2 GB back

Why

Claude Code starts pnpm dev in the background all the time, and when a session ends its dev server keeps running. A Next.js dev server grows to 2 to 6 GB over a few days, and macOS charges all of it to the terminal it came from, so iTerm or Terminal looks like it is eating your memory. On the Mac this was built on, that was 22 dev servers holding 77 GB, 15 of them left behind by sessions that had ended.

Install

At the prompt of a Claude Code terminal session:

/plugin install devservers --marketplace MicoBlanc/devservers

Answer y to add the marketplace, then press Enter for the user scope, which loads it in every session from then on.

From a shell:

claude plugin install devservers --marketplace MicoBlanc/devservers

In the Claude desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with MicoBlanc/devservers, then Add plugin.

Use

Type /devservers. The pane opens at once, your servers land a moment later, and their memory fills in as macOS measures it.

  • The bar at the top is all the memory your dev servers hold. The yellow part is orphaned: servers whose Claude session or terminal is gone, so nothing will ever stop them. Free stops the ones nobody has open, after one more press.
  • The rows are grouped by what started them: orphaned, a Claude session that is still open, a terminal (iTerm, Terminal, Ghostty, VS Code, tmux...) or an app. Each shows its port, folder, memory and when it was last used.
  • Enter on a row shows when and how it was started, with Stop and free, Open in browser, and Copy start command, which puts the exact line to start it again on your clipboard, like cd ~/code/shop && pnpm dev.
  • Stopping is something you watch. Every server gets the signal at the same moment, each bar drains as its process exits, and the header counts what came back.
  • The last line keeps the total devservers has freed on this Mac.

Keys while the pane has focus: Tab moves between rows and buttons, Enter opens a row or presses a button, o frees the orphans, r refreshes, Esc closes.

From the prompt:

CommandDoes
/devserversOpens the pane
/devservers listPrints the same table as text
/devservers stop 3000Stops the server on port 3000, and says how to start it again
/devservers stop orphansStops every orphan nobody has open

How it decides

  • What counts as a dev server: a process of yours listening on a TCP port that a known framework started (Next.js, Vite and what runs on it, Astro, Nuxt, Storybook, Angular, webpack, Expo, Gatsby, Docusaurus, Remix), or that a pnpm, npm, yarn or bun dev, start or serve script started. Databases, Docker and desktop apps are left out.
  • Orphaned: nothing above the server is left but macOS itself. When the shell Claude Code ran it in is still there, the row says a Claude session started it.
  • Last used: Next.js 16 and later keep a dev log. devservers reads the last page view, browser message or server log in it, and skips Turbopack's own cache writes, which happen with nobody around. A server with a browser tab or another client connected shows in use now. Where a framework keeps no log, the row says how long the server has been up.
  • Memory: the figure Activity Monitor shows, compressed and swapped memory included, added up over every process that exists only to run the server.
  • Start command: the package script that started it (pnpm dev -p 3001), else the framework's own command run through npx, in the folder it ran in.

What a stop does

  • It sends SIGTERM to the server and to the processes that exist only to run it (pnpm, next dev, the shell that launched them), all at once, waits up to 8 seconds, then sends SIGKILL to whatever is left.
  • In a monorepo, stopping one app of a turbo dev leaves the other apps running.
  • It refuses when the server already exited and its process ID now belongs to something else.

Privacy

Everything runs on your Mac with ps, lsof, top, git and tail. No network calls, no telemetry. Nothing runs in the background: devservers only looks when you run /devservers, and only stops what you press.

Requirements

  • macOS. Linux and Windows are next.
  • Claude Code with mods; tested with 2.1.303.

The recording

The recording above is a made-up Mac. demo/ is a second mod that answers devservers' process, HOME and store calls with made-up projects, so no real folder shows and no real total changes. Run claude --plugin-dir demo beside an installed devservers to try the pane without touching anything.

License

MIT

Source 6 files
hooks/register.tsx 47 lines
1import type { Register } from 'claude-code'
2
3import { PANE, changeView, prepare, refresh, stopAll, viewOf } from './actions'
4import { idleOrphans, restartLine, summary, table } from './format'
5import { drawPane } from './pane'
6import { isSupported } from './scan'
7
8const UNSUPPORTED = 'devservers reads the macOS versions of ps, lsof and top, so it runs on macOS for now.'
9
10export const register: Register = on => {
11  on('session.start', async ($, e, next) => {
12    await $.command.register({
13      name: 'devservers',
14      description: 'The dev servers on this Mac: who started them, memory, last use, and Stop',
15      argumentHint: '[list | stop <port> | stop orphans]',
16    })
17    return next(e)
18  })
19
20  on('command.run', { command: 'devservers' }, async ($, e) => {
21    if (!(await isSupported($))) return { text: UNSUPPORTED }
22    await prepare($)
23    const [verb = '', target = ''] = e.args.trim().split(/\s+/)
24    if (verb === 'list') return { text: table(await refresh($), await $.clock.now()) }
25    if (verb === 'stop') {
26      const servers = await refresh($)
27      const wanted = Number(target)
28      const targets = target === 'orphans' ? idleOrphans(servers) : servers.filter(s => s.ports.includes(wanted) || s.pid === wanted)
29      if (!targets.length) return { text: target === 'orphans' ? 'No idle orphans to stop.' : `No dev server on ${target || '(no port given)'}.` }
30      const { text, restart } = await stopAll($, targets, 'command')
31      return { text: restart ? `${text} To start it again: ${restartLine(restart, (await viewOf($)).home)}` : text }
32    }
33    // The pane opens before anything is read: its rows land a moment later, and memory after them.
34    await changeView($, v => ({ ...v, confirm: null, note: null, openId: null, freedHere: 0 }))
35    await $.ui.open({ id: PANE, title: 'Dev servers', focus: true, closeOnEscape: true })
36    return { text: summary(await refresh($)) }
37  })
38
39  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => drawPane($, e))
40
41  on('ui.close', { id: PANE }, async ($, e, next) => {
42    // Clearing what was half done is housekeeping: the pane closes whatever happens to it.
43    await changeView($, v => ({ ...v, confirm: null, note: null, openId: null })).catch(() => undefined)
44    return next(e)
45  })
46}
47
hooks/actions.ts 150 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderSurface } from 'claude-code'
3
4import type { DevServer, Freeing, Lifetime, View } from '../types'
5import { count, port, restartLine, size, total } from './format'
6import { measure, scan, stopServers } from './scan'
7
8export const PANE = 'devservers'
9const LIFETIME = 'freed'
10// How long stopped rows stay drained on screen before they fold away.
11const HOLD_MS = 1200
12const COPIED_MS = 2000
13
14const view = atom({ plugin: 'devservers', key: 'view' } as const, {
15  servers: null,
16  isScanning: false,
17  isMeasuring: false,
18  openId: null,
19  confirm: null,
20  freeing: null,
21  freedHere: 0,
22  copiedId: null,
23  note: null,
24  lifetime: null,
25  home: '',
26})
27
28// The pane and the commands reach the view through these two, so its reads and writes stay in this file.
29export function viewOf($: EngineInterface) {
30  return read($, view)
31}
32
33export function changeView($: EngineInterface, change: (v: View) => View) {
34  return update($, view, change)
35}
36
37const isLifetime = (value: unknown): value is Lifetime =>
38  typeof value === 'object' && value !== null && typeof (value as Lifetime).bytes === 'number' && typeof (value as Lifetime).since === 'number'
39
40/** Reads what the view needs from outside the session: $HOME, and the total freed on this Mac. */
41export async function prepare($: EngineInterface) {
42  const [home, stored] = await Promise.all([$.env.get('HOME'), $.store.get(LIFETIME)])
43  await update($, view, v => ({ ...v, home: home ?? '', lifetime: isLifetime(stored) ? stored : null }))
44}
45
46/**
47 * Reads the servers again without emptying the screen: rows land in a fraction of a second with
48 * the memory figures they had, and top's new figures replace them in place a few seconds later.
49 * Without `remeasure`, top runs only for servers that have no figure yet.
50 */
51export async function refresh($: EngineInterface, { remeasure = true } = {}): Promise<DevServer[]> {
52  await update($, view, v => ({ ...v, isScanning: true }))
53  const known = new Map(((await read($, view)).servers ?? []).map(s => [s.id, s.memoryBytes]))
54  const fresh = (await scan($)).map(s => ({ ...s, memoryBytes: known.get(s.id) ?? null }))
55  const needsTop = fresh.some(s => remeasure || s.memoryBytes === null)
56  await update($, view, v => ({ ...v, servers: fresh, isScanning: false, isMeasuring: needsTop }))
57  if (!needsTop) return fresh
58  const memory = await measure($, fresh)
59  await update($, view, v => ({ ...v, isMeasuring: false, servers: v.servers?.map(s => ({ ...s, memoryBytes: memory.get(s.id) ?? s.memoryBytes })) ?? null }))
60  return fresh.map(s => ({ ...s, memoryBytes: memory.get(s.id) ?? s.memoryBytes }))
61}
62
63// Takes one stop's servers out of the progress the header shows; another stop may still be running.
64const settle = (f: Freeing | null, ids: readonly string[], targetBytes: number, freedBytes: number): Freeing | null => {
65  const rest = f?.ids.filter(id => !ids.includes(id)) ?? []
66  if (!f || !rest.length) return null
67  const keep = (list: string[]) => list.filter(id => !ids.includes(id))
68  return { ids: rest, exited: keep(f.exited), refused: keep(f.refused), targetBytes: f.targetBytes - targetBytes, freedBytes: f.freedBytes - freedBytes }
69}
70
71/**
72 * Stops `targets` together and follows them out: each row drains as its processes exit and the
73 * header counts what came back. The drained rows hold for a moment, then fold away. Resolves the
74 * note that says what happened, and the server to start again when it was one. A stop pressed in
75 * the pane says so in a toast too when the pane was closed before it finished.
76 */
77export async function stopAll($: EngineInterface, targets: readonly DevServer[], from: 'pane' | 'command'): Promise<{ text: string; restart: DevServer | null }> {
78  const ids = targets.map(s => s.id)
79  await update($, view, v => {
80    const f = v.freeing ?? { ids: [], exited: [], refused: [], targetBytes: 0, freedBytes: 0 }
81    return { ...v, confirm: null, note: null, freeing: { ...f, ids: [...f.ids, ...ids], targetBytes: f.targetBytes + total(targets) } }
82  })
83  const stopped: DevServer[] = []
84  let reason = ''
85  for await (const outcome of stopServers($, targets)) {
86    const server = targets.find(s => s.id === outcome.id)
87    if (!server) continue
88    if (outcome.isStopped) stopped.push(server)
89    else reason = outcome.reason
90    await update($, view, v => {
91      if (!v.freeing) return v
92      return outcome.isStopped
93        ? { ...v, freeing: { ...v.freeing, exited: [...v.freeing.exited, server.id], freedBytes: v.freeing.freedBytes + (server.memoryBytes ?? 0) } }
94        : { ...v, freeing: { ...v.freeing, refused: [...v.freeing.refused, server.id] } }
95    })
96  }
97
98  const freed = total(stopped)
99  if (freed > 0) {
100    const now = await $.clock.now()
101    const before = (await read($, view)).lifetime
102    const lifetime = { bytes: (before?.bytes ?? 0) + freed, since: before?.since ?? now }
103    await $.store.set(LIFETIME, lifetime)
104    await update($, view, v => ({ ...v, lifetime }))
105  }
106  const one = targets.length === 1 ? targets[0] : undefined
107  const gained = freed > 0 ? ` and freed ${size(freed)}` : ''
108  const text = one
109    ? stopped.length
110      ? `Stopped ${one.folder} (${port(one)})${gained}.`
111      : `Didn't stop ${one.folder}: ${reason}.`
112    : `Stopped ${count(stopped.length, 'server', 'servers')}${gained}.${stopped.length < targets.length ? ` ${targets.length - stopped.length} had already stopped.` : ''}`
113  const note = { text, restart: one && stopped.length ? one : null }
114
115  await $.clock.sleep(HOLD_MS)
116  const gone = stopped.map(s => s.id)
117  await update($, view, v => ({
118    ...v,
119    note,
120    openId: gone.includes(v.openId ?? '') ? null : v.openId,
121    servers: v.servers?.filter(s => !gone.includes(s.id)) ?? null,
122    freeing: settle(v.freeing, ids, total(targets), freed),
123    freedHere: v.freedHere + freed,
124  }))
125  if (from === 'pane' && !(await $.ui.panes()).some(pane => pane.id === PANE)) $.ui.toast(text, { timeoutMs: 6000 })
126  await refresh($, { remeasure: false })
127  return note
128}
129
130/** Puts the line that starts `server` again on the clipboard, and says Copied for a moment. */
131export async function copyStart($: EngineInterface, server: DevServer, surface: RenderSurface) {
132  const { home } = await read($, view)
133  const copied = await $.ui.copy({ text: restartLine(server, home), surface })
134  if (!copied.isCopied) {
135    await update($, view, v => ({ ...v, note: { text: `Couldn't copy here. To start it again: ${restartLine(server, home)}`, restart: null } }))
136    return
137  }
138  await update($, view, v => ({ ...v, copiedId: server.id }))
139  await $.clock.sleep(COPIED_MS)
140  await update($, view, v => (v.copiedId === server.id ? { ...v, copiedId: null } : v))
141}
142
143/** Opens the server in the browser; the row then reads "in use now". */
144export async function openInBrowser($: EngineInterface, server: DevServer) {
145  if (!server.ports[0]) return
146  await $.process.run(['/usr/bin/open', `http://localhost:${server.ports[0]}`])
147  await $.clock.sleep(1500)
148  await refresh($, { remeasure: false })
149}
150
hooks/format.ts 84 lines
1import type { DevServer, Lifetime, Owner } from '../types'
2
3const GB = 1024 ** 3
4const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
5const RANK: Record<Owner['kind'], number> = { orphan: 0, claude: 1, terminal: 2, app: 3 }
6
7export const size = (bytes: number) =>
8  bytes <= 0 ? '0 GB' : bytes >= GB ? `${(bytes / GB).toFixed(1)} GB` : `${Math.max(1, Math.round(bytes / 1024 ** 2))} MB`
9export const total = (servers: readonly DevServer[]) => servers.reduce((sum, s) => sum + (s.memoryBytes ?? 0), 0)
10export const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
11export const port = (s: DevServer) => (s.ports[0] ? `:${s.ports[0]}` : 'no port')
12// What Free stops: an orphan someone has open in a browser stays, a press on its own row stops it.
13export const isIdleOrphan = (s: DevServer) => s.owner.kind === 'orphan' && !s.clients
14export const idleOrphans = (servers: readonly DevServer[]) => servers.filter(isIdleOrphan)
15
16export function span(ms: number) {
17  const s = ms / 1000
18  if (s < 5400) return `${Math.max(1, Math.round(s / 60))} min`
19  if (s < 172800) return `${Math.round(s / 3600)} h`
20  return `${Math.round(s / 86400)} days`
21}
22
23/** The last-used column: in use now, how long ago its dev log saw someone, or how long it has been up. */
24export function usage(s: DevServer, now: number): { text: string; tone: 'strong' | 'plain' | 'quiet' } {
25  const used = s.lastUsed
26  if (used.kind === 'now') return { text: 'in use now', tone: 'strong' }
27  if (used.kind === 'at') return { text: now - used.at < 90_000 ? 'just now' : `${span(now - used.at)} ago`, tone: 'plain' }
28  if (used.kind === 'never') return { text: 'not opened', tone: 'quiet' }
29  return { text: `up ${span(now - s.startedAt)}`, tone: 'quiet' }
30}
31
32export function ownerTitle(o: Owner, now: number) {
33  if (o.kind === 'orphan') return 'Orphaned · what started them is gone'
34  if (o.kind === 'claude') return `Claude session in ${o.where} · open ${span(now - o.since)}`
35  return o.kind === 'terminal' ? `Started in ${o.name}` : `Started by ${o.name}`
36}
37
38export function startedLine(s: DevServer, now: number) {
39  const by = s.owner.kind === 'orphan' && s.owner.startedBy === 'claude' ? ' by a Claude session that has since ended' : ''
40  return `Started ${span(now - s.startedAt)} ago${by}`
41}
42
43/** The line Copy start command puts on the clipboard: `cd ~/code/shop && pnpm dev`. */
44export function restartLine(s: DevServer, home: string) {
45  const shown = home && s.start.cwd.startsWith(`${home}/`) ? `~${s.start.cwd.slice(home.length)}` : s.start.cwd
46  const where = /^[\w@%+=:,./~-]+$/.test(shown) ? shown : `'${s.start.cwd.replace(/'/g, `'\\''`)}'`
47  return `cd ${where} && ${s.start.command}`
48}
49
50export function lifetimeLine(lifetime: Lifetime, now: number) {
51  const since = new Date(lifetime.since)
52  const today = new Date(now)
53  const when = since.toDateString() === today.toDateString() ? 'today' : `since ${MONTHS[since.getMonth()]} ${since.getDate()}`
54  return `${size(lifetime.bytes)} freed ${when}`
55}
56
57export function summary(servers: readonly DevServer[]) {
58  if (!servers.length) return 'No dev servers are running.'
59  const orphans = idleOrphans(servers)
60  return `${count(servers.length, 'dev server holds', 'dev servers hold')} ${size(total(servers))}. ${count(orphans.length, 'orphan holds', 'orphans hold')} ${size(total(orphans))}.`
61}
62
63/** The servers by what started them: orphans first, then Claude sessions oldest first, then terminals and apps. */
64export function groupsOf(servers: readonly DevServer[]): [string, DevServer[]][] {
65  const groups = new Map<string, DevServer[]>()
66  for (const s of servers) {
67    const key = s.owner.kind === 'orphan' ? 'orphan' : `${s.owner.kind}:${s.owner.pid}`
68    groups.set(key, [...(groups.get(key) ?? []), s])
69  }
70  const since = (o: Owner | undefined) => (o?.kind === 'claude' ? o.since : 0)
71  return [...groups.entries()].sort(([keyA, [a]], [keyB, [b]]) => {
72    const kinds = RANK[a?.owner.kind ?? 'app'] - RANK[b?.owner.kind ?? 'app']
73    return kinds || since(a?.owner) - since(b?.owner) || keyA.localeCompare(keyB)
74  })
75}
76
77export function table(servers: readonly DevServer[], now: number) {
78  const rows = groupsOf(servers).flatMap(([, group]) =>
79    group.map(s => [port(s), s.folder, s.framework, s.memoryBytes === null ? '?' : size(s.memoryBytes), usage(s, now).text, s.owner.kind === 'orphan' ? 'orphaned' : ownerTitle(s.owner, now)]),
80  )
81  const widths = rows.reduce((w, row) => row.map((cell, i) => Math.max(w[i] ?? 0, cell.length)), [] as number[])
82  return [summary(servers), '', ...rows.map(row => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i] ?? 0))).join('  '))].join('\n')
83}
84
hooks/pane.tsx 312 lines
1import type { EngineInterface, RenderInput } from 'claude-code'
2
3import type { DevServer } from '../types'
4import { PANE, changeView, copyStart, openInBrowser, refresh, stopAll, viewOf } from './actions'
5import { count, groupsOf, idleOrphans, isIdleOrphan, lifetimeLine, ownerTitle, port, restartLine, size, startedLine, total, usage } from './format'
6
7const PORT = 6
8const SIZE = 7
9// The pane is often a sidebar beside the conversation, 45 to 60 columns: that is the layout drawn
10// first. With room, the columns breathe, the bars lengthen and the framework gets a column.
11const ROOMY = 70
12const FRAMEWORK_FROM = 76
13const FRAMEWORK = 15
14// The desktop and the phone draw bars as pictures, in colors that read on light and dark alike.
15const SVG_INK = 'rgba(128,128,128,0.9)'
16const SVG_YELLOW = '#E3A008'
17const SVG_TRACK = 'rgba(128,128,128,0.22)'
18
19type Part = { bytes: number; tone: 'ink' | 'yellow' | 'track' }
20type Layout = { gap: number; bar: number; used: number; framework: number }
21
22// Narrow, the last-used column keeps a second space before it so `4.2 GB  41 h ago` never runs together.
23const layoutFor = (columns: number): Layout =>
24  columns >= ROOMY ? { gap: 2, bar: 10, used: 12, framework: columns >= FRAMEWORK_FROM ? FRAMEWORK : 0 } : { gap: 1, bar: 5, used: 10, framework: 0 }
25
26function svgBar(parts: readonly Part[], height: number) {
27  const scale = parts.reduce((sum, p) => sum + p.bytes, 0) || 1
28  let x = 0
29  const shapes = parts.map(p => {
30    const width = (p.bytes / scale) * 1000
31    const shape = p.tone === 'track' ? '' : `<rect x="${x.toFixed(1)}" width="${width.toFixed(1)}" height="${height}" fill="${p.tone === 'yellow' ? SVG_YELLOW : SVG_INK}"/>`
32    x += width
33    return shape
34  })
35  return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${height}" viewBox="0 0 1000 ${height}" preserveAspectRatio="none"><rect width="1000" height="${height}" fill="${SVG_TRACK}"/>${shapes.join('')}</svg>`
36}
37
38/**
39 * One bar of `parts` across `width` cells: heavy line where memory is held (yellow where it is
40 * orphaned), light dim line where it is not, or was and has been freed.
41 */
42function meter($: EngineInterface, e: RenderInput<'Pane'>, parts: readonly Part[], width: number, alt: string) {
43  if (e.surface !== 'terminal') {
44    const { Svg } = $.ui.resolve(e)
45    return <Svg source={svgBar(parts, 8)} alt={alt} height={8} />
46  }
47  const { Text } = $.ui.resolve(e)
48  const scale = parts.reduce((sum, p) => sum + p.bytes, 0)
49  let drawn = 0
50  let held = 0
51  const runs = parts.map(p => {
52    held += p.bytes
53    const end = scale ? Math.round((held / scale) * width) : 0
54    const cells = end - drawn
55    drawn = end
56    return { cells, tone: p.tone }
57  })
58  return (
59    <Text wrap="truncate">
60      {runs.map(run =>
61        run.cells > 0 ? (
62          <Text color={run.tone === 'yellow' ? 'warning' : undefined} dimColor={run.tone === 'track'}>
63            {(run.tone === 'track' ? '─' : '━').repeat(run.cells)}
64          </Text>
65        ) : null,
66      )}
67      {drawn < width ? <Text dimColor>{'─'.repeat(width - drawn)}</Text> : null}
68    </Text>
69  )
70}
71
72/** A row's bar: its share of the largest server's memory, to the half cell. */
73function rowMeter($: EngineInterface, e: RenderInput<'Pane'>, s: DevServer, largest: number, width: number, tone: 'ink' | 'yellow' | 'gone') {
74  const bytes = tone === 'gone' ? 0 : (s.memoryBytes ?? 0)
75  if (e.surface !== 'terminal') {
76    const { Svg } = $.ui.resolve(e)
77    const parts: Part[] = [{ bytes, tone: tone === 'yellow' ? 'yellow' : 'ink' }, { bytes: largest - bytes, tone: 'track' }]
78    return <Svg source={svgBar(parts, 6)} alt={s.memoryBytes === null ? 'memory not measured yet' : size(s.memoryBytes)} height={6} />
79  }
80  const { Text } = $.ui.resolve(e)
81  const halves = Math.round(Math.min(1, bytes / largest) * width * 2)
82  const fill = '━'.repeat(Math.floor(halves / 2)) + (halves % 2 ? '╸' : '')
83  return (
84    <Text wrap="truncate">
85      <Text color={tone === 'yellow' ? 'warning' : undefined}>{fill}</Text>
86      <Text dimColor>{'─'.repeat(width - fill.length)}</Text>
87    </Text>
88  )
89}
90
91export async function drawPane($: EngineInterface, e: RenderInput<'Pane'>) {
92  const v = await viewOf($)
93  const now = await $.clock.now()
94  const { Box, Text, Button } = $.ui.resolve(e)
95  const width = Math.max(40, e.props.bodyColumns)
96  const L = layoutFor(width)
97
98  if (!v.servers) return <Text dimColor>{v.isScanning ? 'Looking for dev servers…' : 'Run /devservers to look.'}</Text>
99
100  const servers = v.servers
101  const f = v.freeing
102  const exited = new Set(f?.exited ?? [])
103  const pending = f ? f.ids.filter(id => !exited.has(id) && !f.refused.includes(id)) : []
104  const live = servers.filter(s => !exited.has(s.id))
105  const orphans = idleOrphans(live).filter(s => !pending.includes(s.id))
106  const isMeasured = servers.every(s => s.memoryBytes !== null)
107  const largest = Math.max(1, ...servers.map(s => s.memoryBytes ?? 0))
108  const orphanBytes = total(idleOrphans(live))
109  // Orphans first, so freeing them slides the rest left and leaves empty track where they were.
110  const parts: Part[] = isMeasured
111    ? [
112        { bytes: orphanBytes, tone: 'yellow' },
113        { bytes: total(live.filter(s => !isIdleOrphan(s))), tone: 'ink' },
114        { bytes: (f?.freedBytes ?? 0) + v.freedHere, tone: 'track' },
115      ]
116    : [{ bytes: 1, tone: 'track' }]
117
118  const title = f && pending.length
119    ? `Freed ${size(f.freedBytes)} of ${size(f.targetBytes)}`
120    : !servers.length
121      ? 'No dev servers are running'
122      : isMeasured
123        ? `${size(total(live))} in ${count(live.length, 'dev server', 'dev servers')}`
124        : `${count(live.length, 'dev server', 'dev servers')} · measuring memory`
125
126  const askToFree = async () => {
127    await changeView($, x => ({ ...x, confirm: 'orphans' as const, note: null, openId: null }))
128    // Enter then confirms. A surface that moves no ring (the test kit) leaves it where it was.
129    await $.ui.focus({ requestId: PANE, key: 'free-confirm' }).catch(() => undefined)
130  }
131  const startOver = async () => {
132    await changeView($, x => ({ ...x, note: null, freedHere: 0 }))
133    await refresh($)
134  }
135  // The confirm buttons go as the stop starts: the ring moves to Refresh rather than onto a row being stopped.
136  const freeAll = () => {
137    void stopAll($, orphans, 'pane')
138    void $.ui.focus({ requestId: PANE, key: 'refresh' }).catch(() => undefined)
139  }
140  const toggle = (s: DevServer) => changeView($, x => ({ ...x, openId: x.openId === s.id ? null : s.id, note: null, confirm: null }))
141
142  const refreshButton = (
143    <Button key="refresh" hotkey="r" dimColor onPress={() => void startOver()}>
144      {v.isScanning || v.isMeasuring ? 'Looking…' : 'Refresh'}
145    </Button>
146  )
147  // Under the bar: what the yellow is and the one action, or the question that action asks.
148  const [said, actions] = pending.length
149    ? [<Text dimColor>{`Stopping ${count(pending.length, 'server', 'servers')}…`}</Text>, refreshButton]
150    : v.confirm === 'orphans' && orphans.length
151      ? [
152          <Text>{`Stop ${count(orphans.length, 'idle orphan', 'idle orphans')}?`}</Text>,
153          <Box flexDirection="row" columnGap={1}>
154            <Button key="free-confirm" variant="primary" onPress={freeAll}>
155              Stop them
156            </Button>
157            <Button key="free-keep" onPress={() => void changeView($, x => ({ ...x, confirm: null }))}>
158              Keep
159            </Button>
160          </Box>,
161        ]
162      : [
163          orphans.length ? (
164            isMeasured ? (
165              <Text>
166                <Text color="warning">━ </Text>
167                <Text dimColor>{`${size(orphanBytes)} orphaned`}</Text>
168              </Text>
169            ) : null
170          ) : servers.length ? (
171            <Text dimColor>No orphans</Text>
172          ) : null,
173          <Box flexDirection="row" columnGap={1}>
174            {orphans.length ? (
175              <Button key="free" variant="primary" hotkey="o" autoFocus onPress={() => void askToFree()}>
176                {isMeasured ? `Free ${size(total(orphans))}` : `Free ${count(orphans.length, 'orphan', 'orphans')}`}
177              </Button>
178            ) : null}
179            {refreshButton}
180          </Box>,
181        ]
182
183  const header = (
184    <Box key="header" flexDirection="column">
185      <Text weight="semibold" size="title" wrap="truncate-end">
186        {title}
187      </Text>
188      {servers.length ? meter($, e, parts, width, title) : null}
189      <Box flexDirection="row" justifyContent="space-between" alignItems="center" columnGap={2} flexWrap="wrap">
190        <Box flexShrink={1}>{said ?? <Text> </Text>}</Box>
191        {actions}
192      </Box>
193    </Box>
194  )
195
196  const restart = v.note?.restart
197  const note = v.note ? (
198    <Box key="note" flexDirection="row" columnGap={2} flexWrap="wrap">
199      <Text>{v.note.text}</Text>
200      {restart ? (
201        <Button key="copy:last" dimColor onPress={press => void copyStart($, restart, press.surface)}>
202          {v.copiedId === restart.id ? 'Copied' : 'Copy start command'}
203        </Button>
204      ) : null}
205    </Box>
206  ) : null
207
208  const detail = (s: DevServer, isStopping: boolean) => (
209    <Box key={`detail:${s.id}`} flexDirection="column" paddingLeft={PORT + L.gap} paddingBottom={1}>
210      <Text dimColor>{startedLine(s, now)}</Text>
211      <Text dimColor wrap="truncate-middle">
212        {restartLine(s, v.home)}
213      </Text>
214      <Box flexDirection="row" columnGap={1} flexWrap="wrap">
215        {isStopping ? (
216          <Text dimColor>Stopping…</Text>
217        ) : (
218          <Button key={`stop:${s.id}`} onPress={() => void stopAll($, [s], 'pane')}>
219            {s.memoryBytes ? `Stop and free ${size(s.memoryBytes)}` : 'Stop'}
220          </Button>
221        )}
222        {s.ports[0] ? (
223          <Button key={`open:${s.id}`} dimColor onPress={() => void openInBrowser($, s)}>
224            Open in browser
225          </Button>
226        ) : null}
227        <Button key={`copy:${s.id}`} dimColor onPress={press => void copyStart($, s, press.surface)}>
228          {v.copiedId === s.id ? 'Copied' : 'Copy start command'}
229        </Button>
230      </Box>
231    </Box>
232  )
233
234  const row = (s: DevServer) => {
235    const isGone = exited.has(s.id)
236    const isStopping = pending.includes(s.id)
237    const used = usage(s, now)
238    return (
239      <Box key={`line:${s.id}`} flexDirection="column">
240        <Box flexDirection="row" columnGap={L.gap}>
241          <Box width={PORT} flexShrink={0}>
242            <Text dimColor={isGone}>{port(s)}</Text>
243          </Box>
244          <Box flexGrow={1} flexShrink={1} minWidth={6}>
245            <Button key={`row:${s.id}`} plain dimColor={isGone} onPress={() => void toggle(s)}>
246              <Text wrap="truncate-end">{s.folder}</Text>
247            </Button>
248          </Box>
249          {L.framework ? (
250            <Box width={L.framework} flexShrink={0}>
251              <Text dimColor hover={{ dimColor: false }} wrap="truncate-end">
252                {s.framework}
253              </Text>
254            </Box>
255          ) : null}
256          <Box width={L.bar} flexShrink={0}>
257            {rowMeter($, e, s, largest, L.bar, isGone ? 'gone' : isIdleOrphan(s) ? 'yellow' : 'ink')}
258          </Box>
259          <Box width={SIZE} flexShrink={0} justifyContent="flex-end">
260            <Text dimColor={isGone || s.memoryBytes === null}>{s.memoryBytes === null ? '…' : size(s.memoryBytes)}</Text>
261          </Box>
262          <Box width={L.used} flexShrink={0} marginLeft={2 - L.gap}>
263            <Text
264              weight={used.tone === 'strong' && !isGone ? 'semibold' : undefined}
265              dimColor={isStopping || (used.tone === 'quiet' && !isGone)}
266              hover={{ dimColor: false }}
267              wrap="truncate-end"
268            >
269              {isGone ? 'freed' : isStopping ? 'stopping…' : used.text}
270            </Text>
271          </Box>
272        </Box>
273        {v.openId === s.id && !isGone ? detail(s, isStopping) : null}
274      </Box>
275    )
276  }
277
278  const groups = groupsOf(servers).map(([key, group]) => (
279    <Box key={`group:${key}`} flexDirection="column">
280      <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
281        <Box flexShrink={1}>
282          <Text weight="semibold" wrap="truncate-end">
283            {group[0] ? ownerTitle(group[0].owner, now) : ''}
284          </Text>
285        </Box>
286        <Box flexShrink={0}>
287          <Text dimColor>{isMeasured ? size(total(group.filter(s => !exited.has(s.id)))) : ''}</Text>
288        </Box>
289      </Box>
290      <Box flexDirection="column" paddingLeft={2}>
291        {group.map(row)}
292      </Box>
293    </Box>
294  ))
295
296  const footer = (
297    <Box key="footer" flexDirection="row" justifyContent="space-between" columnGap={2} flexWrap="wrap">
298      {e.surface === 'terminal' ? <Text dimColor>tab next · enter open · o free · r refresh</Text> : null}
299      {v.lifetime ? <Text dimColor>{lifetimeLine(v.lifetime, now)}</Text> : null}
300    </Box>
301  )
302
303  return (
304    <Box flexDirection="column" rowGap={1}>
305      {header}
306      {note}
307      {groups}
308      {footer}
309    </Box>
310  )
311}
312
hooks/scan.ts 412 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { DevServer, Owner } from '../types'
4
5type Proc = { pid: number; ppid: number; uid: number; startedAt: number; started: string; command: string }
6type Server = { top: Proc; root: number; framework: string; ports: number[]; listeners: number[]; chain: Proc[] }
7
8const C_LOCALE = { LC_ALL: 'C', LANG: 'C' }
9const PS = ['/bin/ps', '-axo', 'pid=,ppid=,uid=,etime=,lstart=,command=']
10
11// Matched on the process listening on the port, then on the processes that started it, nearest
12// first. Next.js 13 and later serve from a child process named `next-server (vX.Y.Z)`.
13const FRAMEWORKS: readonly (readonly [string, RegExp])[] = [
14  ['Next.js', /^next-server\b|(^|[\s/])next\s+dev\b|\/next\/dist\/bin\/next\s+dev\b/],
15  ['Vite', /\/vite\/bin\/vite\.js\b|(^|[\s/])vite(\s|$)/],
16  ['Astro', /\/astro\/astro\.js\s+dev\b|(^|[\s/])astro\s+dev\b/],
17  ['Nuxt', /\/nuxi\/bin\/nuxi\.mjs\s+dev\b|(^|[\s/])(nuxi|nuxt)\s+dev\b/],
18  ['Storybook', /\/storybook\/(bin\/)?index\.c?js\s+dev\b|(^|[\s/])storybook\s+dev\b/],
19  ['Angular', /@angular\/cli\/bin\/ng(\.js)?\s+serve\b|(^|[\s/])ng\s+serve\b/],
20  ['webpack', /\/webpack\/bin\/webpack\.js\s+serve\b|webpack-dev-server\b/],
21  ['Expo', /\/expo\/bin\/cli\s+start\b|(^|[\s/])expo\s+start\b/],
22  ['Gatsby', /\/gatsby\/cli\.js\s+develop\b|(^|[\s/])gatsby\s+develop\b/],
23  ['Docusaurus', /(^|[\s/])docusaurus\s+start\b/],
24  ['Remix', /(^|[\s/])remix\s+(vite:)?dev\b/],
25]
26// How most people start one of the above, or a server of their own.
27const SCRIPT = /(^|[\s/])(pnpm|npm|yarn|bun)(\.c?js)?\s+(run\s+)?(dev|start|serve)\b/
28// Where a dev server's own processes end: the Claude session, shell, terminal or app that started it.
29const CLAUDE = /(^|\/)claude(\s|$)/
30// The shell Claude Code's Bash tool runs each command in; it can outlive the session that started it.
31const CLAUDE_SHELL = /\/\.claude\/shell-snapshots\//
32const SHELL = /^-\S+$|^(\S*\/)?(zsh|bash|fish|sh)(\s+-[il]+)*$/
33const MULTIPLEXER = /(^|\/)(tmux|screen)(:|\s|$)/
34const TERMINAL_HOST = /iTermServer|(^|\/)login\s/
35const APP = /\/([^/]+)\.app\//
36const NEXT_LOG = '.next/dev/logs/next-development.log'
37// Turbopack writes these to the Next.js dev log on its own schedule, with nobody using the server.
38const HOUSEKEEPING = /filesystem cache|cache database compaction/i
39const NPX = /(^|[\s/])(npx|npm exec)\s+(.+)$/
40const INTERPRETER = /^(?:\S*\/)?(?:node|bun|deno)\s+(\S+)(.*)$/
41const STOP_GRACE_MS = 8000
42const POLL_MS = 250
43const UNIT: Record<string, number> = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3 }
44
45/** ps `etime`, `[[dd-]hh:]mm:ss`, or a dev log's `hh:mm:ss.mmm` (hours past 24), in seconds. */
46export function clockSeconds(text: string): number {
47  const dash = text.indexOf('-')
48  const days = dash < 0 ? 0 : Number(text.slice(0, dash))
49  return days * 86400 + text.slice(dash + 1).split(':').reduce((total, part) => total * 60 + Number(part), 0)
50}
51
52export function parsePs(stdout: string, now: number): Map<number, Proc> {
53  const procs = new Map<number, Proc>()
54  for (const line of stdout.split('\n')) {
55    const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(.*)$/.exec(line)
56    if (!m) continue
57    const [, pid, ppid, uid, etime, started, command] = m
58    procs.set(Number(pid), {
59      pid: Number(pid),
60      ppid: Number(ppid),
61      uid: Number(uid),
62      startedAt: now - clockSeconds(etime ?? '0') * 1000,
63      started: (started ?? '').replace(/\s+/g, ' '),
64      command: (command ?? '').trim(),
65    })
66  }
67  return procs
68}
69
70/** lsof `-Fpn`: pid -> the NAME of every file it matched. */
71export function parseLsof(stdout: string): Map<number, string[]> {
72  const names = new Map<number, string[]>()
73  let current: string[] | undefined
74  for (const line of stdout.split('\n')) {
75    if (line.startsWith('p')) {
76      current = []
77      names.set(Number(line.slice(1)), current)
78    } else if (line.startsWith('n') && current) current.push(line.slice(1))
79  }
80  return names
81}
82
83/** `top -l 1 -stats pid,mem`: Activity Monitor's Memory column, compressed and swapped pages included. */
84export function parseTop(stdout: string): Map<number, number> {
85  const bytes = new Map<number, number>()
86  for (const line of stdout.split('\n')) {
87    const m = /^\s*(\d+)\s+([\d.]+)([BKMG])/.exec(line)
88    if (m) bytes.set(Number(m[1]), Number(m[2]) * (UNIT[m[3] ?? 'B'] ?? 1))
89  }
90  return bytes
91}
92
93function portOf(address: string) {
94  const port = Number(address.slice(address.lastIndexOf(':') + 1))
95  return Number.isInteger(port) && port > 0 ? port : null
96}
97
98/**
99 * When each Next.js dev log last recorded someone: `tail` of every log (with /dev/null so each
100 * gets a header) and `stat` mtimes. Lines carry time since the server started, so the last real
101 * line's clock time is the file's mtime less how long after it the last line was written.
102 */
103export function parseDevLogs(tail: string, stat: string): Map<string, number | null> {
104  const mtimes = new Map<string, number>()
105  for (const line of stat.split('\n')) {
106    const space = line.indexOf(' ')
107    if (space > 0) mtimes.set(line.slice(space + 1), Number(line.slice(0, space)) * 1000)
108  }
109  const blocks = new Map<string, string[]>()
110  let lines: string[] | undefined
111  for (const line of tail.split('\n')) {
112    const header = /^==> (.*) <==$/.exec(line)
113    if (header) {
114      lines = []
115      blocks.set(header[1] ?? '', lines)
116    } else if (lines && line.trim()) lines.push(line)
117  }
118  const used = new Map<string, number | null>()
119  for (const [path, body] of blocks) {
120    const mtime = mtimes.get(path)
121    if (mtime === undefined) continue
122    let last: number | undefined
123    let lastReal: number | undefined
124    for (let i = body.length - 1; i >= 0 && lastReal === undefined; i -= 1) {
125      const entry = readLogLine(body[i] ?? '')
126      if (!entry) continue
127      last ??= entry.seconds
128      if (!HOUSEKEEPING.test(entry.message)) lastReal = entry.seconds
129    }
130    used.set(path, last === undefined || lastReal === undefined ? null : mtime - (last - lastReal) * 1000)
131  }
132  return used
133}
134
135function readLogLine(line: string): { seconds: number; message: string } | null {
136  if (!line.startsWith('{')) return null
137  try {
138    const entry = JSON.parse(line) as { timestamp?: unknown; message?: unknown }
139    if (typeof entry.timestamp !== 'string') return null
140    return { seconds: clockSeconds(entry.timestamp), message: String(entry.message ?? '') }
141  } catch {
142    // A line cut by tail or by a crash mid-write.
143    return null
144  }
145}
146
147function childrenOf(procs: Map<number, Proc>) {
148  const children = new Map<number, number[]>()
149  for (const p of procs.values()) children.set(p.ppid, [...(children.get(p.ppid) ?? []), p.pid])
150  return children
151}
152
153function subtree(root: number, children: Map<number, number[]>) {
154  const out: number[] = []
155  const stack = [root]
156  while (stack.length) {
157    const pid = stack.pop() as number
158    out.push(pid)
159    stack.push(...(children.get(pid) ?? []))
160  }
161  return out
162}
163
164/** The Claude session, shell, terminal or app a dev server's own processes end at. */
165function isBoundary(p: Proc) {
166  return p.pid <= 1 || CLAUDE.test(p.command) || SHELL.test(p.command) || MULTIPLEXER.test(p.command) || TERMINAL_HOST.test(p.command) || APP.test(p.command)
167}
168
169/** The processes from `pid` up to the boundary that started it, nearest first. */
170function chainOf(pid: number, procs: Map<number, Proc>) {
171  const chain: Proc[] = []
172  for (let p = procs.get(pid); p && !isBoundary(p) && chain.length < 64; p = procs.get(p.ppid)) chain.push(p)
173  return chain
174}
175
176/** What the listening process is, read off it and the processes that started it; null for anything else. */
177function frameworkOf(chain: readonly Proc[]): string | null {
178  for (const p of chain) {
179    for (const [name, test] of FRAMEWORKS) {
180      if (!test.test(p.command)) continue
181      const version = name === 'Next.js' ? /next-server \(v([\d.]+)\)/.exec(p.command)?.[1] : undefined
182      return version ? `${name} ${version}` : name
183    }
184  }
185  for (const p of chain) {
186    const m = SCRIPT.exec(p.command)
187    if (m) return `${m[2]} ${m[4] ?? ''}${m[5]}`
188  }
189  return null
190}
191
192function ownerOf(server: Server, procs: Map<number, Proc>): Owner {
193  const root = procs.get(server.root) ?? server.top
194  let shell: number | null = null
195  let multiplexer: string | null = null
196  for (let p = procs.get(root.ppid), hops = 0; p && p.pid > 1 && hops < 64; p = procs.get(p.ppid), hops += 1) {
197    if (CLAUDE.test(p.command)) return { kind: 'claude', pid: p.pid, since: p.startedAt, where: '' }
198    if (shell === null && SHELL.test(p.command)) shell = p.pid
199    multiplexer ??= MULTIPLEXER.exec(p.command)?.[2] ?? null
200    const app = APP.exec(p.command)?.[1]
201    if (app) return shell === null ? { kind: 'app', pid: p.pid, name: app } : { kind: 'terminal', pid: shell, name: multiplexer ?? app }
202  }
203  if (shell !== null) return { kind: 'terminal', pid: shell, name: multiplexer ?? 'a terminal' }
204  return { kind: 'orphan', startedBy: server.chain.some(p => CLAUDE_SHELL.test(p.command)) ? 'claude' : 'unknown' }
205}
206
207/**
208 * What to type to start a server again, read off its processes nearest first: the package script
209 * (`pnpm dev`), an npx, the framework's own command run through npx, else the listener's own line.
210 * Claude Code's wrapper shell is skipped: its line holds the session's exports, not the command.
211 */
212export function startCommand(commands: readonly string[]): { index: number; command: string } {
213  const lines = [...commands.entries()].filter(([, command]) => !CLAUDE_SHELL.test(command))
214  for (const [index, command] of lines) {
215    const m = SCRIPT.exec(command)
216    if (m) return { index, command: command.slice(m.index + (m[1]?.length ?? 0)).replace(/^(pnpm|npm|yarn|bun)\.c?js\b/, '$1') }
217  }
218  for (const [index, command] of lines) {
219    const m = NPX.exec(command)
220    if (m) return { index, command: `npx ${m[3]}` }
221  }
222  for (const [index, command] of lines) {
223    if (command.startsWith('next-server') || !FRAMEWORKS.some(([, test]) => test.test(command))) continue
224    const m = INTERPRETER.exec(command)
225    if (!m) return { index, command }
226    const script = m[1] ?? ''
227    const modules = script.lastIndexOf('/node_modules/')
228    if (modules < 0) return { index, command: `${script}${m[2]}` }
229    const base = script.slice(script.lastIndexOf('/') + 1).replace(/\.[cm]?js$/, '')
230    const inside = script.slice(modules + '/node_modules/'.length).split('/')
231    const name = base === 'index' || base === 'cli' ? inside.slice(0, inside[0]?.startsWith('@') ? 2 : 1).join('/') : base
232    return { index, command: `npx ${name}${m[2]}` }
233  }
234  return { index: 0, command: commands[0] ?? '' }
235}
236
237/**
238 * Every dev server: the user's listening processes that a framework or a package script started,
239 * one per process tree. Its root is the highest process that exists only to run it, so stopping
240 * one app of a `turbo dev` never takes its siblings down.
241 */
242function serversOf(procs: Map<number, Proc>, listening: Map<number, string[]>): Server[] {
243  const children = childrenOf(procs)
244  const found: { proc: Proc; framework: string; chain: Proc[]; ports: number[] }[] = []
245  for (const [pid, names] of listening) {
246    const proc = procs.get(pid)
247    if (!proc) continue
248    const chain = chainOf(pid, procs)
249    const framework = frameworkOf(chain)
250    const ports = names.map(portOf).filter((p): p is number => p !== null)
251    if (framework && ports.length) found.push({ proc, framework, chain, ports })
252  }
253  // A listener started by another listener (a framework's own child) belongs to that one's server.
254  const tops = found.filter(f => !f.chain.slice(1).some(a => found.some(o => o.proc.pid === a.pid)))
255  const topPids = new Set(tops.map(t => t.proc.pid))
256  return tops.map(t => {
257    const members = found.filter(f => f === t || f.chain.some(a => a.pid === t.proc.pid))
258    const others = new Set([...topPids].filter(pid => pid !== t.proc.pid))
259    let root = t.proc
260    for (const parent of t.chain.slice(1)) {
261      if (parent.uid !== root.uid || subtree(parent.pid, children).some(pid => others.has(pid))) break
262      root = parent
263    }
264    return {
265      top: t.proc,
266      root: root.pid,
267      framework: t.framework,
268      ports: [...new Set(members.flatMap(m => m.ports))].sort((a, b) => a - b),
269      listeners: members.map(m => m.proc.pid),
270      chain: t.chain,
271    }
272  })
273}
274
275async function lsof($: EngineInterface, select: string[]) {
276  return parseLsof((await $.process.run(['/usr/sbin/lsof', '-nP', ...select, '-Fpn'], { env: C_LOCALE })).stdout)
277}
278
279async function detect($: EngineInterface) {
280  const now = await $.clock.now()
281  const user = (await $.env.get('USER')) ?? (await $.process.run(['/usr/bin/id', '-un'])).stdout.trim()
282  const [ps, listening] = await Promise.all([
283    $.process.run(PS, { env: C_LOCALE }),
284    lsof($, ['-iTCP', '-sTCP:LISTEN', '-a', '-u', user]),
285  ])
286  const procs = parsePs(ps.stdout, now)
287  return { now, procs, servers: serversOf(procs, listening) }
288}
289
290/** Where a folder sits: its git checkout's name and the path inside it, else the folder's own name. */
291async function foldersOf($: EngineInterface, dirs: string[]) {
292  const roots = (
293    await $.process.run(['/bin/sh', '-c', 'for d; do git -C "$d" rev-parse --show-toplevel 2>/dev/null || echo; done', 'devservers', ...dirs])
294  ).stdout.split('\n')
295  return dirs.map((dir, i) => {
296    const root = roots[i] ?? ''
297    const base = (path: string) => path.slice(path.lastIndexOf('/') + 1)
298    return root && dir.startsWith(root) ? base(root) + dir.slice(root.length) : base(dir)
299  })
300}
301
302/**
303 * Every dev server, read in a fraction of a second: everything but memory, which `measure` reads
304 * after (top takes two to three seconds). Sorted by port.
305 */
306export async function scan($: EngineInterface): Promise<DevServer[]> {
307  const { procs, servers } = await detect($)
308  if (!servers.length) return []
309  const children = childrenOf(procs)
310  const owners = servers.map(s => ownerOf(s, procs))
311  const starts = servers.map(s => startCommand(s.chain.map(p => p.command)))
312  const startPids = servers.map((s, i) => s.chain[starts[i]?.index ?? 0]?.pid ?? s.top.pid)
313  const sessions = [...new Set(owners.flatMap(o => (o.kind === 'claude' ? [o.pid] : [])))]
314  const [connected, cwds] = await Promise.all([
315    lsof($, ['-iTCP', '-sTCP:ESTABLISHED', '-a', '-p', servers.flatMap(s => s.listeners).join(',')]),
316    lsof($, ['-a', '-p', [...new Set([...servers.map(s => s.top.pid), ...startPids, ...sessions])].join(','), '-d', 'cwd']),
317  ])
318  const cwdOf = (pid: number) => cwds.get(pid)?.[0]
319  const dirs = servers.map(s => cwdOf(s.top.pid) ?? '/')
320  const [folders, sessionFolders] = await Promise.all([foldersOf($, dirs), foldersOf($, sessions.map(pid => cwdOf(pid) ?? '/'))])
321  const logs = servers.flatMap((s, i) => (s.framework.startsWith('Next.js') ? [`${dirs[i]}/${NEXT_LOG}`] : []))
322  const lastLogged = logs.length
323    ? await Promise.all([
324        $.process.run(['/usr/bin/tail', '-n', '200', ...logs, '/dev/null']),
325        $.process.run(['/usr/bin/stat', '-f', '%m %N', ...logs]),
326      ]).then(([tail, stat]) => parseDevLogs(tail.stdout, stat.stdout))
327    : new Map<string, number | null>()
328
329  return servers
330    .map((s, i): DevServer => {
331      const owner = owners[i] ?? { kind: 'orphan', startedBy: 'unknown' }
332      const clients = s.listeners
333        .flatMap(pid => connected.get(pid) ?? [])
334        .filter(name => name.includes('->') && s.ports.includes(portOf(name.split('->')[0] ?? '') ?? -1)).length
335      return {
336        id: `${s.top.pid}@${s.top.started}`,
337        pid: s.top.pid,
338        started: s.top.started,
339        startedAt: s.top.startedAt,
340        ports: s.ports,
341        framework: s.framework,
342        folder: folders[i] ?? '?',
343        cwd: dirs[i] ?? '/',
344        pids: subtree(s.root, children),
345        memoryBytes: null,
346        owner: owner.kind === 'claude' ? { ...owner, where: sessionFolders[sessions.indexOf(owner.pid)] ?? '?' } : owner,
347        clients,
348        lastUsed: clients ? { kind: 'now' } : usedAt(lastLogged.get(`${dirs[i]}/${NEXT_LOG}`)),
349        start: { command: starts[i]?.command ?? '', cwd: cwdOf(startPids[i] ?? s.top.pid) ?? dirs[i] ?? '/' },
350      }
351    })
352    .sort((a, b) => (a.ports[0] ?? 0) - (b.ports[0] ?? 0))
353}
354
355/** A dev log's verdict: no log at all, a log that records nobody, or when it last did. */
356function usedAt(logged: number | null | undefined): DevServer['lastUsed'] {
357  if (logged === undefined) return { kind: 'unknown' }
358  return logged === null ? { kind: 'never' } : { kind: 'at', at: logged }
359}
360
361/** Each server's footprint, by id: one `top` sample, summed over the processes that run it. */
362export async function measure($: EngineInterface, servers: readonly DevServer[]): Promise<Map<string, number>> {
363  if (!servers.length) return new Map()
364  const memory = parseTop((await $.process.run(['/usr/bin/top', '-l', '1', '-stats', 'pid,mem'], { env: C_LOCALE })).stdout)
365  return new Map(servers.map(s => [s.id, s.pids.reduce((sum, pid) => sum + (memory.get(pid) ?? 0), 0)]))
366}
367
368async function alive($: EngineInterface, pids: number[]) {
369  const { stdout } = await $.process.run(['/bin/ps', '-o', 'pid=', '-p', pids.join(',')])
370  return new Set(stdout.split(/\s+/).filter(Boolean).map(Number))
371}
372
373export type StopOutcome = { id: string; isStopped: true } | { id: string; isStopped: false; reason: string }
374
375/**
376 * Stops servers and every process that exists only to run each: SIGTERM to all of them at once,
377 * SIGKILL to whatever is left after 8 s. Yields each server once its processes are gone, so a
378 * drawing can follow them out one by one; refuses one whose pid is gone or now another process's.
379 */
380export async function* stopServers($: EngineInterface, targets: readonly Pick<DevServer, 'id' | 'pid' | 'started'>[]): AsyncGenerator<StopOutcome> {
381  const { procs, servers } = await detect($)
382  const children = childrenOf(procs)
383  const pending = new Map<string, number[]>()
384  for (const target of targets) {
385    const live = servers.find(s => s.top.pid === target.pid && s.top.started === target.started)
386    if (live) pending.set(target.id, subtree(live.root, children))
387    else yield { id: target.id, isStopped: false, reason: 'it had already stopped' }
388  }
389  if (!pending.size) return
390  await $.process.run(['/bin/kill', '-TERM', ...[...pending.values()].flat().map(String)])
391  for (let waited = POLL_MS; pending.size; waited += POLL_MS) {
392    await $.clock.sleep(POLL_MS)
393    const running = await alive($, [...pending.values()].flat())
394    for (const [id, pids] of pending) {
395      if (pids.some(pid => running.has(pid))) continue
396      pending.delete(id)
397      yield { id, isStopped: true }
398    }
399    if (!pending.size || waited < STOP_GRACE_MS) continue
400    await $.process.run(['/bin/kill', '-KILL', ...[...pending.values()].flat().filter(pid => running.has(pid)).map(String)])
401    for (const id of [...pending.keys()]) {
402      pending.delete(id)
403      yield { id, isStopped: true }
404    }
405  }
406}
407
408/** macOS is the one system whose ps, lsof and top output this reads. */
409export async function isSupported($: EngineInterface) {
410  return (await $.process.run(['/usr/bin/uname', '-s'])).stdout.trim() === 'Darwin'
411}
412
types/index.d.ts 77 lines
1/** What keeps a dev server alive. An orphan's Claude session or terminal is gone. */
2export type Owner =
3  /** `startedBy` is `claude` when the shell Claude Code ran it in outlived the session. */
4  | { kind: 'orphan'; startedBy: 'claude' | 'unknown' }
5  | { kind: 'claude'; pid: number; since: number; where: string }
6  | { kind: 'terminal'; pid: number; name: string }
7  | { kind: 'app'; pid: number; name: string }
8
9export type DevServer = {
10  /** `pid@started`: a pid the OS hands to another process later reads as another server. */
11  id: string
12  pid: number
13  /** ps `lstart`, checked again right before a stop. */
14  started: string
15  startedAt: number
16  ports: number[]
17  /** `Next.js 16.3.8`, `Vite`, or the package script that started it (`pnpm dev`). */
18  framework: string
19  /** The git checkout's name and the path inside it, else the folder's own name. */
20  folder: string
21  cwd: string
22  /** Every process that exists only to run it. */
23  pids: number[]
24  /** Footprint of those processes, compressed and swapped pages included; null until measured. */
25  memoryBytes: number | null
26  owner: Owner
27  /** Connections a browser tab or a tool holds on its ports right now. */
28  clients: number
29  /**
30   * When someone last used it: `now` while a client is connected; for Next.js, the last page view,
31   * browser message or server log its dev log records (`never` if none); otherwise `unknown`.
32   */
33  lastUsed: { kind: 'now' } | { kind: 'at'; at: number } | { kind: 'never' } | { kind: 'unknown' }
34  /** What to type, and where, to start it again: `pnpm dev` in `/Users/ana/code/shop`. */
35  start: { command: string; cwd: string }
36}
37
38/** A stop in progress, or the last one: every server it set out to stop and what came of each. */
39export type Freeing = {
40  ids: string[]
41  exited: string[]
42  refused: string[]
43  targetBytes: number
44  freedBytes: number
45}
46
47/** What devservers has freed on this Mac since it first stopped something. */
48export type Lifetime = { bytes: number; since: number }
49
50export type View = {
51  servers: DevServer[] | null
52  /** ps and lsof are being read: the rows are about to change. */
53  isScanning: boolean
54  /** top is being read: the memory figures are about to land. */
55  isMeasuring: boolean
56  /** The row showing its details and actions. */
57  openId: string | null
58  /** `orphans` while Free waits for its second press. */
59  confirm: 'orphans' | null
60  freeing: Freeing | null
61  /** What stops have freed since the pane opened: drawn as empty track, so the top bar keeps its scale. */
62  freedHere: number
63  /** The server whose start command was just copied, for the button's "Copied". */
64  copiedId: string | null
65  /** The outcome of the last action, and the server to start again after a stop of one. */
66  note: { text: string; restart: DevServer | null } | null
67  lifetime: Lifetime | null
68  /** $HOME, to write paths as `~/...`. */
69  home: string
70}
71
72declare module 'claude-code' {
73  interface PluginState {
74    devservers: { view: View }
75  }
76}
77