@agentdeck/bridge 1.0.4 → 1.0.5

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 (202) hide show
  1. package/assets/fm-helper/agentdeck-fm-helper +0 -0
  2. package/dist/adapters/claude-code.d.ts.map +1 -1
  3. package/dist/adapters/claude-code.js +3 -0
  4. package/dist/adapters/claude-code.js.map +1 -1
  5. package/dist/adb-reverse.d.ts.map +1 -1
  6. package/dist/adb-reverse.js +4 -2
  7. package/dist/adb-reverse.js.map +1 -1
  8. package/dist/apme/collector.js +2 -2
  9. package/dist/apme/collector.js.map +1 -1
  10. package/dist/apme/outcome.js +2 -2
  11. package/dist/apme/outcome.js.map +1 -1
  12. package/dist/apme/runner.js +3 -3
  13. package/dist/apme/runner.js.map +1 -1
  14. package/dist/ble-sync-spawn.js +1 -1
  15. package/dist/ble-sync-spawn.js.map +1 -1
  16. package/dist/calendar.d.ts +82 -0
  17. package/dist/calendar.d.ts.map +1 -0
  18. package/dist/calendar.js +324 -0
  19. package/dist/calendar.js.map +1 -0
  20. package/dist/card-feed.d.ts +63 -5
  21. package/dist/card-feed.d.ts.map +1 -1
  22. package/dist/card-feed.js +218 -14
  23. package/dist/card-feed.js.map +1 -1
  24. package/dist/card-modules.d.ts +4 -0
  25. package/dist/card-modules.d.ts.map +1 -1
  26. package/dist/card-modules.js +5 -3
  27. package/dist/card-modules.js.map +1 -1
  28. package/dist/check-deps.js +4 -4
  29. package/dist/check-deps.js.map +1 -1
  30. package/dist/cli.d.ts +35 -0
  31. package/dist/cli.d.ts.map +1 -1
  32. package/dist/cli.js +269 -9
  33. package/dist/cli.js.map +1 -1
  34. package/dist/codex-otel.d.ts +89 -0
  35. package/dist/codex-otel.d.ts.map +1 -0
  36. package/dist/codex-otel.js +487 -0
  37. package/dist/codex-otel.js.map +1 -0
  38. package/dist/codex-rate-limits.d.ts.map +1 -1
  39. package/dist/codex-rate-limits.js +15 -3
  40. package/dist/codex-rate-limits.js.map +1 -1
  41. package/dist/daemon-server.d.ts +4 -0
  42. package/dist/daemon-server.d.ts.map +1 -1
  43. package/dist/daemon-server.js +797 -54
  44. package/dist/daemon-server.js.map +1 -1
  45. package/dist/daemon-ws-client.d.ts +108 -24
  46. package/dist/daemon-ws-client.d.ts.map +1 -1
  47. package/dist/daemon-ws-client.js +175 -39
  48. package/dist/daemon-ws-client.js.map +1 -1
  49. package/dist/daemon.js +1 -1
  50. package/dist/device-voice-reply.d.ts.map +1 -1
  51. package/dist/device-voice-reply.js +15 -3
  52. package/dist/device-voice-reply.js.map +1 -1
  53. package/dist/device-voice.d.ts +37 -0
  54. package/dist/device-voice.d.ts.map +1 -1
  55. package/dist/device-voice.js +130 -1
  56. package/dist/device-voice.js.map +1 -1
  57. package/dist/diag-analyzer.d.ts.map +1 -1
  58. package/dist/diag-analyzer.js +1 -0
  59. package/dist/diag-analyzer.js.map +1 -1
  60. package/dist/display-monitor.d.ts.map +1 -1
  61. package/dist/display-monitor.js +1 -0
  62. package/dist/display-monitor.js.map +1 -1
  63. package/dist/esp32-serial.d.ts +4 -0
  64. package/dist/esp32-serial.d.ts.map +1 -1
  65. package/dist/esp32-serial.js +6 -3
  66. package/dist/esp32-serial.js.map +1 -1
  67. package/dist/foundation-models-helper.d.ts +23 -0
  68. package/dist/foundation-models-helper.d.ts.map +1 -1
  69. package/dist/foundation-models-helper.js +32 -0
  70. package/dist/foundation-models-helper.js.map +1 -1
  71. package/dist/gateway-probe.js +1 -1
  72. package/dist/gateway-probe.js.map +1 -1
  73. package/dist/glance-frame.d.ts +64 -0
  74. package/dist/glance-frame.d.ts.map +1 -0
  75. package/dist/glance-frame.js +364 -0
  76. package/dist/glance-frame.js.map +1 -0
  77. package/dist/hook-codex-sessions.d.ts +40 -0
  78. package/dist/hook-codex-sessions.d.ts.map +1 -0
  79. package/dist/hook-codex-sessions.js +174 -0
  80. package/dist/hook-codex-sessions.js.map +1 -0
  81. package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -1
  82. package/dist/idotmatrix/idotmatrix-discover.js +7 -4
  83. package/dist/idotmatrix/idotmatrix-discover.js.map +1 -1
  84. package/dist/idotmatrix/idotmatrix-settings.d.ts +15 -0
  85. package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -1
  86. package/dist/idotmatrix/idotmatrix-settings.js +21 -0
  87. package/dist/idotmatrix/idotmatrix-settings.js.map +1 -1
  88. package/dist/index.d.ts +7 -0
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +88 -7
  91. package/dist/index.js.map +1 -1
  92. package/dist/linux-service.d.ts +52 -0
  93. package/dist/linux-service.d.ts.map +1 -0
  94. package/dist/linux-service.js +206 -0
  95. package/dist/linux-service.js.map +1 -0
  96. package/dist/logger.d.ts +6 -0
  97. package/dist/logger.d.ts.map +1 -1
  98. package/dist/logger.js +15 -0
  99. package/dist/logger.js.map +1 -1
  100. package/dist/mdns-discover.d.ts +19 -0
  101. package/dist/mdns-discover.d.ts.map +1 -0
  102. package/dist/mdns-discover.js +109 -0
  103. package/dist/mdns-discover.js.map +1 -0
  104. package/dist/model-catalog.d.ts.map +1 -1
  105. package/dist/model-catalog.js +1 -0
  106. package/dist/model-catalog.js.map +1 -1
  107. package/dist/modules/adb-module.js +1 -1
  108. package/dist/modules/adb-module.js.map +1 -1
  109. package/dist/output-parser.d.ts +9 -0
  110. package/dist/output-parser.d.ts.map +1 -1
  111. package/dist/output-parser.js +67 -16
  112. package/dist/output-parser.js.map +1 -1
  113. package/dist/passive-observer.d.ts +57 -8
  114. package/dist/passive-observer.d.ts.map +1 -1
  115. package/dist/passive-observer.js +210 -74
  116. package/dist/passive-observer.js.map +1 -1
  117. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
  118. package/dist/pixoo/pixoo-bridge.js +47 -6
  119. package/dist/pixoo/pixoo-bridge.js.map +1 -1
  120. package/dist/pixoo/pixoo-renderer.d.ts +2 -1
  121. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  122. package/dist/pixoo/pixoo-renderer.js +137 -8
  123. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  124. package/dist/pocket-autonomy.d.ts +78 -0
  125. package/dist/pocket-autonomy.d.ts.map +1 -0
  126. package/dist/pocket-autonomy.js +529 -0
  127. package/dist/pocket-autonomy.js.map +1 -0
  128. package/dist/pty-manager.d.ts.map +1 -1
  129. package/dist/pty-manager.js +3 -2
  130. package/dist/pty-manager.js.map +1 -1
  131. package/dist/python-ble-runtime.d.ts +2 -0
  132. package/dist/python-ble-runtime.d.ts.map +1 -1
  133. package/dist/python-ble-runtime.js +2 -1
  134. package/dist/python-ble-runtime.js.map +1 -1
  135. package/dist/remote-sessions.d.ts +64 -0
  136. package/dist/remote-sessions.d.ts.map +1 -0
  137. package/dist/remote-sessions.js +81 -0
  138. package/dist/remote-sessions.js.map +1 -0
  139. package/dist/review-runner.js +2 -2
  140. package/dist/review-runner.js.map +1 -1
  141. package/dist/session-aggregator.d.ts +2 -0
  142. package/dist/session-aggregator.d.ts.map +1 -1
  143. package/dist/session-aggregator.js +6 -1
  144. package/dist/session-aggregator.js.map +1 -1
  145. package/dist/session-focus-relay.d.ts +47 -1
  146. package/dist/session-focus-relay.d.ts.map +1 -1
  147. package/dist/session-focus-relay.js +130 -15
  148. package/dist/session-focus-relay.js.map +1 -1
  149. package/dist/session-push-channel.d.ts +31 -0
  150. package/dist/session-push-channel.d.ts.map +1 -0
  151. package/dist/session-push-channel.js +94 -0
  152. package/dist/session-push-channel.js.map +1 -0
  153. package/dist/session-registry.d.ts +126 -0
  154. package/dist/session-registry.d.ts.map +1 -1
  155. package/dist/session-registry.js +180 -3
  156. package/dist/session-registry.js.map +1 -1
  157. package/dist/subagent-timeline.d.ts +34 -0
  158. package/dist/subagent-timeline.d.ts.map +1 -0
  159. package/dist/subagent-timeline.js +156 -0
  160. package/dist/subagent-timeline.js.map +1 -0
  161. package/dist/timebox/timebox-discover.js +1 -1
  162. package/dist/timebox/timebox-discover.js.map +1 -1
  163. package/dist/tui/gauge.d.ts +4 -2
  164. package/dist/tui/gauge.d.ts.map +1 -1
  165. package/dist/tui/gauge.js +8 -4
  166. package/dist/tui/gauge.js.map +1 -1
  167. package/dist/tui/renderer.d.ts.map +1 -1
  168. package/dist/tui/renderer.js +28 -0
  169. package/dist/tui/renderer.js.map +1 -1
  170. package/dist/types.d.ts +1 -1
  171. package/dist/types.d.ts.map +1 -1
  172. package/dist/types.js.map +1 -1
  173. package/dist/usage-api.d.ts +43 -0
  174. package/dist/usage-api.d.ts.map +1 -1
  175. package/dist/usage-api.js +114 -9
  176. package/dist/usage-api.js.map +1 -1
  177. package/dist/usage-event.d.ts +19 -0
  178. package/dist/usage-event.d.ts.map +1 -1
  179. package/dist/usage-event.js +35 -0
  180. package/dist/usage-event.js.map +1 -1
  181. package/dist/utils/project-name.d.ts.map +1 -1
  182. package/dist/utils/project-name.js +1 -0
  183. package/dist/utils/project-name.js.map +1 -1
  184. package/dist/version-check.d.ts.map +1 -1
  185. package/dist/version-check.js +3 -2
  186. package/dist/version-check.js.map +1 -1
  187. package/dist/voice-assistant.d.ts.map +1 -1
  188. package/dist/voice-assistant.js +2 -1
  189. package/dist/voice-assistant.js.map +1 -1
  190. package/dist/voice.d.ts.map +1 -1
  191. package/dist/voice.js +1 -0
  192. package/dist/voice.js.map +1 -1
  193. package/dist/weather.d.ts +72 -0
  194. package/dist/weather.d.ts.map +1 -0
  195. package/dist/weather.js +206 -0
  196. package/dist/weather.js.map +1 -0
  197. package/dist/windows-service.js +5 -5
  198. package/dist/windows-service.js.map +1 -1
  199. package/fm-helper/AgentDeckFMHelper.swift +200 -0
  200. package/package.json +15 -13
  201. package/src/idotmatrix/identity_generated.py +50 -0
  202. package/src/idotmatrix/scan.py +33 -13
