@agentdeck/bridge 1.0.19 → 1.0.21

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 (192) 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/codex-turn-manager.js +6 -1
  10. package/dist/apme/adapters/codex-turn-manager.js.map +1 -1
  11. package/dist/apme/adapters/openclaw-hook.d.ts +45 -1
  12. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
  13. package/dist/apme/adapters/openclaw-hook.js +84 -4
  14. package/dist/apme/adapters/openclaw-hook.js.map +1 -1
  15. package/dist/apme/claude-transcript-reader.d.ts +76 -0
  16. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  17. package/dist/apme/claude-transcript-reader.js +152 -1
  18. package/dist/apme/claude-transcript-reader.js.map +1 -1
  19. package/dist/apme/collector.d.ts +63 -5
  20. package/dist/apme/collector.d.ts.map +1 -1
  21. package/dist/apme/collector.js +147 -10
  22. package/dist/apme/collector.js.map +1 -1
  23. package/dist/apme/store.d.ts +23 -1
  24. package/dist/apme/store.d.ts.map +1 -1
  25. package/dist/apme/store.js +81 -2
  26. package/dist/apme/store.js.map +1 -1
  27. package/dist/apme/types.d.ts +1 -1
  28. package/dist/apme/types.d.ts.map +1 -1
  29. package/dist/apme/types.js.map +1 -1
  30. package/dist/auth.d.ts +37 -0
  31. package/dist/auth.d.ts.map +1 -1
  32. package/dist/auth.js +23 -0
  33. package/dist/auth.js.map +1 -1
  34. package/dist/bridge-core.d.ts.map +1 -1
  35. package/dist/bridge-core.js +6 -1
  36. package/dist/bridge-core.js.map +1 -1
  37. package/dist/claude-interrupt-marker.d.ts +31 -0
  38. package/dist/claude-interrupt-marker.d.ts.map +1 -0
  39. package/dist/claude-interrupt-marker.js +58 -0
  40. package/dist/claude-interrupt-marker.js.map +1 -0
  41. package/dist/claude-turn-watchdog.d.ts +93 -0
  42. package/dist/claude-turn-watchdog.d.ts.map +1 -0
  43. package/dist/claude-turn-watchdog.js +173 -0
  44. package/dist/claude-turn-watchdog.js.map +1 -0
  45. package/dist/cli.d.ts +6 -0
  46. package/dist/cli.d.ts.map +1 -1
  47. package/dist/cli.js +399 -115
  48. package/dist/cli.js.map +1 -1
  49. package/dist/codex-hook-silence.d.ts +52 -0
  50. package/dist/codex-hook-silence.d.ts.map +1 -0
  51. package/dist/codex-hook-silence.js +80 -0
  52. package/dist/codex-hook-silence.js.map +1 -0
  53. package/dist/daemon-port.d.ts +90 -0
  54. package/dist/daemon-port.d.ts.map +1 -0
  55. package/dist/daemon-port.js +119 -0
  56. package/dist/daemon-port.js.map +1 -0
  57. package/dist/daemon-server.d.ts +14 -1
  58. package/dist/daemon-server.d.ts.map +1 -1
  59. package/dist/daemon-server.js +297 -69
  60. package/dist/daemon-server.js.map +1 -1
  61. package/dist/daemon-settings.d.ts +46 -0
  62. package/dist/daemon-settings.d.ts.map +1 -0
  63. package/dist/daemon-settings.js +85 -0
  64. package/dist/daemon-settings.js.map +1 -0
  65. package/dist/daemon-takeover.d.ts +75 -0
  66. package/dist/daemon-takeover.d.ts.map +1 -0
  67. package/dist/daemon-takeover.js +137 -0
  68. package/dist/daemon-takeover.js.map +1 -0
  69. package/dist/daemon.js +1 -1
  70. package/dist/index.d.ts +4 -1
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +90 -12
  73. package/dist/index.js.map +1 -1
  74. package/dist/kiro-diagnostics.d.ts +90 -0
  75. package/dist/kiro-diagnostics.d.ts.map +1 -0
  76. package/dist/kiro-diagnostics.js +278 -0
  77. package/dist/kiro-diagnostics.js.map +1 -0
  78. package/dist/kiro-session.d.ts +113 -0
  79. package/dist/kiro-session.d.ts.map +1 -0
  80. package/dist/kiro-session.js +688 -0
  81. package/dist/kiro-session.js.map +1 -0
  82. package/dist/kiro-timeline-feed.d.ts +45 -0
  83. package/dist/kiro-timeline-feed.d.ts.map +1 -0
  84. package/dist/kiro-timeline-feed.js +116 -0
  85. package/dist/kiro-timeline-feed.js.map +1 -0
  86. package/dist/kiro-transcript-timeline.d.ts +57 -0
  87. package/dist/kiro-transcript-timeline.d.ts.map +1 -0
  88. package/dist/kiro-transcript-timeline.js +183 -0
  89. package/dist/kiro-transcript-timeline.js.map +1 -0
  90. package/dist/linux-service.d.ts +2 -1
  91. package/dist/linux-service.d.ts.map +1 -1
  92. package/dist/linux-service.js +8 -3
  93. package/dist/linux-service.js.map +1 -1
  94. package/dist/mdns-discover.d.ts +27 -0
  95. package/dist/mdns-discover.d.ts.map +1 -1
  96. package/dist/mdns-discover.js +60 -7
  97. package/dist/mdns-discover.js.map +1 -1
  98. package/dist/mdns.d.ts.map +1 -1
  99. package/dist/mdns.js +16 -3
  100. package/dist/mdns.js.map +1 -1
  101. package/dist/modules/adb-module.d.ts.map +1 -1
  102. package/dist/modules/adb-module.js +5 -2
  103. package/dist/modules/adb-module.js.map +1 -1
  104. package/dist/modules/index.d.ts +2 -1
  105. package/dist/modules/index.d.ts.map +1 -1
  106. package/dist/modules/index.js +1 -0
  107. package/dist/modules/index.js.map +1 -1
  108. package/dist/modules/types.d.ts +34 -9
  109. package/dist/modules/types.d.ts.map +1 -1
  110. package/dist/modules/types.js +34 -1
  111. package/dist/modules/types.js.map +1 -1
  112. package/dist/network-posture.d.ts +67 -0
  113. package/dist/network-posture.d.ts.map +1 -0
  114. package/dist/network-posture.js +100 -0
  115. package/dist/network-posture.js.map +1 -0
  116. package/dist/observed-steering.d.ts +14 -0
  117. package/dist/observed-steering.d.ts.map +1 -1
  118. package/dist/observed-steering.js +18 -0
  119. package/dist/observed-steering.js.map +1 -1
  120. package/dist/observed-turn-watchdogs.d.ts +84 -0
  121. package/dist/observed-turn-watchdogs.d.ts.map +1 -0
  122. package/dist/observed-turn-watchdogs.js +134 -0
  123. package/dist/observed-turn-watchdogs.js.map +1 -0
  124. package/dist/openclaw-session.d.ts +10 -0
  125. package/dist/openclaw-session.d.ts.map +1 -1
  126. package/dist/openclaw-session.js +12 -0
  127. package/dist/openclaw-session.js.map +1 -1
  128. package/dist/passive-observer.d.ts +19 -0
  129. package/dist/passive-observer.d.ts.map +1 -1
  130. package/dist/passive-observer.js +208 -24
  131. package/dist/passive-observer.js.map +1 -1
  132. package/dist/pixoo/micro-glyphs.d.ts +1 -1
  133. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  134. package/dist/pixoo/micro-glyphs.js +2 -0
  135. package/dist/pixoo/micro-glyphs.js.map +1 -1
  136. package/dist/pixoo/official-dot-glyphs.generated.d.ts +1 -1
  137. package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -1
  138. package/dist/pixoo/official-dot-glyphs.generated.js +47 -0
  139. package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -1
  140. package/dist/pixoo/pixoo-discover.d.ts +8 -1
  141. package/dist/pixoo/pixoo-discover.d.ts.map +1 -1
  142. package/dist/pixoo/pixoo-discover.js +15 -8
  143. package/dist/pixoo/pixoo-discover.js.map +1 -1
  144. package/dist/pixoo/pixoo-renderer.d.ts +4 -2
  145. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  146. package/dist/pixoo/pixoo-renderer.js +105 -36
  147. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  148. package/dist/pixoo/pixoo-settings.d.ts +19 -2
  149. package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
  150. package/dist/pixoo/pixoo-settings.js +22 -4
  151. package/dist/pixoo/pixoo-settings.js.map +1 -1
  152. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
  153. package/dist/pixoo/pixoo-sprites.js +2 -0
  154. package/dist/pixoo/pixoo-sprites.js.map +1 -1
  155. package/dist/session-aggregator.d.ts +9 -1
  156. package/dist/session-aggregator.d.ts.map +1 -1
  157. package/dist/session-aggregator.js +1 -1
  158. package/dist/session-aggregator.js.map +1 -1
  159. package/dist/session-registry.d.ts +71 -3
  160. package/dist/session-registry.d.ts.map +1 -1
  161. package/dist/session-registry.js +126 -19
  162. package/dist/session-registry.js.map +1 -1
  163. package/dist/state-machine.d.ts +38 -1
  164. package/dist/state-machine.d.ts.map +1 -1
  165. package/dist/state-machine.js +112 -8
  166. package/dist/state-machine.js.map +1 -1
  167. package/dist/subagent-timeline.d.ts +38 -7
  168. package/dist/subagent-timeline.d.ts.map +1 -1
  169. package/dist/subagent-timeline.js +154 -23
  170. package/dist/subagent-timeline.js.map +1 -1
  171. package/dist/timeline-store.d.ts.map +1 -1
  172. package/dist/timeline-store.js +26 -1
  173. package/dist/timeline-store.js.map +1 -1
  174. package/dist/tui/renderer.d.ts.map +1 -1
  175. package/dist/tui/renderer.js +13 -1
  176. package/dist/tui/renderer.js.map +1 -1
  177. package/dist/tui/terrarium.d.ts.map +1 -1
  178. package/dist/tui/terrarium.js +10 -5
  179. package/dist/tui/terrarium.js.map +1 -1
  180. package/dist/utils/redact-secrets.d.ts +3 -0
  181. package/dist/utils/redact-secrets.d.ts.map +1 -0
  182. package/dist/utils/redact-secrets.js +29 -0
  183. package/dist/utils/redact-secrets.js.map +1 -0
  184. package/dist/version-check.d.ts +8 -0
  185. package/dist/version-check.d.ts.map +1 -1
  186. package/dist/version-check.js +27 -0
  187. package/dist/version-check.js.map +1 -1
  188. package/dist/windows-service.d.ts +2 -1
  189. package/dist/windows-service.d.ts.map +1 -1
  190. package/dist/windows-service.js +6 -3
  191. package/dist/windows-service.js.map +1 -1
  192. 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
  });
