@agentdeck/bridge 1.0.20 → 1.0.22

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 (211) hide show
  1. package/dist/adapters/openclaw.d.ts +55 -1
  2. package/dist/adapters/openclaw.d.ts.map +1 -1
  3. package/dist/adapters/openclaw.js +278 -45
  4. package/dist/adapters/openclaw.js.map +1 -1
  5. package/dist/adb-reverse.d.ts +26 -3
  6. package/dist/adb-reverse.d.ts.map +1 -1
  7. package/dist/adb-reverse.js +26 -5
  8. package/dist/adb-reverse.js.map +1 -1
  9. package/dist/apme/adapters/openclaw-hook.d.ts +45 -1
  10. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
  11. package/dist/apme/adapters/openclaw-hook.js +84 -4
  12. package/dist/apme/adapters/openclaw-hook.js.map +1 -1
  13. package/dist/apme/claude-transcript-reader.d.ts +54 -0
  14. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  15. package/dist/apme/claude-transcript-reader.js +77 -0
  16. package/dist/apme/claude-transcript-reader.js.map +1 -1
  17. package/dist/apme/collector.d.ts +63 -5
  18. package/dist/apme/collector.d.ts.map +1 -1
  19. package/dist/apme/collector.js +147 -10
  20. package/dist/apme/collector.js.map +1 -1
  21. package/dist/apme/store.d.ts +23 -1
  22. package/dist/apme/store.d.ts.map +1 -1
  23. package/dist/apme/store.js +81 -2
  24. package/dist/apme/store.js.map +1 -1
  25. package/dist/apme/types.d.ts +1 -1
  26. package/dist/apme/types.d.ts.map +1 -1
  27. package/dist/apme/types.js.map +1 -1
  28. package/dist/auth.d.ts +37 -0
  29. package/dist/auth.d.ts.map +1 -1
  30. package/dist/auth.js +23 -0
  31. package/dist/auth.js.map +1 -1
  32. package/dist/ble-sync-spawn.d.ts +27 -0
  33. package/dist/ble-sync-spawn.d.ts.map +1 -1
  34. package/dist/ble-sync-spawn.js +32 -0
  35. package/dist/ble-sync-spawn.js.map +1 -1
  36. package/dist/bridge-core.d.ts +21 -4
  37. package/dist/bridge-core.d.ts.map +1 -1
  38. package/dist/bridge-core.js +52 -23
  39. package/dist/bridge-core.js.map +1 -1
  40. package/dist/claude-interrupt-marker.d.ts +31 -0
  41. package/dist/claude-interrupt-marker.d.ts.map +1 -0
  42. package/dist/claude-interrupt-marker.js +58 -0
  43. package/dist/claude-interrupt-marker.js.map +1 -0
  44. package/dist/claude-turn-watchdog.d.ts +23 -0
  45. package/dist/claude-turn-watchdog.d.ts.map +1 -1
  46. package/dist/claude-turn-watchdog.js +30 -4
  47. package/dist/claude-turn-watchdog.js.map +1 -1
  48. package/dist/cli.d.ts +6 -0
  49. package/dist/cli.d.ts.map +1 -1
  50. package/dist/cli.js +411 -115
  51. package/dist/cli.js.map +1 -1
  52. package/dist/codex-rate-limits-live.d.ts +58 -9
  53. package/dist/codex-rate-limits-live.d.ts.map +1 -1
  54. package/dist/codex-rate-limits-live.js +64 -15
  55. package/dist/codex-rate-limits-live.js.map +1 -1
  56. package/dist/codex-rate-limits.d.ts +2 -2
  57. package/dist/codex-rate-limits.d.ts.map +1 -1
  58. package/dist/codex-rate-limits.js +34 -16
  59. package/dist/codex-rate-limits.js.map +1 -1
  60. package/dist/daemon-port.d.ts +90 -0
  61. package/dist/daemon-port.d.ts.map +1 -0
  62. package/dist/daemon-port.js +119 -0
  63. package/dist/daemon-port.js.map +1 -0
  64. package/dist/daemon-server.d.ts +60 -3
  65. package/dist/daemon-server.d.ts.map +1 -1
  66. package/dist/daemon-server.js +362 -109
  67. package/dist/daemon-server.js.map +1 -1
  68. package/dist/daemon-settings.d.ts +46 -0
  69. package/dist/daemon-settings.d.ts.map +1 -0
  70. package/dist/daemon-settings.js +85 -0
  71. package/dist/daemon-settings.js.map +1 -0
  72. package/dist/daemon-takeover.d.ts +75 -0
  73. package/dist/daemon-takeover.d.ts.map +1 -0
  74. package/dist/daemon-takeover.js +137 -0
  75. package/dist/daemon-takeover.js.map +1 -0
  76. package/dist/daemon.js +1 -1
  77. package/dist/hook-server.d.ts.map +1 -1
  78. package/dist/hook-server.js +17 -0
  79. package/dist/hook-server.js.map +1 -1
  80. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -1
  81. package/dist/idotmatrix/idotmatrix-daemon-sync.js +13 -1
  82. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -1
  83. package/dist/index.d.ts +1 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +34 -12
  86. package/dist/index.js.map +1 -1
  87. package/dist/kiro-diagnostics.d.ts +90 -0
  88. package/dist/kiro-diagnostics.d.ts.map +1 -0
  89. package/dist/kiro-diagnostics.js +278 -0
  90. package/dist/kiro-diagnostics.js.map +1 -0
  91. package/dist/kiro-session.d.ts +113 -0
  92. package/dist/kiro-session.d.ts.map +1 -0
  93. package/dist/kiro-session.js +688 -0
  94. package/dist/kiro-session.js.map +1 -0
  95. package/dist/kiro-timeline-feed.d.ts +45 -0
  96. package/dist/kiro-timeline-feed.d.ts.map +1 -0
  97. package/dist/kiro-timeline-feed.js +116 -0
  98. package/dist/kiro-timeline-feed.js.map +1 -0
  99. package/dist/kiro-transcript-timeline.d.ts +57 -0
  100. package/dist/kiro-transcript-timeline.d.ts.map +1 -0
  101. package/dist/kiro-transcript-timeline.js +183 -0
  102. package/dist/kiro-transcript-timeline.js.map +1 -0
  103. package/dist/linux-service.d.ts +2 -1
  104. package/dist/linux-service.d.ts.map +1 -1
  105. package/dist/linux-service.js +8 -3
  106. package/dist/linux-service.js.map +1 -1
  107. package/dist/mdns-discover.d.ts +27 -0
  108. package/dist/mdns-discover.d.ts.map +1 -1
  109. package/dist/mdns-discover.js +60 -7
  110. package/dist/mdns-discover.js.map +1 -1
  111. package/dist/mdns.d.ts.map +1 -1
  112. package/dist/mdns.js +16 -3
  113. package/dist/mdns.js.map +1 -1
  114. package/dist/modules/adb-module.d.ts.map +1 -1
  115. package/dist/modules/adb-module.js +5 -2
  116. package/dist/modules/adb-module.js.map +1 -1
  117. package/dist/modules/index.d.ts +2 -1
  118. package/dist/modules/index.d.ts.map +1 -1
  119. package/dist/modules/index.js +1 -0
  120. package/dist/modules/index.js.map +1 -1
  121. package/dist/modules/types.d.ts +34 -9
  122. package/dist/modules/types.d.ts.map +1 -1
  123. package/dist/modules/types.js +34 -1
  124. package/dist/modules/types.js.map +1 -1
  125. package/dist/network-posture.d.ts +67 -0
  126. package/dist/network-posture.d.ts.map +1 -0
  127. package/dist/network-posture.js +100 -0
  128. package/dist/network-posture.js.map +1 -0
  129. package/dist/observed-steering.d.ts +14 -0
  130. package/dist/observed-steering.d.ts.map +1 -1
  131. package/dist/observed-steering.js +18 -0
  132. package/dist/observed-steering.js.map +1 -1
  133. package/dist/observed-turn-watchdogs.d.ts +84 -0
  134. package/dist/observed-turn-watchdogs.d.ts.map +1 -0
  135. package/dist/observed-turn-watchdogs.js +134 -0
  136. package/dist/observed-turn-watchdogs.js.map +1 -0
  137. package/dist/openclaw-session.d.ts +10 -0
  138. package/dist/openclaw-session.d.ts.map +1 -1
  139. package/dist/openclaw-session.js +12 -0
  140. package/dist/openclaw-session.js.map +1 -1
  141. package/dist/passive-observer.d.ts +19 -0
  142. package/dist/passive-observer.d.ts.map +1 -1
  143. package/dist/passive-observer.js +208 -24
  144. package/dist/passive-observer.js.map +1 -1
  145. package/dist/pixoo/micro-glyphs.d.ts +1 -1
  146. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  147. package/dist/pixoo/micro-glyphs.js +2 -0
  148. package/dist/pixoo/micro-glyphs.js.map +1 -1
  149. package/dist/pixoo/official-dot-glyphs.generated.d.ts +1 -1
  150. package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -1
  151. package/dist/pixoo/official-dot-glyphs.generated.js +47 -0
  152. package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -1
  153. package/dist/pixoo/pixoo-discover.d.ts +8 -1
  154. package/dist/pixoo/pixoo-discover.d.ts.map +1 -1
  155. package/dist/pixoo/pixoo-discover.js +15 -8
  156. package/dist/pixoo/pixoo-discover.js.map +1 -1
  157. package/dist/pixoo/pixoo-renderer.d.ts +4 -2
  158. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  159. package/dist/pixoo/pixoo-renderer.js +105 -36
  160. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  161. package/dist/pixoo/pixoo-settings.d.ts +19 -2
  162. package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
  163. package/dist/pixoo/pixoo-settings.js +22 -4
  164. package/dist/pixoo/pixoo-settings.js.map +1 -1
  165. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
  166. package/dist/pixoo/pixoo-sprites.js +2 -0
  167. package/dist/pixoo/pixoo-sprites.js.map +1 -1
  168. package/dist/python-ble-runtime.d.ts +2 -0
  169. package/dist/python-ble-runtime.d.ts.map +1 -1
  170. package/dist/python-ble-runtime.js +101 -7
  171. package/dist/python-ble-runtime.js.map +1 -1
  172. package/dist/session-aggregator.d.ts +9 -1
  173. package/dist/session-aggregator.d.ts.map +1 -1
  174. package/dist/session-aggregator.js +1 -1
  175. package/dist/session-aggregator.js.map +1 -1
  176. package/dist/session-registry.d.ts +71 -3
  177. package/dist/session-registry.d.ts.map +1 -1
  178. package/dist/session-registry.js +126 -19
  179. package/dist/session-registry.js.map +1 -1
  180. package/dist/subagent-timeline.d.ts +38 -7
  181. package/dist/subagent-timeline.d.ts.map +1 -1
  182. package/dist/subagent-timeline.js +154 -23
  183. package/dist/subagent-timeline.js.map +1 -1
  184. package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -1
  185. package/dist/timebox/timebox-daemon-sync.js +11 -1
  186. package/dist/timebox/timebox-daemon-sync.js.map +1 -1
  187. package/dist/timeline-store.d.ts.map +1 -1
  188. package/dist/timeline-store.js +26 -1
  189. package/dist/timeline-store.js.map +1 -1
  190. package/dist/tui/renderer.d.ts.map +1 -1
  191. package/dist/tui/renderer.js +13 -1
  192. package/dist/tui/renderer.js.map +1 -1
  193. package/dist/tui/terrarium.d.ts.map +1 -1
  194. package/dist/tui/terrarium.js +10 -5
  195. package/dist/tui/terrarium.js.map +1 -1
  196. package/dist/usage-api.d.ts +28 -1
  197. package/dist/usage-api.d.ts.map +1 -1
  198. package/dist/usage-api.js +74 -33
  199. package/dist/usage-api.js.map +1 -1
  200. package/dist/usage-event.d.ts.map +1 -1
  201. package/dist/usage-event.js +2 -17
  202. package/dist/usage-event.js.map +1 -1
  203. package/dist/utils/redact-secrets.d.ts +3 -0
  204. package/dist/utils/redact-secrets.d.ts.map +1 -0
  205. package/dist/utils/redact-secrets.js +29 -0
  206. package/dist/utils/redact-secrets.js.map +1 -0
  207. package/dist/windows-service.d.ts +2 -1
  208. package/dist/windows-service.d.ts.map +1 -1
  209. package/dist/windows-service.js +6 -3
  210. package/dist/windows-service.js.map +1 -1
  211. package/package.json +3 -3
