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

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.

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.
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.
Type /devservers. The pane opens at once, your servers land a moment later, and their memory fills in as macOS measures it.
cd ~/code/shop && pnpm dev.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:
| Command | Does |
|---|---|
/devservers | Opens the pane |
/devservers list | Prints the same table as text |
/devservers stop 3000 | Stops the server on port 3000, and says how to start it again |
/devservers stop orphans | Stops every orphan nobody has open |
pnpm, npm, yarn or bun dev, start or serve script started. Databases, Docker and desktop apps are left out.pnpm dev -p 3001), else the framework's own command run through npx, in the folder it ran in.pnpm, next dev, the shell that launched them), all at once, waits up to 8 seconds, then sends SIGKILL to whatever is left.turbo dev leaves the other apps running.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.
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.
MIT
hooks/register.tsx 47 lines1import 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}
47hooks/actions.ts 150 lines1import { 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}
150hooks/format.ts 84 lines1import 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}
84hooks/pane.tsx 312 lines1import 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}
312hooks/scan.ts 412 lines1import 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}
412types/index.d.ts 77 lines1/** 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