@agentdeck/bridge 1.0.20 → 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 (175) 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/claude-interrupt-marker.d.ts +31 -0
  33. package/dist/claude-interrupt-marker.d.ts.map +1 -0
  34. package/dist/claude-interrupt-marker.js +58 -0
  35. package/dist/claude-interrupt-marker.js.map +1 -0
  36. package/dist/claude-turn-watchdog.d.ts +23 -0
  37. package/dist/claude-turn-watchdog.d.ts.map +1 -1
  38. package/dist/claude-turn-watchdog.js +30 -4
  39. package/dist/claude-turn-watchdog.js.map +1 -1
  40. package/dist/cli.d.ts +6 -0
  41. package/dist/cli.d.ts.map +1 -1
  42. package/dist/cli.js +398 -115
  43. package/dist/cli.js.map +1 -1
  44. package/dist/daemon-port.d.ts +90 -0
  45. package/dist/daemon-port.d.ts.map +1 -0
  46. package/dist/daemon-port.js +119 -0
  47. package/dist/daemon-port.js.map +1 -0
  48. package/dist/daemon-server.d.ts +14 -1
  49. package/dist/daemon-server.d.ts.map +1 -1
  50. package/dist/daemon-server.js +297 -69
  51. package/dist/daemon-server.js.map +1 -1
  52. package/dist/daemon-settings.d.ts +46 -0
  53. package/dist/daemon-settings.d.ts.map +1 -0
  54. package/dist/daemon-settings.js +85 -0
  55. package/dist/daemon-settings.js.map +1 -0
  56. package/dist/daemon-takeover.d.ts +75 -0
  57. package/dist/daemon-takeover.d.ts.map +1 -0
  58. package/dist/daemon-takeover.js +137 -0
  59. package/dist/daemon-takeover.js.map +1 -0
  60. package/dist/daemon.js +1 -1
  61. package/dist/index.d.ts +1 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +30 -11
  64. package/dist/index.js.map +1 -1
  65. package/dist/kiro-diagnostics.d.ts +90 -0
  66. package/dist/kiro-diagnostics.d.ts.map +1 -0
  67. package/dist/kiro-diagnostics.js +278 -0
  68. package/dist/kiro-diagnostics.js.map +1 -0
  69. package/dist/kiro-session.d.ts +113 -0
  70. package/dist/kiro-session.d.ts.map +1 -0
  71. package/dist/kiro-session.js +688 -0
  72. package/dist/kiro-session.js.map +1 -0
  73. package/dist/kiro-timeline-feed.d.ts +45 -0
  74. package/dist/kiro-timeline-feed.d.ts.map +1 -0
  75. package/dist/kiro-timeline-feed.js +116 -0
  76. package/dist/kiro-timeline-feed.js.map +1 -0
  77. package/dist/kiro-transcript-timeline.d.ts +57 -0
  78. package/dist/kiro-transcript-timeline.d.ts.map +1 -0
  79. package/dist/kiro-transcript-timeline.js +183 -0
  80. package/dist/kiro-transcript-timeline.js.map +1 -0
  81. package/dist/linux-service.d.ts +2 -1
  82. package/dist/linux-service.d.ts.map +1 -1
  83. package/dist/linux-service.js +8 -3
  84. package/dist/linux-service.js.map +1 -1
  85. package/dist/mdns-discover.d.ts +27 -0
  86. package/dist/mdns-discover.d.ts.map +1 -1
  87. package/dist/mdns-discover.js +60 -7
  88. package/dist/mdns-discover.js.map +1 -1
  89. package/dist/mdns.d.ts.map +1 -1
  90. package/dist/mdns.js +16 -3
  91. package/dist/mdns.js.map +1 -1
  92. package/dist/modules/adb-module.d.ts.map +1 -1
  93. package/dist/modules/adb-module.js +5 -2
  94. package/dist/modules/adb-module.js.map +1 -1
  95. package/dist/modules/index.d.ts +2 -1
  96. package/dist/modules/index.d.ts.map +1 -1
  97. package/dist/modules/index.js +1 -0
  98. package/dist/modules/index.js.map +1 -1
  99. package/dist/modules/types.d.ts +34 -9
  100. package/dist/modules/types.d.ts.map +1 -1
  101. package/dist/modules/types.js +34 -1
  102. package/dist/modules/types.js.map +1 -1
  103. package/dist/network-posture.d.ts +67 -0
  104. package/dist/network-posture.d.ts.map +1 -0
  105. package/dist/network-posture.js +100 -0
  106. package/dist/network-posture.js.map +1 -0
  107. package/dist/observed-steering.d.ts +14 -0
  108. package/dist/observed-steering.d.ts.map +1 -1
  109. package/dist/observed-steering.js +18 -0
  110. package/dist/observed-steering.js.map +1 -1
  111. package/dist/observed-turn-watchdogs.d.ts +84 -0
  112. package/dist/observed-turn-watchdogs.d.ts.map +1 -0
  113. package/dist/observed-turn-watchdogs.js +134 -0
  114. package/dist/observed-turn-watchdogs.js.map +1 -0
  115. package/dist/openclaw-session.d.ts +10 -0
  116. package/dist/openclaw-session.d.ts.map +1 -1
  117. package/dist/openclaw-session.js +12 -0
  118. package/dist/openclaw-session.js.map +1 -1
  119. package/dist/passive-observer.d.ts +19 -0
  120. package/dist/passive-observer.d.ts.map +1 -1
  121. package/dist/passive-observer.js +208 -24
  122. package/dist/passive-observer.js.map +1 -1
  123. package/dist/pixoo/micro-glyphs.d.ts +1 -1
  124. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  125. package/dist/pixoo/micro-glyphs.js +2 -0
  126. package/dist/pixoo/micro-glyphs.js.map +1 -1
  127. package/dist/pixoo/official-dot-glyphs.generated.d.ts +1 -1
  128. package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -1
  129. package/dist/pixoo/official-dot-glyphs.generated.js +47 -0
  130. package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -1
  131. package/dist/pixoo/pixoo-discover.d.ts +8 -1
  132. package/dist/pixoo/pixoo-discover.d.ts.map +1 -1
  133. package/dist/pixoo/pixoo-discover.js +15 -8
  134. package/dist/pixoo/pixoo-discover.js.map +1 -1
  135. package/dist/pixoo/pixoo-renderer.d.ts +4 -2
  136. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  137. package/dist/pixoo/pixoo-renderer.js +105 -36
  138. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  139. package/dist/pixoo/pixoo-settings.d.ts +19 -2
  140. package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
  141. package/dist/pixoo/pixoo-settings.js +22 -4
  142. package/dist/pixoo/pixoo-settings.js.map +1 -1
  143. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
  144. package/dist/pixoo/pixoo-sprites.js +2 -0
  145. package/dist/pixoo/pixoo-sprites.js.map +1 -1
  146. package/dist/session-aggregator.d.ts +9 -1
  147. package/dist/session-aggregator.d.ts.map +1 -1
  148. package/dist/session-aggregator.js +1 -1
  149. package/dist/session-aggregator.js.map +1 -1
  150. package/dist/session-registry.d.ts +71 -3
  151. package/dist/session-registry.d.ts.map +1 -1
  152. package/dist/session-registry.js +126 -19
  153. package/dist/session-registry.js.map +1 -1
  154. package/dist/subagent-timeline.d.ts +38 -7
  155. package/dist/subagent-timeline.d.ts.map +1 -1
  156. package/dist/subagent-timeline.js +154 -23
  157. package/dist/subagent-timeline.js.map +1 -1
  158. package/dist/timeline-store.d.ts.map +1 -1
  159. package/dist/timeline-store.js +26 -1
  160. package/dist/timeline-store.js.map +1 -1
  161. package/dist/tui/renderer.d.ts.map +1 -1
  162. package/dist/tui/renderer.js +13 -1
  163. package/dist/tui/renderer.js.map +1 -1
  164. package/dist/tui/terrarium.d.ts.map +1 -1
  165. package/dist/tui/terrarium.js +10 -5
  166. package/dist/tui/terrarium.js.map +1 -1
  167. package/dist/utils/redact-secrets.d.ts +3 -0
  168. package/dist/utils/redact-secrets.d.ts.map +1 -0
  169. package/dist/utils/redact-secrets.js +29 -0
  170. package/dist/utils/redact-secrets.js.map +1 -0
  171. package/dist/windows-service.d.ts +2 -1
  172. package/dist/windows-service.d.ts.map +1 -1
  173. package/dist/windows-service.js +6 -3
  174. package/dist/windows-service.js.map +1 -1
  175. 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,7 +72,10 @@ 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';
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';
76
79
  import { fetchUsageFromApi, hasOAuthToken, 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';
@@ -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';
@@ -580,30 +587,6 @@ async function performWifiEsp32Ota(core, target, firmwarePath) {
580
587
  throw err;
581
588
  }
582
589
  }
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
590
  function latestTimelinePath() {
608
591
  let best = null;
609
592
  for (const dir of getCandidateDataDirs()) {
@@ -767,15 +750,19 @@ export function classifyObservedHookEvent(eventName, mapped) {
767
750
  if (eventName === 'codex_subagent_start' || eventName === 'codex_subagent_stop') {
768
751
  return { boundary: eventName, agentType: 'codex-cli' };
769
752
  }
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)$/
753
+ 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
754
  .exec(eventName);
772
755
  if (!prefixed)
773
756
  return { boundary: mapped, agentType: 'claude-code' };
774
757
  return {
775
- boundary: prefixed[2] === 'turn_complete' ? 'stop' : prefixed[2],
758
+ boundary: prefixed[2] === 'turn_complete' ? 'stop'
759
+ : prefixed[2] === 'agent_spawn' ? 'session_start'
760
+ : prefixed[2],
776
761
  agentType: prefixed[1] === 'codex' ? 'codex-cli'
777
762
  : prefixed[1] === 'opencode' ? 'opencode'
778
- : 'antigravity',
763
+ : prefixed[1] === 'antigravity' ? 'antigravity'
764
+ : prefixed[1] === 'kiro_ide' ? 'kiro-ide'
765
+ : 'kiro-cli',
779
766
  };
780
767
  }
781
768
  function buildNodeModuleHealth(startedModules) {
@@ -901,6 +888,10 @@ export async function startDaemon(opts) {
901
888
  // CLI --wake-word flag OR settings.json wakeWord: true
902
889
  const settings = loadDaemonSettings();
903
890
  const wakeWordEnabled = opts.wakeWord || settings.wakeWord === true;
891
+ // Network posture (bind address + which device modules may run). Resolved once
892
+ // here so the bind, the module set, and the startup log cannot disagree about
893
+ // what this daemon is allowed to do.
894
+ const posture = resolveDaemonPosture({ local: opts.local, loopback: opts.loopback });
904
895
  // ===== Singleton guard + port allocation =====
905
896
  // 1. Check daemon.json and sessions.json for existing daemon
906
897
  const existingInfo = readDaemonInfo();
@@ -948,32 +939,46 @@ export async function startDaemon(opts) {
948
939
  removeDaemonSession(existingSession);
949
940
  }
950
941
  }
951
- // 2. Determine port — try default first, fallback if occupied by non-daemon
952
- const requestedPort = opts.port ?? DAEMON_DEFAULT_PORT;
942
+ // 2. Determine the preferred port — the one this daemon intends to serve.
943
+ // `opts.port` is already resolved by the CLI (flag › AGENTDECK_DAEMON_PORT ›
944
+ // settings.json `daemonPort` › 9120); resolving again here covers the
945
+ // library entry point, and both paths read the same sources.
946
+ const preferred = opts.port !== undefined
947
+ ? { port: opts.port, source: opts.portSource ?? 'flag' }
948
+ : resolveDaemonPort();
949
+ const requestedPort = preferred.port;
953
950
  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
- }
951
+ // Probe the preferred port before binding it. This used to run only for the
952
+ // built-in 9120, which made every other preferred port skip the Swift-daemon
953
+ // eviction and the occupied-port check — a persisted `daemonPort: 9200` would
954
+ // have been a second-class port on the same machine. The port a daemon
955
+ // intends to serve gets the same treatment wherever the intent came from.
956
+ const preferredOccupant = await probeDaemonHealth(requestedPort);
957
+ if (preferredOccupant) {
958
+ if (preferredOccupant.mode === 'daemon' && isForeignDaemon(preferredOccupant)) {
959
+ // Another OS user's daemon. Neither evict it nor concede to it — this
960
+ // user still gets a daemon, on a port of their own.
961
+ log(`[agentdeck] Port ${requestedPort} is held by another user's daemon — leaving it alone and taking a free port.`);
962
+ port = await findAvailablePort();
963
+ }
964
+ else if (preferredOccupant.mode === 'daemon') {
965
+ if (preferredOccupant.isSwift) {
966
+ log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
967
+ await requestDaemonShutdown(requestedPort);
968
+ await waitForDaemonExit(requestedPort);
969
+ await waitForPortBindable(requestedPort);
970
970
  }
971
971
  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();
972
+ // Daemon alive but not in our registryrace condition or stale state
973
+ log(`[agentdeck] Daemon already running on port ${requestedPort} (detected via /health).`);
974
+ process.exit(0);
975
975
  }
976
976
  }
977
+ else {
978
+ // Port occupied by non-daemon (e.g. session bridge) — find alternative
979
+ log(`[agentdeck] Port ${requestedPort} occupied (${preferredOccupant.mode ?? 'unknown'}), finding alternative...`);
980
+ port = await findAvailablePort();
981
+ }
977
982
  }
