@agentdeck/bridge 1.0.24 → 1.1.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 (117) hide show
  1. package/dist/adapters/openclaw.d.ts +74 -4
  2. package/dist/adapters/openclaw.d.ts.map +1 -1
  3. package/dist/adapters/openclaw.js +235 -43
  4. package/dist/adapters/openclaw.js.map +1 -1
  5. package/dist/apme/adapters/openclaw-hook.d.ts +38 -10
  6. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
  7. package/dist/apme/adapters/openclaw-hook.js +181 -37
  8. package/dist/apme/adapters/openclaw-hook.js.map +1 -1
  9. package/dist/apme/collector.d.ts +37 -1
  10. package/dist/apme/collector.d.ts.map +1 -1
  11. package/dist/apme/collector.js +277 -35
  12. package/dist/apme/collector.js.map +1 -1
  13. package/dist/apme/pareto.d.ts +2 -1
  14. package/dist/apme/pareto.d.ts.map +1 -1
  15. package/dist/apme/pareto.js +15 -4
  16. package/dist/apme/pareto.js.map +1 -1
  17. package/dist/apme/recommend.d.ts +24 -1
  18. package/dist/apme/recommend.d.ts.map +1 -1
  19. package/dist/apme/recommend.js +43 -7
  20. package/dist/apme/recommend.js.map +1 -1
  21. package/dist/apme/store.d.ts +6 -0
  22. package/dist/apme/store.d.ts.map +1 -1
  23. package/dist/apme/store.js +244 -72
  24. package/dist/apme/store.js.map +1 -1
  25. package/dist/bridge-core.d.ts +10 -1
  26. package/dist/bridge-core.d.ts.map +1 -1
  27. package/dist/bridge-core.js +20 -5
  28. package/dist/bridge-core.js.map +1 -1
  29. package/dist/card-feed.d.ts +46 -1
  30. package/dist/card-feed.d.ts.map +1 -1
  31. package/dist/card-feed.js +166 -6
  32. package/dist/card-feed.js.map +1 -1
  33. package/dist/cli.d.ts +45 -0
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +388 -27
  36. package/dist/cli.js.map +1 -1
  37. package/dist/daemon-build-identity.d.ts +124 -0
  38. package/dist/daemon-build-identity.d.ts.map +1 -0
  39. package/dist/daemon-build-identity.js +310 -0
  40. package/dist/daemon-build-identity.js.map +1 -0
  41. package/dist/daemon-server.d.ts +34 -6
  42. package/dist/daemon-server.d.ts.map +1 -1
  43. package/dist/daemon-server.js +613 -108
  44. package/dist/daemon-server.js.map +1 -1
  45. package/dist/daemon-takeover.d.ts +11 -0
  46. package/dist/daemon-takeover.d.ts.map +1 -1
  47. package/dist/daemon-takeover.js +62 -17
  48. package/dist/daemon-takeover.js.map +1 -1
  49. package/dist/daemon.js +1 -1
  50. package/dist/daemon.js.map +1 -1
  51. package/dist/esp32-serial.d.ts +14 -0
  52. package/dist/esp32-serial.d.ts.map +1 -1
  53. package/dist/esp32-serial.js +19 -1
  54. package/dist/esp32-serial.js.map +1 -1
  55. package/dist/glance-frame.d.ts.map +1 -1
  56. package/dist/glance-frame.js +3 -1
  57. package/dist/glance-frame.js.map +1 -1
  58. package/dist/hook-codex-sessions.d.ts +3 -1
  59. package/dist/hook-codex-sessions.d.ts.map +1 -1
  60. package/dist/hook-codex-sessions.js +27 -7
  61. package/dist/hook-codex-sessions.js.map +1 -1
  62. package/dist/legacy-rearm-ledger.d.ts +56 -0
  63. package/dist/legacy-rearm-ledger.d.ts.map +1 -0
  64. package/dist/legacy-rearm-ledger.js +66 -0
  65. package/dist/legacy-rearm-ledger.js.map +1 -0
  66. package/dist/mdns.d.ts +33 -0
  67. package/dist/mdns.d.ts.map +1 -1
  68. package/dist/mdns.js +91 -2
  69. package/dist/mdns.js.map +1 -1
  70. package/dist/openclaw-session.d.ts.map +1 -1
  71. package/dist/openclaw-session.js +13 -0
  72. package/dist/openclaw-session.js.map +1 -1
  73. package/dist/openclaw-timeline-feed.d.ts +84 -0
  74. package/dist/openclaw-timeline-feed.d.ts.map +1 -0
  75. package/dist/openclaw-timeline-feed.js +162 -0
  76. package/dist/openclaw-timeline-feed.js.map +1 -0
  77. package/dist/openclaw-transcript-timeline.d.ts +103 -0
  78. package/dist/openclaw-transcript-timeline.d.ts.map +1 -0
  79. package/dist/openclaw-transcript-timeline.js +328 -0
  80. package/dist/openclaw-transcript-timeline.js.map +1 -0
  81. package/dist/passive-observer.d.ts +21 -1
  82. package/dist/passive-observer.d.ts.map +1 -1
  83. package/dist/passive-observer.js +37 -3
  84. package/dist/passive-observer.js.map +1 -1
  85. package/dist/relayed-usage.d.ts +68 -0
  86. package/dist/relayed-usage.d.ts.map +1 -0
  87. package/dist/relayed-usage.js +80 -0
  88. package/dist/relayed-usage.js.map +1 -0
  89. package/dist/session-aggregator.d.ts +3 -0
  90. package/dist/session-aggregator.d.ts.map +1 -1
  91. package/dist/session-aggregator.js.map +1 -1
  92. package/dist/session-registry.d.ts +1 -0
  93. package/dist/session-registry.d.ts.map +1 -1
  94. package/dist/surface-protocol.d.ts +84 -0
  95. package/dist/surface-protocol.d.ts.map +1 -0
  96. package/dist/surface-protocol.js +235 -0
  97. package/dist/surface-protocol.js.map +1 -0
  98. package/dist/timeline-store.d.ts +1 -1
  99. package/dist/timeline-store.d.ts.map +1 -1
  100. package/dist/timeline-store.js +16 -1
  101. package/dist/timeline-store.js.map +1 -1
  102. package/dist/tui/renderer.d.ts.map +1 -1
  103. package/dist/tui/renderer.js +35 -0
  104. package/dist/tui/renderer.js.map +1 -1
  105. package/dist/weather-config.d.ts +17 -0
  106. package/dist/weather-config.d.ts.map +1 -0
  107. package/dist/weather-config.js +54 -0
  108. package/dist/weather-config.js.map +1 -0
  109. package/dist/weather.d.ts +44 -4
  110. package/dist/weather.d.ts.map +1 -1
  111. package/dist/weather.js +223 -15
  112. package/dist/weather.js.map +1 -1
  113. package/dist/ws-server.d.ts.map +1 -1
  114. package/dist/ws-server.js +55 -5
  115. package/dist/ws-server.js.map +1 -1
  116. package/fm-helper/AgentDeckFMHelper.swift +51 -5
  117. package/package.json +3 -3
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';
@@ -323,6 +323,30 @@ for (const b of ESP32_OTA_BOARDS) {
323
323
  export function resolveEsp32OtaDaemonTarget(target) {
324
324
  return ESP32_OTA_BY_TARGET[target]?.board ?? target;
325
325
  }
326
+ /** Resolve one exact OTA namespace from an integration manifest. Ambiguity is
327
+ * an error: a release command must never guess which product or channel owns
328
+ * an image. */
329
+ export function resolveOtaIdentityFromManifest(manifestPath, board, productId, updateChannel) {
330
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
331
+ const matches = [];
332
+ for (const product of manifest.products ?? []) {
333
+ if (typeof product.productId !== 'string' || (productId && product.productId !== productId))
334
+ continue;
335
+ for (const ota of product.otaIdentities ?? []) {
336
+ if (ota.board !== board || typeof ota.updateChannel !== 'string')
337
+ continue;
338
+ if (updateChannel && ota.updateChannel !== updateChannel)
339
+ continue;
340
+ matches.push({ productId: product.productId, board, updateChannel: ota.updateChannel });
341
+ }
342
+ }
343
+ if (matches.length !== 1) {
344
+ throw new Error(matches.length === 0
345
+ ? `Manifest has no OTA identity for board=${board}${productId ? ` productId=${productId}` : ''}${updateChannel ? ` updateChannel=${updateChannel}` : ''}`
346
+ : `Manifest OTA identity is ambiguous for board=${board}; add --product-id and/or --update-channel`);
347
+ }
348
+ return matches[0];
349
+ }
326
350
  function resolveEsp32FirmwarePath(target, envOpt, firmwareOpt) {
327
351
  if (firmwareOpt)
328
352
  return realpathSync(firmwareOpt);
@@ -457,6 +481,74 @@ program
457
481
  .name('agentdeck')
458
482
  .description('AgentDeck — Physical Controller for AI Coding Agents')
459
483
  .version(packageJson.version);
484
+ // ===== Weather context =====
485
+ const weatherCommand = program
486
+ .command('weather')
487
+ .description('Configure weather for Dashboard and offline Surface readers');
488
+ weatherCommand
489
+ .command('set')
490
+ .description('Set a coarse forecast location (no account, API key, or IP lookup)')
491
+ .requiredOption('--lat <latitude>', 'Latitude, -90 to 90')
492
+ .requiredOption('--lon <longitude>', 'Longitude, -180 to 180')
493
+ .option('--place <label>', 'Short display label, for example Seoul')
494
+ .option('--timezone <iana>', 'IANA time zone; defaults to this host, for example Asia/Seoul')
495
+ .action(async (opts) => {
496
+ const { loadDaemonSettings, updateDaemonSetting, ownSettingsPath } = await import('./daemon-settings.js');
497
+ const { buildWeatherSetting, WeatherConfigError } = await import('./weather-config.js');
498
+ const settings = loadDaemonSettings();
499
+ const existing = settings.weather && typeof settings.weather === 'object' ? settings.weather : {};
500
+ try {
501
+ const weather = buildWeatherSetting({
502
+ latitude: opts.lat,
503
+ longitude: opts.lon,
504
+ place: opts.place,
505
+ timeZone: opts.timezone,
506
+ }, existing);
507
+ updateDaemonSetting('weather', weather);
508
+ log(`Weather location saved in ${ownSettingsPath()}.`);
509
+ log(`Coarse location: ${weather.lat.toFixed(2)}, ${weather.lon.toFixed(2)}${weather.place ? ` (${weather.place})` : ''}`);
510
+ log(`Time zone: ${weather.timeZone}; portable provider: MET Norway unless an explicit custom provider is retained.`);
511
+ log('The running daemon reads this setting on the next feed pull; no restart is required.');
512
+ }
513
+ catch (error) {
514
+ if (error instanceof WeatherConfigError) {
515
+ log(`Weather location was not changed: ${error.message}.`);
516
+ process.exitCode = 1;
517
+ return;
518
+ }
519
+ throw error;
520
+ }
521
+ });
522
+ weatherCommand
523
+ .command('show')
524
+ .description('Show the configured forecast location and portable provider')
525
+ .action(async () => {
526
+ const { loadDaemonSettings, ownSettingsPath } = await import('./daemon-settings.js');
527
+ const { describeWeatherSetting } = await import('./weather-config.js');
528
+ for (const line of describeWeatherSetting(loadDaemonSettings().weather))
529
+ log(line);
530
+ log(`Settings: ${ownSettingsPath()}`);
531
+ });
532
+ weatherCommand
533
+ .command('clear')
534
+ .description('Remove the saved forecast location and persisted forecast cache')
535
+ .action(async () => {
536
+ const { updateDaemonSetting, ownSettingsPath } = await import('./daemon-settings.js');
537
+ updateDaemonSetting('weather', undefined);
538
+ const cachePath = join(getDataDir(), 'weather-cache-v1.json');
539
+ try {
540
+ if (existsSync(cachePath))
541
+ unlinkSync(cachePath);
542
+ }
543
+ catch (error) {
544
+ log(`Weather location was cleared, but the old cache could not be removed: ${String(error)}`);
545
+ process.exitCode = 1;
546
+ return;
547
+ }
548
+ log(`Weather location cleared from ${ownSettingsPath()}.`);
549
+ log(`Persisted forecast cache removed from ${cachePath}.`);
550
+ log('The running daemon stops adding weather on the next feed pull; no restart is required.');
551
+ });
460
552
  // ===== Agent session commands =====
461
553
  program
462
554
  .command('claude')
@@ -689,6 +781,88 @@ async function openDaemonLogs(logDir) {
689
781
  };
690
782
  return [open('daemon-stdout.log'), open('daemon-stderr.log')];
691
783
  }
784
+ /** Read `--build` / `--no-build` as three states, not two. */
785
+ function buildModeFrom(cmd, opts) {
786
+ const source = cmd.getOptionValueSource?.('build');
787
+ if (source !== 'cli')
788
+ return 'auto';
789
+ return opts.build === false ? 'never' : 'force';
790
+ }
791
+ /**
792
+ * Make this process the newest build on disk before it starts (or restarts) a
793
+ * daemon.
794
+ *
795
+ * On a dev checkout `agentdeck` is a shim onto `bridge/dist/cli.js`, so
796
+ * "latest version" means "whatever tsc last wrote" — and an edit that has not
797
+ * been built is invisible: the daemon comes up, reports success, and serves the
798
+ * previous build with nothing anywhere naming the gap.
799
+ *
800
+ * When a rebuild happens this function does NOT return: it re-executes the
801
+ * command from the top with `--no-build`. Continuing in place would leave a
802
+ * process that had already loaded the OLD `cli.js` dynamically importing the
803
+ * NEW `daemon-server.js` a moment later — a mixture of two builds that nobody
804
+ * chose and no version string describes.
805
+ *
806
+ * An installed (non-checkout) copy has no sources to compare and returns
807
+ * immediately.
808
+ */
809
+ async function ensureLatestBuild(mode) {
810
+ const { findSourceCheckout, findStaleSources, buildPackages, computeDistBuildIdentity, } = await import('./daemon-build-identity.js');
811
+ const root = findSourceCheckout();
812
+ if (!root)
813
+ return;
814
+ const stale = findStaleSources(root);
815
+ if (stale.length === 0)
816
+ return;
817
+ const names = stale.map((s) => s.pkg).join(', ');
818
+ const newest = stale.reduce((a, b) => (a.sourceMtimeMs > b.sourceMtimeMs ? a : b));
819
+ const rel = newest.newestSource.startsWith(root) ? newest.newestSource.slice(root.length + 1) : newest.newestSource;
820
+ log(`Source is newer than the build in ${names} (newest: ${rel}).`);
821
+ if (mode === 'never') {
822
+ log(`Not rebuilding (--no-build) — running the build already on disk.`);
823
+ return;
824
+ }
825
+ if (mode === 'auto' && !process.stdout.isTTY) {
826
+ // Non-interactive: almost always the autostart unit, whose stdio is a log
827
+ // file. Say what was found; do not spend a compiler run on it. Building
828
+ // here would block the daemon behind `tsc` at login and, on a tree that
829
+ // does not compile, turn a failed build into a KeepAlive respawn loop.
830
+ log(`Not rebuilding (no terminal attached — this is the autostart path). `
831
+ + `Run 'pnpm build' and 'agentdeck daemon restart' to pick it up.`);
832
+ return;
833
+ }
834
+ log(`Rebuilding ${names} first (pass --no-build to skip)…`);
835
+ const before = computeDistBuildIdentity().id;
836
+ const result = buildPackages(root, stale.map((s) => s.pkg));
837
+ if (!result.ok) {
838
+ log(result.reason
839
+ ? `Could not run the build: ${result.reason}.`
840
+ : `Build FAILED — the compiler's errors are above.`);
841
+ // Refusing is the point. Starting here would run the previous build, which
842
+ // is exactly the silent substitution this check exists to prevent — and it
843
+ // would do it right after telling the user a build was needed.
844
+ log(`Not starting. Fix the build, or pass --no-build to start the previous build deliberately.`);
845
+ process.exit(1);
846
+ }
847
+ // Did the build actually produce different code? Very often it did not: an
848
+ // mtime moves for a rebase, a worktree switch or a plain `touch`, and a
849
+ // composite project then compiles nothing at all. Re-executing on that would
850
+ // charge a second process — and a second round of this same message — for a
851
+ // build that changed no byte, so the delta decides, not the fact that tsc ran.
852
+ const after = computeDistBuildIdentity().id;
853
+ if (after === before) {
854
+ log(`Build was already current (${after ?? 'unknown'}) — the timestamps had moved, the code had not.`);
855
+ return;
856
+ }
857
+ // From here the process itself is stale: it loaded the OLD `cli.js` and would
858
+ // dynamically import the NEW `daemon-server.js` a moment later. Start over.
859
+ const scriptPath = fileURLToPath(import.meta.url);
860
+ log(`Rebuilt (${before ?? 'unknown'} → ${after ?? 'unknown'}). Re-running with the new build…`);
861
+ const rerun = spawnSync(process.execPath, [scriptPath, ...process.argv.slice(2), '--no-build'], {
862
+ stdio: 'inherit',
863
+ });
864
+ process.exit(rerun.status ?? 1);
865
+ }
692
866
  daemon
693
867
  .command('start')
694
868
  .description('Start monitoring daemon (WS + mDNS + Gateway proxy)')
@@ -705,9 +879,19 @@ daemon
705
879
  .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
880
  .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
881
  .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) => {
882
+ .option('--build', 'Rebuild stale workspace packages first even with no terminal attached (dev checkout only)')
883
+ .option('--no-build', 'Never rebuild — start whatever build is already on disk')
884
+ .option('--no-upgrade', 'Leave a running daemon of yours alone even when it is serving an older build')
885
+ .action(async (opts, cmd) => {
886
+ // Before anything else: be the newest build on disk. Everything below —
887
+ // the incumbent comparison especially — is a claim about which code is
888
+ // serving the port, and it is worthless if this process is itself stale.
889
+ // Re-executes and never returns when it rebuilds.
890
+ await ensureLatestBuild(buildModeFrom(cmd, opts));
709
891
  const { findExistingDaemon, probeDaemonHealth, readDaemonInfo, removeDaemonInfo, removeDaemonSession, requestDaemonStandDown, requestDaemonShutdown, waitForDaemonExit, waitForPortBindable } = await import('./session-registry.js');
710
892
  const { adoptPeerToken } = await import('./auth.js');
893
+ const { distBuildId } = await import('./daemon-build-identity.js');
894
+ const localBuild = distBuildId();
711
895
  // Reverse two-tier upgrade path: the macOS app may already own the canonical
712
896
  // port with its in-process Swift daemon (Tier 1 — limited: no ADB devices,
713
897
  // no subscription usage, …). A plain "already running → exit" would strand
@@ -753,7 +937,13 @@ daemon
753
937
  const { negotiateIncumbentDaemon } = await import('./daemon-takeover.js');
754
938
  const { capturePeerActivity } = await import('./apme/activity-history.js');
755
939
  const incumbent = await probeDaemonHealth(targetPort);
756
- const outcome = await negotiateIncumbentDaemon({ port: targetPort, incumbent, portWasExplicit: !!opts.port }, {
940
+ const outcome = await negotiateIncumbentDaemon({
941
+ port: targetPort,
942
+ incumbent,
943
+ portWasExplicit: !!opts.port,
944
+ localBuild,
945
+ replaceStaleBuild: opts.upgrade !== false,
946
+ }, {
757
947
  adoptToken: adoptPeerToken,
758
948
  standDown: requestDaemonStandDown,
759
949
  shutdown: requestDaemonShutdown,
@@ -765,26 +955,42 @@ daemon
765
955
  process.exit(0);
766
956
  if (outcome === 'refuse')
767
957
  process.exit(1);
768
- const daemonInfo = readDaemonInfo();
769
- if (daemonInfo) {
770
- const probePort = daemonInfo.httpPort ?? daemonInfo.port;
958
+ // The same decision again, for a daemon that is NOT on the port we aimed at
959
+ // one that fell back to 9121+ once and has been there since. It answers
960
+ // `daemon start` the same way (exit 0, "already running"), so without this
961
+ // it is the one daemon on the machine that could serve an old build
962
+ // forever. Routed through the same negotiation rather than a second copy of
963
+ // the rule: the ownership gate, the token adoption and the build comparison
964
+ // must not differ by which file found the daemon.
965
+ const settleIncumbentOn = async (probePort, reportedPort, pid, forget, what) => {
771
966
  const health = await probeDaemonHealth(probePort);
772
967
  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);
968
+ const decision = await negotiateIncumbentDaemon({ port: probePort, incumbent: health, portWasExplicit: false, localBuild, replaceStaleBuild: opts.upgrade !== false }, {
969
+ adoptToken: adoptPeerToken,
970
+ standDown: requestDaemonStandDown,
971
+ shutdown: requestDaemonShutdown,
972
+ waitForExit: waitForDaemonExit,
973
+ waitForBindable: waitForPortBindable,
974
+ captureActivity: capturePeerActivity,
975
+ });
976
+ if (decision === 'already-running')
977
+ process.exit(0);
978
+ if (decision === 'refuse')
979
+ process.exit(1);
980
+ // 'proceed' — it stood down or was replaced; its registry row is dead.
981
+ forget();
982
+ return;
775
983
  }
776
- log(`Ignoring stale daemon entry on port ${daemonInfo.port} (PID ${daemonInfo.pid}; /health did not respond).`);
777
- removeDaemonInfo();
984
+ log(`Ignoring stale ${what} on port ${reportedPort} (PID ${pid}; /health did not respond).`);
985
+ forget();
986
+ };
987
+ const daemonInfo = readDaemonInfo();
988
+ if (daemonInfo) {
989
+ await settleIncumbentOn(daemonInfo.httpPort ?? daemonInfo.port, daemonInfo.port, daemonInfo.pid, removeDaemonInfo, 'daemon entry');
778
990
  }
779
991
  const existing = findExistingDaemon();
780
992
  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);
993
+ await settleIncumbentOn(existing.port, existing.port, existing.pid, () => removeDaemonSession(existing), 'daemon session');
788
994
  }
789
995
  // Background fork unless --foreground
790
996
  if (!opts.foreground) {
@@ -811,6 +1017,11 @@ daemon
811
1017
  // a window that only applies to the parent moves nothing.
812
1018
  if (opts.portWindow)
813
1019
  args.push('--port-window', String(opts.portWindow));
1020
+ // Freshness was settled by this process, above. The child inherits log
1021
+ // files rather than a terminal, so it would decline to build anyway —
1022
+ // saying so explicitly keeps the child's log free of a warning about a
1023
+ // staleness the parent has already resolved.
1024
+ args.push('--no-build');
814
1025
  const [out, err] = await openDaemonLogs(logDir);
815
1026
  const child = spawn(process.execPath, args, {
816
1027
  detached: true,
@@ -818,7 +1029,7 @@ daemon
818
1029
  windowsHide: true,
819
1030
  });
820
1031
  child.unref();
821
- log(`Daemon started (PID ${child.pid})`);
1032
+ log(`Daemon started (PID ${child.pid}, build ${localBuild ?? 'unknown'})`);
822
1033
  process.exit(0);
823
1034
  }
824
1035
  const { startDaemon } = await import('./daemon-server.js');
@@ -845,7 +1056,14 @@ daemon
845
1056
  .option('-d, --debug', 'Enable debug logging')
846
1057
  .option('--local', 'Disable all device modules on the restarted daemon')
847
1058
  .option('--loopback', 'Restart with a loopback-only posture (see `daemon start --loopback`)')
848
- .action(async (opts) => {
1059
+ .option('--build', 'Rebuild stale workspace packages first even with no terminal attached (dev checkout only)')
1060
+ .option('--no-build', 'Never rebuild — restart onto whatever build is already on disk')
1061
+ .action(async (opts, cmd) => {
1062
+ // `daemon restart` is the command a developer runs to put an edit live, so
1063
+ // it answers to the same freshness rule as `start`. Re-executes and never
1064
+ // returns when it rebuilds — before anything has been stopped, so a failed
1065
+ // build costs the machine nothing.
1066
+ await ensureLatestBuild(buildModeFrom(cmd, opts));
849
1067
  // Two different ports, and conflating them is what made this command lose
850
1068
  // the posture. The daemon must be PROBED and STOPPED where it actually is
851
1069
  // (daemon.json — which is 9121+ whenever it once fell back), but RESTARTED
@@ -853,8 +1071,8 @@ daemon
853
1071
  // daemon had fallen back, and "no posture found" reads exactly like "open
854
1072
  // posture" — a silent enterprise downgrade, which is the one thing this
855
1073
  // inheritance exists to prevent.
856
- const { probeDaemonHealth: probeHealth, readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
857
- const { resolveDaemonPort } = await import('./daemon-port.js');
1074
+ const { probeDaemonHealth: probeHealth, readDaemonInfo, findDaemonPort, waitForDaemonExit, waitForPortBindable, } = await import('./session-registry.js');
1075
+ const { resolveDaemonPort, PREFERRED_PORT_RECLAIM_MS } = await import('./daemon-port.js');
858
1076
  const preferredPort = resolveDaemonPort({ flag: opts.port });
859
1077
  const info = readDaemonInfo();
860
1078
  const runningPort = info?.httpPort ?? info?.port ?? findDaemonPort() ?? preferredPort.port;
@@ -872,8 +1090,21 @@ daemon
872
1090
  log(`Daemon is on port ${runningPort} but prefers ${preferredPort.port} — restarting there.`);
873
1091
  }
874
1092
  await stopDaemon(runningPort);
875
- // Wait for port release + session cleanup
876
- await new Promise(resolve => setTimeout(resolve, 1500));
1093
+ // Wait for the old daemon to stop ANSWERING — a real condition, not the
1094
+ // 1500ms guess that used to sit here and merely outlasted the common case.
1095
+ //
1096
+ // The parent deliberately does NOT also wait for the port to become
1097
+ // bindable. Its probe opens a listener and closes it again, so it proves
1098
+ // nothing the child does not re-prove seconds later — and the child does it
1099
+ // better, because it KEEPS the port on success. Waiting here serialized two
1100
+ // port-reclaim waits back to back (macOS holds a NECP reservation for ~14s
1101
+ // after a listener is cancelled) and made a contended restart twice as slow
1102
+ // for no extra information. Whether the child got the port is answered
1103
+ // below, by asking the daemon that came up who it is.
1104
+ const exited = await waitForDaemonExit(runningPort, 8000);
1105
+ if (!exited) {
1106
+ log(`Warning: the daemon on port ${runningPort} was still answering when the stop budget ran out.`);
1107
+ }
877
1108
  const scriptPath = fileURLToPath(import.meta.url);
878
1109
  const args = [scriptPath, 'daemon', 'start', '--foreground'];
879
1110
  // Same rule as `daemon start`: forward `-p` only when it was typed, so the
@@ -886,6 +1117,9 @@ daemon
886
1117
  args.push('--local');
887
1118
  if (useLoopback)
888
1119
  args.push('--loopback');
1120
+ // Same as the `start` fork: freshness is this process's job, and the child
1121
+ // has log files rather than a terminal.
1122
+ args.push('--no-build');
889
1123
  if ((inheritedLocal && !opts.local) || (inheritedLoopback && !opts.loopback)) {
890
1124
  log(`Carrying over the running daemon's posture (${[
891
1125
  inheritedLoopback ? 'loopback-only' : null,
@@ -898,10 +1132,100 @@ daemon
898
1132
  stdio: ['ignore', rOut, rErr],
899
1133
  windowsHide: true,
900
1134
  });
1135
+ // Liveness is what the wait below actually keys on. `detached` + `unref`
1136
+ // still delivers 'exit' to this process for as long as it is running.
1137
+ let childAlive = true;
1138
+ child.once('exit', () => { childAlive = false; });
901
1139
  child.unref();
902
- log(`Daemon restarted (PID ${child.pid})`);
1140
+ // `spawn` resolving a pid means the OS forked a process — it is not
1141
+ // evidence that a daemon is running. A child that dies on EADDRINUSE (the
1142
+ // old port still held) exits within a second, and this command used to
1143
+ // announce `Daemon restarted (PID …)` regardless, so a failed restart was
1144
+ // indistinguishable from a good one and the real reason sat unread in the
1145
+ // daemon log. Verify by asking the daemon who it is.
1146
+ const spawnedPid = child.pid;
1147
+ const started = spawnedPid !== undefined
1148
+ ? 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.`))
1149
+ : null;
1150
+ if (!started) {
1151
+ log(`Daemon restart FAILED — no daemon with PID ${spawnedPid ?? '?'} is answering.`);
1152
+ // Both streams, not just stderr: the likeliest failure is the child
1153
+ // hitting `daemon start`'s incumbent guard, which reports through `log()`
1154
+ // — i.e. stdout. Naming only stderr pointed at the empty file.
1155
+ log(`The reason is in ${join(homedir(), '.agentdeck', 'daemon-stdout.log')}`
1156
+ + ` or ${join(homedir(), '.agentdeck', 'daemon-stderr.log')}.`);
1157
+ process.exit(1);
1158
+ }
1159
+ if (started.port !== preferredPort.port) {
1160
+ log(`Daemon restarted (PID ${started.pid}) on port ${started.port} — it could not take ${preferredPort.port}.`);
1161
+ }
1162
+ else {
1163
+ log(`Daemon restarted (PID ${started.pid}) on port ${started.port}`);
1164
+ }
903
1165
  process.exit(0);
904
1166
  });
1167
+ /**
1168
+ * Poll until a daemon reporting `pid` answers `/health`, or give up.
1169
+ *
1170
+ * The pid comparison is the load-bearing part: probing the port alone can be
1171
+ * satisfied by the daemon we were trying to REPLACE (a stop that silently
1172
+ * failed), which would report a restart that never happened. The fallback-port
1173
+ * sweep exists because the daemon is allowed to land elsewhere when it cannot
1174
+ * win its preferred port — that is a different outcome from "did not start",
1175
+ * and the caller says so rather than calling it a failure.
1176
+ */
1177
+ export async function waitForDaemonPid(pid, preferredPort, probeHealth, readDaemonInfo, findDaemonPort, timeoutMs = 20_000,
1178
+ /**
1179
+ * Is the spawned child still running?
1180
+ *
1181
+ * A derived timeout is still a guess, and this one was wrong twice: the
1182
+ * budget has to cover not just the child's port-reclaim wait but its
1183
+ * incumbent negotiation first — a Swift daemon standing down costs up to
1184
+ * `EXIT_WAIT_MS + BINDABLE_WAIT_MS` before `startDaemon` is even reached, so
1185
+ * the whole worst case runs past a minute. Giving up early prints
1186
+ * "restart FAILED" while the daemon is coming up, and the user's natural
1187
+ * retry then kills it.
1188
+ *
1189
+ * "The child is still running" is a real condition rather than a derived one,
1190
+ * so `timeoutMs` becomes a floor: while the child is alive we keep waiting,
1191
+ * and only a child that EXITED without a matching pid is a failure. The
1192
+ * ceiling still bounds a child that hangs forever.
1193
+ */
1194
+ isChildAlive, ceilingMs = 180_000, onStillWaiting) {
1195
+ let announcedWait = false;
1196
+ const startedAt = Date.now();
1197
+ const deadline = startedAt + timeoutMs;
1198
+ for (;;) {
1199
+ const candidates = new Set([preferredPort]);
1200
+ const info = readDaemonInfo();
1201
+ if (info?.httpPort)
1202
+ candidates.add(info.httpPort);
1203
+ if (info?.port)
1204
+ candidates.add(info.port);
1205
+ const found = findDaemonPort();
1206
+ if (found)
1207
+ candidates.add(found);
1208
+ for (const port of candidates) {
1209
+ const health = await probeHealth(port);
1210
+ if (health?.pid === pid)
1211
+ return { pid, port };
1212
+ }
1213
+ const now = Date.now();
1214
+ if (now >= startedAt + ceilingMs)
1215
+ return null;
1216
+ if (now >= deadline && !(isChildAlive?.() ?? false))
1217
+ return null;
1218
+ // Past the floor with the child still alive, this can legitimately run for
1219
+ // another couple of minutes (a Swift incumbent standing down, then a port
1220
+ // reclaim). Say so once, or a correct wait is indistinguishable from a hung
1221
+ // terminal.
1222
+ if (now >= deadline && !announcedWait) {
1223
+ announcedWait = true;
1224
+ onStillWaiting?.(pid);
1225
+ }
1226
+ await new Promise((resolve) => setTimeout(resolve, 300));
1227
+ }
1228
+ }
905
1229
  daemon
906
1230
  .command('status')
907
1231
  .description('Show daemon status')
@@ -934,6 +1258,23 @@ daemon
934
1258
  + `but is serving ${registryPort} — it fell back at startup. `
935
1259
  + `Run 'agentdeck daemon restart' to aim at ${preferred.port} again.`);
936
1260
  }
1261
+ // Is the daemon that answered running the code on this disk? `build` in
1262
+ // the payload above is what it STARTED with; `dist/cli.js` is overwritten
1263
+ // in place, so nothing else here would show a divergence.
1264
+ const { distBuildId, findSourceCheckout, findStaleSources } = await import('./daemon-build-identity.js');
1265
+ const onDisk = distBuildId();
1266
+ const running = typeof data.build === 'string' ? data.build : null;
1267
+ if (onDisk && running && onDisk !== running) {
1268
+ log(`Note: it is serving build ${running}; the build on disk is ${onDisk}. `
1269
+ + `Run 'agentdeck daemon restart' to serve the current build.`);
1270
+ }
1271
+ const checkout = findSourceCheckout();
1272
+ const stale = checkout ? findStaleSources(checkout) : [];
1273
+ if (stale.length > 0) {
1274
+ log(`Note: ${stale.map((x) => x.pkg).join(', ')} ${stale.length === 1 ? 'has' : 'have'} `
1275
+ + `source files newer than the build. Run 'pnpm build' if you expect that edit to be live `
1276
+ + `(a bare timestamp move — rebase, worktree switch — compiles to nothing).`);
1277
+ }
937
1278
  }
938
1279
  catch {
939
1280
  if (info)
@@ -1363,6 +1704,7 @@ program
1363
1704
  const ver = dev.version ? ` v${dev.version}` : '';
1364
1705
  const hash = dev.buildHash ? ` (${dev.buildHash})` : '';
1365
1706
  const board = dev.board ?? 'esp32';
1707
+ const product = dev.productId ? ` ${dev.productId}/${dev.updateChannel ?? '?'}` : '';
1366
1708
  const ota = dev.otaSupported === true
1367
1709
  ? ` OTA ${formatBytes(dev.otaSlotSize)}`
1368
1710
  : dev.otaReason ? ` OTA no:${dev.otaReason}` : '';
@@ -1370,7 +1712,7 @@ program
1370
1712
  // Single-path: a board also live on USB serial is driven over
1371
1713
  // serial; its WiFi link is a hot standby (no duplicate traffic).
1372
1714
  const transport = dev.serialActive ? ' [serial-active · wifi standby]' : '';
1373
- lines.push(` ${board}${ver}${hash}${ota}${stale}${transport} @ ${dev.ip ?? 'wifi'}`);
1715
+ lines.push(` ${board}${ver}${hash}${product}${ota}${stale}${transport} @ ${dev.ip ?? 'wifi'}`);
1374
1716
  }
1375
1717
  total += devices.length;
1376
1718
  }
@@ -1384,6 +1726,9 @@ program
1384
1726
  lines.push(` Card feed ${clients.length} pull client${clients.length !== 1 ? 's' : ''}`);
1385
1727
  for (const c of clients) {
1386
1728
  const who = c.board ? `${c.board} @ ${c.client}` : c.client;
1729
+ const product = c.productId
1730
+ ? ` ${c.productId}${c.clientVersion ? ` v${c.clientVersion}` : ''}`
1731
+ : '';
1387
1732
  const ago = `${Math.round((Date.now() - c.lastPullAt) / 1000)}s ago`;
1388
1733
  const cadence = c.lastIntervalSec !== undefined
1389
1734
  ? `, last gap ${c.lastIntervalSec}s vs ${c.lastNextPullSec}s asked`
@@ -1391,7 +1736,7 @@ program
1391
1736
  const honoured = c.pulls > 1
1392
1737
  ? ` [${c.cadenceHonouredCount}/${c.pulls - 1} on cadence]`
1393
1738
  : '';
1394
- lines.push(` ${who} — ${c.pulls} pull${c.pulls !== 1 ? 's' : ''}, last ${ago}${cadence}${honoured}`);
1739
+ lines.push(` ${who}${product} — ${c.pulls} pull${c.pulls !== 1 ? 's' : ''}, last ${ago}${cadence}${honoured}`);
1395
1740
  }
1396
1741
  }
1397
1742
  }
