@beeos-ai/device-agent 0.9.1 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/adapters/transport-bridge-ws.d.ts +12 -0
  2. package/dist/adapters/transport-bridge-ws.js +27 -3
  3. package/dist/adapters/transport-bridge-ws.js.map +1 -1
  4. package/dist/agent-runtime/core.d.ts +1 -1
  5. package/dist/agent-runtime/core.js +41 -0
  6. package/dist/agent-runtime/core.js.map +1 -1
  7. package/dist/app/session-manager.d.ts +14 -11
  8. package/dist/app/session-manager.js +8 -9
  9. package/dist/app/session-manager.js.map +1 -1
  10. package/dist/bootstrap.js +140 -11
  11. package/dist/bootstrap.js.map +1 -1
  12. package/dist/chat.js +11 -1
  13. package/dist/chat.js.map +1 -1
  14. package/dist/config/loader.js +3 -0
  15. package/dist/config/loader.js.map +1 -1
  16. package/dist/config/schema.d.ts +3 -0
  17. package/dist/config/schema.js +51 -15
  18. package/dist/config/schema.js.map +1 -1
  19. package/dist/fleet/adapters/process/node-process-manager.d.ts +115 -13
  20. package/dist/fleet/adapters/process/node-process-manager.js +412 -64
  21. package/dist/fleet/adapters/process/node-process-manager.js.map +1 -1
  22. package/dist/fleet/adapters/state/port-allocator.d.ts +12 -0
  23. package/dist/fleet/adapters/state/port-allocator.js +39 -5
  24. package/dist/fleet/adapters/state/port-allocator.js.map +1 -1
  25. package/dist/fleet/domain/events.d.ts +23 -1
  26. package/dist/fleet/domain/fsm.d.ts +13 -1
  27. package/dist/fleet/domain/fsm.js +70 -0
  28. package/dist/fleet/domain/fsm.js.map +1 -1
  29. package/dist/fleet/supervisor.js +30 -0
  30. package/dist/fleet/supervisor.js.map +1 -1
  31. package/dist/health/adb-track-devices.d.ts +103 -0
  32. package/dist/health/adb-track-devices.js +171 -0
  33. package/dist/health/adb-track-devices.js.map +1 -0
  34. package/dist/health/health-reporter.d.ts +67 -0
  35. package/dist/health/health-reporter.js +123 -0
  36. package/dist/health/health-reporter.js.map +1 -0
  37. package/dist/health/index.d.ts +6 -0
  38. package/dist/health/index.js +4 -0
  39. package/dist/health/index.js.map +1 -0
  40. package/dist/health/mcp-health-monitor.d.ts +190 -0
  41. package/dist/health/mcp-health-monitor.js +267 -0
  42. package/dist/health/mcp-health-monitor.js.map +1 -0
  43. package/dist/inbound/cli/main.js +8 -1
  44. package/dist/inbound/cli/main.js.map +1 -1
  45. package/dist/inbound/cli/run-command.js +11 -1
  46. package/dist/inbound/cli/run-command.js.map +1 -1
  47. package/dist/index.d.ts +1 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/mcp-client/action-mapping.d.ts +45 -0
  50. package/dist/mcp-client/action-mapping.js +224 -0
  51. package/dist/mcp-client/action-mapping.js.map +1 -0
  52. package/dist/mcp-client/autospawn.d.ts +15 -2
  53. package/dist/mcp-client/autospawn.js +82 -4
  54. package/dist/mcp-client/autospawn.js.map +1 -1
  55. package/dist/mcp-client/client.d.ts +134 -38
  56. package/dist/mcp-client/client.js +394 -87
  57. package/dist/mcp-client/client.js.map +1 -1
  58. package/dist/mcp-client/index.d.ts +5 -3
  59. package/dist/mcp-client/index.js +2 -1
  60. package/dist/mcp-client/index.js.map +1 -1
  61. package/dist/mcp-client/inproc.d.ts +2 -2
  62. package/dist/mcp-client/inproc.js.map +1 -1
  63. package/dist/mcp-client/runtime.d.ts +124 -21
  64. package/dist/mcp-client/runtime.js +18 -7
  65. package/dist/mcp-client/runtime.js.map +1 -1
  66. package/dist/model/adapters/autoglm.js +44 -45
  67. package/dist/model/adapters/autoglm.js.map +1 -1
  68. package/dist/plugins/acp/index.d.ts +35 -0
  69. package/dist/plugins/acp/index.js +121 -0
  70. package/dist/plugins/acp/index.js.map +1 -1
  71. package/dist/plugins/message-bus/busy-reply.js +13 -5
  72. package/dist/plugins/message-bus/busy-reply.js.map +1 -1
  73. package/dist/plugins/message-bus/inbound.js +12 -6
  74. package/dist/plugins/message-bus/inbound.js.map +1 -1
  75. package/dist/plugins/message-bus/index.js +14 -2
  76. package/dist/plugins/message-bus/index.js.map +1 -1
  77. package/dist/plugins/message-bus/ms-channel-notifier.d.ts +19 -7
  78. package/dist/plugins/message-bus/ms-channel-notifier.js +33 -8
  79. package/dist/plugins/message-bus/ms-channel-notifier.js.map +1 -1
  80. package/dist/ports/transport.d.ts +11 -0
  81. package/package.json +4 -3
