Background dev server

zigapagos dev normally blocks the terminal it was started in. dev --background detaches it instead — a small feature aimed squarely at AI coding agents: edit a file, poll a JSON status endpoint until a rebuild lands, act on the result, all without a dev process pinning a terminal for the whole session.

This document is the consumer contract for the pieces a script or an agent codes against: the CLI surface, the /_zigapagos/status JSON shape, and .zigbase/dev.json’s fields.

Foreground vs background

zigapagos dev                 # blocks this terminal; Ctrl-C stops it
zigapagos dev --background    # detaches, prints facts, exits 0

--background re-execs this same binary as a detached child (own process group; stdin ignored; stdout/stderr redirected to .zigbase/dev.log, truncated at the start of the session) and polls for the lockfile to appear naming that child’s pid — the same signal waitReady already gates the ready banner on, so a zigbase that fails to boot fails background startup the same way it fails a foreground run. The parent exits 0 only once the server actually answers, printing:

dev: running in the background at http://127.0.0.1:1990/ (pid 12345)
dev: control:  http://127.0.0.1:43121/_zigapagos/status
dev: log file: /abs/path/.zigbase/dev.log
dev: manage:   zigapagos dev stop | status | logs [--follow]
  • Child dies before becoming ready → parent prints the log tail, exits 1.
  • 30 s pass with no readiness → parent SIGTERMs the child’s whole process group, removes any partial lockfile, prints the log tail, exits 1.
  • A live session is already running → --background is idempotent: it prints the existing URL/PID and exits 0. --force stops that session first (the same path as dev stop), then starts fresh. --ignore-lock starts an untracked instance — it never writes the lockfile, and always prints a notice that this instance is untracked (whether or not a tracked one already exists) — and hard-errors when combined with --background or --force (an untracked instance has no lockfile for --background’s own readiness handshake to poll).

Editing and browser recovery

Content, layout, asset and discovered component source edits trigger rebuilds. A successful build updates connected browsers. If the reload connection drops, the browser reconnects automatically. It performs one full page refresh when successful rebuild notifications were missed, including when several HTML/CSS edits occurred while disconnected. Reconnecting without missed updates leaves the page alone. A restarted server at the same reload address also refreshes an existing tab. If the restart picks a different reload port, manually refresh the tab once.

A failed rebuild sets build.status to "failed" and retains an error tail; it does not broadcast a browser reload. Fix the source and save again. The next successful rebuild clears the error and updates the browser. Build commands write output in place: this is not a transactional last-good-output snapshot, so manually refreshing during a failed or running build can expose partial output.

Restart dev after editing root configuration such as zigapagos.ziggy, z-runtime.config.json, or tsconfig.json, or changing which source directories exist. The session discovers its watch directories and URL prefix at startup; root config edits alone do not currently trigger a rebuild. Status and dev wait describe observed source edits, not unwatched configuration changes.

The reload stream is a dev-only transport. New streams announce a session/reload cursor in a named zigapagos-ready event and include it on later events. Native EventSource reconnections send Last-Event-ID; a stale cursor requests a full refresh rather than attempting to replay a possibly incomplete island delta.

Sites with a url_path_prefix

