Runs each repo's dev servers as named services on fixed ports, with a pane, filtered log reads and start/stop tools for the model

Function-hook plugins for Claude Code. Each one installs on its own.
At a Claude Code prompt, install any of the mods below:
/plugin install <mod> --marketplace daviddecorso/claude-mods
Answer y to add the marketplace, then pick a scope.
Blocks Bash commands that write files (inline Python/Node scripts, sed -i, perl -pi, redirects, tee) and points the model at Edit/Write instead, so every file change shows up as a reviewable diff. Writes to temp and scratchpad directories are allowed.
Option extraAllowed: a regex; Bash commands matching it are never blocked (e.g. ^node scripts/gen-).
/plugin install shell-edit-guard --marketplace daviddecorso/claude-mods
Finds each repo's dev servers (dev* package scripts and wrangler dev, for Vite, Astro, Next, Wrangler and Expo), gives them fixed ports, and runs them as named services. Adds a /dev pane and dev_list / dev_start / dev_stop / dev_restart / dev_logs tools, so the model reads filtered logs instead of backgrounding servers in Bash.
/plugin install dev-servers --marketplace daviddecorso/claude-mods
Hands a plans/<slug>.md plan to agy, the Antigravity CLI (headless with live progress, or copied for agy -i), or to Google's Jules (cloud PR), then starts a plan-vs-diff review when the work lands. When an agy run's review reports findings, they can go back to agy to fix (written to plans/fixes/<slug>.md), and that run gets reviewed too. Ships two skills: plan-handoff writes the plan with a recommended implementer, and plan-diff-review checks the result against it.
Needs agy on PATH for agy handoffs, and a Jules API key for Jules handoffs, stored in the macOS Keychain:
security add-generic-password -s jules-api -a "$USER" -w
(JULES_API_KEY in the environment also works.)
/plugin install handoff --marketplace daviddecorso/claude-mods
A pane for a per-repo prompt queue kept in <repo>/.queue/ (shared across worktrees): pick queued prompts, batch them into the prompt box, queue drafts, and archive items with the x hotkey or /aq done <seq…>. Also gives the model an aq_add tool to queue follow-ups. Pairs with the aq CLI from zsh-utils, which reads and writes the same queue.
/plugin install aq --marketplace daviddecorso/claude-mods
Run a mod from a working copy with claude --plugin-dir ./<mod>. Check it with claude plugin validate ./<mod> and claude plugin test ./<mod>.
Each mod's tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code generates the first time it loads the mod. On a fresh clone, run claude --plugin-dir ./<mod> once before type-checking.
MIT
hooks/register.tsx 555 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, Timer } from 'claude-code'
3
4import type { ServiceView } from '../types'
5import { bashIntent } from './bash'
6import {
7 allocatePort,
8 detectPackageManager,
9 discover,
10 planScript,
11 portArgs,
12 portRange,
13 workspaceGlobs,
14 type PackageInfo,
15 type PackageManager,
16 type Service,
17} from './discover'
18import { LogBuffer, findUrl, query } from './logs'
19
20type $ = EngineInterface
21
22type Running = {
23 stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
24 pid?: number
25 isStopping: boolean
26}
27
28type ToolArgs = Record<string, unknown>
29
30const PANE = 'dev-servers'
31const PID_MARK = '__DEVPID '
32const START_WAIT_MS = 20_000
33const PANE_TAIL = 40
34const RUNNERS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'node', 'deno', 'tsx', 'npx', 'bunx', 'sh', 'bash'])
35const STATUS_COLOR = { running: 'success', starting: 'warning', crashed: 'error', blocked: 'error', stopped: 'inactive' } as const
36
37const services = atom({ plugin: 'dev-servers', key: 'services' } as const, [] as ServiceView[])
38const selected = atom({ plugin: 'dev-servers', key: 'selected' } as const, '')
39const tail = atom({ plugin: 'dev-servers', key: 'tail' } as const, [] as string[])
40
41// Process handles and log buffers live only in this module: a hot reload of the mod kills its servers.
42let root = ''
43let pm: PackageManager = 'npm'
44const defs = new Map<string, Service>()
45const logs = new Map<string, LogBuffer>()
46const running = new Map<string, Running>()
47let isTailDirty = false
48let tick: Timer | undefined
49
50// Appends one line to ~/.claude/mod-logs/dev-servers.jsonl, which /mods-report reads; never fails the hook.
51async function logEvent($: $, event: Record<string, unknown>) {
52 try {
53 const home = await $.env.get('HOME')
54 const line = JSON.stringify({ at: new Date().toISOString(), session: await $.session.id(), root, ...event })
55 await $.process.run(
56 ['/bin/sh', '-c', 'mkdir -p "$(dirname "$1")" && cat >> "$1"', 'sh', `${home}/.claude/mod-logs/dev-servers.jsonl`],
57 { stdin: `${line}\n` },
58 )
59 } catch {
60 // Logging is best effort.
61 }
62}
63
64function text(body: string, isError = false) {
65 return { result: { content: [{ type: 'text' as const, text: body }], isError } }
66}
67
68function names() {
69 return [...defs.keys()].join(', ')
70}
71
72function describe(service: ServiceView | undefined): string {
73 if (!service) return 'Unknown service.'
74 const parts = [`${service.name}: ${service.status}`]
75 if (service.url) parts.push(service.url)
76 else if (service.port !== null) parts.push(`port ${service.port}`)
77 if (service.pid !== null) parts.push(`pid ${service.pid}`)
78 if (service.note) parts.push(`(${service.note})`)
79
80 return parts.join(' · ')
81}
82
83async function pause($: $, ms: number) {
84 await $.process.run(['sleep', String(ms / 1000)])
85}
86
87async function view($: $, name: string) {
88 return (await read($, services)).find(s => s.name === name)
89}
90
91async function setView($: $, name: string, patch: Partial<ServiceView>) {
92 await update($, services, list => list.map(s => (s.name === name ? { ...s, ...patch } : s)))
93}
94
95async function readJson($: $, path: string): Promise<unknown> {
96 try {
97 return JSON.parse(String(await $.fs.read(path)))
98 } catch {
99 return undefined
100 }
101}
102
103async function packageAt($: $, dir: string): Promise<PackageInfo | undefined> {
104 const abs = dir ? `${root}/${dir}` : root
105 const json = (await readJson($, `${abs}/package.json`)) as { scripts?: Record<string, string> } | undefined
106 if (!json) return undefined
107 const hasWrangler =
108 (await $.fs.exists(`${abs}/wrangler.jsonc`)) ||
109 (await $.fs.exists(`${abs}/wrangler.json`)) ||
110 (await $.fs.exists(`${abs}/wrangler.toml`))
111
112 return { dir, scripts: json.scripts ?? {}, hasWrangler }
113}
114
115async function listDirs($: $, path: string): Promise<string[]> {
116 try {
117 return (await $.fs.list(path)).filter(entry => entry.kind === 'dir').map(entry => entry.name)
118 } catch {
119 return []
120 }
121}
122
123async function scan($: $) {
124 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: await $.session.cwd() })
125 root = top.exitCode === 0 ? top.stdout.trim() : ''
126 defs.clear()
127 if (!root) {
128 await update($, services, () => [])
129 return
130 }
131
132 const entries = await $.fs.list(root)
133 pm = detectPackageManager(new Set(entries.map(entry => entry.name)))
134 const rootJson = await readJson($, `${root}/package.json`)
135 const yaml = entries.some(entry => entry.name === 'pnpm-workspace.yaml')
136 ? String(await $.fs.read(`${root}/pnpm-workspace.yaml`))
137 : undefined
138
139 const dirs = new Set([''])
140 for (const glob of workspaceGlobs(rootJson, yaml)) {
141 const clean = glob.replace(/\/+$/, '')
142 if (clean.endsWith('/*')) {
143 const parent = clean.slice(0, -2)
144 for (const child of await listDirs($, `${root}/${parent}`)) dirs.add(`${parent}/${child}`)
145 } else if (!clean.includes('*')) {
146 dirs.add(clean)
147 }
148 }
149
150 const packages: PackageInfo[] = []
151 for (const dir of dirs) {
152 const pkg = await packageAt($, dir)
153 if (pkg) packages.push(pkg)
154 }
155
156 const custom = ((await $.store.get(`custom:${root}`)) ?? []) as Service[]
157 for (const service of [...discover(packages, pm), ...custom]) defs.set(service.name, service)
158
159 const ports = ((await $.store.get('ports')) ?? {}) as Record<string, number>
160 await update($, services, previous =>
161 [...defs.values()].map(
162 service =>
163 previous.find(s => s.name === service.name && running.has(s.name)) ?? {
164 name: service.name,
165 status: 'stopped',
166 port: ports[`${root}::${service.name}`] ?? null,
167 url: null,
168 pid: null,
169 note: null,
170 },
171 ),
172 )
173 if (!defs.has(await read($, selected))) await update($, selected, () => defs.keys().next().value ?? '')
174}
175
176async function portFor($: $, service: Service): Promise<number | undefined> {
177 if (!portRange(service.kind)) return undefined
178 const ports = ((await $.store.get('ports')) ?? {}) as Record<string, number>
179 const key = `${root}::${service.name}`
180 if (ports[key] !== undefined) return ports[key]
181 const port = allocatePort(service.kind, new Set(Object.values(ports)))
182 if (port !== undefined) await $.store.set('ports', { ...ports, [key]: port })
183
184 return port
185}
186
187async function listener($: $, port: number) {
188 const found = await $.process.run(['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'])
189 const pid = /^p(\d+)/m.exec(found.stdout)?.[1]
190
191 return pid ? { pid: Number(pid), command: /^c(.*)$/m.exec(found.stdout)?.[1] ?? '?' } : undefined
192}
193
194async function processTree($: $, pid: number): Promise<number[]> {
195 const children = await $.process.run(['pgrep', '-P', String(pid)])
196 const all = [pid]
197 for (const child of children.stdout.split('\n').filter(Boolean)) all.push(...(await processTree($, Number(child))))
198
199 return all
200}
201
202async function alive($: $, pids: string[]): Promise<string[]> {
203 const still: string[] = []
204 for (const pid of pids) if ((await $.process.run(['kill', '-0', pid])).exitCode === 0) still.push(pid)
205
206 return still
207}
208
209async function killTree($: $, pid: number, shouldWait: boolean) {
210 const pids = (await processTree($, pid)).map(String)
211 await $.process.run(['kill', '-TERM', ...pids])
212 if (!shouldWait) return
213 for (let i = 0; i < 10; i++) {
214 if ((await alive($, pids)).length === 0) return
215 await pause($, 300)
216 }
217 const stuck = await alive($, pids)
218 if (stuck.length > 0) await $.process.run(['kill', '-KILL', ...stuck])
219}
220
221async function resolveArgv($: $, service: Service): Promise<string[]> {
222 const bin = service.argv[0]!
223 if (RUNNERS.has(bin) || bin.includes('/')) return service.argv
224 for (const dir of service.dir ? [`${root}/${service.dir}`, root] : [root]) {
225 const path = `${dir}/node_modules/.bin/${bin}`
226 if (await $.fs.exists(path)) return [path, ...service.argv.slice(1)]
227 }
228
229 return service.script ? [pm, 'run', service.script] : ['npx', ...service.argv]
230}
231
232async function pump($: $, name: string, entry: Running) {
233 const buffer = logs.get(name)!
234 let ended: ProcessSpawnResult | undefined
235 try {
236 while (true) {
237 const step = await entry.stream.next()
238 if (step.done) {
239 ended = step.value
240 break
241 }
242 for (const line of buffer.push(step.value.text)) {
243 if (line.startsWith(PID_MARK) && entry.pid === undefined) {
244 entry.pid = Number(line.slice(PID_MARK.length))
245 buffer.lines = buffer.lines.filter(l => !l.startsWith(PID_MARK))
246 await setView($, name, { pid: entry.pid })
247 continue
248 }
249 const url = findUrl(line)
250 if (url && !(await view($, name))?.url) await setView($, name, { status: 'running', url })
251 }
252 if (name === (await read($, selected))) isTailDirty = true
253 }
254 } catch (error) {
255 buffer.push(`[dev-servers] ${String(error)}\n`)
256 }
257
258 running.delete(name)
259 const isClean = entry.isStopping || ended?.code === 0
260 if (!isClean) void logEvent($, { event: 'crash', service: name, code: ended?.code ?? null, signal: ended?.signal ?? null })
261 await setView($, name, {
262 status: isClean ? 'stopped' : 'crashed',
263 pid: null,
264 url: null,
265 note: isClean ? null : `exited with ${ended?.code ?? ended?.signal ?? 'an error'}`,
266 })
267 isTailDirty = true
268}
269
270async function start($: $, name: string): Promise<string> {
271 const service = defs.get(name)
272 if (!service) return `No service named "${name}". Services: ${names()}.`
273 if (running.has(name)) return describe(await view($, name))
274
275 const port = await portFor($, service)
276 if (port !== undefined) {
277 const holder = await listener($, port)
278 if (holder) {
279 const note = `port ${port} held by pid ${holder.pid} (${holder.command})`
280 void logEvent($, { event: 'collision', service: name, port, holder: holder.command })
281 await setView($, name, { status: 'blocked', port, note })
282 return `${name} not started: ${note}, which dev-servers did not start. Ask the user whether that is their own server; do not kill it.`
283 }
284 }
285
286 const argv = [...(await resolveArgv($, service)), ...(port !== undefined ? portArgs(service.kind, port) : [])]
287 const buffer = logs.get(name) ?? new LogBuffer()
288 buffer.clear()
289 logs.set(name, buffer)
290 buffer.push(`$ ${argv.join(' ')}\n`)
291
292 // The shell prints its own pid, then exec keeps it, so the pid is the server's and its tree can be stopped exactly.
293 const stream = $.process.spawn({
294 argv: ['/bin/sh', '-c', `echo "${PID_MARK}$$"; exec "$@"`, 'sh', ...argv],
295 cwd: service.dir ? `${root}/${service.dir}` : root,
296 env: { BROWSER: 'none', NO_COLOR: '1', FORCE_COLOR: '0', ...service.env },
297 })
298 const entry: Running = { stream, isStopping: false }
299 running.set(name, entry)
300 await setView($, name, { status: 'starting', port: port ?? null, url: null, pid: null, note: null })
301 void pump($, name, entry)
302
303 for (let waited = 0; waited < START_WAIT_MS; waited += 500) {
304 const current = await view($, name)
305 if (current?.status !== 'starting') {
306 void logEvent($, { event: 'started', service: name, kind: service.kind, port: port ?? null, status: current?.status, waitedMs: waited })
307 return describe(current)
308 }
309 await pause($, 500)
310 }
311 await setView($, name, { status: 'running', note: 'no URL in its output yet' })
312 void logEvent($, { event: 'started', service: name, kind: service.kind, port: port ?? null, status: 'no-url', waitedMs: START_WAIT_MS })
313
314 return describe(await view($, name))
315}
316
317async function stop($: $, name: string): Promise<string> {
318 const entry = running.get(name)
319 if (!entry) return `${name} is not running.`
320 entry.isStopping = true
321 if (entry.pid !== undefined) await killTree($, entry.pid, true)
322 else await entry.stream.return(undefined as never)
323 for (let i = 0; i < 20 && running.has(name); i++) await pause($, 200)
324
325 return `${name} stopped.`
326}
327
328async function restart($: $, name: string): Promise<string> {
329 if (running.has(name)) await stop($, name)
330 return start($, name)
331}
332
333async function list($: $): Promise<string> {
334 const all = await read($, services)
335 if (all.length === 0) return 'No dev servers found: not in a git repo, or no dev scripts or wrangler configs.'
336 return all.map(describe).join('\n')
337}
338
339async function logsFor($: $, args: ToolArgs): Promise<string> {
340 const name = String(args.name ?? '')
341 if (!defs.has(name)) return `No service named "${name}". Services: ${names()}.`
342 const buffer = logs.get(name)
343 if (!buffer || buffer.lines.length === 0) return `${name} has no output yet.`
344 const found = query(buffer.lines, {
345 tail: typeof args.tail === 'number' ? args.tail : 50,
346 grep: typeof args.grep === 'string' ? args.grep : undefined,
347 level: args.level === 'error' ? 'error' : 'all',
348 })
349 const header = `${describe(await view($, name))}\n${found.lines.length} of ${found.matched} matching lines since last start:`
350
351 return `${header}\n${found.lines.join('\n')}`
352}
353
354async function selectService($: $, name: string) {
355 await update($, selected, () => name)
356 await update($, tail, () => logs.get(name)?.lines.slice(-PANE_TAIL) ?? [])
357}
358
359async function refreshTail($: $) {
360 const lines = logs.get(await read($, selected))?.lines ?? []
361 await update($, tail, () => lines.slice(-PANE_TAIL))
362}
363
364async function stopAll($: $) {
365 for (const entry of running.values()) {
366 entry.isStopping = true
367 if (entry.pid !== undefined) await killTree($, entry.pid, false)
368 }
369}
370
371async function addCustom($: $, name: string, dir: string, command: string): Promise<string> {
372 const service = { ...planScript(dir === '.' ? '' : dir, name, command, pm), name }
373 const key = `custom:${root}`
374 const custom = ((await $.store.get(key)) ?? []) as Service[]
375 await $.store.set(key, [...custom.filter(s => s.name !== name), service])
376 await scan($)
377
378 return `Added ${name} (${service.kind}) in ${dir}.`
379}
380
381async function registerTools($: $) {
382 const nameProp = { name: { type: 'string', description: 'Service name, as dev_list shows it' } }
383 const nameSchema = { type: 'object', properties: nameProp, required: ['name'] }
384
385 await $.tool.register({
386 name: 'dev_list',
387 description:
388 "Lists this repo's dev servers (named services, e.g. 'dev', 'dev:nfl', 'apps/api:wrangler') with status, fixed port, URL and pid. Use instead of starting a dev server through Bash or looking for one with lsof/ps.",
389 })
390 await $.tool.register({
391 name: 'dev_start',
392 description:
393 'Starts a dev server on its fixed port and waits until it prints its URL (up to 20s). Never kills anything: if the port is taken it reports who holds it.',
394 inputSchema: nameSchema,
395 })
396 await $.tool.register({
397 name: 'dev_stop',
398 description: 'Stops a dev server this tool started (its whole process tree). It cannot stop servers the user started.',
399 inputSchema: nameSchema,
400 })
401 await $.tool.register({
402 name: 'dev_restart',
403 description: 'Restarts a dev server (starts it if it is not running).',
404 inputSchema: nameSchema,
405 })
406 await $.tool.register({
407 name: 'dev_logs',
408 description:
409 "Reads a dev server's output since its last start, ANSI and QR codes stripped. Keep it small: tail defaults to 50 lines (max 200); grep filters by a case-insensitive regex; level 'error' keeps error-looking lines.",
410 inputSchema: {
411 type: 'object',
412 properties: {
413 ...nameProp,
414 tail: { type: 'number', description: 'Lines from the end (default 50, max 200)' },
415 grep: { type: 'string', description: 'Case-insensitive regex to keep matching lines' },
416 level: { type: 'string', enum: ['all', 'error'] },
417 },
418 required: ['name'],
419 },
420 })
421}
422
423function bashDenial(command: string): string | undefined {
424 if (defs.size === 0) return undefined
425 const intent = bashIntent(command)
426 if (intent.kind === 'none') return undefined
427
428 const all = [...defs.keys()]
429 if (intent.kind === 'kill') {
430 return `dev-servers: don't kill dev servers from Bash (${intent.pattern}); it can take down the user's own server. Use mcp__dev-servers__dev_stop or dev_restart for the ones dev-servers runs (${all.join(', ')}). If something else holds a port, tell the user.`
431 }
432
433 const wanted = intent.script ?? intent.tool ?? ''
434 const matches = all.filter(name => name === wanted || name.endsWith(`:${wanted}`) || defs.get(name)!.kind === wanted)
435 const suggestion = matches.length > 0 ? matches : all
436
437 return `dev-servers runs this repo's dev servers on fixed ports. Use mcp__dev-servers__dev_start { name: "${suggestion[0]}" } instead${
438 suggestion.length > 1 ? ` (services: ${suggestion.join(', ')})` : ''
439 }, then dev_logs { name, tail, grep } to read its output.`
440}
441
442export const register: Register = on => {
443 on('session.start', async ($, e, next) => {
444 await scan($)
445 await $.command.register({
446 name: 'dev',
447 description: 'Dev servers: open the pane, or start/stop/restart <name>, rescan, add <name> <dir> <command>',
448 argumentHint: '[start|stop|restart <name> | rescan | add <name> <dir> <command>]',
449 })
450 await registerTools($)
451
452 tick?.cancel()
453 tick = $.clock.every(500, () => {
454 if (!isTailDirty) return
455 isTailDirty = false
456 void refreshTail($)
457 })
458
459 return next(e)
460 })
461
462 on('session.end', async ($, e, next) => {
463 await stopAll($)
464 return next(e)
465 })
466
467 on('tool.call', { tool: 'mcp__dev-servers__dev_list' }, async $ => {
468 void logEvent($, { event: 'tool', tool: 'dev_list' })
469 return text(await list($))
470 }).catch(() => text('dev_list failed', true))
471 on('tool.call', { tool: 'mcp__dev-servers__dev_start' }, async ($, e) => {
472 void logEvent($, { event: 'tool', tool: 'dev_start', service: e.name })
473 return text(await start($, String(e.name ?? '')))
474 }).catch(() => text('dev_start failed', true))
475 on('tool.call', { tool: 'mcp__dev-servers__dev_stop' }, async ($, e) => {
476 void logEvent($, { event: 'tool', tool: 'dev_stop', service: e.name })
477 return text(await stop($, String(e.name ?? '')))
478 }).catch(() => text('dev_stop failed', true))
479 on('tool.call', { tool: 'mcp__dev-servers__dev_restart' }, async ($, e) => {
480 void logEvent($, { event: 'tool', tool: 'dev_restart', service: e.name })
481 return text(await restart($, String(e.name ?? '')))
482 }).catch(() => text('dev_restart failed', true))
483 on('tool.call', { tool: 'mcp__dev-servers__dev_logs' }, async ($, e) => {
484 void logEvent($, { event: 'tool', tool: 'dev_logs', service: e.name, tail: e.tail, grep: e.grep, level: e.level })
485 return text(await logsFor($, e))
486 }).catch(() => text('dev_logs failed', true))
487
488 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
489 const deny = bashDenial(e.command)
490 if (deny === undefined) return next(e)
491 void logEvent($, { event: 'bash-block', kind: bashIntent(e.command).kind, command: e.command.slice(0, 400) })
492 return { deny }
493 }).catch(($, e, next) => next(e))
494
495 on('command.run', { command: 'dev' }, async ($, e) => {
496 const [verb = '', name = '', dir = '', ...command] = e.args.trim().split(/\s+/)
497
498 if (verb === 'rescan') {
499 await scan($)
500 return { text: `Found ${defs.size} services: ${names()}` }
501 }
502 if (verb === 'add' && name && dir && command.length > 0) return { text: await addCustom($, name, dir, command.join(' ')) }
503 if (verb === 'start') return { text: await start($, name) }
504 if (verb === 'stop') return { text: await stop($, name) }
505 if (verb === 'restart') return { text: await restart($, name) }
506
507 await $.ui.open({ id: PANE, title: 'Dev servers' })
508 return { text: defs.size > 0 ? 'Dev servers pane opened.' : 'No dev servers found in this repo.' }
509 })
510
511 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
512 const { Box, Text, Button } = $.ui.resolve(e)
513 const all = await read($, services)
514 const current = await read($, selected)
515 const lines = await read($, tail)
516 const room = Math.max(3, (e.viewport?.rows ?? 30) - all.length - 6)
517
518 if (all.length === 0) {
519 return <Text dimColor>No dev servers found. Run /dev rescan from inside a repo.</Text>
520 }
521
522 return (
523 <Box flexDirection="column">
524 {all.map(service => (
525 <Box key={`row-${service.name}`}>
526 <Button
527 key={`pick-${service.name}`}
528 plain
529 label={`${service.name === current ? '▸' : ' '} ${service.name}`}
530 onPress={() => selectService($, service.name)}
531 />
532 <Text color={STATUS_COLOR[service.status]}> {service.status} </Text>
533 <Text dimColor>{service.url ?? (service.port !== null ? `:${service.port}` : '')} </Text>
534 {service.status === 'running' || service.status === 'starting' ? (
535 <Box>
536 <Button key={`restart-${service.name}`} label="Restart" onPress={() => void restart($, service.name)} />
537 <Button key={`stop-${service.name}`} label="Stop" onPress={() => void stop($, service.name)} />
538 </Box>
539 ) : (
540 <Button key={`start-${service.name}`} label="Start" onPress={() => void start($, service.name)} />
541 )}
542 {service.note ? <Text color="warning"> {service.note}</Text> : null}
543 </Box>
544 ))}
545 <Text dimColor>── {current} ──</Text>
546 {lines.slice(-room).map((line, i) => (
547 <Text key={`log-${i}`} wrap="truncate-end">
548 {line}
549 </Text>
550 ))}
551 </Box>
552 )
553 })
554}
555hooks/bash.ts 32 lines1export type BashIntent =
2 | { kind: 'none' }
3 | { kind: 'start'; script?: string; tool?: string }
4 | { kind: 'kill'; pattern: string }
5
6const PM_DEV = /(?:^|[\s;&|(])(?:[A-Z_][A-Z0-9_]*=\S*\s+)*(?:npm|pnpm|yarn|bun)\s+(?:--filter\s+\S+\s+|-C\s+\S+\s+)?(?:run\s+)?(dev(?:[:-][\w:-]+)?)(?=[\s;&|)]|$)/
7const TOOL_DEV = /(?:^|[\s;&|(]|\/)(?:npx\s+|pnpm\s+(?:exec|dlx)\s+|bunx\s+|yarn\s+)?(vite(?=\s*(?:$|[;&|)]|\s--|\s(?:dev|serve)\b))|astro\s+dev|next\s+dev|wrangler\s+dev|expo\s+start)\b/
8const KILL = /\b(pkill|killall)\b([^;&|\n]*)/
9const KILL_BY_PORT = /\blsof\b[^;&\n]*-t[^;&\n]*\|\s*xargs\s+kill|\bkill\b[^;&\n]*\$\(\s*lsof\b/
10const DEV_PROCESS = /\b(vite|wrangler|workerd|expo|metro|astro|next|node|esbuild)\b|:\d{4}/
11
12const NOT_A_SERVER = /\b(kill|stop|clean|reset|setup|seed|migrate)\b/
13
14const HEREDOC = /<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\n|$)/g
15const QUOTED = /'[^']*'|"(?:[^"\\]|\\.)*"/g
16
17export function bashIntent(command: string): BashIntent {
18 const shell = command.replace(HEREDOC, '')
19 const kill = KILL.exec(shell)
20 if (kill && DEV_PROCESS.test(kill[2]!)) return { kind: 'kill', pattern: kill[0].trim() }
21 if (KILL_BY_PORT.test(shell)) return { kind: 'kill', pattern: 'kill by port' }
22
23 const bare = shell.replace(QUOTED, "''")
24 const pm = PM_DEV.exec(bare)
25 if (pm && !NOT_A_SERVER.test(pm[1]!)) return { kind: 'start', script: pm[1] }
26
27 const tool = TOOL_DEV.exec(bare)
28 if (tool) return { kind: 'start', tool: tool[1]!.split(/\s+/)[0] }
29
30 return { kind: 'none' }
31}
32hooks/discover.ts 133 lines1export type Kind = 'vite' | 'astro' | 'next' | 'wrangler' | 'expo' | 'other'
2
3export type Service = {
4 name: string
5 dir: string
6 // The package script behind it, for the package-manager fallback; absent for a bare `wrangler dev`.
7 script?: string
8 argv: string[]
9 env: Record<string, string>
10 kind: Kind
11}
12
13export type PackageInfo = {
14 // Relative to the repo root; '' for the root package.
15 dir: string
16 scripts: Record<string, string>
17 hasWrangler: boolean
18}
19
20export type PackageManager = 'pnpm' | 'yarn' | 'npm' | 'bun'
21
22const PORT_RANGES: Record<Kind, [number, number] | undefined> = {
23 vite: [5180, 5299],
24 astro: [5180, 5299],
25 next: [5180, 5299],
26 wrangler: [8790, 8899],
27 expo: [8181, 8199],
28 other: undefined,
29}
30
31const DEV_SCRIPT = /^dev([:-][\w:-]+)?$/
32const NOT_A_SERVER = /\b(kill|stop|clean|reset|setup|seed|migrate)\b/
33
34const SHELL_SYNTAX = /[;&|<>`$()]|\b(cd|export)\s/
35
36function kindOf(words: string[]): Kind {
37 const bin = words[0]
38 if (bin === 'vite' && (words.length === 1 || ['dev', 'serve', '--config', '-c', '--host', '--mode'].includes(words[1]!))) return 'vite'
39 if (bin === 'astro' && words[1] === 'dev') return 'astro'
40 if (bin === 'next' && words[1] === 'dev') return 'next'
41 if (bin === 'wrangler' && words[1] === 'dev') return 'wrangler'
42 if (bin === 'expo' && words[1] === 'start') return 'expo'
43 return 'other'
44}
45
46function serviceName(dir: string, script: string): string {
47 return dir === '' ? script : `${dir}:${script}`
48}
49
50// How one script runs: its own binary when the script is a plain command, so the mod owns the server's pid; otherwise through the package manager.
51export function planScript(
52 dir: string,
53 script: string,
54 body: string,
55 pm: PackageManager,
56): Service {
57 const env: Record<string, string> = {}
58 const words = body.trim().split(/\s+/)
59 while (words.length > 0 && /^[A-Z_][A-Z0-9_]*=\S*$/.test(words[0]!)) {
60 const [key, ...rest] = words.shift()!.split('=')
61 env[key!] = rest.join('=')
62 }
63
64 const name = serviceName(dir, script)
65 if (SHELL_SYNTAX.test(words.join(' ')) || words.length === 0) {
66 return { name, dir, script, argv: [pm, 'run', script], env: {}, kind: 'other' }
67 }
68
69 const kind = kindOf(words)
70 if (kind === 'expo') env.CI = '1'
71 return { name, dir, script, argv: words, env, kind }
72}
73
74export function discover(packages: PackageInfo[], pm: PackageManager): Service[] {
75 const services: Service[] = []
76
77 for (const pkg of packages) {
78 let hasWranglerScript = false
79 for (const [script, body] of Object.entries(pkg.scripts)) {
80 if (!DEV_SCRIPT.test(script) || NOT_A_SERVER.test(script) || NOT_A_SERVER.test(body)) continue
81 if (/\bwrangler\s+dev\b/.test(body)) hasWranglerScript = true
82 services.push(planScript(pkg.dir, script, body, pm))
83 }
84 if (pkg.hasWrangler && !hasWranglerScript) {
85 services.push({ name: serviceName(pkg.dir, 'wrangler'), dir: pkg.dir, argv: ['wrangler', 'dev'], env: {}, kind: 'wrangler' })
86 }
87 }
88
89 return services
90}
91
92export function portRange(kind: Kind): [number, number] | undefined {
93 return PORT_RANGES[kind]
94}
95
96export function portArgs(kind: Kind, port: number): string[] {
97 const p = String(port)
98 if (kind === 'vite') return ['--port', p, '--strictPort']
99 if (kind === 'next') return ['-p', p]
100 if (kind === 'astro' || kind === 'wrangler' || kind === 'expo') return ['--port', p]
101 return []
102}
103
104export function allocatePort(kind: Kind, taken: ReadonlySet<number>): number | undefined {
105 const range = PORT_RANGES[kind]
106 if (!range) return undefined
107 for (let port = range[0]; port <= range[1]; port++) {
108 if (!taken.has(port)) return port
109 }
110
111 return undefined
112}
113
114export function detectPackageManager(lockfiles: ReadonlySet<string>): PackageManager {
115 if (lockfiles.has('pnpm-lock.yaml')) return 'pnpm'
116 if (lockfiles.has('bun.lockb') || lockfiles.has('bun.lock')) return 'bun'
117 if (lockfiles.has('yarn.lock')) return 'yarn'
118 return 'npm'
119}
120
121// Workspace globs as pnpm-workspace.yaml and package.json write them; only `dir/*` and plain dirs are expanded.
122export function workspaceGlobs(rootPackageJson: unknown, pnpmWorkspaceYaml: string | undefined): string[] {
123 const globs: string[] = []
124 if (pnpmWorkspaceYaml) {
125 for (const match of pnpmWorkspaceYaml.matchAll(/^\s*-\s*['"]?([^'"\n#]+?)['"]?\s*$/gm)) globs.push(match[1]!)
126 }
127 const workspaces = (rootPackageJson as { workspaces?: unknown } | undefined)?.workspaces
128 const list = Array.isArray(workspaces) ? workspaces : (workspaces as { packages?: unknown } | undefined)?.packages
129 if (Array.isArray(list)) globs.push(...list.filter((g): g is string => typeof g === 'string'))
130
131 return globs.filter(g => !g.startsWith('!'))
132}
133hooks/logs.ts 56 lines1const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*\u0007/g
2const QR_LINE = /[█▀▄▌▐]{6,}/
3const ERROR_LINE = /\b(error|errors|exception|failed|failure|unhandled|uncaught|cannot|ERR!|ERR_)\b|✘|✖|\[ERROR\]/i
4const URL = /\bhttps?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):\d+[^\s'"]*/
5
6export const MAX_LINES = 2000
7export const MAX_TOOL_LINES = 200
8
9export class LogBuffer {
10 lines: string[] = []
11 private partial = ''
12
13 push(text: string): string[] {
14 const pieces = (this.partial + text.replace(ANSI, '').replace(/\r(?!\n)/g, '\n')).split(/\r?\n/)
15 this.partial = pieces.pop() ?? ''
16 const added = pieces.filter(line => !QR_LINE.test(line))
17 this.lines.push(...added)
18 if (this.lines.length > MAX_LINES) this.lines.splice(0, this.lines.length - MAX_LINES)
19
20 return added
21 }
22
23 clear() {
24 this.lines = []
25 this.partial = ''
26 }
27}
28
29export type LogQuery = {
30 tail?: number
31 grep?: string
32 level?: 'error' | 'all'
33}
34
35export function query(lines: readonly string[], { tail = 50, grep, level = 'all' }: LogQuery): { lines: string[]; matched: number } {
36 let pattern: RegExp | undefined
37 if (grep) {
38 try {
39 pattern = new RegExp(grep, 'i')
40 } catch {
41 pattern = new RegExp(grep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
42 }
43 }
44
45 const matching = lines.filter(
46 line => line.trim() !== '' && (level !== 'error' || ERROR_LINE.test(line)) && (!pattern || pattern.test(line)),
47 )
48 const count = Math.max(1, Math.min(MAX_TOOL_LINES, Math.floor(tail)))
49
50 return { lines: matching.slice(-count), matched: matching.length }
51}
52
53export function findUrl(line: string): string | undefined {
54 return URL.exec(line)?.[0]?.replace(/\/$/, '')
55}
56types/index.d.ts 21 lines1export type ServiceStatus = 'stopped' | 'starting' | 'running' | 'crashed' | 'blocked'
2
3export type ServiceView = {
4 name: string
5 status: ServiceStatus
6 port: number | null
7 url: string | null
8 pid: number | null
9 note: string | null
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'dev-servers': {
15 services: ServiceView[]
16 selected: string
17 tail: string[]
18 }
19 }
20}
21