Side pane listing local dev servers with Open, Restart and Kill

A Claude Code mod that lists the dev servers running on your machine in a side pane.
┌ Dev servers ─────────────────────┐
│ shop-web :4321 │
│ astro dev · up 2h 5m │
│ Open [ Restart ] [ Kill ] │
└──────────────────────────────────┘
astro dev rather than the full node …/node_modules/.bin/astro dev) and its uptime.http://localhost:<port>.$TMPDIR/dev-servers-<port>.log.The pane opens with the session when the terminal is wide enough for a sidebar; otherwise run /dev-servers.
macOS and Linux (uses lsof and ps).
/plugin install dev-servers --marketplace kk5190/claude-code-mods
Restart rebuilds the command from the process list, which does not keep shell quoting: a server started with an argument containing spaces may not relaunch correctly. Use Kill and start it yourself in that case.
MIT. See LICENSE.
hooks/register.tsx 141 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pending, Server } from '../types'
5import { label, parseListening, toServers } from './servers'
6
7const PANE = 'dev-servers'
8const TITLE = 'Dev servers'
9const POLL_MS = 5000
10const STOP_WAIT_MS = 5000
11
12const servers = atom({ plugin: 'dev-servers', key: 'servers' } as const, [])
13const pending = atom({ plugin: 'dev-servers', key: 'pending' } as const, {})
14const error = atom({ plugin: 'dev-servers', key: 'error' } as const, null)
15
16const scan = async ($: EngineInterface) => {
17 try {
18 // lsof exits 1 when nothing listens; that is an empty list, not a failure.
19 const listening = await $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn'])
20 const pids = [...parseListening(listening.stdout).keys()].join(',')
21 if (!pids) {
22 await update($, servers, () => [])
23 } else {
24 const [ps, cwd] = await Promise.all([
25 $.process.run(['ps', '-ww', '-o', 'pid=,etime=,command=', '-p', pids]),
26 $.process.run(['lsof', '-a', '-d', 'cwd', '-Fpn', '-p', pids]),
27 ])
28 await update($, servers, () => toServers(listening.stdout, ps.stdout, cwd.stdout))
29 }
30 await update($, error, () => null)
31 } catch (err) {
32 await update($, error, () => `Could not list servers: ${String(err)}`)
33 }
34}
35
36const isAlive = async ($: EngineInterface, pid: number) =>
37 (await $.process.run(['kill', '-0', String(pid)])).exitCode === 0
38
39// SIGTERM first so the server can shut down cleanly; SIGKILL only if it ignores that.
40const stop = async ($: EngineInterface, pid: number) => {
41 await $.process.run(['kill', String(pid)])
42 for (let waited = 0; waited < STOP_WAIT_MS; waited += 250) {
43 if (!(await isAlive($, pid))) return
44 await $.clock.sleep(250)
45 }
46 await $.process.run(['kill', '-9', String(pid)])
47}
48
49const setPending = ($: EngineInterface, pid: number, state: Pending | null) =>
50 update($, pending, all => {
51 const { [pid]: _, ...rest } = all
52 return state === null ? rest : { ...rest, [pid]: state }
53 })
54
55const kill = async ($: EngineInterface, server: Server) => {
56 await setPending($, server.pid, 'stopping')
57 try {
58 await stop($, server.pid)
59 $.ui.toast(`Stopped ${label(server.command)} on :${server.ports[0]}`)
60 } finally {
61 await setPending($, server.pid, null)
62 await scan($)
63 }
64}
65
66// Re-runs the same command line from the same directory, detached so it outlives this session.
67const restart = async ($: EngineInterface, server: Server) => {
68 await setPending($, server.pid, 'restarting')
69 try {
70 await stop($, server.pid)
71 const port = String(server.ports[0])
72 await $.process.run([
73 'sh', '-c',
74 'cd "$1" && nohup sh -c "$2" >"${TMPDIR:-/tmp}/dev-servers-$3.log" 2>&1 &',
75 'sh', server.cwd, server.command, port,
76 ])
77 $.ui.toast(`Restarting ${label(server.command)} on :${port}`)
78 await $.clock.sleep(1500)
79 } finally {
80 await setPending($, server.pid, null)
81 await scan($)
82 }
83}
84
85export const register: Register = on => {
86 on('session.start', async ($, e, next) => {
87 await $.command.register({ name: 'dev-servers', description: 'Show running dev servers in a pane' })
88 void $.ui.open({ id: PANE, title: TITLE })
89 void scan($)
90 $.clock.every(POLL_MS, () => void scan($))
91
92 return next(e)
93 })
94
95 on('command.run', { command: 'dev-servers' }, async $ => {
96 await scan($)
97 await $.ui.open({ id: PANE, title: TITLE, focus: true })
98
99 return { text: 'Dev servers opened.' }
100 })
101
102 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
103 const { Box, Text, Button, Link } = $.ui.resolve(e)
104 const list = await read($, servers)
105 const busy = await read($, pending)
106 const problem = await read($, error)
107 const width = Math.max(20, e.props.bodyColumns - 2)
108 const cut = (s: string) => (s.length > width ? `${s.slice(0, width - 1)}…` : s)
109
110 return (
111 <Box flexDirection="column" paddingX={1}>
112 {problem !== null && <Text color="error">{cut(problem)}</Text>}
113 {list.length === 0 && problem === null && <Text dimColor>No dev servers running.</Text>}
114 {list.map((server, i) => {
115 const url = `http://localhost:${server.ports[0]}`
116 const state = busy[server.pid]
117
118 return (
119 <Box key={`server-${server.pid}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
120 <Box flexDirection="row" justifyContent="space-between">
121 <Text bold>{cut(server.project)}</Text>
122 <Text color="success">{server.ports.map(p => `:${p}`).join(' ')}</Text>
123 </Box>
124 <Text dimColor>{cut(`${label(server.command)} · up ${server.uptime}`)}</Text>
125 {state ? (
126 <Text color="warning">{state === 'restarting' ? 'Restarting…' : 'Stopping…'}</Text>
127 ) : (
128 <Box flexDirection="row" gap={1}>
129 <Link href={url} label="Open" />
130 <Button key={`restart-${server.pid}`} label="Restart" onPress={() => restart($, server)} />
131 <Button key={`kill-${server.pid}`} label="Kill" onPress={() => kill($, server)} />
132 </Box>
133 )}
134 </Box>
135 )
136 })}
137 </Box>
138 )
139 })
140}
141hooks/servers.ts 94 lines1import type { Server } from '../types'
2
3// Runtimes that serve dev builds; anything else listening (AirPlay, Spotify, rapportd) is left out.
4const RUNTIMES = /^(node|bun|deno|python[\d.]*|ruby|php|java|go|cargo|uvicorn|gunicorn|flask|vite|next-server|astro)$/
5// Ports at or above 49152 are ephemeral (kernels, debuggers, IPC), not servers you browse to.
6const EPHEMERAL = 49152
7
8const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? path
9
10/** `lsof -nP -iTCP -sTCP:LISTEN -Fpn` → listening ports per pid. */
11export const parseListening = (text: string): Map<number, number[]> => {
12 const ports = new Map<number, number[]>()
13 let pid = 0
14 for (const line of text.split('\n')) {
15 if (line.startsWith('p')) pid = Number(line.slice(1))
16 if (line.startsWith('n') && pid) {
17 const port = Number(line.slice(line.lastIndexOf(':') + 1))
18 if (port > 0 && port < EPHEMERAL) {
19 const list = ports.get(pid) ?? []
20 if (!list.includes(port)) ports.set(pid, [...list, port].sort((a, b) => a - b))
21 }
22 }
23 }
24 return ports
25}
26
27/** `ps -ww -o pid=,etime=,command= -p …` → elapsed time and full command per pid. */
28export const parseProcesses = (text: string): Map<number, { etime: string; command: string }> => {
29 const procs = new Map<number, { etime: string; command: string }>()
30 for (const line of text.split('\n')) {
31 const m = line.trim().match(/^(\d+)\s+(\S+)\s+(.+)$/)
32 if (m) procs.set(Number(m[1]), { etime: m[2]!, command: m[3]! })
33 }
34 return procs
35}
36
37/** `lsof -a -d cwd -p … -Fpn` → working directory per pid. */
38export const parseCwds = (text: string): Map<number, string> => {
39 const cwds = new Map<number, string>()
40 let pid = 0
41 for (const line of text.split('\n')) {
42 if (line.startsWith('p')) pid = Number(line.slice(1))
43 if (line.startsWith('n') && pid) cwds.set(pid, line.slice(1))
44 }
45 return cwds
46}
47
48export const isDevServer = (command: string) => RUNTIMES.test(basename(command.split(/\s+/)[0] ?? ''))
49
50/** `ps` etime (`[[dd-]hh:]mm:ss`) → `3d 2h`, `2h 5m`, `4m`, `20s`. */
51export const humanize = (etime: string) => {
52 const [days, clock] = etime.includes('-') ? etime.split('-') : ['0', etime]
53 const parts = clock!.split(':').map(Number)
54 const [h, m, s] = parts.length === 3 ? parts : [0, ...parts]
55 const d = Number(days)
56 if (d > 0) return `${d}d ${h}h`
57 if (h! > 0) return `${h}h ${m}m`
58 if (m! > 0) return `${m}m`
59 return `${s}s`
60}
61
62/** A command line with paths shortened to their last segment: `node …/.bin/astro dev` → `astro dev`. */
63export const label = (command: string) => {
64 const raw = command.split(/\s+/)
65 // A script inside node_modules is named by its package, followed by its own arguments.
66 const at = raw.findIndex(w => w.includes('node_modules/'))
67 if (at >= 0) {
68 const bin = raw[at]!.match(/node_modules\/\.bin\/([^/]+)|node_modules\/((?:@[^/]+\/)?[^/]+)/)
69 return [bin?.[1] ?? bin?.[2] ?? basename(raw[at]!), ...raw.slice(at + 1)].join(' ')
70 }
71 return raw.map(w => (w.includes('/') ? basename(w) : w)).join(' ')
72}
73
74export const toServers = (listening: string, ps: string, cwd: string): Server[] => {
75 const ports = parseListening(listening)
76 const procs = parseProcesses(ps)
77 const cwds = parseCwds(cwd)
78 const servers: Server[] = []
79 for (const [pid, list] of ports) {
80 const proc = procs.get(pid)
81 if (!proc || !isDevServer(proc.command)) continue
82 const dir = cwds.get(pid) ?? ''
83 servers.push({
84 pid,
85 ports: list,
86 project: dir ? basename(dir) : '?',
87 cwd: dir,
88 command: proc.command,
89 uptime: humanize(proc.etime),
90 })
91 }
92 return servers.sort((a, b) => a.ports[0]! - b.ports[0]!)
93}
94types/index.d.ts 17 lines1export type Server = {
2 pid: number
3 ports: number[]
4 project: string
5 cwd: string
6 command: string
7 uptime: string
8}
9
10export type Pending = 'restarting' | 'stopping'
11
12declare module 'claude-code' {
13 interface PluginState {
14 'dev-servers': { servers: Server[]; pending: Record<string, Pending>; error: string | null }
15 }
16}
17