A site with url_path_prefix set in zigapagos.ziggy (the documented setup for a GitHub Pages project site, served from https://user.github.io/repo/ rather than the domain root — this repo’s own site/zigapagos.ziggy uses it) emits every href, asset link and island module URL under /<prefix>/…. The built tree itself deliberately contains no prefix directory — url_path_prefix says where a host mounts the tree, not where files sit inside it (see docs/spa.md) — so a plain root mount 404s every one of those URLs.

dev (and zigapagos e2e --url-prefix=) handle this automatically: they stage a served root that mounts the built tree at /<prefix>/ — refreshed after every rebuild — and point zigbase at that staged root instead of at the tree itself, so local URLs resolve exactly like production. Nested prefixes (docs/v2) work the same way.

Two knock-on effects, both by design:

  • GET / 404s. The staged root’s only real content lives under /<prefix>/, so there is no index.html at the served root. The readiness probe and the printed banner URL already account for this — both default to /<prefix>/ instead of / when the site has a prefix — but a manually passed --ready-path=/, or a bookmark at the bare origin, still 404s.
  • Unprefixed sites are completely unaffected. No staging happens and zigbase serves the built tree directly, byte-identical to before this existed.

The staged copy lives at .zigbase/.served-root/ and — deliberately — is never cleaned up when dev exits: it shares the (persistent, gitignored) zigbase data dir’s own lifecycle, so the next dev session reuses the directory and just re-stages into it, the same way .zigbase/ itself is never deleted between sessions.

Control verbs

zigapagos dev stop
zigapagos dev status [--json]
zigapagos dev wait [--timeout-ms=N]
zigapagos dev logs [--json] [--follow|-f]

Every sub-verb accepts --data-dir=DIR (same meaning as dev’s own flag) and must be run from the site directory — they resolve the data dir against zigapagos.ziggy the same way dev itself does.

dev stop — idempotent: nothing running is a friendly no-op, exit 0. A live session gets SIGTERM on the dev pid (the existing reaper — the signal handler dev installs on boot — cascades that into its own tracked zigbase child), polled for up to 5 s, escalated to SIGKILL of that same dev pid if it doesn’t respond. Neither signal targets a process group here — that’s --background’s 30 s startup-timeout path, not stop‘s — which matters because a SIGKILLed dev process never runs the reaper at all, and its zigbase is left orphaned on the port. That is exactly why the orphan sweep runs afterward regardless: if the lockfile’s zigbase_pid is still alive, stop confirms it is really our zigbase by hitting its /api/health, then TERMs/KILLs it — covering whatever the reaper missed (or never ran to cover). This is what retires the documented pkill zigbase recovery for a kill -9‘d dev session. One case exits 1 instead of 0: a session that holds the lock but hasn’t published dev.json yet (mid-startup) — stop reports that truthfully rather than silently doing nothing.

dev status — exit 0 with the session’s facts when one is running, exit 1 (“No dev server is running.” or {"running":false} under --json) when not. A third, in-between state: a session can hold the lock but not have published dev.json yet — the same startup window dev stop already reports as its own .starting case (see above) — and status reports it truthfully rather than folding it into “not running”: exit 1, “A dev session is starting (lock held, dev.json not yet published) — retry in a few seconds.” on stdout, or {"running":false,"starting":true} under --json. status never polls/blocks to resolve this — it is a snapshot verb, so it reports whatever it sees on one immediate read and lets the caller retry. When the control endpoint can’t be reached for a session that has published dev.json (e.g. it’s still warming up), status degrades gracefully instead: it still prints from the lockfile alone, with build: unknown (control server unreachable). Control response reads have a one-second budget. Timeouts and read errors (including a connection reset after response bytes arrive) discard that response rather than accepting a partial body; status then uses the lockfile fallback above. This bounds response reads, not connection establishment or request writes.

dev logs [--follow] — prints .zigbase/dev.log. A foreground session has no log file to read — its output is in the terminal that started it — so logs points there and exits 1. --follow/-f stat-polls for new bytes every 200 ms and exits once the session’s lock is no longer held. Reads are capped at 16 MiB; past that, logs fails with a message pointing at reading the file directly (tail -f). With --json, logs reads the structured build stream instead, which is available for tracked foreground sessions too; see Waiting and structured build logs.

The status endpoint

GET http://127.0.0.1:<control_port>/_zigapagos/status — the same server that already serves the SSE live-reload stream, now always on (even under --no-live-reload, which disables only snippet injection and reload events). CORS is wide open (access-control-allow-origin: *), same as the reload stream.

curl -s http://127.0.0.1:43121/_zigapagos/status | jq .
{
  "ok": true,
  "pid": 12345,
  "url": "http://127.0.0.1:1990/",
  "started_at": "2026-08-07T12:34:56Z",
  "build": {
    "generation": 7,
    "status": "ok",
    "duration_ms": 412,
    "error": null
  }
}
fieldtypemeaning
okboolalways true — the endpoint answered
pidintthe dev process’s pid
urlstringthe served site’s URL
started_atstringISO-8601 UTC, session start
build.generationintmonotonic counter, bumped once per completed rebuild (the initial build is generation 1)
build.status"ok" | "failed" | "building"outcome of the most recently finished rebuild, or "building" while one is in flight
build.duration_msinthow long that rebuild took
build.errorstring | nulla bounded tail of the failed rebuild’s captured output, null on success

generation bumps on both success and failure — an agent polling for its own edit to land needs the counter to move either way, and only then branches on status.

The agent workflow this is for:

  1. Edit a file.
  2. Poll /_zigapagos/status until build.generation is greater than the value observed before the edit.
  3. Branch on build.status: "ok" → fetch the page; "failed" → read build.error; "building" → keep polling.

This is deliberately more than Astro’s equivalent endpoint, which returns only {"ok": true} and gives an agent nothing to act on.

The lockfile

.zigbase/dev.json — written by every dev session, foreground and background, immediately after waitReady (ports are late-bound, so nothing sooner is knowable). Written atomically (temp file + rename). Treat it as a read-only contract: dev owns it, tooling only reads it.

{
  "version": 1,
  "pid": 12345,
  "zigbase_pid": 12346,
  "host": "127.0.0.1",
  "port": 1990,
  "url": "http://127.0.0.1:1990/",
  "control_port": 43121,
  "data_dir": "/abs/path/.zigbase",
  "background": true,
  "started_at": "2026-08-07T12:34:56Z"
}
fieldmeaning
versionlockfile schema version (currently 1)
pidthe dev process
zigbase_pidthe zigbase child dev spawned
hostthe session’s --host (default 127.0.0.1) — the bind address for both port and control_port
portthe served site’s port
urlthe served site’s URL
control_portthe /_zigapagos/status port
data_dirabsolute path to the ZigBase data dir
backgroundtrue for a --background session, false for foreground
started_atISO-8601 UTC

Liveness is a try-lock, not a pid check. .zigbase/dev.lock is an empty file the dev process holds flock(LOCK_EX) on for its whole lifetime; the kernel drops that lock the instant the process dies, by any means, including kill -9. status/stop/duplicate-start detection all attempt a non-blocking flock: acquirable → stale (remove dev.json, treat as absent); busy → a live session really is there. This sidesteps both of kill(pid, 0)’s failure modes — PID reuse reading as “alive”, and EPERM (alive, just not ours) reading as “dead”. dev.lock itself is never deleted — unlinking a file another process still holds an flock on does not release that lock, it just lets a second lockfile of the same name coexist and defeats the whole liveness scheme.

.zigbase/dev.log — a background session’s stdout+stderr, truncated at the start of each --background session (the same content a foreground run prints to its terminal — no banner strings changed to make this work).

.zigbase/last-build.log — captured fresh on every rebuild in the watch loop, purely as the source for build.error’s tail on a failed build; the initial build streams straight to the terminal/log and is never captured here.

Agent auto-detection

Detected agent environments get backgrounded automatically — the same idea as Astro’s astro dev --background, ported from the same am-i-vibing-shaped table: env-var checks only, no process-ancestry sniffing, and no hybrid/interactive entries (a Warp-the-terminal false positive was Astro’s own first post-release fix for this).

env varprovider
CLAUDECODEClaude Code
CODEX_THREAD_IDOpenAI Codex
GEMINI_CLIGemini CLI
CODEIUM_EDITOR_APP_ROOTWindsurf
AIDER_API_KEYAider
OZ_RUN_IDWarp agent
AMP_CURRENT_THREAD_IDAmp
AUGMENT_AGENTAuggie
QWEN_CODEQwen Code
ANTIGRAVITY_AGENTAntigravity
PI_CODING_AGENTPi
OPENCODEOpenCode
CRUSHCrush
CURSOR_TRACE_ID + PAGER=head -n 10000 | catCursor agent
AGENT (any non-empty value)agent (AGENT env)
AI_AGENT (any non-empty value)agent (AI_AGENT env)

An empty value never counts (an exported-but-unset shell variable is not a signal); CURSOR_TRACE_ID alone is the interactive terminal, not an agent — only paired with the agent-mode PAGER rewrite does it count.

Detection applies to zigapagos dev only — never to a sub-verb, and never when ZIGAPAGOS_DEV_BACKGROUND_CHILD is set (see below). A detected agent backgrounds exactly as if --background had been passed, and the parent says so: dev: agent environment detected (Claude Code) — starting in the background (set ZIGAPAGOS_DEV_BACKGROUND=0 to disable).

  • ZIGAPAGOS_DEV_BACKGROUND=0 (or any value other than 1) disables auto-detection. =1 forces background regardless of detection.
  • ZIGAPAGOS_DEV_BACKGROUND_CHILD is internal — the recursion guard set on the re-exec’d child so it runs the plain foreground path instead of backgrounding itself again. Deliberately a separate variable from the opt-out above: conflating the two (as Astro does) means opting out corrupts the child’s own lockfile background field.

Precedence (checked in this order — the first match decides, nothing later is consulted): the recursion guard (ZIGAPAGOS_DEV_BACKGROUND_CHILD) forces foreground unconditionally; short of that, an explicit --background wins outright, with no provider attribution; short of that, --ignore-lock suppresses auto-detection entirely (an untracked instance has no lockfile for the readiness handshake to poll); short of that, the opt-out env is an explicit override (=1 backgrounds, anything else foregrounds); only then does agent detection get to decide, and only then is a provider ever attributed in the parent’s output.

Conventions for ZigBase (portability, not v1 work)

If zigbase ever grows serve --background for standalone dev use, it adopts these as-is rather than inventing a second pattern:

  • Lockfile: same field names (version, pid, port, url, background, started_at), same atomic write, same flock-held-for-lifetime liveness.
  • Same verb shape (stop|status|logs [--follow]), same idempotency rules, same --ignore-lock semantics and conflicts.
  • Same two-variable rule: internal recursion guard ≠ user-facing opt-out.
  • Same readiness handshake: lockfile appears only when the server is actually serving.

Waiting and structured build logs

zigapagos dev wait [--timeout-ms=30000] [--data-dir=DIR] waits for watcher debounce, queued changes, and the current rebuild to settle. It prints the final status JSON and exits 0 for success or 1 for a failed build, stopped session, or timeout. It observes changes already delivered to the watcher, not future edits. The status endpoint’s build.pending remains true when another edit arrives during a rebuild; staging the served output finishes before a build settles.

zigapagos dev logs --json [--follow] reads .zigbase/dev.ndjson, available for tracked foreground and background sessions. Each line is a version-1 build event with kind: "build", timestamp_ms, generation, status, duration_ms, and error. Starts and finishes are recorded in order; generation counts completed builds. Error strings are JSON escaped. The file resets on session startup. This is a build-event stream, not a JSON conversion of arbitrary subprocess output; plain dev logs still reads the background log.

Out of scope

  • Log rotation, restart-on-crash, any supervisor process — not planned; dev --background is one detached process, no daemon.
  • Windows rides the Zig 0.17 port (see docs/ROADMAP.md); anything resembling a proxy in front of zigbase was removed deliberately (issue #56) and is not coming back.