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

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!
hooks/register.tsx 166 lines1import { 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}
166types/index.d.ts 15 lines1export 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