package/dist/cli.js CHANGED
@@ -8,10 +8,13 @@ import { fileURLToPath } from 'url';
8
8
  import { createRequire } from 'module';
9
9
  import { request } from 'http';
10
10
  import { BRIDGE_WS_PORT } from './types.js';
11
- import { SESSION_WEIGHT_MIN, SESSION_WEIGHT_MAX } from '@agentdeck/shared';
11
+ import { SESSION_WEIGHT_MIN, SESSION_WEIGHT_MAX, stopDeliveryLoss } from '@agentdeck/shared';
12
12
  import { ensureBleRuntime, getBleRuntimeStatus } from './python-ble-runtime.js';
13
13
  import { TASK_NAME, installWindowsTask, taskExists, runWindowsTask, endWindowsTask, deleteWindowsTask, } from './windows-service.js';
14
- import { deriveRemoteAttachOpts } from './session-registry.js';
14
+ import { deriveRemoteAttachOpts, daemonPortWindow, isDefaultDaemonPortWindow, DEFAULT_DAEMON_PORT_WINDOW, } from './session-registry.js';
15
+ // From modules/types.js, not modules/index.js: the latter pulls the whole
16
+ // device stack (serial, Pixoo, BLE) into `agentdeck --help`.
17
+ import { allModulesOff } from './modules/types.js';
15
18
  import { SERVICE_NAME, hasSystemctl, unitExists, installUnit, startUnit, disableUnit, getUnitPath, getDataDir, } from './linux-service.js';
16
19
  const require = createRequire(import.meta.url);
17
20
  const packageJson = require('../package.json');
@@ -41,6 +44,22 @@ async function refreshClaudeHooks() {
41
44
  log(`Claude hooks unavailable: ${String(err)}`);
42
45
  }
43
46
  }
