@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
@@ -28,7 +28,7 @@ import { createPushChannelHandler } from './session-push-channel.js';
28
28
  import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, advanceAskUserQuestionOverlay, getAskUserQuestionAnswers, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
29
29
  import { askEchoMatches, askGateDecision, askPressVerdict } from './ask-gate.js';
30
30
  import { registerPending, resolvePending, resolvePendingWithReason, sweepStalePending, drainAllPending, isPendingRequest, } from './permission-resolver.js';
31
- import { shouldHoldPreToolUse, beginAskGate, gateReleased, buildGateQuestion, buildAskAnswerReason, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, } from './observed-steering.js';
31
+ import { shouldHoldPreToolUse, beginAskGate, gateReleased, buildGateQuestion, buildAskAnswerReason, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirectiveForStop, clearOnUserPrompt, clearSession as clearSteeringSession, } from './observed-steering.js';
32
32
  import { notePermissionPromptShown, noteToolEnd, steeringSnapshot, } from './observed-steering.js';
33
33
  import { resolveSessionIdPrefix } from './session-id-resolve.js';
34
34
  import { injectObservedSelection, injectObservedText } from './observed-inject.js';
@@ -72,8 +72,11 @@ const ASK_GATE_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGE
72
72
  const APME_ABANDONED_RUN_STALE_SEC = Math.max(600, Number(process.env.AGENTDECK_APME_ABANDON_SEC) || 7200);
73
73
  import { VoiceManager } from './voice.js';
74
74
  import { VoiceAssistantManager } from './voice-assistant.js';
75
- import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, waitForPortBindable, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
76
- import { fetchUsageFromApi, hasOAuthToken, resetConsecutiveFailures } from './usage-api.js';
75
+ import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, waitForPortBindable, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
76
+ import { isForeignDaemon } from './daemon-takeover.js';
77
+ import { loadDaemonSettings } from './daemon-settings.js';
78
+ import { resolveDaemonPort, describeDaemonPortSource, PREFERRED_PORT_RECLAIM_MS, } from './daemon-port.js';
79
+ import { fetchUsageFromApi, resetConsecutiveFailures } from './usage-api.js';
77
80
  import { getOrCreateToken, isLocalConnection, validateToken } from './auth.js';
78
81
  import { buildPublicHealth, gateHttpRequest, isAuthorizedHttpRequest } from './http-auth-gate.js';
79
82
  import { closePairingWindow, getPairingWindowStatus, openPairingWindow, pairingWindowOpen, redeemPairingCode, } from './pairing-window.js';
@@ -85,16 +88,20 @@ import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
85
88
  import { enableDebugLog, debug, debugThrottled } from './logger.js';
86
89
  import { CodexOtelTracker, CODEX_OTEL_TRACES_PATH, spanNameSummary } from './codex-otel.js';
87
90
  import { HookCodexSessions } from './hook-codex-sessions.js';
91
+ import { ObservedTurnWatchdogs } from './observed-turn-watchdogs.js';
88
92
  import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
89
93
  import { FallbackTaskTimeline } from './fallback-task-timeline.js';
90
94
  import { handleApmeRequest } from './apme/http.js';
91
95
  import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
92
96
  import { transcriptTimelineForSession, lastAssistantTextFromTranscript, lastAssistantTextForSession, } from './session-transcript-timeline.js';
97
+ import { kiroTimelineForSession } from './kiro-transcript-timeline.js';
98
+ import { KiroTimelineFeed } from './kiro-timeline-feed.js';
93
99
  import { DeviceVoiceReplyRouter, speakableReply, spokenDigest, pcmFromWav, } from './device-voice-reply.js';
94
100
  import { rawSessionId } from '@agentdeck/shared';
95
101
  import { codexTurnOutcomeFromRollout, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
96
102
  import { callFoundationModelsHelper } from './foundation-models-helper.js';
97
103
  import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
104
+ import { bindHostFor, daemonModuleConfigs, describeDaemonPosture, resolveDaemonPosture, } from './network-posture.js';
98
105
  import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendAuthProvisionToAll, sendWifiProvision, sendWifiProvisionToAll, handleESP32Wake, getESP32Ports, getSerialConnectionStatus, getSerialLastError, getSerialReachableBoards } from './esp32-serial.js';
99
106
  import { loadWifiConfig } from './wifi-config.js';
100
107
  import { getConnectedAdbDevices, hasAdb, getAdbDeviceCount } from './adb-reverse.js';
@@ -194,16 +201,50 @@ function saveStagedFw() {
194
201
  log(`[agentdeck] staged-fw persist failed: ${err instanceof Error ? err.message : String(err)}`);
195
202
  }
196
203
  }
