SLOPSHOPPER

devservers-demo

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

v0.1.0MITupdated 2026-10-19MicoBlanc/devservers/demo
A shopper browsing a rack in a slop shop
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 2 files
hooks/register.ts 51 lines
1import 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}
51
machine.ts 167 lines
1/**
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