@@ -22,7 +22,9 @@ import { BridgeLogStream } from './log-stream.js';
22
22
  import { PassiveSessionObserver } from './passive-observer.js';
23
23
  import { SessionTimelineRelay } from './session-timeline-relay.js';
24
24
  import { SessionFocusRelay } from './session-focus-relay.js';
25
- import { updatePushState } from './session-aggregator.js';
25
+ import { SubagentTimelineTracker } from './subagent-timeline.js';
26
+ import { getRemoteSession, getRemoteSender, listRemoteEnriched, } from './remote-sessions.js';
27
+ import { createPushChannelHandler } from './session-push-channel.js';
26
28
  import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
27
29
  import { registerPending, resolvePending, sweepStalePending, drainAllPending, isPendingRequest } from './permission-resolver.js';
28
30
  import { shouldHoldPreToolUse, gateReleased, buildGateQuestion, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, } from './observed-steering.js';
@@ -33,7 +35,7 @@ import { setSerialCommandSink, setSerialVoiceSink, setSerialQuiesceCheck, sendSe
33
35
  import { parsePeripheralMappings, resolvePeripheralAction, commandForAction } from './peripheral-mapping.js';
34
36
  import { DeviceVoiceCollector } from './device-voice.js';
35
37
  import { DevicePhotoCollector } from './device-photo.js';
36
- import { transcribeWithHelper, synthesizeWavWithHelper } from './foundation-models-helper.js';
38
+ import { transcribeWithHelper, synthesizeWavWithHelper, recordWithHelper, stopHelperRecording, speakWithHelper, } from './foundation-models-helper.js';
37
39
  import { enqueueOpenCodeCommand, pollOpenCodeCommands } from './opencode-steering.js';
38
40
  import { runSessionReview, reviewSnapshot } from './review-runner.js';
39
41
  /** Observed-session device approval gate (PreToolUse hold). Default ON — the
@@ -54,13 +56,15 @@ import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
54
56
  import { handlePixooWake } from './pixoo/pixoo-client.js';
55
57
  import { triggerMdnsRecovery } from './mdns.js';
56
58
  import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
57
- import { enableDebugLog, debug } from './logger.js';
59
+ import { enableDebugLog, debug, debugThrottled } from './logger.js';
60
+ import { CodexOtelTracker, CODEX_OTEL_TRACES_PATH, spanNameSummary } from './codex-otel.js';
61
+ import { HookCodexSessions } from './hook-codex-sessions.js';
58
62
  import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
59
63
  import { FallbackTaskTimeline } from './fallback-task-timeline.js';
60
64
  import { handleApmeRequest } from './apme/http.js';
61
65
  import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
62
66
  import { transcriptTimelineForSession, lastAssistantTextFromTranscript, lastAssistantTextForSession, } from './session-transcript-timeline.js';
63
- import { DeviceVoiceReplyRouter, speakableReply, } from './device-voice-reply.js';
67
+ import { DeviceVoiceReplyRouter, speakableReply, pcmFromWav, } from './device-voice-reply.js';
64
68
  import { rawSessionId } from '@agentdeck/shared';
65
69
  import { lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
66
70
  import { callFoundationModelsHelper } from './foundation-models-helper.js';
@@ -72,9 +76,15 @@ import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js
72
76
  import { loadTimeboxDevices } from './timebox/timebox-settings.js';
73
77
  import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
74
78
  import { injectOpenClawSession } from './openclaw-session.js';
75
- import { buildCardFeed, applyOutboxDecisions, FeedPullTracker, formatFeedPull, normalizeClientIp, } from './card-feed.js';
76
- import { CARD_FEED_PATH, CARD_OUTBOX_PATH } from '@agentdeck/shared';
77
- import { readFileSync, statSync } from 'fs';
79
+ import { buildCardFeed, buildGlance, applyOutboxDecisions, FeedPullTracker, formatFeedPull, normalizeClientIp, parsePullTelemetry, } from './card-feed.js';
80
+ import { threadModule } from './card-modules.js';
81
+ import { AutonomousPocketEngine, createAutonomousPocketModules, parsePocketAutonomyConfig, } from './pocket-autonomy.js';
82
+ import { WeatherProvider, parseWeatherSettings } from './weather.js';
83
+ import { CalendarProvider, parseCalendarSettings } from './calendar.js';
84
+ import { renderGlanceFrame, GLANCE_FRAME_BOARDS } from './glance-frame.js';
85
+ import { mergeRelayedSessionUsage } from './usage-event.js';
86
+ import { CARD_FEED_PATH, CARD_OUTBOX_PATH, GLANCE_FRAME_PATH } from '@agentdeck/shared';
87
+ import { readFileSync, statSync, writeFileSync, appendFileSync } from 'fs';
78
88
  import { readFile, rm } from 'fs/promises';
79
89
  import { tmpdir } from 'os';
80
90
  import { join } from 'path';
@@ -121,6 +131,76 @@ function listWifiEsp32Devices() {
121
131
  * other observable — a battery client with an empty outbox never identifies
122
132
  * itself and never opens a socket. */
123
133
  const feedPulls = new FeedPullTracker();
134
+ /** Daemon-owned personal feed. It persists aggregate bandit state only — no
135
+ * card copy, session names, calendar titles or weather locations. */
136
+ const pocketAutonomy = new AutonomousPocketEngine({ onError: (message) => log(`[agentdeck] ${message}`) });
137
+ const pocketCardModules = [threadModule, ...createAutonomousPocketModules(pocketAutonomy)];
138
+ // THREAD is a live AgentDeck session digest. It remains available to legacy
139
+ // feed consumers, while the dedicated Pocket surface receives only portable,
140
+ // daemon-authored reading cards.
141
+ const pocketReaderModules = pocketCardModules.filter((module) => module.id !== 'thread');
142
+ /** Glance weather for the card-feed sleep dashboard. Config is read from
143
+ * settings.json per pull (cheap; honors edits without a restart); the
144
+ * provider caches the upstream fetch itself. */
145
+ const glanceWeather = new WeatherProvider();
146
+ /** Glance schedule (today's ICS events) — same config/caching contract as
147
+ * weather: settings.json `calendar: {ics}`, 30 min cache, never throws. */
148
+ const glanceCalendar = new CalendarProvider();
149
+ const stagedFwByBoard = new Map();
150
+ const stagedFwStatePath = () => join(homedir(), '.agentdeck', 'staged-fw.json');
151
+ function loadStagedFw() {
152
+ try {
153
+ const raw = JSON.parse(readFileSync(stagedFwStatePath(), 'utf8'));
154
+ for (const [board, s] of Object.entries(raw)) {
155
+ if (s && typeof s.firmwarePath === 'string' && typeof s.md5 === 'string' && typeof s.size === 'number') {
156
+ stagedFwByBoard.set(board, s);
157
+ }
158
+ }
159
+ }
160
+ catch { /* no staging file — nothing staged */ }
161
+ }
162
+ loadStagedFw();
163
+ function saveStagedFw() {
164
+ try {
165
+ writeFileSync(stagedFwStatePath(), JSON.stringify(Object.fromEntries(stagedFwByBoard), null, 2));
166
+ }
167
+ catch (err) {
168
+ log(`[agentdeck] staged-fw persist failed: ${err instanceof Error ? err.message : String(err)}`);
169
+ }
170
+ }
171
+ /** Stage a build for a board. Reads the file once to fingerprint it; the file
172
+ * itself is re-read at device download time (a rebuild at the same path is
173
+ * re-fingerprinted by re-staging). */
174
+ function stageEsp32Fw(board, firmwarePath) {
175
+ const firmware = readFileSync(firmwarePath);
176
+ const md5 = createHash('md5').update(firmware).digest('hex');
177
+ stagedFwByBoard.set(board, { firmwarePath, md5, size: firmware.length, stagedAt: Date.now() });
178
+ saveStagedFw();
179
+ log(`[agentdeck] staged firmware for ${board}: ${firmware.length} bytes md5=${md5} — installs on its next feed pull`);
180
+ return { board, bytes: firmware.length, md5 };
181
+ }
182
+ /** The staged advert for a feed response, re-validated against the file on
183
+ * disk so a rebuilt-in-place binary never adverts a stale md5. */
184
+ function stagedFwAdvert(board) {
185
+ if (!board)
186
+ return undefined;
187
+ const staged = stagedFwByBoard.get(board);
188
+ if (!staged)
189
+ return undefined;
190
+ try {
191
+ const firmware = readFileSync(staged.firmwarePath);
192
+ const md5 = createHash('md5').update(firmware).digest('hex');
193
+ if (md5 !== staged.md5 || firmware.length !== staged.size) {
194
+ stagedFwByBoard.set(board, { ...staged, md5, size: firmware.length });
195
+ saveStagedFw();
196
+ return { size: firmware.length, md5 };
197
+ }
198
+ return { size: staged.size, md5: staged.md5 };
199
+ }
200
+ catch {
201
+ return undefined; // file gone — advert nothing rather than a dead download
202
+ }
203
+ }
124
204
  /** Name a pulling client from the WiFi WS roster. A board that has been
125
205
  * deep-sleeping may have aged out of that roster, which is why the tracker
126
206
  * keeps its own IP→board memory once it has learned one. */
@@ -219,6 +299,12 @@ function registerWifiEsp32(d, ws) {
219
299
  if (previous?.uptimeSec != null && uptimeSec != null && uptimeSec + 10 < previous.uptimeSec) {
220
300
  log(`[agentdeck] ESP32 reboot observed: ${key} uptime ${previous.uptimeSec}s -> ${uptimeSec}s reset=${resetReason ?? 'unknown'} code=${resetReasonCode ?? 'unknown'}`);
221
301
  }
302
+ // Plain-log a board APPEARING on WS (absent or stale before) — a sleeping
303
+ // battery board's brief interactive window is exactly when a WS OTA can
304
+ // land, and this line is the only wake signal an operator/script can tail.
305
+ if (!previous || Date.now() - previous.lastSeenMs > 90_000) {
306
+ log(`[agentdeck] ESP32 WiFi registered: ${key}`);
307
+ }
222
308
  debug('daemon', `WiFi ESP32 registered: ${key} v${String(d.version ?? '')} build=${String(d.buildHash ?? '')} uptime=${String(uptimeSec ?? '')} reset=${String(resetReason ?? '')}`);
223
309
  }
224
310
  function touchWifiEsp32Socket(ws) {
@@ -563,6 +649,7 @@ async function fetchUsageViaWs(siblings) {
563
649
  extraUsageMonthlyLimit: evt.extraUsageMonthlyLimit ?? null,
564
650
  extraUsageUsedCredits: evt.extraUsageUsedCredits ?? null,
565
651
  extraUsageUtilization: evt.extraUsageUtilization ?? null,
652
+ scopedLimits: Array.isArray(evt.scopedLimits) ? evt.scopedLimits : [],
566
653
  inferredBillingType: null,
567
654
  });
568
655
  }