@@ -457,12 +546,12 @@ program
457
546
  daemonHost: opts.daemonHost,
458
547
  daemonToken: opts.daemonToken,
459
548
  weight: opts.weight,
460
- modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : {
461
- mdns: false, // daemon-only
549
+ codexHooksExpected: opts.codexHooks !== false,
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(),
462
554
  adb: opts.adb !== false ? 'auto' : false,
463
- serial: false, // daemon-only
464
- pixoo: false, // daemon-only
465
- timebox: false, // daemon-only
466
555
  },
467
556
  });
468
557
  });
@@ -517,12 +606,10 @@ program
517
606
  daemonHost: opts.daemonHost,
518
607
  daemonToken: opts.daemonToken,
519
608
  weight: opts.weight,
520
- modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : {
521
- mdns: false,
609
+ modules: opts.local ? allModulesOff() : {
610
+ // See the claude action: all off, ADB back on.
611
+ ...allModulesOff(),
522
612
  adb: opts.adb !== false ? 'auto' : false,
523
- serial: false,
524
- pixoo: false,
525
- timebox: false,
526
613
  },
527
614
  });
528
615
  });
@@ -552,7 +639,7 @@ program
552
639
  daemonHost: opts.daemonHost,
553
640
  daemonToken: opts.daemonToken,
554
641
  weight: opts.weight,
555
- modules: opts.local ? { mdns: false, adb: false, serial: false, pixoo: false, timebox: false } : undefined,
642
+ modules: opts.local ? allModulesOff() : undefined,
556
643
  });
557
644
  });
558
645
  // The worktree-compare feature ("cockpit") was extracted into its own
@@ -597,10 +684,19 @@ async function openDaemonLogs(logDir) {
597
684
  daemon
598
685
  .command('start')
599
686
  .description('Start monitoring daemon (WS + mDNS + Gateway proxy)')
600
- .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})`)
601
694
  .option('-d, --debug', 'Enable debug logging')
602
695
  .option('-f, --foreground', 'Run in foreground (default: background fork)')
603
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')
604
700
  .action(async (opts) => {
605
701
  const { findExistingDaemon, probeDaemonHealth, readDaemonInfo, removeDaemonInfo, removeDaemonSession, requestDaemonStandDown, requestDaemonShutdown, waitForDaemonExit, waitForPortBindable } = await import('./session-registry.js');
606
702
  const { adoptPeerToken } = await import('./auth.js');
@@ -612,79 +708,53 @@ daemon
612
708
  // CLI daemon can take over with the full feature set, then wait for the port
613
709
  // to clear before binding. A real Node daemon on the port still means
614
710
  // "already running".
615
- 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');
616
746
  const incumbent = await probeDaemonHealth(targetPort);
617
- if (incumbent?.mode === 'daemon') {
618
- // Take over the fleet's credential along with the port. The app's daemon
619
- // keeps its token inside its sandbox container, unreadable from here, so
620
- // this loopback answer is the only place we can learn what every paired
621
- // board is currently holding. Adopting it BEFORE the stand-down means the
622
- // handover costs no device its pairing.
623
- if (adoptPeerToken(incumbent.pairingToken)) {
624
- log(`Adopted the incumbent daemon's pairing token so paired devices survive the handover.`);
625
- }
626
- if (incumbent.isSwift) {
627
- log(`AgentDeck app's in-process daemon holds port ${targetPort} — requesting stand-down to take over with the full CLI feature set…`);
628
- // Prefer /stand-down (clean demote: the app stays running as a client).
629
- // Fall back to /shutdown for older app builds that predate the endpoint.
630
- let acked = await requestDaemonStandDown(targetPort);
631
- if (!acked) {
632
- await requestDaemonShutdown(targetPort);
633
- acked = true; // shutdown is best-effort (no ack body); rely on the exit wait
634
- }
635
- // Two conditions, not one. `waitForDaemonExit` proves the app stopped
636
- // answering; `waitForPortBindable` proves the socket is actually gone.
637
- // The app releases the port with NWListener.cancel(), which returns
638
- // before the teardown completes — binding on the first signal alone is
639
- // what silently demoted this daemon to the 9121 fallback and left the
640
- // canonical port ownerless.
641
- if (acked
642
- && await waitForDaemonExit(targetPort, 12000)
643
- // 30s, and that is not padding. Cancelling the app's NWListener does
644
- // not free the port: macOS keeps a NECP reservation on it for ~14s
645
- // afterwards, during which `lsof` shows no sockets at all and bind()
646
- // still returns EADDRINUSE (measured 2026-08-06 — bindable at ~17s).
647
- // Anything shorter gives up while the kernel, not the app, is holding
648
- // the port. The app's takeover-yield window is longer still (45s), so
649
- // it stays off the port until well after this wait has claimed it.
650
- && await waitForPortBindable(targetPort, 30000)) {
651
- log(`App daemon yielded port ${targetPort}. Starting CLI daemon…`);
652
- // fall through — port is clear, proceed to bind below
653
- }
654
- else {
655
- // The app yielded but the PORT did not come back, and that is often
656
- // not the app's doing: a WiFi device that has gone to sleep with bytes
657
- // still queued to it leaves its socket in LAST_ACK on this port, and
658
- // TCP holds that until it exhausts retransmits — minutes, sometimes.
659
- // Network.framework exposes no SO_LINGER, so the app cannot RST out of
660
- // it (measured 2026-08-06: two sleeping ESP32 boards, 11901 and 4
661
- // bytes queued, were the only things left on 9120).
662
- //
663
- // Carry on rather than exiting. The daemon binds a fallback port and
664
- // writes it to daemon.json, which is what every client resolves
665
- // through and what the app itself re-reads when its yield window ends
666
- // — so the deck keeps working. What must NOT happen is this being
667
- // silent, which is how the canonical port ended up owned by nobody
668
- // with the CLI reporting success.
669
- log(`The AgentDeck app yielded port ${targetPort} but the port has not been released `
670
- + `(usually a sleeping WiFi device holding a half-closed socket on it).`);
671
- if (opts.port) {
672
- // The port was ASKED for, not defaulted to. Quietly binding a
673
- // different one would answer a question the user did not ask;
674
- // an explicit choice fails loudly instead.
675
- log(`Quit the AgentDeck app and retry, or choose another port.`);
676
- process.exit(1);
677
- }
678
- log(`Starting on a fallback port instead — clients resolve it from daemon.json. `
679
- + `For the canonical port, quit the AgentDeck app and retry, or pass -p.`);
680
- // fall through to normal port selection
681
- }
682
- }
683
- else {
684
- log(`Daemon already running on port ${targetPort}. Use 'agentdeck daemon stop' first.`);
685
- process.exit(0);
686
- }
687
- }
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);
688
758
  const daemonInfo = readDaemonInfo();
