@agentdeck/bridge 1.0.2 → 1.0.4

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/assets/fm-helper/agentdeck-fm-helper +0 -0
  2. package/dist/apme/collector.d.ts.map +1 -1
  3. package/dist/apme/collector.js +5 -2
  4. package/dist/apme/collector.js.map +1 -1
  5. package/dist/apme/runner.d.ts +11 -0
  6. package/dist/apme/runner.d.ts.map +1 -1
  7. package/dist/apme/runner.js +60 -4
  8. package/dist/apme/runner.js.map +1 -1
  9. package/dist/apme/settings.d.ts +10 -0
  10. package/dist/apme/settings.d.ts.map +1 -1
  11. package/dist/apme/settings.js +14 -0
  12. package/dist/apme/settings.js.map +1 -1
  13. package/dist/awaiting-overlay.d.ts +44 -10
  14. package/dist/awaiting-overlay.d.ts.map +1 -1
  15. package/dist/awaiting-overlay.js +96 -28
  16. package/dist/awaiting-overlay.js.map +1 -1
  17. package/dist/bridge-core.d.ts +10 -0
  18. package/dist/bridge-core.d.ts.map +1 -1
  19. package/dist/bridge-core.js +47 -12
  20. package/dist/bridge-core.js.map +1 -1
  21. package/dist/card-feed.d.ts +107 -0
  22. package/dist/card-feed.d.ts.map +1 -0
  23. package/dist/card-feed.js +277 -0
  24. package/dist/card-feed.js.map +1 -0
  25. package/dist/card-modules.d.ts +87 -0
  26. package/dist/card-modules.d.ts.map +1 -0
  27. package/dist/card-modules.js +179 -0
  28. package/dist/card-modules.js.map +1 -0
  29. package/dist/cli.d.ts.map +1 -1
  30. package/dist/cli.js +232 -69
  31. package/dist/cli.js.map +1 -1
  32. package/dist/daemon-server.d.ts.map +1 -1
  33. package/dist/daemon-server.js +1008 -47
  34. package/dist/daemon-server.js.map +1 -1
  35. package/dist/daemon-ws-client.d.ts +2 -1
  36. package/dist/daemon-ws-client.d.ts.map +1 -1
  37. package/dist/daemon-ws-client.js +2 -1
  38. package/dist/daemon-ws-client.js.map +1 -1
  39. package/dist/daemon.js +1 -1
  40. package/dist/device-photo.d.ts +86 -0
  41. package/dist/device-photo.d.ts.map +1 -0
  42. package/dist/device-photo.js +200 -0
  43. package/dist/device-photo.js.map +1 -0
  44. package/dist/device-voice-reply.d.ts +124 -0
  45. package/dist/device-voice-reply.d.ts.map +1 -0
  46. package/dist/device-voice-reply.js +236 -0
  47. package/dist/device-voice-reply.js.map +1 -0
  48. package/dist/device-voice.d.ts +56 -0
  49. package/dist/device-voice.d.ts.map +1 -0
  50. package/dist/device-voice.js +134 -0
  51. package/dist/device-voice.js.map +1 -0
  52. package/dist/esp32-serial.d.ts +40 -0
  53. package/dist/esp32-serial.d.ts.map +1 -1
  54. package/dist/esp32-serial.js +109 -13
  55. package/dist/esp32-serial.js.map +1 -1
  56. package/dist/foundation-models-helper.d.ts +30 -0
  57. package/dist/foundation-models-helper.d.ts.map +1 -1
  58. package/dist/foundation-models-helper.js +45 -0
  59. package/dist/foundation-models-helper.js.map +1 -1
  60. package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -1
  61. package/dist/idotmatrix/idotmatrix-daemon-sync.js +5 -18
  62. package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -1
  63. package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -1
  64. package/dist/idotmatrix/idotmatrix-discover.js +6 -14
  65. package/dist/idotmatrix/idotmatrix-discover.js.map +1 -1
  66. package/dist/index.js +2 -2
  67. package/dist/index.js.map +1 -1
  68. package/dist/mdns.d.ts +11 -0
  69. package/dist/mdns.d.ts.map +1 -1
  70. package/dist/mdns.js +13 -0
  71. package/dist/mdns.js.map +1 -1
  72. package/dist/observed-inject.d.ts +119 -0
  73. package/dist/observed-inject.d.ts.map +1 -0
  74. package/dist/observed-inject.js +405 -0
  75. package/dist/observed-inject.js.map +1 -0
  76. package/dist/passive-observer.d.ts +14 -0
  77. package/dist/passive-observer.d.ts.map +1 -1
  78. package/dist/passive-observer.js +35 -5
  79. package/dist/passive-observer.js.map +1 -1
  80. package/dist/peripheral-mapping.d.ts +65 -0
  81. package/dist/peripheral-mapping.d.ts.map +1 -0
  82. package/dist/peripheral-mapping.js +115 -0
  83. package/dist/peripheral-mapping.js.map +1 -0
  84. package/dist/python-ble-runtime.d.ts +62 -0
  85. package/dist/python-ble-runtime.d.ts.map +1 -0
  86. package/dist/python-ble-runtime.js +215 -0
  87. package/dist/python-ble-runtime.js.map +1 -0
  88. package/dist/session-aggregator.d.ts +4 -1
  89. package/dist/session-aggregator.d.ts.map +1 -1
  90. package/dist/session-aggregator.js +5 -5
  91. package/dist/session-aggregator.js.map +1 -1
  92. package/dist/session-id-resolve.d.ts +17 -0
  93. package/dist/session-id-resolve.d.ts.map +1 -0
  94. package/dist/session-id-resolve.js +35 -0
  95. package/dist/session-id-resolve.js.map +1 -0
  96. package/dist/session-transcript-timeline.d.ts +7 -0
  97. package/dist/session-transcript-timeline.d.ts.map +1 -1
  98. package/dist/session-transcript-timeline.js +16 -6
  99. package/dist/session-transcript-timeline.js.map +1 -1
  100. package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -1
  101. package/dist/timebox/timebox-daemon-sync.js +5 -20
  102. package/dist/timebox/timebox-daemon-sync.js.map +1 -1
  103. package/dist/timebox/timebox-discover.d.ts.map +1 -1
  104. package/dist/timebox/timebox-discover.js +6 -15
  105. package/dist/timebox/timebox-discover.js.map +1 -1
  106. package/dist/timeline-store.js +2 -2
  107. package/dist/timeline-store.js.map +1 -1
  108. package/dist/usage-event.d.ts.map +1 -1
  109. package/dist/usage-event.js +14 -7
  110. package/dist/usage-event.js.map +1 -1
  111. package/dist/voice.d.ts +13 -10
  112. package/dist/voice.d.ts.map +1 -1
  113. package/dist/voice.js +26 -272
  114. package/dist/voice.js.map +1 -1
  115. package/dist/ws-server.d.ts +14 -0
  116. package/dist/ws-server.d.ts.map +1 -1
  117. package/dist/ws-server.js +66 -1
  118. package/dist/ws-server.js.map +1 -1
  119. package/fm-helper/AgentDeckFMHelper.swift +303 -0
  120. package/package.json +8 -4
  121. package/python/requirements-ble.txt +5 -0
  122. package/src/idotmatrix/brightness.py +40 -0
  123. package/src/idotmatrix/scan.py +35 -0
  124. package/src/idotmatrix/sync.py +400 -0
  125. package/src/pysync/matrix_sync_common.py +89 -0
  126. package/src/timebox/scan_ble.py +52 -0
  127. package/src/timebox/sync_ble.py +394 -0
  128. package/dist/d200h/hid-protocol.d.ts +0 -62
  129. package/dist/d200h/hid-protocol.d.ts.map +0 -1
  130. package/dist/d200h/hid-protocol.js +0 -124
  131. package/dist/d200h/hid-protocol.js.map +0 -1
  132. package/dist/d200h/image-renderer.d.ts +0 -24
  133. package/dist/d200h/image-renderer.d.ts.map +0 -1
  134. package/dist/d200h/image-renderer.js +0 -321
  135. package/dist/d200h/image-renderer.js.map +0 -1
  136. package/dist/hook-migration.d.ts +0 -2
  137. package/dist/hook-migration.d.ts.map +0 -1
  138. package/dist/hook-migration.js +0 -104
  139. package/dist/hook-migration.js.map +0 -1
  140. package/dist/modules/d200h-module.d.ts +0 -65
  141. package/dist/modules/d200h-module.d.ts.map +0 -1
  142. package/dist/modules/d200h-module.js +0 -495
  143. package/dist/modules/d200h-module.js.map +0 -1
  144. package/dist/modules/d200h-renderer.d.ts +0 -7
  145. package/dist/modules/d200h-renderer.d.ts.map +0 -1
  146. package/dist/modules/d200h-renderer.js +0 -321
  147. package/dist/modules/d200h-renderer.js.map +0 -1
  148. package/dist/modules/trmnl-module.d.ts +0 -38
  149. package/dist/modules/trmnl-module.d.ts.map +0 -1
  150. package/dist/modules/trmnl-module.js +0 -111
  151. package/dist/modules/trmnl-module.js.map +0 -1
  152. package/dist/trmnl/byos-server.d.ts +0 -39
  153. package/dist/trmnl/byos-server.d.ts.map +0 -1
  154. package/dist/trmnl/byos-server.js +0 -301
  155. package/dist/trmnl/byos-server.js.map +0 -1
  156. package/dist/trmnl/frame-cache.d.ts +0 -37
  157. package/dist/trmnl/frame-cache.d.ts.map +0 -1
  158. package/dist/trmnl/frame-cache.js +0 -157
  159. package/dist/trmnl/frame-cache.js.map +0 -1
  160. package/dist/trmnl/image-renderer.d.ts +0 -27
  161. package/dist/trmnl/image-renderer.d.ts.map +0 -1
  162. package/dist/trmnl/image-renderer.js +0 -235
  163. package/dist/trmnl/image-renderer.js.map +0 -1
  164. package/dist/trmnl/trmnl-settings.d.ts +0 -96
  165. package/dist/trmnl/trmnl-settings.d.ts.map +0 -1
  166. package/dist/trmnl/trmnl-settings.js +0 -177
  167. package/dist/trmnl/trmnl-settings.js.map +0 -1
  168. package/dist/trmnl/trmnl-telemetry.d.ts +0 -47
  169. package/dist/trmnl/trmnl-telemetry.d.ts.map +0 -1
  170. package/dist/trmnl/trmnl-telemetry.js +0 -56
  171. package/dist/trmnl/trmnl-telemetry.js.map +0 -1
  172. package/dist/whisper-server-manager.d.ts +0 -19
  173. package/dist/whisper-server-manager.d.ts.map +0 -1
  174. package/dist/whisper-server-manager.js +0 -171
  175. package/dist/whisper-server-manager.js.map +0 -1
