A Servers pane for this project's dev servers: what is listening, on which port, since when, with Start, Stop, Restart and Log buttons; names the process…

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|done|all> removes jobs.tail, checks processes with ps, and runs df.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.
Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./busy.When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.
/busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast./second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits.
Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.gh pr checks and the tail of the failed log attached, so you don't paste it./prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo.~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed./routine shows the routine's name, how long it has run, its waits, and the settings.A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.
/second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters./second-opinion commits 5/second-opinion diff (uncommitted changes)/second-opinion file docs/ADR-007.md/second-opinion <question>: adds a question for Fable to answer first.second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message./orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./secrets test <text> output is masked too./secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.Settings:
closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.fullSpectrumPattern (the regex above)checkContents (on)The quit request goes out when Claude issues the command, before any permission prompt for it.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.
claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)hooks/register.tsx 611 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunInit, ProcessRunResult, Register } from 'claude-code'
3
4import type { Holder, KnownEntry, Server, Snapshot } from '../types'
5import {
6 basename,
7 describe,
8 detectManager,
9 expandHome,
10 findConflict,
11 holderNote,
12 isInside,
13 knownEntries,
14 lastLines,
15 LOCKFILES,
16 logPath,
17 matchServers,
18 packageScripts,
19 parseCwds,
20 parseListeners,
21 parsePortTable,
22 parseProcesses,
23 portFromCommand,
24 portList,
25 serverLine,
26 startScript,
27 statusLine,
28 tokenize,
29} from './servers'
30
31type Engine = EngineInterface
32
33const PANE = 'dev-servers'
34const TITLE = 'Servers'
35/** How often a wait (a stop, a free port, a start) looks again. */
36const STEP_MS = 500
37/** How long a start waits for its port before saying it is not up. */
38const START_WAIT_MS = 10_000
39/** How long a restart waits for the stopped server's ports to be free. */
40const FREE_WAIT_MS = 10_000
41/** How long after Stop a second press force stops (SIGKILL). */
42const FORCE_WINDOW_MS = 10_000
43/** How long a force stop waits for the process to go. */
44const KILL_WAIT_MS = 2_000
45/** Pane widths below this draw a row's buttons on a line of their own. */
46const STACK_BELOW = 64
47
48const snapshot = atom({ plugin: 'dev-servers', key: 'snapshot' } as const, null)
49const busy = atom({ plugin: 'dev-servers', key: 'busy' } as const, {})
50const armed = atom({ plugin: 'dev-servers', key: 'armed' } as const, {})
51
52type Config = { pollMs: number; logDir: string; graceMs: number }
53
54let config: Config = { pollMs: 15_000, logDir: '~/.claude/dev-servers', graceMs: 5_000 }
55let timer: { cancel: () => void } | null = null
56let inflight: Promise<Snapshot | null> | null = null
57/** The status line last shown; null before the first. */
58let shownStatus: string | undefined | null = null
59let rootFor: { cwd: string; root: string } | null = null
60
61/** A host command's result, or null when it could not start or timed out. */
62async function run($: Engine, argv: readonly string[], init?: ProcessRunInit): Promise<ProcessRunResult | null> {
63 return $.process.run(argv, { timeoutMs: 10_000, ...init }).catch(() => null)
64}
65
66/** The git top level of the session's folder, else the folder itself. */
67async function projectRoot($: Engine): Promise<string> {
68 const cwd = await $.session.cwd()
69 if (rootFor?.cwd === cwd) {
70 return rootFor.root
71 }
72 const out = await run($, ['git', 'rev-parse', '--show-toplevel'], { cwd })
73 const top = out?.exitCode === 0 ? out.stdout.trim() : ''
74 rootFor = { cwd, root: top || cwd }
75 return rootFor.root
76}
77
78async function readText($: Engine, path: string): Promise<string | null> {
79 return $.fs.read(path).catch(() => null)
80}
81
82/** package.json server scripts, .claude/launch.json configurations and Procfile lines. */
83async function readEntries($: Engine, root: string): Promise<KnownEntry[]> {
84 const names = new Set((await $.fs.list(root).catch(() => [])).map(entry => entry.name))
85 const [packageJson, launchJson, procfile] = await Promise.all([
86 names.has('package.json') ? readText($, `${root}/package.json`) : null,
87 names.has('.claude') ? readText($, `${root}/.claude/launch.json`) : null,
88 names.has('Procfile') ? readText($, `${root}/Procfile`) : null,
89 ])
90 const lockfiles = LOCKFILES.map(([file]) => file).filter(file => names.has(file))
91 const manager = detectManager(lockfiles, packageJson === null ? undefined : packageScripts(packageJson)?.packageManager)
92 return knownEntries({ packageJson, launchJson, procfile, manager })
93}
94
95function showStatus($: Engine, servers: readonly Server[]) {
96 const text = statusLine(servers)
97 if (text !== shownStatus) {
98 shownStatus = text
99 $.ui.status(text)
100 }
101}
102
103/** One scan: every TCP listener, which of them run inside the project, and the known entries. */
104async function collect($: Engine): Promise<Snapshot | null> {
105 const root = await projectRoot($)
106 const entries = await readEntries($, root)
107 const listed = await run($, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpcn'])
108 if (listed === null) {
109 return read($, snapshot)
110 }
111 const listeners = parseListeners(listed.stdout)
112 const cwdOut =
113 listeners.length > 0 ? await run($, ['lsof', '-a', '-p', listeners.map(l => l.pid).join(','), '-d', 'cwd', '-Fn']) : null
114 const cwds = parseCwds(cwdOut?.stdout ?? '')
115 const mine = listeners.filter(listener => {
116 const cwd = cwds.get(listener.pid)
117 return cwd !== undefined && isInside(cwd, root)
118 })
119 const psOut =
120 mine.length > 0 ? await run($, ['ps', '-o', 'pid=,etime=,command=', '-p', mine.map(l => l.pid).join(',')]) : null
121 const processes = parseProcesses(psOut?.stdout ?? '')
122 const servers: Server[] = mine.map(listener => {
123 const proc = processes.get(listener.pid)
124 const commandLine = proc?.commandLine ?? listener.command
125 return {
126 pid: listener.pid,
127 name: basename(tokenize(commandLine)[0] ?? '') || listener.command,
128 entryId: null,
129 ports: listener.ports,
130 cwd: cwds.get(listener.pid) ?? root,
131 upSeconds: proc?.upSeconds ?? null,
132 commandLine,
133 }
134 })
135 const matched = matchServers(servers, entries)
136 const fresh: Snapshot = {
137 root,
138 servers: matched.servers,
139 entries,
140 stopped: matched.stopped,
141 others: listeners.length - mine.length,
142 at: await $.clock.now(),
143 }
144 await update($, snapshot, () => fresh)
145 showStatus($, fresh.servers)
146 return fresh
147}
148
149/** Scans now; a scan already under way is shared, not repeated. */
150function refresh($: Engine): Promise<Snapshot | null> {
151 if (!inflight) {
152 inflight = collect($)
153 .catch(() => null)
154 .finally(() => {
155 inflight = null
156 })
157 }
158 return inflight
159}
160
161function ensureTimer($: Engine) {
162 if (!timer) {
163 timer = $.clock.every(config.pollMs, () => void refresh($))
164 }
165}
166
167/** The first process listening on `port`, from `lsof -nP -iTCP:<port> -sTCP:LISTEN`. */
168async function listenerOn($: Engine, port: number): Promise<{ pid: number; command: string } | null> {
169 const out = await run($, ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN'])
170 return out === null ? null : (parsePortTable(out.stdout)[0] ?? null)
171}
172
173/** Who holds `port`: its process, how long it has run and in which folder. */
174async function holderOf($: Engine, port: number): Promise<Holder | null> {
175 const first = await listenerOn($, port)
176 if (first === null) {
177 return null
178 }
179 const [cwdOut, psOut] = await Promise.all([
180 run($, ['lsof', '-a', '-p', String(first.pid), '-d', 'cwd', '-Fn']),
181 run($, ['ps', '-o', 'pid=,etime=,command=', '-p', String(first.pid)]),
182 ])
183 return {
184 pid: first.pid,
185 command: first.command,
186 upSeconds: parseProcesses(psOut?.stdout ?? '').get(first.pid)?.upSeconds ?? null,
187 cwd: parseCwds(cwdOut?.stdout ?? '').get(first.pid) ?? null,
188 }
189}
190
191async function portsFree($: Engine, ports: readonly number[]): Promise<boolean> {
192 for (const port of ports) {
193 if ((await listenerOn($, port)) !== null) {
194 return false
195 }
196 }
197 return true
198}
199
200/** Calls `check` every `stepMs` until it answers true (true) or `ms` has passed (false). */
201async function waitFor($: Engine, ms: number, stepMs: number, check: () => Promise<boolean>): Promise<boolean> {
202 const until = (await $.clock.now()) + ms
203 for (;;) {
204 if (await check()) {
205 return true
206 }
207 const now = await $.clock.now()
208 if (now >= until) {
209 return false
210 }
211 await $.clock.sleep(Math.min(stepMs, until - now))
212 }
213}
214
215async function setBusy($: Engine, key: string, label: string | null) {
216 await update($, busy, all => {
217 const next = { ...all }
218 if (label === null) {
219 delete next[key]
220 } else {
221 next[key] = label
222 }
223 return next
224 })
225}
226
227async function pruneArmed($: Engine) {
228 const now = await $.clock.now()
229 await update($, armed, all => Object.fromEntries(Object.entries(all).filter(([, until]) => until > now)))
230}
231
232/** Opens the force-stop window for `pid`: a Stop pressed before it ends sends SIGKILL. */
233async function arm($: Engine, pid: number) {
234 const until = (await $.clock.now()) + FORCE_WINDOW_MS
235 await update($, armed, all => ({ ...all, [String(pid)]: until }))
236 $.clock.after(FORCE_WINDOW_MS, () => void pruneArmed($))
237}
238
239async function disarm($: Engine, pid: number) {
240 await update($, armed, all => Object.fromEntries(Object.entries(all).filter(([key]) => key !== String(pid))))
241}
242
243async function isGone($: Engine, pid: number): Promise<boolean> {
244 const out = await run($, ['kill', '-0', String(pid)])
245 return out !== null && out.exitCode !== 0
246}
247
248/** Sends `signal` to `pid`, and to its process group when it leads one; whether the pid took it. */
249async function signal($: Engine, pid: number, name: 'TERM' | 'KILL'): Promise<boolean> {
250 const group = await run($, ['ps', '-o', 'pgid=', '-p', String(pid)])
251 const leads = group?.exitCode === 0 && Number(group.stdout.trim()) === pid
252 const out = await run($, ['kill', `-${name}`, String(pid)])
253 if (leads) {
254 await run($, ['kill', `-${name}`, '--', `-${pid}`])
255 }
256 return out?.exitCode === 0
257}
258
259/** Whether `pid` still runs the command line the pane drew it with: a pid reused since is never signalled. */
260async function pidState($: Engine, server: Server): Promise<'same' | 'gone' | 'other'> {
261 const out = await run($, ['ps', '-o', 'pid=,etime=,command=', '-p', String(server.pid)])
262 const now = parseProcesses(out?.stdout ?? '').get(server.pid)
263 return now === undefined ? 'gone' : now.commandLine === server.commandLine ? 'same' : 'other'
264}
265
266/** Whether the pid may be signalled; when it is gone or now runs something else, says so and refreshes. */
267async function mayStop($: Engine, server: Server, quiet: boolean): Promise<'same' | 'gone' | 'other'> {
268 const state = await pidState($, server)
269 if (state === 'other') {
270 $.ui.toast(`pid ${server.pid} no longer runs ${server.name}; nothing was signalled`)
271 } else if (state === 'gone' && !quiet) {
272 $.ui.toast(`${server.name} (pid ${server.pid}) has already stopped`)
273 }
274 if (state !== 'same') {
275 void refresh($)
276 }
277 return state
278}
279
280/** SIGTERM, then up to the grace period for it to exit. True once it is gone. */
281async function stopServer($: Engine, server: Server, quiet: boolean): Promise<boolean> {
282 const key = `pid-${server.pid}`
283 const state = await mayStop($, server, quiet)
284 if (state !== 'same') {
285 return state === 'gone'
286 }
287 await arm($, server.pid)
288 await setBusy($, key, 'stopping')
289 try {
290 const took = await signal($, server.pid, 'TERM')
291 const gone = await waitFor($, config.graceMs, STEP_MS, () => isGone($, server.pid))
292 if (gone) {
293 // A force stop pressed meanwhile disarmed it and says so itself.
294 const stillArmed = (await read($, armed))[String(server.pid)] !== undefined
295 await disarm($, server.pid)
296 if (stillArmed && !quiet) {
297 $.ui.toast(`Stopped ${server.name} (pid ${server.pid})`)
298 }
299 return true
300 }
301 if (!took) {
302 await disarm($, server.pid)
303 $.ui.toast(`Could not signal ${server.name} (pid ${server.pid}): kill was refused`)
304 return false
305 }
306 await arm($, server.pid)
307 $.ui.toast(
308 `${server.name} (pid ${server.pid}) is still running ${Math.round(config.graceMs / 1000)}s after SIGTERM. Press Stop again within ${FORCE_WINDOW_MS / 1000}s to force stop it.`,
309 { timeoutMs: FORCE_WINDOW_MS },
310 )
311 return false
312 } finally {
313 await setBusy($, key, null)
314 void refresh($)
315 }
316}
317
318/** SIGKILL: only ever from a second Stop press inside the force window. */
319async function forceStop($: Engine, server: Server) {
320 const key = `pid-${server.pid}`
321 await disarm($, server.pid)
322 if ((await mayStop($, server, false)) !== 'same') {
323 return
324 }
325 await setBusy($, key, 'force stopping')
326 try {
327 await signal($, server.pid, 'KILL')
328 const gone = await waitFor($, KILL_WAIT_MS, STEP_MS, () => isGone($, server.pid))
329 $.ui.toast(
330 gone
331 ? `Force stopped ${server.name} (pid ${server.pid}) with SIGKILL`
332 : `${server.name} (pid ${server.pid}) is still there after SIGKILL`,
333 )
334 } finally {
335 await setBusy($, key, null)
336 void refresh($)
337 }
338}
339
340async function pressStop($: Engine, server: Server) {
341 const until = (await read($, armed))[String(server.pid)]
342 if (until !== undefined && (await $.clock.now()) <= until) {
343 await forceStop($, server)
344 return
345 }
346 if ((await read($, busy))[`pid-${server.pid}`] !== undefined) {
347 return
348 }
349 await stopServer($, server, false)
350}
351
352async function logFile($: Engine, root: string, entry: KnownEntry): Promise<string> {
353 const home = await $.env.get('HOME').catch(() => undefined)
354 const dir = expandHome(config.logDir, home)
355 return logPath(dir.startsWith('~') ? '/tmp/dev-servers' : dir, root, entry)
356}
357
358async function lastLogLine($: Engine, log: string): Promise<string> {
359 const out = await run($, ['tail', '-n', '20', log])
360 return out?.exitCode === 0 ? (lastLines(out.stdout, 1)[0] ?? '') : ''
361}
362
363/** A server matched to `entry` that was not running before, by its port or the next scan. */
364async function startedServer(
365 $: Engine,
366 entry: KnownEntry,
367 before: ReadonlySet<number>,
368): Promise<{ pid: number; ports: number[] } | null> {
369 if (entry.port !== null) {
370 const first = await listenerOn($, entry.port)
371 return first === null ? null : { pid: first.pid, ports: [entry.port] }
372 }
373 const fresh = await refresh($)
374 const server = fresh?.servers.find(one => one.entryId === entry.id && !before.has(one.pid))
375 return server === undefined ? null : { pid: server.pid, ports: server.ports }
376}
377
378/** Starts `entry` detached (nohup, output to its log), then waits up to 10s for it to listen. */
379async function startEntry($: Engine, entry: KnownEntry, verb: 'Started' | 'Restarted'): Promise<boolean> {
380 const key = `entry-${entry.id}`
381 await setBusy($, key, 'starting')
382 try {
383 if (entry.port !== null) {
384 const holder = await holderOf($, entry.port)
385 if (holder !== null) {
386 $.ui.toast(`${entry.name} was not started: ${holderNote(entry.port, holder)}`, { timeoutMs: 10_000 })
387 return false
388 }
389 }
390 const root = await projectRoot($)
391 const log = await logFile($, root, entry)
392 const before = new Set(((await read($, snapshot))?.servers ?? []).map(server => server.pid))
393 const out = await run($, ['sh', '-c', startScript(root, entry.command, log)], { cwd: root })
394 if (out === null || out.exitCode !== 0) {
395 const reason = out?.stderr.trim().split('\n').pop() || 'sh did not run'
396 $.ui.toast(`Could not start ${entry.name}: ${reason}`)
397 return false
398 }
399 const seen: { server: { pid: number; ports: number[] } | null } = { server: null }
400 await waitFor($, START_WAIT_MS, entry.port === null ? 1000 : STEP_MS, async () => {
401 seen.server = await startedServer($, entry, before)
402 return seen.server !== null
403 })
404 if (seen.server !== null) {
405 $.ui.toast(`${verb} ${entry.name} on ${portList(seen.server.ports)} (pid ${seen.server.pid})`)
406 return true
407 }
408 const last = await lastLogLine($, log)
409 const waiting =
410 entry.port === null
411 ? `${verb} ${entry.name}, but no new port is listening after ${START_WAIT_MS / 1000}s.`
412 : `${entry.name} has not opened :${entry.port} after ${START_WAIT_MS / 1000}s.`
413 $.ui.toast(last ? `${waiting} Log: ${last}` : `${waiting} Log: ${log}`, { timeoutMs: 10_000 })
414 return false
415 } finally {
416 await setBusy($, key, null)
417 void refresh($)
418 }
419}
420
421async function pressStart($: Engine, entry: KnownEntry) {
422 if ((await read($, busy))[`entry-${entry.id}`] === undefined) {
423 await startEntry($, entry, 'Started')
424 }
425}
426
427/** Stop, wait until its ports are free, start: never a start while the old one still holds the port. */
428async function restartServer($: Engine, server: Server, entry: KnownEntry) {
429 const working = await read($, busy)
430 if (working[`entry-${entry.id}`] !== undefined || working[`pid-${server.pid}`] !== undefined) {
431 return
432 }
433 const stopped = await stopServer($, server, true)
434 if (!stopped) {
435 return
436 }
437 const free = await waitFor($, FREE_WAIT_MS, STEP_MS, () => portsFree($, server.ports))
438 if (!free) {
439 $.ui.toast(
440 `${server.name} stopped, but ${portList(server.ports)} is still in use after ${FREE_WAIT_MS / 1000}s; not restarting`,
441 { timeoutMs: 10_000 },
442 )
443 return
444 }
445 await startEntry($, entry, 'Restarted')
446}
447
448/** Toasts the last three lines of the log a pane start wrote. */
449async function showLog($: Engine, entry: KnownEntry) {
450 const root = await projectRoot($)
451 const log = await logFile($, root, entry)
452 const out = await run($, ['tail', '-n', '50', log])
453 if (out === null || out.exitCode !== 0) {
454 $.ui.toast(`No log for ${entry.name}: only servers started from this pane write one (${log})`, { timeoutMs: 8_000 })
455 return
456 }
457 const lines = lastLines(out.stdout, 3)
458 $.ui.toast(lines.length > 0 ? `${entry.name}: ${lines.join(' | ')}` : `${entry.name}: the log is empty`, {
459 timeoutMs: 10_000,
460 })
461}
462
463export const register: Register = (on, options) => {
464 const poll = Number(options.pollSeconds ?? 15)
465 const grace = Number(options.stopGraceSeconds ?? 5)
466 config = {
467 pollMs: Math.max(2, Number.isFinite(poll) ? poll : 15) * 1000,
468 logDir: String(options.logDir ?? '').trim() || '~/.claude/dev-servers',
469 graceMs: Math.min(60, Math.max(1, Number.isFinite(grace) ? grace : 5)) * 1000,
470 }
471 timer = null
472 inflight = null
473 shownStatus = null
474 rootFor = null
475
476 on('session.start', async ($, e, next) => {
477 await $.command.register({
478 name: 'servers',
479 description: "Open the Servers pane: this project's dev servers, with Start, Stop, Restart and Log",
480 immediate: true,
481 })
482 ensureTimer($)
483 void refresh($)
484 return next(e)
485 })
486
487 on('command.run', { command: 'servers' }, async $ => {
488 ensureTimer($)
489 const fresh = await refresh($)
490 await $.ui.open({ id: PANE, title: TITLE })
491 return { text: fresh === null ? 'dev-servers: lsof did not answer, so no servers could be listed.' : describe(fresh) }
492 })
493
494 // A command that failed on a taken port: say who holds it.
495 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
496 const ran = await next(e)
497 if (ran.deny !== undefined) {
498 return ran
499 }
500 try {
501 const conflict = findConflict(ran.text ?? '')
502 const port = conflict === null ? null : (conflict.port ?? portFromCommand(e.command))
503 if (port === null) {
504 return ran
505 }
506 const holder = await holderOf($, port)
507 if (holder === null) {
508 return ran
509 }
510 const note = holderNote(port, holder)
511 $.ui.toast(note, { timeoutMs: 10_000 })
512 const root = await projectRoot($)
513 const own = holder.cwd !== null && isInside(holder.cwd, root) ? " It is one of this project's servers; /servers can restart or stop it." : ''
514 return { ...ran, context: [...(ran.context ?? []), `dev-servers: ${note}.${own}`] }
515 } catch (error) {
516 $.ui.log(`dev-servers: could not look up the port holder: ${String(error)}`, { to: 'debug' })
517 return ran
518 }
519 })
520
521 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
522 const { Box, Text, Button } = $.ui.resolve(e)
523 const current = await read($, snapshot)
524 const working = await read($, busy)
525 const forcing = await read($, armed)
526 if (current === null) {
527 return <Text dimColor>Looking for this project's servers…</Text>
528 }
529 const stacked = e.props.bodyColumns < STACK_BELOW
530 const entryOf = (id: string | null) => current.entries.find(entry => entry.id === id) ?? null
531 const others = `${current.others} other listener${current.others === 1 ? '' : 's'} on this Mac`
532
533 return (
534 <Box flexDirection="column" gap={1}>
535 <Text dimColor wrap="truncate-start">
536 {current.root}
537 </Text>
538 {current.servers.length === 0 ? (
539 <Text dimColor>No dev servers are listening in this project.</Text>
540 ) : (
541 <Box flexDirection="column">
542 {current.servers.map(server => {
543 const entry = entryOf(server.entryId)
544 const doing = working[`pid-${server.pid}`]
545 const isArmed = forcing[String(server.pid)] !== undefined
546 return (
547 <Box
548 key={`server-${server.pid}`}
549 flexDirection={stacked ? 'column' : 'row'}
550 justifyContent="space-between"
551 columnGap={1}
552 >
553 <Box flexShrink={1}>
554 <Text wrap="truncate-end">
555 <Text color="green">●</Text> {serverLine(server)}
556 {doing === undefined ? '' : ` · ${doing}…`}
557 </Text>
558 </Box>
559 <Box flexDirection="row" gap={1} flexShrink={0}>
560 {entry !== null && (
561 <Button key={`restart-${server.pid}`} label="Restart" onPress={() => void restartServer($, server, entry)} />
562 )}
563 <Button
564 key={`stop-${server.pid}`}
565 label={isArmed ? 'Force stop?' : 'Stop'}
566 variant={isArmed ? 'primary' : undefined}
567 onPress={() => void pressStop($, server)}
568 />
569 {entry !== null && (
570 <Button key={`log-${server.pid}`} label="Log" dimColor onPress={() => void showLog($, entry)} />
571 )}
572 </Box>
573 </Box>
574 )
575 })}
576 </Box>
577 )}
578 {current.stopped.length > 0 && (
579 <Box flexDirection="column">
580 {current.stopped.map(entry => {
581 const doing = working[`entry-${entry.id}`]
582 const detail = [entry.name, entry.command, entry.port === null ? null : `:${entry.port}`]
583 .filter(Boolean)
584 .join(' · ')
585 return (
586 <Box
587 key={`entry-${entry.id}`}
588 flexDirection={stacked ? 'column' : 'row'}
589 justifyContent="space-between"
590 columnGap={1}
591 >
592 <Box flexShrink={1}>
593 <Text dimColor wrap="truncate-end">
594 ○ {detail}
595 {doing === undefined ? '' : ` · ${doing}…`}
596 </Text>
597 </Box>
598 <Box flexShrink={0}>
599 <Button key={`start-${entry.id}`} label="Start" onPress={() => void pressStart($, entry)} />
600 </Box>
601 </Box>
602 )
603 })}
604 </Box>
605 )}
606 <Text dimColor>{others}</Text>
607 </Box>
608 )
609 })
610}
611hooks/servers.ts 600 lines1import type { Holder, KnownEntry, Listener, Manager, Server } from '../types'
2
3// ---------------------------------------------------------------------------
4// lsof and ps output
5// ---------------------------------------------------------------------------
6
7/** The port of an lsof address: `*:4000`, `127.0.0.1:5173`, `[::1]:3000`; null for anything else. */
8export function portOfAddress(address: string): number | null {
9 const local = (address.split('->')[0] ?? '').trim()
10 const match = /:(\d{1,5})(?:\s*\(LISTEN\))?$/.exec(local)
11 const port = match ? Number(match[1]) : NaN
12 return port > 0 && port < 65536 ? port : null
13}
14
15/** `lsof -nP -iTCP -sTCP:LISTEN -F pcn`: one Listener per pid, its ports sorted and deduplicated. */
16export function parseListeners(text: string): Listener[] {
17 const byPid = new Map<number, Listener>()
18 let current: Listener | null = null
19 for (const raw of text.split('\n')) {
20 const line = raw.replace(/\r$/, '')
21 const tag = line[0]
22 const value = line.slice(1)
23 if (tag === 'p') {
24 const pid = Number(value)
25 current = Number.isInteger(pid) && pid > 0 ? (byPid.get(pid) ?? { pid, command: '', ports: [] }) : null
26 if (current) {
27 byPid.set(pid, current)
28 }
29 } else if (tag === 'c' && current) {
30 current.command = value
31 } else if (tag === 'n' && current) {
32 const port = portOfAddress(value)
33 if (port !== null && !current.ports.includes(port)) {
34 current.ports.push(port)
35 }
36 }
37 }
38 return [...byPid.values()]
39 .filter(listener => listener.ports.length > 0)
40 .map(listener => ({ ...listener, ports: [...listener.ports].sort((a, b) => a - b) }))
41}
42
43/** `lsof -a -p <pids> -d cwd -Fn`: each pid's working directory. */
44export function parseCwds(text: string): Map<number, string> {
45 const cwds = new Map<number, string>()
46 let pid: number | null = null
47 for (const raw of text.split('\n')) {
48 const line = raw.replace(/\r$/, '')
49 if (line.startsWith('p')) {
50 const n = Number(line.slice(1))
51 pid = Number.isInteger(n) && n > 0 ? n : null
52 } else if (line.startsWith('n') && pid !== null && !cwds.has(pid)) {
53 cwds.set(pid, line.slice(1))
54 }
55 }
56 return cwds
57}
58
59/** `ps` elapsed time, `[[dd-]hh:]mm:ss`, in seconds; null when it does not read as one. */
60export function parseEtime(text: string): number | null {
61 const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(text.trim())
62 if (!match) {
63 return null
64 }
65 const [, days = '0', hours = '0', minutes = '0', seconds = '0'] = match
66 return Number(days) * 86_400 + Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds)
67}
68
69/** `ps -o pid=,etime=,command= -p <pids>`: each pid's uptime and command line. */
70export function parseProcesses(text: string): Map<number, { upSeconds: number | null; commandLine: string }> {
71 const rows = new Map<number, { upSeconds: number | null; commandLine: string }>()
72 for (const line of text.split('\n')) {
73 const match = /^\s*(\d+)\s+(\S+)\s+(.*?)\s*$/.exec(line)
74 if (match) {
75 rows.set(Number(match[1]), { upSeconds: parseEtime(match[2] ?? ''), commandLine: match[3] ?? '' })
76 }
77 }
78 return rows
79}
80
81/** `lsof -nP -iTCP:<port> -sTCP:LISTEN` (the table form): the processes listed, header skipped, one per pid. */
82export function parsePortTable(text: string): { command: string; pid: number }[] {
83 const seen = new Set<number>()
84 const holders: { command: string; pid: number }[] = []
85 for (const line of text.split('\n')) {
86 const match = /^(\S+)\s+(\d+)\s/.exec(line)
87 if (match && !line.startsWith('COMMAND')) {
88 const pid = Number(match[2])
89 if (!seen.has(pid)) {
90 seen.add(pid)
91 holders.push({ command: match[1] ?? '', pid })
92 }
93 }
94 }
95 return holders
96}
97
98/** `2h`, `5m`, `3d`, `40s`: the largest whole unit. */
99export function formatUptime(seconds: number): string {
100 const s = Math.max(0, Math.floor(seconds))
101 if (s < 60) {
102 return `${s}s`
103 }
104 if (s < 3600) {
105 return `${Math.floor(s / 60)}m`
106 }
107 if (s < 86_400) {
108 return `${Math.floor(s / 3600)}h`
109 }
110 return `${Math.floor(s / 86_400)}d`
111}
112
113/** Whether `path` is `root` or lies beneath it. */
114export function isInside(path: string, root: string): boolean {
115 const base = root.length > 1 ? root.replace(/\/+$/, '') : root
116 return path === base || path.startsWith(base === '/' ? '/' : `${base}/`)
117}
118
119export function basename(path: string): string {
120 const trimmed = path.replace(/\/+$/, '')
121 return trimmed.slice(trimmed.lastIndexOf('/') + 1)
122}
123
124export function dirname(path: string): string {
125 const cut = path.lastIndexOf('/')
126 return cut <= 0 ? '/' : path.slice(0, cut)
127}
128
129// ---------------------------------------------------------------------------
130// Known start commands
131// ---------------------------------------------------------------------------
132
133/** Lockfiles in the order they decide the manager. */
134export const LOCKFILES: readonly (readonly [string, Manager])[] = [
135 ['pnpm-lock.yaml', 'pnpm'],
136 ['yarn.lock', 'yarn'],
137 ['bun.lockb', 'bun'],
138 ['bun.lock', 'bun'],
139 ['package-lock.json', 'npm'],
140 ['npm-shrinkwrap.json', 'npm'],
141]
142
143/** The package manager: the first lockfile present, else package.json's `packageManager`, else npm. */
144export function detectManager(present: readonly string[], packageManager?: unknown): Manager {
145 for (const [file, manager] of LOCKFILES) {
146 if (present.includes(file)) {
147 return manager
148 }
149 }
150 const named = typeof packageManager === 'string' ? /^(npm|pnpm|yarn|bun)@/.exec(packageManager) : null
151 return (named?.[1] as Manager | undefined) ?? 'npm'
152}
153
154/** Script names that start a server: dev, start, serve, preview, dev:*, start:*. */
155export function isServerScript(name: string): boolean {
156 return /^(dev|start|serve|preview)$/.test(name) || /^(dev|start):./.test(name)
157}
158
159/** The `scripts` of a package.json, or null when it does not parse. */
160export function packageScripts(text: string): { scripts: Record<string, string>; packageManager?: unknown } | null {
161 try {
162 const parsed: unknown = JSON.parse(text)
163 if (!parsed || typeof parsed !== 'object') {
164 return null
165 }
166 const record = parsed as { scripts?: unknown; packageManager?: unknown }
167 const scripts: Record<string, string> = {}
168 if (record.scripts && typeof record.scripts === 'object') {
169 for (const [name, body] of Object.entries(record.scripts as Record<string, unknown>)) {
170 if (typeof body === 'string') {
171 scripts[name] = body
172 }
173 }
174 }
175 return { scripts, packageManager: record.packageManager }
176 } catch {
177 return null
178 }
179}
180
181type Draft = Omit<KnownEntry, 'id'>
182
183/** The server scripts of a package.json, run with `manager`. */
184export function parsePackageJson(text: string, manager: Manager): Draft[] {
185 const scripts = packageScripts(text)?.scripts ?? {}
186 return Object.entries(scripts)
187 .filter(([name]) => isServerScript(name))
188 .map(([name, body]) => ({
189 name,
190 command: `${manager} run ${name}`,
191 source: 'package.json' as const,
192 port: portFromCommand(body),
193 matchText: resolveScript(body, scripts),
194 }))
195}
196
197/** JSON with `//` and block comments and trailing commas, as VS Code writes launch.json. */
198export function parseLooseJson(text: string): unknown {
199 let out = ''
200 let inString = false
201 for (let i = 0; i < text.length; i++) {
202 const char = text[i]
203 if (inString) {
204 out += char
205 if (char === '\\') {
206 out += text[++i] ?? ''
207 } else if (char === '"') {
208 inString = false
209 }
210 } else if (char === '"') {
211 inString = true
212 out += char
213 } else if (char === '/' && text[i + 1] === '/') {
214 while (i < text.length && text[i] !== '\n') i++
215 out += '\n'
216 } else if (char === '/' && text[i + 1] === '*') {
217 i += 2
218 while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++
219 i++
220 } else {
221 out += char
222 }
223 }
224 return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'))
225}
226
227/** `.claude/launch.json` configurations that run a command: name, runtimeExecutable + runtimeArgs, port. */
228export function parseLaunchJson(text: string): Draft[] {
229 let parsed: unknown
230 try {
231 parsed = parseLooseJson(text)
232 } catch {
233 return []
234 }
235 const configurations = (parsed as { configurations?: unknown } | null)?.configurations
236 if (!Array.isArray(configurations)) {
237 return []
238 }
239 const drafts: Draft[] = []
240 for (const item of configurations as unknown[]) {
241 const config = (item ?? {}) as { name?: unknown; runtimeExecutable?: unknown; runtimeArgs?: unknown; port?: unknown }
242 if (typeof config.name !== 'string' || typeof config.runtimeExecutable !== 'string' || !config.runtimeExecutable) {
243 continue
244 }
245 const args = Array.isArray(config.runtimeArgs) ? config.runtimeArgs.map(String) : []
246 const command = [config.runtimeExecutable, ...args].map(shellQuote).join(' ')
247 const port = Number(config.port)
248 drafts.push({
249 name: config.name,
250 command,
251 source: 'launch.json',
252 port: Number.isInteger(port) && port > 0 && port < 65536 ? port : portFromCommand(command),
253 matchText: command,
254 })
255 }
256 return drafts
257}
258
259/** Procfile lines, `name: command`. */
260export function parseProcfile(text: string): Draft[] {
261 const drafts: Draft[] = []
262 for (const line of text.split('\n')) {
263 const match = /^\s*([A-Za-z0-9_-]+)\s*:\s*(\S.*?)\s*$/.exec(line)
264 if (match && !line.trimStart().startsWith('#')) {
265 const command = match[2] ?? ''
266 drafts.push({ name: match[1] ?? '', command, source: 'Procfile', port: portFromCommand(command), matchText: command })
267 }
268 }
269 return drafts
270}
271
272/** The package script a command runs (`npm run dev`, `pnpm dev`, `yarn start`, `bun run dev`), or null. */
273export function scriptCalled(command: string): string | null {
274 const match = /^\s*(npm|pnpm|yarn|bun)\s+(?:run(?:-script)?\s+)?([\w:.@/-]+)\s*$/.exec(command)
275 if (!match) {
276 return null
277 }
278 const [, manager, name = ''] = match
279 // `npm dev` is not a script call; npm runs only start, stop, test and restart bare.
280 if (manager === 'npm' && !/^\s*npm\s+run/.test(command) && name !== 'start') {
281 return null
282 }
283 return name
284}
285
286/** A script's body, followed one level when it only calls another script. */
287export function resolveScript(body: string, scripts: Record<string, string>): string {
288 const called = scriptCalled(body)
289 return called !== null && scripts[called] !== undefined ? scripts[called] : body
290}
291
292/** The port a command line names: `--port 3000`, `--port=3000`, `-p 3000`, `PORT=3000`, `--bind :8000`, `runserver 8000`. */
293export function portFromCommand(command: string): number | null {
294 const patterns = [
295 /--port[=\s]+(\d{2,5})\b/,
296 /(?:^|\s)-p\s*(\d{2,5})(?=\s|$)/,
297 /\bPORT=(\d{2,5})\b/,
298 /--(?:bind|listen)[=\s]+[\w.[\]]*:(\d{2,5})\b/,
299 /\brunserver\s+(?:[\w.[\]]+:)?(\d{2,5})\b/,
300 /\bhttp\.server\s+(\d{2,5})\b/,
301 ]
302 for (const pattern of patterns) {
303 const match = pattern.exec(command)
304 const port = match ? Number(match[1]) : NaN
305 if (port > 0 && port < 65536) {
306 return port
307 }
308 }
309 return null
310}
311
312/** A lowercase, file-name-safe id: `dev:api` → `dev-api`. */
313export function slug(text: string): string {
314 return (
315 text
316 .toLowerCase()
317 .replace(/[^a-z0-9]+/g, '-')
318 .replace(/^-+|-+$/g, '') || 'server'
319 )
320}
321
322/**
323 * Every known way to start this project's servers: launch.json first (it names ports), then the
324 * Procfile, then package.json scripts those two do not already run. Names and ids made unique.
325 */
326export function knownEntries(sources: {
327 packageJson: string | null
328 launchJson: string | null
329 procfile: string | null
330 manager: Manager
331}): KnownEntry[] {
332 const scripts = sources.packageJson === null ? {} : (packageScripts(sources.packageJson)?.scripts ?? {})
333 const withScripts = (draft: Draft): Draft => {
334 const called = scriptCalled(draft.command)
335 const body = called === null ? undefined : scripts[called]
336 if (body === undefined) {
337 return draft
338 }
339 const matchText = resolveScript(body, scripts)
340 return { ...draft, matchText, port: draft.port ?? portFromCommand(body) }
341 }
342 const front = [
343 ...(sources.launchJson === null ? [] : parseLaunchJson(sources.launchJson)),
344 ...(sources.procfile === null ? [] : parseProcfile(sources.procfile)),
345 ].map(withScripts)
346 const covered = new Set(front.map(draft => scriptCalled(draft.command)).filter(name => name !== null))
347 const fromPackage =
348 sources.packageJson === null
349 ? []
350 : parsePackageJson(sources.packageJson, sources.manager).filter(draft => !covered.has(draft.name))
351
352 const names = new Set<string>()
353 const ids = new Set<string>()
354 const entries: KnownEntry[] = []
355 for (const draft of [...front, ...fromPackage]) {
356 const name = names.has(draft.name) ? `${draft.name} (${draft.source})` : draft.name
357 names.add(name)
358 let id = slug(name)
359 for (let n = 2; ids.has(id); n++) {
360 id = `${slug(name)}-${n}`
361 }
362 ids.add(id)
363 entries.push({ ...draft, name, id })
364 }
365 return entries
366}
367
368// ---------------------------------------------------------------------------
369// Matching running servers to known entries
370// ---------------------------------------------------------------------------
371
372/** Words a command is launched through, skipped to reach the program itself. */
373const WRAPPERS = new Set(['npx', 'bunx', 'pnpx', 'exec', 'nohup', 'cross-env', 'env', 'nice', 'time'])
374
375/** Interpreters whose name alone says nothing: their arguments must match as well. */
376const GENERIC = new Set(['node', 'python', 'ruby', 'bun', 'deno', 'php', 'java', 'sh', 'bash', 'zsh', 'perl', 'tsx', 'ts-node', 'go', 'dotnet', 'uv', 'poetry'])
377
378/** Splits a command line into words, honouring single and double quotes. */
379export function tokenize(command: string): string[] {
380 const words: string[] = []
381 const pattern = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
382 for (let match = pattern.exec(command); match !== null; match = pattern.exec(command)) {
383 words.push(match[1] ?? match[2] ?? match[3] ?? '')
384 }
385 return words
386}
387
388/** A program's name without its folder, version or script suffix: `/usr/bin/python3.12` → `python`. */
389export function programName(word: string): string {
390 return basename(word)
391 .toLowerCase()
392 .replace(/\.(c|m)?js$|\.exe$/, '')
393 .replace(/([a-z])[\d.]+$/, '$1')
394}
395
396/** The program and its plain arguments (no flags, no variables) of a command's last segment. */
397export function programWords(command: string): { program: string; args: string[] } | null {
398 const segment = command.split(/&&|\|\||;/).map(part => part.trim()).filter(Boolean).pop() ?? ''
399 const words = tokenize(segment.split(/[|<>]/)[0] ?? '')
400 while (words.length > 0 && (/^\w+=/.test(words[0] ?? '') || WRAPPERS.has(words[0] ?? ''))) {
401 words.shift()
402 }
403 const [first, ...rest] = words
404 if (first === undefined) {
405 return null
406 }
407 return { program: programName(first), args: rest.filter(word => !word.startsWith('-') && !word.includes('$')) }
408}
409
410/**
411 * How well a process's command line fits a start command: 2 for its program and every argument,
412 * 1 for the program alone (never for a bare interpreter such as node), 0 for no fit.
413 */
414export function commandScore(command: string, commandLine: string): number {
415 const words = programWords(command)
416 if (words === null) {
417 return 0
418 }
419 const tokens = tokenize(commandLine)
420 const hasProgram = tokens.some(token => {
421 const name = programName(token)
422 return name === words.program || name.startsWith(`${words.program}-`)
423 })
424 if (!hasProgram) {
425 return 0
426 }
427 const hasArgs = words.args.every(arg => {
428 const bare = arg.replace(/^\.\//, '')
429 return tokens.some(token => token === arg || token === bare || token.endsWith(`/${bare}`))
430 })
431 return hasArgs ? 2 : GENERIC.has(words.program) ? 0 : 1
432}
433
434/** How well a running server fits an entry: 3 by port, else commandScore. */
435export function matchScore(entry: KnownEntry, server: Pick<Server, 'ports' | 'commandLine'>): number {
436 if (entry.port !== null && server.ports.includes(entry.port)) {
437 return 3
438 }
439 return commandScore(entry.matchText, server.commandLine)
440}
441
442/** Names each server by its best-fitting entry (earliest on a tie) and lists the entries none fits. */
443export function matchServers(
444 servers: readonly Server[],
445 entries: readonly KnownEntry[],
446): { servers: Server[]; stopped: KnownEntry[] } {
447 const used = new Set<string>()
448 const named = servers.map(server => {
449 let best: KnownEntry | null = null
450 let bestScore = 0
451 for (const entry of entries) {
452 const score = matchScore(entry, server)
453 if (score > bestScore) {
454 best = entry
455 bestScore = score
456 }
457 }
458 if (best === null) {
459 return { ...server, entryId: null }
460 }
461 used.add(best.id)
462 return { ...server, entryId: best.id, name: best.name }
463 })
464 return { servers: named, stopped: entries.filter(entry => !used.has(entry.id)) }
465}
466
467/** Ports as the status line and the pane draw them: `:4000 :5173`. */
468export function portList(ports: readonly number[]): string {
469 return ports.map(port => `:${port}`).join(' ')
470}
471
472/** The status line: `servers: :4000 :5173`, or undefined when no project server is up. */
473export function statusLine(servers: readonly Server[]): string | undefined {
474 const ports = [...new Set(servers.flatMap(server => server.ports))].sort((a, b) => a - b)
475 return ports.length > 0 ? `servers: ${portList(ports)}` : undefined
476}
477
478// ---------------------------------------------------------------------------
479// Starting, logs and port conflicts
480// ---------------------------------------------------------------------------
481
482/** A word the shell reads back as itself. */
483export function shellQuote(word: string): string {
484 return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`
485}
486
487/** A short stable hash, base 36. */
488export function hashOf(text: string): string {
489 let hash = 0
490 for (let i = 0; i < text.length; i++) {
491 hash = (hash * 31 + text.charCodeAt(i)) | 0
492 }
493 return (hash >>> 0).toString(36)
494}
495
496/** The project's folder name under the log folder: `myapp-1x2y3z`. */
497export function projectKey(root: string): string {
498 return `${slug(basename(root) || 'root')}-${hashOf(root)}`
499}
500
501/** Where an entry started from the pane writes its output. */
502export function logPath(logDir: string, root: string, entry: Pick<KnownEntry, 'id'>): string {
503 return `${logDir.replace(/\/+$/, '')}/${projectKey(root)}/${entry.id}.log`
504}
505
506/** `~` and `$HOME` at the start of a path, expanded. */
507export function expandHome(path: string, home: string | undefined): string {
508 if (!home) {
509 return path
510 }
511 return path.replace(/^(~|\$HOME|\$\{HOME\})(?=\/|$)/, home)
512}
513
514/**
515 * The `sh -c` script that starts a command detached from the session: in the project root, its
516 * output to the log, its input from /dev/null. A command with shell operators runs under its own sh.
517 */
518export function startScript(root: string, command: string, log: string): string {
519 const program = /&&|\|\||;|\|/.test(command) ? `sh -c ${shellQuote(command)}` : command
520 return `mkdir -p ${shellQuote(dirname(log))} && cd ${shellQuote(root)} && nohup ${program} > ${shellQuote(log)} 2>&1 < /dev/null &`
521}
522
523/** The last `count` non-empty lines of a log's tail. */
524export function lastLines(text: string, count: number): string[] {
525 return text
526 .split(/\r?\n|\r/)
527 .map(line => line.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').trimEnd())
528 .filter(line => line.trim() !== '')
529 .slice(-count)
530}
531
532/**
533 * Whether a command's output says a port was taken, and which: `EADDRINUSE ... :::4000`,
534 * `Port 5173 is already in use`, `listen tcp :8080: bind: address already in use`.
535 * `{ port: null }` when it says so without naming the port; null when it does not say so.
536 */
537export function findConflict(text: string): { port: number | null } | null {
538 if (!/EADDRINUSE|address already in use|port \d+ is already in use/i.test(text)) {
539 return null
540 }
541 const patterns = [
542 /port (\d{1,5}) is already in use/i,
543 /EADDRINUSE[^\n]*?:(\d{1,5})\b/,
544 /:(\d{1,5}):? bind: address already in use/i,
545 /address already in use[^\n]*?\bport (\d{1,5})\b/i,
546 /address already in use[^\n]*?:(\d{1,5})\b/i,
547 /\bport (\d{1,5})\b[^\n]*address already in use/i,
548 ]
549 for (const pattern of patterns) {
550 const match = pattern.exec(text)
551 const port = match ? Number(match[1]) : NaN
552 if (port > 0 && port < 65536) {
553 return { port }
554 }
555 }
556 return { port: null }
557}
558
559/** `Port 4000 is held by node (pid 123, up 2h, in /Users/me/other)`. */
560export function holderNote(port: number, holder: Holder): string {
561 const details = [
562 `pid ${holder.pid}`,
563 holder.upSeconds === null ? null : `up ${formatUptime(holder.upSeconds)}`,
564 holder.cwd === null ? null : `in ${holder.cwd}`,
565 ].filter(Boolean)
566 return `Port ${port} is held by ${holder.command} (${details.join(', ')})`
567}
568
569/** One server as a line: `dev · :5173 · pid 4242 · up 2h`. */
570export function serverLine(server: Server): string {
571 return [
572 server.name,
573 portList(server.ports),
574 `pid ${server.pid}`,
575 server.upSeconds === null ? null : `up ${formatUptime(server.upSeconds)}`,
576 ]
577 .filter(Boolean)
578 .join(' · ')
579}
580
581/** What /servers prints: the running servers, the stopped entries, the other listeners. */
582export function describe(snapshot: {
583 root: string
584 servers: readonly Server[]
585 stopped: readonly KnownEntry[]
586 others: number
587}): string {
588 const lines = [
589 snapshot.servers.length === 0
590 ? `No dev servers are listening in ${snapshot.root}.`
591 : `Running in ${snapshot.root}:\n${snapshot.servers.map(server => ` ${serverLine(server)}`).join('\n')}`,
592 ]
593 if (snapshot.stopped.length > 0) {
594 lines.push(`Not running: ${snapshot.stopped.map(entry => `${entry.name} (${entry.command})`).join(', ')}`)
595 }
596 lines.push(`${snapshot.others} other listener${snapshot.others === 1 ? '' : 's'} on this Mac.`)
597 return lines.join('\n')
598}
599
600types/index.d.ts 64 lines1/** A package manager, picked from the project's lockfile. */
2export type Manager = 'npm' | 'pnpm' | 'yarn' | 'bun'
3
4/** Where a known start command was read from. */
5export type EntrySource = 'package.json' | 'launch.json' | 'Procfile'
6
7/** A way to start one of this project's servers. */
8export type KnownEntry = {
9 /** Unique within the project, safe in a file name and a Button key. */
10 id: string
11 name: string
12 /** The shell command line that starts it, run from the project root. */
13 command: string
14 source: EntrySource
15 /** The port it listens on, when the entry says (launch.json `port`, `--port 3000`, `PORT=3000`). */
16 port: number | null
17 /** The command line its process runs, for matching against `ps`: a package script's body, else `command`. */
18 matchText: string
19}
20
21/** One process listening on TCP, from `lsof -F pcn`. */
22export type Listener = { pid: number; command: string; ports: number[] }
23
24/** A listening process whose working directory is inside the project. */
25export type Server = {
26 pid: number
27 /** The matched entry's name, else the process's own name. */
28 name: string
29 /** The id of the known entry this server matched, or null. */
30 entryId: string | null
31 ports: number[]
32 cwd: string
33 upSeconds: number | null
34 /** Its command line as `ps` prints it. */
35 commandLine: string
36}
37
38/** What one scan found. */
39export type Snapshot = {
40 root: string
41 servers: Server[]
42 entries: KnownEntry[]
43 /** Known entries no running server matched. */
44 stopped: KnownEntry[]
45 /** Listening processes outside the project. */
46 others: number
47 at: number
48}
49
50/** The process holding a port, for a conflict note. */
51export type Holder = { pid: number; command: string; upSeconds: number | null; cwd: string | null }
52
53declare module 'claude-code' {
54 interface PluginState {
55 'dev-servers': {
56 snapshot: Snapshot | null
57 /** What is under way per row (`pid-4242`, `entry-dev`): `stopping`, `starting`, `restarting`. */
58 busy: Record<string, string>
59 /** Pids whose Stop was pressed: until this time a second press force stops. */
60 armed: Record<string, number>
61 }
62 }
63}
64