SLOPSHOPPER

dev-servers

A pane listing local dev servers with Open, Restart and Stop; stops the ones this session started on exit

newpaneguardcommandprocesstimer
★ 3v0.1.0no licenseupdated 2026-10-06ewels/dotfiles/claude-mods/dev-servers
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-servers
│ ┃ Dev servers ✕ › fix the failing auth test and add an audit log call │ ┃ No dev servers listening. │ ⏺ 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 │ │ › /dev-servers │ ⎿ dev-servers: Dev servers pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dev servers
No dev servers listening.
README

dotfiles

Personal dotfiles for sharing setups and configs between machines.

Feel free to steal or use as you like.. Let me know if I'm missing any good tricks!

Source 2 files
hooks/register.tsx 166 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DevServer } from '../types'
5
6const PANE = 'dev-servers'
7const servers = atom({ plugin: 'dev-servers', key: 'servers' } as const, [])
8
9const SERVER_PROCESS = /^(node|bun|deno|python[\d.]*|Python|ruby|php)$/
10const DEV_COMMAND = /\b(dev|serve|preview|start|storybook|vite|astro|next|http\.server|jekyll|hugo)\b/
11// A server can take a while to bind after the background command starts.
12const ADOPT_WINDOW_MS = 120_000
13
14let pending: { command: string; at: number }[] = []
15let known = new Set<number>()
16let owned = new Map<number, string>()
17let isPaneOpen = false
18
19async function scan($: EngineInterface) {
20  const ls = await $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpcn']).catch(() => undefined)
21  if (!ls) return
22  const found = new Map<number, { process: string; ports: Set<number> }>()
23  let pid = 0
24  let proc = ''
25  for (const line of ls.stdout.split('\n')) {
26    if (line.startsWith('p')) pid = Number(line.slice(1))
27    else if (line.startsWith('c')) proc = line.slice(1)
28    else if (line.startsWith('n') && SERVER_PROCESS.test(proc)) {
29      const port = Number(line.slice(line.lastIndexOf(':') + 1))
30      if (port < 1024) continue
31      const s = found.get(pid) ?? { process: proc, ports: new Set<number>() }
32      s.ports.add(port)
33      found.set(pid, s)
34    }
35  }
36
37  const cwds = new Map<number, string>()
38  if (found.size) {
39    const cw = await $.process.run(['lsof', '-a', '-d', 'cwd', '-Fn', '-p', [...found.keys()].join(',')]).catch(() => undefined)
40    let p = 0
41    for (const line of cw?.stdout.split('\n') ?? []) {
42      if (line.startsWith('p')) p = Number(line.slice(1))
43      else if (line.startsWith('n')) cwds.set(p, line.slice(1))
44    }
45  }
46
47  // ponytail: attributes a new listener to the oldest pending start; two servers launched at once may swap commands
48  const now = await $.clock.now()
49  pending = pending.filter(p => now - p.at < ADOPT_WINDOW_MS)
50  for (const p of found.keys()) {
51    if (!known.has(p) && pending.length) {
52      const start = pending.shift()
53      if (start) owned.set(p, start.command)
54    }
55  }
56  known = new Set(found.keys())
57  for (const p of owned.keys()) if (!known.has(p)) owned.delete(p)
58
59  const list: DevServer[] = [...found].map(([p, s]) => ({
60    pid: p,
61    ports: [...s.ports].sort((a, b) => a - b),
62    process: s.process,
63    cwd: cwds.get(p) ?? '',
64    command: owned.get(p),
65  }))
66  await update($, servers, prev => (JSON.stringify(prev) === JSON.stringify(list) ? prev : list))
67
68  if (!isPaneOpen && list.some(s => s.command)) await openPane($)
69}
70
71async function openPane($: EngineInterface) {
72  const opened = await $.ui.open({ id: PANE, title: 'Dev servers' })
73  isPaneOpen = opened.isPlaced
74}
75
76async function stop($: EngineInterface, s: DevServer) {
77  await $.process.run(['kill', String(s.pid)]).catch(() => undefined)
78  owned.delete(s.pid)
79  await scan($)
80}
81
82async function restart($: EngineInterface, s: DevServer) {
83  if (!s.command) return
84  await stop($, s)
85  pending.push({ command: s.command, at: await $.clock.now() })
86  await $.tool.call({ tool: 'Bash', command: s.command, run_in_background: true, description: 'Restart dev server' })
87}
88
89function shortPath(cwd: string) {
90  return cwd.split('/').slice(-2).join('/')
91}
92
93export const register: Register = on => {
94  on('session.start', async ($, e, next) => {
95    const started = await next(e)
96    await $.command.register({ name: 'dev-servers', description: 'Show local dev servers with Open, Restart and Stop' })
97    void scan($)
98    $.clock.every(5_000, () => void scan($))
99    return started
100  })
101
102  on('command.run', { command: 'dev-servers' }, async $ => {
103    await scan($)
104    await $.ui.open({ id: PANE, title: 'Dev servers' })
105    isPaneOpen = true
106    return { text: 'Dev servers pane opened.' }
107  })
108
109  on('ui.close', async ($, e, next) => {
110    if (e.id === PANE) isPaneOpen = false
111    return next(e)
112  })
113
114  on('tool.call', async ($, e, next) => {
115    if (e.tool === 'Bash' && e.run_in_background === true && DEV_COMMAND.test(e.command)) {
116      pending.push({ command: e.command, at: await $.clock.now() })
117      $.clock.after(3_000, () => void scan($))
118    }
119    return next(e)
120  }).catch(($, e, next) => next(e))
121
122  // Leave servers running across /clear: you often clear and keep reviewing the same site.
123  on('session.end', async ($, e, next) => {
124    if (e.reason !== 'clear') {
125      for (const pid of owned.keys()) await $.process.run(['kill', String(pid)]).catch(() => undefined)
126    }
127    return next(e)
128  })
129
130  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
131    const { Box, Button, Link, Text } = $.ui.resolve(e)
132    const list = await read($, servers)
133    return (
134      <Box flexDirection="column">
135        {list.length === 0 && <Text dimColor>No dev servers listening.</Text>}
136        {list.map(s => (
137          <Box key={`srv-${s.pid}`} flexDirection="column" marginBottom={1}>
138            <Box>
139              {s.ports.map(port => (
140                <Link key={`open-${s.pid}-${port}`} href={`http://localhost:${port}`} label={`:${port} `} />
141              ))}
142              <Text dimColor>
143                {s.process} {s.command ? '' : '(not this session)'}
144              </Text>
145            </Box>
146            <Text dimColor>{shortPath(s.cwd)}</Text>
147            <Box>
148              {s.command && <Button key={`restart-${s.pid}`} label="Restart" onPress={() => restart($, s)} />}
149              <Button key={`stop-${s.pid}`} label="Stop" onPress={() => stop($, s)} />
150            </Box>
151          </Box>
152        ))}
153        {list.length > 1 && (
154          <Button
155            key="stop-all"
156            label="Stop all"
157            onPress={async () => {
158              for (const s of list) await stop($, s)
159            }}
160          />
161        )}
162      </Box>
163    )
164  })
165}
166
types/index.d.ts 15 lines
1export type DevServer = {
2  pid: number
3  ports: number[]
4  process: string
5  cwd: string
6  /** The Bash command this session started it with; absent for servers started elsewhere. */
7  command?: string
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'dev-servers': { servers: DevServer[] }
13  }
14}
15