@@ -1,23 +1,54 @@
1
1
  /**
2
2
  * Adapter — `IProcessManager` implementation backed by `child_process.spawn`.
3
3
  *
4
- * Spawns and supervises up to three long-running siblings per bound device:
4
+ * 0.4.0 SINGLETON MCP TOPOLOGY:
5
5
  *
6
- * - **mcp** `device-mcp-server --backend <kind> --adb-serial <serial>
7
- * --host 127.0.0.1 --port <mcpPort>`
6
+ * Pre-0.4 the manager spawned **one mcp + one daemon per device** (1:1) so
7
+ * the daemon could pin its `--mcp-url` to a per-device port. With the new
8
+ * SDK-backed device-mcp-server (which is itself a singleton multi-device
9
+ * BackendRegistry process), the manager keeps the per-device `IProcessManager`
10
+ * API the FSM/supervisor expect but internally collapses every `spawnMcp`
11
+ * call onto a **single shared MCP child**:
12
+ *
13
+ * - The first `spawnMcp` call actually starts `device-mcp-server` on the
14
+ * supplied port, runs the `initialize` JSON-RPC readiness handshake,
15
+ * and remembers that port as the global MCP port.
16
+ * - Subsequent `spawnMcp` calls (for additional devices) skip the spawn,
17
+ * immediately emit a synthetic per-device `mcp_started` for the
18
+ * existing global port, and add the serial to a refcount set.
19
+ * - `killMcp(serial)` removes the serial from the refcount; the actual
20
+ * `device-mcp-server` child is only killed when the last device drops
21
+ * out (or `stop()` is called).
22
+ * - When the global MCP exits unexpectedly, every refcounted serial
23
+ * receives a synthetic `mcp_exited` so each per-device FSM transitions
24
+ * coherently and triggers a respawn (whose first call rebuilds the
25
+ * singleton).
26
+ *
27
+ * The daemon's `--mcp-url` always points at the global port (recorded in
28
+ * `globalMcpPort`), so per-device daemons all share the same backend
29
+ * registry. Tool calls route by the `device` argument the daemon's
30
+ * `McpClient` injects from its `defaultDevice = serial`. The MCP server's
31
+ * own `--adb-serial` per-device pin flag is gone; the registry discovers
32
+ * every connected device on its own.
33
+ *
34
+ * Spawn surface:
35
+ *
36
+ * - **mcp** `device-mcp-server --backend <kind>
37
+ * --host 127.0.0.1 --port <globalMcpPort>` (singleton)
8
38
  * - **daemon** `device-agent --key <keyFile> --agent-gateway-url <url>
9
- * --mcp-url http://127.0.0.1:<mcpPort> --adb <serial>
10
- * [--http --http-port <httpPort>]`
11
- * - **scrcpy** `scrcpy-bridge` (no args; configured via env mirroring
12
- * `web/packages/core/src/runtime/scrcpy-bridge.ts` `buildEnv`).
13
- * Optional — only spawned when the binding's `videoMode === "scrcpy"`.
39
+ * --mcp-url http://127.0.0.1:<globalMcpPort>
40
+ * --device <serial> [--adb <serial> for ADB backends]
41
+ * [--http --http-port <httpPort>]` (per device)
42
+ * - **scrcpy** `scrcpy-bridge` (no args; per device, optional —
43
+ * only spawned when the binding's `videoMode === "scrcpy"`).
14
44
  *
15
45
  * Lifecycle responsibilities:
16
46
  *
17
- * - `spawnMcp` waits for `GET /healthz` to return 200 (configurable timeout)
18
- * before emitting `mcp_started`. If readiness times out the spawn is
19
- * killed and an `mcp_exited` event is reported (so the FSM treats the
20
- * attempt as a crash and either retries or backs off).
47
+ * - `spawnMcp` (first call) waits for the MCP `initialize` handshake to
48
+ * return 200/202 before emitting `mcp_started`. If readiness times out
49
+ * the global child is killed and a synthetic `mcp_exited` is reported
50
+ * so the FSM treats the attempt as a crash. Subsequent (refcount-bump)
51
+ * calls emit `mcp_started` synchronously without re-probing.
21
52
  * - `spawnDaemon` emits `daemon_started` as soon as the child has a PID
22
53
  * (the daemon's own readiness is observable through ACP traffic which is
23
54
  * out of fleet scope; the daemon process itself dying is what we react to
@@ -79,6 +110,42 @@ export class NodeProcessManager {
79
110
  slots = new Map();
80
111
  listeners = new Set();
81
112
  started = false;
113
+ // ── Singleton MCP bookkeeping ───────────────────────────────────────────
114
+ /**
115
+ * The lone `device-mcp-server` child shared across all devices, or
116
+ * `undefined` when no device currently needs the MCP. First `spawnMcp`
117
+ * call spawns it; the last `killMcp` (or `stop()`) tears it down.
118
+ */
119
+ globalMcpSlot;
120
+ /** Port the singleton is bound to. 0 when not running. */
121
+ globalMcpPort = 0;
122
+ /**
123
+ * Port the most recent successfully-probed singleton was bound to.
124
+ * Survives `globalMcpPort` being reset to 0 on exit; consulted by
125
+ * the next `startGlobalMcp` to detect port drift across respawn.
126
+ * 0 means "no prior cycle has succeeded" (suppresses the false-
127
+ * positive `mcp_port_changed` on the very first spawn).
128
+ */
129
+ previousGlobalMcpPort = 0;
130
+ /** Serials currently refcounting the global MCP. */
131
+ mcpRefs = new Set();
132
+ /**
133
+ * Single-flight readiness: when two devices land on `spawnMcp` at the
134
+ * same time we want only one of them to actually probe `initialize`;
135
+ * the other just awaits this promise.
136
+ */
137
+ mcpReadyPromise;
138
+ /**
139
+ * Single-flight first-spawn: protects the
140
+ * (`globalMcpSlot === undefined` → `spawnFn` → `globalMcpSlot = …`)
141
+ * critical section. Without this, two concurrent first-time
142
+ * `spawnMcp(A)` / `spawnMcp(B)` calls could each call `spawnFn`
143
+ * (the `globalMcpSlot` write is post-spawn, so the second caller
144
+ * sees `undefined` and overwrites with its own child, leaking
145
+ * the first one). When non-undefined, every other caller waits
146
+ * here instead of starting a second mcp.
147
+ */
148
+ globalMcpStartPromise;
82
149
  constructor(opts = {}) {
83
150
  this.mcpBin =
84
151
  opts.mcpBin ?? process.env.BEEOS_DEVICE_MCP_BIN ?? "device-mcp-server";
@@ -110,6 +177,16 @@ export class NodeProcessManager {
110
177
  this.killDaemon(s, { expected: true }),
111
178
  this.killScrcpy(s, { expected: true }),
112
179
  ]));
