Manage Next.js dev servers across worktrees. Start, stop, and read logs from dev servers. Agents can access logs from any running session, regardless of who started it.
Centralized management of Next.js dev servers across multiple git worktrees. The daemon handles port allocation, environment variable injection, and log aggregation so that any agent can access dev server logs regardless of who started the server.
# Check what's running
node .claude/skills/dev-server/cli.mjs status
# Start a dev server for current worktree
node .claude/skills/dev-server/cli.mjs start
# Start for a specific worktree
node .claude/skills/dev-server/cli.mjs start /path/to/worktree
# Start with a service on production (see Env modes)
node .claude/skills/dev-server/cli.mjs start /path/to/worktree --prod buzz
# View logs
node .claude/skills/dev-server/cli.mjs logs <session-id>
# Stop a session
node .claude/skills/dev-server/cli.mjs stop <session-id>
Checking if server is ready: After starting, poll the session status to check ready: true. The daemon marks sessions ready either via configured health check endpoint or by detecting "Ready" patterns in logs.
The daemon is spawned with process.execPath (startDaemon in cli.mjs and console.mjs), i.e.
whatever node ran the CLI verb that first started it. It then passes its own environment down to every next dev it
supervises. So the node you happened to have on PATH the first time you typed any command above is the
node the whole tree runs on, until someone shuts the daemon down — and nothing records which one that was.
That is not academic. Measured on a dev box: the daemon was running on ambient node 26.7.0, while
.nvmrc pins 24.19.0, package.json declares engines.node: ">=24.0.0 <25", and production is built
on node:24.19.0-alpine3.24. It had been started from a shell outside the dev shell, and that shell also
had no pnpm at all — which silently disables the daemon's own auto-install path (it shells out to
pnpm install / pnpm run db:generate when it sees the lockfile or the schema move).
The fix, whatever your setup: start it from a shell whose node --version matches .nvmrc and which
has pnpm on PATH. nvm use at the repo root gives you both.
On NixOS (optional) the flake wrapper does that for you — it pins node to the same version .nvmrc
names, puts pnpm on PATH, and exports the Prisma engine paths NixOS needs:
nix run .#dev-server -- status
nix run .#dev-server -- start
nix run .#dev-server -- logs <session-id>
Every node .claude/skills/dev-server/cli.mjs … invocation elsewhere in this document takes the same
subcommands — the wrapper only decides which node runs them, so nothing here depends on having Nix.
Either way, check what you have got before trusting a session:
# the daemon's real interpreter, not the one you assume.
# the pid file belongs to the skill dir the daemon RUNS FROM, which is the primary checkout — a
# relative path here reads the wrong tree's file, or none, when you are standing in a worktree.
readlink -f /proc/$(cat <primary-checkout>/.claude/skills/dev-server/daemon.pid)/exe
Changing node means restarting the daemon — cli.mjs shutdown, then start it again from the right shell.
A running daemon will not pick up a new PATH.
DEV_DAEMON_PORTThe daemon lives on 127.0.0.1:9444. Set DEV_DAEMON_PORT to stand another one beside it:
DEV_DAEMON_PORT=9555 node .claude/skills/dev-server/cli.mjs status
The CLI, the console, scripts/test-unit-run.mjs and the daemon itself all resolve the port through
scripts/daemon-port.mjs, and a daemon a client spawns inherits that client's environment — so no two
of them can disagree about where it is. Until 2026-08-19 the daemon did not read the variable at all,
and setting it pointed the client at a port nothing was serving.
The variable is only a default for the daemon; an explicit node scripts/daemon.mjs --port <port> still
wins. Each daemon owns the shared daemon.pid, so the last one started is the one that file names.
probe insteadnode .claude/skills/dev-server/cli.mjs probe /home
A dev server that has stopped serving properly does not refuse connections. It accepts and answers
slowly, or accepts and never answers — so an unbounded curl sits there until the 300s tool timeout
and returns nothing you can act on. Chaining a few in one shell call is how ten minutes disappear.
A PreToolUse hook blocks unbounded requests at dev ports for this reason; --max-time is the
escape hatch if you really want curl.
probe requests the route twice with a hard budget, reads the session's own log for those two
requests, and returns a verdict with the matching remedy. It always terminates.
UPSTREAM-SLOW http://localhost:3000/home
UPSTREAM-SLOW — the framework is fine; time is spent in application code (database, tunnel, cache).
first : 200 in 8.10s [next.js 30ms | application-code 8.10s]
repeat: 200 in 8.10s [next.js 31ms | application-code 8.10s]
-> Not a cache problem — do NOT purge, it will not help. [...]
Exit code is 0 for ok/cold and 1 for everything else, so it substitutes for a curl in a check.
Two verdicts worth knowing before you meet them. stopped-answering means the first request was
served and the second was not — the process is up and something inside it has parked, so starting
another session is the wrong move and the remedy says so. And a probe may add
note: more than one request to this route in the window: probe tags its own request with a
?__probe= nonce so it can find its own log line, but a route that already has a query string —
and every /api/trpc/* route, whose handlers parse their query — falls back to matching on the
path, where a busy session's traffic is genuinely indistinguishable from ours. The note means the
reading may not be about your request, which is different from the server declining to explain
itself.
Two different failures produce the same shape — a page that takes ~8s, warm and cold alike, with
a 200 and nothing in the log that reads as an error. Health checks keep passing through both, because
/api/health is a cheap already-compiled API route and neither failure touches what it does.
Next already separates them on every request line it prints:
GET /home 200 in 8.1s (next.js: 39ms, proxy.ts: 8ms, application-code: 8.1s)
| Reading | Meaning | Remedy |
|---|---|---|
| next.js dominates on a repeat hit | The build cache is not serving; the framework redoes the work every time | unwedge — purge and restart |
| application-code dominates | The framework is fine; the time is downstream (database, tunnel, cache) | Fix the upstream. Purging costs 45s and changes nothing |
| Repeat is fast | Cold compile, working as designed | Nothing |
Two failures are FAST, and both are checked before any timing rule, because a verdict of ok on
a broken page is worse than no verdict. A stale node_modules after a merge or checkout 500s in
milliseconds (STALE-DEPS — the fix is pnpm install, and purging the build cache installs
nothing); and the settings self-fetch case below renders a degraded page quickly.
One case defeats the split, and probe checks for it first. _app self-fetches
/api/user/settings on every SSR render and aborts at APP_SETTINGS_FETCH_TIMEOUT_MS (8s default).
That abort is billed to application-code, so a server that cannot answer its own API route reads as
"slow database" on the split alone. The greppable marker [_app] settings bootstrap fetch failed
outranks the timing, and the verdict is SELF-FETCH-FAILING; probe then requests that endpoint
directly and tells you which of the two causes you have — a self-fetch aimed at the wrong port
(NEXTAUTH_URL_INTERNAL vs the session's port) or an endpoint that genuinely never answers.
The tell that separates it from any slow dependency: the page time is a constant equal to a
configured timeout, identical warm and cold. A slow dependency varies; a timeout does not. Measured
2026-08-16 — moving APP_SETTINGS_FETCH_TIMEOUT_MS from 8000 to 30000 moved /home from 8.1s to
30.1s in lockstep, and purging .next did not help at either value.
Worth knowing when you read that verdict: updateEnvUrlsForPort returns early on port 3000, so a
primary session uses NEXTAUTH_URL_INTERNAL exactly as the .env writes it while every secondary
session gets it rewritten to its own port. The value is correct today — this is only why the two
kinds of session can differ, and why the verdict asks you to compare it against the session's port.
This is why "flat 8s means purge .next" is wrong as a rule and cost real time as a habit: measured
on this repo on 2026-08-16, a flat 8.1s /home was 30ms of framework and 8.1s of application
code — a purge would have deleted several GB and fixed nothing. Read the split, not the total.
When the split is unavailable (log buffer overran, no session), the verdict is slow-unclassified
and it says so rather than guessing.
unwedge — only after a WEDGED verdictnode .claude/skills/dev-server/cli.mjs unwedge <session-id>
Stops the session, deletes its build dir, restarts on the same env modes, waits for ready, re-probes, and prints each timing. It costs a guaranteed ~45s rebuild, so it is not automatic and it does not guess a session: name the id, because the session you did not name is usually the one someone is looking at.
Self-tests. Standalone scripts, no vitest — run the ones your change touches, and cli-verbs
after any edit to cli.mjs:
node .claude/skills/dev-server/scripts/env-chain.selftest.mjs # .env layering, both directions
node .claude/skills/dev-server/scripts/app-registry.selftest.mjs # registry, ports, path identity, the lock
node .claude/skills/dev-server/scripts/db-host.selftest.mjs # the DB host line never emits a credential
node .claude/skills/dev-server/scripts/cli-verbs.selftest.mjs # every dispatch target in cli.mjs exists
node .claude/skills/dev-server/scripts/branch-watch.selftest.mjs # HEAD watching + the restart decision
node .claude/skills/dev-server/scripts/probe.selftest.mjs # the classifier, pure
node .claude/skills/dev-server/scripts/probe.integration.selftest.mjs # the real probe() end to end
node .claude/skills/dev-server/scripts/worktree.selftest.mjs # what `wt stale` / `wt rm` say about a PR, a prune, and the daemon's home
node .claude/skills/dev-server/scripts/worktree-remove.integration.selftest.mjs # `wt rm`'s daemon guard, against a throwaway repo
node .claude/skills/dev-server/scripts/daemon-home.selftest.mjs # the daemon runs from the primary, never the calling worktree
node .claude/hooks/check-writable.selftest.mjs # the hook, both directions
The integration one exists because the unit one cannot see the bug that matters most. runSample
once dropped missingModule on the way to classify, so the whole stale-deps verdict was dead
code — and every unit case for it passed, because they hand-built the field the shipping code never
produced. Reintroduce that bug today and the unit test is still green while the integration test
fails. Anything that adds a new signal belongs in the integration file, not just the unit one.
| Command | Description |
|---------|-------------|
| probe [route] | Bounded request + verdict (ok/cold/wedged/stale-deps/upstream-slow/proxy-slow/error-status/self-fetch-failing). Use instead of curl |
| unwedge <session-id> | Stop, purge the build dir, restart, wait, re-probe (~45s) |
| status | Check daemon status and list all sessions |
| list | List all dev sessions |
| start [worktree] [--app name] [--prod a,b] [--dev a,b] | Start a dev server (default: the main app, current directory) |
| logs [session-id] [--app name] | Get logs for a session, or for an app in this worktree |
| tail [session-id] [--app name] | Tail logs continuously |
| stop <session-id> | stop --app name | Stop a session or an app |
| restart <session-id> | restart --app name | Restart a session or an app |
| rgb [subcmd] | RGB proxy control (status|start|stop|restart|logs) |
| app | List running apps, their worktree, and what is available |
| app <name> [subcmd] [worktree] | App control (status|start|stop|restart|logs) |
| auth [subcmd] | Auth hub control (status|start|stop|restart|logs) |
| test run [worktree] | Queue a unit-test run; returns position + the command to wait on it |
| test wait <run-id> | Block until that run finishes; exits with the run's exit code |
| test list / test show <id> / test logs <id> | Queue state, one run, one run's output |
| test cancel <id> | Cancel a queued or running run |
| test config [n] | Show or set the concurrency limit (0 pauses the queue) |
| shutdown | Shutdown the daemon |
A session layers its .env files — the primary checkout's as the base, the worktree's own on top —
and then applies a per-service overlay on that. The overlay only restates the keys for the
services it names, so nothing else in the chain moves.
No env-modes.local means no overlay at all — not "everything on dev". The file is gitignored,
so it never comes with a checkout: until it exists, every start runs on the base .env exactly as
before this feature, and the summary says (no groups defined — no overlay applied).
# Every defined group on dev. This is what a bare start does.
node .claude/skills/dev-server/cli.mjs start
# Buzz on production, everything else still dev.
node .claude/skills/dev-server/cli.mjs start --prod buzz
# Several groups, the whole lot, and the whole lot with an exception.
node .claude/skills/dev-server/cli.mjs start --prod db,search
node .claude/skills/dev-server/cli.mjs start --prod all
node .claude/skills/dev-server/cli.mjs start --prod all --dev search
--prod/--dev can only move the groups your own env-modes.local defines, and that file is
gitignored — a fresh copy of env-modes.example defines none, so --prod all is a no-op until you
fill it in. Adding a service is an edit to that file, not to the code.
⚠️ env-modes.local does not fall through the way .env now does. It is read from the skill
directory of the daemon that is running (env-modes.mjs), and that file is gitignored, so it exists
only in the primary checkout. A CLI or console carrying the resolveDaemonHome change spawns the
daemon from the primary, which handles it — but the skill directory is committed, so each worktree
runs its own copy: a tree cut before that change still spawns the daemon into itself, and so does a
daemon started by hand with node scripts/daemon.mjs. Either way it reads that worktree's skill
directory, finds no env-modes.local, and applies no overlay at all.
Several services have no dev counterpart to move to at all — env-modes.mjs lists orchestrator,
payments, s3, clickhouse, notifications, feeds and opensearch in PROD_ONLY_GROUPS, and auth-hub
in UNMOVABLE_GROUPS (it is one shared process reading its own .env, so an overlay there would
repoint the app at a hub that is not running). mode: dev therefore never means "nothing here is
production".
⚠️ Apps have no env modes. --prod/--dev apply to the main app only; an app runs on its .env
chain with no overlay. start --app <name> --prod db is refused rather than silently ignored.
Defaults. Every group defaults to dev. Move a default in the skill's own .env when a dev
service is unreliable:
DEVSERVER_PROD_GROUPS=buzz
Editing that line does not move a session that is already up — a session pins the defaults it was
created with, so no branch switch or crash restart can quietly relocate it. The cost is that until
those sessions are restarted, a bare start against one of them is refused as a mode mismatch,
which is accurate rather than convenient: a bare start would now resolve to something else.
A --prod / --dev flag beats that, and the flag applies to that start only — a bare start after
a --prod start is back on dev, including when it reuses a dead session. Asking for different
modes while a session is already running that worktree is refused rather than silently answered
with the running one; stop it first.
⚠️ dev is not a synonym for safe. The orchestrator, payments, S3, ClickHouse, the
notifications DB, the feeds proxy and OpenSearch have no dev counterpart at all — a session in
full dev mode still talks to production for every one of them. Pressing Generate submits a real job
and spends real Buzz whatever the mode says. That list is printed after every mode summary for
exactly this reason.
The auth hub does not follow db. It is one process shared by every session and reads its own
apps/auth/.env, so a --prod db session authenticates against whatever database that file names
and then resolves the resulting user id against production. Dev and prod user ids are unrelated
rows, so expect a 404 — or, worse, to be acting as a different real user. Point apps/auth/.env at
the same database by hand before using --prod db with a login.
Changing search or signals mode on a warm build dir is not fully clean. Those groups set
NEXT_PUBLIC_* values, which Turbopack inlines into client chunks, and the build dir is keyed on
branch rather than on mode. Delete .next when you change either of them if the browser matters —
server-side code reads the new value immediately, so this only affects what the client was compiled
against.
Where to read a running session's modes: status and list carry envModes and
envModeSummary, and the daemon log prints them next to Env: at start:
Env: C:\Dev\Repos\work\wt-thing\.env
Env modes: buzz=dev db=prod redis=dev search=dev signals=dev | always prod (no dev target): ...
The unit suite takes every core. One run is fine; five agents each starting one at the same moment is what flattens the machine. The daemon serialises them.
# 1. Request a run. Returns immediately, whether it started or queued.
node .claude/skills/dev-server/cli.mjs test run
# Run t3f9a2 queued at position 2 of 3 (1/1 running).
# Wait for it in the background: node .claude/skills/dev-server/cli.mjs test wait t3f9a2
# 2. Wait for it — in the background, so you can work meanwhile.
node .claude/skills/dev-server/cli.mjs test wait t3f9a2
test wait exits with the run's own exit code, so it substitutes for pnpm run test:unit:run
wherever that was being checked. Extra args after -- are passed to vitest, so
test run . -- path/to/one.test.ts narrows the run.
Concurrency defaults to 1, configurable with TEST_CONCURRENCY in the skill's .env or at
runtime with test config <n>. 0 is legal and means paused — nothing starts until it is raised.
A caller that queues behind a paused queue is told so explicitly rather than being handed a position
and left waiting.
Things worth knowing before you rely on it:
test wait polls every 2s, so a live waiter never trips this.timeout. If the kill produces no
exit, the slot is released anyway after a grace period rather than held forever.test wait treats an unknown run id as terminal and exits
nonzero telling you to re-request — it will not poll forever against a daemon that has forgotten
you.test wait or a test logs read can be a
fragment. Both waiters print WARNING: this log is INCOMPLETE … naming how many lines went,
and logsDropped is on the run view — a non-zero value means do not quote what you see as
the whole run. A live waiter that has been streaming from the start is unaffected.exitCodeFor's, in both waiters. test wait and pnpm run test:unit:run
read the same rule, so a run killed by a signal reports 1 from either, never a shell 255.Each session includes:
{
"id": "a1b2c3d4",
"worktree": "/path/to/worktree",
"branch": "feature/my-feature",
"port": 3000,
"status": "running",
"ready": true,
"readyAt": "2024-01-15T10:30:02.000Z",
"startedAt": "2024-01-15T10:30:00.000Z",
"url": "http://localhost:3000",
"envModes": { "db": "dev", "buzz": "dev", "search": "dev", "signals": "dev", "redis": "dev" },
"envModeSummary": "buzz=dev db=dev ... | always prod (no dev target): orchestrator, payments, ..."
}
Status values: starting, running, stopped, crashed, error
{
"index": 42,
"timestamp": "2024-01-15T10:30:05.123Z",
"level": "stdout",
"message": "Ready on http://localhost:3000"
}
Log levels: stdout, stderr, error, warn, info
Run node .claude/skills/dev-server/console.mjs (or pnpm run dev:daemon) for a live terminal dashboard.
| Key | Action |
|-----|--------|
| 1 | Filter: errors (error + warn levels) |
| 3 | Filter: trpc |
| 4 | Filter: api |
| 5 | Filter: prisma |
| 6 | Filter: stdout only |
| 7 | Filter: stderr only |
| 8 | Filter: info (daemon messages) |
| / or f | Free-text search (type query, Enter to apply) |
| a | Show all logs (clear filter) |
| r | Restart session |
| c | Clear log buffer |
| x | Stop session + exit |
| R | Toggle RGB proxy (start/stop) |
| A | Toggle auth hub (start/stop) |
| s or Tab | Switch to the next session (only shown when more than one is running) |
| q | Quit dashboard (server keeps running) |
| K | Kill daemon + quit |
Filters toggle on/off. Active filter is highlighted in the footer bar. Search highlights matching text in red.
The daemon can optionally manage the rgb-proxy reverse proxy (serves civitai-dev.{red,green,blue} against the local dev server).
Edit .claude/skills/dev-server/.env:
RGB_PROXY_ENABLED=true # auto-start proxy when daemon boots
RGB_PROXY_PATH=../rgb-proxy # path relative to project root
Also ensure the main .env has NEXTAUTH_URL=https://civitai-dev.blue + SERVER_DOMAIN_* and hosts file maps the three domains to 127.0.0.1. See .claude/skills/rgb-proxy/SKILL.md for first-time setup.
# Start / stop / restart / status / logs via CLI
node .claude/skills/dev-server/cli.mjs rgb start
node .claude/skills/dev-server/cli.mjs rgb status
# Or via pnpm scripts
pnpm dev:rgb # start proxy (daemon boots if not already running)
pnpm dev:rgb:stop
pnpm dev:rgb:status
In the dashboard TUI, press R to toggle the proxy.
Redbird binds ports 80 and 443. On Windows the daemon must be launched from an elevated terminal; on macOS/Linux start it with sudo. If it fails the daemon surfaces lastError via /rgb status and in RGB proxy logs.
moderator and creator-studio run throug
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer