@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.
- package/README.md +14 -6
- package/assets/fonts/NOTICE-PocketSansWorld.md +10 -0
- package/assets/fonts/OFL-PocketSansWorld.txt +93 -0
- package/assets/fonts/PocketSansWorld_12.cpfont +0 -0
- package/assets/fonts/pocket-sans-world.manifest.json +49 -0
- package/assets/learning/LICENSE-CC-BY-SA-4.0.txt +10 -0
- package/assets/learning/NOTICE.md +33 -0
- package/assets/learning/jp-n3-ko.manifest.json +27 -0
- package/assets/learning/jp-n3-ko.pdl +0 -0
- 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/agent-cli-diagnostics.d.ts +20 -0
- package/dist/agent-cli-diagnostics.d.ts.map +1 -0
- package/dist/agent-cli-diagnostics.js +100 -0
- package/dist/agent-cli-diagnostics.js.map +1 -0
- 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 +55 -2
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +324 -37
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/dashboard-html.d.ts.map +1 -1
- package/dist/apme/dashboard-html.js +275 -35
- package/dist/apme/dashboard-html.js.map +1 -1
- package/dist/apme/http.d.ts.map +1 -1
- package/dist/apme/http.js +33 -5
- package/dist/apme/http.js.map +1 -1
- package/dist/apme/index.d.ts.map +1 -1
- package/dist/apme/index.js +6 -2
- package/dist/apme/index.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 +47 -1
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +397 -99
- package/dist/apme/store.js.map +1 -1
- package/dist/bridge-core.d.ts +25 -1
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +50 -9
- 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 +56 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +442 -34
- package/dist/cli.js.map +1 -1
- package/dist/codex-rate-limits-live.d.ts +180 -10
- package/dist/codex-rate-limits-live.d.ts.map +1 -1
- package/dist/codex-rate-limits-live.js +475 -22
- package/dist/codex-rate-limits-live.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 +876 -109
- 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-ws-client.d.ts +1 -0
- package/dist/daemon-ws-client.d.ts.map +1 -1
- package/dist/daemon-ws-client.js +25 -16
- package/dist/daemon-ws-client.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/daemon.js.map +1 -1
- package/dist/esp32-serial.d.ts +18 -0
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +23 -1
- package/dist/esp32-serial.js.map +1 -1
- package/dist/font-pack.d.ts +15 -0
- package/dist/font-pack.d.ts.map +1 -0
- package/dist/font-pack.js +75 -0
- package/dist/font-pack.js.map +1 -0
- package/dist/glance-frame.d.ts.map +1 -1
- package/dist/glance-frame.js +5 -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/http-auth-gate.d.ts.map +1 -1
- package/dist/http-auth-gate.js +9 -1
- package/dist/http-auth-gate.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/learning-pack.d.ts +24 -0
- package/dist/learning-pack.d.ts.map +1 -0
- package/dist/learning-pack.js +125 -0
- package/dist/learning-pack.js.map +1 -0
- 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 +92 -3
- 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/pairing-knocks.d.ts +80 -0
- package/dist/pairing-knocks.d.ts.map +1 -0
- package/dist/pairing-knocks.js +312 -0
- package/dist/pairing-knocks.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/pixoo/micro-glyphs.d.ts +4 -0
- package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
- package/dist/pixoo/micro-glyphs.js +29 -0
- package/dist/pixoo/micro-glyphs.js.map +1 -1
- package/dist/pixoo/pixoo-font.d.ts.map +1 -1
- package/dist/pixoo/pixoo-font.js +3 -1
- package/dist/pixoo/pixoo-font.js.map +1 -1
- package/dist/pixoo/pixoo-renderer.d.ts +1 -1
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +17 -2
- package/dist/pixoo/pixoo-renderer.js.map +1 -1
- package/dist/pty-manager.d.ts +9 -0
- package/dist/pty-manager.d.ts.map +1 -1
- package/dist/pty-manager.js +63 -4
- package/dist/pty-manager.js.map +1 -1
- package/dist/relayed-usage.d.ts +98 -0
- package/dist/relayed-usage.d.ts.map +1 -0
- package/dist/relayed-usage.js +103 -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 +237 -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/dashboard.d.ts.map +1 -1
- package/dist/tui/dashboard.js +8 -1
- package/dist/tui/dashboard.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 +18 -0
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +95 -7
- package/dist/ws-server.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +51 -5
- package/package.json +4 -3
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
.
|
|
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({
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
774
|
-
|
|
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
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`,
|
|
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
|
|
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`);
|