47
+ /** Kiro v3 global hooks preserve native `kiro-cli --v3` launch behavior. */
48
+ async function refreshKiroHooks() {
49
+ try {
50
+ const { installKiroHooksIfNeeded } = await import('@agentdeck/hooks');
51
+ const result = installKiroHooksIfNeeded();
52
+ if (result.installed) {
53
+ log(`Kiro v3 lifecycle hooks installed/refreshed in ${result.path}`);
54
+ }
55
+ else if (result.reason) {
56
+ log(`Kiro hooks skipped: ${result.reason}`);
57
+ }
58
+ }
59
+ catch (err) {
60
+ log(`Kiro hooks unavailable: ${String(err)}`);
61
+ }
62
+ }
44
63
  /**
45
64
  * Parse a `--weight <n>` value into an integer sort override. Rejects
46
65
  * non-integers so a typo (`--weight foo`) fails loudly instead of silently
@@ -65,6 +84,19 @@ function formatBytes(value) {
65
84
  return `${Math.round(bytes / 1024)}KB`;
66
85
  return `${bytes}B`;
67
86
  }
87
+ /** Parse a `--since`-style lookback (`90m`, `6h`, `3d`, `2w`) into milliseconds.
88
+ * Returns null for anything unrecognized so the caller can reject it loudly —
89
+ * silently defaulting a typo'd window would report a rate over the wrong span. */
90
+ function parseLookbackWindow(value) {
91
+ const m = /^(\d+(?:\.\d+)?)\s*(m|h|d|w)$/i.exec(String(value ?? '').trim());
92
+ if (!m)
93
+ return null;
94
+ const n = Number(m[1]);
95
+ if (!Number.isFinite(n) || n <= 0)
96
+ return null;
97
+ const unit = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[m[2].toLowerCase()];
98
+ return n * unit;
99
+ }
68
100
  function postJsonWithTimeout(urlString, body, timeoutMs) {
69
101
  return new Promise((resolve, reject) => {
70
102
  const payload = JSON.stringify(body);
@@ -113,9 +145,54 @@ function getAgentdeckBin() {
113
145
  return join(distDir, 'cli.js');
114
146
  }
115
147
  }
116
- function buildPlist() {
148
+ /**
149
+ * Extra `daemon start` argv the autostart unit must carry.
150
+ *
151
+ * A posture that only applies when typed by hand is not a posture: an
152
+ * enterprise install IS an autostart install, so the flags have to live in the
153
+ * LaunchAgent / Scheduled Task / systemd unit. Baking them into argv (rather
154
+ * than an environment variable) is the one mechanism all three writers already
155
+ * have — Task Scheduler has no environment element at all.
156
+ */
157
+ /**
158
+ * Warn before handing out a LAN credential that cannot be used.
159
+ *
160
+ * `agentdeck qr` and `agentdeck pair` both hand a phone or a board a
161
+ * `ws://<lan-ip>:<port>` endpoint. Against a loopback-only daemon that endpoint
162
+ * refuses every connection, and nothing on either side says why — the user sees
163
+ * a QR that "doesn't work". Ask the daemon what posture it is in (that is what
164
+ * `posture` on `/health` is for) and say so, rather than printing a dead URL.
165
+ * Silent on any probe failure: this is an explanation, never a gate.
166
+ */
167
+ async function warnIfLoopbackPosture(port) {
168
+ try {
169
+ const { probeDaemonHealth } = await import('./session-registry.js');
170
+ const health = await probeDaemonHealth(port);
171
+ if (health?.posture?.loopbackOnly !== true)
172
+ return;
173
+ log('Note: this daemon is running loopback-only — it is bound to 127.0.0.1 and refuses LAN connections.');
174
+ log(' A phone, tablet or WiFi board cannot reach the address below, whatever credential it holds.');
175
+ log(' Restart without --loopback (and unset AGENTDECK_LOOPBACK_ONLY) to pair a LAN device.');
176
+ }
177
+ catch {
178
+ /* daemon not answering — nothing to say about its posture */
179
+ }
180
+ }
181
+ export function daemonPostureArgs(opts) {
182
+ const args = [];
183
+ // --enterprise is the admin-facing spelling of the loopback posture: bind
184
+ // 127.0.0.1 and put nothing on the wire. It deliberately does NOT imply
185
+ // --local — some sites want hardware on a lab subnet with discovery off.
186
+ if (opts.local)
187
+ args.push('--local');
188
+ if (opts.loopback || opts.enterprise)
189
+ args.push('--loopback');
190
+ return args;
191
+ }
192
+ export function buildPlist(extraArgs = []) {
117
193
  const bin = getAgentdeckBin();
118
194
  const logDir = join(homedir(), '.agentdeck');
195
+ const extra = extraArgs.map((a) => `\n <string>${a}</string>`).join('');
119
196
  return `<?xml version="1.0" encoding="UTF-8"?>
120
197
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
121
198
  <plist version="1.0">
@@ -127,7 +204,7 @@ function buildPlist() {
127
204
  <string>${bin}</string>
128
205
  <string>daemon</string>
129
206
  <string>start</string>
130
- <string>--foreground</string>
207
+ <string>--foreground</string>${extra}
131
208
  </array>
132
209
  <key>RunAtLoad</key>
133
210
  <true/>
@@ -150,9 +227,18 @@ function buildPlist() {
150
227
  }
151
228
  // ===== Helpers =====
152
229
  async function stopDaemon(port) {
153
- const { readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
230
+ const { readDaemonInfo, findDaemonPort, probeDaemonHealth } = await import('./session-registry.js');
231
+ const { isForeignDaemon } = await import('./daemon-takeover.js');
154
232
  const info = readDaemonInfo();
155
233
  const targetPort = info?.httpPort ?? info?.port ?? findDaemonPort() ?? port;
234
+ // The registry resolves to this user's own daemon, but the `-p` fallback
235
+ // resolves to whatever is on that port — which on a shared host is somebody
236
+ // else's daemon, and `/shutdown` is trusted purely for being local.
237
+ if (isForeignDaemon(await probeDaemonHealth(targetPort))) {
238
+ log(`Port ${targetPort} is held by another user's daemon — refusing to stop it.`);
239
+ log(`You have no daemon of your own running.`);
240
+ return;
241
+ }
156
242
  try {
157
243
  await fetch(`http://127.0.0.1:${targetPort}/shutdown`, {
158
244
  method: 'POST',
@@ -396,12 +482,15 @@ program
396
482
  daemonHost: opts.daemonHost,
397
483
  daemonToken: opts.daemonToken,
398
484
  weight: opts.weight,
399
- modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : {
400
- mdns: false, // daemon-only session bridges never advertise mDNS
485
+ modules: opts.local ? allModulesOff() : {
486
+ // Off by default, ADB back on: a session bridge is an internal hook/PTY
487
+ // process. Only the daemon hub advertises itself or drives devices —
488
+ // and spelling that as "all off, then add back" is what stops the next
489
+ // module from arriving as an implicit 'auto' here (idotmatrix did, and
490
+ // every session spawned a second Python BLE client alongside the
491
+ // daemon's).
492
+ ...allModulesOff(),
401
493
  adb: opts.adb !== false ? 'auto' : false,
402
- serial: false, // daemon-only — session bridges never talk to ESP32
403
- pixoo: false, // daemon-only — session bridges never talk to Pixoo
404
- timebox: false, // daemon-only — session bridges never talk to Timebox
405
494
  },
406
495
  });
407
496
  });
@@ -458,12 +547,11 @@ program
458
547
  daemonToken: opts.daemonToken,
459
548
  weight: opts.weight,
460
549
  codexHooksExpected: opts.codexHooks !== false,
461
- modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : {
462
- mdns: false, // daemon-only
550
+ modules: opts.local ? allModulesOff() : {
551
+ // See the claude action: all off, ADB back on. Session bridges are
552
+ // internal hook/PTY processes; only the daemon hub drives devices.
553
+ ...allModulesOff(),
463
554
  adb: opts.adb !== false ? 'auto' : false,
464
- serial: false, // daemon-only
465
- pixoo: false, // daemon-only
466
- timebox: false, // daemon-only
467
555
  },
468
556
  });
469
557
  });