@@ -23,9 +23,17 @@ 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
25
  import { updatePushState } from './session-aggregator.js';
26
- import { setAwaitingOverlay, clearAwaitingOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved } from './awaiting-overlay.js';
26
+ import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
27
27
  import { registerPending, resolvePending, sweepStalePending, drainAllPending, isPendingRequest } from './permission-resolver.js';
28
- import { shouldHoldPreToolUse, gateReleased, buildGateQuestion, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, notePermissionPromptShown, noteToolEnd, steeringSnapshot, } from './observed-steering.js';
28
+ import { shouldHoldPreToolUse, gateReleased, buildGateQuestion, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, } from './observed-steering.js';
29
+ import { notePermissionPromptShown, noteToolEnd, steeringSnapshot, } from './observed-steering.js';
30
+ import { resolveSessionIdPrefix } from './session-id-resolve.js';
31
+ import { injectObservedSelection, injectObservedText } from './observed-inject.js';
32
+ import { setSerialCommandSink, setSerialVoiceSink, setSerialQuiesceCheck, sendSerialJson, serialPortConnected, serialPortCapabilities, serialPortBoard, liveSerialPortForBoard, serialBoardsAttached, } from './esp32-serial.js';
33
+ import { parsePeripheralMappings, resolvePeripheralAction, commandForAction } from './peripheral-mapping.js';
34
+ import { DeviceVoiceCollector } from './device-voice.js';
35
+ import { DevicePhotoCollector } from './device-photo.js';
36
+ import { transcribeWithHelper, synthesizeWavWithHelper } from './foundation-models-helper.js';
29
37
  import { enqueueOpenCodeCommand, pollOpenCodeCommands } from './opencode-steering.js';
30
38
  import { runSessionReview, reviewSnapshot } from './review-runner.js';
31
39
  /** Observed-session device approval gate (PreToolUse hold). Default ON — the
@@ -51,7 +59,9 @@ import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/in
51
59
  import { FallbackTaskTimeline } from './fallback-task-timeline.js';
52
60
  import { handleApmeRequest } from './apme/http.js';
53
61
  import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
54
- import { transcriptTimelineForSession, lastAssistantTextFromTranscript } from './session-transcript-timeline.js';
62
+ import { transcriptTimelineForSession, lastAssistantTextFromTranscript, lastAssistantTextForSession, } from './session-transcript-timeline.js';
63
+ import { DeviceVoiceReplyRouter, speakableReply, } from './device-voice-reply.js';
64
+ import { rawSessionId } from '@agentdeck/shared';
55
65
  import { lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
56
66
  import { callFoundationModelsHelper } from './foundation-models-helper.js';
57
67
  import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
@@ -62,7 +72,11 @@ import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js
62
72
  import { loadTimeboxDevices } from './timebox/timebox-settings.js';
63
73
  import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
64
74
  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';
65
77
  import { readFileSync, statSync } from 'fs';
78
+ import { readFile, rm } from 'fs/promises';
79
+ import { tmpdir } from 'os';
66
80
  import { join } from 'path';
67
81
  import { homedir } from 'os';
68
82
  import { OPENCLAW_CAPABILITIES, State, } from './types.js';
@@ -80,6 +94,10 @@ function exitProcessNow(code = 0) {
80
94
  }
81
95
  const wifiEsp32Devices = new Map();
82
96
  const wifiEsp32Sockets = new Map();
97
+ /** What each ESP32 socket reported about itself, independent of whether the
98
+ * single-path dedup kept it in the registry above. */
99
+ const wsSelfBoard = new Map();
100
+ const wsSelfCaps = new Map();
83
101
  const otaWaiters = new Map();
84
102
  function listWifiEsp32Devices() {
85
103
  const now = Date.now();
@@ -98,6 +116,24 @@ function listWifiEsp32Devices() {
98
116
  }
99
117
  return out;
100
118
  }
119
+ /** Card-feed pull log. Daemon-lifetime only: it answers "is the wake cadence
120
+ * working right now", which is exactly the M6 power-ladder check that has no
121
+ * other observable — a battery client with an empty outbox never identifies
122
+ * itself and never opens a socket. */
123
+ const feedPulls = new FeedPullTracker();
124
+ /** Name a pulling client from the WiFi WS roster. A board that has been
125
+ * deep-sleeping may have aged out of that roster, which is why the tracker
126
+ * keeps its own IP→board memory once it has learned one. */
127
+ function boardForClientIp(ip) {
128
+ const want = normalizeClientIp(ip);
129
+ if (!want)
130
+ return undefined;
131
+ for (const d of wifiEsp32Devices.values()) {
132
+ if (d.ip && normalizeClientIp(d.ip) === want)
133
+ return d.board;
134
+ }
135
+ return undefined;
136
+ }
101
137
  function wifiEsp32Key(d) {
102
138
  const board = typeof d.board === 'string' && d.board ? d.board : 'unknown';
103
139
  const ip = typeof d.ip === 'string' && d.ip ? d.ip : 'no-ip';
@@ -164,6 +200,19 @@ function registerWifiEsp32(d, ws) {
164
200
  otaSlotSize: typeof d.otaSlotSize === 'number' ? d.otaSlotSize : undefined,
165
201
  otaFreeSketchSpace: typeof d.otaFreeSketchSpace === 'number' ? d.otaFreeSketchSpace : undefined,
166
202
  otaReason: typeof d.otaReason === 'string' ? d.otaReason : undefined,
203
+ // Peripheral telemetry/diag — without these the /devices view silently
204
+ // drops what capability-advertising boards report (the t_embed battery
205
+ // fields were invisible here for a day for exactly this reason).
206
+ capabilities: Array.isArray(d.capabilities) ? d.capabilities : undefined,
207
+ batteryPercent: typeof d.batteryPercent === 'number' ? d.batteryPercent : undefined,
208
+ batteryVoltageMv: typeof d.batteryVoltageMv === 'number' ? d.batteryVoltageMv : undefined,
209
+ batteryCharging: typeof d.batteryCharging === 'boolean' ? d.batteryCharging : undefined,
210
+ usbPowered: typeof d.usbPowered === 'boolean' ? d.usbPowered : undefined,
211
+ batteryDiag: typeof d.batteryDiag === 'number' ? d.batteryDiag : undefined,
212
+ touchReady: typeof d.touchReady === 'boolean' ? d.touchReady : undefined,
213
+ touchDownSamples: typeof d.touchDownSamples === 'number' ? d.touchDownSamples : undefined,
214
+ touchGestures: typeof d.touchGestures === 'number' ? d.touchGestures : undefined,
215
+ alsReady: typeof d.alsReady === 'boolean' ? d.alsReady : undefined,
167
216
  lastSeenMs: Date.now(),
168
217
  });
169
218
  wifiEsp32Sockets.set(key, ws);
@@ -815,6 +864,15 @@ export async function startDaemon(opts) {
815
864
  let gatewayAdapter = null;
816
865
  let gatewayConnecting = false;
817
866
  let moduleHealthProvider = () => ({});
867
+ // Gateway-local activity state for the virtual `openclaw-gateway` session row.
868
+ // Driven ONLY by the OpenClaw adapter's own parser events — NOT the global
869
+ // `core.stateMachine`, which every observed Claude session hook also feeds
870
+ // (daemon-server.ts:1181-1188). Borrowing `snap.state` there painted OpenClaw
871
+ // as "processing" whenever any unrelated Claude/opencode turn was in flight,
872
+ // even though the Gateway itself was idle. Mirror of Swift DaemonServer's
873
+ // `gatewaySessionState` / `gatewayModelName` dedicated fields.
874
+ let gatewaySessionState = 'idle';
875
+ let gatewayModelName;
818
876
  // Wi-Fi WebSocket e-ink panels (XTeink X3/X4 CrossPoint fork) self-register
819
877
  // their device roster via `client_register{clientType:"eink-device"}` — the
820
878
  // same volunteer model as the Stream Deck plugin. This handler lived ONLY in
@@ -868,6 +926,17 @@ export async function startDaemon(opts) {
868
926
  }
869
927
  return out;
870
928
  };
929
+ /**
930
+ * Debug hook behind `POST /voice/speak` — synthesize arbitrary text straight
931
+ * to a board's amplifier, bypassing the arming that the real voice loop needs.
932
+ * Without it the only way to hear the speaker is to hold a board's mic button
933
+ * and wait for an agent turn, which is useless for verifying a flash.
934
+ *
935
+ * Assigned late, once the reply router and its sinks exist: those live far
936
+ * below the HTTP server, which is already listening by then, so the route must
937
+ * read this binding at request time rather than close over one in its TDZ.
938
+ */
939
+ let speakToBoard = null;
871
940
  // ===== HTTP server =====
872
941
  const httpServer = createServer((req, res) => {
873
942
  // APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
@@ -942,6 +1011,10 @@ export async function startDaemon(opts) {
942
1011
  { type: 'tui', devices: core.wsServer.getTuiClients() },
943
1012
  { type: 'esp32', count: esp32ConnectionCount(), ports: getESP32Ports(), devices: getESP32DeviceInfo() },
944
1013
  { type: 'esp32-wifi', devices: listWifiEsp32Devices() },
1014
+ // Pull-sync clients (M6). Separate from esp32-wifi on purpose: a
1015
+ // battery board that wakes, syncs over HTTP and sleeps is never in
1016
+ // the WS roster while it matters.
1017
+ { type: 'card-feed', clients: feedPulls.clients(), recent: feedPulls.recent(8) },
945
1018
  { type: 'pixoo', details: getPixooDeviceDetails() },
946
1019
  { type: 'timebox', devices: loadTimeboxDevices() },
947
1020
  { type: 'idotmatrix', devices: loadIDotMatrixDevices() },
@@ -956,6 +1029,55 @@ export async function startDaemon(opts) {
956
1029
  }));
957
1030
  return;
958
1031
  }
