SLOPSHOPPER

dev-servers

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…

newpaneguardcommandtoaststatus
v0.1.0no licenseupdated 2026-10-07joeldg/claude-mods/dev-servers
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-servers
│ ┃ Servers ✕ › fix the failing auth test and add an audit log call │ ┃ /work/app │ ┃ ⏺ Read(src/auth.ts) │ ┃ No dev servers are listening in this ⎿ Read 6 lines │ ┃ project. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 0 other listeners on this Mac ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /servers │ ⎿ dev-servers: No dev servers are listening in /work/app. │ ⎿ dev-servers: 0 other listeners on this Mac. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Servers
/work/app No dev servers are listening in this project. 0 other listeners on this Mac
README

claude-mods

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

dev-servers

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:
  • running servers whose working folder is in this repo: name, port, pid and uptime, with Restart, Stop and Log
  • known start commands that aren't running, with Start: package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile
  • a count of other listeners on the Mac
  • Only button presses start or stop anything:
  • Start runs the command detached, logging to ~/.claude/dev-servers/<project>/.
  • Stop sends SIGTERM. If the server ignores it, pressing again within 10s force-stops it.
  • Restart stops the server, waits for the port to free up, then starts it.
  • Before any signal, it checks the pid still runs the same command.
  • When a command fails with "address already in use", Claude gets a note (and you a toast) naming the holder, e.g. Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).
  • Status line: servers: :4000 :5173.
  • Makes no model calls: lsof and ps every 15s.

downloads-drop

Puts files you just downloaded into your prompt with one click.

  • Watches ~/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].
  • Attach puts @"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files.
  • Waits until a file has finished downloading (skips partial downloads and files still growing), and ignores hidden and zero-byte files.
  • /downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.
  • Stacks with other mods' bands (repo-brief, standing-orders, secret-guard) instead of hiding them.
  • Makes no model calls.

Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).

effort-router

Sets effort per message, so you don't have to switch it by hand.

  • Git chores ("merged", "#219 merged", "commit and push", "push it", "open a PR", "close the issue") run at low effort and come back faster.
  • Deep asks (audit, review, plan, design, investigate, root cause, "why does…", "figure out") run at max.
  • Everything else, including approvals like "yes", "go ahead" and "continue" and anything that starts new work ("merged 219, go ahead with #214"), keeps your session's own effort.
  • Prompt cache: changing effort makes the whole conversation get cached again. So it never switches mid-turn, raises effort at once, and over a large, warm cache lowers it only after 2 routine turns in a row. In small contexts, or once the cache has lapsed, it switches right away.
  • Model guard: set 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.
  • Status line while a turn is routed: effort: low (routine).
  • Makes no model calls.

Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).

job-watch

