Status:
accepted · Version v0.2.1 · Filed 2026-05-03SPEC-072 — Per-Session Daemon Lifecycle — Spawn, Termination, Cleanup
Status: draft (v0.2 — revised per Texi review 2026-05-03 02:39Z) Author: Donna (Engineering) Reviewer: Texi (Architect) PO: Lola Date: 2026-05-03 Relationship to SPEC-070: narrows the launch / termination contract of SPEC-070 v0.2’s daemon. Replaces §B4 (per-OS launcher integration via LaunchAgent / systemd-user / Task Scheduler). The daemon binary architecture (FSM, IPC server, WS client, plugin loader, metrics — PRs #51/#57/#58/#59/#61/#66/#67) is unchanged.1. Motivation
SPEC-070 v0.2 §B4 specified that the per-persona daemon would be registered with the host OS as a long-lived service (LaunchAgent on macOS,systemctl --user on Linux, Task Scheduler logon trigger on Windows). The daemon would be brought up at user-login time and torn down via prism_persona_destroy, surviving across editor restarts and idle periods.
Two failures of fit when reviewed against actual usage:
- The daemon runs when no Prism is in use. A user who logs in but doesn’t open any editor for the day still has a daemon process consuming a WS connection, heartbeating the backend, and occupying registration rows. The system pays for an “always-on listener” whose only consumer (the editor surface) isn’t running.
- OS-launcher integration is heavy and platform-specific. Each OS requires a different installer path. The install lane carries the cross-OS complexity. Every new OS adds another launcher template.
prism_start already returns queued signals on next editor open, so the daemon adds only the narrow window of “system notification while no editor is open” — which Desktop’s surface can’t even surface to the LLM (no notifications/claude/channel support).
This SPEC moves the daemon’s lifecycle inside Prism’s own usage envelope: the daemon’s lifetime is the bootstrapped Prism session, spawned on prism_start and terminated on prism_wrap. The structural value SPEC-070 actually delivers (single durable WS subscription, FSM-managed reconnect, signal continuity within an active session) is preserved.
2. Goals
- Daemon’s lifetime is bounded by the Prism session: it spawns on
prism_startand terminates onprism_wrap(or when the shim process dies abruptly without a wrap). - Never running when there’s no active Prism session.
- No OS-launcher integration. Cross-platform contract is identical.
- 1:1 relationship — one bootstrapped session, one daemon. No shared-resource machinery (no lockfile, no connection counting, no linger window).
- Backend orphan-row cleanup is automatic via the existing routing-registry liveness probe (PR #56).
- Shim-side handle tracking ensures bootstraps are idempotent — repeated
prism_startcalls without interveningprism_wrapdo not duplicate daemons.
3. Non-goals
- No multi-session sharing. If a future workflow puts the same persona on two editor surfaces simultaneously (each with its own bootstrapped session), two daemons spawn. Signals fan out via the existing publish path; the recipient sees the doorbell twice. Duplicate but not broken.
- No always-on listener. “Lola idle 4h, teammate sends mail” → bootstrap drain on next session.
- No OS service / LaunchAgent / systemd integration. SPEC-070 §B4 templates are retired.
- No daemon-spawned-by-installer.
prism installdoes not start the daemon.
4. The unit of life: one bootstrapped session, one daemon
The MCP shim is a long-lived process inside the editor (Desktop / Code / Codex / etc.). It survivesprism_start / prism_wrap / re-bootstrap cycles for the entire editor’s lifetime (and beyond, per PR #81’s relauncher staleness check). The daemon is not scoped to the shim process; it is scoped to the bootstrapped session that the shim is currently hosting.
Lifecycle contract per shim instance:
The unit is one daemon per bootstrapped session. The shim’s singleton handle prevents duplication across re-bootstrap. Stdin EOF is the only normative parent-death backstop for the unclean-exit case.
5. Spawn — on prism_start
mcp-node/src/verbs/lifecycle.ts:prism_start gains a daemon-spawn step after session registration succeeds. The shim retains the ChildProcess handle in module-level state:
stdio: ["pipe", ...] is the load-bearing detail. The shim holds a writable stream to the daemon’s stdin. When the shim closes the pipe (either explicitly on prism_wrap or implicitly via process death), the daemon receives EOF and shuts down. This is the entire parent-side termination mechanism, identical across Mac / Linux / Windows.
detached: false is implementation hygiene only — same process group, easier debugging — but is not part of the correctness story. Stdin EOF is the only normative termination signal. Process-group membership is not a portable lifecycle guarantee.
unref() allows the shim’s event loop to exit if the shim shuts down first (uncommon — the shim usually outlives the daemon).
The exit listener clears the singleton handle so a subsequent prism_start (after the daemon has gone away) will re-spawn cleanly.
daemonBinPath() resolves to mcp-node/dist/daemon/server.js per Texi’s packaging answer — the daemon ships inside the same mcp-node dist artifact, not a separate repo-root daemon tree. Same runtime, same installer surface, same version.
Spawn-failure policy: if spawn() throws (binary missing, exec permission denied), the shim logs to stderr and emits a prism_daemon_spawn_failed{reason} metric (Texi calibration). The shim continues without daemon backing — same posture as today’s behavior when no daemon is running. No system signal to the operator.
6. Termination — on prism_wrap, and on shim death
6.1 Clean termination via prism_wrap
mcp-node/src/verbs/lifecycle.ts:prism_wrap gains a daemon-shutdown step before session deregistration:
6.2 Daemon-side EOF handler
The daemon binary’s entry point opens a stdin reader at process start:initiateGracefulShutdown(reason) performs:
- Mark FSM
SHUTTING_DOWN(new terminal state added to ADR-41 FSM) - Stop accepting new UDS clients
- Close all WS connections to backend (sends WS close frame so backend’s
_forward_pubsubtask observes disconnect cleanly) - Call
prism_wrap(kind=daemon)to deregister the daemon row (best-effort; backend liveness probe handles it if this fails) - Flush any in-flight metrics
process.exit(0)
6.3 Shim-death backstop
If the shim process dies abruptly without callingprism_wrap (Cmd+Q without wrap, segfault, OOM, kernel kill), the OS closes the shim’s open file descriptors — including the stdin pipe to the daemon. The daemon’s stdin reader fires the same EOF handler. This is the only parent-death mechanism the spec relies on; it is identical across all three target platforms.
7. Edge cases
8. Backend cleanup of fragments
Daemon registers askind=daemon (PR #52) and heartbeats over the existing transport. Three terminal cleanup paths exist already:
- Graceful deregister — daemon’s
initiateGracefulShutdowncallsprism_wrap(kind=daemon). Backend marks the rowreleased_at = NOW(). - Liveness probe (PR #56) — if heartbeat falls behind the 60s threshold, send-time resolution treats the daemon as
not_available_staleand the routing-registry sweep marks the row stale. - gRPC heartbeat continuity — bidirectional gRPC stream (via
prism-server-backend-grpccontainer) provides faster signal than HTTP polling; broken stream is immediate evidence of daemon death. Aspirational optimization, not a v1 gate (Texi calibration). v1 ships with HTTP heartbeat.
9. Cross-platform considerations
The stdin-EOF mechanism works identically on macOS, Linux, and Windows because it operates at the OS pipe level. Noprctl(PR_SET_PDEATHSIG) (Linux-only), no kqueue EVFILT_PROC (macOS-only), no Windows Job Object — none of these are needed. They are explicitly not part of this SPEC’s correctness contract.
daemonBinPath() resolution:
- All platforms: resolved relative to the running shim binary’s location:
path.join(path.dirname(import.meta.url), "daemon", "server.js")(after URL→path normalization) - Spawn:
spawn(process.execPath, [daemonBinPath()], ...)invokes the same Node binary the shim is running under — no cross-platform shell wrapper
prism install writes the same env block on every platform. No additional templates or per-OS install scripts are introduced by this SPEC.
10. What changes vs SPEC-070 v0.2
Lafonda’s in-flight worktree
feat/spec-070-b4-install-lane-2026-05-02 becomes a no-op for daemon launch. Install-lane work narrows to: ensure mcp-node/dist/daemon/server.js ships in prism install output, and the env block contains PRISM_DAEMON_SHIM_PID / PRISM_DAEMON_SHIM_DOORBELL_PATH keys (latter already present per claude_code.ts:resolveShimDoorbellSocketPath).
11. Implementation plan
Phase A — Shim spawn integration (Donna)
- A1. Module-level
daemon_process: ChildProcess | nullsingleton inmcp-node/src/verbs/lifecycle.ts - A2.
spawnDaemonForSession()helper called after session registration inprism_start. Idempotent against re-spawn. - A3. Daemon path resolution helper (
daemonBinPath()) usingpath.dirname(import.meta.url)+ relative resolve todaemon/server.js - A4. Spawn-failure handling — log to stderr, emit
prism_daemon_spawn_failed{reason}metric, non-fatal
Phase B — Shim-side wrap shutdown + daemon stdin-EOF handler (Donna)
- B1.
shutdownDaemonForSession()inprism_wrappath — clears singleton, closes daemon stdin - B2. Daemon entry point opens
process.stdinreader on start - B3. EOF / error handlers call
initiateGracefulShutdown(reason) - B4. FSM gains
SHUTTING_DOWNterminal state; reconnect logic gates on it - B5. Graceful shutdown sequence (UDS close → WS close → deregister → metrics flush → exit)
Phase C — Retire SPEC-070 §B4 (Lafonda + Donna)
- C1. Lafonda: close the
feat/spec-070-b4-install-lane-2026-05-02worktree without merging the launcher templates - C2. Lafonda + Donna: move daemon entry to
mcp-node/dist/daemon/server.jspackaging; ensureprism installships the file - C3. Donna: remove or repurpose
prism_persona_destroy’s daemon-talking path - C4. Update SPEC-070 (v0.3 or annotation) to point at SPEC-072 for launch lifecycle
Phase D — Smoke (Donna + Porsche)
- D1. End-to-end smoke: shim bootstrap →
prism_start→ daemon spawn → WS connection visible inprism_statusdaemon view →prism_wrap→ daemon exits within 1s → backend row marked released. Thenprism_startagain in same shim → fresh daemon. Then kill shim → daemon exits via stdin EOF backstop. - D2. Porsche: dashboard shows daemon spawn / death events as observable mesh state (folds into SPEC-071 §11 card)
12. Resolved during review
Texi review 2026-05-03 02:39Z answered all v0.1 open questions:- Daemon binary packaging: ships at
mcp-node/dist/daemon/server.js(sibling ofmcp-node/dist/server.js). Same shipped artifact as the shim, same versioning unit. Adopted in §10. - Spawn-failure policy: non-fatal, log to stderr, emit metric. No system signal escalation. Adopted in §5.
- gRPC heartbeat upgrade: aspirational, not a v1 gate. v1 ships with HTTP heartbeat. Adopted in §8 item 3.
13. References
- SPEC-070 v0.2 — daemon binary architecture (foundation; this SPEC narrows §B4 only)
- ADR-41 — daemon FSM; gains
SHUTTING_DOWNterminal state via this SPEC - ADR-42 — project attach lifecycle (unchanged)
- ADR-43 — daemon-via-shim (filed by Texi during SPEC-070 ratification; this SPEC provides the concrete launch contract)
- PR #52 —
agent_sessions.kind=daemondiscriminator (unchanged) - PR #56 — routing-registry liveness probe (provides 60s stale threshold for fragment cleanup)
- PR #57 — daemon binary skeleton
- PR #67 — backend wake-up: daemon WS receipt is notification, not delivery
- PR #81 — mcp-launcher staleness check (relevant: the launcher is what keeps the shim long-lived across editor restarts; the unit-of-life is therefore the bootstrapped session, not the shim process)
project_open_todo_kill_fallback— retired by this SPECfeedback_frank_spidy_sense_pattern— encapsulation > resource economy; drives the 1:1 choicefeedback_optimize_later— get architecture uniform first; multi-surface-per-persona is unobservedproject_lane_split_install_vs_deploy— Lafonda owns install lane scope reduction in §11 Phase C- SPEC-071 — Signal Bus QoS (independent SPEC; daemon participates as a
kind=daemonrecipient via the same delivery semantics)
14. Revision log
- v0.1 (2026-05-03 02:35Z) — initial draft circulated for review
- v0.2 (2026-05-03 02:42Z) — Texi review revisions:
- Finding 1: corrected the unit-of-life. Daemon is scoped to the bootstrapped session, NOT the shim process. Spawn on
prism_start(idempotent against re-bootstrap), terminate onprism_wrap(shim closes daemon’s stdin, fires the same EOF mechanism). Stdin-EOF on shim death remains the unclean-exit backstop. §4-§7 rewritten. - Finding 2: removed the “process-group cleanup belt-and-suspenders” claim from the correctness story. Stdin EOF is the only normative parent-death mechanism.
detached: falseis now flagged as implementation hygiene only. - Finding 3: added the module-level singleton handle
daemon_processin the shim. Re-bootstrap without wrap is a no-op (existing daemon continues); wrap clears the handle so the nextprism_startspawns fresh. §5-§7 carry the rule. - Plus calibration: daemon packaging at
mcp-node/dist/daemon/server.js, spawn-failure metric, gRPC aspirational not v1 gate.
- Finding 1: corrected the unit-of-life. Daemon is scoped to the bootstrapped session, NOT the shim process. Spawn on