204
+ /** Resolve a stage target (canonical board, registry key, or IP) to the board
205
+ * string the advert is keyed on.
206
+ *
207
+ * This map is read back by `stagedFwAdvert(device_info.board)`, so anything
208
+ * else stored as the key is an advert that can never fire. Staging with an IP
209
+ * is not a misuse to reject — it is the documented way to name one unit when
210
+ * two share a `board` string (see esp32-ota's ambiguity error) — so normalize
211
+ * it here instead. Consequence worth stating: the feed pull carries only a
212
+ * board, so a stage aimed at one unit adverts to every unit of that board.
213
+ * They run the same image, so that is correct rather than merely tolerable.
214
+ *
215
+ * Unlike `findWifiOtaTarget` this must NOT require an open socket: a
216
+ * wake-sync-sleep board holds none by design, which is the entire reason
217
+ * staging exists. An unrecognized target is kept verbatim — staging for a
218
+ * board this daemon has not met yet is legitimate. */
219
+ function resolveStagedFwBoard(target) {
220
+ for (const [key, device] of wifiEsp32Devices) {
221
+ if (key === target || device.board === target || device.ip === target)
222
+ return device.board;
223
+ }
224
+ // A pull-sync client ages out of the WS roster between wakes; the feed
225
+ // tracker keeps its IP→board memory precisely for that gap.
226
+ const puller = feedPulls.clients().find((c) => c.client === target || c.board === target);
227
+ return puller?.board ?? target;
228
+ }
197
229
  /** Stage a build for a board. Reads the file once to fingerprint it; the file
198
230
  * itself is re-read at device download time (a rebuild at the same path is
199
- * re-fingerprinted by re-staging). */
200
- function stageEsp32Fw(board, firmwarePath) {
231
+ * re-fingerprinted by re-staging).
232
+ *
233
+ * `pullSeen` reports whether this daemon has actually observed a feed pull
234
+ * from the board, because "staged" proves neither transfer nor install and a
235
+ * bare success reads as if it did. Only pull-sync firmware fetches a staged
236
+ * image; a board that holds a live WS never asks, so its stage sits forever
237
+ * while the CLI says it is handled. It is daemon-lifetime state, so `false`
238
+ * means "not seen since this daemon started" — not "cannot pull". */
239
+ function stageEsp32Fw(target, firmwarePath) {
240
+ const board = resolveStagedFwBoard(target);
201
241
  const firmware = readFileSync(firmwarePath);
202
242
  const md5 = createHash('md5').update(firmware).digest('hex');
203
243
  stagedFwByBoard.set(board, { firmwarePath, md5, size: firmware.length, stagedAt: Date.now() });
204
244
  saveStagedFw();
205
- log(`[agentdeck] staged firmware for ${board}: ${firmware.length} bytes md5=${md5} — installs on its next feed pull`);
206
- return { board, bytes: firmware.length, md5 };
245
+ const pullSeen = feedPulls.clients().some((c) => c.board === board);
246
+ log(`[agentdeck] staged firmware for ${board}: ${firmware.length} bytes md5=${md5} — ${pullSeen ? 'installs on its next feed pull' : 'NO feed pull seen from this board since daemon start'}`);
247
+ return { board, bytes: firmware.length, md5, pullSeen };
207
248
  }
208
249
  /** The staged advert for a feed response, re-validated against the file on
209
250
  * disk so a rebuilt-in-place binary never adverts a stale md5. */
@@ -483,6 +524,11 @@ export const __wifiOtaTestApi = {
483
524
  unregisterWifiEsp32Socket,
484
525
  handleEsp32OtaReply,
485
526
  performWifiEsp32Ota,
527
+ resolveStagedFwBoard,
528
+ stageEsp32Fw,
529
+ stagedFwAdvert,
530
+ feedPulls,
531
+ clearStagedFwForTest: () => { stagedFwByBoard.clear(); },
486
532
  };
487
533
  async function performWifiEsp32Ota(core, target, firmwarePath) {
488
534
  const { key, device } = findWifiOtaTarget(target);
@@ -580,30 +626,6 @@ async function performWifiEsp32Ota(core, target, firmwarePath) {
580
626
  throw err;
581
627
  }
582
628
  }