@@ -1504,6 +1849,9 @@ program
1504
1849
  .option('-f, --firmware <path>', 'Firmware .bin path')
1505
1850
  .option('--build', 'Build the PlatformIO environment before upload')
1506
1851
  .option('--stage', 'Stage for pull OTA: the board installs it on its next feed pull (no live WS needed)')
1852
+ .option('--manifest <path>', 'Integration manifest used to resolve the exact pull-OTA identity')
1853
+ .option('--product-id <id>', 'Surface productId for pull-OTA isolation')
1854
+ .option('--update-channel <channel>', 'Surface update channel for pull-OTA isolation')
1507
1855
  .action(async (target, opts) => {
1508
1856
  const { readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
1509
1857
  const info = readDaemonInfo();
@@ -1525,10 +1873,23 @@ program
1525
1873
  const daemonTarget = resolveEsp32OtaDaemonTarget(target);
1526
1874
  const firmwarePath = resolveEsp32FirmwarePath(target, opts.env, opts.firmware);
1527
1875
  if (opts.stage) {
1876
+ let otaIdentity;
1877
+ if (opts.manifest) {
1878
+ otaIdentity = resolveOtaIdentityFromManifest(realpathSync(opts.manifest), daemonTarget, opts.productId, opts.updateChannel);
1879
+ }
1880
+ else if (opts.productId || opts.updateChannel) {
1881
+ if (!opts.productId || !opts.updateChannel) {
1882
+ throw new Error('--product-id and --update-channel are required together (or use --manifest)');
1883
+ }
1884
+ otaIdentity = { productId: opts.productId, board: daemonTarget, updateChannel: opts.updateChannel };
1885
+ }
1886
+ if (!otaIdentity && (daemonTarget === 'xteink_x3' || daemonTarget === 'xteink_x4')) {
1887
+ throw new Error('XTeink pull OTA requires --manifest, or both --product-id and --update-channel');
1888
+ }
1528
1889
  // Pull-OTA staging: for wake-sync-sleep boards (XTeink X3/X4) that never
1529
1890
  // hold a live WS. The daemon adverts the build on the board's feed pulls;
1530
1891
  // 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);
1892
+ const { statusCode, body } = await postJsonWithTimeout(`http://127.0.0.1:${port}/esp32/ota`, { target: daemonTarget, firmwarePath, stage: true, ...(otaIdentity ?? {}) }, 30_000);
1532
1893
  if (statusCode < 200 || statusCode >= 300 || body.ok === false) {
1533
1894
  throw new Error(String(body.error ?? `HTTP ${statusCode}`));
1534
1895
  }