@agentdeck/bridge 1.0.24 → 1.2.0

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 (184) hide show
  1. package/README.md +14 -6
  2. package/assets/fonts/NOTICE-PocketSansWorld.md +10 -0
  3. package/assets/fonts/OFL-PocketSansWorld.txt +93 -0
  4. package/assets/fonts/PocketSansWorld_12.cpfont +0 -0
  5. package/assets/fonts/pocket-sans-world.manifest.json +49 -0
  6. package/assets/learning/LICENSE-CC-BY-SA-4.0.txt +10 -0
  7. package/assets/learning/NOTICE.md +33 -0
  8. package/assets/learning/jp-n3-ko.manifest.json +27 -0
  9. package/assets/learning/jp-n3-ko.pdl +0 -0
  10. package/dist/adapters/openclaw.d.ts +74 -4
  11. package/dist/adapters/openclaw.d.ts.map +1 -1
  12. package/dist/adapters/openclaw.js +235 -43
  13. package/dist/adapters/openclaw.js.map +1 -1
  14. package/dist/agent-cli-diagnostics.d.ts +20 -0
  15. package/dist/agent-cli-diagnostics.d.ts.map +1 -0
  16. package/dist/agent-cli-diagnostics.js +100 -0
  17. package/dist/agent-cli-diagnostics.js.map +1 -0
  18. package/dist/apme/adapters/openclaw-hook.d.ts +38 -10
  19. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
  20. package/dist/apme/adapters/openclaw-hook.js +181 -37
  21. package/dist/apme/adapters/openclaw-hook.js.map +1 -1
  22. package/dist/apme/collector.d.ts +55 -2
  23. package/dist/apme/collector.d.ts.map +1 -1
  24. package/dist/apme/collector.js +324 -37
  25. package/dist/apme/collector.js.map +1 -1
  26. package/dist/apme/dashboard-html.d.ts.map +1 -1
  27. package/dist/apme/dashboard-html.js +275 -35
  28. package/dist/apme/dashboard-html.js.map +1 -1
  29. package/dist/apme/http.d.ts.map +1 -1
  30. package/dist/apme/http.js +33 -5
  31. package/dist/apme/http.js.map +1 -1
  32. package/dist/apme/index.d.ts.map +1 -1
  33. package/dist/apme/index.js +6 -2
  34. package/dist/apme/index.js.map +1 -1
  35. package/dist/apme/pareto.d.ts +2 -1
  36. package/dist/apme/pareto.d.ts.map +1 -1
  37. package/dist/apme/pareto.js +15 -4
  38. package/dist/apme/pareto.js.map +1 -1
  39. package/dist/apme/recommend.d.ts +24 -1
  40. package/dist/apme/recommend.d.ts.map +1 -1
  41. package/dist/apme/recommend.js +43 -7
  42. package/dist/apme/recommend.js.map +1 -1
  43. package/dist/apme/store.d.ts +47 -1
  44. package/dist/apme/store.d.ts.map +1 -1
  45. package/dist/apme/store.js +397 -99
  46. package/dist/apme/store.js.map +1 -1
  47. package/dist/bridge-core.d.ts +25 -1
  48. package/dist/bridge-core.d.ts.map +1 -1
  49. package/dist/bridge-core.js +50 -9
  50. package/dist/bridge-core.js.map +1 -1
  51. package/dist/card-feed.d.ts +46 -1
  52. package/dist/card-feed.d.ts.map +1 -1
  53. package/dist/card-feed.js +166 -6
  54. package/dist/card-feed.js.map +1 -1
  55. package/dist/cli.d.ts +56 -0
  56. package/dist/cli.d.ts.map +1 -1
  57. package/dist/cli.js +442 -34
  58. package/dist/cli.js.map +1 -1
  59. package/dist/codex-rate-limits-live.d.ts +180 -10
  60. package/dist/codex-rate-limits-live.d.ts.map +1 -1
  61. package/dist/codex-rate-limits-live.js +475 -22
  62. package/dist/codex-rate-limits-live.js.map +1 -1
  63. package/dist/daemon-build-identity.d.ts +124 -0
  64. package/dist/daemon-build-identity.d.ts.map +1 -0
  65. package/dist/daemon-build-identity.js +310 -0
  66. package/dist/daemon-build-identity.js.map +1 -0
  67. package/dist/daemon-server.d.ts +34 -6
  68. package/dist/daemon-server.d.ts.map +1 -1
  69. package/dist/daemon-server.js +876 -109
  70. package/dist/daemon-server.js.map +1 -1
  71. package/dist/daemon-takeover.d.ts +11 -0
  72. package/dist/daemon-takeover.d.ts.map +1 -1
  73. package/dist/daemon-takeover.js +62 -17
  74. package/dist/daemon-takeover.js.map +1 -1
  75. package/dist/daemon-ws-client.d.ts +1 -0
  76. package/dist/daemon-ws-client.d.ts.map +1 -1
  77. package/dist/daemon-ws-client.js +25 -16
  78. package/dist/daemon-ws-client.js.map +1 -1
  79. package/dist/daemon.js +1 -1
  80. package/dist/daemon.js.map +1 -1
  81. package/dist/esp32-serial.d.ts +18 -0
  82. package/dist/esp32-serial.d.ts.map +1 -1
  83. package/dist/esp32-serial.js +23 -1
  84. package/dist/esp32-serial.js.map +1 -1
  85. package/dist/font-pack.d.ts +15 -0
  86. package/dist/font-pack.d.ts.map +1 -0
  87. package/dist/font-pack.js +75 -0
  88. package/dist/font-pack.js.map +1 -0
  89. package/dist/glance-frame.d.ts.map +1 -1
  90. package/dist/glance-frame.js +5 -1
  91. package/dist/glance-frame.js.map +1 -1
  92. package/dist/hook-codex-sessions.d.ts +3 -1
  93. package/dist/hook-codex-sessions.d.ts.map +1 -1
  94. package/dist/hook-codex-sessions.js +27 -7
  95. package/dist/hook-codex-sessions.js.map +1 -1
  96. package/dist/http-auth-gate.d.ts.map +1 -1
  97. package/dist/http-auth-gate.js +9 -1
  98. package/dist/http-auth-gate.js.map +1 -1
  99. package/dist/index.js +2 -2
  100. package/dist/index.js.map +1 -1
  101. package/dist/learning-pack.d.ts +24 -0
  102. package/dist/learning-pack.d.ts.map +1 -0
  103. package/dist/learning-pack.js +125 -0
  104. package/dist/learning-pack.js.map +1 -0
  105. package/dist/legacy-rearm-ledger.d.ts +56 -0
  106. package/dist/legacy-rearm-ledger.d.ts.map +1 -0
  107. package/dist/legacy-rearm-ledger.js +66 -0
  108. package/dist/legacy-rearm-ledger.js.map +1 -0
  109. package/dist/mdns.d.ts +33 -0
  110. package/dist/mdns.d.ts.map +1 -1
  111. package/dist/mdns.js +92 -3
  112. package/dist/mdns.js.map +1 -1
  113. package/dist/openclaw-session.d.ts.map +1 -1
  114. package/dist/openclaw-session.js +13 -0
  115. package/dist/openclaw-session.js.map +1 -1
  116. package/dist/openclaw-timeline-feed.d.ts +84 -0
  117. package/dist/openclaw-timeline-feed.d.ts.map +1 -0
  118. package/dist/openclaw-timeline-feed.js +162 -0
  119. package/dist/openclaw-timeline-feed.js.map +1 -0
  120. package/dist/openclaw-transcript-timeline.d.ts +103 -0
  121. package/dist/openclaw-transcript-timeline.d.ts.map +1 -0
  122. package/dist/openclaw-transcript-timeline.js +328 -0
  123. package/dist/openclaw-transcript-timeline.js.map +1 -0
  124. package/dist/pairing-knocks.d.ts +80 -0
  125. package/dist/pairing-knocks.d.ts.map +1 -0
  126. package/dist/pairing-knocks.js +312 -0
  127. package/dist/pairing-knocks.js.map +1 -0
  128. package/dist/passive-observer.d.ts +21 -1
  129. package/dist/passive-observer.d.ts.map +1 -1
  130. package/dist/passive-observer.js +37 -3
  131. package/dist/passive-observer.js.map +1 -1
  132. package/dist/pixoo/micro-glyphs.d.ts +4 -0
  133. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  134. package/dist/pixoo/micro-glyphs.js +29 -0
  135. package/dist/pixoo/micro-glyphs.js.map +1 -1
  136. package/dist/pixoo/pixoo-font.d.ts.map +1 -1
  137. package/dist/pixoo/pixoo-font.js +3 -1
  138. package/dist/pixoo/pixoo-font.js.map +1 -1
  139. package/dist/pixoo/pixoo-renderer.d.ts +1 -1
  140. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  141. package/dist/pixoo/pixoo-renderer.js +17 -2
  142. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  143. package/dist/pty-manager.d.ts +9 -0
  144. package/dist/pty-manager.d.ts.map +1 -1
  145. package/dist/pty-manager.js +63 -4
  146. package/dist/pty-manager.js.map +1 -1
  147. package/dist/relayed-usage.d.ts +98 -0
  148. package/dist/relayed-usage.d.ts.map +1 -0
  149. package/dist/relayed-usage.js +103 -0
  150. package/dist/relayed-usage.js.map +1 -0
  151. package/dist/session-aggregator.d.ts +3 -0
  152. package/dist/session-aggregator.d.ts.map +1 -1
  153. package/dist/session-aggregator.js.map +1 -1
  154. package/dist/session-registry.d.ts +1 -0
  155. package/dist/session-registry.d.ts.map +1 -1
  156. package/dist/surface-protocol.d.ts +84 -0
  157. package/dist/surface-protocol.d.ts.map +1 -0
  158. package/dist/surface-protocol.js +237 -0
  159. package/dist/surface-protocol.js.map +1 -0
  160. package/dist/timeline-store.d.ts +1 -1
  161. package/dist/timeline-store.d.ts.map +1 -1
  162. package/dist/timeline-store.js +16 -1
  163. package/dist/timeline-store.js.map +1 -1
  164. package/dist/tui/dashboard.d.ts.map +1 -1
  165. package/dist/tui/dashboard.js +8 -1
  166. package/dist/tui/dashboard.js.map +1 -1
  167. package/dist/tui/renderer.d.ts.map +1 -1
  168. package/dist/tui/renderer.js +35 -0
  169. package/dist/tui/renderer.js.map +1 -1
  170. package/dist/weather-config.d.ts +17 -0
  171. package/dist/weather-config.d.ts.map +1 -0
  172. package/dist/weather-config.js +54 -0
  173. package/dist/weather-config.js.map +1 -0
  174. package/dist/weather.d.ts +44 -4
  175. package/dist/weather.d.ts.map +1 -1
  176. package/dist/weather.js +223 -15
  177. package/dist/weather.js.map +1 -1
  178. package/dist/ws-server.d.ts +18 -0
  179. package/dist/ws-server.d.ts.map +1 -1
  180. package/dist/ws-server.js +95 -7
  181. package/dist/ws-server.js.map +1 -1
  182. package/fm-helper/AgentDeckFMHelper.swift +51 -5
  183. package/package.json +4 -3
  184. package/src/timebox/sync_ble.py +35 -16
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { Command, InvalidArgumentError } from 'commander';
3
3
  import { writeFileSync, unlinkSync, existsSync, realpathSync, readFileSync } from 'fs';