583
- function loadDaemonSettings() {
584
- // Newest settings.json across candidate data dirs — mirrors the
585
- // daemon.json/sessions.json cross-dir discovery (and honors the
586
- // AGENTDECK_DATA_DIR test override, which the old hardcoded
587
- // ~/.agentdeck path ignored). The App Store sandbox container is
588
- // intentionally NOT a candidate (TCC hang risk — see
589
- // getCandidateDataDirs), so settings written by the sandboxed Swift
590
- // app stay invisible here; that coexistence limit is documented in
591
- // docs/appstore-feature-matrix.md.
592
- let best = null;
593
- for (const dir of getCandidateDataDirs()) {
594
- try {
595
- const path = join(dir, 'settings.json');
596
- const mtime = statSync(path).mtimeMs;
597
- if (best && best.mtime >= mtime)
598
- continue;
599
- best = { mtime, parsed: JSON.parse(readFileSync(path, 'utf-8')) };
600
- }
601
- catch {
602
- // Missing or unreadable — skip this candidate.
603
- }
604
- }
605
- return best?.parsed ?? {};
606
- }
607
629
  function latestTimelinePath() {
608
630
  let best = null;
609
631
  for (const dir of getCandidateDataDirs()) {
@@ -696,13 +718,15 @@ async function fetchUsageViaWs(siblings) {
696
718
  export async function fetchUsageRelayed(selfPort) {
697
719
  const sessions = listActiveSessions();
698
720
  const siblings = sessions.filter(s => s.port !== selfPort && s.agentType !== 'daemon');
721
+ // A sibling's reading is a real successful one, not a failure fallback, so it
722
+ // is `fresh` — its age contract is the 5-minute gate inside fetchUsageViaHttp.
699
723
  if (siblings.length > 0) {
700
724
  const httpResult = await fetchUsageViaHttp(siblings);
701
725
  if (httpResult)
702
- return httpResult.usage;
726
+ return { data: httpResult.usage, fresh: true };
703
727
  const wsResult = await fetchUsageViaWs(siblings);
704
728
  if (wsResult)
705
- return wsResult;
729
+ return { data: wsResult, fresh: true };
706
730
  // Relay is only an optimization to avoid multiple API pollers. A PTY session
707
731
  // bridge only refreshes usage when it has *direct* inbound WS clients, but
708
732
  // dashboards/Stream Deck connect to the daemon (sole entry point) — so a
@@ -767,15 +791,19 @@ export function classifyObservedHookEvent(eventName, mapped) {
767
791
  if (eventName === 'codex_subagent_start' || eventName === 'codex_subagent_stop') {
768
792
  return { boundary: eventName, agentType: 'codex-cli' };
769
793
  }
770
- const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
794
+ const prefixed = /^(codex|opencode|antigravity|kiro|kiro_ide)_(agent_spawn|session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
771
795
  .exec(eventName);
772
796
  if (!prefixed)
773
797
  return { boundary: mapped, agentType: 'claude-code' };
774
798
  return {
775
- boundary: prefixed[2] === 'turn_complete' ? 'stop' : prefixed[2],
799
+ boundary: prefixed[2] === 'turn_complete' ? 'stop'
800
+ : prefixed[2] === 'agent_spawn' ? 'session_start'
801
+ : prefixed[2],
776
802
  agentType: prefixed[1] === 'codex' ? 'codex-cli'
777
803
  : prefixed[1] === 'opencode' ? 'opencode'
778
- : 'antigravity',
804
+ : prefixed[1] === 'antigravity' ? 'antigravity'
805
+ : prefixed[1] === 'kiro_ide' ? 'kiro-ide'
806
+ : 'kiro-cli',
779
807
  };
780
808
  }
781
809
  function buildNodeModuleHealth(startedModules) {
@@ -901,6 +929,10 @@ export async function startDaemon(opts) {
901
929
  // CLI --wake-word flag OR settings.json wakeWord: true
902
930
  const settings = loadDaemonSettings();
903
931
  const wakeWordEnabled = opts.wakeWord || settings.wakeWord === true;
932
+ // Network posture (bind address + which device modules may run). Resolved once
933
+ // here so the bind, the module set, and the startup log cannot disagree about
934
+ // what this daemon is allowed to do.
935
+ const posture = resolveDaemonPosture({ local: opts.local, loopback: opts.loopback });
904
936
  // ===== Singleton guard + port allocation =====
905
937
  // 1. Check daemon.json and sessions.json for existing daemon
906
938
  const existingInfo = readDaemonInfo();
@@ -948,32 +980,46 @@ export async function startDaemon(opts) {
948
980
  removeDaemonSession(existingSession);
949
981
  }
950
982
  }
951
- // 2. Determine port — try default first, fallback if occupied by non-daemon
952
- const requestedPort = opts.port ?? DAEMON_DEFAULT_PORT;
983
+ // 2. Determine the preferred port — the one this daemon intends to serve.
984
+ // `opts.port` is already resolved by the CLI (flag › AGENTDECK_DAEMON_PORT ›
985
+ // settings.json `daemonPort` › 9120); resolving again here covers the
986
+ // library entry point, and both paths read the same sources.
987
+ const preferred = opts.port !== undefined
988
+ ? { port: opts.port, source: opts.portSource ?? 'flag' }
989
+ : resolveDaemonPort();
990
+ const requestedPort = preferred.port;
953
991
  let port = requestedPort;
954
- // If using default port, check if it's available
955
- if (requestedPort === DAEMON_DEFAULT_PORT) {
956
- const health = await probeDaemonHealth(requestedPort);
957
- if (health) {
958
- if (health.mode === 'daemon') {
959
- if (health.isSwift) {
960
- log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
961
- await requestDaemonShutdown(requestedPort);
962
- await waitForDaemonExit(requestedPort);
963
- await waitForPortBindable(requestedPort);
964
- }
965
- else {
966
- // Daemon alive but not in our registry — race condition or stale state
967
- log(`[agentdeck] Daemon already running on port ${requestedPort} (detected via /health).`);
968
- process.exit(0);
969
- }
992
+ // Probe the preferred port before binding it. This used to run only for the
993
+ // built-in 9120, which made every other preferred port skip the Swift-daemon
994
+ // eviction and the occupied-port check — a persisted `daemonPort: 9200` would
995
+ // have been a second-class port on the same machine. The port a daemon
996
+ // intends to serve gets the same treatment wherever the intent came from.
997
+ const preferredOccupant = await probeDaemonHealth(requestedPort);
998
+ if (preferredOccupant) {
999
+ if (preferredOccupant.mode === 'daemon' && isForeignDaemon(preferredOccupant)) {
1000
+ // Another OS user's daemon. Neither evict it nor concede to it — this
1001
+ // user still gets a daemon, on a port of their own.
1002
+ log(`[agentdeck] Port ${requestedPort} is held by another user's daemon — leaving it alone and taking a free port.`);
1003
+ port = await findAvailablePort();
1004
+ }
1005
+ else if (preferredOccupant.mode === 'daemon') {
1006
+ if (preferredOccupant.isSwift) {
1007
+ log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
1008
+ await requestDaemonShutdown(requestedPort);
1009
+ await waitForDaemonExit(requestedPort);
1010
+ await waitForPortBindable(requestedPort);
970
1011
  }
971
1012
  else {
972
- // Port occupied by non-daemon (e.g. session bridge)find alternative
973
- log(`[agentdeck] Port ${requestedPort} occupied (${health.mode ?? 'unknown'}), finding alternative...`);
974
- port = await findAvailablePort();
1013
+ // Daemon alive but not in our registryrace condition or stale state
1014
+ log(`[agentdeck] Daemon already running on port ${requestedPort} (detected via /health).`);
1015
+ process.exit(0);
975
1016
  }
976
1017
  }
1018
+ else {
1019
+ // Port occupied by non-daemon (e.g. session bridge) — find alternative
1020
+ log(`[agentdeck] Port ${requestedPort} occupied (${preferredOccupant.mode ?? 'unknown'}), finding alternative...`);
1021
+ port = await findAvailablePort();
1022
+ }
977
1023
  }
978
1024
  // 3. Cross-implementation sweep of the daemon port window. File discovery
979
1025
  // has two blind spots that the checks above never cover: the App Store
@@ -985,6 +1031,14 @@ export async function startDaemon(opts) {
985
1031
  // a live Node daemon wins and we exit.
986
1032
  const strayDaemons = await scanDaemonPortWindow(new Set([requestedPort]));
987
1033
  for (const stray of strayDaemons) {
1034
+ if (isForeignDaemon(stray.health)) {
1035
+ // Not a stray — someone else's daemon, doing its job for its own user.
1036
+ // This sweep runs on every start, so without this check one user
1037
+ // starting a CLI daemon shuts down every colleague's macOS app on the
1038
+ // box (docs/ENTERPRISE-ROADMAP.md §1.3).
1039
+ log(`[agentdeck] Port ${stray.port} is held by another user's daemon — not touching it.`);
1040
+ continue;
1041
+ }
988
1042
  if (stray.health.isSwift) {
989
1043
  log(`[agentdeck] Swift daemon detected on fallback port ${stray.port}. Requesting shutdown to take over...`);
990
1044
  await requestDaemonShutdown(stray.port);
@@ -1017,6 +1071,53 @@ export async function startDaemon(opts) {
1017
1071
  * anything arrived SINCE this turn opened.
1018
1072
  */
1019
1073
  const hookSessionLastSeenAt = new Map();
1074
+ /**
1075
+ * Missed-Stop recovery for observed Claude sessions (plain `claude` in the
1076
+ * user's own terminal — no PTY, hooks only).
1077
+ *
1078
+ * The Stop hook is the sole authority that closes a Claude turn, and its
1079
+ * curl is fire-and-forget with a tight budget, so it drops. `agentdeck
1080
+ * claude` has recovered from that since 1.0.20; observed sessions had
1081
+ * nothing, which is the majority of real usage. Recovery re-enters this
1082
+ * daemon's own `/hooks/Stop` over loopback rather than reimplementing that
1083
+ * branch — see the class doc for why the same path matters.
1084
+ *
1085
+ * Declared before the HTTP server (hooks arrive the moment the port binds)
1086
+ * and, like every other route, trusted purely because it is loopback: the
1087
+ * auth gate treats same-machine as fully authorized, so no token is
1088
+ * involved and none is minted.
1089
+ */
1090
+ const observedStopWatchdogs = new ObservedTurnWatchdogs({
1091
+ onMissedStop: ({ sessionId, transcriptPath, reason, cwd, projectName }) => {
1092
+ const url = `http://127.0.0.1:${port}/hooks/Stop`;
1093
+ const controller = new AbortController();
1094
+ // Bounded like every other peer await, loopback or not: a daemon
1095
+ // mid-shutdown accepts the socket and never answers, and an unbounded
1096
+ // wait there would pin the recovery forever.
1097
+ const timer = setTimeout(() => controller.abort(), 2_000);
1098
+ void fetch(url, {
1099
+ method: 'POST',
1100
+ headers: { 'Content-Type': 'application/json' },
1101
+ signal: controller.signal,
1102
+ body: JSON.stringify({
1103
+ session_id: sessionId,
1104
+ transcript_path: transcriptPath,
1105
+ // Tags the APME turn `end_source='synthetic_stop'`, which is the
1106
+ // standing measurement of how often the real hook drops — unless
1107
+ // the user cancelled, in which case no Stop was ever due and the
1108
+ // turn must not be charged to that rate.
1109
+ synthetic_stop: true,
1110
+ ...(reason === 'interrupted' ? { interrupted: true } : {}),
1111
+ // Same for a client abort (usage limit, auth, API error): Claude
1112
+ // Code fires no Stop for it, so nothing was lost to recover.
1113
+ ...(reason === 'aborted' ? { aborted: true } : {}),
1114
+ ...(cwd ? { cwd } : {}),
1115
+ ...(projectName ? { project_name: projectName } : {}),
1116
+ }),
1117
+ }).catch(() => { })
1118
+ .finally(() => clearTimeout(timer));
1119
+ },
1120
+ });
1020
1121
  // Codex OTel span state. Declared before the HTTP server because the
1021
1122
  // `/otel/v1/traces` handler closes over it — Codex's exporter can POST before
1022
1123
  // the rest of daemon startup finishes.
@@ -1030,6 +1131,9 @@ export async function startDaemon(opts) {
1030
1131
  // the rest of startup finishes. A later `const` would leave that window
1031
1132
  // throwing on the temporal dead zone.
1032
1133
  const passiveSessionObserver = new PassiveSessionObserver();
1134
+ /** Produces live timeline rows for observed Kiro sessions — see the class
1135
+ * doc for why Kiro needs a producer when hook agents do not. */
1136
+ const kiroTimelineFeed = new KiroTimelineFeed();
1033
1137
  /** Observed Claude sessions whose AskUserQuestion PreToolUse is held open for
1034
1138
  * a device answer, keyed by the bare Claude session UUID. Its presence is
1035
1139
  * what makes the session's options pressable (`liveAnswerable`) and what
@@ -1255,6 +1359,11 @@ export async function startDaemon(opts) {
1255
1359
  // Workers require this flag before remote-attaching; the Swift daemon
1256
1360
  // does not advertise it and is therefore never selected as a remote hub.
1257
1361
  sameSocketControl: true,
1362
+ // Network posture, so `agentdeck daemon restart` can carry the running
1363
+ // daemon's posture across the restart instead of silently downgrading
1364
+ // an enterprise install back to "advertise everything". Local-only
1365
+ // payload; it is diagnostic, not a credential.
1366
+ posture,
1258
1367
  modules: moduleHealthProvider(),
1259
1368
  apme: apme
1260
1369
  ? {
@@ -1887,13 +1996,22 @@ export async function startDaemon(opts) {
1887
1996
  const earlyHookProject = (typeof json.project_name === 'string' && json.project_name)
1888
1997
  ? json.project_name
1889
1998
  : (earlyHookCwd ? earlyHookCwd.split('/').filter(Boolean).pop() : undefined);
1890
- const childHook = subagentTimeline?.handle({
1999
+ const childResult = subagentTimeline?.handle({
1891
2000
  eventName,
1892
2001
  payload: json,
1893
2002
  sessionId: earlyHookSid,
1894
2003
  agentType: hookAgentType,
1895
2004
  projectName: earlyHookProject,
1896
- }).childOnly === true;
2005
+ });
2006
+ // A child starting or stopping is the ONLY thing that moves a parent's
2007
+ // `subagents` block, and it moves nothing else — the parent's own state
2008
+ // is untouched by design. Without this rebroadcast the census would sit
2009
+ // stale until some unrelated event happened to refresh the list, which
2010
+ // for an idle parent waiting on eight children is never.
2011
+ if (childResult?.censusChangedFor) {
2012
+ core.broadcastSessionsList().catch(() => { });
2013
+ }
2014
+ const childHook = childResult?.childOnly === true;
1897
2015
  if (childHook) {
1898
2016
  // Child activity is observation-only. Never let child PreToolUse
1899
2017
  // reach the parent's approval, STOP, state, or APME paths.
@@ -2044,6 +2162,15 @@ export async function startDaemon(opts) {
2044
2162
  // (even one persisted before the restart) must not be force-closed.
2045
2163
  hookSessionsSeen.add(hookSid);
2046
2164
  hookSessionLastSeenAt.set(hookSid, Date.now());
2165
+ // Missed-Stop recovery for observed Claude sessions. Fed the raw
2166
+ // PascalCase event name — the watchdog's vocabulary is Claude's, and
2167
+ // its transcript probe reads Claude's JSONL, so only Claude sessions
2168
+ // are admitted (Codex/OpenCode carry their own turn-end signals).
2169
+ // A synthetic Stop re-enters here as a normal hook and disarms the
2170
+ // watchdog exactly like the real one would.
2171
+ if (hookAgentType === 'claude-code') {
2172
+ observedStopWatchdogs.noteHookEvent(hookSid, eventName, json);
2173
+ }
2047
2174
  // Claude hooks carry `cwd` (the worktree dir), not project_name/path —
2048
2175
  // capture it so APME runs are attributable to a specific worktree.
2049
2176
  const hookCwd = (typeof json.cwd === 'string' ? json.cwd
@@ -2205,7 +2332,13 @@ export async function startDaemon(opts) {
2205
2332
  }
2206
2333
  const turnOpen = Boolean(lastStart && lastStart.ts > lastCompletionTs);
2207
2334
  const tp = typeof json.transcript_path === 'string' ? json.transcript_path : '';
2208
- const inlineResponse = [json.last_assistant_message, json.response, json.output, json.result]
2335
+ const inlineResponse = [
2336
+ json.last_assistant_message,
2337
+ json.assistant_response,
2338
+ json.response,
2339
+ json.output,
2340
+ json.result,
2341
+ ]
2209
2342
  .find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
2210
2343
  // Codex's stop payload rarely carries the text inline; its rollout
2211
2344
  // JSONL (agent_message / task_complete records) is the
@@ -2242,6 +2375,18 @@ export async function startDaemon(opts) {
2242
2375
  // response, so a duplicate stop is a no-op instead of a clobber.
2243
2376
  apme.collector.setLastClosedTurnResponse(hookSid, responseText);
2244
2377
  }
2378
+ // Close the APME turn on the Stop that ended it, tagged with which
2379
+ // Stop that was. Runs AFTER the response capture above (the
2380
+ // trajectory event wants the still-active turn) and is a no-op when
2381
+ // no turn is open, so a duplicate/late stop cannot re-attribute an
2382
+ // already-closed turn. `turnOpen` above is a TIMELINE predicate —
2383
+ // it says nothing about the collector's turn, which is why this is
2384
+ // unconditional rather than nested under it.
2385
+ apme.collector.noteTurnStop(hookSid, {
2386
+ synthetic: json.synthetic_stop === true,
2387
+ interrupted: json.interrupted === true,
2388
+ aborted: json.aborted === true,
2389
+ });
2245
2390
  }
2246
2391
  if (turnOpen && lastStart) {
2247
2392
  const now = Date.now();
@@ -2442,7 +2587,13 @@ export async function startDaemon(opts) {
2442
2587
  // by blocking the stop with the directive as the continuation
2443
2588
  // reason. Empty queue (the overwhelmingly common case) → empty body,
2444
2589
  // Claude ends the turn normally — no stop_hook_active loop possible.
2445
- const directive = claudeSid ? takeDirective(claudeSid) : undefined;
2590
+ //
2591
+ // `takeDirectiveForStop` refuses a synthetic Stop (missed-Stop
2592
+ // watchdog): nothing is listening on that response, so draining the
2593
+ // queue there would silently eat the user's queued follow-up.
2594
+ // `clearStop` still runs either way — the transcript proved the turn
2595
+ // ended, so a pending soft-stop is moot however we learned it.
2596
+ const directive = claudeSid ? takeDirectiveForStop(claudeSid, json) : undefined;
2446
2597
  if (claudeSid)
2447
2598
  clearStop(claudeSid); // turn ended — a pending soft-stop is moot
2448
2599
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2680,8 +2831,9 @@ export async function startDaemon(opts) {
2680
2831
  // pull-sync e-ink clients live on the LAN — and the security boundary is the
2681
2832
  // pairing token (http-auth-gate.ts default-denies unauthenticated LAN
2682
2833
  // requests; the WS server has always token-gated non-local peers). Users
2683
- // with no LAN devices can opt into a loopback-only bind (issue #145).
2684
- const bindHost = process.env.AGENTDECK_LOOPBACK_ONLY === '1' ? '127.0.0.1' : '0.0.0.0';
2834
+ // with no LAN devices can opt into a loopback-only posture (issue #145),
2835
+ // which also silences everything the daemon emits (see network-posture.ts).
2836
+ const bindHost = bindHostFor(posture);
2685
2837
  await new Promise((resolve, reject) => {
2686
2838
  httpServer.on('error', (err) => {
2687
2839
  if (err.code === 'EADDRINUSE') {
@@ -2716,8 +2868,48 @@ export async function startDaemon(opts) {
2716
2868
  if (occupant?.mode === 'daemon') {
2717
2869
  log(`[agentdeck] Port ${requestedPort} reports a daemon but PID ${occupant.pid} is not a live distinct process; treating as stale and falling back.`);
2718
2870
  }
2871
+ // Nobody answered on the preferred port, yet it refuses to bind. That is
2872
+ // not a peer to yield to — it is the kernel still holding a port whose
2873
+ // owner has already gone: macOS keeps a NECP reservation for ~14s after
2874
+ // an `NWListener.cancel()` (measured 2026-08-06: bindable at ~17s, with
2875
+ // `lsof` showing zero sockets throughout), and a half-closed socket in
2876
+ // TIME_WAIT/LAST_ACK looks the same from here.
2877
+ //
2878
+ // Without this wait the daemon conceded instantly and moved to 9121 —
2879
+ // permanently, because nothing ever moved it back. Seconds after that
2880
+ // decision the canonical port was free and owned by nobody. Waiting is
2881
+ // the whole recovery: `waitForPortBindable` polls, so a kernel-held port
2882
+ // costs the ~14s it is actually held, and only a genuinely occupied port
2883
+ // pays the full budget before falling back.
2884
+ if (!occupant) {
2885
+ log(`[agentdeck] Port ${requestedPort} is held but nothing answers /health — usually the kernel still `
2886
+ + `releasing it after the previous owner exited. Waiting up to ${Math.round(PREFERRED_PORT_RECLAIM_MS / 1000)}s…`);
2887
+ if (await waitForPortBindable(requestedPort, PREFERRED_PORT_RECLAIM_MS)) {
2888
+ const bound = await new Promise((resolve) => {
2889
+ const onError = () => resolve(false);
2890
+ httpServer.once('error', onError);
2891
+ httpServer.listen(requestedPort, bindHost, () => {
2892
+ httpServer.removeListener('error', onError);
2893
+ resolve(true);
2894
+ });
2895
+ });
2896
+ if (bound) {
2897
+ log(`[agentdeck] Port ${requestedPort} came free — bound it instead of falling back.`);
2898
+ return;
2899
+ }
2900
+ // Lost it again between the probe bind and ours. Rare, and the
2901
+ // fallback below is exactly the right answer for it.
2902
+ log(`[agentdeck] Port ${requestedPort} was taken again before this daemon could bind it.`);
2903
+ }
2904
+ else {
2905
+ log(`[agentdeck] Port ${requestedPort} is still held after ${Math.round(PREFERRED_PORT_RECLAIM_MS / 1000)}s — `
2906
+ + `something outside AgentDeck is listening on it, or a sleeping device is holding a half-closed socket.`);
2907
+ }
2908
+ }
2719
2909
  port = await findAvailablePort();
2720
2910
  log(`[agentdeck] Port ${requestedPort} grabbed by ${occupant?.mode ?? 'a non-daemon'}, retrying on ${port}...`);
2911
+ log(`[agentdeck] This daemon prefers port ${requestedPort} (${describeDaemonPortSource(preferred.source)}). `
2912
+ + `Clients resolve the actual port from daemon.json; 'agentdeck daemon restart' aims at the preferred port again.`);
2721
2913
  await new Promise((resolve, reject) => {
2722
2914
  httpServer.on('error', (e) => reject(e));
2723
2915
  httpServer.listen(port, bindHost, () => resolve());
@@ -2727,11 +2919,11 @@ export async function startDaemon(opts) {
2727
2919
  throw err;
2728
2920
  }
2729
2921
  });
2730
- if (bindHost === '127.0.0.1') {
2731
- log(`[agentdeck] AGENTDECK_LOOPBACK_ONLY=1 daemon bound to 127.0.0.1:${port}; LAN devices (companion apps, ESP32/WiFi boards) cannot connect.`);
2732
- }
2733
- else {
2734
- log(`[agentdeck] Daemon listening on all interfaces (:${port}) LAN requests require the pairing token (~/.agentdeck/auth-token). Set AGENTDECK_LOOPBACK_ONLY=1 for a loopback-only bind.`);
2922
+ log(describeDaemonPosture(posture, port));
2923
+ if (preferred.source === 'settings' || preferred.source === 'env') {
2924
+ // A persisted or environmental port is invisible in the command line that
2925
+ // started this daemon, so the log is the only place it can be discovered.
2926
+ log(`[agentdeck] Preferred port ${requestedPort} comes from ${describeDaemonPortSource(preferred.source)}.`);
2735
2927
  }
2736
2928
  // Write daemon.json for client discovery (must be after successful bind).
2737
2929
  // Keep the original startedAt stable when the self-heal timer rewrites it.
@@ -2744,8 +2936,14 @@ export async function startDaemon(opts) {
2744
2936
  httpServer,
2745
2937
  isDaemon: true,
2746
2938
  });
2747
- subagentTimeline = new SubagentTimelineTracker((entry) => {
2748
- core.bridgeTimeline.addEntry(entry, { bypassSuppression: true });
2939
+ subagentTimeline = new SubagentTimelineTracker((entry, upsert) => {
2940
+ // A dispatch row is upserted as its burst grows (one row per fan-out, the
2941
+ // `task_start` folding pattern) — emitting a fresh row per child would
2942
+ // spend a 200-entry buffer shared by every session on one workflow.
2943
+ if (upsert)
2944
+ core.bridgeTimeline.upsertEntry(entry, { bypassSuppression: true });
2945
+ else
2946
+ core.bridgeTimeline.addEntry(entry, { bypassSuppression: true });
2749
2947
  });
2750
2948
  // Hooks resolve a fallback daemon port through daemon.json. Another process
2751
2949
  // can delete that file while this daemon remains healthy, which makes every
@@ -2828,6 +3026,11 @@ export async function startDaemon(opts) {
2828
3026
  const TURN_WATCHDOG_SILENCE_MS = 3 * 60_000;
2829
3027
  const turnWatchdog = setInterval(() => {
2830
3028
  const now = Date.now();
3029
+ // Drop per-session missed-Stop watchdogs whose session has gone cold.
3030
+ // Rides this existing tick rather than owning a timer: an observed
3031
+ // session that dies mid-turn never sends SessionEnd, so its watchdog
3032
+ // would otherwise poll for the life of the daemon.
3033
+ observedStopWatchdogs.sweep();
2831
3034
  const active = new Set();
2832
3035
  for (const [sid, at] of hookSessionLastSeenAt) {
2833
3036
  if (now - at <= TURN_WATCHDOG_SILENCE_MS)
@@ -2912,15 +3115,7 @@ export async function startDaemon(opts) {
2912
3115
  // Reset backoff from pre-sleep failures and fetch fresh usage after network stabilizes
2913
3116
  resetConsecutiveFailures();
2914
3117
  setTimeout(() => {
2915
- fetchUsageRelayed(port).then((usage) => {
2916
- if (usage)
2917
- core.updateApiUsage(usage);
2918
- else {
2919
- core.oauthConnected = hasOAuthToken();
2920
- if (core.cachedApiUsage)
2921
- core.apiUsageStale = true;
2922
- }
2923
- });
3118
+ fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
2924
3119
  }, 4000);
2925
3120
  });
2926
3121
  // Subscribe to sibling session bridges' timelines + modelCatalog relay
@@ -3015,6 +3210,19 @@ export async function startDaemon(opts) {
3015
3210
  // So split the event by half instead of by event: the ACCOUNT half (quota,
3016
3211
  // scoped caps, extra usage, subscriptions, Codex/Ollama, usageStale) comes
3017
3212
  // from the daemon's authoritative aggregate, the SESSION half from the relay.
3213
+ //
3214
+ // That split applies on the `else` branch ONLY. When the relay carries
3215
+ // Claude percentages its event goes out verbatim, so the account half —
3216
+ // `codexRateLimits` included — is the BRIDGE's. Usually identical: both
3217
+ // sides run the same plan-aware rollout read. They can differ only when no
3218
+ // plan-matching rollout exists at all, since the daemon additionally holds
3219
+ // a `codex app-server` reading that a session bridge structurally cannot
3220
+ // (no host processes there) — then successive events alternate between a
3221
+ // real Codex window and a voided one. Left as is deliberately: the only
3222
+ // targeted fix is to overwrite the block from `core.buildUsage()` here, and
3223
+ // that is the call which arms the throttled spawn, on the one path whose
3224
+ // whole history is about not clobbering the dashboard. Tracked as issue
3225
+ // #253, not just as this comment.
3018
3226
  if (hasClaudeData) {
3019
3227
  core.wsServer.broadcast(evt);
3020
3228
  }
@@ -3039,7 +3247,12 @@ export async function startDaemon(opts) {
3039
3247
  // driven exclusively by the Ulanzi Studio plugin (`ulanzi-plugin`) over WebSocket;
3040
3248
  // the daemon never opens it over HID. Its health is reported via the Ulanzi client
3041
3249
  // count in moduleHealthProvider below.
3042
- const startedModules = await initModules(deviceModules, { mdns: true, broadcast: true, adb: 'auto', serial: serialMode, pixoo: 'auto', timebox: 'auto', idotmatrix: 'auto' }, { port, authToken: core.authToken, projectName: 'AgentDeck', wsServer: core.wsServer });
3250
+ const startedModules = await initModules(deviceModules, daemonModuleConfigs(posture, serialMode), {
3251
+ port, authToken: core.authToken, projectName: 'AgentDeck', wsServer: core.wsServer,
3252
+ // AdbModule restricts `adb reverse` to USB transports under loopback —
3253
+ // a TCP/mDNS adb device would carry the tunnel over the LAN.
3254
+ loopbackOnly: posture.loopbackOnly,
3255
+ });
3043
3256
  moduleHealthProvider = () => {
3044
3257
  const modules = buildNodeModuleHealth(startedModules);
3045
3258
  const einkDevices = collectEinkDevices();
@@ -3293,7 +3506,24 @@ export async function startDaemon(opts) {
3293
3506
  // The observer scans in the background now (collect() returns the cache
3294
3507
  // immediately). When a scan lands fresh observations, push them out via
3295
3508
  // the debounced broadcast so clients don't wait for the next 10 s poll.
3296
- passiveSessionObserver.onRefreshed = () => core.maybeBroadcastSessionsList();
3509
+ passiveSessionObserver.onRefreshed = () => {
3510
+ // Kiro pushes nothing: no `kiro_*` hook has ever reached this daemon, so
3511
+ // without a producer here its session shows in the HUD while the timeline
3512
+ // beside it stays empty. #218 taught the per-session QUERY to read Kiro's
3513
+ // own transcript; the main timeline is a STREAM, and this is what feeds it.
3514
+ try {
3515
+ const kiroIds = passiveSessionObserver.collect([])
3516
+ .filter((s) => typeof s.agentType === 'string' && s.agentType.startsWith('kiro'))
3517
+ .map((s) => s.id);
3518
+ for (const entry of kiroTimelineFeed.pump(kiroIds)) {
3519
+ core.bridgeTimeline.addEntry(entry);
3520
+ }
3521
+ }
3522
+ catch (err) {
3523
+ debug('daemon', `kiro timeline feed failed: ${String(err)}`);
3524
+ }
3525
+ core.maybeBroadcastSessionsList();
3526
+ };
3297
3527
  // OTel turn boundaries arrive in milliseconds where the observer only re-reads
3298
3528
  // the rollout tail every scan interval, so a span that changed thread state is
3299
3529
  // worth a broadcast of its own.
@@ -3344,15 +3574,24 @@ export async function startDaemon(opts) {
3344
3574
  // Derive per-session elapsed seconds from startedAt so NTP-less devices
3345
3575
  // (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
3346
3576
  const now = Date.now();
3577
+ // Live child-agent census. Hooks key children by the BARE session uuid
3578
+ // while these rows are `observed:<agent>:<uuid>`, so normalize before the
3579
+ // lookup — the two id forms are the standing trap here.
3580
+ const subagentCensus = subagentTimeline?.summaries() ?? new Map();
3347
3581
  const enrichedSessions = [...sessions, ...observed, ...remote].map((s) => {
3348
3582
  // On-demand review badge (REVIEW tile verdict / REVIEWING state) —
3349
3583
  // applies to every session type, managed included.
3350
3584
  const review = reviewSnapshot(s.id);
3351
3585
  const withReview = Object.keys(review).length > 0 ? { ...s, ...review } : s;
3352
- if (withReview.elapsedSec != null || !withReview.startedAt)
3353
- return withReview;
3354
- const sec = Math.round((now - Date.parse(withReview.startedAt)) / 1000);
3355
- return Number.isFinite(sec) && sec >= 0 ? { ...withReview, elapsedSec: sec } : withReview;
3586
+ // Emitted whenever this session has EVER had a child, zeros included: a
3587
+ // field that disappears when the last child exits latches "8 running" on
3588
+ // every client that merges retain-on-absent.
3589
+ const census = subagentCensus.get(rawSessionId(withReview.id));
3590
+ const withCensus = census ? { ...withReview, subagents: census } : withReview;
3591
+ if (withCensus.elapsedSec != null || !withCensus.startedAt)
3592
+ return withCensus;
3593
+ const sec = Math.round((now - Date.parse(withCensus.startedAt)) / 1000);
3594
+ return Number.isFinite(sec) && sec >= 0 ? { ...withCensus, elapsedSec: sec } : withCensus;
3356
3595
  });
3357
3596
  // SSOT: inject iff Gateway is authenticated (gatewayConnected). Reachability
3358
3597
  // / adapter-liveness alone must not materialize a session — that kept a
@@ -3371,6 +3610,11 @@ export async function startDaemon(opts) {
3371
3610
  projectName: 'OpenClaw',
3372
3611
  modelName: gatewayModelName,
3373
3612
  controlMode: 'managed',
3613
+ // Read live from the adapter rather than cached alongside
3614
+ // `gatewaySessionState`: the two would drift, and a row that says
3615
+ // "awaiting_permission" while carrying no options is exactly the
3616
+ // dead end this fixes.
3617
+ approval: gatewayAdapter?.getPendingApproval() ?? null,
3374
3618
  });
3375
3619
  });
3376
3620
  // OpenClaw-specific APME session id. Distinct from `core.sessionId`
@@ -3432,6 +3676,14 @@ export async function startDaemon(opts) {
3432
3676
  gatewaySessionState = 'idle';
3433
3677
  else if (evt.event === 'permission_prompt')
3434
3678
  gatewaySessionState = 'awaiting_permission';
3679
+ // The virtual Gateway row carries the approval's question/options, so
3680
+ // a prompt appearing or clearing changes `sessions_list` — not just
3681
+ // the state event. Devices read their option cells from that list; a
3682
+ // state-only broadcast left them showing the previous prompt (or
3683
+ // none) until some unrelated event forced a refresh.
3684
+ if (evt.event === 'spinner_start' || evt.event === 'idle' || evt.event === 'permission_prompt') {
3685
+ core.broadcastSessionsList().catch(() => { });
3686
+ }
3435
3687
  // The OpenClaw adapter emits the real model via a model_info parser
3436
3688
  // event, but it only ever updated the display StateMachine — the APME
3437
3689
  // run was never told, so every openclaw run persisted model_id=NULL.
@@ -3487,6 +3739,14 @@ export async function startDaemon(opts) {
3487
3739
  core.bridgeTimeline.addEntry(enriched);
3488
3740
  if (enriched.type === 'tool_request')
3489
3741
  bridgeLogStream.trackToolRequest(enriched.raw);
3742
+ // Flip the matching `tool_request` row off `pending`. The resolution
3743
+ // has always been emitted as its own row, so the request row kept
3744
+ // `status: 'pending'` forever — an approval answered minutes ago
3745
+ // still read as outstanding in the timeline. `updateEntryStatus`
3746
+ // existed for exactly this and had no production caller.
3747
+ if (enriched.type === 'tool_resolved' && enriched.approvalId) {
3748
+ core.bridgeTimeline.updateEntryStatus(enriched.approvalId, enriched.status === 'approved' ? 'approved' : 'denied');
3749
+ }
3490
3750
  }
3491
3751
  break;
3492
3752
  case 'connection': {
@@ -3800,6 +4060,17 @@ export async function startDaemon(opts) {
3800
4060
  }
3801
4061
  catch { /* read-only best effort */ }
3802
4062
  }
4063
+ // Kiro keeps its transcript in its OWN store, so the Claude reader
4064
+ // above can never find one — and no `kiro_*` hook has ever reached
4065
+ // this daemon (v2 has no global hook surface at all), so the timeline
4066
+ // store is empty for it too. Without this branch an observed Kiro
4067
+ // session's Detail view is empty whatever it does.
4068
+ if (entries.length === 0 && sessionId.startsWith('observed:kiro')) {
4069
+ try {
4070
+ entries = kiroTimelineForSession(sessionId, { since: sinceMs });
4071
+ }
4072
+ catch { /* read-only best effort */ }
4073
+ }
3803
4074
  const historyEvent = buildCappedTimelineHistory(entries, undefined, { sessionId });
3804
4075
  if (historyEvent) {
3805
4076
  try {
@@ -4233,12 +4504,7 @@ export async function startDaemon(opts) {
4233
4504
  return;
4234
4505
  }
4235
4506
  if (cmd.type === 'query_usage') {
4236
- fetchUsageRelayed(port).then((usage) => {
4237
- if (usage)
4238
- core.updateApiUsage(usage);
4239
- else if (core.cachedApiUsage)
4240
- core.apiUsageStale = true;
4241
- });
4507
+ fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
4242
4508
  }
4243
4509
  };
4244
4510
  // ── Device-sourced voice ──────────────────────────────────────────────
@@ -5061,15 +5327,7 @@ export async function startDaemon(opts) {
5061
5327
  // Fetch usage on connect if stale
5062
5328
  const cacheAge = Date.now() - core.lastApiFetchTime;
5063
5329
  if (!core.cachedApiUsage || (core.lastApiFetchTime > 0 && cacheAge > 5 * 60 * 1000)) {
5064
- fetchUsageRelayed(port).then((usage) => {
5065
- if (usage)
5066
- core.updateApiUsage(usage);
5067
- else {
5068
- core.oauthConnected = hasOAuthToken();
5069
- if (core.cachedApiUsage)
5070
- core.apiUsageStale = true;
5071
- }
5072
- });
5330
+ fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
5073
5331
  }
5074
5332
  });
5075
5333
  core.wsServer.onClientDisconnect((ws) => {
@@ -5286,15 +5544,7 @@ export async function startDaemon(opts) {
5286
5544
  }
5287
5545
  // Initial usage fetch (delayed 10s)
5288
5546
  core.addTimeout(setTimeout(() => {
5289
- fetchUsageRelayed(port).then((usage) => {
5290
- if (usage)
5291
- core.updateApiUsage(usage);
5292
- else {
5293
- core.oauthConnected = hasOAuthToken();
5294
- if (core.cachedApiUsage)
5295
- core.apiUsageStale = true;
5296
- }
5297
- });
5547
+ fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
5298
5548
  }, 10_000));
5299
5549
  // Backstop sweep for held PreToolUse approval responses whose per-entry timer
5300
5550
  // somehow didn't fire — resolves anything older than 60s to "ask" so a held
@@ -5309,6 +5559,9 @@ export async function startDaemon(opts) {
5309
5559
  removeDaemonInfo();
5310
5560
  focusRelay.stop();
5311
5561
  timelineRelay.stop();
5562
+ // Clears every per-session poll timer. A watchdog that survived teardown
5563
+ // would keep firing self-POSTs at a port this daemon no longer owns.
5564
+ observedStopWatchdogs.stop();
5312
5565
  voiceAssistant?.stop();
5313
5566
  bridgeLogStream.stop();
5314
5567
  // Flush synchronously — the process exits a few lines below and a pending