1032
+ // Device photo upload — a whole JPEG as one request body. The chunked
1033
+ // WS/serial path is loss-prone on this hardware (HWCDC drops 64-byte
1034
+ // blocks; the board's WS TX jammed after the first binary frame), and a
1035
+ // partial JPEG is worthless, so a board with WiFi posts the image over
1036
+ // plain TCP and lets the kernel handle ordering and retransmit.
1037
+ if (req.method === 'POST' && pathname === '/esp32/photo') {
1038
+ const ip = req.socket.remoteAddress ?? '';
1039
+ if (!isLocalConnection(ip)) {
1040
+ const token = parsedUrl.searchParams.get('token') ?? '';
1041
+ if (!validateToken(token)) {
1042
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1043
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1044
+ return;
1045
+ }
1046
+ }
1047
+ (async () => {
1048
+ const chunks = [];
1049
+ let total = 0;
1050
+ for await (const chunk of req) {
1051
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1052
+ total += buf.length;
1053
+ if (total > 8 * 1024 * 1024)
1054
+ throw new Error('photo_too_large');
1055
+ chunks.push(buf);
1056
+ }
1057
+ if (total === 0)
1058
+ throw new Error('empty_body');
1059
+ const jpeg = Buffer.concat(chunks);
1060
+ const board = parsedUrl.searchParams.get('board') ?? 'esp32';
1061
+ const saved = devicePhoto.saveDirect(jpeg, {
1062
+ board,
1063
+ sessionId: parsedUrl.searchParams.get('sessionId') ?? '',
1064
+ width: Number(parsedUrl.searchParams.get('w') ?? 0) || 0,
1065
+ height: Number(parsedUrl.searchParams.get('h') ?? 0) || 0,
1066
+ });
1067
+ // Reply on the board's own transport (serial when it is cabled), the
1068
+ // same way the chunked path did — the HTTP response only confirms
1069
+ // receipt of the bytes.
1070
+ await finishPhotoCapture(saved, photoResultSinkFor(board));
1071
+ return { ok: true, bytes: saved.bytes, path: saved.path };
1072
+ })().then((result) => {
1073
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1074
+ res.end(JSON.stringify(result));
1075
+ }).catch((err) => {
1076
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1077
+ res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
1078
+ });
1079
+ return;
1080
+ }
959
1081
  if (req.method === 'POST' && pathname === '/esp32/ota') {
960
1082
  (async () => {
961
1083
  const body = await readJsonBody(req);
@@ -974,6 +1096,61 @@ export async function startDaemon(opts) {
974
1096
  });
975
1097
  return;
976
1098
  }
1099
+ // ===== Card Feed pull sync (M6) — wake-sync-sleep battery clients =====
1100
+ // Auth mirrors /apme: local connections are free; anything else needs the
1101
+ // pairing token (?token=). Devices hold it from provisioning; /health
1102
+ // exposes it for LAN pairing.
1103
+ if ((req.method === 'GET' && pathname === CARD_FEED_PATH)
1104
+ || (req.method === 'POST' && pathname === CARD_OUTBOX_PATH)) {
1105
+ const ip = req.socket.remoteAddress ?? '';
1106
+ if (!isLocalConnection(ip)) {
1107
+ const token = parsedUrl.searchParams.get('token') ?? '';
1108
+ if (!validateToken(token)) {
1109
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1110
+ res.end(JSON.stringify({ error: 'Unauthorized — token required for card feed routes' }));
1111
+ return;
1112
+ }
1113
+ }
1114
+ (async () => {
1115
+ if (req.method === 'GET') {
1116
+ const sessions = await core.buildSessionsSnapshot();
1117
+ const feed = buildCardFeed(sessions);
1118
+ // A sleeping client's whole visit is this one request: no body, no
1119
+ // board id, nothing pushed. Record it, because the gap between two
1120
+ // pulls is the only evidence that the timer wake fired at all.
1121
+ feedPulls.noteBoard(ip, boardForClientIp(ip));
1122
+ log(`[agentdeck] ${formatFeedPull(feedPulls.record(ip, {
1123
+ cards: feed.cards.length,
1124
+ nextPullSec: feed.nextPullSec,
1125
+ }))}`);
1126
+ return feed;
1127
+ }
1128
+ const body = await readJsonBody(req);
1129
+ const board = typeof body.board === 'string' ? body.board : 'unknown';
1130
+ // An outbox push is the one request that names the board — bind it to
1131
+ // the IP so the (anonymous) feed pulls that follow are attributable.
1132
+ if (board !== 'unknown')
1133
+ feedPulls.noteBoard(ip, board);
1134
+ const sessions = await core.buildSessionsSnapshot();
1135
+ const result = applyOutboxDecisions(body, {
1136
+ sessions: sessions,
1137
+ isPendingRequest,
1138
+ dispatch: (cmd) => handleDeviceCommand(cmd),
1139
+ });
1140
+ const applied = result.results.filter((r) => r.status === 'applied').length;
1141
+ if (result.results.length > 0) {
1142
+ log(`[agentdeck] outbox push from ${board}: ${applied}/${result.results.length} applied`);
1143
+ }
1144
+ return result;
1145
+ })().then((payload) => {
1146
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1147
+ res.end(JSON.stringify(payload));
1148
+ }).catch((err) => {
1149
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1150
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
1151
+ });
1152
+ return;
1153
+ }
977
1154
  if (req.method === 'GET' && pathname === '/display-state') {
978
1155
  res.writeHead(200, {
979
1156
  'Content-Type': 'application/json',
@@ -1083,6 +1260,7 @@ export async function startDaemon(opts) {
1083
1260
  const eventMap = {
1084
1261
  SessionStart: 'session_start', SessionEnd: 'session_end',
1085
1262
  PreToolUse: 'tool_start', PostToolUse: 'tool_end',
1263
+ PostToolUseFailure: 'tool_failure',
1086
1264
  Stop: 'stop', UserPromptSubmit: 'user_prompt_submit',
1087
1265
  Notification: 'notification',
1088
1266
  };
@@ -1109,17 +1287,22 @@ export async function startDaemon(opts) {
1109
1287
  else if (mapped === 'tool_end') {
1110
1288
  core.stateMachine.handleHookEvent('PostToolUse', json);
1111
1289
  }
1112
- // Per-session awaiting overlay (observed/direct-`claude` sessions),
1113
- // display-only. Claude emits Notification with `notification_type:
1114
- // "permission_prompt"` only when a permission prompt is actually shown
1115
- // to the user (auto-approved tools fire PreToolUse but never this), so
1116
- // it is a genuine "waiting for explicit response" signal — unlike the
1117
- // removed PreToolUse gate. No requestId is ever set: surfaces render
1118
- // awaiting + question with the respond-in-terminal fallback, never a
1119
- // fabricated Allow/Deny. Keyed by Claude's own session_id and merged
1120
- // into the observed-session list at enrich time (awaiting-overlay.ts).
1290
+ // Per-session awaiting overlay for observed/direct-`claude` sessions.
1291
+ // Permission prompts stay Notification-driven and display-only unless
1292
+ // the precision PreToolUse gate below owns a real requestId.
1293
+ // AskUserQuestion is different: its PreToolUse payload already carries
1294
+ // question/options/tool_use_id, so preserve that structured lifecycle
1295
+ // as awaiting_option until the matching PostToolUse/Failure. This avoids
1296
+ // both the generic "Claude needs your permission" downgrade and the
1297
+ // transcript-mtime false resolution that affected a live 17-minute
1298
+ // choice wait. No requestId is fabricated: observed surfaces remain
1299
+ // respond-in-terminal only.
1121
1300
  const claudeSid = typeof json.session_id === 'string' ? json.session_id : undefined;
1122
1301
  if (claudeSid) {
1302
+ const toolName = typeof json.tool_name === 'string' ? json.tool_name : '';
1303
+ const toolUseId = typeof json.tool_use_id === 'string' ? json.tool_use_id : undefined;
1304
+ const toolInput = (json.tool_input && typeof json.tool_input === 'object')
1305
+ ? json.tool_input : undefined;
1123
1306
  if (mapped === 'notification') {
1124
1307
  const message = typeof json.message === 'string' ? json.message : '';
1125
1308
  const notificationType = typeof json.notification_type === 'string' ? json.notification_type : undefined;
@@ -1128,13 +1311,23 @@ export async function startDaemon(opts) {
1128
1311
  // releases were real prompts, so nothing gets learned as
1129
1312
  // auto-approved (observed-steering learner).
1130
1313
  notePermissionPromptShown(claudeSid);
1131
- setAwaitingOverlay(claudeSid, message);
1132
- // Broadcast immediately rather than waiting for the 2s debounce
1133
- // or the 5s observer tick, so the prompt surfaces within one frame.
1314
+ // AskUserQuestion emits this generic permission notification
1315
+ // several seconds after its structured PreToolUse. Never replace
1316
+ // the real question/options with the generic message.
1317
+ if (setPermissionNotificationOverlay(claudeSid, message)) {
1318
+ // Broadcast immediately rather than waiting for the 2s debounce
1319
+ // or the 5s observer tick, so the prompt surfaces within one frame.
1320
+ core.broadcastSessionsList().catch(() => { });
1321
+ }
1322
+ }
1323
+ }
1324
+ else if (mapped === 'tool_start' && toolName === 'AskUserQuestion') {
1325
+ if (setAskUserQuestionOverlay(claudeSid, toolUseId, toolInput)) {
1326
+ debug('daemon', `AskUserQuestion awaiting_option set: sid=${claudeSid} tool=${toolUseId ?? 'unknown'}`);
1134
1327
  core.broadcastSessionsList().catch(() => { });
1135
1328
  }
1136
1329
  }
1137
- else if (mapped === 'tool_start' || mapped === 'tool_end' ||
1330
+ else if (mapped === 'tool_start' || mapped === 'tool_end' || mapped === 'tool_failure' ||
1138
1331
  mapped === 'user_prompt_submit' || mapped === 'stop' ||
1139
1332
  mapped === 'session_start' || mapped === 'session_end') {
1140
1333
  // Steering lifecycle: a PostToolUse right after an undecided gate
@@ -1143,7 +1336,7 @@ export async function startDaemon(opts) {
1143
1336
  // never held again this session. User prompt / session end clear
1144
1337
  // pending STOP + queued directives (the user took over).
1145
1338
  if (mapped === 'tool_end') {
1146
- noteToolEnd(claudeSid, typeof json.tool_name === 'string' ? json.tool_name : undefined);
1339
+ noteToolEnd(claudeSid, toolName || undefined);
1147
1340
  }
1148
1341
  else if (mapped === 'user_prompt_submit') {
1149
1342
  if (clearOnUserPrompt(claudeSid))
@@ -1152,16 +1345,29 @@ export async function startDaemon(opts) {
1152
1345
  else if (mapped === 'session_end') {
1153
1346
  clearSteeringSession(claudeSid);
1154
1347
  }
1155
- // Any subsequent hook means the prompt was answered — drop the
1156
- // overlay. Only rebroadcast if there was actually one to clear
1157
- // (direct-`claude` sessions fire tool hooks constantly).
1158
- // EXCEPT while a held gate is still pending: parallel tool calls
1159
- // fire their own PreToolUse/PostToolUse hooks, and clearing here
1160
- // would strip the Allow/Deny UI out from under the open request
1161
- // (its own onResolved clears the overlay when it settles).
1162
- const heldOverlay = getAwaitingOverlay(claudeSid);
1163
- const gateStillPending = Boolean(heldOverlay?.requestId && isPendingRequest(heldOverlay.requestId));
1164
- if (!gateStillPending && clearAwaitingOverlay(claudeSid)) {
1348
+ const currentOverlay = getAwaitingOverlay(claudeSid);
1349
+ let cleared = false;
1350
+ if ((mapped === 'tool_end' || mapped === 'tool_failure') &&
1351
+ toolName === 'AskUserQuestion') {
1352
+ cleared = clearAskUserQuestionOverlay(claudeSid, toolUseId);
1353
+ if (cleared) {
1354
+ debug('daemon', `AskUserQuestion awaiting_option cleared by ${eventName}: sid=${claudeSid} tool=${toolUseId ?? 'unknown'}`);
1355
+ }
1356
+ }
1357
+ else if (mapped === 'user_prompt_submit' || mapped === 'stop' ||
1358
+ mapped === 'session_start' || mapped === 'session_end') {
1359
+ // Authoritative turn/session boundary supersedes every overlay.
1360
+ cleared = clearAwaitingOverlay(claudeSid);
1361
+ }
1362
+ else if (currentOverlay?.kind !== 'option') {
1363
+ // Permission overlay: any subsequent tool hook means the prompt
1364
+ // was answered. Held gates are owned by their resolver and must
1365
+ // survive unrelated parallel tool hooks.
1366
+ const gateStillPending = Boolean(currentOverlay?.requestId && isPendingRequest(currentOverlay.requestId));
1367
+ if (!gateStillPending)
1368
+ cleared = clearAwaitingOverlay(claudeSid);
1369
+ }
1370
+ if (cleared) {
1165
1371
  core.broadcastSessionsList().catch(() => { });
1166
1372
  }
1167
1373
  }
@@ -1600,6 +1806,53 @@ export async function startDaemon(opts) {
1600
1806
  });
1601
1807
  return;
1602
1808
  }
1809
+ // Speak arbitrary text on a board's amplifier — the bench test for the
1810
+ // playback half of the voice loop, which otherwise only fires when a
1811
+ // dictation happens to arm a board and its session finishes a turn. Body:
1812
+ // { board: string, text: string, force?: boolean, locale?: string }
1813
+ // `board` is the name `agentdeck devices` prints (e.g. "t_embed"); serial is
1814
+ // preferred over WiFi, matching the reply router's routing policy.
1815
+ if (req.method === 'POST' && pathname === '/voice/speak') {
1816
+ let body = '';
1817
+ req.on('data', (chunk) => { body += chunk; if (body.length > 8192)
1818
+ req.destroy(); });
1819
+ req.on('end', () => {
1820
+ let board = '', text = '', force = false, locale;
1821
+ try {
1822
+ const parsed = body ? JSON.parse(body) : {};
1823
+ board = typeof parsed.board === 'string' ? parsed.board : '';
1824
+ text = typeof parsed.text === 'string' ? parsed.text : '';
1825
+ force = parsed.force === true;
1826
+ locale = typeof parsed.locale === 'string' && parsed.locale ? parsed.locale : undefined;
1827
+ }
1828
+ catch (err) {
1829
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1830
+ res.end(JSON.stringify({ error: `bad body: ${String(err)}` }));
1831
+ return;
1832
+ }
1833
+ if (!board || !text) {
1834
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1835
+ res.end(JSON.stringify({ error: 'board and text required' }));
1836
+ return;
1837
+ }
1838
+ if (!speakToBoard) {
1839
+ // Only reachable in the window between listen() and the voice wiring.
1840
+ res.writeHead(503, { 'Content-Type': 'application/json' });
1841
+ res.end(JSON.stringify({ error: 'voice not initialized yet' }));
1842
+ return;
1843
+ }
1844
+ speakToBoard(board, text, { force, locale })
1845
+ .then(({ ok, detail }) => {
1846
+ res.writeHead(ok ? 200 : 409, { 'Content-Type': 'application/json' });
1847
+ res.end(JSON.stringify({ spoke: ok, board, detail }));
1848
+ })
1849
+ .catch((err) => {
1850
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1851
+ res.end(JSON.stringify({ error: `speak failed: ${String(err)}` }));
1852
+ });
1853
+ });
1854
+ return;
1855
+ }
1603
1856
  // On-device Apple Intelligence (FoundationModels) text generation via the
1604
1857
  // daemon-managed *persistent* helper. Keeping it warm in the daemon avoids
1605
1858
  // the ~7s per-process cold start callers would otherwise pay each time
@@ -2096,12 +2349,16 @@ export async function startDaemon(opts) {
2096
2349
  // Steering feedback for observed Claude sessions: devices render
2097
2350
  // "stopping at next tool" / queued-directive badges from these.
2098
2351
  if (!s.id.startsWith('observed:claude:'))
2099
- return s;
2352
+ return { ...s, liveAnswerable: false };
2353
+ // Can this daemon type into the session's live prompt? Only observed
2354
+ // Claude routes select_option to the injector, and only with a host to
2355
+ // aim at. Sent in both polarities — decks read it to decide whether an
2356
+ // AskUserQuestion option is a button or an inert mirror of the terminal.
2357
+ const liveAnswerable = Boolean(s.tty || s.appName);
2100
2358
  const snap = steeringSnapshot(s.id.slice('observed:claude:'.length));
2101
- if (!snap.stopRequested && snap.queuedDirectives === 0)
2102
- return s;
2103
2359
  return {
2104
2360
  ...s,
2361
+ liveAnswerable,
2105
2362
  ...(snap.stopRequested ? { stopRequested: true } : {}),
2106
2363
  ...(snap.queuedDirectives > 0 ? { queuedDirectives: snap.queuedDirectives } : {}),
2107
2364
  };
@@ -2123,12 +2380,18 @@ export async function startDaemon(opts) {
2123
2380
  // / adapter-liveness alone must not materialize a session — that kept a
2124
2381
  // phantom OpenClaw alive on devices after it was effectively off. Shared
2125
2382
  // injector with bridge/src/index.ts; mirror of Swift buildSessionsListEvent.
2126
- const snap = core.stateMachine.getSnapshot();
2383
+ //
2384
+ // State/project/model come from Gateway-LOCAL trackers, never the global
2385
+ // `core.stateMachine` snapshot: the daemon hub feeds that same snapshot from
2386
+ // every observed Claude/opencode hook, so borrowing it here reported OpenClaw
2387
+ // as active whenever any unrelated session was mid-turn. `projectName` is
2388
+ // pinned to 'OpenClaw' (Swift parity) rather than the global machine's
2389
+ // last-seen project for the same reason.
2127
2390
  return injectOpenClawSession(enrichedSessions, {
2128
2391
  gatewayConnected: core.cachedGatewayConnected,
2129
- state: snap.state,
2130
- projectName: snap.projectName ?? 'OpenClaw',
2131
- modelName: snap.modelName ?? undefined,
2392
+ state: gatewaySessionState,
2393
+ projectName: 'OpenClaw',
2394
+ modelName: gatewayModelName,
2132
2395
  controlMode: 'managed',
2133
2396
  });
2134
2397
  });
@@ -2174,13 +2437,26 @@ export async function startDaemon(opts) {
2174
2437
  break;
2175
2438
  case 'parser':
2176
2439
  core.stateMachine.handleParserEvent(evt.event, evt.data);
2440
+ // Gateway-local activity state for the virtual session row (mirror of
2441
+ // Swift `gatewaySessionState`). Kept separate from the global
2442
+ // `core.stateMachine` above so the OpenClaw row reflects only the
2443
+ // Gateway's own turn lifecycle, not unrelated observed sessions.
2444
+ if (evt.event === 'spinner_start')
2445
+ gatewaySessionState = 'processing';
2446
+ else if (evt.event === 'idle')
2447
+ gatewaySessionState = 'idle';
2448
+ else if (evt.event === 'permission_prompt')
2449
+ gatewaySessionState = 'awaiting_permission';
2177
2450
  // The OpenClaw adapter emits the real model via a model_info parser
2178
2451
  // event, but it only ever updated the display StateMachine — the APME
2179
2452
  // run was never told, so every openclaw run persisted model_id=NULL.
2180
- if (evt.event === 'model_info' && apme && openclawApmeSessionId) {
2453
+ if (evt.event === 'model_info') {
2181
2454
  const model = evt.data?.model;
2182
- if (model)
2183
- apme.collector.updateModel(openclawApmeSessionId, model);
2455
+ if (model) {
2456
+ gatewayModelName = model;
2457
+ if (apme && openclawApmeSessionId)
2458
+ apme.collector.updateModel(openclawApmeSessionId, model);
2459
+ }
2184
2460
  }
2185
2461
  break;
2186
2462
  case 'metadata':
@@ -2237,6 +2513,7 @@ export async function startDaemon(opts) {
2237
2513
  core.cachedGatewayAvailable = true;
2238
2514
  core.cachedGatewayConnected = true;
2239
2515
  core.cachedGatewayAuthStatus = 'connected';
2516
+ gatewaySessionState = 'idle';
2240
2517
  bridgeLogStream.start();
2241
2518
  log('[agentdeck] OpenClaw Gateway connected');
2242
2519
  if (core.stateMachine.getSnapshot().state === 'disconnected') {
@@ -2257,6 +2534,9 @@ export async function startDaemon(opts) {
2257
2534
  else {
2258
2535
  core.cachedGatewayConnected = false;
2259
2536
  core.cachedGatewayAuthStatus = 'gateway_not_found';
2537
+ // Reset activity to idle; gatewayModelName is preserved across brief
2538
+ // disconnects (Swift parity) so a flap doesn't blank the model chip.
2539
+ gatewaySessionState = 'idle';
2260
2540
  bridgeLogStream.stop();
2261
2541
  log('[agentdeck] OpenClaw Gateway disconnected');
2262
2542
  core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
@@ -2300,6 +2580,7 @@ export async function startDaemon(opts) {
2300
2580
  core.cachedGatewayConnected = false;
2301
2581
  core.cachedGatewayAuthStatus = 'gateway_not_found';
2302
2582
  core.cachedModelCatalog = null;
2583
+ gatewaySessionState = 'idle';
2303
2584
  if (wasAlive)
2304
2585
  core.stateMachine.handleHookEvent('SessionEnd', {});
2305
2586
  else
@@ -2391,6 +2672,18 @@ export async function startDaemon(opts) {
2391
2672
  // sender here so OTA can address the exact socket instead of broadcasting.
2392
2673
  if (msg.type === 'device_info') {
2393
2674
  core.wsServer.markEsp32Client(sender);
2675
+ // Remember what this socket said about itself BEFORE registration, which
2676
+ // is deliberately skipped when the same board is also live on USB serial
2677
+ // (single-path dedup). Anything keyed off the WiFi registry would then see
2678
+ // a board with no board string and no capabilities — that is what made a
2679
+ // dictation from a USB-attached board fail to arm its spoken reply.
2680
+ const selfBoard = typeof msg.board === 'string' ? msg.board : '';
2681
+ if (selfBoard)
2682
+ wsSelfBoard.set(sender, selfBoard);
2683
+ if (Array.isArray(msg.capabilities)) {
2684
+ wsSelfCaps.set(sender, msg
2685
+ .capabilities.filter((c) => typeof c === 'string'));
2686
+ }
2394
2687
  registerWifiEsp32(msg, sender);
2395
2688
  return true;
2396
2689
  }
@@ -2454,9 +2747,9 @@ export async function startDaemon(opts) {
2454
2747
  return true; // consumed
2455
2748
  }
2456
2749
  if (msg.type === 'session_push_state') {
2457
- const { sessionId, state, modelName, effortLevel } = msg;
2750
+ const { sessionId, state, modelName, effortLevel, permissionMode } = msg;
2458
2751
  if (sessionId && state) {
2459
- updatePushState(sessionId, state, modelName, effortLevel);
2752
+ updatePushState(sessionId, state, modelName, effortLevel, permissionMode);
2460
2753
  // Trigger sessions list broadcast so clients get fresh state
2461
2754
  core.maybeBroadcastSessionsList();
2462
2755
  }
@@ -2471,12 +2764,41 @@ export async function startDaemon(opts) {
2471
2764
  core.broadcastSessionsList().catch(() => { });
2472
2765
  return true; // consumed
2473
2766
  }
2767
+ if (msg.type === 'voice_begin') {
2768
+ deviceVoice.begin(sender, msg);
2769
+ return true; // consumed
2770
+ }
2771
+ if (msg.type === 'voice_end') {
2772
+ const captured = deviceVoice.end(sender, msg);
2773
+ if (captured)
2774
+ void finishVoiceCapture(captured, stableSink(sender));
2775
+ return true; // consumed
2776
+ }
2777
+ if (msg.type === 'photo_begin') {
2778
+ devicePhoto.begin(sender, msg);
2779
+ return true; // consumed
2780
+ }
2781
+ if (msg.type === 'photo_end') {
2782
+ const captured = devicePhoto.end(sender, msg);
2783
+ if (captured) {
2784
+ void finishPhotoCapture(captured, stableSink(sender));
2785
+ }
2786
+ else if (msg.cancel !== true) {
2787
+ // Unlike PCM, a JPEG that lost frames is undecodable — tell the board
2788
+ // instead of silently eating the shutter press.
2789
+ try {
2790
+ sender.send(JSON.stringify({ type: 'photo_result', delivered: false, error: 'empty_or_corrupt_capture' }));
2791
+ }
2792
+ catch { /* client disconnecting */ }
2793
+ }
2794
+ return true; // consumed
2795
+ }
2474
2796
  if (msg.type === 'query_session_timeline') {
2475
2797
  // Reply to THIS requester only with the session's recent timeline so a
2476
2798
  // device that connected mid-session can fill its Detail view on demand
2477
2799
  // (the live timeline_event stream is forward-only). Useful for any
2478
2800
  // reconnecting surface, not just the XTeink X3.
2479
- const sessionId = msg.sessionId;
2801
+ const sessionId = resolveDeviceSessionId(msg.sessionId);
2480
2802
  const since = msg.since;
2481
2803
  if (typeof sessionId === 'string' && sessionId) {
2482
2804
  const sinceMs = typeof since === 'number' ? since : undefined;
@@ -2503,6 +2825,26 @@ export async function startDaemon(opts) {
2503
2825
  }
2504
2826
  return false; // not consumed — pass to command handler
2505
2827
  });
2828
+ // Device clients with narrow id buffers (ESP32 SessionInfo.id[32]) echo back
2829
+ // session ids truncated by prepareForSerial's 31-char cap. Observed ids
2830
+ // (`observed:claude:<uuid>`, 52 chars) always exceed that, so an exact-match
2831
+ // lookup silently dropped their commands — worse, the observed-steering
2832
+ // branch queued state under a truncated uuid no hook ever reads. Restore the
2833
+ // full id by unique-prefix match against the known roster before routing.
2834
+ function resolveDeviceSessionId(raw) {
2835
+ if (typeof raw !== 'string' || !raw)
2836
+ return '';
2837
+ const known = ['openclaw-gateway'];
2838
+ for (const s of listActiveSessions())
2839
+ known.push(s.id);
2840
+ const last = core.getLastSessionsListEvent();
2841
+ if (Array.isArray(last?.sessions)) {
2842
+ for (const s of last.sessions)
2843
+ if (typeof s?.id === 'string')
2844
+ known.push(s.id);
2845
+ }
2846
+ return resolveSessionIdPrefix(raw, known);
2847
+ }
2506
2848
  // Steering commands for observed (hook-only, no PTY) Claude sessions —
2507
2849
  // shared by the session_command route and the bare-command fallback below.
2508
2850
  // Semantics differ from managed PTY on purpose:
@@ -2528,14 +2870,39 @@ export async function startDaemon(opts) {
2528
2870
  ? command.index === 0
2529
2871
  : command.value === 'y' || command.value === 'yes';
2530
2872
  resolvePending(ov.requestId, allow ? 'allow' : 'deny');
2873
+ return;
2874
+ }
2875
+ // No held gate — a live TUI prompt (AskUserQuestion / selector) is on
2876
+ // the user's screen. Type the selection into the terminal itself
2877
+ // (tmux/iTerm2 by tty) — no hold, no timeout, no auto-proceed: if the
2878
+ // terminal isn't reachable the prompt simply stays for the user.
2879
+ if (type === 'select_option' && typeof command.index === 'number') {
2880
+ const idx = command.index;
2881
+ const obs = passiveSessionObserver.collect([])
2882
+ .find((s) => s.id === `observed:claude:${uuid}`);
2883
+ // The option's visible label lets app-hosted prompts press the matching
2884
+ // native button instead of guessing cursor movement.
2885
+ const label = ov?.options?.[idx]?.label;
2886
+ injectObservedSelection({ tty: obs?.tty, appName: obs?.appName, label }, idx).then((r) => {
2887
+ debug('daemon', r.ok
2888
+ ? `observed selection injected via ${r.via} (idx ${idx}, ${uuid.slice(0, 8)})`
2889
+ : `observed selection NOT injected: ${r.reason} (${uuid.slice(0, 8)})`);
2890
+ }).catch(() => { });
2531
2891
  }
2532
2892
  return;
2533
2893
  }
2534
2894
  debug('daemon', `observed steering: unsupported command ${type} for ${uuid}`);
2535
2895
  }
2536
- core.wsServer.onCommand((cmd) => {
2896
+ const handleDeviceCommand = (cmd) => {
2537
2897
  debug('daemon', `cmd: ${cmd.type}`);
2538
- if (gatewayAdapter?.isAlive() && gatewayAdapter.handleCommand(cmd)) {
2898
+ // Session-scoped commands are NEVER OpenClaw's to consume: a device
2899
+ // answering a named session (select_option{sessionId}) must reach the
2900
+ // session routing below — the gateway swallowing it as an exec-approval
2901
+ // ate the Focus Strip's tap-to-approve.
2902
+ const sessionScopedCmd = typeof cmd.sessionId === 'string'
2903
+ && cmd.sessionId
2904
+ && cmd.sessionId !== 'openclaw-gateway';
2905
+ if (!sessionScopedCmd && gatewayAdapter?.isAlive() && gatewayAdapter.handleCommand(cmd)) {
2539
2906
  switch (cmd.type) {
2540
2907
  case 'respond':
2541
2908
  core.stateMachine.handleUserAction('respond');
@@ -2583,7 +2950,7 @@ export async function startDaemon(opts) {
2583
2950
  return;
2584
2951
  }
2585
2952
  if (cmd.type === 'focus_session') {
2586
- const sessionId = cmd.sessionId;
2953
+ const sessionId = resolveDeviceSessionId(cmd.sessionId);
2587
2954
  if (!sessionId)
2588
2955
  return;
2589
2956
  userFocusedSessionId = sessionId;
@@ -2606,7 +2973,7 @@ export async function startDaemon(opts) {
2606
2973
  // type — no agent control involved. Result: WS events + badge fields +
2607
2974
  // HTML report opened in the browser (this daemon's "popup" tier).
2608
2975
  if (cmd.type === 'review_run') {
2609
- const sessionId = cmd.sessionId;
2976
+ const sessionId = resolveDeviceSessionId(cmd.sessionId);
2610
2977
  if (!sessionId)
2611
2978
  return;
2612
2979
  const managed = listActiveSessions().find(s => s.id === sessionId);
@@ -2678,9 +3045,10 @@ export async function startDaemon(opts) {
2678
3045
  }
2679
3046
  // Session-scoped command: forward inner command to a specific session's bridge
2680
3047
  if (cmd.type === 'session_command') {
2681
- const { sessionId, command } = cmd;
2682
- if (!sessionId || !command)
3048
+ const { sessionId: rawSessionId, command } = cmd;
3049
+ if (!rawSessionId || !command)
2683
3050
  return;
3051
+ const sessionId = resolveDeviceSessionId(rawSessionId);
2684
3052
  // Observed Claude sessions have no bridge/PTY — route to the hook-based
2685
3053
  // steering primitives instead (soft STOP, turn-end directive queue,
2686
3054
  // gate approval). This replaces the old silent drop.
@@ -2719,7 +3087,15 @@ export async function startDaemon(opts) {
2719
3087
  // any awaiting cell can be answered, not just the focused one. Focus the
2720
3088
  // named session, then route a plain select_option to its bridge.
2721
3089
  if (cmd.type === 'select_option' && typeof cmd.sessionId === 'string') {
2722
- const sessionId = cmd.sessionId;
3090
+ const sessionId = resolveDeviceSessionId(cmd.sessionId);
3091
+ // Session-scoped answering of an OBSERVED session's gate — the same
3092
+ // steering primitive the session_command route uses. Without this, a
3093
+ // device answering an observed awaiting cell fell through to the
3094
+ // focused-session relay and was dropped.
3095
+ if (sessionId.startsWith('observed:claude:')) {
3096
+ handleObservedClaudeCommand(sessionId.slice('observed:claude:'.length), cmd);
3097
+ return;
3098
+ }
2723
3099
  const target = listActiveSessions().find(s => s.id === sessionId);
2724
3100
  if (target) {
2725
3101
  userFocusedSessionId = sessionId;
@@ -2742,6 +3118,45 @@ export async function startDaemon(opts) {
2742
3118
  handleObservedClaudeCommand(userFocusedSessionId.slice('observed:claude:'.length), cmd);
2743
3119
  return;
2744
3120
  }
3121
+ // Peripheral primitive from a sensor-bearing board (t_embed NFC etc.):
3122
+ // log + relay to dashboard clients. Boards never receive it back
3123
+ // (peripheral_event is not serial-forwardable), and command mapping
3124
+ // (tag → focus/approve) comes later as daemon-side config.
3125
+ if (cmd.type === 'peripheral_event') {
3126
+ const p = cmd;
3127
+ const label = p.uid ?? p.code ?? '';
3128
+ debug('daemon', `peripheral_event: ${p.kind} ${label} (${p.board ?? 'esp32'})`);
3129
+ core.wsServer.broadcast(cmd);
3130
+ // Meaning is user configuration, never firmware policy: a mapped tag /
3131
+ // code becomes one of the steering commands the daemon already speaks.
3132
+ try {
3133
+ const mappings = parsePeripheralMappings(loadDaemonSettings());
3134
+ if (mappings.length > 0) {
3135
+ const roster = core.getLastSessionsListEvent()?.sessions ?? [];
3136
+ const view = roster.map((r) => ({
3137
+ id: String(r.id ?? ''),
3138
+ projectName: typeof r.projectName === 'string' ? r.projectName : undefined,
3139
+ state: typeof r.state === 'string' ? r.state : undefined,
3140
+ requestId: typeof r.requestId === 'string' ? r.requestId : undefined,
3141
+ })).filter((r) => r.id);
3142
+ const resolved = resolvePeripheralAction({ kind: String(p.kind ?? ''), uid: p.uid, code: p.code }, mappings, view);
3143
+ if (!resolved) {
3144
+ debug('daemon', `peripheral_event: no mapping for ${label} (add one in settings.json peripheralMappings)`);
3145
+ }
3146
+ else {
3147
+ const mapped = commandForAction(resolved);
3148
+ if (mapped) {
3149
+ debug('daemon', `peripheral_event ${label} → ${resolved.action} on ${resolved.session.id.slice(0, 20)}`);
3150
+ handleDeviceCommand(mapped);
3151
+ }
3152
+ }
3153
+ }
3154
+ }
3155
+ catch (err) {
3156
+ debug('daemon', `peripheral mapping failed: ${String(err).slice(0, 120)}`);
3157
+ }
3158
+ return;
3159
+ }
2745
3160
  if (cmd.type === 'query_usage') {
2746
3161
  fetchUsageRelayed(port).then((usage) => {
2747
3162
  if (usage)
@@ -2750,7 +3165,553 @@ export async function startDaemon(opts) {
2750
3165
  core.apiUsageStale = true;
2751
3166
  });
2752
3167
  }
3168
+ };
3169
+ // ── Device-sourced voice ──────────────────────────────────────────────
3170
+ // A board streams PCM16 while its push-to-talk key is held; the daemon
3171
+ // transcribes with Apple's on-device recognizer (bundled Swift helper) and
3172
+ // turns the text into a prompt for the session the board was pointing at.
3173
+ const deviceVoice = new DeviceVoiceCollector();
3174
+ // A board sends a one-shot JPEG after its shutter press; the daemon saves it
3175
+ // and routes a prompt referencing the file path to the target session.
3176
+ const devicePhoto = new DevicePhotoCollector();
3177
+ /**
3178
+ * Reply router. The resolver lets a queued reply reach a board over whatever
3179
+ * transport is live *now*: a board plugged in over USB parks its radio and
3180
+ * drops the WebSocket it dictated over, and the answer must still arrive.
3181
+ * Serial first, mirroring the single-path dedup policy used elsewhere.
3182
+ */
3183
+ const voiceReply = new DeviceVoiceReplyRouter(() => Date.now(), (ms) => new Promise((r) => setTimeout(r, ms)), (deviceKey) => {
3184
+ const serialPort = liveSerialPortForBoard(deviceKey);
3185
+ if (serialPort) {
3186
+ log(`[agentdeck] voice: routing reply for ${deviceKey} over serial ${serialPort}`);
3187
+ return serialSinkFor(serialPort);
3188
+ }
3189
+ for (const [key, sock] of wifiEsp32Sockets) {
3190
+ if (sock.readyState !== WebSocket.OPEN)
3191
+ continue;
3192
+ 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
+ }
3197
+ }
3198
+ log(`[agentdeck] voice: no live transport for ${deviceKey}`
3199
+ + ` (serial boards: ${serialBoardsAttached().join(',') || 'none'})`);
3200
+ return null;
3201
+ });
3202
+ /**
3203
+ * Wrap a board's socket as a reply sink. Capabilities come from the device
3204
+ * registry rather than being assumed: only a board that advertised
3205
+ * `audio_out` gets audio, so the same voice path degrades to text-only on
3206
+ * boards without an amplifier instead of streaming into nothing.
3207
+ */
3208
+ const replySinkFor = (ws) => ({
3209
+ send: (data) => { try {
3210
+ ws.send(data);
3211
+ }
3212
+ catch { /* closing */ } },
3213
+ sendBinary: (data) => { try {
3214
+ ws.send(data, { binary: true });
3215
+ }
3216
+ catch { /* closing */ } },
3217
+ isOpen: () => ws.readyState === WebSocket.OPEN,
3218
+ describe: () => 'ws',
3219
+ deviceKey: () => {
3220
+ const self = wsSelfBoard.get(ws);
3221
+ if (self)
3222
+ return self;
3223
+ for (const [key, registered] of wifiEsp32Sockets) {
3224
+ if (registered !== ws)
3225
+ continue;
3226
+ return wifiEsp32Devices.get(key)?.board ?? key;
3227
+ }
3228
+ return 'ws';
3229
+ },
3230
+ capabilities: () => {
3231
+ const self = wsSelfCaps.get(ws);
3232
+ if (self)
3233
+ return self;
3234
+ for (const [key, registered] of wifiEsp32Sockets) {
3235
+ if (registered !== ws)
3236
+ continue;
3237
+ return wifiEsp32Devices.get(key)?.capabilities ?? [];
3238
+ }
3239
+ return [];
3240
+ },
3241
+ });
3242
+ /**
3243
+ * The other half of the voice round trip. A dictated prompt arms the board
3244
+ * that sent it; when that session's turn completes, the reply is synthesized
3245
+ * on the host and streamed to the board's amplifier. `chat_response` is the
3246
+ * one chokepoint every agent's completion reaches — managed sessions relay it
3247
+ * from their bridge, hook-observed ones get it from the Stop handler above —
3248
+ * so one listener covers all of them.
3249
+ */
3250
+ /** Speak `text` to every board waiting on `sessionId`, or tell them there was
3251
+ * nothing to read. Shared by both completion shapes below. */
3252
+ const speakReplyTo = (sessionId, text) => {
3253
+ const targets = voiceReply.targetsFor(sessionId);
3254
+ if (targets.length === 0)
3255
+ return;
3256
+ log(`[agentdeck] voice: reply ready for ${sessionId.slice(0, 32)}`
3257
+ + ` -> ${targets.length} board(s)`);
3258
+ const voiceCfg = loadDaemonSettings().voice;
3259
+ if (voiceCfg?.speakReplies === false)
3260
+ return; // opt-out, default on
3261
+ const spoken = speakableReply(text);
3262
+ if (!spoken) {
3263
+ // A code-only answer has nothing worth reading aloud; say that instead of
3264
+ // spelling out punctuation, so the user still knows the turn finished.
3265
+ log('[agentdeck] voice: reply held nothing speakable — skipped');
3266
+ for (const sink of targets) {
3267
+ try {
3268
+ sink.send(JSON.stringify({ type: 'voice_reply_skipped' }));
3269
+ }
3270
+ catch { /* closing */ }
3271
+ // Consume the arming either way: the dictation was answered, and
3272
+ // leaving it armed would read out the session's *next* turn instead.
3273
+ voiceReply.disarm(sink);
3274
+ }
3275
+ return;
3276
+ }
3277
+ const locale = typeof voiceCfg?.locale === 'string' && voiceCfg.locale
3278
+ ? voiceCfg.locale : undefined;
3279
+ void (async () => {
3280
+ const out = join(tmpdir(), `agentdeck-reply-${process.pid}-${Date.now()}.wav`);
3281
+ try {
3282
+ await synthesizeWavWithHelper(spoken, out, locale ? { locale } : {});
3283
+ const wav = await readFile(out);
3284
+ for (const sink of targets) {
3285
+ const ok = await voiceReply.stream(sink, wav, spoken);
3286
+ log(`[agentdeck] voice: ${ok ? 'spoke' : 'FAILED to speak'} reply`
3287
+ + ` (${wav.length}B, ${spoken.length} chars)`);
3288
+ }
3289
+ }
3290
+ catch (err) {
3291
+ log(`[agentdeck] voice: reply synthesis failed: ${String(err).slice(0, 140)}`);
3292
+ }
3293
+ finally {
3294
+ await rm(out, { force: true }).catch(() => { });
3295
+ }
3296
+ })();
3297
+ };
3298
+ /**
3299
+ * Turn completion for an armed session. Two shapes reach here:
3300
+ *
3301
+ * `chat_response` carries the text and speaks immediately. `chat_end` means
3302
+ * the turn closed with nothing extractable — which routinely happens because
3303
+ * the agent's own record hadn't flushed when the Stop hook fired, not because
3304
+ * the turn was silent. (Measured: a Codex turn closed as "Completed · 5s" and
3305
+ * its rollout held the full answer seconds later.) So a chat_end retries from
3306
+ * that record before giving up, and only then tells the board there was
3307
+ * nothing to read.
3308
+ */
3309
+ const REPLY_RECOVER_DELAY_MS = 2500;
3310
+ core.bridgeTimeline.onEntry((entry, upsert) => {
3311
+ // Upserts re-fire for rows that already spoke; only a new row is a new turn.
3312
+ if (upsert || !entry.sessionId)
3313
+ return;
3314
+ if (entry.type !== 'chat_response' && entry.type !== 'chat_end')
3315
+ return;
3316
+ const sessionId = String(entry.sessionId);
3317
+ if (voiceReply.targetsFor(sessionId).length === 0) {
3318
+ // Only noisy while something is actually waiting — and this is exactly the
3319
+ // case that used to fail silently: a completion row arrived, nothing
3320
+ // matched it, and there was no way to tell a lost arming from a listener
3321
+ // that never ran.
3322
+ if (voiceReply.armedCount() > 0) {
3323
+ log(`[agentdeck] voice: ${entry.type} for ${sessionId.slice(0, 32)} matched no`
3324
+ + ` arming (armed: ${voiceReply.describeArmed()})`);
3325
+ }
3326
+ return;
3327
+ }
3328
+ if (entry.type === 'chat_response') {
3329
+ const detail = typeof entry.detail === 'string' ? entry.detail : '';
3330
+ speakReplyTo(sessionId, detail || String(entry.raw ?? ''));
3331
+ return;
3332
+ }
3333
+ const agentType = String(entry.agentType ?? '');
3334
+ setTimeout(() => {
3335
+ if (voiceReply.targetsFor(sessionId).length === 0)
3336
+ return;
3337
+ const recovered = agentType.startsWith('codex')
3338
+ ? lastAgentMessageFromCodexRollout(rawSessionId(sessionId))
3339
+ : lastAssistantTextForSession(sessionId);
3340
+ log(`[agentdeck] voice: chat_end recovery for ${sessionId.slice(0, 32)}`
3341
+ + ` (${agentType || 'unknown'}): ${recovered ? `${recovered.length} chars` : 'nothing'}`);
3342
+ speakReplyTo(sessionId, recovered || '');
3343
+ }, REPLY_RECOVER_DELAY_MS).unref?.();
3344
+ });
3345
+ const sinkByWs = new Map();
3346
+ const stableSink = (ws) => {
3347
+ // The router keys arming by sink identity, so the same socket must map to
3348
+ // the same object every time — a fresh wrapper per call would arm one
3349
+ // object and look up another.
3350
+ let sink = sinkByWs.get(ws);
3351
+ if (!sink) {
3352
+ sink = replySinkFor(ws);
3353
+ sinkByWs.set(ws, sink);
3354
+ }
3355
+ return sink;
3356
+ };
3357
+ // Serial-attached boards get the same sink shape. Audio rides base64 inside
3358
+ // JSON lines because this transport is line-delimited and raw PCM would tear
3359
+ // the framing; the encoding cost is invisible on native-USB CDC.
3360
+ const serialSinks = new Map();
3361
+ const serialSinkFor = (port) => {
3362
+ let sink = serialSinks.get(port);
3363
+ if (sink)
3364
+ return sink;
3365
+ sink = {
3366
+ send: (data) => { sendSerialJson(port, data); },
3367
+ sendBinary: (data) => {
3368
+ sendSerialJson(port, { type: 'audio_play_chunk', d: data.toString('base64') });
3369
+ },
3370
+ isOpen: () => serialPortConnected(port),
3371
+ describe: () => `serial:${port.replace('/dev/cu.', '')}`,
3372
+ deviceKey: () => serialPortBoard(port) ?? port,
3373
+ capabilities: () => serialPortCapabilities(port),
3374
+ };
3375
+ serialSinks.set(port, sink);
3376
+ return sink;
3377
+ };
3378
+ /**
3379
+ * Back the `POST /voice/speak` bench test now that both sink flavours exist.
3380
+ * Same transport policy as the reply router — serial first, WiFi second — so
3381
+ * what this proves is what the real reply path will do.
3382
+ */
3383
+ speakToBoard = async (board, text, opts) => {
3384
+ const spoken = speakableReply(text);
3385
+ if (!spoken)
3386
+ return { ok: false, detail: 'nothing speakable in text' };
3387
+ const serialPort = liveSerialPortForBoard(board);
3388
+ let sink = serialPort ? serialSinkFor(serialPort) : null;
3389
+ if (!sink) {
3390
+ for (const [key, sock] of wifiEsp32Sockets) {
3391
+ if (sock.readyState !== WebSocket.OPEN)
3392
+ continue;
3393
+ if (wifiEsp32Devices.get(key)?.board === board || key === board) {
3394
+ sink = stableSink(sock);
3395
+ break;
3396
+ }
3397
+ }
3398
+ }
3399
+ if (!sink) {
3400
+ const wifi = [...wifiEsp32Devices.values()].map((d) => d.board).filter(Boolean);
3401
+ return {
3402
+ ok: false,
3403
+ detail: `no live transport for "${board}"`
3404
+ + ` (serial: ${serialBoardsAttached().join(',') || 'none'};`
3405
+ + ` wifi: ${wifi.join(',') || 'none'})`,
3406
+ };
3407
+ }
3408
+ // A board that never initialized its amplifier drops the frames on the
3409
+ // floor, which is indistinguishable from a wiring fault at the listening
3410
+ // end. Say so instead, and let --force stream anyway when the question is
3411
+ // whether the advertisement itself is wrong.
3412
+ const caps = sink.capabilities();
3413
+ if (!caps.includes('audio_out') && !opts.force) {
3414
+ return {
3415
+ ok: false,
3416
+ detail: `${sink.describe()} does not advertise audio_out`
3417
+ + ` (capabilities: ${caps.join(',') || 'none'}) — pass force to stream anyway`,
3418
+ };
3419
+ }
3420
+ const voiceCfg = loadDaemonSettings().voice;
3421
+ const locale = opts.locale
3422
+ ?? (typeof voiceCfg?.locale === 'string' && voiceCfg.locale ? voiceCfg.locale : undefined);
3423
+ const out = join(tmpdir(), `agentdeck-speak-${process.pid}-${Date.now()}.wav`);
3424
+ try {
3425
+ await synthesizeWavWithHelper(spoken, out, locale ? { locale } : {});
3426
+ const wav = await readFile(out);
3427
+ // Note: stream() consumes any arming held by this sink, so a bench test
3428
+ // run while a board is genuinely waiting on a reply eats that reply.
3429
+ const ok = await voiceReply.stream(sink, wav, spoken);
3430
+ log(`[agentdeck] voice: bench ${ok ? 'spoke' : 'FAILED to speak'} on ${sink.describe()}`
3431
+ + ` (${wav.length}B, ${spoken.length} chars)`);
3432
+ return {
3433
+ ok,
3434
+ detail: ok
3435
+ ? `${sink.describe()} · ${wav.length}B · ${spoken.length} chars`
3436
+ : `stream rejected by ${sink.describe()} (transport closed or already speaking)`,
3437
+ };
3438
+ }
3439
+ finally {
3440
+ await rm(out, { force: true }).catch(() => { });
3441
+ }
3442
+ };
3443
+ // Voice from a USB-attached board. Without this the mic worked only while the
3444
+ // board was on WiFi — and attaching USB parks WiFi, so plugging in to charge
3445
+ // silently removed the feature.
3446
+ // Half-duplex guard: hold daemon→board serial writes while that port has an
3447
+ // open photo capture (see flushNextWrite — full-duplex CDC drops blocks).
3448
+ setSerialQuiesceCheck((port) => devicePhoto.isOpen(port));
3449
+ /**
3450
+ * Where a photo_result goes for an HTTP-uploaded photo: the request carries
3451
+ * no socket to answer on, so resolve the board's live transport the same
3452
+ * way a queued voice reply does (serial first, then its WiFi socket).
3453
+ */
3454
+ const photoResultSinkFor = (board) => {
3455
+ const port = liveSerialPortForBoard(board);
3456
+ if (port)
3457
+ return serialSinkFor(port);
3458
+ for (const [key, sock] of wifiEsp32Sockets) {
3459
+ if (sock.readyState !== WebSocket.OPEN)
3460
+ continue;
3461
+ if (wifiEsp32Devices.get(key)?.board === board || key === board) {
3462
+ return stableSink(sock);
3463
+ }
3464
+ }
3465
+ return {
3466
+ send: () => { },
3467
+ sendBinary: () => { },
3468
+ isOpen: () => false,
3469
+ capabilities: () => [],
3470
+ describe: () => `http:${board}(no live transport)`,
3471
+ deviceKey: () => board,
3472
+ };
3473
+ };
3474
+ setSerialVoiceSink((port, msg) => {
3475
+ if (msg.type === 'voice_begin') {
3476
+ deviceVoice.begin(port, msg);
3477
+ }
3478
+ else if (msg.type === 'audio_chunk') {
3479
+ const b64 = typeof msg.d === 'string' ? msg.d : '';
3480
+ if (b64)
3481
+ deviceVoice.append(port, Buffer.from(b64, 'base64'));
3482
+ }
3483
+ else if (msg.type === 'voice_end') {
3484
+ const captured = deviceVoice.end(port, msg);
3485
+ if (captured)
3486
+ void finishVoiceCapture(captured, serialSinkFor(port));
3487
+ }
3488
+ else if (msg.type === 'photo_begin') {
3489
+ // Photo over USB: the strip is a desk fixture and usually powered from
3490
+ // the host, which parks its radio — the camera must not go dead just
3491
+ // because the board is on the cable it lives on.
3492
+ devicePhoto.begin(port, msg);
3493
+ }
3494
+ else if (msg.type === 'photo_chunk') {
3495
+ const b64 = typeof msg.d === 'string' ? msg.d : '';
3496
+ if (b64 && !devicePhoto.append(port, Buffer.from(b64, 'base64'))) {
3497
+ // A chunk with no open capture means photo_begin was lost (torn
3498
+ // serial line, board reset) — say so, or the drop is undiagnosable.
3499
+ debug('photo', `orphan serial photo_chunk from ${port} (no open capture)`);
3500
+ }
3501
+ }
3502
+ else if (msg.type === 'photo_end') {
3503
+ const captured = devicePhoto.end(port, msg);
3504
+ if (captured) {
3505
+ void finishPhotoCapture(captured, serialSinkFor(port));
3506
+ }
3507
+ else if (msg.cancel !== true) {
3508
+ try {
3509
+ serialSinkFor(port).send(JSON.stringify({ type: 'photo_result', delivered: false, error: 'empty_or_corrupt_capture' }));
3510
+ }
3511
+ catch { /* port closing */ }
3512
+ }
3513
+ }
3514
+ });
3515
+ /**
3516
+ * Finish one captured utterance: transcribe, route the text to the session,
3517
+ * tell the board what happened, and arm it for the spoken reply. Shared by
3518
+ * the WS and serial paths — a board attached over USB must behave exactly
3519
+ * like the same board on WiFi, which is only true if there is one
3520
+ * implementation.
3521
+ */
3522
+ const finishVoiceCapture = async (captured, sink) => {
3523
+ try {
3524
+ // Locale matters: the recognizer falls back to the system locale,
3525
+ // which mangles speech in another language. settings.json
3526
+ // `voice.locale` (BCP-47, e.g. "en-US") overrides it.
3527
+ const voiceCfg = loadDaemonSettings().voice;
3528
+ const locale = typeof voiceCfg?.locale === 'string' && voiceCfg.locale
3529
+ ? voiceCfg.locale : undefined;
3530
+ const text = await transcribeWithHelper(captured.wavPath, locale);
3531
+ const sessionId = resolveDeviceSessionId(captured.sessionId);
3532
+ debug('voice', `transcript "${text.slice(0, 60)}" → ${sessionId.slice(0, 20) || '(no session)'}`);
3533
+ // Always tell the board what was heard, even when it is empty or
3534
+ // unroutable — a device that shows nothing is indistinguishable
3535
+ // from a broken mic.
3536
+ let delivered = false;
3537
+ let via;
3538
+ let deliverReason;
3539
+ if (text && sessionId) {
3540
+ if (sessionId.startsWith('observed:')) {
3541
+ // Any observed agent (claude, codex, …): type the dictated line
3542
+ // into the terminal that owns the session. session_command only
3543
+ // knew how to route observed *Claude*, so a Codex target was
3544
+ // silently dropped — the board said "sent" and nothing arrived.
3545
+ const obs = passiveSessionObserver.collect([])
3546
+ .find((s) => s.id === sessionId);
3547
+ const r = await injectObservedText({ tty: obs?.tty, appName: obs?.appName }, text);
3548
+ delivered = r.ok;
3549
+ via = r.via;
3550
+ deliverReason = r.reason;
3551
+ }
3552
+ else {
3553
+ handleDeviceCommand({
3554
+ type: 'session_command',
3555
+ sessionId,
3556
+ command: { type: 'send_prompt', text },
3557
+ });
3558
+ delivered = true;
3559
+ via = 'session-bridge';
3560
+ }
3561
+ // Only a delivered prompt earns a spoken reply: arming on a
3562
+ // dropped one would read out whatever that session happened to
3563
+ // answer someone else.
3564
+ const armed = delivered ? voiceReply.arm(sink, sessionId) : false;
3565
+ // Logged at info level rather than debug: these are rare,
3566
+ // user-initiated events, and the silence made "the board said sent
3567
+ // but nothing happened" undiagnosable without a rebuild.
3568
+ log(`[agentdeck] voice: "${text.slice(0, 40)}" -> ${sessionId.slice(0, 32)}`
3569
+ + ` delivered=${delivered}${via ? ` via ${via}` : ''}`
3570
+ + `${deliverReason ? ` (${deliverReason})` : ''}`
3571
+ + ` spokenReply=${armed ? `armed(${sink.describe()})` : 'no'}`
3572
+ + ` [${sink.describe()} key=${sink.deviceKey()}`
3573
+ + ` caps=${sink.capabilities().join('|') || 'none'}]`);
3574
+ }
3575
+ try {
3576
+ sink.send(JSON.stringify({
3577
+ type: 'voice_result', text, sessionId, delivered,
3578
+ ...(via ? { via } : {}),
3579
+ ...(deliverReason ? { deliverReason } : {}),
3580
+ }));
3581
+ }
3582
+ catch { /* client disconnecting */ }
3583
+ }
3584
+ catch (err) {
3585
+ const reason = String(err).slice(0, 160);
3586
+ debug('voice', `transcription failed: ${reason}`);
3587
+ try {
3588
+ sink.send(JSON.stringify({ type: 'voice_result', text: '', error: reason }));
3589
+ }
3590
+ catch { /* client disconnecting */ }
3591
+ }
3592
+ finally {
3593
+ captured.cleanup();
3594
+ }
3595
+ };
3596
+ /**
3597
+ * Finish one captured photo: build the prompt that points the target session
3598
+ * at the saved file, deliver it through the same ladder as dictated text
3599
+ * (observed → terminal injection, managed → send_prompt), and tell the board
3600
+ * what happened. Shared by the WS and serial paths for the same reason as
3601
+ * finishVoiceCapture: one implementation or the transports drift.
3602
+ */
3603
+ const finishPhotoCapture = async (captured, sink) => {
3604
+ try {
3605
+ // The strip aims at its own focus pick; when it sends none, fall back to
3606
+ // the daemon's explicitly focused session rather than dropping the shot.
3607
+ let sessionId = resolveDeviceSessionId(captured.sessionId);
3608
+ if (!sessionId && userFocusedSessionId)
3609
+ sessionId = userFocusedSessionId;
3610
+ const photoCfg = loadDaemonSettings().photo;
3611
+ const template = typeof photoCfg?.promptTemplate === 'string'
3612
+ && photoCfg.promptTemplate.includes('{path}')
3613
+ ? photoCfg.promptTemplate
3614
+ : 'Look at the photo I just captured for you on the AgentDeck device and respond: {path}';
3615
+ const text = template.replace('{path}', captured.path);
3616
+ let delivered = false;
3617
+ let via;
3618
+ let deliverReason;
3619
+ if (sessionId) {
3620
+ if (sessionId.startsWith('observed:')) {
3621
+ // Any observed agent: type the prompt into the terminal that owns the
3622
+ // session — the same ladder dictated text uses.
3623
+ const obs = passiveSessionObserver.collect([])
3624
+ .find((s) => s.id === sessionId);
3625
+ const r = await injectObservedText({ tty: obs?.tty, appName: obs?.appName }, text);
3626
+ delivered = r.ok;
3627
+ via = r.via;
3628
+ deliverReason = r.reason;
3629
+ }
3630
+ else {
3631
+ handleDeviceCommand({
3632
+ type: 'session_command',
3633
+ sessionId,
3634
+ command: { type: 'send_prompt', text },
3635
+ });
3636
+ delivered = true;
3637
+ via = 'session-bridge';
3638
+ }
3639
+ }
3640
+ else {
3641
+ deliverReason = 'no_target_session';
3642
+ }
3643
+ // Info level, like voice: rare user-initiated events whose silence made
3644
+ // "the board said sent but nothing happened" undiagnosable.
3645
+ log(`[agentdeck] photo: ${captured.bytes}B -> ${sessionId.slice(0, 32) || '(no session)'}`
3646
+ + ` delivered=${delivered}${via ? ` via ${via}` : ''}`
3647
+ + `${deliverReason ? ` (${deliverReason})` : ''} [${captured.path}]`);
3648
+ try {
3649
+ sink.send(JSON.stringify({
3650
+ type: 'photo_result', delivered, sessionId, bytes: captured.bytes,
3651
+ ...(via ? { via } : {}),
3652
+ ...(deliverReason ? { deliverReason } : {}),
3653
+ }));
3654
+ }
3655
+ catch { /* client disconnecting */ }
3656
+ }
3657
+ catch (err) {
3658
+ const reason = String(err).slice(0, 160);
3659
+ debug('photo', `delivery failed: ${reason}`);
3660
+ try {
3661
+ sink.send(JSON.stringify({ type: 'photo_result', delivered: false, error: reason }));
3662
+ }
3663
+ catch { /* client disconnecting */ }
3664
+ }
3665
+ };
3666
+ core.wsServer.onBinary((data, sender) => {
3667
+ // A connection holds an open voice utterance OR an open photo, never both
3668
+ // (protocol contract) — offer the unenveloped frame to each in turn.
3669
+ if (deviceVoice.append(sender, data))
3670
+ return;
3671
+ if (devicePhoto.append(sender, data))
3672
+ return;
3673
+ debug('voice', `binary frame with no open utterance/capture (${data.length} bytes) — ignored`);
2753
3674
  });
3675
+ setInterval(() => {
3676
+ deviceVoice.sweep();
3677
+ for (const conn of devicePhoto.sweep()) {
3678
+ // Tell the board its capture died (lost photo_end): the strip otherwise
3679
+ // has no way to distinguish "delivered" from "evaporated in transit".
3680
+ try {
3681
+ const sink = typeof conn === 'string' ? serialSinkFor(conn) : stableSink(conn);
3682
+ sink.send(JSON.stringify({ type: 'photo_result', delivered: false, error: 'capture_timeout' }));
3683
+ }
3684
+ catch { /* transport gone too */ }
3685
+ }
3686
+ // Keep armings alive while their session is still working: a dictated
3687
+ // question that starts a long task must still get its answer read out when
3688
+ // the task finally finishes, and the TTL alone would have expired first.
3689
+ void core.buildSessionsSnapshot().then((sessions) => {
3690
+ voiceReply.refresh(sessions
3691
+ .filter((s) => s.state === 'processing' || s.state === 'awaiting_input')
3692
+ .map((s) => String(s.id)));
3693
+ }).catch(() => { });
3694
+ voiceReply.sweep();
3695
+ for (const ws of sinkByWs.keys()) {
3696
+ if (ws.readyState !== WebSocket.OPEN)
3697
+ sinkByWs.delete(ws);
3698
+ }
3699
+ for (const ws of wsSelfBoard.keys()) {
3700
+ if (ws.readyState === WebSocket.CLOSED) {
3701
+ wsSelfBoard.delete(ws);
3702
+ wsSelfCaps.delete(ws);
3703
+ }
3704
+ }
3705
+ }, 30_000).unref?.();
3706
+ core.wsServer.onCommand(handleDeviceCommand);
3707
+ // Idle display-only boards send no messages — their protocol pongs are the
3708
+ // only liveness signal. Without this, every healthy quiet XTeink read as
3709
+ // "stale" in /devices and the roster aged it toward eviction.
3710
+ core.wsServer.onPong((ws) => touchWifiEsp32Socket(ws));
3711
+ // Serial-attached boards get the SAME command pipeline: their steering taps
3712
+ // arrive over USB when WiFi is parked (serial-primary), and used to be
3713
+ // silently dropped by the device_info-only serial parser.
3714
+ setSerialCommandSink((c) => handleDeviceCommand(c));
2754
3715
  // ===== Client connect =====
2755
3716
  core.wsServer.onClientConnect((ws) => {
2756
3717
  const gwAlive = gatewayAdapter?.isAlive() ?? false;