Stops a third dev server from starting

Four small mods for Claude Code. Install the ones you want.
| Mod | What it does |
|---|---|
codex | Claude can hand a task to Codex (OpenAI's gpt-6.1-sol), like a code review or a bug hunt. It runs in the background so the chat isn't blocked. |
imagegen | Claude can make and edit images through Codex, and shows the finished images right in the chat. |
devserver-limit | Stops Claude from starting a dev server when 2 are already running. Good for laptops with little memory. |
done-sound | Plays a chime when a task that took over a minute finishes. |
In a Claude Code terminal session, type one line per mod:
/plugin install codex --marketplace arjunkambj/claude-mods
/plugin install imagegen --marketplace arjunkambj/claude-mods
/plugin install devserver-limit --marketplace arjunkambj/claude-mods
/plugin install done-sound --marketplace arjunkambj/claude-mods
The first time, it asks to add the marketplace: press y. Then pick a scope (Enter picks "user", so it works in every project).
imagegen and done-sound use macOS tools and sounds.codex and imagegen: the Codex CLI installed and signed in (codex login). Images count against your Codex plan.hooks/register.ts 66 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const LIMIT = 2
4const STARTS = [
5 /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|preview)\b/,
6 /\b(next|astro|nuxt|remix|wrangler)\s+dev\b/,
7 /\bvite\b(?!\s+build)/,
8 /\bpython3?\s+-m\s+http\.server\b/,
9 /\b(uvicorn|flask\s+run|rails\s+s(erver)?)\b/,
10]
11const SERVER_PROCESSES = /^(node|bun|deno|python\S*|Python|ruby|php|workerd)$/
12
13type Server = { cwd: string; ports: string[] }
14
15// Listening dev processes, grouped by the folder they run in, so one server
16// that opens several ports (wrangler's node + workerd) counts once.
17async function runningServers($: EngineInterface): Promise<Server[]> {
18 const { stdout } = await $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fcpn'], { timeoutMs: 5000 })
19 const ports = new Map<string, string[]>()
20 let pid = ''
21 let isServer = false
22 for (const line of stdout.split('\n')) {
23 if (line.startsWith('p')) pid = line.slice(1)
24 else if (line.startsWith('c')) isServer = SERVER_PROCESSES.test(line.slice(1))
25 else if (line.startsWith('n') && isServer) ports.set(pid, [...(ports.get(pid) ?? []), line.slice(1).replace(/^.*:/, '')])
26 }
27
28 const byCwd = new Map<string, string[]>()
29 for (const [id, list] of ports) {
30 const { stdout: out } = await $.process.run(['lsof', '-a', '-p', id, '-d', 'cwd', '-Fn'], { timeoutMs: 5000 })
31 const cwd = out.split('\n').find(l => l.startsWith('n'))?.slice(1) ?? `pid ${id}`
32 byCwd.set(cwd, [...new Set([...(byCwd.get(cwd) ?? []), ...list])])
33 }
34
35 return [...byCwd].map(([cwd, list]) => ({ cwd, ports: list }))
36}
37
38function describe(servers: Server[]): string {
39 return servers.map(s => `${s.cwd.split('/').pop()} (port ${s.ports.join(', ')})`).join('; ')
40}
41
42export const register: Register = on => {
43 on('tool.call', async ($, e, next) => {
44 const input = e as { tool: string; command?: string; name?: string; run_in_background?: boolean }
45 const isStart =
46 (input.tool === 'Bash' && STARTS.some(re => re.test(input.command ?? ''))) ||
47 (input.tool.endsWith('preview_start') && typeof input.name === 'string')
48
49 if (!isStart) {
50 return next(e)
51 }
52
53 const servers = await runningServers($)
54 if (servers.length >= LIMIT) {
55 $.ui.toast(`Blocked a dev server: ${servers.length} already running`)
56 return {
57 deny:
58 `Dev server limit: ${servers.length} dev servers are already running (${describe(servers)}). ` +
59 `The limit is ${LIMIT}. Reuse one of them, or stop one you started before starting another.`,
60 }
61 }
62
63 return next(e)
64 }).catch(($, e, next) => next(e)) // if the check itself fails, let the server start
65}
66