689
759
  if (daemonInfo) {
690
760
  const probePort = daemonInfo.httpPort ?? daemonInfo.port;
@@ -711,12 +781,26 @@ daemon
711
781
  const logDir = join(homedir(), '.agentdeck');
712
782
  const scriptPath = fileURLToPath(import.meta.url);
713
783
  const args = [scriptPath, 'daemon', 'start', '--foreground'];
714
- if (opts.port !== String(BRIDGE_WS_PORT))
715
- 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));
716
790
  if (opts.debug)
717
791
  args.push('-d');
718
792
  if (opts.wakeWord)
719
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));
720
804
  const [out, err] = await openDaemonLogs(logDir);
721
805
  const child = spawn(process.execPath, args, {
722
806
  detached: true,
@@ -729,9 +813,12 @@ daemon
729
813
  }
730
814
  const { startDaemon } = await import('./daemon-server.js');
731
815
  await startDaemon({
732
- port: parseInt(opts.port, 10),
816
+ port: preferredPort.port,
817
+ portSource: preferredPort.source,
733
818
  debug: opts.debug,
734
819
  wakeWord: !!opts.wakeWord,
820
+ local: !!opts.local,
821
+ loopback: !!opts.loopback,
735
822
  });
736
823
  });
737
824
  daemon
