The Agentheim dashboard server — Agentheim's first runtime. Node standard library
only: no framework, no node_modules, no install step. Runs on the Node that
Claude Code already provides. See ADR-0002
(.agentheim/knowledge/decisions/0002-dashboard-runtime-transport.md) for the
transport/launch/discovery decision this implements.
Server skeleton (agentic-workflow-004): static asset serving + a health check, the
live-update SSE stream (infrastructure-003, ADR-0006), and the read endpoints
GET /api/tree + GET /api/doc (agentic-workflow-005). The dashboard is read-only
(ADR-0017): it has no write path. Not here:
- No
POST /api/task/move— the dashboard never writes lifecycle state. The drag-to-Promote endpoint (agentic-workflow-009) was removed (ADR-0017); skills (modeling/work) are the sole owners of task-lifecycle transitions and the board reflects their on-disk moves via the SSE stream. The canonical moverapplyTaskMove(lib/task-lifecycle.mjs) lives on for the skills. - The pre-bundled
dist/assets — infrastructure-002
The single read model the board, slide-over, and navigation all rebuild from, and that the
SSE consumer re-fetches on a tree-changed frame.
GET /api/tree→ JSON. Walks the discovered.agentheim/and returns, per BC, its four lifecycle folders and each task's frontmatter projection (id, title, status, type, context, path), plus the locations of vision / context-map / per-BC README+INDEX+concepts / ADRs / research. Pointers and metadata only — no document bodies. A task with missingstatus/contextfalls back to its folder / BC name (disk is the source of truth); malformed frontmatter degrades gracefully — the card is still listed, the walk never aborts. The projection logic lives intree.mjs(stdlib-only frontmatter parser, no YAML dependency).GET /api/doc?path=<in-root path>→ rawtext/markdown. A validated file carrier for one artifact's body (rendering is client-side).pathis project-root-relative (e.g..agentheim/vision.md); it is resolved against the root and an escaping path is rejected 403 touching no file. Missingpath→ 400; a non-existent or non-file in-root path → 404.
Both endpoints are pure reads, reuse the discovery.mjs startsWith(root) guard, and never
write or interpret a lifecycle move.
A long-lived text/event-stream connection that pushes a debounced tree-changed
pointer whenever the project's .agentheim/ tree changes, so an open board re-fetches
/api/tree and re-renders in near-real-time. Transport only — the payload is a raw
pointer, never an interpreted Task transition (that is agentic-workflow-009's job).
- Frame:
event: tree-changed\ndata: {"type":"tree-changed","path":"<root-relative>"}. An openinghelloframe and periodic:heartbeat comment frames keep the stream live; aretry:hint plus the browser'sEventSourcehandle reconnect automatically. - Watcher:
node:fs.watch(.agentheim, { recursive: true })where supported (macOS, Windows) with a debounced stat-poll fallback where recursive watch is unreliable (Linux, some Windows / network-drive cases). Bursts (a move = delete+create, atomic saves, folder renames) collapse into one event. Every emitted path is validated against the discovered root; the dashboard's own writes are not special-cased.
Until infrastructure-002 lands, dashboard/dist/ is absent and the static handler
returns a graceful 404 ("assets not built yet"). The server itself runs fine.
The static handler serves the committed build from a module-relative directory:
defaultAssetRoot() resolves dist/ beside server.mjs (via import.meta.url), not
against the discovered project root. This is the ADR-0002 "plugin-relative directory"
contract and the asset-serving consequence of ADR-0004's cwd/root decoupling — when the
plugin is installed and pointed at a foreign project (project root ≠ plugin dir), the
built dist/ still lives in the plugin cache beside the module, so the foreign root holds
none. serve.mjs honours AGENTHEIM_DASHBOARD_DIST as a dev override; the explicit
assetRoot parameter on createDashboardServer remains the test/override seam.
(infrastructure-004)
node dashboard/launch.mjs # launch (detached); prints the URL (open it yourself)
node dashboard/launch.mjs stop # stop: kill by pid, remove the runfile
node dashboard/launch.mjs status # report running/not-running + port (read-only, never launches)The builder-facing surface is the /dashboard slash command (agentic-workflow-011,
commands/dashboard.md) — a thin trigger that passes the verb straight through to this
launcher. /dashboard, /dashboard stop, /dashboard status. It is the single, documented
exception to Agentheim's "phrasing, not slash commands" rule (a process-launcher, not a
dialogue).
Launcher location: env-independent resolver, run from the project cwd — ADR-0002 (infrastructure-010)
launch.mjs ships inside the plugin's install dir, not in the consumer project. When
Agentheim runs as an installed plugin against a foreign codebase there is no dashboard/
beside that project's .agentheim/, so a project-relative node dashboard/launch.mjs would
fail with a module-not-found error.
infrastructure-008 first reached the launcher via node "${CLAUDE_PLUGIN_ROOT:-.}/dashboard/launch.mjs",
but the field disproved that approach: $CLAUDE_PLUGIN_ROOT comes through empty in the
command's Bash context for an installed plugin, so ${VAR:-.} collapsed back to . → the
consumer project → the exact module-not-found error it set out to fix. The launcher location
must never depend on $CLAUDE_PLUGIN_ROOT (infrastructure-010; see the ADR-0002 addendum).
The /dashboard command instead runs a tiny env-free node -e bootstrap that derives the plugin
cache path from os.homedir() (<home>/.claude/plugins/cache/agentheim/agentheim), picks the
newest cached version by semver (0.8.10 > 0.8.9 > 0.8.3), imports
dashboard/resolve-launcher.mjs from there (or the repo-local copy when run from the Agentheim
repo), and that resolver spawns launch.mjs. If no cached launcher is found the bootstrap fails
loudly naming the searched path — never a silent .-relative fallback. The pure helpers
(cacheRoot, pickNewestVersion, resolveLauncher, locateLauncher) are exported and
unit-tested.
The split is load-bearing: the script path may live in the plugin cache while the cwd
stays the consumer project. The resolver spawns the launcher with cwd inherited, and the
launcher's CLI discovers the root via discoverRoot(process.cwd()) (walk up from cwd for
.agentheim/), so launch/stop/status all find the foreign project and write the runfile under it
regardless of where the script lives. The command must not cd or pass a project path.
launch spawns the server detached so the terminal returns to a prompt, then prints the
served URL — it does not open a browser (agentic-workflow-032 removed the auto-open; starting
the server and opening a tab are separate decisions). The builder opens the printed URL
themselves. The server binds 127.0.0.1 only on an ephemeral port, reads the OS-assigned
port back, and writes the sole runtime artifact:
.agentheim/.dashboard/runtime.json = { pid, port, startedAt } (gitignored)
Relaunch is idempotent: a live runfile is reused (no second process); a stale runfile (dead pid) is reaped and replaced — nothing is orphaned.
stop uses process.kill(pid) on both OSes. On Windows, if the process is stubborn
the launcher falls back to taskkill /PID <pid> /F /T. Manual fallback:
taskkill /PID <pid> /F /Tdiscovery.mjs— walk up for.agentheim/, resolve an absolute root, validate every served path (path.resolve+ separator-safestartsWith).static.mjs— content-type map + static handler (traversal → 403, missing/absent dist → 404, otherwise stream).server.mjs—node:httpserver:/healthz+GET /api/events(SSE) +GET /api/tree+GET /api/doc+ static. No write route yet (aw-009).tree.mjs— the/api/treeread projection: BC × lifecycle × task frontmatter + artifact locations, plus a stdlib-only frontmatter parser. Pointers, never bodies.read-api.mjs— the/api/treeand/api/docrequest handlers (JSON projection + validated raw-markdown carrier).events.mjs— the SSE handler: stream headers, hello + heartbeat frames, pushestree-changedevents from the watcher, cleans up on client close (ADR-0006).watcher.mjs—.agentheim/file-watcher: recursivefs.watch+ debounced poll fallback, emits path-validated{type:"tree-changed", path}pointers (ADR-0006).runfile.mjs— runfile read/write/delete, pid-liveness probe, reuse-or-replace.serve.mjs— the long-running entry spawned detached; binds, writes the runfile.launch.mjs— the single cross-platform launcher (launch/stop/statusCLI), includingstatusDashboard(pure runfile read). Prints the served URL on launch but does not open a browser (agentic-workflow-032). Driven byresolve-launcher.mjs.resolve-launcher.mjs— env-independent launcher locator (infrastructure-010): derives the plugin cache fromos.homedir(), picks the newest version by semver, fails loud, and spawnslaunch.mjscwd-inherited. Exposes purecacheRoot/pickNewestVersion/resolveLauncher/locateLauncher+ arun()thecommands/dashboard.mdnode -ebootstrap delegates to.
node --test "dashboard/test/*.test.mjs"Covers discovery + traversal rejection, content-type mapping, runfile
reuse-or-replace, the HTTP server (health check, static streaming, traversal 4xx,
graceful absent-dist 404), a real detached launch → health check → stop cycle, the read
endpoints (/api/tree projection shape + no-body guarantee, /api/doc valid markdown,
traversal-403, missing-path-400, missing-file-404, directory-refusal) and the tree.mjs
projection (lifecycle folders, frontmatter fields, BC+status on every card, artifact
locations, malformed-frontmatter degradation), the SSE stream (text/event-stream +
no-cache headers, hello/heartbeat frame, a real .agentheim/ mutation pushing a
tree-changed frame, reconnect), and the watcher
(tree-changed pointer on change/move, in-root path guarantee, burst debounce, clean
close). The watcher's recursive-vs-poll fallback selection is platform-dependent and
documented rather than fully unit-tested; the debounce + emit-shape are covered.
- Windows — launch + stop verified on the builder's box (this implementation run): detached launch returns the prompt, runfile written, health check reachable, stop kills by pid and removes the runfile, reuse/stale-replace confirmed.
- POSIX (macOS/Linux) — pending builder verification (acceptance criterion
5). The POSIX spawn path is
spawn(..., { detached: true, stdio: 'ignore' }).unref(), written per ADR-0002 but not executed here. Run the Usage commands on one POSIX OS to close the criterion.