4
4
  import { homedir } from 'os';
5
5
  import { dirname, join } from 'path';
6
- import { execFileSync, execSync, spawn } from 'child_process';
6
+ import { execFileSync, execSync, spawn, spawnSync } from 'child_process';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { createRequire } from 'module';
9
9
  import { request } from 'http';
@@ -28,6 +28,25 @@ function warnIfDaemonHostIgnored(opts) {
28
28
  console.error('[agentdeck] --daemon-host / AGENTDECK_DAEMON_HOST is set but ignored: remote attach requires --remote-daemon (or AGENTDECK_REMOTE_DAEMON=1). Session stays local-only.');
29
29
  }
30
30
  }
31
+ /**
32
+ * User-visible compatibility notice for the legacy per-session bridge. Keep
33
+ * this pure so CLI tests can pin the public migration contract without starting
34
+ * a PTY or importing its native dependencies.
35
+ */
36
+ export function managedPtyCompatibilityNotice(command) {
37
+ const directLaunch = command === 'monitor'
38
+ ? 'run `claude`, `codex`, or `opencode` normally'
39
+ : `run \`${command}\` directly`;
40
+ return [
41
+ `[agentdeck] LEGACY COMPATIBILITY MODE: \`agentdeck ${command}\` uses the managed per-session bridge; daemon-first launch is the recommended default.`,
42
+ `[agentdeck] For ordinary local sessions, run \`agentdeck daemon install\`, then ${directLaunch}.`,
43
+ '[agentdeck] --remote-daemon, --weight, AGENTDECK_<AGENT>_ARGS, and terminal-only controls do not yet have daemon-first equivalents. No removal date is set. Design: https://github.com/puritysb/AgentDeck/discussions/278 Tracking: https://github.com/puritysb/AgentDeck/issues/273',
44
+ ];
45
+ }
46
+ function warnManagedPtyCompatibility(command) {
47
+ for (const line of managedPtyCompatibilityNotice(command))
48
+ console.error(line);
49
+ }
31
50
  function log(msg) {
32
51
  process.stderr.write(msg + '\n');
33
52
  }
