feat(client): daemonLifecycle attach mode and ensureDaemon()

TH
1h ago
+1708-77

Summary

Layer 2 of 3 for #3754 (umbrella #3747); based on #3764. The client half: who owns the daemon is now an explicit choice.

  • createAgentDeviceClient({ daemonLifecycle: 'attach' | 'managed' }); managed is the default and behaves as before (additive in 0.21.x).
  • attach connects to the registered daemon and never starts, replaces or kills one, including after a timeout. A missing daemon fails with details.reason: 'daemon-not-running', an incompatible one with 'daemon-incompatible' (both identities, hint names agent-device daemon stop).
  • ensureDaemon({ stateDir, onStart }) starts a missing daemon and replaces an incompatible one only through the quiesce request from #3764. A busy daemon fails daemon-busy, a daemon without the capability is never replaced, and a live daemon on another transport is quiesced, not killed. A daemon that times out on quiesce is force-retired only after repeated failed liveness probes.
  • A remote daemonBaseUrl connection never starts or retires a local daemon in either mode; ensureDaemon reads no remote env.
await ensureDaemon({ stateDir });
const client = createAgentDeviceClient({ stateDir, daemonLifecycle: 'attach' });

30 files incl. docs and changelog. The repo has no changeset tooling, so the CHANGELOG.md Unreleased entry is the changeset. Layer 3 adopts this in the CLI and MCP.

Validation

pnpm check:affected --run --head f21b0019dd: passed. New tests cover attach (missing, incompatible, unreachable, timeout), ensureDaemon (start, idle replace, busy, old daemon, transport, contention, unconfirmed retirement) and remote.

🤖 Generated with Claude Code