@@ -744,18 +831,57 @@ daemon
744
831
  daemon
745
832
  .command('restart')
746
833
  .description('Stop and restart the daemon')
747
- .option('-p, --port <port>', 'Server port', String(BRIDGE_WS_PORT))
834
+ .option('-p, --port <port>', 'Server port to restart on (default: the persisted daemonPort)')
748
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`)')
749
838
  .action(async (opts) => {
750
- 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);
751
865
  // Wait for port release + session cleanup
752
866
  await new Promise(resolve => setTimeout(resolve, 1500));
753
867
  const scriptPath = fileURLToPath(import.meta.url);
754
868
  const args = [scriptPath, 'daemon', 'start', '--foreground'];
755
- if (opts.port !== String(BRIDGE_WS_PORT))
756
- 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));
757
873
  if (opts.debug)
758
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
+ }
759
885
  const [rOut, rErr] = await openDaemonLogs(join(homedir(), '.agentdeck'));
760
886
  const child = spawn(process.execPath, args, {
761
887
  detached: true,
@@ -776,11 +902,28 @@ daemon
776
902
  const info = readDaemonInfo();
777
903
  const sessions = listActive();
778
904
  const d = sessions.find(s => s.agentType === 'daemon');
779
- 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;
780
913
  try {
781
914
  const res = await fetch(`http://127.0.0.1:${targetPort}/health`);