@@ -323,6 +342,38 @@ for (const b of ESP32_OTA_BOARDS) {
323
342
  export function resolveEsp32OtaDaemonTarget(target) {
324
343
  return ESP32_OTA_BY_TARGET[target]?.board ?? target;
325
344
  }
345
+ export function inlineOtaPayload(target, firmwareB64, opts = {}) {
346
+ return {
347
+ target,
348
+ firmwareB64,
349
+ ...(opts.stage ? { stage: true } : {}),
350
+ ...(opts.identity ?? {}),
351
+ };
352
+ }
353
+ /** Resolve one exact OTA namespace from an integration manifest. Ambiguity is
354
+ * an error: a release command must never guess which product or channel owns
355
+ * an image. */
356
+ export function resolveOtaIdentityFromManifest(manifestPath, board, productId, updateChannel) {
357
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
358
+ const matches = [];
359
+ for (const product of manifest.products ?? []) {
360
+ if (typeof product.productId !== 'string' || (productId && product.productId !== productId))
361
+ continue;
362
+ for (const ota of product.otaIdentities ?? []) {
363
+ if (ota.board !== board || typeof ota.updateChannel !== 'string')
364
+ continue;
365
+ if (updateChannel && ota.updateChannel !== updateChannel)
366
+ continue;
367
+ matches.push({ productId: product.productId, board, updateChannel: ota.updateChannel });
368
+ }
369
+ }
370
+ if (matches.length !== 1) {
371
+ throw new Error(matches.length === 0
372
+ ? `Manifest has no OTA identity for board=${board}${productId ? ` productId=${productId}` : ''}${updateChannel ? ` updateChannel=${updateChannel}` : ''}`
373
+ : `Manifest OTA identity is ambiguous for board=${board}; add --product-id and/or --update-channel`);
374
+ }
375
+ return matches[0];
376
+ }
326
377
  function resolveEsp32FirmwarePath(target, envOpt, firmwareOpt) {
327
378
  if (firmwareOpt)
328
379
  return realpathSync(firmwareOpt);
@@ -457,10 +508,78 @@ program
457
508
  .name('agentdeck')
458
509
  .description('AgentDeck — Physical Controller for AI Coding Agents')
459
510
  .version(packageJson.version);
511
+ // ===== Weather context =====
512
+ const weatherCommand = program
513
+ .command('weather')
514
+ .description('Configure weather for Dashboard and offline Surface readers');
515
+ weatherCommand
516
+ .command('set')
517
+ .description('Set a coarse forecast location (no account, API key, or IP lookup)')
518
+ .requiredOption('--lat <latitude>', 'Latitude, -90 to 90')
519
+ .requiredOption('--lon <longitude>', 'Longitude, -180 to 180')
520
+ .option('--place <label>', 'Short display label, for example Seoul')
521
+ .option('--timezone <iana>', 'IANA time zone; defaults to this host, for example Asia/Seoul')
522
+ .action(async (opts) => {
523
+ const { loadDaemonSettings, updateDaemonSetting, ownSettingsPath } = await import('./daemon-settings.js');
524
+ const { buildWeatherSetting, WeatherConfigError } = await import('./weather-config.js');
525
+ const settings = loadDaemonSettings();
526
+ const existing = settings.weather && typeof settings.weather === 'object' ? settings.weather : {};
527
+ try {
528
+ const weather = buildWeatherSetting({
529
+ latitude: opts.lat,
530
+ longitude: opts.lon,
531
+ place: opts.place,
532
+ timeZone: opts.timezone,
533
+ }, existing);
534
+ updateDaemonSetting('weather', weather);
535
+ log(`Weather location saved in ${ownSettingsPath()}.`);
536
+ log(`Coarse location: ${weather.lat.toFixed(2)}, ${weather.lon.toFixed(2)}${weather.place ? ` (${weather.place})` : ''}`);
537
+ log(`Time zone: ${weather.timeZone}; portable provider: MET Norway unless an explicit custom provider is retained.`);
538
+ log('The running daemon reads this setting on the next feed pull; no restart is required.');
539
+ }
540
+ catch (error) {
541
+ if (error instanceof WeatherConfigError) {
542
+ log(`Weather location was not changed: ${error.message}.`);
543
+ process.exitCode = 1;
544
+ return;
545
+ }
546
+ throw error;
547
+ }
548
+ });
549
+ weatherCommand
550
+ .command('show')
551
+ .description('Show the configured forecast location and portable provider')
552
+ .action(async () => {
553
+ const { loadDaemonSettings, ownSettingsPath } = await import('./daemon-settings.js');
554
+ const { describeWeatherSetting } = await import('./weather-config.js');
555
+ for (const line of describeWeatherSetting(loadDaemonSettings().weather))
556
+ log(line);
557
+ log(`Settings: ${ownSettingsPath()}`);
558
+ });
559
+ weatherCommand
560
+ .command('clear')
561
+ .description('Remove the saved forecast location and persisted forecast cache')
562
+ .action(async () => {
563
+ const { updateDaemonSetting, ownSettingsPath } = await import('./daemon-settings.js');
564
+ updateDaemonSetting('weather', undefined);
565
+ const cachePath = join(getDataDir(), 'weather-cache-v1.json');
566
+ try {
567
+ if (existsSync(cachePath))
568
+ unlinkSync(cachePath);
569
+ }
570
+ catch (error) {
571
+ log(`Weather location was cleared, but the old cache could not be removed: ${String(error)}`);
572
+ process.exitCode = 1;
573
+ return;
574
+ }
575
+ log(`Weather location cleared from ${ownSettingsPath()}.`);
576
+ log(`Persisted forecast cache removed from ${cachePath}.`);
577
+ log('The running daemon stops adding weather on the next feed pull; no restart is required.');
578
+ });
460
579
  // ===== Agent session commands =====
461
580
  program
462
581
  .command('claude')
463
- .description('Start Claude Code session (PTY + bridge)')
582
+ .description('[legacy compatibility] Start Claude Code in the managed PTY session bridge')
464
583
  .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
465
584
  .option('-c, --command <cmd>', 'Command to spawn', 'claude')
466
585
  .option('-d, --debug', 'Enable debug logging')
@@ -476,6 +595,7 @@ program
476
595
  .option('--no-env-args', 'Ignore AGENTDECK_COMMANDER_ARGS and AGENTDECK_<AGENT>_ARGS for this invocation')
477
596
  .option('--weight <n>', 'Deck/tab sort order override (integer, lower first; default 0)', parseWeight)
478
597
  .action(async (opts) => {
598
+ warnManagedPtyCompatibility('claude');
479
599
  warnIfDaemonHostIgnored(opts);
480
600
  const { startSession } = await import('./index.js');
481
601
  await startSession({
@@ -504,7 +624,7 @@ program
504
624
  });
505
625
  program
506
626
  .command('codex')
507
- .description('Start Codex CLI session (PTY + bridge)')
627
+ .description('[legacy compatibility] Start Codex in the managed PTY session bridge')
508
628
  .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
509
629
  .option('-c, --command <cmd>', 'Command to spawn', 'codex')
510
630
  .option('-d, --debug', 'Enable debug logging')
@@ -518,6 +638,7 @@ program
518
638
  .option('--no-env-args', 'Ignore AGENTDECK_COMMANDER_ARGS and AGENTDECK_<AGENT>_ARGS for this invocation')
519
639
  .option('--weight <n>', 'Deck/tab sort order override (integer, lower first; default 0)', parseWeight)
520
640
  .action(async (opts) => {
641
+ warnManagedPtyCompatibility('codex');
521
642
  warnIfDaemonHostIgnored(opts);
522
643
  // Install Codex lifecycle hooks before starting the session so the
523
644
  // first prompt's UserPromptSubmit / Stop events reach the daemon.
@@ -565,7 +686,7 @@ program
565
686
  });
566
687
  program
567
688
  .command('opencode')
568
- .description('Start OpenCode session (PTY + SSE bridge)')
689
+ .description('[legacy compatibility] Start OpenCode in the managed PTY/SSE session bridge')
569
690
  .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
570
691
  .option('-c, --command <cmd>', 'Command to spawn', 'opencode')
571
692
  .option('-d, --debug', 'Enable debug logging')
@@ -579,6 +700,7 @@ program
579
700
  .option('--no-env-args', 'Ignore AGENTDECK_COMMANDER_ARGS and AGENTDECK_<AGENT>_ARGS for this invocation')
580
701
  .option('--weight <n>', 'Deck/tab sort order override (integer, lower first; default 0)', parseWeight)
581
702
  .action(async (opts) => {
703
+ warnManagedPtyCompatibility('opencode');
582
704
  warnIfDaemonHostIgnored(opts);
583
705
  // Install the OpenCode observer plugin so standalone `opencode` runs
584
706
  // (outside this managed session) also reach the daemon timeline. The
@@ -623,7 +745,7 @@ program
623
745
  });
624
746
  program
625
747
  .command('monitor')
626
- .description('Start hook-only bridge (no PTY — run claude separately)')
748
+ .description('[legacy compatibility] Start the managed hook-only per-session bridge')
627
749
  .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
628
750
  .option('-d, --debug', 'Enable debug logging')
629
751
  .option('--local', 'Disable all device modules')
@@ -634,6 +756,7 @@ program
634
756
  .option('--no-env-args', 'Ignore AGENTDECK_COMMANDER_ARGS for this invocation')
635
757
  .option('--weight <n>', 'Deck/tab sort order override (integer, lower first; default 0)', parseWeight)
636
758
  .action(async (opts) => {
759
+ warnManagedPtyCompatibility('monitor');
637
760
  warnIfDaemonHostIgnored(opts);
638
761
  const { startSession } = await import('./index.js');
639
762
  const port = parseInt(opts.port, 10);
@@ -689,6 +812,88 @@ async function openDaemonLogs(logDir) {
689
812
  };
690
813
  return [open('daemon-stdout.log'), open('daemon-stderr.log')];
691
814
  }
815
+ /** Read `--build` / `--no-build` as three states, not two. */
816
+ function buildModeFrom(cmd, opts) {
817
+ const source = cmd.getOptionValueSource?.('build');
818
+ if (source !== 'cli')
819
+ return 'auto';
820
+ return opts.build === false ? 'never' : 'force';
821
+ }
822
+ /**
823
+ * Make this process the newest build on disk before it starts (or restarts) a
824
+ * daemon.
825
+ *
826
+ * On a dev checkout `agentdeck` is a shim onto `bridge/dist/cli.js`, so
827
+ * "latest version" means "whatever tsc last wrote" — and an edit that has not
828
+ * been built is invisible: the daemon comes up, reports success, and serves the
829
+ * previous build with nothing anywhere naming the gap.
830
+ *
831
+ * When a rebuild happens this function does NOT return: it re-executes the
832
+ * command from the top with `--no-build`. Continuing in place would leave a
833
+ * process that had already loaded the OLD `cli.js` dynamically importing the
834
+ * NEW `daemon-server.js` a moment later — a mixture of two builds that nobody
835
+ * chose and no version string describes.
836
+ *
837
+ * An installed (non-checkout) copy has no sources to compare and returns
838
+ * immediately.
839
+ */
840
+ async function ensureLatestBuild(mode) {
841
+ const { findSourceCheckout, findStaleSources, buildPackages, computeDistBuildIdentity, } = await import('./daemon-build-identity.js');
842
+ const root = findSourceCheckout();
843
+ if (!root)
844
+ return;
845
+ const stale = findStaleSources(root);
846
+ if (stale.length === 0)
847
+ return;
848
+ const names = stale.map((s) => s.pkg).join(', ');
849
+ const newest = stale.reduce((a, b) => (a.sourceMtimeMs > b.sourceMtimeMs ? a : b));
850
+ const rel = newest.newestSource.startsWith(root) ? newest.newestSource.slice(root.length + 1) : newest.newestSource;
851
+ log(`Source is newer than the build in ${names} (newest: ${rel}).`);
852
+ if (mode === 'never') {
853
+ log(`Not rebuilding (--no-build) — running the build already on disk.`);
854
+ return;
855
+ }
856
+ if (mode === 'auto' && !process.stdout.isTTY) {
857
+ // Non-interactive: almost always the autostart unit, whose stdio is a log
858
+ // file. Say what was found; do not spend a compiler run on it. Building
859
+ // here would block the daemon behind `tsc` at login and, on a tree that
860
+ // does not compile, turn a failed build into a KeepAlive respawn loop.
861
+ log(`Not rebuilding (no terminal attached — this is the autostart path). `
862
+ + `Run 'pnpm build' and 'agentdeck daemon restart' to pick it up.`);
863
+ return;
864
+ }
865
+ log(`Rebuilding ${names} first (pass --no-build to skip)…`);
866
+ const before = computeDistBuildIdentity().id;
867
+ const result = buildPackages(root, stale.map((s) => s.pkg));
868
+ if (!result.ok) {
869
+ log(result.reason
870
+ ? `Could not run the build: ${result.reason}.`
871
+ : `Build FAILED — the compiler's errors are above.`);
872
+ // Refusing is the point. Starting here would run the previous build, which
873
+ // is exactly the silent substitution this check exists to prevent — and it
874
+ // would do it right after telling the user a build was needed.
875
+ log(`Not starting. Fix the build, or pass --no-build to start the previous build deliberately.`);
876
+ process.exit(1);
877
+ }
878
+ // Did the build actually produce different code? Very often it did not: an
879
+ // mtime moves for a rebase, a worktree switch or a plain `touch`, and a
880
+ // composite project then compiles nothing at all. Re-executing on that would
881
+ // charge a second process — and a second round of this same message — for a
882
+ // build that changed no byte, so the delta decides, not the fact that tsc ran.
883
+ const after = computeDistBuildIdentity().id;
884
+ if (after === before) {
885
+ log(`Build was already current (${after ?? 'unknown'}) — the timestamps had moved, the code had not.`);
886
+ return;
887
+ }
888
+ // From here the process itself is stale: it loaded the OLD `cli.js` and would
889
+ // dynamically import the NEW `daemon-server.js` a moment later. Start over.
890
+ const scriptPath = fileURLToPath(import.meta.url);
891
+ log(`Rebuilt (${before ?? 'unknown'} → ${after ?? 'unknown'}). Re-running with the new build…`);
892
+ const rerun = spawnSync(process.execPath, [scriptPath, ...process.argv.slice(2), '--no-build'], {
893
+ stdio: 'inherit',
894
+ });
895
+ process.exit(rerun.status ?? 1);
896
+ }
692
897
  daemon
693
898
  .command('start')
694
899
  .description('Start monitoring daemon (WS + mDNS + Gateway proxy)')
@@ -705,9 +910,19 @@ daemon
705
910
  .option('--local', 'Disable all device modules (no mDNS, UDP beacon, LAN sweep, BLE, ADB or serial); still binds all interfaces for paired companion apps')
706
911
  .option('--loopback', 'Bind 127.0.0.1 only and emit nothing onto the LAN (no mDNS, UDP beacon, LAN sweep or BLE; USB serial and ADB reverse stay on). Same as AGENTDECK_LOOPBACK_ONLY=1')
707
912
  .option('--port-window <range>', 'Work in a non-default daemon port window, e.g. 9200-9209. Only for running a throwaway daemon beside the real one: the singleton guard sweeps this range, and a daemon outside the documented 9120-9139 window is invisible to clients that scan it. Same as AGENTDECK_PORT_WINDOW')
708
- .action(async (opts) => {
913
+ .option('--build', 'Rebuild stale workspace packages first even with no terminal attached (dev checkout only)')
914
+ .option('--no-build', 'Never rebuild — start whatever build is already on disk')
915
+ .option('--no-upgrade', 'Leave a running daemon of yours alone even when it is serving an older build')
916
+ .action(async (opts, cmd) => {
917
+ // Before anything else: be the newest build on disk. Everything below —
918
+ // the incumbent comparison especially — is a claim about which code is
919
+ // serving the port, and it is worthless if this process is itself stale.
920
+ // Re-executes and never returns when it rebuilds.
921
+ await ensureLatestBuild(buildModeFrom(cmd, opts));
709
922
  const { findExistingDaemon, probeDaemonHealth, readDaemonInfo, removeDaemonInfo, removeDaemonSession, requestDaemonStandDown, requestDaemonShutdown, waitForDaemonExit, waitForPortBindable } = await import('./session-registry.js');
710
923
  const { adoptPeerToken } = await import('./auth.js');
924
+ const { distBuildId } = await import('./daemon-build-identity.js');
925
+ const localBuild = distBuildId();
711
926
  // Reverse two-tier upgrade path: the macOS app may already own the canonical
712
927
  // port with its in-process Swift daemon (Tier 1 — limited: no ADB devices,
713
928
  // no subscription usage, …). A plain "already running → exit" would strand
@@ -753,7 +968,13 @@ daemon
753
968
  const { negotiateIncumbentDaemon } = await import('./daemon-takeover.js');
754
969
  const { capturePeerActivity } = await import('./apme/activity-history.js');
755
970
  const incumbent = await probeDaemonHealth(targetPort);
756
- const outcome = await negotiateIncumbentDaemon({ port: targetPort, incumbent, portWasExplicit: !!opts.port }, {
971
+ const outcome = await negotiateIncumbentDaemon({
972
+ port: targetPort,
973
+ incumbent,
974
+ portWasExplicit: !!opts.port,
975
+ localBuild,
976
+ replaceStaleBuild: opts.upgrade !== false,
977
+ }, {
757
978
  adoptToken: adoptPeerToken,
758
979
  standDown: requestDaemonStandDown,
759
980
  shutdown: requestDaemonShutdown,
@@ -765,26 +986,42 @@ daemon
765
986
  process.exit(0);
766
987
  if (outcome === 'refuse')
767
988
  process.exit(1);
768
- const daemonInfo = readDaemonInfo();
769
- if (daemonInfo) {
770
- const probePort = daemonInfo.httpPort ?? daemonInfo.port;
989
+ // The same decision again, for a daemon that is NOT on the port we aimed at
990
+ // one that fell back to 9121+ once and has been there since. It answers
991
+ // `daemon start` the same way (exit 0, "already running"), so without this
992
+ // it is the one daemon on the machine that could serve an old build
993
+ // forever. Routed through the same negotiation rather than a second copy of
994
+ // the rule: the ownership gate, the token adoption and the build comparison
995
+ // must not differ by which file found the daemon.
996
+ const settleIncumbentOn = async (probePort, reportedPort, pid, forget, what) => {
771
997
  const health = await probeDaemonHealth(probePort);
772
998
  if (health?.mode === 'daemon') {
773
- log(`Daemon already running on port ${daemonInfo.port} (PID ${daemonInfo.pid}). Use 'agentdeck daemon stop' first.`);
774
- process.exit(0);
999
+ const decision = await negotiateIncumbentDaemon({ port: probePort, incumbent: health, portWasExplicit: false, localBuild, replaceStaleBuild: opts.upgrade !== false }, {
1000
+ adoptToken: adoptPeerToken,
1001
+ standDown: requestDaemonStandDown,
1002
+ shutdown: requestDaemonShutdown,
1003
+ waitForExit: waitForDaemonExit,
1004
+ waitForBindable: waitForPortBindable,
1005
+ captureActivity: capturePeerActivity,
1006
+ });
1007
+ if (decision === 'already-running')
1008
+ process.exit(0);
1009
+ if (decision === 'refuse')
1010
+ process.exit(1);
1011
+ // 'proceed' — it stood down or was replaced; its registry row is dead.
1012
+ forget();
1013
+ return;
775
1014
  }
776
- log(`Ignoring stale daemon entry on port ${daemonInfo.port} (PID ${daemonInfo.pid}; /health did not respond).`);
777
- removeDaemonInfo();
1015
+ log(`Ignoring stale ${what} on port ${reportedPort} (PID ${pid}; /health did not respond).`);
1016
+ forget();
1017
+ };
1018
+ const daemonInfo = readDaemonInfo();
1019
+ if (daemonInfo) {
1020
+ await settleIncumbentOn(daemonInfo.httpPort ?? daemonInfo.port, daemonInfo.port, daemonInfo.pid, removeDaemonInfo, 'daemon entry');
778
1021
  }
779
1022
  const existing = findExistingDaemon();
780
1023
  if (existing) {
781
- const health = await probeDaemonHealth(existing.port);
782
- if (health?.mode === 'daemon') {
783
- log(`Daemon already running on port ${existing.port} (PID ${existing.pid}). Use 'agentdeck daemon stop' first.`);
784
- process.exit(0);
785
- }
786
- log(`Ignoring stale daemon session on port ${existing.port} (PID ${existing.pid}; /health did not respond).`);
787
- removeDaemonSession(existing);
1024
+ await settleIncumbentOn(existing.port, existing.port, existing.pid, () => removeDaemonSession(existing), 'daemon session');
788
1025
  }
789
1026
  // Background fork unless --foreground
790
1027
  if (!opts.foreground) {
@@ -811,6 +1048,11 @@ daemon
811
1048
  // a window that only applies to the parent moves nothing.
812
1049
  if (opts.portWindow)
813
1050
  args.push('--port-window', String(opts.portWindow));
1051
+ // Freshness was settled by this process, above. The child inherits log
1052
+ // files rather than a terminal, so it would decline to build anyway —
1053
+ // saying so explicitly keeps the child's log free of a warning about a
1054
+ // staleness the parent has already resolved.
1055
+ args.push('--no-build');
814
1056
  const [out, err] = await openDaemonLogs(logDir);
815
1057
  const child = spawn(process.execPath, args, {
816
1058
  detached: true,
@@ -818,7 +1060,7 @@ daemon
818
1060
  windowsHide: true,
819
1061
  });
820
1062
  child.unref();
821
- log(`Daemon started (PID ${child.pid})`);
1063
+ log(`Daemon started (PID ${child.pid}, build ${localBuild ?? 'unknown'})`);
822
1064
  process.exit(0);
823
1065
  }
824
1066
  const { startDaemon } = await import('./daemon-server.js');
@@ -845,7 +1087,14 @@ daemon
845
1087
  .option('-d, --debug', 'Enable debug logging')
846
1088
  .option('--local', 'Disable all device modules on the restarted daemon')
847
1089
  .option('--loopback', 'Restart with a loopback-only posture (see `daemon start --loopback`)')
848
- .action(async (opts) => {
1090
+ .option('--build', 'Rebuild stale workspace packages first even with no terminal attached (dev checkout only)')
1091
+ .option('--no-build', 'Never rebuild — restart onto whatever build is already on disk')
1092
+ .action(async (opts, cmd) => {
1093
+ // `daemon restart` is the command a developer runs to put an edit live, so
1094
+ // it answers to the same freshness rule as `start`. Re-executes and never
1095
+ // returns when it rebuilds — before anything has been stopped, so a failed
1096
+ // build costs the machine nothing.
1097
+ await ensureLatestBuild(buildModeFrom(cmd, opts));
849
1098
  // Two different ports, and conflating them is what made this command lose
850
1099
  // the posture. The daemon must be PROBED and STOPPED where it actually is
851
1100
  // (daemon.json — which is 9121+ whenever it once fell back), but RESTARTED
@@ -853,8 +1102,8 @@ daemon
853
1102
  // daemon had fallen back, and "no posture found" reads exactly like "open
854
1103
  // posture" — a silent enterprise downgrade, which is the one thing this
855
1104
  // inheritance exists to prevent.
856
- const { probeDaemonHealth: probeHealth, readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
857
- const { resolveDaemonPort } = await import('./daemon-port.js');
1105
+ const { probeDaemonHealth: probeHealth, readDaemonInfo, findDaemonPort, waitForDaemonExit, waitForPortBindable, } = await import('./session-registry.js');
1106
+ const { resolveDaemonPort, PREFERRED_PORT_RECLAIM_MS } = await import('./daemon-port.js');
858
1107
  const preferredPort = resolveDaemonPort({ flag: opts.port });
859
1108
  const info = readDaemonInfo();
860
1109
  const runningPort = info?.httpPort ?? info?.port ?? findDaemonPort() ?? preferredPort.port;
@@ -872,8 +1121,21 @@ daemon
872
1121
  log(`Daemon is on port ${runningPort} but prefers ${preferredPort.port} — restarting there.`);
873
1122
  }
874
1123
  await stopDaemon(runningPort);
875
- // Wait for port release + session cleanup
876
- await new Promise(resolve => setTimeout(resolve, 1500));
1124
+ // Wait for the old daemon to stop ANSWERING — a real condition, not the
1125
+ // 1500ms guess that used to sit here and merely outlasted the common case.
1126
+ //
1127
+ // The parent deliberately does NOT also wait for the port to become
1128
+ // bindable. Its probe opens a listener and closes it again, so it proves
1129
+ // nothing the child does not re-prove seconds later — and the child does it
1130
+ // better, because it KEEPS the port on success. Waiting here serialized two
1131
+ // port-reclaim waits back to back (macOS holds a NECP reservation for ~14s
1132
+ // after a listener is cancelled) and made a contended restart twice as slow
1133
+ // for no extra information. Whether the child got the port is answered
1134
+ // below, by asking the daemon that came up who it is.
1135
+ const exited = await waitForDaemonExit(runningPort, 8000);
1136
+ if (!exited) {
1137
+ log(`Warning: the daemon on port ${runningPort} was still answering when the stop budget ran out.`);
1138
+ }
877
1139
  const scriptPath = fileURLToPath(import.meta.url);
878
1140
  const args = [scriptPath, 'daemon', 'start', '--foreground'];
879
1141
  // Same rule as `daemon start`: forward `-p` only when it was typed, so the
@@ -886,6 +1148,9 @@ daemon
886
1148
  args.push('--local');
887
1149
  if (useLoopback)
888
1150
  args.push('--loopback');
1151
+ // Same as the `start` fork: freshness is this process's job, and the child
1152
+ // has log files rather than a terminal.
1153
+ args.push('--no-build');
889
1154
  if ((inheritedLocal && !opts.local) || (inheritedLoopback && !opts.loopback)) {
890
1155
  log(`Carrying over the running daemon's posture (${[
891
1156
  inheritedLoopback ? 'loopback-only' : null,
@@ -898,10 +1163,100 @@ daemon
898
1163
  stdio: ['ignore', rOut, rErr],
899
1164
  windowsHide: true,
900
1165
  });
1166
+ // Liveness is what the wait below actually keys on. `detached` + `unref`
1167
+ // still delivers 'exit' to this process for as long as it is running.
1168
+ let childAlive = true;
1169
+ child.once('exit', () => { childAlive = false; });
901
1170
  child.unref();
902
- log(`Daemon restarted (PID ${child.pid})`);
1171
+ // `spawn` resolving a pid means the OS forked a process — it is not
1172
+ // evidence that a daemon is running. A child that dies on EADDRINUSE (the
1173
+ // old port still held) exits within a second, and this command used to
1174
+ // announce `Daemon restarted (PID …)` regardless, so a failed restart was
1175
+ // indistinguishable from a good one and the real reason sat unread in the
1176
+ // daemon log. Verify by asking the daemon who it is.
1177
+ const spawnedPid = child.pid;
1178
+ const started = spawnedPid !== undefined
1179
+ ? await waitForDaemonPid(spawnedPid, preferredPort.port, probeHealth, readDaemonInfo, findDaemonPort, PREFERRED_PORT_RECLAIM_MS, () => childAlive, undefined, (p) => log(`Still starting — child PID ${p} is alive; waiting for it to bind.`))
1180
+ : null;
1181
+ if (!started) {
1182
+ log(`Daemon restart FAILED — no daemon with PID ${spawnedPid ?? '?'} is answering.`);
1183
+ // Both streams, not just stderr: the likeliest failure is the child
1184
+ // hitting `daemon start`'s incumbent guard, which reports through `log()`
1185
+ // — i.e. stdout. Naming only stderr pointed at the empty file.
1186
+ log(`The reason is in ${join(homedir(), '.agentdeck', 'daemon-stdout.log')}`
1187
+ + ` or ${join(homedir(), '.agentdeck', 'daemon-stderr.log')}.`);
1188
+ process.exit(1);
1189
+ }
1190
+ if (started.port !== preferredPort.port) {
1191
+ log(`Daemon restarted (PID ${started.pid}) on port ${started.port} — it could not take ${preferredPort.port}.`);
1192
+ }
1193
+ else {
1194
+ log(`Daemon restarted (PID ${started.pid}) on port ${started.port}`);
1195
+ }
903
1196
  process.exit(0);
904
1197
  });
1198
+ /**
1199
+ * Poll until a daemon reporting `pid` answers `/health`, or give up.
1200
+ *
1201
+ * The pid comparison is the load-bearing part: probing the port alone can be
1202
+ * satisfied by the daemon we were trying to REPLACE (a stop that silently
1203
+ * failed), which would report a restart that never happened. The fallback-port
1204
+ * sweep exists because the daemon is allowed to land elsewhere when it cannot
1205
+ * win its preferred port — that is a different outcome from "did not start",
1206
+ * and the caller says so rather than calling it a failure.
1207
+ */
1208
+ export async function waitForDaemonPid(pid, preferredPort, probeHealth, readDaemonInfo, findDaemonPort, timeoutMs = 20_000,
1209
+ /**
1210
+ * Is the spawned child still running?
1211
+ *
1212
+ * A derived timeout is still a guess, and this one was wrong twice: the
1213
+ * budget has to cover not just the child's port-reclaim wait but its
1214
+ * incumbent negotiation first — a Swift daemon standing down costs up to
1215
+ * `EXIT_WAIT_MS + BINDABLE_WAIT_MS` before `startDaemon` is even reached, so
1216
+ * the whole worst case runs past a minute. Giving up early prints
1217
+ * "restart FAILED" while the daemon is coming up, and the user's natural
1218
+ * retry then kills it.
1219
+ *
1220
+ * "The child is still running" is a real condition rather than a derived one,
1221
+ * so `timeoutMs` becomes a floor: while the child is alive we keep waiting,
1222
+ * and only a child that EXITED without a matching pid is a failure. The
1223
+ * ceiling still bounds a child that hangs forever.
1224
+ */
1225
+ isChildAlive, ceilingMs = 180_000, onStillWaiting) {
1226
+ let announcedWait = false;
1227
+ const startedAt = Date.now();
1228
+ const deadline = startedAt + timeoutMs;
1229
+ for (;;) {
1230
+ const candidates = new Set([preferredPort]);
1231
+ const info = readDaemonInfo();
1232
+ if (info?.httpPort)
1233
+ candidates.add(info.httpPort);
1234
+ if (info?.port)
1235
+ candidates.add(info.port);
1236
+ const found = findDaemonPort();
1237
+ if (found)
1238
+ candidates.add(found);
1239
+ for (const port of candidates) {
1240
+ const health = await probeHealth(port);
1241
+ if (health?.pid === pid)
1242
+ return { pid, port };
1243
+ }
1244
+ const now = Date.now();
1245
+ if (now >= startedAt + ceilingMs)
1246
+ return null;
1247
+ if (now >= deadline && !(isChildAlive?.() ?? false))
1248
+ return null;
1249
+ // Past the floor with the child still alive, this can legitimately run for
1250
+ // another couple of minutes (a Swift incumbent standing down, then a port
1251
+ // reclaim). Say so once, or a correct wait is indistinguishable from a hung
1252
+ // terminal.
1253
+ if (now >= deadline && !announcedWait) {
1254
+ announcedWait = true;
1255
+ onStillWaiting?.(pid);
1256
+ }
1257
+ await new Promise((resolve) => setTimeout(resolve, 300));
1258
+ }
1259
+ }
905
1260
  daemon
906
1261
  .command('status')
907
1262
  .description('Show daemon status')
@@ -934,6 +1289,23 @@ daemon
934
1289
  + `but is serving ${registryPort} — it fell back at startup. `
935
1290
  + `Run 'agentdeck daemon restart' to aim at ${preferred.port} again.`);
936
1291
  }
1292
+ // Is the daemon that answered running the code on this disk? `build` in
1293
+ // the payload above is what it STARTED with; `dist/cli.js` is overwritten
1294
+ // in place, so nothing else here would show a divergence.
1295
+ const { distBuildId, findSourceCheckout, findStaleSources } = await import('./daemon-build-identity.js');
1296
+ const onDisk = distBuildId();
1297
+ const running = typeof data.build === 'string' ? data.build : null;
1298
+ if (onDisk && running && onDisk !== running) {
1299
+ log(`Note: it is serving build ${running}; the build on disk is ${onDisk}. `
1300
+ + `Run 'agentdeck daemon restart' to serve the current build.`);
1301
+ }
1302
+ const checkout = findSourceCheckout();
1303
+ const stale = checkout ? findStaleSources(checkout) : [];
1304
+ if (stale.length > 0) {
1305
+ log(`Note: ${stale.map((x) => x.pkg).join(', ')} ${stale.length === 1 ? 'has' : 'have'} `
1306
+ + `source files newer than the build. Run 'pnpm build' if you expect that edit to be live `
1307
+ + `(a bare timestamp move — rebase, worktree switch — compiles to nothing).`);
1308
+ }
937
1309
  }
938
1310
  catch {
939
1311
  if (info)
@@ -1363,6 +1735,7 @@ program
1363
1735
  const ver = dev.version ? ` v${dev.version}` : '';
1364
1736
  const hash = dev.buildHash ? ` (${dev.buildHash})` : '';
1365
1737
  const board = dev.board ?? 'esp32';
1738
+ const product = dev.productId ? ` ${dev.productId}/${dev.updateChannel ?? '?'}` : '';
1366
1739
  const ota = dev.otaSupported === true
1367
1740
  ? ` OTA ${formatBytes(dev.otaSlotSize)}`
1368
1741
  : dev.otaReason ? ` OTA no:${dev.otaReason}` : '';
@@ -1370,7 +1743,7 @@ program
1370
1743
  // Single-path: a board also live on USB serial is driven over
1371
1744
  // serial; its WiFi link is a hot standby (no duplicate traffic).
1372
1745
  const transport = dev.serialActive ? ' [serial-active · wifi standby]' : '';
1373
- lines.push(` ${board}${ver}${hash}${ota}${stale}${transport} @ ${dev.ip ?? 'wifi'}`);
1746
+ lines.push(` ${board}${ver}${hash}${product}${ota}${stale}${transport} @ ${dev.ip ?? 'wifi'}`);
1374
1747
  }
1375
1748
  total += devices.length;
1376
1749
  }
@@ -1384,6 +1757,9 @@ program
1384
1757
  lines.push(` Card feed ${clients.length} pull client${clients.length !== 1 ? 's' : ''}`);
1385
1758
  for (const c of clients) {
1386
1759
  const who = c.board ? `${c.board} @ ${c.client}` : c.client;
1760
+ const product = c.productId
1761
+ ? ` ${c.productId}${c.clientVersion ? ` v${c.clientVersion}` : ''}`
1762
+ : '';
1387
1763
  const ago = `${Math.round((Date.now() - c.lastPullAt) / 1000)}s ago`;
1388
1764
  const cadence = c.lastIntervalSec !== undefined
1389
1765
  ? `, last gap ${c.lastIntervalSec}s vs ${c.lastNextPullSec}s asked`
@@ -1391,7 +1767,7 @@ program
1391
1767
  const honoured = c.pulls > 1
1392
1768
  ? ` [${c.cadenceHonouredCount}/${c.pulls - 1} on cadence]`
1393
1769
  : '';
1394
- lines.push(` ${who} — ${c.pulls} pull${c.pulls !== 1 ? 's' : ''}, last ${ago}${cadence}${honoured}`);
1770
+ lines.push(` ${who}${product} — ${c.pulls} pull${c.pulls !== 1 ? 's' : ''}, last ${ago}${cadence}${honoured}`);
1395
1771
  }
1396
1772
  }
1397
1773
  }
@@ -1504,6 +1880,9 @@ program
1504
1880
  .option('-f, --firmware <path>', 'Firmware .bin path')
1505
1881
  .option('--build', 'Build the PlatformIO environment before upload')
1506
1882
  .option('--stage', 'Stage for pull OTA: the board installs it on its next feed pull (no live WS needed)')
1883
+ .option('--manifest <path>', 'Integration manifest used to resolve the exact pull-OTA identity')
1884
+ .option('--product-id <id>', 'Surface productId for pull-OTA isolation')
1885
+ .option('--update-channel <channel>', 'Surface update channel for pull-OTA isolation')
1507
1886
  .action(async (target, opts) => {
1508
1887
  const { readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
1509
1888
  const info = readDaemonInfo();
@@ -1525,10 +1904,33 @@ program
1525
1904
  const daemonTarget = resolveEsp32OtaDaemonTarget(target);
1526
1905
  const firmwarePath = resolveEsp32FirmwarePath(target, opts.env, opts.firmware);
1527
1906
  if (opts.stage) {
1907
+ let otaIdentity;
1908
+ if (opts.manifest) {
1909
+ otaIdentity = resolveOtaIdentityFromManifest(realpathSync(opts.manifest), daemonTarget, opts.productId, opts.updateChannel);
1910
+ }
1911
+ else if (opts.productId || opts.updateChannel) {
1912
+ if (!opts.productId || !opts.updateChannel) {
1913
+ throw new Error('--product-id and --update-channel are required together (or use --manifest)');
1914
+ }
1915
+ otaIdentity = { productId: opts.productId, board: daemonTarget, updateChannel: opts.updateChannel };
1916
+ }
1917
+ if (!otaIdentity && (daemonTarget === 'xteink_x3' || daemonTarget === 'xteink_x4')) {
1918
+ throw new Error('XTeink pull OTA requires --manifest, or both --product-id and --update-channel');
1919
+ }
1528
1920
  // Pull-OTA staging: for wake-sync-sleep boards (XTeink X3/X4) that never
1529
1921
  // hold a live WS. The daemon adverts the build on the board's feed pulls;
1530
1922
  // the board downloads + flashes itself on its next wake.
1531
- const { statusCode, body } = await postJsonWithTimeout(`http://127.0.0.1:${port}/esp32/ota`, { target: daemonTarget, firmwarePath, stage: true }, 30_000);
1923
+ let { statusCode, body } = await postJsonWithTimeout(`http://127.0.0.1:${port}/esp32/ota`, { target: daemonTarget, firmwarePath, stage: true, ...(otaIdentity ?? {}) }, 30_000);
1924
+ if (body.ok === false && String(body.error ?? '').startsWith('firmware_unreadable')) {
1925
+ // The App Store daemon cannot reopen an arbitrary CLI path. Preserve
1926
+ // the stage bit and exact product namespace when retrying inline — the
1927
+ // live-OTA retry below is a different operation and must not consume a
1928
+ // pull-stage request by accident.
1929
+ log('Daemon cannot read the firmware path (sandbox) — resending stage inline...');
1930
+ const { readFileSync } = await import('fs');
1931
+ const firmwareB64 = readFileSync(firmwarePath).toString('base64');
1932
+ ({ statusCode, body } = await postJsonWithTimeout(`http://127.0.0.1:${port}/esp32/ota`, inlineOtaPayload(daemonTarget, firmwareB64, { stage: true, identity: otaIdentity }), 30_000));
1933
+ }
1532
1934
  if (statusCode < 200 || statusCode >= 300 || body.ok === false) {
1533
1935
  throw new Error(String(body.error ?? `HTTP ${statusCode}`));
1534
1936
  }
@@ -1558,7 +1960,7 @@ program
1558
1960
  log('Daemon cannot read the firmware path (sandbox) — resending inline...');
1559
1961
  const { readFileSync } = await import('fs');
1560
1962
  const firmwareB64 = readFileSync(firmwarePath).toString('base64');
1561
- ({ statusCode, body } = await postJsonWithTimeout(`http://127.0.0.1:${port}/esp32/ota`, { target: daemonTarget, firmwareB64 }, 15 * 60_000));
1963
+ ({ statusCode, body } = await postJsonWithTimeout(`http://127.0.0.1:${port}/esp32/ota`, inlineOtaPayload(daemonTarget, firmwareB64), 15 * 60_000));
1562
1964
  }
1563
1965
  if (statusCode < 200 || statusCode >= 300 || body.ok === false) {
1564
1966
  throw new Error(String(body.error ?? `HTTP ${statusCode}`));
@@ -1983,8 +2385,8 @@ program
1983
2385
  .option('--json', 'Print target diagnostics as machine-readable JSON')
1984
2386
  .action(async (target, opts) => {
1985
2387
  if (target) {
1986
- if (target !== 'kiro') {
1987
- log(`Unknown diagnostic target: ${target}. Supported target: kiro`);
2388
+ if (target !== 'kiro' && target !== 'agents') {
2389
+ log(`Unknown diagnostic target: ${target}. Supported targets: agents, kiro`);
1988
2390
  process.exitCode = 1;
1989
2391
  return;
1990
2392
  }
@@ -1993,6 +2395,12 @@ program
1993
2395
  process.exitCode = 1;
1994
2396
  return;
1995
2397
  }
2398
+ if (target === 'agents') {
2399
+ const { collectAgentCliDiagnosticReport, formatAgentCliDiagnosticReport } = await import('./agent-cli-diagnostics.js');
2400
+ const report = collectAgentCliDiagnosticReport();
2401
+ process.stdout.write(`${opts.json ? JSON.stringify(report, null, 2) : formatAgentCliDiagnosticReport(report)}\n`);
2402
+ return;
2403
+ }
1996
2404
  const { collectKiroDiagnosticReport, formatKiroDiagnosticReport } = await import('./kiro-diagnostics.js');
1997
2405
  const report = await collectKiroDiagnosticReport();
1998
2406
  process.stdout.write(`${opts.json ? JSON.stringify(report, null, 2) : formatKiroDiagnosticReport(report)}\n`);