A made-up Mac for recording devservers: answers its process, HOME and store calls with ana's projects, so no real folder shows and no real total changes

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.ts 51 lines1import type { Register } from 'claude-code'
2
3import { HOME, fakeMac } from '../machine'
4
5// How long each made-up server takes to exit once stopped, so they leave one by one on screen.
6const EXIT_MS: Record<number, number> = { 100: 600, 101: 600, 102: 600, 110: 1400, 111: 1400, 112: 1400 }
7// About as long as top takes on a real Mac.
8const TOP_MS = 1800
9const GB = 1024 ** 3
10const DAY = 86_400_000
11
12// A hook that fails would let the call through to the real Mac: refuse it instead.
13function refuse() {
14 return { deny: 'devservers-demo could not answer' }
15}
16
17/**
18 * Answers devservers' calls for the made-up Mac in `../machine`. Run a session with
19 * `claude --plugin-dir <this folder>` beside the installed devservers; nothing else is touched.
20 */
21export const register: Register = on => {
22 const mac = fakeMac({ exitAfter: pid => EXIT_MS[pid] ?? 1000 })
23 const store = new Map<string, unknown>()
24 let isSeeded = false
25 const isDevservers = (plugin: string) => plugin === 'devservers'
26
27 on('process.run', async ($, e, next) => {
28 if (!isDevservers(next.origin.plugin)) return next(e)
29 if (e.argv[0] === '/usr/bin/top') await $.clock.sleep(TOP_MS)
30 const stdout = mac.run(e.argv, await $.clock.now())
31 return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
32 }).catch(refuse)
33 on('env.get', ($, e, next) => {
34 if (!isDevservers(next.origin.plugin)) return next(e)
35 if (e.name === 'HOME') return { value: HOME }
36 return e.name === 'USER' ? { value: 'ana' } : next(e)
37 }).catch(refuse)
38 on('store.get', async ($, e, next) => {
39 if (!isDevservers(next.origin.plugin)) return next(e)
40 // ana has been using devservers for a couple of weeks.
41 if (!isSeeded) store.set('freed', { bytes: 212 * GB, since: (await $.clock.now()) - 15 * DAY })
42 isSeeded = true
43 return { value: store.get(e.key) }
44 }).catch(refuse)
45 on('store.set', ($, e, next) => {
46 if (!isDevservers(next.origin.plugin)) return next(e)
47 store.set(e.key, e.value)
48 return { value: undefined }
49 }).catch(refuse)
50}
51machine.ts 167 lines1/**
2 * A made-up Mac, for the tests and for the README's recording: ana's projects, nobody's real
3 * folders. `run` answers the commands devservers runs the way macOS does, and processes a kill
4 * reaches exit `exitAfter` ms later (at once in the tests).
5 */
6
7export const HOME = '/Users/ana'
8
9// pid, ppid, etime, lstart, command (uid 501 throughout).
10const PROCESSES: readonly (readonly [number, number, string, string, string])[] = [
11 [1, 0, '10-00:00:00', 'Mon Oct 6 08:00:00 2026', '/sbin/launchd'],
12 // shop: an orphan whose pnpm hangs off launchd since the session that started it ended.
13 [100, 1, '2-04:00:00', 'Wed Oct 15 16:00:00 2026', 'node /Users/ana/.local/bin/pnpm dev'],
14 [101, 100, '2-04:00:00', 'Wed Oct 15 16:00:00 2026', 'node /Users/ana/code/shop/node_modules/.bin/../next/dist/bin/next dev'],
15 [102, 101, '2-04:00:00', 'Wed Oct 15 16:00:00 2026', 'next-server (v15.0.3) '],
16 // studio: an orphan Claude Code started; the shell it ran in outlived the session.
17 [110, 1, '3-02:00:00', 'Tue Oct 14 18:00:00 2026', "/bin/zsh -c source /Users/ana/.claude/shell-snapshots/snapshot-zsh-1.sh && eval 'pnpm dev'"],
18 [111, 110, '3-02:00:00', 'Tue Oct 14 18:00:00 2026', 'node /Users/ana/.local/bin/pnpm dev'],
19 [112, 111, '3-02:00:00', 'Tue Oct 14 18:00:00 2026', 'node /Users/ana/code/studio/node_modules/.bin/../astro/astro.js dev'],
20 // blog: a Vite app a Claude session started in an iTerm tab, with a browser tab connected.
21 [190, 1, '5-00:00:00', 'Sun Oct 12 20:00:00 2026', '/Applications/iTerm.app/Contents/MacOS/iTerm2'],
22 [195, 190, '5-00:00:00', 'Sun Oct 12 20:00:00 2026', '/Users/ana/Library/Application Support/iTerm2/iTermServer-3.7.1 /tmp/iterm.socket'],
23 [196, 195, '3-00:00:00', 'Tue Oct 14 20:00:00 2026', '/usr/bin/login -fpl ana /Applications/iTerm.app/Contents/MacOS/ShellLauncher --launch_shell'],
24 [197, 196, '3-00:00:00', 'Tue Oct 14 20:00:00 2026', '-zsh'],
25 [200, 197, '3-00:00:00', 'Tue Oct 14 20:00:00 2026', 'claude --model opus'],
26 [202, 200, '20:00:00', 'Fri Oct 17 00:00:00 2026', "/bin/zsh -c source /Users/ana/.claude/shell-snapshots/snapshot-zsh-2.sh && eval 'pnpm dev'"],
27 [203, 202, '20:00:00', 'Fri Oct 17 00:00:00 2026', 'node /Users/ana/.local/bin/pnpm dev'],
28 [204, 203, '20:00:00', 'Fri Oct 17 00:00:00 2026', 'node /Users/ana/code/blog/node_modules/.bin/../vite/bin/vite.js'],
29 // mono: a monorepo's `turbo dev` in a Terminal tab, two apps under one turbo.
30 [300, 1, '1-00:00:00', 'Thu Oct 16 20:00:00 2026', '/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal'],
31 [301, 300, '1-00:00:00', 'Thu Oct 16 20:00:00 2026', '/usr/bin/login -pf ana'],
32 [302, 301, '1-00:00:00', 'Thu Oct 16 20:00:00 2026', '-zsh'],
33 [303, 302, '05:00:00', 'Fri Oct 17 15:00:00 2026', 'node /opt/homebrew/bin/pnpm dev'],
34 [304, 303, '05:00:00', 'Fri Oct 17 15:00:00 2026', '/Users/ana/code/mono/node_modules/turbo-darwin-arm64/bin/turbo run dev'],
35 [305, 304, '05:00:00', 'Fri Oct 17 15:00:00 2026', 'node /opt/homebrew/bin/pnpm run dev'],
36 [306, 305, '05:00:00', 'Fri Oct 17 15:00:00 2026', 'node /Users/ana/code/mono/node_modules/.bin/../next/dist/bin/next dev -p 3001'],
37 [307, 306, '05:00:00', 'Fri Oct 17 15:00:00 2026', 'next-server (v15.0.3) '],
38 [308, 304, '05:00:00', 'Fri Oct 17 15:00:00 2026', 'node /opt/homebrew/bin/pnpm run dev'],
39 [309, 308, '05:00:00', 'Fri Oct 17 15:00:00 2026', 'node /Users/ana/code/mono/node_modules/vite/bin/vite.js --port 3002'],
40 // Listening, and not dev servers.
41 [400, 1, '9-00:00:00', 'Wed Oct 8 20:00:00 2026', '/opt/homebrew/opt/postgresql@16/bin/postgres -D /opt/homebrew/var/postgresql@16'],
42 [410, 1, '9-00:00:00', 'Wed Oct 8 20:00:00 2026', '/Applications/Figma.app/Contents/Helpers/figma_agent.app/Contents/MacOS/figma_agent'],
43]
44const LISTENING: Record<number, string> = {
45 102: '*:3000',
46 112: '127.0.0.1:4321',
47 204: '127.0.0.1:5173',
48 307: '*:3001',
49 309: '[::1]:3002',
50 400: '127.0.0.1:5432',
51 410: '127.0.0.1:44950',
52}
53// A browser tab on blog; shop's own outgoing connection is no client of its port.
54const CONNECTED: readonly (readonly [number, string])[] = [
55 [204, '127.0.0.1:5173->127.0.0.1:55555'],
56 [102, '127.0.0.1:60000->127.0.0.1:60001'],
57]
58const CWD: Record<number, string> = {
59 100: '/Users/ana/code/shop',
60 102: '/Users/ana/code/shop',
61 111: '/Users/ana/code/studio',
62 112: '/Users/ana/code/studio',
63 200: '/Users/ana/code/blog',
64 203: '/Users/ana/code/blog',
65 204: '/Users/ana/code/blog',
66 305: '/Users/ana/code/mono/apps/web',
67 307: '/Users/ana/code/mono/apps/web',
68 308: '/Users/ana/code/mono/apps/docs',
69 309: '/Users/ana/code/mono/apps/docs',
70}
71const GIT_ROOT: Record<string, string> = {
72 '/Users/ana/code/shop': '/Users/ana/code/shop',
73 '/Users/ana/code/studio': '/Users/ana/code/studio',
74 '/Users/ana/code/blog': '/Users/ana/code/blog',
75 '/Users/ana/code/mono/apps/web': '/Users/ana/code/mono',
76 '/Users/ana/code/mono/apps/docs': '/Users/ana/code/mono',
77}
78const MEGABYTES: Record<number, number> = {
79 100: 30,
80 101: 20,
81 102: 4200,
82 110: 2,
83 111: 30,
84 112: 2100,
85 200: 500,
86 202: 1,
87 203: 30,
88 204: 1500,
89 303: 30,
90 304: 40,
91 305: 30,
92 306: 20,
93 307: 2000,
94 308: 30,
95 309: 900,
96 400: 300,
97}
98export const SHOP_LOG = '/Users/ana/code/shop/.next/dev/logs/next-development.log'
99export const WEB_LOG = '/Users/ana/code/mono/apps/web/.next/dev/logs/next-development.log'
100const MINUTE = 60_000
101const HOUR = 60 * MINUTE
102const logLine = (timestamp: string, message: string, source = 'Server') => JSON.stringify({ timestamp, source, level: 'LOG', message })
103
104export type FakeMac = {
105 /** stdout of one command as macOS would print it; throws for a command devservers never runs. */
106 run: (argv: readonly string[], now: number) => string
107 /** Every kill's arguments, in order. */
108 kills: string[][]
109 /** Every URL `open` was handed. */
110 opened: string[]
111}
112
113export function fakeMac({ system = 'Darwin', exitAfter = () => 0 }: { system?: string; exitAfter?: (pid: number) => number } = {}): FakeMac {
114 const exits = new Map<number, number>()
115 const kills: string[][] = []
116 const opened: string[] = []
117 const isLive = (pid: number, now: number) => (exits.get(pid) ?? Infinity) > now
118 const lsof = (entries: readonly (readonly [number, string])[]) => entries.map(([pid, name]) => `p${pid}\nn${name}`).join('\n')
119
120 const run = (argv: readonly string[], now: number) => {
121 const [bin, ...args] = argv
122 const live = <T,>(entries: readonly (readonly [number, T])[]) => entries.filter(([pid]) => isLive(pid, now))
123 if (bin === '/usr/bin/uname') return `${system}\n`
124 if (bin === '/bin/ps' && args[0] === '-axo')
125 return PROCESSES.filter(([pid]) => isLive(pid, now))
126 .map(([pid, ppid, etime, lstart, command]) => `${String(pid).padStart(5)} ${String(ppid).padStart(5)} 501 ${etime.padStart(11)} ${lstart} ${command}`)
127 .join('\n')
128 if (bin === '/bin/ps')
129 return (args[args.indexOf('-p') + 1] ?? '')
130 .split(',')
131 .filter(pid => isLive(Number(pid), now))
132 .join('\n')
133 if (bin === '/bin/kill') {
134 kills.push([...args])
135 for (const pid of args.slice(1).map(Number)) exits.set(pid, Math.min(exits.get(pid) ?? Infinity, now + (args[0] === '-KILL' ? 0 : exitAfter(pid))))
136 return ''
137 }
138 if (bin === '/usr/sbin/lsof') {
139 const pids = (args[args.indexOf('-p') + 1] ?? '').split(',').map(Number)
140 if (args.includes('-sTCP:LISTEN')) return lsof(live(Object.entries(LISTENING).map(([pid, name]) => [Number(pid), name] as const)))
141 if (args.includes('-sTCP:ESTABLISHED')) return lsof(live(CONNECTED).filter(([pid]) => pids.includes(pid)))
142 return lsof(pids.flatMap(pid => (CWD[pid] ? [[pid, CWD[pid]] as const] : [])))
143 }
144 if (bin === '/bin/sh') return args.slice(3).map(dir => GIT_ROOT[dir] ?? '').join('\n')
145 if (bin === '/usr/bin/top') return ['PID MEM', ...live(Object.entries(MEGABYTES).map(([pid, mb]) => [Number(pid), mb] as const)).map(([pid, mb]) => `${pid} ${mb}M`)].join('\n')
146 if (bin === '/usr/bin/tail')
147 return [
148 `==> ${SHOP_LOG} <==`,
149 logLine('10:00:00.000', '[INFO] GET /checkout 200 in 41ms'),
150 logLine('50:00:00.000', '✓ Finished writing to filesystem cache in 16.4s'),
151 '',
152 `==> ${WEB_LOG} <==`,
153 logLine('04:30:00.000', 'Error', 'Browser'),
154 '',
155 '==> /dev/null <==',
156 ].join('\n')
157 // The last line of shop's log, a cache write, came an hour ago; web's, half an hour ago.
158 if (bin === '/usr/bin/stat') return `${(now - HOUR) / 1000} ${SHOP_LOG}\n${(now - 30 * MINUTE) / 1000} ${WEB_LOG}\n`
159 if (bin === '/usr/bin/open') {
160 opened.push(args[0] ?? '')
161 return ''
162 }
163 throw new Error(`unexpected command: ${argv.join(' ')}`)
164 }
165 return { run, kills, opened }
166}
167