A Jobs pane for long-running work: training runs, downloads, extractions.

  • Picks up background Bash tasks and detached nohup … > log & launches by itself.
  • /watch <log> [label] adds any other log file.
  • Shows progress, ETA and the last log line, and flags a job as stalled when its log goes quiet.
  • Shows free space on / and /Volumes/* (the NAS).
  • Toasts when a job finishes or stalls. The status line shows jobs: 2 running · 1 stalled.
  • /jobs opens the pane, /unwatch <label|done|all> removes jobs.
  • Makes no model calls: it reads logs with 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.

machine-guard

Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.

  • Status line: RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure, or
  • the Mac is reserved with /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.
  • Remote runs (modal run, ssh), tests (pytest) and installs are never treated as heavy.
  • Add your own heavy commands with the "Also heavy" setting (a regex), e.g. overnight_|nightly_run\.sh.

mod-monitor

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.

  • Failures: any mod hook that throws, times out or rejects, read from the hook chain's results (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.
  • What each mod did: its toasts ("#219 merged → …", "Blocked: …"), status-line changes, the mod commands you used (never their arguments), and failed subprocesses (a burst of 5 in 10 minutes raises a toast). Git checks run outside a repository are logged as expected, not as failures. It also records model calls (the only usage the mods cost: /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.
  • Logs: ~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.
  • Makes no model calls and adds no measurable latency.
  • Error lines mods log themselves ($.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.
  • Transcript-row hooks (secret-guard masks /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).

modal-meter

Keeps an eye on Modal so idle GPU containers don't burn credits.

  • Status line while containers run: Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.
  • A toast when an app has had containers up longer than 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.
  • Shows today's spend and alerts on a budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.
  • Finds the CLI as 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.
  • Makes no model calls: only the Modal CLI, every 60s.

pr-autopilot

Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.

  • Watches your open PRs in the session's repo: it adopts them at session start, and picks up every gh pr create Claude runs. It polls gh pr view every 60s.
  • Status line: PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.
  • When a PR merges, it cleans up with plain local git, then toasts the outcome and suggests carrying on (Tab to accept):
  • git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.
  • Never with uncommitted changes, never --force, never other branches.
  • Deletes the local branch only if it points at exactly the commit GitHub merged, so nothing local is lost. It also leaves a branch checked out in another worktree alone.
  • A merge seen mid-turn is cleaned up when the turn ends, so git never races Claude.
  • When you mention failing CI ("#258 is failing", "CI failed, fix it"), your message goes to Claude with 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.
  • Makes no model calls: only 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.

recall

Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.

  • What it searches: Claude Code sessions, Codex sessions, subagent and workflow runs, Claude's memory files, standing orders, second-opinion reviews, and your /remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.
  • What it keeps: prompts, answers, compaction summaries, session titles, commands, files touched, commits, PRs, issues, URLs, tasks and decisions (what you approved or ruled out). Read-only look-ups like grep and cat are kept but ranked low.
  • History survives cleanup: extracts stay searchable after Claude Code deletes old transcripts.
  • Claude searches it itself with four read-only tools, 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.
  • Commands:
  • /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>.
  • Bands:
  • Once per session: Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].
  • When a prompt mentions #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.
  • Query syntax: words must all match. 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.
  • Privacy:
  • The index lives at ~/.claude/recall/index.db, readable only by you, and never goes in a repo.
  • Secrets are masked before anything is stored: known token shapes, labelled values ("password: …"), the values of secret-named exports in ~/.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.
  • Cost: no model calls except /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.
  • Requires macOS's /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).

repo-brief

Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".

  • Gathers in the background at session start:
  • branch, ahead/behind and uncommitted files
  • the last 8 commits
  • open PRs with CI ✓/✗/…
  • issues labelled owner, todo, P0 or blocked
  • stale branches (merged, or upstream gone)
  • A one-line band above the prompt, e.g. main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.
  • Claude gets the same summary once, in its first message, so the prompt cache stays warm. It refreshes after compaction.
  • /brief re-gathers now and prints the full summary.
  • Makes no model calls: only git and gh. The band refreshes after a turn at most every 2 minutes.

Settings: focus labels, refresh minutes, and whether to brief Claude.

routine-watch

Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.

  • Knows a session is a routine from its scheduled-task prompt, and does nothing in your other sessions.
  • When a routine stops to wait for your OK on a permission prompt or an AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.
  • When a turn ends in an error, or the routine finishes, you get a notification: Routine daily-report finished after 23m · waited on you 2 times.
  • Phone push (optional): 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.
  • Makes no model calls.

second-opinion

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.
  • Other forms:
  • /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.
  • The command returns at once, and the status line shows 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.
  • Send to Claude attaches the review to your next prompt (once) and drafts "What do you agree with, and what would you act on?".
  • Reviews are saved in ~/.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).

standing-orders

Keeps your "always / never / don't / from now on" instructions alive across compaction.

  • When you write an instruction like "never open bambu with full spectrum files", a band asks: Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.
  • Project orders live in ~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session.
  • Claude gets them at the start of every conversation and again after each compaction or /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).
  • Makes no model calls.

secret-guard

Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.

  • Catches known token shapes: AWS, GitHub, Anthropic, OpenAI, Slack, Google, Hugging Face, GitLab, npm, Stripe, private keys and bearer tokens.
  • Also catches labelled values ("password: …", "api key = …", "the wifi password is …") and the two-line "Access Key ID / Secret Access Key" paste.
  • Leaves alone $NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.
  • On a hit, the prompt isn't sent and goes back in the box. A band shows the secret masked (…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:
  • Save as env var appends export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME.
  • Send anyway lets exactly that text through once.
  • Edit dismisses the band.
  • The value is never shown in toasts, status, state or the transcript, and /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.
  • Makes no model calls.

Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).

slicer-handoff

Makes Claude's open commands hand 3D files to the right slicer.

  • Full-spectrum files go to Snapmaker Orca. Bambu Studio and OrcaSlicer can't open them. A file counts as full-spectrum when:
  • its name or folder matches full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], or
  • its 3MF names a Full Spectrum filament profile.

An 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.

  • Earlier windows close first. Before opening a file, it asks the running slicer to quit (a normal quit, never forced), so windows don't pile up. If one won't close, for example because it's waiting on a save prompt, it stops trying and tells Claude to leave it alone.
  • /slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.
  • Recognizes 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.
  • Makes no model calls.

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.

Loading

  • One session from a terminal: pass --plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot
  • Every session, including the desktop app: add to ~/.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" } } ``

Checking

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)
Source 3 files
hooks/register.tsx 611 lines
1import { 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}
611
hooks/servers.ts 600 lines
1import 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
600
types/index.d.ts 64 lines
1/** 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