@@ -518,12 +606,10 @@ program
518
606
  daemonHost: opts.daemonHost,
519
607
  daemonToken: opts.daemonToken,
520
608
  weight: opts.weight,
521
- modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : {
522
- mdns: false,
609
+ modules: opts.local ? allModulesOff() : {
610
+ // See the claude action: all off, ADB back on.
611
+ ...allModulesOff(),
523
612
  adb: opts.adb !== false ? 'auto' : false,
524
- serial: false,
525
- pixoo: false,
526
- timebox: false,
527
613
  },
528
614
  });
529
615
  });
@@ -553,7 +639,7 @@ program
553
639
  daemonHost: opts.daemonHost,
554
640
  daemonToken: opts.daemonToken,
555
641
  weight: opts.weight,
556
- modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : undefined,
642
+ modules: opts.local ? allModulesOff() : undefined,
557
643
  });
558
644
  });
559
645
  // The worktree-compare feature ("cockpit") was extracted into its own
@@ -598,10 +684,19 @@ async function openDaemonLogs(logDir) {
598
684
  daemon
599
685
  .command('start')
600
686
  .description('Start monitoring daemon (WS + mDNS + Gateway proxy)')
601
- .option('-p, --port <port>', 'Server port', String(BRIDGE_WS_PORT))
687
+ // No default value on purpose: an absent `-p` must be distinguishable from a
688
+ // typed one. The port then resolves through AGENTDECK_DAEMON_PORT and the
689
+ // persisted `daemonPort` (see `agentdeck daemon port`), and the "was this
690
+ // port asked for?" test below has something real to test. With commander's
691
+ // old default of "9120" that test was always true, so the branch meant to
692
+ // start on a fallback port could never be reached.
693
+ .option('-p, --port <port>', `Server port (default: the persisted daemonPort, else ${BRIDGE_WS_PORT})`)
602
694
  .option('-d, --debug', 'Enable debug logging')
603
695
  .option('-f, --foreground', 'Run in foreground (default: background fork)')
604
696
  .option('--wake-word', 'Enable wake word voice assistant ("오픈클로")')
697
+ .option('--local', 'Disable all device modules (no mDNS, UDP beacon, LAN sweep, BLE, ADB or serial); still binds all interfaces for paired companion apps')
698
+ .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')
699
+ .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')
605
700
  .action(async (opts) => {
606
701
  const { findExistingDaemon, probeDaemonHealth, readDaemonInfo, removeDaemonInfo, removeDaemonSession, requestDaemonStandDown, requestDaemonShutdown, waitForDaemonExit, waitForPortBindable } = await import('./session-registry.js');
607
702
  const { adoptPeerToken } = await import('./auth.js');
@@ -613,79 +708,53 @@ daemon
613
708
  // CLI daemon can take over with the full feature set, then wait for the port
614
709
  // to clear before binding. A real Node daemon on the port still means
615
710
  // "already running".
616
- const targetPort = opts.port ? parseInt(String(opts.port), 10) : BRIDGE_WS_PORT;
711
+ // Set before anything reads the window: the singleton sweep, the port
712
+ // allocator and the daemon itself all resolve it per call, and this action
713
+ // is the first place the flag is known.
714
+ if (opts.portWindow)
715
+ process.env.AGENTDECK_PORT_WINDOW = String(opts.portWindow);
716
+ const portWindow = daemonPortWindow();
717
+ if (!isDefaultDaemonPortWindow(portWindow)) {
718
+ const [lo, hi] = portWindow;
719
+ const [dlo, dhi] = DEFAULT_DAEMON_PORT_WINDOW;
720
+ log(`Port window ${lo}-${hi} (default ${dlo}-${dhi}).`);
721
+ log(`Note: clients discover daemons by scanning ${dlo}-${dhi}, so a daemon outside that range `
722
+ + `is reachable only by explicit host/port. Use this for a throwaway daemon, not the real one.`);
723
+ if (opts.portWindow && String(opts.portWindow).trim() && lo === dlo && hi === dhi) {
724
+ log(`(--port-window ${opts.portWindow} was not parseable — falling back to the default window.)`);
725
+ }
726
+ }
727
+ else if (opts.portWindow) {
728
+ log(`--port-window ${opts.portWindow} was not parseable — using the default window.`);
729
+ }
730
+ // The preferred port — what this daemon intends to serve. Everything below
731
+ // (the stand-down negotiation, the singleton guard, the bind) aims at it.
732
+ const { resolveDaemonPort, describeDaemonPortSource, DAEMON_PORT_MIN, DAEMON_PORT_MAX } = await import('./daemon-port.js');
733
+ const preferredPort = resolveDaemonPort({ flag: opts.port });
734
+ if (opts.port && preferredPort.source !== 'flag') {
735
+ log(`--port ${opts.port} is not a valid port (${DAEMON_PORT_MIN}-${DAEMON_PORT_MAX}) — using ${preferredPort.port} instead.`);
736
+ }
737
+ if (preferredPort.source === 'settings' || preferredPort.source === 'env') {
738
+ log(`Preferred port ${preferredPort.port} (${describeDaemonPortSource(preferredPort.source)}).`);
739
+ }
740
+ const targetPort = preferredPort.port;
741
+ // Adopting a credential from the incumbent and asking it to stand down are
742
+ // both same-user-only operations, and the whole decision — including who
743
+ // owns that process — lives in `negotiateIncumbentDaemon` so it can be
744
+ // driven in tests against a foreign-owned incumbent.
745
+ const { negotiateIncumbentDaemon } = await import('./daemon-takeover.js');
617
746
  const incumbent = await probeDaemonHealth(targetPort);
618
- if (incumbent?.mode === 'daemon') {
619
- // Take over the fleet's credential along with the port. The app's daemon
620
- // keeps its token inside its sandbox container, unreadable from here, so
621
- // this loopback answer is the only place we can learn what every paired
622
- // board is currently holding. Adopting it BEFORE the stand-down means the
623
- // handover costs no device its pairing.
624
- if (adoptPeerToken(incumbent.pairingToken)) {
625
- log(`Adopted the incumbent daemon's pairing token so paired devices survive the handover.`);
626
- }
627
- if (incumbent.isSwift) {
628
- log(`AgentDeck app's in-process daemon holds port ${targetPort} — requesting stand-down to take over with the full CLI feature set…`);
629
- // Prefer /stand-down (clean demote: the app stays running as a client).
630
- // Fall back to /shutdown for older app builds that predate the endpoint.
631
- let acked = await requestDaemonStandDown(targetPort);
632
- if (!acked) {
633
- await requestDaemonShutdown(targetPort);
634
- acked = true; // shutdown is best-effort (no ack body); rely on the exit wait
635
- }
636
- // Two conditions, not one. `waitForDaemonExit` proves the app stopped
637
- // answering; `waitForPortBindable` proves the socket is actually gone.
638
- // The app releases the port with NWListener.cancel(), which returns
639
- // before the teardown completes — binding on the first signal alone is
640
- // what silently demoted this daemon to the 9121 fallback and left the
641
- // canonical port ownerless.
642
- if (acked
643
- && await waitForDaemonExit(targetPort, 12000)
644
- // 30s, and that is not padding. Cancelling the app's NWListener does
645
- // not free the port: macOS keeps a NECP reservation on it for ~14s
646
- // afterwards, during which `lsof` shows no sockets at all and bind()
647
- // still returns EADDRINUSE (measured 2026-08-06 — bindable at ~17s).
648
- // Anything shorter gives up while the kernel, not the app, is holding
649
- // the port. The app's takeover-yield window is longer still (45s), so
650
- // it stays off the port until well after this wait has claimed it.
651
- && await waitForPortBindable(targetPort, 30000)) {
652
- log(`App daemon yielded port ${targetPort}. Starting CLI daemon…`);
653
- // fall through — port is clear, proceed to bind below
654
- }
655
- else {
656
- // The app yielded but the PORT did not come back, and that is often
657
- // not the app's doing: a WiFi device that has gone to sleep with bytes
658
- // still queued to it leaves its socket in LAST_ACK on this port, and
659
- // TCP holds that until it exhausts retransmits — minutes, sometimes.
660
- // Network.framework exposes no SO_LINGER, so the app cannot RST out of
661
- // it (measured 2026-08-06: two sleeping ESP32 boards, 11901 and 4
662
- // bytes queued, were the only things left on 9120).
663
- //
664
- // Carry on rather than exiting. The daemon binds a fallback port and
665
- // writes it to daemon.json, which is what every client resolves
666
- // through and what the app itself re-reads when its yield window ends
667
- // — so the deck keeps working. What must NOT happen is this being
668
- // silent, which is how the canonical port ended up owned by nobody
669
- // with the CLI reporting success.
670
- log(`The AgentDeck app yielded port ${targetPort} but the port has not been released `
671
- + `(usually a sleeping WiFi device holding a half-closed socket on it).`);
672
- if (opts.port) {
673
- // The port was ASKED for, not defaulted to. Quietly binding a
674
- // different one would answer a question the user did not ask;
675
- // an explicit choice fails loudly instead.
676
- log(`Quit the AgentDeck app and retry, or choose another port.`);
677
- process.exit(1);
678
- }
679
- log(`Starting on a fallback port instead — clients resolve it from daemon.json. `
680
- + `For the canonical port, quit the AgentDeck app and retry, or pass -p.`);
681
- // fall through to normal port selection
682
- }
683
- }
684
- else {
685
- log(`Daemon already running on port ${targetPort}. Use 'agentdeck daemon stop' first.`);
686
- process.exit(0);
687
- }
688
- }
747
+ const outcome = await negotiateIncumbentDaemon({ port: targetPort, incumbent, portWasExplicit: !!opts.port }, {
748
+ adoptToken: adoptPeerToken,
749
+ standDown: requestDaemonStandDown,
750
+ shutdown: requestDaemonShutdown,
751
+ waitForExit: waitForDaemonExit,
752
+ waitForBindable: waitForPortBindable,
753
+ });
754
+ if (outcome === 'already-running')
755
+ process.exit(0);
756
+ if (outcome === 'refuse')
757
+ process.exit(1);
689
758
  const daemonInfo = readDaemonInfo();
690
759
  if (daemonInfo) {
691
760
  const probePort = daemonInfo.httpPort ?? daemonInfo.port;
@@ -712,12 +781,26 @@ daemon
712
781
  const logDir = join(homedir(), '.agentdeck');
713
782
  const scriptPath = fileURLToPath(import.meta.url);
714
783
  const args = [scriptPath, 'daemon', 'start', '--foreground'];
715
- if (opts.port !== String(BRIDGE_WS_PORT))
716
- args.push('-p', opts.port);
784
+ // Forward `-p` only when the user typed it. For every other source the
785
+ // child re-resolves from the same inherited environment and the same
786
+ // settings.json, so it lands on the same port — and it keeps its own
787
+ // provenance, which a forwarded `-p` would flatten into "asked for".
788
+ if (preferredPort.source === 'flag')
789
+ args.push('-p', String(preferredPort.port));
717
790
  if (opts.debug)
718
791
  args.push('-d');
719
792
  if (opts.wakeWord)
720
793
  args.push('--wake-word');
794
+ // The posture flags must survive the fork — the forked process IS the
795
+ // daemon, and a posture that only applies to the parent is no posture.
796
+ if (opts.local)
797
+ args.push('--local');
798
+ if (opts.loopback)
799
+ args.push('--loopback');
800
+ // Same reason as the posture flags: the forked process IS the daemon, and
801
+ // a window that only applies to the parent moves nothing.
802
+ if (opts.portWindow)
803
+ args.push('--port-window', String(opts.portWindow));
721
804
  const [out, err] = await openDaemonLogs(logDir);
722
805
  const child = spawn(process.execPath, args, {
723
806
  detached: true,
@@ -730,9 +813,12 @@ daemon
730
813
  }
731
814
  const { startDaemon } = await import('./daemon-server.js');
732
815
  await startDaemon({
733
- port: parseInt(opts.port, 10),
816
+ port: preferredPort.port,
817
+ portSource: preferredPort.source,
734
818
  debug: opts.debug,
735
819
  wakeWord: !!opts.wakeWord,
820
+ local: !!opts.local,
821
+ loopback: !!opts.loopback,
736
822
  });
737
823
  });
738
824
  daemon
@@ -745,18 +831,57 @@ daemon
745
831
  daemon
746
832
  .command('restart')
747
833
  .description('Stop and restart the daemon')
748
- .option('-p, --port <port>', 'Server port', String(BRIDGE_WS_PORT))
834
+ .option('-p, --port <port>', 'Server port to restart on (default: the persisted daemonPort)')
749
835
  .option('-d, --debug', 'Enable debug logging')
836
+ .option('--local', 'Disable all device modules on the restarted daemon')
837
+ .option('--loopback', 'Restart with a loopback-only posture (see `daemon start --loopback`)')
750
838
  .action(async (opts) => {
751
- await stopDaemon(parseInt(opts.port, 10));
839
+ // Two different ports, and conflating them is what made this command lose
840
+ // the posture. The daemon must be PROBED and STOPPED where it actually is
841
+ // (daemon.json — which is 9121+ whenever it once fell back), but RESTARTED
842
+ // on the port it prefers. Probing 9120 blindly found nothing whenever the
843
+ // daemon had fallen back, and "no posture found" reads exactly like "open
844
+ // posture" — a silent enterprise downgrade, which is the one thing this
845
+ // inheritance exists to prevent.
846
+ const { probeDaemonHealth: probeHealth, readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
847
+ const { resolveDaemonPort } = await import('./daemon-port.js');
848
+ const preferredPort = resolveDaemonPort({ flag: opts.port });
849
+ const info = readDaemonInfo();
850
+ const runningPort = info?.httpPort ?? info?.port ?? findDaemonPort() ?? preferredPort.port;
851
+ // Read the running daemon's posture BEFORE stopping it. A restart that
852
+ // defaults to "advertise everything" would silently undo an enterprise
853
+ // install — the LaunchAgent / Scheduled Task / systemd unit carries the
854
+ // posture in its argv, and this command does not read those three files.
855
+ // Explicit flags still win; inheritance only fills in what wasn't asked for.
856
+ const running = await probeHealth(runningPort);
857
+ const inheritedLocal = running?.posture?.noDeviceModules === true;
858
+ const inheritedLoopback = running?.posture?.loopbackOnly === true;
859
+ const useLocal = !!opts.local || inheritedLocal;
860
+ const useLoopback = !!opts.loopback || inheritedLoopback;
861
+ if (running && runningPort !== preferredPort.port) {
862
+ log(`Daemon is on port ${runningPort} but prefers ${preferredPort.port} — restarting there.`);
863
+ }
864
+ await stopDaemon(runningPort);
752
865
  // Wait for port release + session cleanup
753
866
  await new Promise(resolve => setTimeout(resolve, 1500));
754
867
  const scriptPath = fileURLToPath(import.meta.url);
755
868
  const args = [scriptPath, 'daemon', 'start', '--foreground'];
756
- if (opts.port !== String(BRIDGE_WS_PORT))
757
- args.push('-p', opts.port);
869
+ // Same rule as `daemon start`: forward `-p` only when it was typed, so the
870
+ // child keeps the real provenance of its port.
871
+ if (preferredPort.source === 'flag')
872
+ args.push('-p', String(preferredPort.port));
758
873
  if (opts.debug)
759
874
  args.push('-d');
875
+ if (useLocal)
876
+ args.push('--local');
877
+ if (useLoopback)
878
+ args.push('--loopback');
879
+ if ((inheritedLocal && !opts.local) || (inheritedLoopback && !opts.loopback)) {
880
+ log(`Carrying over the running daemon's posture (${[
881
+ inheritedLoopback ? 'loopback-only' : null,
882
+ inheritedLocal ? 'no device modules' : null,
883
+ ].filter(Boolean).join(', ')}).`);
884
+ }
760
885
  const [rOut, rErr] = await openDaemonLogs(join(homedir(), '.agentdeck'));
761
886
  const child = spawn(process.execPath, args, {
762
887
  detached: true,
@@ -777,11 +902,28 @@ daemon
777
902
  const info = readDaemonInfo();
778
903
  const sessions = listActive();
779
904
  const d = sessions.find(s => s.agentType === 'daemon');
780
- const targetPort = info?.httpPort ?? info?.port ?? d?.port ?? port;
905
+ // Two ways to arrive at a port, and only one of them identifies OUR daemon.
906
+ // `registryPort` comes from this install's own daemon.json / sessions.json;
907
+ // the `?? port` tail is a blind probe of the default, which on a machine
908
+ // running a second AgentDeck answers with *its* daemon. Anything that
909
+ // claims something about "this daemon" may only use the registry-resolved
910
+ // port — a note attached to a stranger's daemon is worse than no note.
911
+ const registryPort = info?.httpPort ?? info?.port ?? d?.port ?? null;
912
+ const targetPort = registryPort ?? port;
781
913
  try {
782
914
  const res = await fetch(`http://127.0.0.1:${targetPort}/health`);
783
915
  const data = await res.json();
784
916
  log(`Daemon status (port ${targetPort}): ${JSON.stringify(data, null, 2)}`);
917
+ // A daemon on a fallback port keeps working — clients resolve it from
918
+ // daemon.json — so nothing else ever says it happened. Without this line
919
+ // the only symptom is that the canonical port is owned by nobody.
920
+ const { resolveDaemonPort, describeDaemonPortSource } = await import('./daemon-port.js');
921
+ const preferred = resolveDaemonPort();
922
+ if (registryPort !== null && preferred.port !== registryPort) {
923
+ log(`Note: this daemon prefers port ${preferred.port} (${describeDaemonPortSource(preferred.source)}) `
924
+ + `but is serving ${registryPort} — it fell back at startup. `
925
+ + `Run 'agentdeck daemon restart' to aim at ${preferred.port} again.`);
926
+ }
785
927
  }
786
928
  catch {
787
929
  if (info)
@@ -792,13 +934,70 @@ daemon
792
934
  process.exit(1);
793
935
  }
794
936
  });
937
+ daemon
938
+ .command('port')
939
+ .argument('[port]', 'Port to persist as this machine\'s preferred daemon port')
940
+ .description('Show or set the persisted daemon port (settings.json daemonPort)')
941
+ .option('--clear', 'Forget the persisted port and go back to the built-in default')
942
+ .action(async (portArg, opts) => {
943
+ const { resolveDaemonPort, describeDaemonPortSource, parseDaemonPort, preferredDaemonPortFrom, DAEMON_PORT_SETTING_KEY, DAEMON_PORT_MIN, DAEMON_PORT_MAX, } = await import('./daemon-port.js');
944
+ const { loadDaemonSettings, updateDaemonSetting, ownSettingsPath } = await import('./daemon-settings.js');
945
+ const { readDaemonInfo } = await import('./session-registry.js');
946
+ if (opts.clear) {
947
+ updateDaemonSetting(DAEMON_PORT_SETTING_KEY, undefined);
948
+ log(`Cleared the persisted daemon port in ${ownSettingsPath()}.`);
949
+ }
950
+ else if (portArg !== undefined) {
951
+ const parsed = parseDaemonPort(portArg);
952
+ if (parsed === null) {
953
+ // Strict here, lenient in the reader: a typo must fail where the user
954
+ // can see it, not silently revert to 9120 six weeks later.
955
+ log(`"${portArg}" is not a valid port. Give an integer between ${DAEMON_PORT_MIN} and ${DAEMON_PORT_MAX}.`);
956
+ process.exit(1);
957
+ }
958
+ updateDaemonSetting(DAEMON_PORT_SETTING_KEY, parsed);
959
+ log(`Persisted daemon port ${parsed} in ${ownSettingsPath()}.`);
960
+ log(`It takes effect on the next 'agentdeck daemon restart'.`);
961
+ // A preferred port outside the sweep window is a quiet split-brain: the
962
+ // singleton guard scans the window, so a second daemon started later
963
+ // never sees this one and both run. Same warning `--port-window` prints,
964
+ // for the same reason — the window is a discovery contract, not a range
965
+ // of convenience.
966
+ const [lo, hi] = daemonPortWindow();
967
+ if (parsed < lo || parsed > hi) {
968
+ log(`Warning: ${parsed} is outside the daemon port window ${lo}-${hi}. Clients that discover `
969
+ + `by scanning that window will not find this daemon, and the singleton guard will not see `
970
+ + `it either — a second daemon could start alongside it. Move the window too `
971
+ + `(AGENTDECK_PORT_WINDOW), or pick a port inside it.`);
972
+ }
973
+ }
974
+ const resolved = resolveDaemonPort();
975
+ const stored = preferredDaemonPortFrom(loadDaemonSettings());
976
+ log(`Preferred port: ${resolved.port} (${describeDaemonPortSource(resolved.source)})`);
977
+ log(`Persisted value: ${stored ?? '(none)'}`);
978
+ const info = readDaemonInfo();
979
+ if (info) {
980
+ log(`Running daemon: port ${info.port} (PID ${info.pid})`
981
+ + (info.port === resolved.port ? '' : ' — on a fallback port, not its preferred one'));
982
+ }
983
+ else {
984
+ log('Running daemon: none found');
985
+ }
986
+ });
795
987
  daemon
796
988
  .command('install')
797
989
  .description('Install daemon auto-start (LaunchAgent on macOS, Scheduled Task on Windows, systemd --user unit on Linux)')
798
- .action(async () => {
990
+ .option('--enterprise', 'Install with a loopback-only posture: bind 127.0.0.1, no mDNS, no UDP beacon, no LAN sweep, no BLE, no ADB')
991
+ .option('--local', 'Install with all device modules disabled')
992
+ .option('--loopback', 'Alias for --enterprise')
993
+ .action(async (opts) => {
994
+ const postureArgs = daemonPostureArgs(opts);
995
+ if (postureArgs.length > 0) {
996
+ log(`Autostart posture: ${postureArgs.join(' ')} (baked into the autostart unit's arguments).`);
997
+ }
799
998
  if (process.platform === 'win32') {
800
999
  try {
801
- installWindowsTask();
1000
+ installWindowsTask(postureArgs);
802
1001
  log(`Scheduled task '${TASK_NAME}' registered. Daemon will auto-start on logon.`);
803
1002
  }
804
1003
  catch (e) {
@@ -820,6 +1019,7 @@ daemon
820
1019
  log('Task registered; immediate start failed — it will start on next logon.');
821
1020
  }
822
1021
  await refreshClaudeHooks();
1022
+ await refreshKiroHooks();
823
1023
  // Install Codex lifecycle hooks for parity with the macOS install path.
824
1024
  try {
825
1025
  const { installCodexHooksIfNeeded } = await import('@agentdeck/hooks');
@@ -857,7 +1057,7 @@ daemon
857
1057
  }
858
1058
  else {
859
1059
  try {
860
- installUnit();
1060
+ installUnit(postureArgs);
861
1061
  startUnit();
862
1062
  log(`systemd user unit '${SERVICE_NAME}' installed and started.`);
863
1063
  log(`Unit file: ${getUnitPath()}`);
@@ -879,6 +1079,7 @@ daemon
879
1079
  }
880
1080
  }
881
1081
  await refreshClaudeHooks();
1082
+ await refreshKiroHooks();
882
1083
  // Install Codex + OpenCode hooks for parity with the macOS/Windows paths.
883
1084
  try {
884
1085
  const { installCodexHooksIfNeeded } = await import('@agentdeck/hooks');
@@ -910,7 +1111,7 @@ daemon
910
1111
  log('LaunchAgent is macOS-only');
911
1112
  process.exit(1);
912
1113
  }
913
- const plist = buildPlist();
1114
+ const plist = buildPlist(postureArgs);
914
1115
  writeFileSync(PLIST_PATH, plist, 'utf-8');
915
1116
  log(`Wrote ${PLIST_PATH}`);
916
1117
  try {
@@ -920,6 +1121,7 @@ daemon
920
1121
  execSync(`launchctl load "${PLIST_PATH}"`);
921
1122
  log('LaunchAgent loaded. Daemon will auto-start on login.');
922
1123
  await refreshClaudeHooks();
1124
+ await refreshKiroHooks();
923
1125
  // Install Codex lifecycle hooks parallel to the LaunchAgent install
924
1126
  // so the daemon hub gets codex_* events as soon as Codex CLI runs.
925
1127
  try {
@@ -1321,6 +1523,19 @@ program
1321
1523
  throw new Error(String(body.error ?? `HTTP ${statusCode}`));
1322
1524
  }
1323
1525
  log(`Staged for ${body.board}: ${formatBytes(body.bytes)} md5=${body.md5}`);
1526
+ if (body.pullSeen === false) {
1527
+ // "Staged" is a promise the board has to keep, and only a board that
1528
+ // pulls the feed can keep it. Boards in this repo's esp32/ tree hold a
1529
+ // live WebSocket and never pull, so a stage for one is a reservation
1530
+ // nothing will ever collect — reported as success, which is how a board
1531
+ // sat a full release behind while its OTA looked handled.
1532
+ log(`WARNING: this daemon has seen no feed pull from ${body.board} since it started.`);
1533
+ log(' Only pull-sync boards (XTeink X3/X4/M6) fetch staged firmware. A board that');
1534
+ log(' holds a live WS never asks for it, so this stage may never install.');
1535
+ log(' Check `agentdeck devices` (card-feed clients); otherwise flash over USB serial.');
1536
+ log(' Verify with the board\'s reported buildHash, never with this message.');
1537
+ return;
1538
+ }
1324
1539
  log('The board installs it on its next feed pull (battery cadence: typically within 15-60 min).');
1325
1540
  return;
1326
1541
  }
@@ -1364,6 +1579,7 @@ program
1364
1579
  }
1365
1580
  }
1366
1581
  getOrCreateToken();
1582
+ await warnIfLoopbackPosture(port);
1367
1583
  const url = getWsUrl(port);
1368
1584
  log(`\nPairing URL:\n ${url}\n`);
1369
1585
  try {
@@ -1395,6 +1611,9 @@ program
1395
1611
  ? opts.adopt
1396
1612
  : (typeof opts.adopt === 'string' ? [opts.adopt] : []);
1397
1613
  const daemon = `http://127.0.0.1:${port}`;
1614
+ // A pairing window against a loopback-only daemon can never be redeemed:
1615
+ // the peer it is meant for cannot open a socket to this host at all.
1616
+ await warnIfLoopbackPosture(port);
1398
1617
  let opened;
1399
1618
  try {
1400
1619
  const res = await fetch(`${daemon}/pair/open`, {
@@ -1521,12 +1740,29 @@ program
1521
1740
  }
1522
1741
  });
1523
1742
  program
1524
- .command('diag')
1525
- .description('Generate diagnostic dump')
1743
+ .command('diag [target]')
1744
+ .description('Generate a diagnostic dump, or a privacy-safe agent diagnostic')
1526
1745
  .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
1527
1746
  .option('-a, --analyze', 'Run AI analysis on the dump')
1528
1747
  .option('-t, --tail <lines>', 'Number of journal entries', '200')
1529
- .action(async (opts) => {
1748
+ .option('--json', 'Print target diagnostics as machine-readable JSON')
1749
+ .action(async (target, opts) => {
1750
+ if (target) {
1751
+ if (target !== 'kiro') {
1752
+ log(`Unknown diagnostic target: ${target}. Supported target: kiro`);
1753
+ process.exitCode = 1;
1754
+ return;
1755
+ }
1756
+ if (opts.analyze) {
1757
+ log('`--analyze` is only available for the general daemon diagnostic dump.');
1758
+ process.exitCode = 1;
1759
+ return;
1760
+ }
1761
+ const { collectKiroDiagnosticReport, formatKiroDiagnosticReport } = await import('./kiro-diagnostics.js');
1762
+ const report = await collectKiroDiagnosticReport();
1763
+ process.stdout.write(`${opts.json ? JSON.stringify(report, null, 2) : formatKiroDiagnosticReport(report)}\n`);
1764
+ return;
1765
+ }
1530
1766
  const { readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
1531
1767
  const info = readDaemonInfo();
1532
1768
  const port = info?.httpPort ?? info?.port ?? findDaemonPort() ?? parseInt(opts.port, 10);
@@ -2008,12 +2244,24 @@ timebox
2008
2244
  const pixoo = program.command('pixoo').description('Manage Pixoo64 LED matrix devices');
2009
2245
  pixoo
2010
2246
  .command('scan')
2011
- .description('Discover Pixoo devices on LAN')
2012
- .action(async () => {
2013
- const { discoverDevices, getDeviceConfig } = await import('./pixoo/pixoo-client.js');
2247
+ .description('Discover Pixoo devices on LAN (Divoom cloud lookup, then a local /24 sweep)')
2248
+ .option('--no-cloud', 'Skip the Divoom cloud lookup (app.divoom-gz.com) and sweep the local subnet only')
2249
+ .action(async (opts) => {
2250
+ const { getDeviceConfig } = await import('./pixoo/pixoo-client.js');
2251
+ const { discoverPixoo } = await import('./pixoo/pixoo-discover.js');
2014
2252
  const { loadPixooDevices, savePixooDevices } = await import('./pixoo/pixoo-settings.js');
2015
- log('Scanning for Pixoo devices...');
2016
- const found = await discoverDevices();
2253
+ // Say what this actually does before it does it. Auto-discovery is off by
2254
+ // default precisely because these two steps are not free on a shared
2255
+ // network; running them from an explicit command is fine, running them
2256
+ // unannounced is not.
2257
+ if (opts.cloud === false) {
2258
+ log('Scanning the local subnet for Pixoo devices (cloud lookup skipped)...');
2259
+ }
2260
+ else {
2261
+ log('Scanning for Pixoo devices — querying the Divoom cloud (app.divoom-gz.com), then sweeping the local /24 if needed.');
2262
+ log('Use --no-cloud to keep the scan on your own network.');
2263
+ }
2264
+ const found = await discoverPixoo({ cloud: opts.cloud !== false });
2017
2265
  if (found.length === 0) {
2018
2266
  log('No devices found.');
2019
2267
  return;
@@ -2082,7 +2330,12 @@ pixoo
2082
2330
  const { loadPixooDevices } = await import('./pixoo/pixoo-settings.js');
2083
2331
  const devices = loadPixooDevices();
2084
2332
  if (devices.length === 0) {
2085
- log('No Pixoo devices configured. Run `agentdeck pixoo scan`.');
2333
+ // Says why nothing appeared on its own. The daemon no longer sweeps the
2334
+ // LAN unprompted, so "I plugged it in and nothing happened" needs an
2335
+ // answer here — this is where a user looks when the panel is missing.
2336
+ log('No Pixoo devices configured. Run `agentdeck pixoo scan` (add --no-cloud to keep the lookup on your own network).');
2337
+ log('Auto-discovery is off by default: the daemon does not sweep the LAN or call the Divoom cloud on its own.');
2338
+ log('Set "pixooAutoDiscover": true in ~/.agentdeck/settings.json to opt back in.');
2086
2339
  return;
2087
2340
  }
2088
2341
  log(`${devices.length} device(s):`);
@@ -2473,6 +2726,49 @@ apme
2473
2726
  log(` ${c.modelId.slice(0, 26).padEnd(26)} ${c.agentType.padEnd(14)} ${String(c.runs).padEnd(6)} ${score.padEnd(8)} ${tests.padEnd(8)} ${cost.padEnd(10)} ${cpq}`);
2474
2727
  }
2475
2728
  });
2729
+ apme
2730
+ .command('stop-health')
2731
+ .description('Stop-hook delivery rate — how turns actually got closed')
2732
+ .option('--since <window>', 'Lookback window, e.g. 6h / 3d / 2w (default 7d)', '7d')
2733
+ .option('--agent <type>', 'Restrict to one agent type (claude-code, codex-cli, …)')
2734
+ .action(async (opts) => {
2735
+ const { initApme } = await import('./apme/index.js');
2736
+ const apme = await initApme();
2737
+ if (!apme) {
2738
+ log('APME not available');
2739
+ process.exit(1);
2740
+ }
2741
+ const windowMs = parseLookbackWindow(opts.since);
2742
+ if (windowMs == null) {
2743
+ log(`Unrecognized --since value: ${opts.since}`);
2744
+ process.exit(1);
2745
+ }
2746
+ const sinceMs = Date.now() - windowMs;
2747
+ const rows = apme.store.stopDelivery({ sinceMs, agentType: opts.agent });
2748
+ if (rows.length === 0) {
2749
+ log(`No turns started in the last ${opts.since}.`);
2750
+ return;
2751
+ }
2752
+ log(`\n Turns started in the last ${opts.since} — how each one's end was learned`);
2753
+ log(` ${'Agent'.padEnd(14)} ${'Turns'.padEnd(7)} ${'Stop'.padEnd(7)} ${'Synth'.padEnd(7)} ${'NoStop'.padEnd(7)} ${'Esc'.padEnd(6)} ${'Abort'.padEnd(6)} ${'Folded'.padEnd(7)} ${'SessEnd'.padEnd(8)} ${'Open'.padEnd(6)} ${'?'.padEnd(6)} Stop loss`);
2754
+ log(` ${'─'.repeat(14)} ${'─'.repeat(7)} ${'─'.repeat(7)} ${'─'.repeat(7)} ${'─'.repeat(7)} ${'─'.repeat(6)} ${'─'.repeat(6)} ${'─'.repeat(7)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(6)} ${'─'.repeat(9)}`);
2755
+ for (const r of rows) {
2756
+ // Which buckets the ratio may read is defined once, in shared — see
2757
+ // `stopDeliveryLoss`. Restating it here is how the instrument would come
2758
+ // to measure something other than what its legend claims.
2759
+ const { adjudicated, ratio } = stopDeliveryLoss(r);
2760
+ const rate = ratio != null ? `${(ratio * 100).toFixed(0)}% of ${adjudicated}` : '—';
2761
+ log(` ${r.agentType.slice(0, 14).padEnd(14)} ${String(r.total).padEnd(7)} ${String(r.stop).padEnd(7)} ${String(r.syntheticStop).padEnd(7)} ${String(r.nextPrompt).padEnd(7)} ${String(r.interrupted).padEnd(6)} ${String(r.aborted).padEnd(6)} ${String(r.superseded).padEnd(7)} ${String(r.sessionEnd).padEnd(8)} ${String(r.open).padEnd(6)} ${String(r.preInstrument).padEnd(6)} ${rate}`);
2762
+ }
2763
+ log('');
2764
+ log(' Synth = Stop hook dropped, watchdog recovered it from the transcript');
2765
+ log(' NoStop = Stop hook dropped and nothing recovered it (closed by the next prompt)');
2766
+ log(' Esc = user cancelled the turn — Claude Code owes no Stop, so not a loss');
2767
+ log(' Abort = usage limit / auth / API error ended the turn — no Stop is fired for those');
2768
+ log(' Folded = a second prompt arrived before this one ran; one turn served both');
2769
+ log(' ? = closed before end_source existed — signal unknown, never guessed');
2770
+ log('');
2771
+ });
2476
2772
  apme
2477
2773
  .command('judge')
2478
2774
  .description('Run evaluation on unevaluated runs')