782
915
  const data = await res.json();
783
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
+ }
784
927
  }
785
928
  catch {
786
929
  if (info)
@@ -791,13 +934,70 @@ daemon
791
934
  process.exit(1);
792
935
  }
793
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
+ });
794
987
  daemon
795
988
  .command('install')
796
989
  .description('Install daemon auto-start (LaunchAgent on macOS, Scheduled Task on Windows, systemd --user unit on Linux)')
797
- .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
+ }
798
998
  if (process.platform === 'win32') {
799
999
  try {
800
- installWindowsTask();
1000
+ installWindowsTask(postureArgs);
801
1001
  log(`Scheduled task '${TASK_NAME}' registered. Daemon will auto-start on logon.`);
802
1002
  }
803
1003
  catch (e) {
@@ -819,6 +1019,7 @@ daemon
819
1019
  log('Task registered; immediate start failed — it will start on next logon.');
820
1020
  }
821
1021
  await refreshClaudeHooks();
1022
+ await refreshKiroHooks();
822
1023
  // Install Codex lifecycle hooks for parity with the macOS install path.
823
1024
  try {
824
1025
  const { installCodexHooksIfNeeded } = await import('@agentdeck/hooks');
@@ -856,7 +1057,7 @@ daemon
856
1057
  }
857
1058
  else {
858
1059
  try {
859
- installUnit();
1060
+ installUnit(postureArgs);
860
1061
  startUnit();
861
1062
  log(`systemd user unit '${SERVICE_NAME}' installed and started.`);
862
1063
  log(`Unit file: ${getUnitPath()}`);
@@ -878,6 +1079,7 @@ daemon
878
1079
  }
879
1080
  }
880
1081
  await refreshClaudeHooks();
1082
+ await refreshKiroHooks();
881
1083
  // Install Codex + OpenCode hooks for parity with the macOS/Windows paths.
882
1084
  try {
883
1085
  const { installCodexHooksIfNeeded } = await import('@agentdeck/hooks');
@@ -909,7 +1111,7 @@ daemon
909
1111
  log('LaunchAgent is macOS-only');
910
1112
  process.exit(1);
911
1113
  }
912
- const plist = buildPlist();
1114
+ const plist = buildPlist(postureArgs);
913
1115
  writeFileSync(PLIST_PATH, plist, 'utf-8');
914
1116
  log(`Wrote ${PLIST_PATH}`);
915
1117
  try {
@@ -919,6 +1121,7 @@ daemon
919
1121
  execSync(`launchctl load "${PLIST_PATH}"`);
920
1122
  log('LaunchAgent loaded. Daemon will auto-start on login.');
921
1123
  await refreshClaudeHooks();
1124
+ await refreshKiroHooks();
922
1125
  // Install Codex lifecycle hooks parallel to the LaunchAgent install
923
1126
  // so the daemon hub gets codex_* events as soon as Codex CLI runs.
924
1127
  try {
@@ -1363,6 +1566,7 @@ program
1363
1566
  }
1364
1567
  }
1365
1568
  getOrCreateToken();
1569
+ await warnIfLoopbackPosture(port);
1366
1570
  const url = getWsUrl(port);
1367
1571
  log(`\nPairing URL:\n ${url}\n`);
1368
1572
  try {
@@ -1394,6 +1598,9 @@ program
1394
1598
  ? opts.adopt
1395
1599
  : (typeof opts.adopt === 'string' ? [opts.adopt] : []);
1396
1600
  const daemon = `http://127.0.0.1:${port}`;
1601
+ // A pairing window against a loopback-only daemon can never be redeemed:
1602
+ // the peer it is meant for cannot open a socket to this host at all.
1603
+ await warnIfLoopbackPosture(port);
1397
1604
  let opened;
1398
1605
  try {
1399
1606
  const res = await fetch(`${daemon}/pair/open`, {
@@ -1520,12 +1727,29 @@ program
1520
1727
  }
1521
1728
  });
1522
1729
  program
1523
- .command('diag')
1524
- .description('Generate diagnostic dump')
1730
+ .command('diag [target]')
1731
+ .description('Generate a diagnostic dump, or a privacy-safe agent diagnostic')
1525
1732
  .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
1526
1733
  .option('-a, --analyze', 'Run AI analysis on the dump')
1527
1734
  .option('-t, --tail <lines>', 'Number of journal entries', '200')
1528
- .action(async (opts) => {
1735
+ .option('--json', 'Print target diagnostics as machine-readable JSON')
1736
+ .action(async (target, opts) => {
1737
+ if (target) {
1738
+ if (target !== 'kiro') {
1739
+ log(`Unknown diagnostic target: ${target}. Supported target: kiro`);
1740
+ process.exitCode = 1;
1741
+ return;
1742
+ }
1743
+ if (opts.analyze) {
1744
+ log('`--analyze` is only available for the general daemon diagnostic dump.');
1745
+ process.exitCode = 1;
1746
+ return;
1747
+ }
1748
+ const { collectKiroDiagnosticReport, formatKiroDiagnosticReport } = await import('./kiro-diagnostics.js');
1749
+ const report = await collectKiroDiagnosticReport();
1750
+ process.stdout.write(`${opts.json ? JSON.stringify(report, null, 2) : formatKiroDiagnosticReport(report)}\n`);
1751
+ return;
1752
+ }
1529
1753
  const { readDaemonInfo, findDaemonPort } = await import('./session-registry.js');
1530
1754
  const info = readDaemonInfo();
1531
1755
  const port = info?.httpPort ?? info?.port ?? findDaemonPort() ?? parseInt(opts.port, 10);
@@ -2007,12 +2231,24 @@ timebox
2007
2231
  const pixoo = program.command('pixoo').description('Manage Pixoo64 LED matrix devices');
2008
2232
  pixoo
2009
2233
  .command('scan')
2010
- .description('Discover Pixoo devices on LAN')
2011
- .action(async () => {
2012
- const { discoverDevices, getDeviceConfig } = await import('./pixoo/pixoo-client.js');
2234
+ .description('Discover Pixoo devices on LAN (Divoom cloud lookup, then a local /24 sweep)')
2235
+ .option('--no-cloud', 'Skip the Divoom cloud lookup (app.divoom-gz.com) and sweep the local subnet only')
2236
+ .action(async (opts) => {
2237
+ const { getDeviceConfig } = await import('./pixoo/pixoo-client.js');
2238
+ const { discoverPixoo } = await import('./pixoo/pixoo-discover.js');
2013
2239
  const { loadPixooDevices, savePixooDevices } = await import('./pixoo/pixoo-settings.js');
2014
- log('Scanning for Pixoo devices...');
2015
- const found = await discoverDevices();
2240
+ // Say what this actually does before it does it. Auto-discovery is off by
2241
+ // default precisely because these two steps are not free on a shared
2242
+ // network; running them from an explicit command is fine, running them
2243
+ // unannounced is not.
2244
+ if (opts.cloud === false) {
2245
+ log('Scanning the local subnet for Pixoo devices (cloud lookup skipped)...');
2246
+ }
2247
+ else {
2248
+ log('Scanning for Pixoo devices — querying the Divoom cloud (app.divoom-gz.com), then sweeping the local /24 if needed.');
2249
+ log('Use --no-cloud to keep the scan on your own network.');
2250
+ }
2251
+ const found = await discoverPixoo({ cloud: opts.cloud !== false });
2016
2252
  if (found.length === 0) {
2017
2253
  log('No devices found.');
2018
2254
  return;
@@ -2081,7 +2317,12 @@ pixoo
2081
2317
  const { loadPixooDevices } = await import('./pixoo/pixoo-settings.js');
2082
2318
  const devices = loadPixooDevices();
2083
2319
  if (devices.length === 0) {
2084
- log('No Pixoo devices configured. Run `agentdeck pixoo scan`.');
2320
+ // Says why nothing appeared on its own. The daemon no longer sweeps the
2321
+ // LAN unprompted, so "I plugged it in and nothing happened" needs an
2322
+ // answer here — this is where a user looks when the panel is missing.
2323
+ log('No Pixoo devices configured. Run `agentdeck pixoo scan` (add --no-cloud to keep the lookup on your own network).');
2324
+ log('Auto-discovery is off by default: the daemon does not sweep the LAN or call the Divoom cloud on its own.');
2325
+ log('Set "pixooAutoDiscover": true in ~/.agentdeck/settings.json to opt back in.');
2085
2326
  return;
2086
2327
  }
2087
2328
  log(`${devices.length} device(s):`);
@@ -2472,6 +2713,49 @@ apme
2472
2713
  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}`);
2473
2714
  }
2474
2715
  });
2716
+ apme
2717
+ .command('stop-health')
2718
+ .description('Stop-hook delivery rate — how turns actually got closed')
2719
+ .option('--since <window>', 'Lookback window, e.g. 6h / 3d / 2w (default 7d)', '7d')
2720
+ .option('--agent <type>', 'Restrict to one agent type (claude-code, codex-cli, …)')
2721
+ .action(async (opts) => {
2722
+ const { initApme } = await import('./apme/index.js');
2723
+ const apme = await initApme();
2724
+ if (!apme) {
2725
+ log('APME not available');
2726
+ process.exit(1);
2727
+ }
2728
+ const windowMs = parseLookbackWindow(opts.since);
2729
+ if (windowMs == null) {
2730
+ log(`Unrecognized --since value: ${opts.since}`);
2731
+ process.exit(1);
2732
+ }
2733
+ const sinceMs = Date.now() - windowMs;
2734
+ const rows = apme.store.stopDelivery({ sinceMs, agentType: opts.agent });
2735
+ if (rows.length === 0) {
2736
+ log(`No turns started in the last ${opts.since}.`);
2737
+ return;
2738
+ }
2739
+ log(`\n Turns started in the last ${opts.since} — how each one's end was learned`);
2740
+ 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`);
2741
+ 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)}`);
2742
+ for (const r of rows) {
2743
+ // Which buckets the ratio may read is defined once, in shared — see
2744
+ // `stopDeliveryLoss`. Restating it here is how the instrument would come
2745
+ // to measure something other than what its legend claims.
2746
+ const { adjudicated, ratio } = stopDeliveryLoss(r);
2747
+ const rate = ratio != null ? `${(ratio * 100).toFixed(0)}% of ${adjudicated}` : '—';
2748
+ 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}`);
2749
+ }
2750
+ log('');
2751
+ log(' Synth = Stop hook dropped, watchdog recovered it from the transcript');
2752
+ log(' NoStop = Stop hook dropped and nothing recovered it (closed by the next prompt)');
2753
+ log(' Esc = user cancelled the turn — Claude Code owes no Stop, so not a loss');
2754
+ log(' Abort = usage limit / auth / API error ended the turn — no Stop is fired for those');
2755
+ log(' Folded = a second prompt arrived before this one ran; one turn served both');
2756
+ log(' ? = closed before end_source existed — signal unknown, never guessed');
2757
+ log('');
2758
+ });
2475
2759
  apme
2476
2760
  .command('judge')
2477
2761
  .description('Run evaluation on unevaluated runs')