180
+ // Belt + braces: if any refs remain (shouldn't, but defensive), tear
181
+ // down the singleton MCP explicitly. Otherwise the last killMcp above
182
+ // already cleared it.
183
+ if (this.globalMcpSlot) {
184
+ await this.killGlobalMcp(true);
185
+ }
186
+ // Forget the prior port so a future `start()` doesn't fire a
187
+ // spurious `mcp_port_changed` against a slate that's been torn
188
+ // down completely.
189
+ this.previousGlobalMcpPort = 0;
113
190
  }
114
191
  on(listener) {
115
192
  this.listeners.add(listener);
@@ -153,24 +230,77 @@ export class NodeProcessManager {
153
230
  });
154
231
  return;
155
232
  }
156
- // Per-device backend takes precedence over the constructor-time
157
- // default. `devices.json#backend` flows here via `SpawnOptions`,
158
- // so once self-attach lands the same code path serves ADB,
159
- // macOS / Linux / Windows hosts.
233
+ // Singleton refcount path — when the global MCP is already running,
234
+ // we just attach this serial as a ref and emit the per-device
235
+ // `mcp_started` synchronously. No new process spawn, no second
236
+ // `initialize` probe.
237
+ if (this.globalMcpSlot) {
238
+ this.attachToGlobalMcp(slot, opts.serial);
239
+ return;
240
+ }
241
+ // Concurrent first-spawn: another caller is already driving
242
+ // `startGlobalMcp`. Wait for it instead of starting a duplicate
243
+ // child (which would leak the first one — `globalMcpSlot` is
244
+ // overwritten post-spawn, so a naive concurrent path could
245
+ // double-spawn the binary).
246
+ if (this.globalMcpStartPromise) {
247
+ await this.globalMcpStartPromise;
248
+ if (this.globalMcpSlot) {
249
+ this.attachToGlobalMcp(slot, opts.serial);
250
+ return;
251
+ }
252
+ // The driver's start failed — emit our own per-device synthetic
253
+ // `mcp_exited` so this caller's FSM transitions out of `spawning`
254
+ // (the driver only emitted for ITS serial).
255
+ this.emit({
256
+ kind: "mcp_exited",
257
+ serial: opts.serial,
258
+ code: null,
259
+ signal: null,
260
+ expected: false,
261
+ });
262
+ return;
263
+ }
264
+ // First device — actually spawn the MCP child. We use this device's
265
+ // allocated port (`opts.mcpPort`) as the global port; later devices
266
+ // will share it irrespective of what the allocator handed them.
267
+ this.globalMcpStartPromise = this.startGlobalMcp(opts).finally(() => {
268
+ this.globalMcpStartPromise = undefined;
269
+ });
270
+ await this.globalMcpStartPromise;
271
+ if (!this.globalMcpSlot) {
272
+ // startGlobalMcp already emitted a synthetic `mcp_exited` for this
273
+ // serial; nothing else to do — the FSM will retry per its policy.
274
+ return;
275
+ }
276
+ this.attachToGlobalMcp(slot, opts.serial);
277
+ }
278
+ // ── Singleton helpers ────────────────────────────────────────────────────
279
+ /**
280
+ * Spawn the singleton `device-mcp-server`. Driven by the FIRST device
281
+ * to enter the spawn pipeline — its `opts.mcpPort` becomes the global
282
+ * port, and its `opts.backend` (or the constructor default) selects
283
+ * the backend kind. On readiness failure, emits a synthetic per-device
284
+ * `mcp_exited` for `opts.serial` so the caller's FSM transitions
285
+ * cleanly; on success leaves `globalMcpSlot` populated for refcount
286
+ * attachment.
287
+ */
288
+ async startGlobalMcp(opts) {
160
289
  const backend = opts.backend ?? this.mcpBackend;
161
- const args = ["--backend", backend];
162
- // `--adb-serial` is only meaningful for the ADB backend. Other
163
- // backends rely on the host process (one device-mcp-server per
164
- // host), so passing the serial there would be confusing.
165
- if (isAdbBackend(backend)) {
166
- args.push("--adb-serial", opts.serial);
167
- }
168
- args.push("--host", this.mcpHost, "--port", String(opts.mcpPort));
290
+ const args = [
291
+ "--backend",
292
+ backend,
293
+ // 0.4.0: device-mcp-server discovers every connected ADB device on
294
+ // its own — no `--adb-serial` per-device pin flag.
295
+ "--host",
296
+ this.mcpHost,
297
+ "--port",
298
+ String(opts.mcpPort),
299
+ ];
169
300
  const env = {
170
301
  ...process.env,
171
302
  ...this.extraEnv,
172
303
  DEVICEAGENT_BACKEND: backend,
173
- ANDROID_SERIAL: opts.serial,
174
304
  MCP_HOST: this.mcpHost,
175
305
  MCP_PORT: String(opts.mcpPort),
176
306
  };
@@ -179,7 +309,7 @@ export class NodeProcessManager {
179
309
  child = this.spawnFn(this.mcpBin, args, env);
180
310
  }
181
311
  catch (e) {
182
- this.logger.error("failed to spawn mcp", {
312
+ this.logger.error("failed to spawn singleton mcp", {
183
313
  serial: opts.serial,
184
314
  error: String(e),
185
315
  });
@@ -192,54 +322,202 @@ export class NodeProcessManager {
192
322
  });
193
323
  return;
194
324
  }
195
- const childSlot = {
325
+ const globalSlot = {
196
326
  child,
197
327
  expectingKill: false,
198
328
  spawnedAt: Date.now(),
199
329
  bin: this.mcpBin,
200
330
  args,
201
331
  };
202
- slot.mcp = childSlot;
203
- this.logger.info("spawned device-mcp-server", {
204
- serial: opts.serial,
332
+ this.globalMcpSlot = globalSlot;
333
+ this.globalMcpPort = opts.mcpPort;
334
+ this.logger.info("spawned singleton device-mcp-server", {
205
335
  pid: child.pid ?? null,
206
336
  port: opts.mcpPort,
337
+ backend,
207
338
  });
208
- this.attachExitListener(opts.serial, "mcp", childSlot);
209
- // Readiness probe (best-effort; in tests httpProbeFn returns true instantly).
210
- const url = `http://${this.mcpHost}:${opts.mcpPort}/healthz`;
211
- const ok = await this.httpProbeFn(url, this.mcpHealthTimeoutMs);
339
+ this.attachGlobalExitListener(globalSlot);
340
+ // Single-flight initialize probe.
341
+ if (!this.mcpReadyPromise) {
342
+ const url = `http://${this.mcpHost}:${opts.mcpPort}/mcp`;
343
+ this.mcpReadyPromise = this.httpProbeFn(url, this.mcpHealthTimeoutMs);
344
+ }
345
+ let ok = false;
346
+ try {
347
+ ok = await this.mcpReadyPromise;
348
+ }
349
+ finally {
350
+ this.mcpReadyPromise = undefined;
351
+ }
212
352
  if (!ok) {
213
- this.logger.warn("mcp /healthz did not return 200 within grace", {
353
+ this.logger.warn("singleton mcp initialize handshake did not succeed within grace", {
214
354
  serial: opts.serial,
215
- url,
355
+ port: opts.mcpPort,
216
356
  });
217
- // Treat the readiness failure as a crash. Mark the upcoming exit as
218
- // expected (we caused the kill) so the FSM won't double-count, but
219
- // emit the synthetic exit AFTER the actual kill bookkeeping is done.
220
- childSlot.expectingKill = true;
357
+ // Treat readiness failure as a crash. Fan-out synthetic
358
+ // `mcp_exited` to every serial that attached during the probe
359
+ // (concurrent waiters who saw `globalMcpSlot` truthy and joined
360
+ // as refs before the probe resolved) PLUS the driver itself if
361
+ // it isn't already a ref. Without this fan-out, those waiters
362
+ // would only see the eventual real-child exit (fired by the
363
+ // async SIGTERM listener), and we'd race the listener against
364
+ // the test / supervisor's expected immediate transition.
365
+ globalSlot.expectingKill = true;
221
366
  try {
222
367
  child.kill("SIGTERM");
223
368
  }
224
369
  catch {
225
370
  /* ignore */
226
371
  }
227
- this.emit({
228
- kind: "mcp_exited",
229
- serial: opts.serial,
230
- code: null,
231
- signal: "SIGTERM",
232
- expected: false,
233
- });
372
+ const refsToFanOut = Array.from(this.mcpRefs);
373
+ this.globalMcpSlot = undefined;
374
+ this.globalMcpPort = 0;
375
+ this.mcpRefs.clear();
376
+ for (const serial of refsToFanOut) {
377
+ const refSlot = this.slots.get(serial);
378
+ if (refSlot)
379
+ refSlot.mcp = undefined;
380
+ this.emit({
381
+ kind: "mcp_exited",
382
+ serial,
383
+ code: null,
384
+ signal: "SIGTERM",
385
+ expected: false,
386
+ });
387
+ }
388
+ if (!refsToFanOut.includes(opts.serial)) {
389
+ this.emit({
390
+ kind: "mcp_exited",
391
+ serial: opts.serial,
392
+ code: null,
393
+ signal: "SIGTERM",
394
+ expected: false,
395
+ });
396
+ }
397
+ return;
398
+ }
399
+ // Probe succeeded — the singleton is healthy on `globalMcpPort`. If
400
+ // the previous cycle was on a different port, every still-alive
401
+ // daemon is now wired to a stale `--mcp-url`. Emit per-serial
402
+ // `mcp_port_changed` so the supervisor's FSM (`mcp_port_changed`
403
+ // case in fromHealthy / fromSpawning) can issue a serialized
404
+ // `restart_daemon` for each. The very first successful spawn (no
405
+ // prior port) is silent.
406
+ const previous = this.previousGlobalMcpPort;
407
+ this.previousGlobalMcpPort = this.globalMcpPort;
408
+ if (previous > 0 && previous !== this.globalMcpPort) {
409
+ for (const [serial, slotEntry] of this.slots) {
410
+ if (slotEntry.daemon) {
411
+ this.emit({
412
+ kind: "mcp_port_changed",
413
+ serial,
414
+ previousPort: previous,
415
+ newPort: this.globalMcpPort,
416
+ });
417
+ }
418
+ }
419
+ }
420
+ }
421
+ /**
422
+ * Wire `serial` into the running global MCP: store a ref-pointing
423
+ * `ChildSlot` on the device's slots, register the ref, and emit a
424
+ * per-device `mcp_started` event using the global port. Idempotent —
425
+ * a serial already in `mcpRefs` becomes a no-op.
426
+ */
427
+ attachToGlobalMcp(slot, serial) {
428
+ if (!this.globalMcpSlot)
429
+ return;
430
+ if (this.mcpRefs.has(serial)) {
431
+ this.logger.debug("mcp ref already present — skipping", { serial });
234
432
  return;
235
433
  }
434
+ // The per-device slot points at the SHARED child reference so
435
+ // pid()/isAlive() return sensible values, but kill()/exit listeners
436
+ // are managed at the global level. We do NOT call `attachExitListener`
437
+ // here — the global exit listener fans out to every ref.
438
+ slot.mcp = {
439
+ child: this.globalMcpSlot.child,
440
+ expectingKill: false,
441
+ spawnedAt: this.globalMcpSlot.spawnedAt,
442
+ bin: this.globalMcpSlot.bin,
443
+ args: this.globalMcpSlot.args,
444
+ };
445
+ this.mcpRefs.add(serial);
236
446
  this.emit({
237
447
  kind: "mcp_started",
238
- serial: opts.serial,
239
- pid: child.pid ?? 0,
240
- port: opts.mcpPort,
448
+ serial,
449
+ pid: this.globalMcpSlot.child.pid ?? 0,
450
+ port: this.globalMcpPort,
241
451
  });
242
452
  }
453
+ /**
454
+ * Attach a single global exit listener to the singleton MCP child. When
455
+ * it dies, fan-out a synthetic `mcp_exited` to every refcounted serial
456
+ * (clearing their slots) so each per-device FSM transitions cleanly.
457
+ * The `expected` flag is taken from `globalMcpSlot.expectingKill`, which
458
+ * is set by `killGlobalMcp(expected=true)` during graceful shutdown.
459
+ */
460
+ attachGlobalExitListener(globalSlot) {
461
+ globalSlot.child.once("exit", (code, signal) => {
462
+ const expected = globalSlot.expectingKill;
463
+ const refs = Array.from(this.mcpRefs);
464
+ this.logger.warn("singleton mcp exited", {
465
+ code,
466
+ signal,
467
+ expected,
468
+ refs: refs.length,
469
+ });
470
+ // Clear bookkeeping BEFORE re-entry so a respawn-driving FSM can
471
+ // call `spawnMcp` and find `globalMcpSlot === undefined`.
472
+ this.globalMcpSlot = undefined;
473
+ this.globalMcpPort = 0;
474
+ this.mcpRefs.clear();
475
+ for (const serial of refs) {
476
+ const slot = this.slots.get(serial);
477
+ if (slot)
478
+ slot.mcp = undefined;
479
+ this.emit({ kind: "mcp_exited", serial, code, signal, expected });
480
+ }
481
+ });
482
+ globalSlot.child.once("error", (err) => {
483
+ this.logger.error("singleton mcp child error", { error: String(err) });
484
+ });
485
+ }
486
+ /**
487
+ * Tear down the singleton MCP child unconditionally. `expected=true`
488
+ * marks the upcoming exit so FSMs don't penalise restartCount; the
489
+ * actual `*_exited` events are emitted by the global exit listener.
490
+ * A no-op when the singleton is already gone.
491
+ */
492
+ async killGlobalMcp(expected) {
493
+ const globalSlot = this.globalMcpSlot;
494
+ if (!globalSlot)
495
+ return;
496
+ globalSlot.expectingKill = expected;
497
+ try {
498
+ globalSlot.child.kill("SIGTERM");
499
+ }
500
+ catch (e) {
501
+ this.logger.warn("singleton mcp SIGTERM failed", { error: String(e) });
502
+ }
503
+ const start = Date.now();
504
+ while (this.globalMcpSlot && Date.now() - start < this.shutdownGraceMs) {
505
+ await sleep(50);
506
+ }
507
+ if (this.globalMcpSlot) {
508
+ this.logger.warn("singleton mcp SIGTERM grace expired — sending SIGKILL");
509
+ try {
510
+ globalSlot.child.kill("SIGKILL");
511
+ }
512
+ catch (e) {
513
+ this.logger.warn("singleton mcp SIGKILL failed", { error: String(e) });
514
+ }
515
+ const killStart = Date.now();
516
+ while (this.globalMcpSlot && Date.now() - killStart < 2_000) {
517
+ await sleep(50);
518
+ }
519
+ }
520
+ }
243
521
  async spawnDaemon(opts) {
244
522
  const slot = this.ensureSlot(opts.serial);
245
523
  if (slot.daemon) {
@@ -249,7 +527,17 @@ export class NodeProcessManager {
249
527
  });
250
528
  return;
251
529
  }
252
- const mcpUrl = `http://${this.mcpHost}:${opts.mcpPort}`;
530
+ // 0.4.0 SINGLETON MCP TOPOLOGY: every daemon connects to the SAME
531
+ // shared `device-mcp-server`, so `--mcp-url` MUST point at
532
+ // `globalMcpPort`, not at this device's allocator-allocated port.
533
+ // Pre-singleton each device had its own MCP, so `opts.mcpPort` was
534
+ // authoritative; with the singleton refactor only the FIRST device's
535
+ // port is bound — for every subsequent device `opts.mcpPort` is the
536
+ // allocator-leased number that nothing actually listens on. Falling
537
+ // back to `opts.mcpPort` only when the singleton is not up keeps
538
+ // direct test invocations (no surrounding `spawnMcp` call) working.
539
+ const effectiveMcpPort = this.globalMcpPort > 0 ? this.globalMcpPort : opts.mcpPort;
540
+ const mcpUrl = `http://${this.mcpHost}:${effectiveMcpPort}`;
253
541
  // P1-K of the install-link review: only ADB devices need
254
542
  // `--adb <serial>` — the device-agent uses the flag to pick the
255
543
  // physical Android device. For desktop / self-attach backends
@@ -263,6 +551,12 @@ export class NodeProcessManager {
263
551
  ? opts.backend === "adb"
264
552
  : true;
265
553
  const args = ["--key", opts.keyFile, "--mcp-url", mcpUrl];
554
+ // 0.4.0: route every tool call to the right backend by setting the
555
+ // McpClient's `defaultDevice`. `--adb` is kept as an alias for ADB
556
+ // backends because the daemon's CLI still maps both `--adb` and
557
+ // `--device` to `adbSerial` for backward compat — but `--device`
558
+ // is the canonical 0.4.0 flag.
559
+ args.push("--device", opts.serial);
266
560
  if (isAdbBackend) {
267
561
  args.push("--adb", opts.serial);
268
562
  }
@@ -467,7 +761,32 @@ export class NodeProcessManager {
467
761
  }
468
762
  // ── Kill paths ───────────────────────────────────────────────────────────
469
763
  async killMcp(serial, opts = {}) {
470
- await this.killChild(serial, "mcp", opts.expected ?? true);
764
+ const expected = opts.expected ?? true;
765
+ const slot = this.slots.get(serial);
766
+ if (!slot)
767
+ return;
768
+ // Singleton MCP: this serial may or may not currently hold a ref. Always
769
+ // clear the per-device pointer + emit per-device `mcp_exited`, then
770
+ // tear down the global child only when the LAST ref drops.
771
+ const hadRef = this.mcpRefs.delete(serial);
772
+ if (slot.mcp) {
773
+ slot.mcp = undefined;
774
+ // Per-device exit so the FSM transitions cleanly. The global child
775
+ // is still alive for any remaining refs (or being torn down below).
776
+ this.emit({
777
+ kind: "mcp_exited",
778
+ serial,
779
+ code: null,
780
+ signal: null,
781
+ expected,
782
+ });
783
+ }
784
+ if (hadRef && this.mcpRefs.size === 0 && this.globalMcpSlot) {
785
+ // Last ref gone — tear down the singleton. The global exit listener
786
+ // will be a no-op for `mcp_exited` fan-out (refs is empty), so this
787
+ // is safe even with `expected=true` already on the slot.
788
+ await this.killGlobalMcp(expected);
789
+ }
471
790
  }
472
791
  async killDaemon(serial, opts = {}) {
473
792
  await this.killChild(serial, "daemon", opts.expected ?? true);
@@ -600,20 +919,57 @@ const defaultSpawnFn = (bin, args, env) => {
600
919
  });
601
920
  return child;
602
921
  };
922
+ /**
923
+ * MCP readiness probe — POSTs an `initialize` JSON-RPC request to the
924
+ * given URL (which is the singleton-MCP `/mcp` endpoint) and treats a
925
+ * 200 / 202 response as readiness. Replaces the pre-0.4 `/healthz` GET
926
+ * because device-mcp-server 0.4 only speaks the official MCP wire
927
+ * protocol over `/mcp`.
928
+ */
603
929
  const defaultHttpProbeFn = async (url, timeoutMs) => {
604
930
  const deadline = Date.now() + timeoutMs;
605
931
  while (Date.now() < deadline) {
606
- const ok = await probeOnce(url, 1_000);
932
+ const ok = await probeOnce(url, 1_500);
607
933
  if (ok)
608
934
  return true;
609
935
  await sleep(250);
610
936
  }
611
937
  return false;
612
938
  };
939
+ const PROBE_INITIALIZE_BODY = JSON.stringify({
940
+ jsonrpc: "2.0",
941
+ id: 1,
942
+ method: "initialize",
943
+ params: {
944
+ protocolVersion: "2025-06-18",
945
+ capabilities: {},
946
+ clientInfo: { name: "fleet-readiness-probe", version: "0.4.0" },
947
+ },
948
+ });
613
949
  function probeOnce(url, timeoutMs) {
614
950
  return new Promise((resolve) => {
615
- const req = http.get(url, (res) => {
616
- const ok = res.statusCode === 200;
951
+ const parsed = (() => {
952
+ try {
953
+ return new URL(url);
954
+ }
955
+ catch {
956
+ return null;
957
+ }
958
+ })();
959
+ if (!parsed)
960
+ return resolve(false);
961
+ const req = http.request({
962
+ method: "POST",
963
+ host: parsed.hostname,
964
+ port: parsed.port,
965
+ path: parsed.pathname || "/mcp",
966
+ headers: {
967
+ "content-type": "application/json",
968
+ accept: "application/json, text/event-stream",
969
+ "content-length": Buffer.byteLength(PROBE_INITIALIZE_BODY),
970
+ },
971
+ }, (res) => {
972
+ const ok = res.statusCode === 200 || res.statusCode === 202;
617
973
  res.resume();
618
974
  resolve(ok);
619
975
  });
@@ -622,6 +978,7 @@ function probeOnce(url, timeoutMs) {
622
978
  resolve(false);
623
979
  });
624
980
  req.on("error", () => resolve(false));
981
+ req.end(PROBE_INITIALIZE_BODY);
625
982
  });
626
983
  }
627
984
  /**
@@ -719,15 +1076,6 @@ async function materializeInlineAgentConfig(instanceId) {
719
1076
  return null;
720
1077
  }
721
1078
  }
722
- /**
723
- * `device-mcp-server` `--adb-serial <serial>` is only meaningful when the
724
- * backend talks to ADB. Currently that's just `adb` (canonical) and the
725
- * `android` alias; desktop / mock backends ignore the flag entirely so we
726
- * skip it to keep argv readable in `ps`.
727
- */
728
- function isAdbBackend(name) {
729
- return name === "adb" || name === "android";
730
- }
731
1079
  function sleep(ms) {
732
1080
  return new Promise((res) => {
733
1081
  const t = setTimeout(res, ms);