@@ -579,7 +666,8 @@ async function fetchUsageViaWs(siblings) {
579
666
  }
580
667
  return null;
581
668
  }
582
- async function fetchUsageRelayed(selfPort) {
669
+ /** @internal Exported for the relay-fallback regression test. */
670
+ export async function fetchUsageRelayed(selfPort) {
583
671
  const sessions = listActiveSessions();
584
672
  const siblings = sessions.filter(s => s.port !== selfPort && s.agentType !== 'daemon');
585
673
  if (siblings.length > 0) {
@@ -589,8 +677,15 @@ async function fetchUsageRelayed(selfPort) {
589
677
  const wsResult = await fetchUsageViaWs(siblings);
590
678
  if (wsResult)
591
679
  return wsResult;
592
- debug('daemon', 'Siblings exist but relay failed skipping direct API');
593
- return null;
680
+ // Relay is only an optimization to avoid multiple API pollers. A PTY session
681
+ // bridge only refreshes usage when it has *direct* inbound WS clients, but
682
+ // dashboards/Stream Deck connect to the daemon (sole entry point) — so a
683
+ // sibling with no direct clients never populates its /usage and the relay
684
+ // yields null forever, freezing the daemon's usage at its startup value.
685
+ // Fall back to the direct API; the shared usage-cache.json (120s TTL) dedupes
686
+ // so this can't cause a 429 storm even if a sibling is also actively fetching.
687
+ debug('daemon', 'Siblings exist but relay yielded nothing fresh — falling back to direct API (shared file cache dedupes)');
688
+ return fetchUsageFromApi();
594
689
  }
595
690
  debug('daemon', 'No siblings, using direct API');
596
691
  return fetchUsageFromApi();
@@ -610,6 +705,11 @@ export function enrichGatewayTimelineEntry(entry) {
610
705
  ...entry,
611
706
  agentType: entry.agentType ?? 'openclaw',
612
707
  projectName: entry.projectName ?? 'OpenClaw',
708
+ // Devices and the voice-reply router address the gateway session as
709
+ // 'openclaw-gateway'; without this stamp its chat_response rows carry no
710
+ // sessionId, so a board armed for a spoken reply never matches the turn
711
+ // completion and the reply silently never plays (observed 2026-07-31).
712
+ sessionId: entry.sessionId ?? 'openclaw-gateway',
613
713
  };
614
714
  }
615
715
  /**
@@ -638,6 +738,9 @@ export function enrichGatewayTimelineEntry(entry) {
638
738
  * daemon change.
639
739
  */
640
740
  export function classifyObservedHookEvent(eventName, mapped) {
741
+ if (eventName === 'codex_subagent_start' || eventName === 'codex_subagent_stop') {
742
+ return { boundary: eventName, agentType: 'codex-cli' };
743
+ }
641
744
  const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
642
745
  .exec(eventName);
643
746
  if (!prefixed)
@@ -858,6 +961,13 @@ export async function startDaemon(opts) {
858
961
  // that is provably alive (agentic turns regularly outlive any fixed age
859
962
  // threshold). Declared before the HTTP handlers that populate it.
860
963
  const hookSessionsSeen = new Set();
964
+ // Codex OTel span state. Declared before the HTTP server because the
965
+ // `/otel/v1/traces` handler closes over it — Codex's exporter can POST before
966
+ // the rest of daemon startup finishes.
967
+ const codexOtel = new CodexOtelTracker();
968
+ // Codex sessions known only from `codex_*` hooks — the backstop for when the
969
+ // process scan can't see one (lsof timeout, no rollout held open).
970
+ const hookCodexSessions = new HookCodexSessions();
861
971
  // Declare early — HTTP /health handler references this in its closure.
862
972
  // Must be declared before the HTTP server so it's initialized (not in TDZ)
863
973
  // when the first /health request arrives.
@@ -937,6 +1047,9 @@ export async function startDaemon(opts) {
937
1047
  * read this binding at request time rather than close over one in its TDZ.
938
1048
  */
939
1049
  let speakToBoard = null;
1050
+ // Assigned after BridgeCore exists. The HTTP handler reads it only after
1051
+ // startup; nullable keeps the tiny listen→core initialization window safe.
1052
+ let subagentTimeline = null;
940
1053
  // ===== HTTP server =====
941
1054
  const httpServer = createServer((req, res) => {
942
1055
  // APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
@@ -971,6 +1084,11 @@ export async function startDaemon(opts) {
971
1084
  gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
972
1085
  uptime: process.uptime(), port, pid: process.pid,
973
1086
  pairingToken: core.authToken,
1087
+ // Capability: this daemon drives remote sessions down their own push
1088
+ // socket (session_focus_down / session_command_down / session_event_up).
1089
+ // Workers require this flag before remote-attaching; the Swift daemon
1090
+ // does not advertise it and is therefore never selected as a remote hub.
1091
+ sameSocketControl: true,
974
1092
  modules: moduleHealthProvider(),
975
1093
  apme: apme
976
1094
  ? {
@@ -1067,7 +1185,7 @@ export async function startDaemon(opts) {
1067
1185
  // Reply on the board's own transport (serial when it is cabled), the
1068
1186
  // same way the chunked path did — the HTTP response only confirms
1069
1187
  // receipt of the bytes.
1070
- await finishPhotoCapture(saved, photoResultSinkFor(board));
1188
+ await finishPhotoCapture(saved, boardResultSinkFor(board));
1071
1189
  return { ok: true, bytes: saved.bytes, path: saved.path };
1072
1190
  })().then((result) => {
1073
1191
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1078,6 +1196,113 @@ export async function startDaemon(opts) {
1078
1196
  });
1079
1197
  return;
1080
1198
  }
1199
+ // Device voice upload — a whole utterance of raw PCM16LE as one request
1200
+ // body. Live streaming lost audio on both of the ips10's transports
1201
+ // (115200-baud serial ceiling; hosted-C6 WS stalls overflowing the DRAM
1202
+ // ring), and transcription only starts once the utterance is complete, so
1203
+ // buffering on-device and letting TCP handle delivery loses nothing.
1204
+ // Mirrors /esp32/photo, including replying on the board's own transport.
1205
+ if (req.method === 'POST' && pathname === '/esp32/voice') {
1206
+ const ip = req.socket.remoteAddress ?? '';
1207
+ if (!isLocalConnection(ip)) {
1208
+ const token = parsedUrl.searchParams.get('token') ?? '';
1209
+ if (!validateToken(token)) {
1210
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1211
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1212
+ return;
1213
+ }
1214
+ }
1215
+ (async () => {
1216
+ const chunks = [];
1217
+ let total = 0;
1218
+ for await (const chunk of req) {
1219
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1220
+ total += buf.length;
1221
+ // 16 kHz mono PCM16 for the firmware's 30 s runaway cap is 960 KB.
1222
+ if (total > 2 * 1024 * 1024)
1223
+ throw new Error('utterance_too_large');
1224
+ chunks.push(buf);
1225
+ }
1226
+ if (total === 0)
1227
+ throw new Error('empty_body');
1228
+ const board = parsedUrl.searchParams.get('board') ?? 'esp32';
1229
+ const saved = deviceVoice.saveDirect(Buffer.concat(chunks), {
1230
+ board,
1231
+ sessionId: parsedUrl.searchParams.get('sessionId') ?? '',
1232
+ sampleRate: Number(parsedUrl.searchParams.get('rate') ?? 0) || 16000,
1233
+ durationMs: Number(parsedUrl.searchParams.get('ms') ?? 0) || 0,
1234
+ });
1235
+ // The HTTP response only confirms receipt of the bytes; the
1236
+ // transcript/outcome goes to the board as a voice_result frame.
1237
+ await finishVoiceCapture(saved, boardResultSinkFor(board));
1238
+ return { ok: true, bytes: total };
1239
+ })().then((result) => {
1240
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1241
+ res.end(JSON.stringify(result));
1242
+ }).catch((err) => {
1243
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1244
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
1245
+ });
1246
+ return;
1247
+ }
1248
+ // Staged spoken reply for an `audio_http_pull` board — raw PCM16LE body.
1249
+ // The board fetches at its own pace (TCP flow control), which is what
1250
+ // keeps the hosted-C6 RX path from being burst-fed and asserting.
1251
+ if (req.method === 'GET' && pathname === '/esp32/voice-reply') {
1252
+ const ip = req.socket.remoteAddress ?? '';
1253
+ if (!isLocalConnection(ip)) {
1254
+ const token = parsedUrl.searchParams.get('token') ?? '';
1255
+ if (!validateToken(token)) {
1256
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1257
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1258
+ return;
1259
+ }
1260
+ }
1261
+ const board = parsedUrl.searchParams.get('board') ?? '';
1262
+ const staged = board ? stagedVoiceReplies.get(board) : undefined;
1263
+ if (!staged) {
1264
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1265
+ res.end(JSON.stringify({ error: 'no_staged_reply' }));
1266
+ return;
1267
+ }
1268
+ // Kept until TTL rather than deleted here: an aborted download may retry.
1269
+ res.writeHead(200, {
1270
+ 'Content-Type': 'application/octet-stream',
1271
+ 'Content-Length': staged.pcm.length,
1272
+ 'X-Sample-Rate': String(staged.sampleRate),
1273
+ });
1274
+ // Paced writes, NOT res.end(pcm): a single end() hands the whole body
1275
+ // to the kernel, which blasts a TCP-initial-window-sized segment burst.
1276
+ // TCP flow control lives in lwIP — it cannot protect the hosted C6→P4
1277
+ // handoff underneath it, whose RX pool exhausted and ASSERTED the board
1278
+ // one second into the first pull (`sdio_rx_get_buffer (*buf)`,
1279
+ // 2026-07-31 12:06). 2 KB per 15 ms ≈ 133 KB/s keeps at most a couple
1280
+ // of segments in flight while still beating playback rate 4x.
1281
+ {
1282
+ // 8 KB / 10 ms ≈ 800 KB/s — a 2 MB answer lands in ~2.5 s. The
1283
+ // original 2 KB / 15 ms (~133 KB/s) predated the PSRAM-mempool core
1284
+ // fix and made long replies take 15+ s to start, which read as "no
1285
+ // playback"; with the hosted mempool in PSRAM the burst constraint is
1286
+ // gone and only gentle pacing is kept as hygiene.
1287
+ const CHUNK = 8192;
1288
+ const GAP_MS = 10;
1289
+ let off = 0;
1290
+ const writeNext = () => {
1291
+ if (res.destroyed)
1292
+ return;
1293
+ if (off >= staged.pcm.length) {
1294
+ res.end();
1295
+ return;
1296
+ }
1297
+ const end = Math.min(off + CHUNK, staged.pcm.length);
1298
+ res.write(staged.pcm.subarray(off, end));
1299
+ off = end;
1300
+ setTimeout(writeNext, GAP_MS).unref?.();
1301
+ };
1302
+ writeNext();
1303
+ }
1304
+ return;
1305
+ }
1081
1306
  if (req.method === 'POST' && pathname === '/esp32/ota') {
1082
1307
  (async () => {
1083
1308
  const body = await readJsonBody(req);
@@ -1086,6 +1311,11 @@ export async function startDaemon(opts) {
1086
1311
  if (!target || !firmwarePath) {
1087
1312
  throw new Error('target and firmwarePath are required');
1088
1313
  }
1314
+ // Pull-OTA staging: remember the build; the board installs it on its
1315
+ // next feed pull (no live WS needed). See stagedFwByBoard.
1316
+ if (body.stage === true) {
1317
+ return { ok: true, staged: true, ...stageEsp32Fw(target, firmwarePath) };
1318
+ }
1089
1319
  return performWifiEsp32Ota(core, target, firmwarePath);
1090
1320
  })().then((result) => {
1091
1321
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1096,10 +1326,118 @@ export async function startDaemon(opts) {
1096
1326
  });
1097
1327
  return;
1098
1328
  }
1329
+ // ===== Pull OTA (feed-carried) — staged firmware download =====
1330
+ // The board saw `fw: {size, md5}` on its feed pull and fetches the image
1331
+ // here. Auth mirrors the feed routes (LAN needs the pairing token).
1332
+ if (req.method === 'GET' && pathname === '/esp32/fw') {
1333
+ const ip = req.socket.remoteAddress ?? '';
1334
+ if (!isLocalConnection(ip)) {
1335
+ const token = parsedUrl.searchParams.get('token') ?? '';
1336
+ if (!validateToken(token)) {
1337
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1338
+ res.end(JSON.stringify({ error: 'Unauthorized — token required for firmware download' }));
1339
+ return;
1340
+ }
1341
+ }
1342
+ const board = parsedUrl.searchParams.get('board') ?? '';
1343
+ const staged = stagedFwByBoard.get(board);
1344
+ if (!staged) {
1345
+ res.writeHead(404, { 'Content-Type': 'application/json' });
1346
+ res.end(JSON.stringify({ error: `nothing staged for board "${board}"` }));
1347
+ return;
1348
+ }
1349
+ try {
1350
+ const firmware = readFileSync(staged.firmwarePath);
1351
+ // Resumable download: `?from=<offset>` serves the remainder. A 5 MB
1352
+ // image over a marginal link never survives one connection (this
1353
+ // bridge host's two-NICs-on-one-subnet setup black-holes full-MTU
1354
+ // segments in one direction, and a restart-from-zero download can
1355
+ // never converge there). The device appends whatever arrives and
1356
+ // re-asks from where it stopped, so partial transfers accumulate.
1357
+ const fromRaw = Number(parsedUrl.searchParams.get('from') ?? 0);
1358
+ const from = Number.isFinite(fromRaw) && fromRaw > 0 ? Math.min(Math.floor(fromRaw), firmware.length) : 0;
1359
+ const body = from > 0 ? firmware.subarray(from) : firmware;
1360
+ log(`[agentdeck] pull-OTA download: ${board} (${ip}) ← ${body.length} bytes`
1361
+ + (from > 0 ? ` (resume from ${from}/${firmware.length})` : ''));
1362
+ res.writeHead(from > 0 ? 206 : 200, {
1363
+ 'Content-Type': 'application/octet-stream',
1364
+ 'Content-Length': String(body.length),
1365
+ ...(from > 0
1366
+ ? { 'Content-Range': `bytes ${from}-${Math.max(from, firmware.length - 1)}/${firmware.length}` }
1367
+ : {}),
1368
+ 'X-FW-MD5': staged.md5,
1369
+ 'Cache-Control': 'no-store',
1370
+ });
1371
+ res.end(body);
1372
+ }
1373
+ catch (err) {
1374
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1375
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
1376
+ }
1377
+ return;
1378
+ }
1099
1379
  // ===== Card Feed pull sync (M6) — wake-sync-sleep battery clients =====
1100
1380
  // Auth mirrors /apme: local connections are free; anything else needs the
1101
1381
  // pairing token (?token=). Devices hold it from provisioning; /health
1102
1382
  // exposes it for LAN pairing.
1383
+ // ===== Glance Frame (M8) — daemon-rendered pixels of the glance =====
1384
+ // The device-side glance renderer is the offline fallback; this is the
1385
+ // rich face. `?format=png` returns the exact dithered panel pixels, so a
1386
+ // browser preview IS the production frame (protocol.ts § Glance Frame).
1387
+ if (req.method === 'GET' && pathname === GLANCE_FRAME_PATH) {
1388
+ const ip = req.socket.remoteAddress ?? '';
1389
+ if (!isLocalConnection(ip)) {
1390
+ const token = parsedUrl.searchParams.get('token') ?? '';
1391
+ if (!validateToken(token)) {
1392
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1393
+ res.end(JSON.stringify({ error: 'Unauthorized — token required for glance frame' }));
1394
+ return;
1395
+ }
1396
+ }
1397
+ (async () => {
1398
+ const board = parsedUrl.searchParams.get('board') ?? 'xteink_x3';
1399
+ const preset = GLANCE_FRAME_BOARDS[board];
1400
+ const w = Number(parsedUrl.searchParams.get('w'));
1401
+ const h = Number(parsedUrl.searchParams.get('h'));
1402
+ const geometry = Number.isFinite(w) && Number.isFinite(h) && w >= 128 && h >= 128 && w <= 1600 && h <= 1600
1403
+ ? { width: Math.round(w), height: Math.round(h), landscape: w > h }
1404
+ : preset ?? GLANCE_FRAME_BOARDS.xteink_x3;
1405
+ const sessions = await core.buildSessionsSnapshot();
1406
+ const glance = buildGlance({
1407
+ sessions: sessions,
1408
+ usage: core.buildUsage(),
1409
+ weather: await glanceWeather.get(parseWeatherSettings(loadDaemonSettings())),
1410
+ events: await glanceCalendar.get(parseCalendarSettings(loadDaemonSettings())),
1411
+ });
1412
+ const d = new Date();
1413
+ const serverHm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
1414
+ const frame = await renderGlanceFrame({ glance, serverHm, geometry });
1415
+ const headers = {
1416
+ 'X-Frame-Width': String(frame.width),
1417
+ 'X-Frame-Height': String(frame.height),
1418
+ 'X-Frame-Sig': frame.sig,
1419
+ 'Cache-Control': 'no-store',
1420
+ };
1421
+ // Conditional fetch, one layer down from the feed's deckSig: matching
1422
+ // sig → 304, no body, the panel keeps holding the frame it has.
1423
+ if (parsedUrl.searchParams.get('sig') === frame.sig) {
1424
+ res.writeHead(304, headers);
1425
+ res.end();
1426
+ return;
1427
+ }
1428
+ if (parsedUrl.searchParams.get('format') === 'png') {
1429
+ res.writeHead(200, { ...headers, 'Content-Type': 'image/png' });
1430
+ res.end(await frame.png());
1431
+ return;
1432
+ }
1433
+ res.writeHead(200, { ...headers, 'Content-Type': 'application/octet-stream' });
1434
+ res.end(frame.packed);
1435
+ })().catch((err) => {
1436
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1437
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
1438
+ });
1439
+ return;
1440
+ }
1103
1441
  if ((req.method === 'GET' && pathname === CARD_FEED_PATH)
1104
1442
  || (req.method === 'POST' && pathname === CARD_OUTBOX_PATH)) {
1105
1443
  const ip = req.socket.remoteAddress ?? '';
@@ -1114,7 +1452,33 @@ export async function startDaemon(opts) {
1114
1452
  (async () => {
1115
1453
  if (req.method === 'GET') {
1116
1454
  const sessions = await core.buildSessionsSnapshot();
1117
- const feed = buildCardFeed(sessions);
1455
+ const settings = loadDaemonSettings();
1456
+ pocketAutonomy.configure(parsePocketAutonomyConfig(settings));
1457
+ // Sleep-dashboard glance: provider quota + work wrap-up + weather.
1458
+ // Weather resolves from cache almost always (30 min window) and is
1459
+ // bounded by its own fetch timeout, so the pull stays fast.
1460
+ const glance = buildGlance({
1461
+ sessions: sessions,
1462
+ usage: core.buildUsage(),
1463
+ weather: await glanceWeather.get(parseWeatherSettings(settings)),
1464
+ events: await glanceCalendar.get(parseCalendarSettings(settings)),
1465
+ });
1466
+ const now = Date.now();
1467
+ const pocketReader = parsedUrl.searchParams.get('surface') === 'pocket-reader';
1468
+ const feed = buildCardFeed(sessions, now, pocketReader ? pocketReaderModules : pocketCardModules, {
1469
+ glance,
1470
+ echoSig: parsedUrl.searchParams.get('sig') ?? undefined,
1471
+ includeSessions: !pocketReader,
1472
+ });
1473
+ // Pull-OTA advert: rides full AND `unchanged` responses, so a
1474
+ // sleeping board learns about a staged build on any pull. Board
1475
+ // identity from the query (newer firmware) or the IP memory.
1476
+ const pullBoard = parsedUrl.searchParams.get('board') ?? boardForClientIp(ip);
1477
+ if (!feed.unchanged)
1478
+ pocketAutonomy.observeDelivery(feed.cards, now, pullBoard ?? undefined);
1479
+ const fwAdvert = stagedFwAdvert(pullBoard ?? undefined);
1480
+ if (fwAdvert)
1481
+ feed.fw = fwAdvert;
1118
1482
  // A sleeping client's whole visit is this one request: no body, no
1119
1483
  // board id, nothing pushed. Record it, because the gap between two
1120
1484
  // pulls is the only evidence that the timer wake fired at all.
@@ -1122,6 +1486,8 @@ export async function startDaemon(opts) {
1122
1486
  log(`[agentdeck] ${formatFeedPull(feedPulls.record(ip, {
1123
1487
  cards: feed.cards.length,
1124
1488
  nextPullSec: feed.nextPullSec,
1489
+ unchanged: feed.unchanged === true,
1490
+ telemetry: parsePullTelemetry(parsedUrl.searchParams),
1125
1491
  }))}`);
1126
1492
  return feed;
1127
1493
  }
@@ -1136,6 +1502,7 @@ export async function startDaemon(opts) {
1136
1502
  sessions: sessions,
1137
1503
  isPendingRequest,
1138
1504
  dispatch: (cmd) => handleDeviceCommand(cmd),
1505
+ modules: pocketCardModules,
1139
1506
  });
1140
1507
  const applied = result.results.filter((r) => r.status === 'applied').length;
1141
1508
  if (result.results.length > 0) {
@@ -1175,6 +1542,12 @@ export async function startDaemon(opts) {
1175
1542
  billingType: snap.billingType,
1176
1543
  },
1177
1544
  wsClients: core.wsServer.getClientCount(),
1545
+ // Codex observation backstops — which sessions each source believes in.
1546
+ // A session present here but absent from sessions_list means the ps
1547
+ // observer found it too and won, which is the expected steady state.
1548
+ hookCodexSessions: hookCodexSessions.snapshot(),
1549
+ codexOtelThreads: codexOtel.snapshot(),
1550
+ pocketAutonomy: pocketAutonomy.diagnostics(),
1178
1551
  recentJournal: [],
1179
1552
  ptyTail: '',
1180
1553
  journalDir: join(homedir(), '.agentdeck'),
@@ -1272,6 +1645,41 @@ export async function startDaemon(opts) {
1272
1645
  // codex/opencode *state* is owned by the passive observer's turn
1273
1646
  // semantics, not these hooks.
1274
1647
  const { boundary, agentType: hookAgentType } = classifyObservedHookEvent(eventName, mapped);
1648
+ const earlyHookSid = typeof json.session_id === 'string' && json.session_id
1649
+ ? json.session_id : 'daemon-hook';
1650
+ const earlyHookCwd = (typeof json.cwd === 'string' ? json.cwd
1651
+ : (typeof json.project_path === 'string' ? json.project_path : '')) || '';
1652
+ const earlyHookProject = (typeof json.project_name === 'string' && json.project_name)
1653
+ ? json.project_name
1654
+ : (earlyHookCwd ? earlyHookCwd.split('/').filter(Boolean).pop() : undefined);
1655
+ const childHook = subagentTimeline?.handle({
1656
+ eventName,
1657
+ payload: json,
1658
+ sessionId: earlyHookSid,
1659
+ agentType: hookAgentType,
1660
+ projectName: earlyHookProject,
1661
+ }).childOnly === true;
1662
+ if (childHook) {
1663
+ // Child activity is observation-only. Never let child PreToolUse
1664
+ // reach the parent's approval, STOP, state, or APME paths.
1665
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1666
+ res.end(eventName === 'PreToolUse' || eventName === 'Stop'
1667
+ ? ''
1668
+ : JSON.stringify({ received: true }));
1669
+ return;
1670
+ }
1671
+ // Hook-derived Codex session rows. Placed after the child-hook return so
1672
+ // subagent lifecycle never drives the parent row, and kept independent
1673
+ // of the state machine: this only decides whether a Codex the process
1674
+ // scan can't see still has a session. See bridge/src/hook-codex-sessions.ts.
1675
+ if (eventName.startsWith('codex_')) {
1676
+ hookCodexSessions.note(eventName, {
1677
+ sessionId: typeof json.session_id === 'string' ? json.session_id : undefined,
1678
+ cwd: earlyHookCwd || undefined,
1679
+ projectName: earlyHookProject,
1680
+ toolName: typeof json.tool_name === 'string' ? json.tool_name : undefined,
1681
+ });
1682
+ }
1275
1683
  // State machine
1276
1684
  if (mapped === 'session_start')
1277
1685
  core.stateMachine.handleHookEvent('SessionStart', json);
@@ -1724,6 +2132,42 @@ export async function startDaemon(opts) {
1724
2132
  });
1725
2133
  return;
1726
2134
  }
2135
+ // Codex OTel exporter target. Codex (CLI and the ChatGPT.app desktop app)
2136
+ // POSTs OTLP/HTTP JSON spans here when `[otel.trace_exporter.otlp-http]`
2137
+ // points at the daemon. Only the Swift daemon used to implement this, so a
2138
+ // Node-daemon host answered 404 and dropped every span. Always 200: an OTel
2139
+ // exporter that sees errors backs off, and nothing here is worth telling
2140
+ // Codex about. See bridge/src/codex-otel.ts.
2141
+ if (req.method === 'POST' && (pathname === CODEX_OTEL_TRACES_PATH || pathname === '/v1/traces')) {
2142
+ let body = '';
2143
+ req.on('data', (c) => { body += c; if (body.length > 8_000_000)
2144
+ req.destroy(); });
2145
+ req.on('end', () => {
2146
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2147
+ res.end('{}');
2148
+ try {
2149
+ const json = body ? JSON.parse(body) : {};
2150
+ // Dev capture. Codex's span vocabulary is not a stable API, so when it
2151
+ // shifts the only way to re-derive the dispatch table is to look at
2152
+ // real batches: `AGENTDECK_CODEX_OTEL_DUMP=/tmp/otel.jsonl agentdeck
2153
+ // daemon start`. Bodies are pretty-printed and large — point it at a
2154
+ // scratch path and delete it afterwards.
2155
+ if (process.env.AGENTDECK_CODEX_OTEL_DUMP) {
2156
+ try {
2157
+ appendFileSync(process.env.AGENTDECK_CODEX_OTEL_DUMP, `${body}\n`);
2158
+ }
2159
+ catch { /* dev only */ }
2160
+ }
2161
+ if (codexOtel.ingest(json) === 0) {
2162
+ debugThrottled('codex-otel', 'no-recognized-spans', 60_000, `no recognized spans; len=${body.length} names=${spanNameSummary(json)}`);
2163
+ }
2164
+ }
2165
+ catch {
2166
+ debugThrottled('codex-otel', 'unparsable-body', 60_000, `unparsable body len=${body.length}`);
2167
+ }
2168
+ });
2169
+ return;
2170
+ }
1727
2171
  // OpenCode observer-plugin steering: the plugin long-polls here and
1728
2172
  // executes returned commands via its in-process SDK client (abort /
1729
2173
  // prompt). See bridge/src/opencode-steering.ts.
@@ -1955,6 +2399,9 @@ export async function startDaemon(opts) {
1955
2399
  httpServer,
1956
2400
  isDaemon: true,
1957
2401
  });
2402
+ subagentTimeline = new SubagentTimelineTracker((entry) => {
2403
+ core.bridgeTimeline.addEntry(entry, { bypassSuppression: true });
2404
+ });
1958
2405
  // Hooks resolve a fallback daemon port through daemon.json. Another process
1959
2406
  // can delete that file while this daemon remains healthy, which makes every
1960
2407
  // standalone Claude/Codex hook silently fall back to an empty :9120. Mirror
@@ -2097,6 +2544,12 @@ export async function startDaemon(opts) {
2097
2544
  timelineRelay.start();
2098
2545
  // Session focus relay — allows SD plugin to interact with a specific session via daemon
2099
2546
  const focusRelay = new SessionFocusRelay();
2547
+ // Remote (cross-machine) sessions aren't in sessions.json. Their only
2548
+ // reverse path is same-socket: drive the session down the live push socket
2549
+ // it opened — no inbound dial, so NAT'd / SSH-only workers just work.
2550
+ focusRelay.setSameSocketResolver((sessionId) => getRemoteSender(sessionId) ?? null);
2551
+ // Push-channel frames from session bridges (register / state / reverse events).
2552
+ const handlePushChannelMessage = createPushChannelHandler({ core, focusRelay });
2100
2553
  focusRelay.setEventHandler((evt) => {
2101
2554
  if (evt.type === 'state_update') {
2102
2555
  const focusedId = focusRelay.getFocusedSessionId();
@@ -2121,14 +2574,43 @@ export async function startDaemon(opts) {
2121
2574
  else if (evt.type === 'usage_update') {
2122
2575
  // Sync daemon cache with relay's already-adjusted values (prevents oscillation)
2123
2576
  const u = evt;
2577
+ const hasClaudeData = u.fiveHourPercent != null || u.sevenDayPercent != null;
2124
2578
  if (core.cachedApiUsage && u.fiveHourPercent != null) {
2125
2579
  core.cachedApiUsage.fiveHourPercent = u.fiveHourPercent;
2126
2580
  core.cachedApiUsage.fiveHourResetsAt = u.fiveHourResetsAt ?? null;
2127
2581
  core.cachedApiUsage.sevenDayPercent = u.sevenDayPercent ?? core.cachedApiUsage.sevenDayPercent;
2128
2582
  core.cachedApiUsage.sevenDayResetsAt = u.sevenDayResetsAt ?? core.cachedApiUsage.sevenDayResetsAt ?? null;
2583
+ // Scoped caps ride the same relay snapshot. The Swift daemon syncs them on
2584
+ // its equivalent live-update path; leaving them out here made the daemon's
2585
+ // own next broadcastUsage re-emit a stale scoped set until the next full
2586
+ // fetch — the exact drift DaemonServer.swift documents.
2587
+ core.cachedApiUsage.scopedLimits = Array.isArray(u.scopedLimits) ? u.scopedLimits : [];
2129
2588
  core.apiUsagePreAdjusted = true;
2130
2589
  }
2131
- core.wsServer.broadcast(evt);
2590
+ // A session bridge broadcasts usage on EVERY state change. In daemon-first
2591
+ // mode its snapshot usually has no model context, so the Claude quota model
2592
+ // gate (isClaudeSubscriptionModel) yields no percents and the event carries
2593
+ // usageStale=true. Forwarding that bare event verbatim clobbered the daemon's
2594
+ // own authoritative aggregate on the dashboard: the Claude gauge blanked to
2595
+ // "No usage data" on every state tick (the Codex gauge, which ignores global
2596
+ // staleness, stayed put — hence the Claude-only flicker).
2597
+ //
2598
+ // Suppressing the relay instead is NOT an option: usage_update is the ONLY
2599
+ // carrier of per-session tokens / cost / duration (state_update has none of
2600
+ // those fields), and the daemon's own buildUsage() reads a UsageTracker that
2601
+ // no PTY ever feeds, so its session half is zeros. Dropping the event would
2602
+ // blank the session numbers for every focus that legitimately carries no
2603
+ // Claude quota — Codex, OpenCode, API billing.
2604
+ //
2605
+ // So split the event by half instead of by event: the ACCOUNT half (quota,
2606
+ // scoped caps, extra usage, subscriptions, Codex/Ollama, usageStale) comes
2607
+ // from the daemon's authoritative aggregate, the SESSION half from the relay.
2608
+ if (hasClaudeData) {
2609
+ core.wsServer.broadcast(evt);
2610
+ }
2611
+ else {
2612
+ core.wsServer.broadcast(mergeRelayedSessionUsage(core.buildUsage(), u));
2613
+ }
2132
2614
  }
2133
2615
  else {
2134
2616
  // prompt_options — relay as-is
@@ -2333,6 +2815,11 @@ export async function startDaemon(opts) {
2333
2815
  // immediately). When a scan lands fresh observations, push them out via
2334
2816
  // the debounced broadcast so clients don't wait for the next 10 s poll.
2335
2817
  passiveSessionObserver.onRefreshed = () => core.maybeBroadcastSessionsList();
2818
+ // OTel turn boundaries arrive in milliseconds where the observer only re-reads
2819
+ // the rollout tail every scan interval, so a span that changed thread state is
2820
+ // worth a broadcast of its own.
2821
+ codexOtel.onChanged = () => core.maybeBroadcastSessionsList();
2822
+ hookCodexSessions.onChanged = () => core.maybeBroadcastSessionsList();
2336
2823
  // ===== Gateway adapter lifecycle =====
2337
2824
  // (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
2338
2825
  // Inject OpenClaw virtual session only after Gateway authentication succeeds.
@@ -2344,7 +2831,11 @@ export async function startDaemon(opts) {
2344
2831
  // the 5s-throttled observer, so a Notification arriving mid-window still
2345
2832
  // surfaces within one frame. Key = the Claude session UUID embedded in
2346
2833
  // `observed:claude:<uuid>`.
2347
- const observed = applyAwaitingOverlayToObserved(passiveSessionObserver.collect(sessions))
2834
+ // Codex OTel spans overlay state/tool onto the matching ps-observed row and
2835
+ // synthesize a `codex-app` row for any thread the process scan missed —
2836
+ // Swift's only Codex-app source, here a second opinion on top of the
2837
+ // observer. See bridge/src/codex-otel.ts.
2838
+ const observed = applyAwaitingOverlayToObserved(hookCodexSessions.applyTo(codexOtel.applyTo(passiveSessionObserver.collect(sessions))))
2348
2839
  .map((s) => {
2349
2840
  // Steering feedback for observed Claude sessions: devices render
2350
2841
  // "stopping at next tool" / queued-directive badges from these.
@@ -2363,10 +2854,15 @@ export async function startDaemon(opts) {
2363
2854
  ...(snap.queuedDirectives > 0 ? { queuedDirectives: snap.queuedDirectives } : {}),
2364
2855
  };
2365
2856
  });
2857
+ // Sessions that attached from another machine over the push channel. They
2858
+ // aren't in this daemon's sessions.json, so inject them here (dedup against
2859
+ // any id already present from the local registry).
2860
+ const localIds = new Set(sessions.map((s) => s.id));
2861
+ const remote = listRemoteEnriched().filter((s) => !localIds.has(s.id));
2366
2862
  // Derive per-session elapsed seconds from startedAt so NTP-less devices
2367
2863
  // (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
2368
2864
  const now = Date.now();
2369
- const enrichedSessions = [...sessions, ...observed].map((s) => {
2865
+ const enrichedSessions = [...sessions, ...observed, ...remote].map((s) => {
2370
2866
  // On-demand review badge (REVIEW tile verdict / REVIEWING state) —
2371
2867
  // applies to every session type, managed included.
2372
2868
  const review = reviewSnapshot(s.id);
@@ -2736,24 +3232,10 @@ export async function startDaemon(opts) {
2736
3232
  if (handleEsp32OtaReply(msg)) {
2737
3233
  return true;
2738
3234
  }
2739
- if (msg.type === 'session_push_register') {
2740
- const { sessionId, port: sessionPort, agentType: at, projectName: pn } = msg;
2741
- debug('daemon', `session_push_register: ${sessionId} port=${sessionPort} agent=${at}`);
2742
- // Acknowledge registration
2743
- try {
2744
- sender.send(JSON.stringify({ type: 'session_push_ack', sessionId }));
2745
- }
2746
- catch { /* client disconnecting */ }
2747
- return true; // consumed
2748
- }
2749
- if (msg.type === 'session_push_state') {
2750
- const { sessionId, state, modelName, effortLevel, permissionMode } = msg;
2751
- if (sessionId && state) {
2752
- updatePushState(sessionId, state, modelName, effortLevel, permissionMode);
2753
- // Trigger sessions list broadcast so clients get fresh state
2754
- core.maybeBroadcastSessionsList();
2755
- }
2756
- return true; // consumed
3235
+ // Push-channel frames (session_push_register / session_push_state /
3236
+ // session_event_up) extracted handler, integration-tested directly.
3237
+ if (handlePushChannelMessage(msg, sender)) {
3238
+ return true;
2757
3239
  }
2758
3240
  if (msg.type === 'deck_slot_map') {
2759
3241
  // Plugin pushed its keypad layout. Forward to other viewers (extra
@@ -2770,8 +3252,24 @@ export async function startDaemon(opts) {
2770
3252
  }
2771
3253
  if (msg.type === 'voice_end') {
2772
3254
  const captured = deviceVoice.end(sender, msg);
2773
- if (captured)
3255
+ if (captured) {
2774
3256
  void finishVoiceCapture(captured, stableSink(sender));
3257
+ }
3258
+ else if (msg.cancel !== true) {
3259
+ // Nothing was captured but the user did hold the button — answer
3260
+ // anyway, or the board's "Sending…" state has nothing to resolve it.
3261
+ try {
3262
+ sender.send(JSON.stringify({ type: 'voice_result', text: '', error: 'empty_capture' }));
3263
+ }
3264
+ catch { /* client disconnecting */ }
3265
+ }
3266
+ return true; // consumed
3267
+ }
3268
+ if (msg.type === 'voice_abort') {
3269
+ // The board tried to upload an utterance over HTTP and failed; the
3270
+ // reason would otherwise only exist on its serial console.
3271
+ log(`[agentdeck] voice: device upload aborted (${String(msg.reason ?? 'unknown')}, `
3272
+ + `${String(msg.total ?? '?')} bytes)`);
2775
3273
  return true; // consumed
2776
3274
  }
2777
3275
  if (msg.type === 'photo_begin') {
@@ -2895,6 +3393,13 @@ export async function startDaemon(opts) {
2895
3393
  }
2896
3394
  const handleDeviceCommand = (cmd) => {
2897
3395
  debug('daemon', `cmd: ${cmd.type}`);
3396
+ // Host push-to-talk is the daemon's own capability (host mic + speakers),
3397
+ // so claim it ahead of the gateway-first-dibs routing below — it must not
3398
+ // depend on what any adapter chooses to do with the legacy `voice` shape.
3399
+ if (cmd.type === 'voice') {
3400
+ handleHostVoicePtt(cmd);
3401
+ return;
3402
+ }
2898
3403
  // Session-scoped commands are NEVER OpenClaw's to consume: a device
2899
3404
  // answering a named session (select_option{sessionId}) must reach the
2900
3405
  // session routing below — the gateway swallowing it as an exec-approval
@@ -3049,6 +3554,16 @@ export async function startDaemon(opts) {
3049
3554
  if (!rawSessionId || !command)
3050
3555
  return;
3051
3556
  const sessionId = resolveDeviceSessionId(rawSessionId);
3557
+ // The gateway session has no bridge port — the focus-relay path below
3558
+ // would connect to nothing and silently drop the command. Hand it to
3559
+ // the adapter, the same way the wake-word assistant does.
3560
+ if (sessionId === 'openclaw-gateway') {
3561
+ if (gatewayAdapter?.isAlive())
3562
+ gatewayAdapter.handleCommand(command);
3563
+ else
3564
+ debug('daemon', 'session_command for openclaw-gateway but gateway not alive');
3565
+ return;
3566
+ }
3052
3567
  // Observed Claude sessions have no bridge/PTY — route to the hook-based
3053
3568
  // steering primitives instead (soft STOP, turn-end directive queue,
3054
3569
  // gate approval). This replaces the old silent drop.
@@ -3070,7 +3585,7 @@ export async function startDaemon(opts) {
3070
3585
  return;
3071
3586
  }
3072
3587
  const sessions = listActiveSessions();
3073
- const target = sessions.find(s => s.id === sessionId);
3588
+ const target = sessions.find(s => s.id === sessionId) ?? getRemoteSession(sessionId);
3074
3589
  if (!target) {
3075
3590
  debug('daemon', `session_command: session ${sessionId} not found`);
3076
3591
  return;
@@ -3096,7 +3611,7 @@ export async function startDaemon(opts) {
3096
3611
  handleObservedClaudeCommand(sessionId.slice('observed:claude:'.length), cmd);
3097
3612
  return;
3098
3613
  }
3099
- const target = listActiveSessions().find(s => s.id === sessionId);
3614
+ const target = listActiveSessions().find(s => s.id === sessionId) ?? getRemoteSession(sessionId);
3100
3615
  if (target) {
3101
3616
  userFocusedSessionId = sessionId;
3102
3617
  broadcastFocusedState();
@@ -3180,20 +3695,46 @@ export async function startDaemon(opts) {
3180
3695
  * drops the WebSocket it dictated over, and the answer must still arrive.
3181
3696
  * Serial first, mirroring the single-path dedup policy used elsewhere.
3182
3697
  */
3698
+ /**
3699
+ * Replies staged for `audio_http_pull` boards, keyed by device (board id) —
3700
+ * the board fetches via GET /esp32/voice-reply at its own pace. Swept by
3701
+ * TTL: a board that reboots before fetching must not hear a stale answer
3702
+ * minutes later.
3703
+ */
3704
+ const stagedVoiceReplies = new Map();
3705
+ const STAGED_REPLY_TTL_MS = 2 * 60_000;
3706
+ const stagedReplySweep = setInterval(() => {
3707
+ const cutoff = Date.now() - STAGED_REPLY_TTL_MS;
3708
+ for (const [key, entry] of stagedVoiceReplies) {
3709
+ if (entry.ts < cutoff)
3710
+ stagedVoiceReplies.delete(key);
3711
+ }
3712
+ }, 30_000);
3713
+ stagedReplySweep.unref?.();
3183
3714
  const voiceReply = new DeviceVoiceReplyRouter(() => Date.now(), (ms) => new Promise((r) => setTimeout(r, ms)), (deviceKey) => {
3715
+ // This resolver serves the spoken-reply router only, so unlike the
3716
+ // general serial-first dedup it prefers whichever live transport can
3717
+ // actually PLAY audio (advertises audio_out): a CH340 serial link
3718
+ // physically cannot carry PCM even when it is the primary state path.
3719
+ const candidates = [];
3184
3720
  const serialPort = liveSerialPortForBoard(deviceKey);
3185
- if (serialPort) {
3186
- log(`[agentdeck] voice: routing reply for ${deviceKey} over serial ${serialPort}`);
3187
- return serialSinkFor(serialPort);
3188
- }
3721
+ if (serialPort)
3722
+ candidates.push(serialSinkFor(serialPort));
3189
3723
  for (const [key, sock] of wifiEsp32Sockets) {
3190
3724
  if (sock.readyState !== WebSocket.OPEN)
3191
3725
  continue;
3192
3726
  const board = wifiEsp32Devices.get(key)?.board;
3193
- if (board === deviceKey || key === deviceKey) {
3194
- log(`[agentdeck] voice: routing reply for ${deviceKey} over ws ${key}`);
3195
- return stableSink(sock);
3196
- }
3727
+ if (board === deviceKey || key === deviceKey)
3728
+ candidates.push(stableSink(sock));
3729
+ }
3730
+ const pick = candidates.find((s) => {
3731
+ const caps = s.capabilities();
3732
+ return caps.includes('audio_out') || caps.includes('audio_http_pull');
3733
+ }) ?? candidates[0];
3734
+ if (pick) {
3735
+ log(`[agentdeck] voice: routing reply for ${deviceKey} via ${pick.describe()}`
3736
+ + ` (caps=${pick.capabilities().join('|') || 'none'})`);
3737
+ return pick;
3197
3738
  }
3198
3739
  log(`[agentdeck] voice: no live transport for ${deviceKey}`
3199
3740
  + ` (serial boards: ${serialBoardsAttached().join(',') || 'none'})`);
@@ -3276,12 +3817,59 @@ export async function startDaemon(opts) {
3276
3817
  }
3277
3818
  const locale = typeof voiceCfg?.locale === 'string' && voiceCfg.locale
3278
3819
  ? voiceCfg.locale : undefined;
3820
+ // Host-armed dictations (deck push-to-talk) play through the host's own
3821
+ // speakers — AVSpeechSynthesizer speaks directly, no WAV/PCM streaming
3822
+ // detour. Split them out before synthesis so a host-only reply skips the
3823
+ // file round trip entirely.
3824
+ const hostTargets = targets.filter((s) => s.capabilities().includes('audio_host'));
3825
+ const boardTargets = targets.filter((s) => !s.capabilities().includes('audio_host'));
3826
+ if (hostTargets.length > 0) {
3827
+ for (const sink of hostTargets)
3828
+ voiceReply.disarm(sink);
3829
+ void (async () => {
3830
+ try {
3831
+ await speakWithHelper(spoken, locale ? { locale } : {});
3832
+ log(`[agentdeck] voice: spoke reply on host speakers (${spoken.length} chars)`);
3833
+ }
3834
+ catch (err) {
3835
+ log(`[agentdeck] voice: host speak failed: ${String(err).slice(0, 140)}`);
3836
+ }
3837
+ })();
3838
+ }
3839
+ if (boardTargets.length === 0)
3840
+ return;
3279
3841
  void (async () => {
3280
3842
  const out = join(tmpdir(), `agentdeck-reply-${process.pid}-${Date.now()}.wav`);
3281
3843
  try {
3282
3844
  await synthesizeWavWithHelper(spoken, out, locale ? { locale } : {});
3283
3845
  const wav = await readFile(out);
3284
- for (const sink of targets) {
3846
+ for (const sink of boardTargets) {
3847
+ // Pull-capable boards fetch the audio themselves over HTTP — the WS
3848
+ // push stream both stuttered and could crash the hosted-link RX
3849
+ // path (pkt_rxbuff assert). Stage the PCM, send a tiny notify
3850
+ // frame, and let TCP flow control pace the transfer.
3851
+ if (sink.capabilities().includes('audio_http_pull')) {
3852
+ const parsed = pcmFromWav(wav);
3853
+ if (parsed) {
3854
+ stagedVoiceReplies.set(sink.deviceKey(), {
3855
+ pcm: parsed.pcm, sampleRate: parsed.sampleRate, ts: Date.now(),
3856
+ });
3857
+ try {
3858
+ sink.send(JSON.stringify({
3859
+ type: 'audio_reply_ready',
3860
+ bytes: parsed.pcm.length,
3861
+ sampleRate: parsed.sampleRate,
3862
+ durationMs: Math.round((parsed.pcm.length / 2) / parsed.sampleRate * 1000),
3863
+ text: spoken.slice(0, 120),
3864
+ }));
3865
+ log(`[agentdeck] voice: staged reply for ${sink.deviceKey()}`
3866
+ + ` (${parsed.pcm.length}B pull, ${spoken.length} chars)`);
3867
+ }
3868
+ catch { /* sink closing — TTL sweep reclaims the staging */ }
3869
+ voiceReply.disarm(sink);
3870
+ continue;
3871
+ }
3872
+ }
3285
3873
  const ok = await voiceReply.stream(sink, wav, spoken);
3286
3874
  log(`[agentdeck] voice: ${ok ? 'spoke' : 'FAILED to speak'} reply`
3287
3875
  + ` (${wav.length}B, ${spoken.length} chars)`);
@@ -3451,7 +4039,7 @@ export async function startDaemon(opts) {
3451
4039
  * no socket to answer on, so resolve the board's live transport the same
3452
4040
  * way a queued voice reply does (serial first, then its WiFi socket).
3453
4041
  */
3454
- const photoResultSinkFor = (board) => {
4042
+ const boardResultSinkFor = (board) => {
3455
4043
  const port = liveSerialPortForBoard(board);
3456
4044
  if (port)
3457
4045
  return serialSinkFor(port);
@@ -3482,8 +4070,19 @@ export async function startDaemon(opts) {
3482
4070
  }
3483
4071
  else if (msg.type === 'voice_end') {
3484
4072
  const captured = deviceVoice.end(port, msg);
3485
- if (captured)
4073
+ if (captured) {
3486
4074
  void finishVoiceCapture(captured, serialSinkFor(port));
4075
+ }
4076
+ else if (msg.cancel !== true) {
4077
+ try {
4078
+ serialSinkFor(port).send(JSON.stringify({ type: 'voice_result', text: '', error: 'empty_capture' }));
4079
+ }
4080
+ catch { /* port closing */ }
4081
+ }
4082
+ }
4083
+ else if (msg.type === 'voice_abort') {
4084
+ log(`[agentdeck] voice: device upload aborted (${String(msg.reason ?? 'unknown')}, `
4085
+ + `${String(msg.total ?? '?')} bytes) [serial ${port}]`);
3487
4086
  }
3488
4087
  else if (msg.type === 'photo_begin') {
3489
4088
  // Photo over USB: the strip is a desk fixture and usually powered from
@@ -3519,8 +4118,38 @@ export async function startDaemon(opts) {
3519
4118
  * like the same board on WiFi, which is only true if there is one
3520
4119
  * implementation.
3521
4120
  */
4121
+ /**
4122
+ * The sink to ARM for the spoken reply, which is not always the sink the
4123
+ * result JSON goes to: an HTTP-uploaded utterance resolves its result sink
4124
+ * serial-first (guaranteed delivery), but the ips10 advertises `audio_out`
4125
+ * on its WiFi device_info only — its 115200-baud serial physically cannot
4126
+ * carry PCM, so arming the serial sink would make voiceReply.arm() decline
4127
+ * and the reply silently never play. Prefer the sink itself when it can
4128
+ * play audio; otherwise find the board's live WS socket that can.
4129
+ */
4130
+ const canPlayReply = (s) => {
4131
+ const caps = s.capabilities();
4132
+ return caps.includes('audio_out') || caps.includes('audio_http_pull');
4133
+ };
4134
+ const audioArmSinkFor = (sink, board) => {
4135
+ if (canPlayReply(sink))
4136
+ return sink;
4137
+ for (const [key, sock] of wifiEsp32Sockets) {
4138
+ if (sock.readyState !== WebSocket.OPEN)
4139
+ continue;
4140
+ if (wifiEsp32Devices.get(key)?.board === board || key === board) {
4141
+ const s = stableSink(sock);
4142
+ if (canPlayReply(s))
4143
+ return s;
4144
+ }
4145
+ }
4146
+ return sink;
4147
+ };
3522
4148
  const finishVoiceCapture = async (captured, sink) => {
3523
4149
  try {
4150
+ if (captured.integrityError) {
4151
+ throw new Error(captured.integrityError);
4152
+ }
3524
4153
  // Locale matters: the recognizer falls back to the system locale,
3525
4154
  // which mangles speech in another language. settings.json
3526
4155
  // `voice.locale` (BCP-47, e.g. "en-US") overrides it.
@@ -3537,7 +4166,20 @@ export async function startDaemon(opts) {
3537
4166
  let via;
3538
4167
  let deliverReason;
3539
4168
  if (text && sessionId) {
3540
- if (sessionId.startsWith('observed:')) {
4169
+ if (sessionId === 'openclaw-gateway') {
4170
+ // The gateway session has no bridge port: the generic
4171
+ // session_command path focuses a relay that can never connect
4172
+ // and silently drops the prompt while this function reported
4173
+ // delivered=true (observed 2026-07-31 — "transcript showed,
4174
+ // nothing answered"). Route straight to the adapter and report
4175
+ // what actually happened.
4176
+ delivered = !!(gatewayAdapter?.isAlive()
4177
+ && gatewayAdapter.handleCommand({ type: 'send_prompt', text }));
4178
+ via = 'gateway';
4179
+ if (!delivered)
4180
+ deliverReason = 'gateway_unavailable';
4181
+ }
4182
+ else if (sessionId.startsWith('observed:')) {
3541
4183
  // Any observed agent (claude, codex, …): type the dictated line
3542
4184
  // into the terminal that owns the session. session_command only
3543
4185
  // knew how to route observed *Claude*, so a Codex target was
@@ -3560,15 +4202,17 @@ export async function startDaemon(opts) {
3560
4202
  }
3561
4203
  // Only a delivered prompt earns a spoken reply: arming on a
3562
4204
  // dropped one would read out whatever that session happened to
3563
- // answer someone else.
3564
- const armed = delivered ? voiceReply.arm(sink, sessionId) : false;
4205
+ // answer someone else. Armed on an audio-capable sink, which may
4206
+ // differ from the result sink see audioArmSinkFor.
4207
+ const armSink = audioArmSinkFor(sink, captured.board);
4208
+ const armed = delivered ? voiceReply.arm(armSink, sessionId) : false;
3565
4209
  // Logged at info level rather than debug: these are rare,
3566
4210
  // user-initiated events, and the silence made "the board said sent
3567
4211
  // but nothing happened" undiagnosable without a rebuild.
3568
4212
  log(`[agentdeck] voice: "${text.slice(0, 40)}" -> ${sessionId.slice(0, 32)}`
3569
4213
  + ` delivered=${delivered}${via ? ` via ${via}` : ''}`
3570
4214
  + `${deliverReason ? ` (${deliverReason})` : ''}`
3571
- + ` spokenReply=${armed ? `armed(${sink.describe()})` : 'no'}`
4215
+ + ` spokenReply=${armed ? `armed(${armSink.describe()})` : 'no'}`
3572
4216
  + ` [${sink.describe()} key=${sink.deviceKey()}`
3573
4217
  + ` caps=${sink.capabilities().join('|') || 'none'}]`);
3574
4218
  }
@@ -3582,10 +4226,24 @@ export async function startDaemon(opts) {
3582
4226
  catch { /* client disconnecting */ }
3583
4227
  }
3584
4228
  catch (err) {
3585
- const reason = String(err).slice(0, 160);
3586
- debug('voice', `transcription failed: ${reason}`);
4229
+ const reason = String(err).slice(0, 300);
4230
+ // Voice turns are rare and user-initiated. Keep failures at info
4231
+ // level so a board's short "Voice error" banner can always be
4232
+ // diagnosed after the temporary WAV has been cleaned up.
4233
+ log(`[agentdeck] voice: transcription failed on ${sink.describe()}: ${reason}`);
4234
+ // The board renders `error` on a one-line banner at glance distance —
4235
+ // a short stable code it can translate beats 160 chars of
4236
+ // NSError soup. The full reason still ships as `detail` and lives in
4237
+ // the log above.
4238
+ const code = /No speech detected|kAFAssistantErrorDomain Code=1110/.test(reason)
4239
+ ? 'no_speech'
4240
+ : /audio_transport_overflow/.test(reason) ? 'audio_lost'
4241
+ : /audio_transport_incomplete/.test(reason) ? 'audio_incomplete'
4242
+ : 'voice_failed';
3587
4243
  try {
3588
- sink.send(JSON.stringify({ type: 'voice_result', text: '', error: reason }));
4244
+ sink.send(JSON.stringify({
4245
+ type: 'voice_result', text: '', error: code, detail: reason.slice(0, 160),
4246
+ }));
3589
4247
  }
3590
4248
  catch { /* client disconnecting */ }
3591
4249
  }
@@ -3593,6 +4251,91 @@ export async function startDaemon(opts) {
3593
4251
  captured.cleanup();
3594
4252
  }
3595
4253
  };
4254
+ // ── Host push-to-talk (deck surfaces: Stream Deck, D200H) ────────────────
4255
+ // A deck key contributes only a button; the host contributes the mic, the
4256
+ // recognizer and the speakers. This restores the capability of the retired
4257
+ // Voice dial (pulled pre-Marketplace because capture meant borrowing
4258
+ // iTerm2's mic grant via AppleScript + Homebrew sox + a whisper model) on a
4259
+ // dependency-free stack: capture, STT and TTS all live in the bundled Swift
4260
+ // helper, and delivery/reply reuse the device-voice pipeline unchanged.
4261
+ const HOST_SPEAKER_KEY = 'host-speaker';
4262
+ const broadcastVoiceState = (state, extra = {}) => {
4263
+ core.broadcast({ type: 'voice_state', state, ...extra });
4264
+ };
4265
+ /**
4266
+ * ReplySink whose "board" is the host itself. `voice_result` frames become
4267
+ * broadcast `voice_state` events so every deck surface can render the
4268
+ * outcome; the spoken reply is played by the audio_host branch in
4269
+ * speakReplyTo rather than streamed as PCM frames.
4270
+ */
4271
+ const hostSpeakerSink = {
4272
+ send: (data) => {
4273
+ try {
4274
+ const msg = JSON.parse(data);
4275
+ if (msg.type === 'voice_result') {
4276
+ broadcastVoiceState(msg.error ? 'error' : 'idle', {
4277
+ ...(msg.text ? { text: msg.text } : {}),
4278
+ ...(msg.error ? { error: msg.error } : {}),
4279
+ ...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
4280
+ });
4281
+ }
4282
+ }
4283
+ catch { /* nothing renderable */ }
4284
+ },
4285
+ sendBinary: () => { },
4286
+ isOpen: () => true,
4287
+ capabilities: () => ['audio_out', 'audio_host'],
4288
+ describe: () => 'host:speaker',
4289
+ deviceKey: () => HOST_SPEAKER_KEY,
4290
+ };
4291
+ /** One capture at a time — PTT models a single held key. */
4292
+ let hostPttBusy = false;
4293
+ function handleHostVoicePtt(cmd) {
4294
+ if (process.platform !== 'darwin') {
4295
+ broadcastVoiceState('error', { error: 'host voice capture requires macOS' });
4296
+ return;
4297
+ }
4298
+ if (cmd.action === 'stop' || cmd.action === 'cancel') {
4299
+ if (!hostPttBusy)
4300
+ return;
4301
+ // Resolves the in-flight recordWithHelper below; a release that races
4302
+ // the max-duration stop is a harmless no-op.
4303
+ void stopHelperRecording({ cancel: cmd.action === 'cancel' }).catch(() => { });
4304
+ return;
4305
+ }
4306
+ if (hostPttBusy)
4307
+ return;
4308
+ hostPttBusy = true;
4309
+ const sessionId = resolveDeviceSessionId(cmd.sessionId) || userFocusedSessionId || '';
4310
+ const sessionExtra = sessionId ? { sessionId } : {};
4311
+ const wavPath = join(tmpdir(), `agentdeck-ptt-${process.pid}-${Date.now()}.wav`);
4312
+ broadcastVoiceState('recording', sessionExtra);
4313
+ void (async () => {
4314
+ try {
4315
+ const rec = await recordWithHelper(wavPath);
4316
+ broadcastVoiceState('transcribing', sessionExtra);
4317
+ await finishVoiceCapture({
4318
+ wavPath: rec.wav,
4319
+ sessionId,
4320
+ board: HOST_SPEAKER_KEY,
4321
+ cleanup: () => { void rm(rec.wav, { force: true }).catch(() => { }); },
4322
+ }, hostSpeakerSink);
4323
+ }
4324
+ catch (err) {
4325
+ const reason = String(err);
4326
+ if (/record_cancelled/.test(reason)) {
4327
+ broadcastVoiceState('idle', sessionExtra);
4328
+ }
4329
+ else {
4330
+ log(`[agentdeck] voice: host PTT failed: ${reason.slice(0, 200)}`);
4331
+ broadcastVoiceState('error', { error: reason.slice(0, 160), ...sessionExtra });
4332
+ }
4333
+ }
4334
+ finally {
4335
+ hostPttBusy = false;
4336
+ }
4337
+ })();
4338
+ }
3596
4339
  /**
3597
4340
  * Finish one captured photo: build the prompt that points the target session
3598
4341
  * at the saved file, deliver it through the same ladder as dictated text