@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.
- package/dist/adapters/openclaw.d.ts +74 -4
- package/dist/adapters/openclaw.d.ts.map +1 -1
- package/dist/adapters/openclaw.js +235 -43
- package/dist/adapters/openclaw.js.map +1 -1
- package/dist/apme/adapters/openclaw-hook.d.ts +38 -10
- package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
- package/dist/apme/adapters/openclaw-hook.js +181 -37
- package/dist/apme/adapters/openclaw-hook.js.map +1 -1
- package/dist/apme/collector.d.ts +37 -1
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +277 -35
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/pareto.d.ts +2 -1
- package/dist/apme/pareto.d.ts.map +1 -1
- package/dist/apme/pareto.js +15 -4
- package/dist/apme/pareto.js.map +1 -1
- package/dist/apme/recommend.d.ts +24 -1
- package/dist/apme/recommend.d.ts.map +1 -1
- package/dist/apme/recommend.js +43 -7
- package/dist/apme/recommend.js.map +1 -1
- package/dist/apme/store.d.ts +6 -0
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +244 -72
- package/dist/apme/store.js.map +1 -1
- package/dist/bridge-core.d.ts +10 -1
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +20 -5
- package/dist/bridge-core.js.map +1 -1
- package/dist/card-feed.d.ts +46 -1
- package/dist/card-feed.d.ts.map +1 -1
- package/dist/card-feed.js +166 -6
- package/dist/card-feed.js.map +1 -1
- package/dist/cli.d.ts +45 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +388 -27
- package/dist/cli.js.map +1 -1
- package/dist/daemon-build-identity.d.ts +124 -0
- package/dist/daemon-build-identity.d.ts.map +1 -0
- package/dist/daemon-build-identity.js +310 -0
- package/dist/daemon-build-identity.js.map +1 -0
- package/dist/daemon-server.d.ts +34 -6
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +613 -108
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon-takeover.d.ts +11 -0
- package/dist/daemon-takeover.d.ts.map +1 -1
- package/dist/daemon-takeover.js +62 -17
- package/dist/daemon-takeover.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/daemon.js.map +1 -1
- package/dist/esp32-serial.d.ts +14 -0
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +19 -1
- package/dist/esp32-serial.js.map +1 -1
- package/dist/glance-frame.d.ts.map +1 -1
- package/dist/glance-frame.js +3 -1
- package/dist/glance-frame.js.map +1 -1
- package/dist/hook-codex-sessions.d.ts +3 -1
- package/dist/hook-codex-sessions.d.ts.map +1 -1
- package/dist/hook-codex-sessions.js +27 -7
- package/dist/hook-codex-sessions.js.map +1 -1
- package/dist/legacy-rearm-ledger.d.ts +56 -0
- package/dist/legacy-rearm-ledger.d.ts.map +1 -0
- package/dist/legacy-rearm-ledger.js +66 -0
- package/dist/legacy-rearm-ledger.js.map +1 -0
- package/dist/mdns.d.ts +33 -0
- package/dist/mdns.d.ts.map +1 -1
- package/dist/mdns.js +91 -2
- package/dist/mdns.js.map +1 -1
- package/dist/openclaw-session.d.ts.map +1 -1
- package/dist/openclaw-session.js +13 -0
- package/dist/openclaw-session.js.map +1 -1
- package/dist/openclaw-timeline-feed.d.ts +84 -0
- package/dist/openclaw-timeline-feed.d.ts.map +1 -0
- package/dist/openclaw-timeline-feed.js +162 -0
- package/dist/openclaw-timeline-feed.js.map +1 -0
- package/dist/openclaw-transcript-timeline.d.ts +103 -0
- package/dist/openclaw-transcript-timeline.d.ts.map +1 -0
- package/dist/openclaw-transcript-timeline.js +328 -0
- package/dist/openclaw-transcript-timeline.js.map +1 -0
- package/dist/passive-observer.d.ts +21 -1
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +37 -3
- package/dist/passive-observer.js.map +1 -1
- package/dist/relayed-usage.d.ts +68 -0
- package/dist/relayed-usage.d.ts.map +1 -0
- package/dist/relayed-usage.js +80 -0
- package/dist/relayed-usage.js.map +1 -0
- package/dist/session-aggregator.d.ts +3 -0
- package/dist/session-aggregator.d.ts.map +1 -1
- package/dist/session-aggregator.js.map +1 -1
- package/dist/session-registry.d.ts +1 -0
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/surface-protocol.d.ts +84 -0
- package/dist/surface-protocol.d.ts.map +1 -0
- package/dist/surface-protocol.js +235 -0
- package/dist/surface-protocol.js.map +1 -0
- package/dist/timeline-store.d.ts +1 -1
- package/dist/timeline-store.d.ts.map +1 -1
- package/dist/timeline-store.js +16 -1
- package/dist/timeline-store.js.map +1 -1
- package/dist/tui/renderer.d.ts.map +1 -1
- package/dist/tui/renderer.js +35 -0
- package/dist/tui/renderer.js.map +1 -1
- package/dist/weather-config.d.ts +17 -0
- package/dist/weather-config.d.ts.map +1 -0
- package/dist/weather-config.js +54 -0
- package/dist/weather-config.js.map +1 -0
- package/dist/weather.d.ts +44 -4
- package/dist/weather.d.ts.map +1 -1
- package/dist/weather.js +223 -15
- package/dist/weather.js.map +1 -1
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +55 -5
- package/dist/ws-server.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +51 -5
- 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
|
-
.
|
|
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({
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
876
|
-
|
|
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
|
-
|
|
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
|
}
|