feat(cli): add embeddable node lifecycle

KH
3mo ago
+1419-113

Summary

  • publish agent-relay/node-embedded with structured startEmbeddedNode, downEmbeddedNode, and statusEmbeddedNode results
  • reuse the existing runUpCommand / runDownCommand / runStatusCommand lifecycle implementation through CoreDependencies injection
  • replace process exit with BrokerLifecycleExitError, capture lifecycle signal handlers behind an idempotent handle, isolate the host environment, and route CLI/provider output without global console or inherited Python-provider stdio ownership
  • bump the published CLI, config, and harness-driver packages to 10.3.0 and document the API

Lifecycle ownership

Embedded startup supports foreground mode only. Any truthy background value returns a structured code-2 failure. The value forwarded to runUpCommand is authoritatively set to false after spreading caller options, so stateful JavaScript getters/proxies cannot reach the detached-spawn path. The host forwards its own signals through handle.stop(); Relay does not install SIGINT/SIGTERM listeners on the host.

Exit-site audit

All 14 deps.exit sites were traced on the final lifecycle implementation: 11 in runUpCommand, 0 in runDownCommand, and 3 in runStatusCommand.

  • Background-only runUpCommand exits at current lines 1129, 1133, 1148, 1195, and 1201 are structurally unreachable because embedded startup always forwards a plain background: false. Static truthy values and false-then-true accessors were independently probed with zero detached spawns.
  • Foreground exits at lines 1235 and 1314, plus their shared outer catch at 1366, receive the injected BrokerLifecycleExitError; host-process survival was independently exercised through both nested chains.
  • The second-SIGINT exit at line 1336 is unreachable through the public idempotent handle/no-global-listener API, but its captured handler closes over the same injected exit. First-SIGINT and SIGTERM exits at lines 1342 and 1349 are invoked only through the returned handle and convert to structured success.
  • runStatusCommand exits at lines 1520, 1531, and 1563 all receive the injected dependencies and return structured failures; stopped-after-wait, live-PID/API-unavailable, and invalid-wait paths were exercised.

A static regression test pins the 11 + 0 + 3 count and bans raw process.exit, SIGINT/SIGTERM process.on, direct console.*, and inherited stdio from the embedded lifecycle implementation.

Verification

  • npm run typecheck
  • npm --prefix packages/cli run lint (passes with pre-existing warnings only)
  • full Vitest suite: 98 files passed, 1 skipped; 1234 tests passed, 11 skipped
  • independent adversarial review: host survival, signal/console/listener isolation, static and stateful background bypasses, env isolation, stderr classification, shutdown failures/state retention, and nested-CWD marker discovery
  • npm --prefix packages/cli run build
  • installed-workspace subpath import smoke for agent-relay/node-embedded
  • CLI/config package dry-runs at 10.3.0; CLI tarball contains the new JS and declarations
  • npm ci --dry-run --ignore-scripts

The CodeQL regex annotation is pre-existing at blame commit 83a19438ab and outside this PR's changed hunks.

No publish or deploy performed.