Repository navigation
feat(host): startAgentDevice() for embedder-owned daemons #3757
Description
Part of #3747. Functional prerequisites: #3752 (#3772), HostEnvironment foundation/adoption (#3749–#3751), and landed #3754/#3755. #3753 remains an umbrella gate, not an implementation blocker. Reconcile pending runtime work with main's registry cleanup before merging. Imported Limrun acceptance additionally needs #3311's extraction.
Purpose and API
Let a driver process own a daemon and supply imported plugin objects, while other processes attach to that exact instance.
import { startAgentDevice } from 'agent-device/host';
import testmu from '@agent-device/testmu';
await using host = await startAgentDevice({
stateDir,
host: optionalHostEnvironment,
plugins: [testmu({ credentials })],
log: optionalLogCallback,
});
host.client; // AgentDeviceClient
host.clientConfig; // serializable, attach-only, socket, pinned auth + instance
const report = await host.close(); // ShutdownReportExport the host and options types, HostEnvironment, LogEntry and ShutdownReport from agent-device/host. plugins defaults to an empty array. host defaults to one captured copy of process env/cwd. log defaults to silent runtime streams; entries contain stream and text. close() is idempotent; concurrent callers share its result. Async disposal awaits close and returns void.
Required behavior
- Resolve required stateDir against captured cwd. Copy supplied host/env. Never change process env/cwd, install process handlers, or exit the host process.
- Reuse startDaemonRuntime/createDaemonRuntimeDeps. Keep executable startup outside the host import graph.
- Serve the existing authenticated socket protocol on an ephemeral loopback TCP listener. No HTTP or listener customization is required.
- Select built-in then supplied embedder candidates through the existing registry. Do not read the store. Preserve first-claim ordering and failure inventory. Keep the set fixed for the runtime lifetime.
- Keep registry.close as the single plugin cleanup owner. Reconcile bounded ShutdownReport outcomes with plugin shutdown/dispose, including composition failure and startup rollback.
- Acquire registration with ADR 0030 claims. Refuse a live occupant with
details.reason: 'state-dir-in-use'. Retain unproven ownership and distinguish that refusal. Never replace an occupant. - Publish embedded ownership. Central lifecycle/retirement policy must never signal an embedded host PID, including hung recovery and forced stop. Preserve detached behavior until 0.22: remove superseded plugin, client and environment APIs #3759.
- Add serializable daemonBinding containing the instance's token, PID and process start time. Host clientConfig explicitly selects local socket attach. Propagate the binding outside command flags. Compare before dispatch and authenticate using the pinned token. Missing/replaced instances fail with typed reasons; no start, replacement or redirection occurs.
- Keep runtime state and teardown isolated across hosts, including Android helper sessions/retirements and Apple runner state. Closing one host must preserve another. Disable automatic idle reap for embedder-owned lifetime.
- Make daemon stop use authenticated, instance-fenced shutdown for embedded ownership. Explicit shutdown drains active work; quiesce remains stop-if-idle. Acknowledge before transport teardown. Await bounded, instance-fenced completion/report instead of process exit. Preserve existing CLI claim results. Never run PID-wide clean against sibling embedded runtimes.
- Return truthful close outcomes. If uncancelled work requires registration retention, return its abandoned/pending report and retain the fence; do not claim successful removal or safe reuse. Timeouts never escalate to killing the host.
Done when
- Integration coverage uses the public host API with an imported fixture plugin and a second-process client.
- Tests prove two simultaneous hosts with distinct env/cwd/state dirs, no store loading (including a conflicting/broken store), built-in precedence, failed-plugin inventory, and exact-once disposal.
- Tests cover busy/unproven startup, post-setup rollback, wrong token, replacement inside the same PID, replacement between lookup/send, and no managed/ensure/timeout signaling of embedded owners.
- Tests cover concurrent close/dispose, authenticated CLI stop with active work, close after CLI stop, partial/hung cleanup, retained registration, fenced reports, and shutdown of A while B's native helper remains active. Check process listeners/env/cwd remain unchanged.
- A packed-install ESM import and TypeScript consumer resolve
agent-device/host, construct it with a fixture plugin, attach from another process, and close without private workspace imports. - Add the build entry, export map and package checks. Verify ADR 0027 closure budgets and R78/R10 layering; root/CLI/MCP cannot reach the host entry, even dynamically. Stage new modules before trusting tracked-file layering scans.
- Update client API/website hosting docs with ownership, config sensitivity and partial shutdown behavior. Update skill routing only if startup guidance changes. Add a changeset. Run required format/build/focused checks and affected gates.
e2e-side sketch (consumer context, not part of this issue)
Keep the existing driver child per worker; this is an operational choice, not an API requirement after isolation fixes. Today TestMu installs a fake store (packages/testmu/src/daemon.ts:50–56), spawns/disconnects (:63–74, driver.ts:16–25), and stops through CLI (daemon.ts:79–85). Limrun similarly disconnects (driver.ts:19–22); parent heartbeat reconstructs a state-dir client (TestMu heartbeat.ts:6–8).
// Driver child: keep IPC connected after allocation.
const host = await startAgentDevice({ stateDir, plugins: [testmu({ credentials })] });
const lease = await host.client.leases.allocate(allocateOptions);
process.send?.({ type: 'ready', lease, clientConfig: host.clientConfig });
// On typed stop IPC or parent disconnect, share one close promise:
const report = await host.close();
process.send?.({ type: 'closed', report }, () => process.disconnect?.());Parent retains the child handle, passes { ...clientConfig, ...leaseScope } to heartbeat/workers, stops and awaits heartbeat, sends stop, awaits report, then joins the child. Driver handles disconnect/startup failure with the same close owner; parent handles child death explicitly. Judge pending/unknown releases and abandoned requests before declaring cleanup complete. Delete fake installation/home wiring and CLI stop. Limrun uses the same flow after extraction resolves its built-in ID collision. Retain version checks where client/driver compatibility still requires them.
Design notes
- Limrun example. A built-in Limrun claims
limruneven when dormant, so an imported@agent-device/limrunloses first-claim until the refactor(provider-limrun): ship Limrun as the@agent-device/limrunplugin #3311 extraction lands. Do not add embedder-override semantics; TestMu is the acceptance plugin. - Split. At most two PRs. (1) Embedded registration ownership, authenticated instance-fenced stop and completion, and
daemonBindingclient pinning, with the lifecycle tests. (2) Theagent-device/hostentry, the composition, exports and docs, and the packed-install acceptance test. - Owned by feat(daemon): the runtime owns its state and never the process #3772, not here. Per-runtime Android helper session isolation, and reconciling the registry cleanup outcomes into
ShutdownReport. - Design pass: a Codex review dated 2026-10-17, against
origin/main92db564.
Activity