978
983
  // 3. Cross-implementation sweep of the daemon port window. File discovery
979
984
  // has two blind spots that the checks above never cover: the App Store
@@ -985,6 +990,14 @@ export async function startDaemon(opts) {
985
990
  // a live Node daemon wins and we exit.
986
991
  const strayDaemons = await scanDaemonPortWindow(new Set([requestedPort]));
987
992
  for (const stray of strayDaemons) {
993
+ if (isForeignDaemon(stray.health)) {
994
+ // Not a stray — someone else's daemon, doing its job for its own user.
995
+ // This sweep runs on every start, so without this check one user
996
+ // starting a CLI daemon shuts down every colleague's macOS app on the
997
+ // box (docs/ENTERPRISE-ROADMAP.md §1.3).
998
+ log(`[agentdeck] Port ${stray.port} is held by another user's daemon — not touching it.`);
999
+ continue;
1000
+ }
988
1001
  if (stray.health.isSwift) {
989
1002
  log(`[agentdeck] Swift daemon detected on fallback port ${stray.port}. Requesting shutdown to take over...`);
990
1003
  await requestDaemonShutdown(stray.port);
@@ -1017,6 +1030,53 @@ export async function startDaemon(opts) {
1017
1030
  * anything arrived SINCE this turn opened.
1018
1031
  */
1019
1032
  const hookSessionLastSeenAt = new Map();
1033
+ /**
1034
+ * Missed-Stop recovery for observed Claude sessions (plain `claude` in the
1035
+ * user's own terminal — no PTY, hooks only).
1036
+ *
1037
+ * The Stop hook is the sole authority that closes a Claude turn, and its
1038
+ * curl is fire-and-forget with a tight budget, so it drops. `agentdeck
1039
+ * claude` has recovered from that since 1.0.20; observed sessions had
1040
+ * nothing, which is the majority of real usage. Recovery re-enters this
1041
+ * daemon's own `/hooks/Stop` over loopback rather than reimplementing that
1042
+ * branch — see the class doc for why the same path matters.
1043
+ *
1044
+ * Declared before the HTTP server (hooks arrive the moment the port binds)
1045
+ * and, like every other route, trusted purely because it is loopback: the
1046
+ * auth gate treats same-machine as fully authorized, so no token is
1047
+ * involved and none is minted.
1048
+ */
1049
+ const observedStopWatchdogs = new ObservedTurnWatchdogs({
1050
+ onMissedStop: ({ sessionId, transcriptPath, reason, cwd, projectName }) => {
1051
+ const url = `http://127.0.0.1:${port}/hooks/Stop`;
1052
+ const controller = new AbortController();
1053
+ // Bounded like every other peer await, loopback or not: a daemon
1054
+ // mid-shutdown accepts the socket and never answers, and an unbounded
1055
+ // wait there would pin the recovery forever.
1056
+ const timer = setTimeout(() => controller.abort(), 2_000);
1057
+ void fetch(url, {
1058
+ method: 'POST',
1059
+ headers: { 'Content-Type': 'application/json' },
1060
+ signal: controller.signal,
1061
+ body: JSON.stringify({
1062
+ session_id: sessionId,
1063
+ transcript_path: transcriptPath,
1064
+ // Tags the APME turn `end_source='synthetic_stop'`, which is the
1065
+ // standing measurement of how often the real hook drops — unless
1066
+ // the user cancelled, in which case no Stop was ever due and the
1067
+ // turn must not be charged to that rate.
1068
+ synthetic_stop: true,
1069
+ ...(reason === 'interrupted' ? { interrupted: true } : {}),
1070
+ // Same for a client abort (usage limit, auth, API error): Claude
1071
+ // Code fires no Stop for it, so nothing was lost to recover.
1072
+ ...(reason === 'aborted' ? { aborted: true } : {}),
1073
+ ...(cwd ? { cwd } : {}),
1074
+ ...(projectName ? { project_name: projectName } : {}),
1075
+ }),
1076
+ }).catch(() => { })
1077
+ .finally(() => clearTimeout(timer));
1078
+ },
1079
+ });
1020
1080
  // Codex OTel span state. Declared before the HTTP server because the
1021
1081
  // `/otel/v1/traces` handler closes over it — Codex's exporter can POST before
1022
1082
  // the rest of daemon startup finishes.
@@ -1030,6 +1090,9 @@ export async function startDaemon(opts) {
1030
1090
  // the rest of startup finishes. A later `const` would leave that window
1031
1091
  // throwing on the temporal dead zone.
1032
1092
  const passiveSessionObserver = new PassiveSessionObserver();
1093
+ /** Produces live timeline rows for observed Kiro sessions — see the class
1094
+ * doc for why Kiro needs a producer when hook agents do not. */
1095
+ const kiroTimelineFeed = new KiroTimelineFeed();
1033
1096
  /** Observed Claude sessions whose AskUserQuestion PreToolUse is held open for
1034
1097
  * a device answer, keyed by the bare Claude session UUID. Its presence is
1035
1098
  * what makes the session's options pressable (`liveAnswerable`) and what
@@ -1255,6 +1318,11 @@ export async function startDaemon(opts) {
1255
1318
  // Workers require this flag before remote-attaching; the Swift daemon
1256
1319
  // does not advertise it and is therefore never selected as a remote hub.
1257
1320
  sameSocketControl: true,
1321
+ // Network posture, so `agentdeck daemon restart` can carry the running
1322
+ // daemon's posture across the restart instead of silently downgrading
1323
+ // an enterprise install back to "advertise everything". Local-only
1324
+ // payload; it is diagnostic, not a credential.
1325
+ posture,
1258
1326
  modules: moduleHealthProvider(),
1259
1327
  apme: apme
1260
1328
  ? {
@@ -1887,13 +1955,22 @@ export async function startDaemon(opts) {
1887
1955
  const earlyHookProject = (typeof json.project_name === 'string' && json.project_name)
1888
1956
  ? json.project_name
1889
1957
  : (earlyHookCwd ? earlyHookCwd.split('/').filter(Boolean).pop() : undefined);
1890
- const childHook = subagentTimeline?.handle({
1958
+ const childResult = subagentTimeline?.handle({
1891
1959
  eventName,
1892
1960
  payload: json,
1893
1961
  sessionId: earlyHookSid,
1894
1962
  agentType: hookAgentType,
1895
1963
  projectName: earlyHookProject,
1896
- }).childOnly === true;
1964
+ });
1965
+ // A child starting or stopping is the ONLY thing that moves a parent's
1966
+ // `subagents` block, and it moves nothing else — the parent's own state
1967
+ // is untouched by design. Without this rebroadcast the census would sit
1968
+ // stale until some unrelated event happened to refresh the list, which
1969
+ // for an idle parent waiting on eight children is never.
1970
+ if (childResult?.censusChangedFor) {
1971
+ core.broadcastSessionsList().catch(() => { });
1972
+ }
1973
+ const childHook = childResult?.childOnly === true;
1897
1974
  if (childHook) {
1898
1975
  // Child activity is observation-only. Never let child PreToolUse
1899
1976
  // reach the parent's approval, STOP, state, or APME paths.
@@ -2044,6 +2121,15 @@ export async function startDaemon(opts) {
2044
2121
  // (even one persisted before the restart) must not be force-closed.
2045
2122
  hookSessionsSeen.add(hookSid);
2046
2123
  hookSessionLastSeenAt.set(hookSid, Date.now());
2124
+ // Missed-Stop recovery for observed Claude sessions. Fed the raw
2125
+ // PascalCase event name — the watchdog's vocabulary is Claude's, and
2126
+ // its transcript probe reads Claude's JSONL, so only Claude sessions
2127
+ // are admitted (Codex/OpenCode carry their own turn-end signals).
2128
+ // A synthetic Stop re-enters here as a normal hook and disarms the
2129
+ // watchdog exactly like the real one would.
2130
+ if (hookAgentType === 'claude-code') {
2131
+ observedStopWatchdogs.noteHookEvent(hookSid, eventName, json);
2132
+ }
2047
2133
  // Claude hooks carry `cwd` (the worktree dir), not project_name/path —
2048
2134
  // capture it so APME runs are attributable to a specific worktree.
2049
2135
  const hookCwd = (typeof json.cwd === 'string' ? json.cwd
@@ -2205,7 +2291,13 @@ export async function startDaemon(opts) {
2205
2291
  }
2206
2292
  const turnOpen = Boolean(lastStart && lastStart.ts > lastCompletionTs);
2207
2293
  const tp = typeof json.transcript_path === 'string' ? json.transcript_path : '';
2208
- const inlineResponse = [json.last_assistant_message, json.response, json.output, json.result]
2294
+ const inlineResponse = [
2295
+ json.last_assistant_message,
2296
+ json.assistant_response,
2297
+ json.response,
2298
+ json.output,
2299
+ json.result,
2300
+ ]
2209
2301
  .find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
2210
2302
  // Codex's stop payload rarely carries the text inline; its rollout
2211
2303
  // JSONL (agent_message / task_complete records) is the
@@ -2242,6 +2334,18 @@ export async function startDaemon(opts) {
2242
2334
  // response, so a duplicate stop is a no-op instead of a clobber.
2243
2335
  apme.collector.setLastClosedTurnResponse(hookSid, responseText);
2244
2336
  }
2337
+ // Close the APME turn on the Stop that ended it, tagged with which
2338
+ // Stop that was. Runs AFTER the response capture above (the
2339
+ // trajectory event wants the still-active turn) and is a no-op when
2340
+ // no turn is open, so a duplicate/late stop cannot re-attribute an
2341
+ // already-closed turn. `turnOpen` above is a TIMELINE predicate —
2342
+ // it says nothing about the collector's turn, which is why this is
2343
+ // unconditional rather than nested under it.
2344
+ apme.collector.noteTurnStop(hookSid, {
2345
+ synthetic: json.synthetic_stop === true,
2346
+ interrupted: json.interrupted === true,
2347
+ aborted: json.aborted === true,
2348
+ });
2245
2349
  }
2246
2350
  if (turnOpen && lastStart) {
2247
2351
  const now = Date.now();
@@ -2442,7 +2546,13 @@ export async function startDaemon(opts) {
2442
2546
  // by blocking the stop with the directive as the continuation
2443
2547
  // reason. Empty queue (the overwhelmingly common case) → empty body,
2444
2548
  // Claude ends the turn normally — no stop_hook_active loop possible.
2445
- const directive = claudeSid ? takeDirective(claudeSid) : undefined;
2549
+ //
2550
+ // `takeDirectiveForStop` refuses a synthetic Stop (missed-Stop
2551
+ // watchdog): nothing is listening on that response, so draining the
2552
+ // queue there would silently eat the user's queued follow-up.
2553
+ // `clearStop` still runs either way — the transcript proved the turn
2554
+ // ended, so a pending soft-stop is moot however we learned it.
2555
+ const directive = claudeSid ? takeDirectiveForStop(claudeSid, json) : undefined;
2446
2556
  if (claudeSid)
2447
2557
  clearStop(claudeSid); // turn ended — a pending soft-stop is moot
2448
2558
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -2680,8 +2790,9 @@ export async function startDaemon(opts) {
2680
2790
  // pull-sync e-ink clients live on the LAN — and the security boundary is the
2681
2791
  // pairing token (http-auth-gate.ts default-denies unauthenticated LAN
2682
2792
  // 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';
2793
+ // with no LAN devices can opt into a loopback-only posture (issue #145),
2794
+ // which also silences everything the daemon emits (see network-posture.ts).
2795
+ const bindHost = bindHostFor(posture);
2685
2796
  await new Promise((resolve, reject) => {
2686
2797
  httpServer.on('error', (err) => {
2687
2798
  if (err.code === 'EADDRINUSE') {
@@ -2716,8 +2827,48 @@ export async function startDaemon(opts) {
2716
2827
  if (occupant?.mode === 'daemon') {
2717
2828
  log(`[agentdeck] Port ${requestedPort} reports a daemon but PID ${occupant.pid} is not a live distinct process; treating as stale and falling back.`);
2718
2829
  }
2830
+ // Nobody answered on the preferred port, yet it refuses to bind. That is
2831
+ // not a peer to yield to — it is the kernel still holding a port whose
2832
+ // owner has already gone: macOS keeps a NECP reservation for ~14s after
2833
+ // an `NWListener.cancel()` (measured 2026-08-06: bindable at ~17s, with
2834
+ // `lsof` showing zero sockets throughout), and a half-closed socket in
2835
+ // TIME_WAIT/LAST_ACK looks the same from here.
2836
+ //
2837
+ // Without this wait the daemon conceded instantly and moved to 9121 —
2838
+ // permanently, because nothing ever moved it back. Seconds after that
2839
+ // decision the canonical port was free and owned by nobody. Waiting is
2840
+ // the whole recovery: `waitForPortBindable` polls, so a kernel-held port
2841
+ // costs the ~14s it is actually held, and only a genuinely occupied port
2842
+ // pays the full budget before falling back.
2843
+ if (!occupant) {
2844
+ log(`[agentdeck] Port ${requestedPort} is held but nothing answers /health — usually the kernel still `
2845
+ + `releasing it after the previous owner exited. Waiting up to ${Math.round(PREFERRED_PORT_RECLAIM_MS / 1000)}s…`);
2846
+ if (await waitForPortBindable(requestedPort, PREFERRED_PORT_RECLAIM_MS)) {
2847
+ const bound = await new Promise((resolve) => {
2848
+ const onError = () => resolve(false);
2849
+ httpServer.once('error', onError);
2850
+ httpServer.listen(requestedPort, bindHost, () => {
2851
+ httpServer.removeListener('error', onError);
2852
+ resolve(true);
2853
+ });
2854
+ });
2855
+ if (bound) {
2856
+ log(`[agentdeck] Port ${requestedPort} came free — bound it instead of falling back.`);
2857
+ return;
2858
+ }
2859
+ // Lost it again between the probe bind and ours. Rare, and the
2860
+ // fallback below is exactly the right answer for it.
2861
+ log(`[agentdeck] Port ${requestedPort} was taken again before this daemon could bind it.`);
2862
+ }
2863
+ else {
2864
+ log(`[agentdeck] Port ${requestedPort} is still held after ${Math.round(PREFERRED_PORT_RECLAIM_MS / 1000)}s — `
2865
+ + `something outside AgentDeck is listening on it, or a sleeping device is holding a half-closed socket.`);
2866
+ }
2867
+ }
2719
2868
  port = await findAvailablePort();
2720
2869
  log(`[agentdeck] Port ${requestedPort} grabbed by ${occupant?.mode ?? 'a non-daemon'}, retrying on ${port}...`);
2870
+ log(`[agentdeck] This daemon prefers port ${requestedPort} (${describeDaemonPortSource(preferred.source)}). `
2871
+ + `Clients resolve the actual port from daemon.json; 'agentdeck daemon restart' aims at the preferred port again.`);
2721
2872
  await new Promise((resolve, reject) => {
2722
2873
  httpServer.on('error', (e) => reject(e));
2723
2874
  httpServer.listen(port, bindHost, () => resolve());
@@ -2727,11 +2878,11 @@ export async function startDaemon(opts) {
2727
2878
  throw err;
2728
2879
  }
2729
2880
  });
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.`);
2881
+ log(describeDaemonPosture(posture, port));
2882
+ if (preferred.source === 'settings' || preferred.source === 'env') {
2883
+ // A persisted or environmental port is invisible in the command line that
2884
+ // started this daemon, so the log is the only place it can be discovered.
2885
+ log(`[agentdeck] Preferred port ${requestedPort} comes from ${describeDaemonPortSource(preferred.source)}.`);
2735
2886
  }
2736
2887
  // Write daemon.json for client discovery (must be after successful bind).
2737
2888
  // Keep the original startedAt stable when the self-heal timer rewrites it.
@@ -2744,8 +2895,14 @@ export async function startDaemon(opts) {
2744
2895
  httpServer,
2745
2896
  isDaemon: true,
2746
2897
  });
2747
- subagentTimeline = new SubagentTimelineTracker((entry) => {
2748
- core.bridgeTimeline.addEntry(entry, { bypassSuppression: true });
2898
+ subagentTimeline = new SubagentTimelineTracker((entry, upsert) => {
2899
+ // A dispatch row is upserted as its burst grows (one row per fan-out, the
2900
+ // `task_start` folding pattern) — emitting a fresh row per child would
2901
+ // spend a 200-entry buffer shared by every session on one workflow.
2902
+ if (upsert)
2903
+ core.bridgeTimeline.upsertEntry(entry, { bypassSuppression: true });
2904
+ else
2905
+ core.bridgeTimeline.addEntry(entry, { bypassSuppression: true });
2749
2906
  });
2750
2907
  // Hooks resolve a fallback daemon port through daemon.json. Another process
2751
2908
  // can delete that file while this daemon remains healthy, which makes every
@@ -2828,6 +2985,11 @@ export async function startDaemon(opts) {
2828
2985
  const TURN_WATCHDOG_SILENCE_MS = 3 * 60_000;
2829
2986
  const turnWatchdog = setInterval(() => {
2830
2987
  const now = Date.now();
2988
+ // Drop per-session missed-Stop watchdogs whose session has gone cold.
2989
+ // Rides this existing tick rather than owning a timer: an observed
2990
+ // session that dies mid-turn never sends SessionEnd, so its watchdog
2991
+ // would otherwise poll for the life of the daemon.
2992
+ observedStopWatchdogs.sweep();
2831
2993
  const active = new Set();
2832
2994
  for (const [sid, at] of hookSessionLastSeenAt) {
2833
2995
  if (now - at <= TURN_WATCHDOG_SILENCE_MS)
@@ -3039,7 +3201,12 @@ export async function startDaemon(opts) {
3039
3201
  // driven exclusively by the Ulanzi Studio plugin (`ulanzi-plugin`) over WebSocket;
3040
3202
  // the daemon never opens it over HID. Its health is reported via the Ulanzi client
3041
3203
  // 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 });
3204
+ const startedModules = await initModules(deviceModules, daemonModuleConfigs(posture, serialMode), {
3205
+ port, authToken: core.authToken, projectName: 'AgentDeck', wsServer: core.wsServer,
3206
+ // AdbModule restricts `adb reverse` to USB transports under loopback —
3207
+ // a TCP/mDNS adb device would carry the tunnel over the LAN.
3208
+ loopbackOnly: posture.loopbackOnly,
3209
+ });
3043
3210
  moduleHealthProvider = () => {
3044
3211
  const modules = buildNodeModuleHealth(startedModules);
3045
3212
  const einkDevices = collectEinkDevices();
@@ -3293,7 +3460,24 @@ export async function startDaemon(opts) {
3293
3460
  // The observer scans in the background now (collect() returns the cache
3294
3461
  // immediately). When a scan lands fresh observations, push them out via
3295
3462
  // the debounced broadcast so clients don't wait for the next 10 s poll.
3296
- passiveSessionObserver.onRefreshed = () => core.maybeBroadcastSessionsList();
3463
+ passiveSessionObserver.onRefreshed = () => {
3464
+ // Kiro pushes nothing: no `kiro_*` hook has ever reached this daemon, so
3465
+ // without a producer here its session shows in the HUD while the timeline
3466
+ // beside it stays empty. #218 taught the per-session QUERY to read Kiro's
3467
+ // own transcript; the main timeline is a STREAM, and this is what feeds it.
3468
+ try {
3469
+ const kiroIds = passiveSessionObserver.collect([])
3470
+ .filter((s) => typeof s.agentType === 'string' && s.agentType.startsWith('kiro'))
3471
+ .map((s) => s.id);
3472
+ for (const entry of kiroTimelineFeed.pump(kiroIds)) {
3473
+ core.bridgeTimeline.addEntry(entry);
3474
+ }
3475
+ }
3476
+ catch (err) {
3477
+ debug('daemon', `kiro timeline feed failed: ${String(err)}`);
3478
+ }
3479
+ core.maybeBroadcastSessionsList();
3480
+ };
3297
3481
  // OTel turn boundaries arrive in milliseconds where the observer only re-reads
3298
3482
  // the rollout tail every scan interval, so a span that changed thread state is
3299
3483
  // worth a broadcast of its own.
@@ -3344,15 +3528,24 @@ export async function startDaemon(opts) {
3344
3528
  // Derive per-session elapsed seconds from startedAt so NTP-less devices
3345
3529
  // (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
3346
3530
  const now = Date.now();
3531
+ // Live child-agent census. Hooks key children by the BARE session uuid
3532
+ // while these rows are `observed:<agent>:<uuid>`, so normalize before the
3533
+ // lookup — the two id forms are the standing trap here.
3534
+ const subagentCensus = subagentTimeline?.summaries() ?? new Map();
3347
3535
  const enrichedSessions = [...sessions, ...observed, ...remote].map((s) => {
3348
3536
  // On-demand review badge (REVIEW tile verdict / REVIEWING state) —
3349
3537
  // applies to every session type, managed included.
3350
3538
  const review = reviewSnapshot(s.id);
3351
3539
  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;
3540
+ // Emitted whenever this session has EVER had a child, zeros included: a
3541
+ // field that disappears when the last child exits latches "8 running" on
3542
+ // every client that merges retain-on-absent.
3543
+ const census = subagentCensus.get(rawSessionId(withReview.id));
3544
+ const withCensus = census ? { ...withReview, subagents: census } : withReview;
3545
+ if (withCensus.elapsedSec != null || !withCensus.startedAt)
3546
+ return withCensus;
3547
+ const sec = Math.round((now - Date.parse(withCensus.startedAt)) / 1000);
3548
+ return Number.isFinite(sec) && sec >= 0 ? { ...withCensus, elapsedSec: sec } : withCensus;
3356
3549
  });
3357
3550
  // SSOT: inject iff Gateway is authenticated (gatewayConnected). Reachability
3358
3551
  // / adapter-liveness alone must not materialize a session — that kept a
@@ -3371,6 +3564,11 @@ export async function startDaemon(opts) {
3371
3564
  projectName: 'OpenClaw',
3372
3565
  modelName: gatewayModelName,
3373
3566
  controlMode: 'managed',
3567
+ // Read live from the adapter rather than cached alongside
3568
+ // `gatewaySessionState`: the two would drift, and a row that says
3569
+ // "awaiting_permission" while carrying no options is exactly the
3570
+ // dead end this fixes.
3571
+ approval: gatewayAdapter?.getPendingApproval() ?? null,
3374
3572
  });
3375
3573
  });
3376
3574
  // OpenClaw-specific APME session id. Distinct from `core.sessionId`
@@ -3432,6 +3630,14 @@ export async function startDaemon(opts) {
3432
3630
  gatewaySessionState = 'idle';
3433
3631
  else if (evt.event === 'permission_prompt')
3434
3632
  gatewaySessionState = 'awaiting_permission';
3633
+ // The virtual Gateway row carries the approval's question/options, so
3634
+ // a prompt appearing or clearing changes `sessions_list` — not just
3635
+ // the state event. Devices read their option cells from that list; a
3636
+ // state-only broadcast left them showing the previous prompt (or
3637
+ // none) until some unrelated event forced a refresh.
3638
+ if (evt.event === 'spinner_start' || evt.event === 'idle' || evt.event === 'permission_prompt') {
3639
+ core.broadcastSessionsList().catch(() => { });
3640
+ }
3435
3641
  // The OpenClaw adapter emits the real model via a model_info parser
3436
3642
  // event, but it only ever updated the display StateMachine — the APME
3437
3643
  // run was never told, so every openclaw run persisted model_id=NULL.
@@ -3487,6 +3693,14 @@ export async function startDaemon(opts) {
3487
3693
  core.bridgeTimeline.addEntry(enriched);
3488
3694
  if (enriched.type === 'tool_request')
3489
3695
  bridgeLogStream.trackToolRequest(enriched.raw);
3696
+ // Flip the matching `tool_request` row off `pending`. The resolution
3697
+ // has always been emitted as its own row, so the request row kept
3698
+ // `status: 'pending'` forever — an approval answered minutes ago
3699
+ // still read as outstanding in the timeline. `updateEntryStatus`
3700
+ // existed for exactly this and had no production caller.
3701
+ if (enriched.type === 'tool_resolved' && enriched.approvalId) {
3702
+ core.bridgeTimeline.updateEntryStatus(enriched.approvalId, enriched.status === 'approved' ? 'approved' : 'denied');
3703
+ }
3490
3704
  }
3491
3705
  break;
3492
3706
  case 'connection': {
@@ -3800,6 +4014,17 @@ export async function startDaemon(opts) {
3800
4014
  }
3801
4015
  catch { /* read-only best effort */ }
3802
4016
  }
4017
+ // Kiro keeps its transcript in its OWN store, so the Claude reader
4018
+ // above can never find one — and no `kiro_*` hook has ever reached
4019
+ // this daemon (v2 has no global hook surface at all), so the timeline
4020
+ // store is empty for it too. Without this branch an observed Kiro
4021
+ // session's Detail view is empty whatever it does.
4022
+ if (entries.length === 0 && sessionId.startsWith('observed:kiro')) {
4023
+ try {
4024
+ entries = kiroTimelineForSession(sessionId, { since: sinceMs });
4025
+ }
4026
+ catch { /* read-only best effort */ }
4027
+ }
3803
4028
  const historyEvent = buildCappedTimelineHistory(entries, undefined, { sessionId });
3804
4029
  if (historyEvent) {
3805
4030
  try {
@@ -5309,6 +5534,9 @@ export async function startDaemon(opts) {
5309
5534
  removeDaemonInfo();
5310
5535
  focusRelay.stop();
5311
5536
  timelineRelay.stop();
5537
+ // Clears every per-session poll timer. A watchdog that survived teardown
5538
+ // would keep firing self-POSTs at a port this daemon no longer owns.
5539
+ observedStopWatchdogs.stop();
5312
5540
  voiceAssistant?.stop();
5313
5541
  bridgeLogStream.stop();
5314
5542
  // Flush synchronously — the process exits a few lines below and a pending