@agentdeck/bridge 1.0.24 → 1.2.0

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 (184) hide show
  1. package/README.md +14 -6
  2. package/assets/fonts/NOTICE-PocketSansWorld.md +10 -0
  3. package/assets/fonts/OFL-PocketSansWorld.txt +93 -0
  4. package/assets/fonts/PocketSansWorld_12.cpfont +0 -0
  5. package/assets/fonts/pocket-sans-world.manifest.json +49 -0
  6. package/assets/learning/LICENSE-CC-BY-SA-4.0.txt +10 -0
  7. package/assets/learning/NOTICE.md +33 -0
  8. package/assets/learning/jp-n3-ko.manifest.json +27 -0
  9. package/assets/learning/jp-n3-ko.pdl +0 -0
  10. package/dist/adapters/openclaw.d.ts +74 -4
  11. package/dist/adapters/openclaw.d.ts.map +1 -1
  12. package/dist/adapters/openclaw.js +235 -43
  13. package/dist/adapters/openclaw.js.map +1 -1
  14. package/dist/agent-cli-diagnostics.d.ts +20 -0
  15. package/dist/agent-cli-diagnostics.d.ts.map +1 -0
  16. package/dist/agent-cli-diagnostics.js +100 -0
  17. package/dist/agent-cli-diagnostics.js.map +1 -0
  18. package/dist/apme/adapters/openclaw-hook.d.ts +38 -10
  19. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
  20. package/dist/apme/adapters/openclaw-hook.js +181 -37
  21. package/dist/apme/adapters/openclaw-hook.js.map +1 -1
  22. package/dist/apme/collector.d.ts +55 -2
  23. package/dist/apme/collector.d.ts.map +1 -1
  24. package/dist/apme/collector.js +324 -37
  25. package/dist/apme/collector.js.map +1 -1
  26. package/dist/apme/dashboard-html.d.ts.map +1 -1
  27. package/dist/apme/dashboard-html.js +275 -35
  28. package/dist/apme/dashboard-html.js.map +1 -1
  29. package/dist/apme/http.d.ts.map +1 -1
  30. package/dist/apme/http.js +33 -5
  31. package/dist/apme/http.js.map +1 -1
  32. package/dist/apme/index.d.ts.map +1 -1
  33. package/dist/apme/index.js +6 -2
  34. package/dist/apme/index.js.map +1 -1
  35. package/dist/apme/pareto.d.ts +2 -1
  36. package/dist/apme/pareto.d.ts.map +1 -1
  37. package/dist/apme/pareto.js +15 -4
  38. package/dist/apme/pareto.js.map +1 -1
  39. package/dist/apme/recommend.d.ts +24 -1
  40. package/dist/apme/recommend.d.ts.map +1 -1
  41. package/dist/apme/recommend.js +43 -7
  42. package/dist/apme/recommend.js.map +1 -1
  43. package/dist/apme/store.d.ts +47 -1
  44. package/dist/apme/store.d.ts.map +1 -1
  45. package/dist/apme/store.js +397 -99
  46. package/dist/apme/store.js.map +1 -1
  47. package/dist/bridge-core.d.ts +25 -1
  48. package/dist/bridge-core.d.ts.map +1 -1
  49. package/dist/bridge-core.js +50 -9
  50. package/dist/bridge-core.js.map +1 -1
  51. package/dist/card-feed.d.ts +46 -1
  52. package/dist/card-feed.d.ts.map +1 -1
  53. package/dist/card-feed.js +166 -6
  54. package/dist/card-feed.js.map +1 -1
  55. package/dist/cli.d.ts +56 -0
  56. package/dist/cli.d.ts.map +1 -1
  57. package/dist/cli.js +442 -34
  58. package/dist/cli.js.map +1 -1
  59. package/dist/codex-rate-limits-live.d.ts +180 -10
  60. package/dist/codex-rate-limits-live.d.ts.map +1 -1
  61. package/dist/codex-rate-limits-live.js +475 -22
  62. package/dist/codex-rate-limits-live.js.map +1 -1
  63. package/dist/daemon-build-identity.d.ts +124 -0
  64. package/dist/daemon-build-identity.d.ts.map +1 -0
  65. package/dist/daemon-build-identity.js +310 -0
  66. package/dist/daemon-build-identity.js.map +1 -0
  67. package/dist/daemon-server.d.ts +34 -6
  68. package/dist/daemon-server.d.ts.map +1 -1
  69. package/dist/daemon-server.js +876 -109
  70. package/dist/daemon-server.js.map +1 -1
  71. package/dist/daemon-takeover.d.ts +11 -0
  72. package/dist/daemon-takeover.d.ts.map +1 -1
  73. package/dist/daemon-takeover.js +62 -17
  74. package/dist/daemon-takeover.js.map +1 -1
  75. package/dist/daemon-ws-client.d.ts +1 -0
  76. package/dist/daemon-ws-client.d.ts.map +1 -1
  77. package/dist/daemon-ws-client.js +25 -16
  78. package/dist/daemon-ws-client.js.map +1 -1
  79. package/dist/daemon.js +1 -1
  80. package/dist/daemon.js.map +1 -1
  81. package/dist/esp32-serial.d.ts +18 -0
  82. package/dist/esp32-serial.d.ts.map +1 -1
  83. package/dist/esp32-serial.js +23 -1
  84. package/dist/esp32-serial.js.map +1 -1
  85. package/dist/font-pack.d.ts +15 -0
  86. package/dist/font-pack.d.ts.map +1 -0
  87. package/dist/font-pack.js +75 -0
  88. package/dist/font-pack.js.map +1 -0
  89. package/dist/glance-frame.d.ts.map +1 -1
  90. package/dist/glance-frame.js +5 -1
  91. package/dist/glance-frame.js.map +1 -1
  92. package/dist/hook-codex-sessions.d.ts +3 -1
  93. package/dist/hook-codex-sessions.d.ts.map +1 -1
  94. package/dist/hook-codex-sessions.js +27 -7
  95. package/dist/hook-codex-sessions.js.map +1 -1
  96. package/dist/http-auth-gate.d.ts.map +1 -1
  97. package/dist/http-auth-gate.js +9 -1
  98. package/dist/http-auth-gate.js.map +1 -1
  99. package/dist/index.js +2 -2
  100. package/dist/index.js.map +1 -1
  101. package/dist/learning-pack.d.ts +24 -0
  102. package/dist/learning-pack.d.ts.map +1 -0
  103. package/dist/learning-pack.js +125 -0
  104. package/dist/learning-pack.js.map +1 -0
  105. package/dist/legacy-rearm-ledger.d.ts +56 -0
  106. package/dist/legacy-rearm-ledger.d.ts.map +1 -0
  107. package/dist/legacy-rearm-ledger.js +66 -0
  108. package/dist/legacy-rearm-ledger.js.map +1 -0
  109. package/dist/mdns.d.ts +33 -0
  110. package/dist/mdns.d.ts.map +1 -1
  111. package/dist/mdns.js +92 -3
  112. package/dist/mdns.js.map +1 -1
  113. package/dist/openclaw-session.d.ts.map +1 -1
  114. package/dist/openclaw-session.js +13 -0
  115. package/dist/openclaw-session.js.map +1 -1
  116. package/dist/openclaw-timeline-feed.d.ts +84 -0
  117. package/dist/openclaw-timeline-feed.d.ts.map +1 -0
  118. package/dist/openclaw-timeline-feed.js +162 -0
  119. package/dist/openclaw-timeline-feed.js.map +1 -0
  120. package/dist/openclaw-transcript-timeline.d.ts +103 -0
  121. package/dist/openclaw-transcript-timeline.d.ts.map +1 -0
  122. package/dist/openclaw-transcript-timeline.js +328 -0
  123. package/dist/openclaw-transcript-timeline.js.map +1 -0
  124. package/dist/pairing-knocks.d.ts +80 -0
  125. package/dist/pairing-knocks.d.ts.map +1 -0
  126. package/dist/pairing-knocks.js +312 -0
  127. package/dist/pairing-knocks.js.map +1 -0
  128. package/dist/passive-observer.d.ts +21 -1
  129. package/dist/passive-observer.d.ts.map +1 -1
  130. package/dist/passive-observer.js +37 -3
  131. package/dist/passive-observer.js.map +1 -1
  132. package/dist/pixoo/micro-glyphs.d.ts +4 -0
  133. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  134. package/dist/pixoo/micro-glyphs.js +29 -0
  135. package/dist/pixoo/micro-glyphs.js.map +1 -1
  136. package/dist/pixoo/pixoo-font.d.ts.map +1 -1
  137. package/dist/pixoo/pixoo-font.js +3 -1
  138. package/dist/pixoo/pixoo-font.js.map +1 -1
  139. package/dist/pixoo/pixoo-renderer.d.ts +1 -1
  140. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  141. package/dist/pixoo/pixoo-renderer.js +17 -2
  142. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  143. package/dist/pty-manager.d.ts +9 -0
  144. package/dist/pty-manager.d.ts.map +1 -1
  145. package/dist/pty-manager.js +63 -4
  146. package/dist/pty-manager.js.map +1 -1
  147. package/dist/relayed-usage.d.ts +98 -0
  148. package/dist/relayed-usage.d.ts.map +1 -0
  149. package/dist/relayed-usage.js +103 -0
  150. package/dist/relayed-usage.js.map +1 -0
  151. package/dist/session-aggregator.d.ts +3 -0
  152. package/dist/session-aggregator.d.ts.map +1 -1
  153. package/dist/session-aggregator.js.map +1 -1
  154. package/dist/session-registry.d.ts +1 -0
  155. package/dist/session-registry.d.ts.map +1 -1
  156. package/dist/surface-protocol.d.ts +84 -0
  157. package/dist/surface-protocol.d.ts.map +1 -0
  158. package/dist/surface-protocol.js +237 -0
  159. package/dist/surface-protocol.js.map +1 -0
  160. package/dist/timeline-store.d.ts +1 -1
  161. package/dist/timeline-store.d.ts.map +1 -1
  162. package/dist/timeline-store.js +16 -1
  163. package/dist/timeline-store.js.map +1 -1
  164. package/dist/tui/dashboard.d.ts.map +1 -1
  165. package/dist/tui/dashboard.js +8 -1
  166. package/dist/tui/dashboard.js.map +1 -1
  167. package/dist/tui/renderer.d.ts.map +1 -1
  168. package/dist/tui/renderer.js +35 -0
  169. package/dist/tui/renderer.js.map +1 -1
  170. package/dist/weather-config.d.ts +17 -0
  171. package/dist/weather-config.d.ts.map +1 -0
  172. package/dist/weather-config.js +54 -0
  173. package/dist/weather-config.js.map +1 -0
  174. package/dist/weather.d.ts +44 -4
  175. package/dist/weather.d.ts.map +1 -1
  176. package/dist/weather.js +223 -15
  177. package/dist/weather.js.map +1 -1
  178. package/dist/ws-server.d.ts +18 -0
  179. package/dist/ws-server.d.ts.map +1 -1
  180. package/dist/ws-server.js +95 -7
  181. package/dist/ws-server.js.map +1 -1
  182. package/fm-helper/AgentDeckFMHelper.swift +51 -5
  183. package/package.json +4 -3
  184. package/src/timebox/sync_ble.py +35 -16
@@ -19,6 +19,7 @@ import { SERIAL_FORWARDED_EVENTS } from '@agentdeck/shared/protocol';
19
19
  import { prepareForSerial } from './esp32-serial.js';
20
20
  import { OpenClawAdapter } from './adapters/openclaw.js';
21
21
  import { BridgeLogStream } from './log-stream.js';
22
+ import { distBuildId } from './daemon-build-identity.js';
22
23
  import { PassiveSessionObserver } from './passive-observer.js';
23
24
  import { SessionTimelineRelay } from './session-timeline-relay.js';
24
25
  import { SessionFocusRelay } from './session-focus-relay.js';
@@ -72,7 +73,7 @@ const ASK_GATE_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGE
72
73
  const APME_ABANDONED_RUN_STALE_SEC = Math.max(600, Number(process.env.AGENTDECK_APME_ABANDON_SEC) || 7200);
73
74
  import { VoiceManager } from './voice.js';
74
75
  import { VoiceAssistantManager } from './voice-assistant.js';
75
- import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, waitForPortBindable, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
76
+ import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, waitForPortBindable, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getDataDir, getOwnTimelineFile, } from './session-registry.js';
76
77
  import { isForeignDaemon } from './daemon-takeover.js';
77
78
  import { loadDaemonSettings } from './daemon-settings.js';
78
79
  import { resolveDaemonPort, describeDaemonPortSource, PREFERRED_PORT_RECLAIM_MS, } from './daemon-port.js';
@@ -80,12 +81,14 @@ import { fetchUsageFromApi, resetConsecutiveFailures } from './usage-api.js';
80
81
  import { getOrCreateToken, isLocalConnection, validateToken } from './auth.js';
81
82
  import { buildPublicHealth, gateHttpRequest, isAuthorizedHttpRequest } from './http-auth-gate.js';
82
83
  import { closePairingWindow, getPairingWindowStatus, openPairingWindow, pairingWindowOpen, redeemPairingCode, } from './pairing-window.js';
84
+ import { approvePeer, dismissKnock, isApprovedPeer, pairingApprovals, pairingKnocks, recordKnock, revokePeer, } from './pairing-knocks.js';
83
85
  import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
84
86
  import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
85
87
  import { handlePixooWake } from './pixoo/pixoo-client.js';
86
88
  import { triggerMdnsRecovery } from './mdns.js';
87
89
  import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
88
90
  import { enableDebugLog, debug, debugThrottled } from './logger.js';
91
+ import { LegacyRearmLedger } from './legacy-rearm-ledger.js';
89
92
  import { CodexOtelTracker, CODEX_OTEL_TRACES_PATH, spanNameSummary } from './codex-otel.js';
90
93
  import { HookCodexSessions } from './hook-codex-sessions.js';
91
94
  import { ObservedTurnWatchdogs } from './observed-turn-watchdogs.js';
@@ -96,6 +99,7 @@ import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
96
99
  import { transcriptTimelineForSession, lastAssistantTextFromTranscript, lastAssistantTextForSession, } from './session-transcript-timeline.js';
97
100
  import { kiroTimelineForSession } from './kiro-transcript-timeline.js';
98
101
  import { KiroTimelineFeed } from './kiro-timeline-feed.js';
102
+ import { OpenClawTimelineFeed, OPENCLAW_FEED_INTERVAL_MS } from './openclaw-timeline-feed.js';
99
103
  import { DeviceVoiceReplyRouter, speakableReply, spokenDigest, pcmFromWav, } from './device-voice-reply.js';
100
104
  import { rawSessionId } from '@agentdeck/shared';
101
105
  import { codexTurnOutcomeFromRollout, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
@@ -110,17 +114,20 @@ import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js
110
114
  import { loadTimeboxDevices } from './timebox/timebox-settings.js';
111
115
  import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
112
116
  import { injectOpenClawSession, OPENCLAW_SESSION_ID } from './openclaw-session.js';
113
- import { buildCardFeed, buildGlance, applyOutboxDecisions, FeedPullTracker, formatFeedPull, normalizeClientIp, parsePullTelemetry, } from './card-feed.js';
117
+ import { buildCardFeed, buildGlance, projectPortableReaderGlance, applyOutboxDecisions, FeedPullTracker, formatFeedPull, applyPullOtaBootstrap, OutboxIdempotencyLedger, normalizeClientIp, parsePullTelemetry, } from './card-feed.js';
114
118
  import { threadModule } from './card-modules.js';
115
119
  import { AutonomousPocketEngine, createAutonomousPocketModules, parsePocketAutonomyConfig, } from './pocket-autonomy.js';
116
120
  import { WeatherProvider, parseWeatherSettings } from './weather.js';
117
121
  import { CalendarProvider, parseCalendarSettings } from './calendar.js';
122
+ import { loadLearningPack, matchesLearningPackRequest } from './learning-pack.js';
123
+ import { loadFontPack, matchesFontPackRequest } from './font-pack.js';
118
124
  import { renderGlanceFrame, GLANCE_FRAME_BOARDS } from './glance-frame.js';
119
- import { mergeRelayedSessionUsage } from './usage-event.js';
120
- import { CARD_FEED_PATH, CARD_OUTBOX_PATH, GLANCE_FRAME_PATH } from '@agentdeck/shared';
125
+ import { isPortableReaderProfile, negotiateSurface, parseHttpSurfaceIdentity, pullOtaResponseStatus, surfaceErrorBody, surfaceAllowsEvent, SurfaceProtocolError, validateSurfaceOtaIdentity, validateSurfaceQueryTuple, usesPortableReaderProjection, } from './surface-protocol.js';
126
+ import { resolveRelayedUsageEvent } from './relayed-usage.js';
127
+ import { CARD_FEED_PATH, CARD_OUTBOX_PATH, FONT_PACK_PATH, GLANCE_FRAME_PATH, LEARNING_PACK_PATH } from '@agentdeck/shared';
121
128
  import { readFileSync, statSync, writeFileSync, appendFileSync } from 'fs';
122
129
  import { readFile, rm } from 'fs/promises';
123
- import { tmpdir } from 'os';
130
+ import { tmpdir, networkInterfaces } from 'os';
124
131
  import { join } from 'path';
125
132
  import { homedir } from 'os';
126
133
  import { OPENCLAW_CAPABILITIES, State, } from './types.js';
@@ -142,6 +149,7 @@ const wifiEsp32Sockets = new Map();
142
149
  * single-path dedup kept it in the registry above. */
143
150
  const wsSelfBoard = new Map();
144
151
  const wsSelfCaps = new Map();
152
+ const surfaceNegotiations = new Map();
145
153
  const otaWaiters = new Map();
146
154
  function listWifiEsp32Devices() {
147
155
  const now = Date.now();
@@ -165,6 +173,7 @@ function listWifiEsp32Devices() {
165
173
  * other observable — a battery client with an empty outbox never identifies
166
174
  * itself and never opens a socket. */
167
175
  const feedPulls = new FeedPullTracker();
176
+ const outboxIdempotency = new OutboxIdempotencyLedger();
168
177
  /** Daemon-owned personal feed. It persists aggregate bandit state only — no
169
178
  * card copy, session names, calendar titles or weather locations. */
170
179
  const pocketAutonomy = new AutonomousPocketEngine({ onError: (message) => log(`[agentdeck] ${message}`) });
@@ -176,18 +185,123 @@ const pocketReaderModules = pocketCardModules.filter((module) => module.id !== '
176
185
  /** Glance weather for the card-feed sleep dashboard. Config is read from
177
186
  * settings.json per pull (cheap; honors edits without a restart); the
178
187
  * provider caches the upstream fetch itself. */
179
- const glanceWeather = new WeatherProvider();
188
+ const glanceWeather = new WeatherProvider(fetch, (message) => log(`[agentdeck] ${message}`), join(getDataDir(), 'weather-cache-v1.json'));
180
189
  /** Glance schedule (today's ICS events) — same config/caching contract as
181
190
  * weather: settings.json `calendar: {ics}`, 30 min cache, never throws. */
182
191
  const glanceCalendar = new CalendarProvider();
183
- const stagedFwByBoard = new Map();
184
- const stagedFwStatePath = () => join(homedir(), '.agentdeck', 'staged-fw.json');
192
+ // Legacy firmware does only six GETs per awake pass, so its previous 128 KiB
193
+ // response cap made at most 768 KiB progress before sleeping. 256 KiB was
194
+ // measured to complete in 1–2 seconds on X3 and halves those reconnects. New
195
+ // cooperative clients explicitly ask for a smaller `limit` when they need a
196
+ // tighter UI-yield budget.
197
+ const PULL_OTA_SEGMENT_BYTES = 256 * 1024;
198
+ const PULL_OTA_MIN_SEGMENT_BYTES = 32 * 1024;
199
+ const PULL_OTA_MAX_SEGMENT_BYTES = 512 * 1024;
200
+ function pullOtaSegment(firmware, requestedFrom, requestedLimit) {
201
+ const from = Number.isFinite(requestedFrom) && requestedFrom > 0
202
+ ? Math.min(Math.floor(requestedFrom), firmware.length) : 0;
203
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
204
+ ? Math.max(PULL_OTA_MIN_SEGMENT_BYTES, Math.min(Math.floor(requestedLimit), PULL_OTA_MAX_SEGMENT_BYTES))
205
+ : PULL_OTA_SEGMENT_BYTES;
206
+ return { from, body: firmware.subarray(from, Math.min(from + limit, firmware.length)) };
207
+ }
208
+ /** The route above keeps dual-homed hosts on the device-side Wi-Fi interface.
209
+ * Once there, hand the bounded remainder to Node directly: timer-paced 1 KiB
210
+ * writes stretched a 1.6 MB transfer past the device's 60-second read timeout.
211
+ * TCP backpressure still governs the socket; res.end does not retain another
212
+ * firmware copy because `body` is a view over the already-loaded image. */
213
+ function sendPullOtaBody(res, body) {
214
+ res.socket?.setNoDelay(true);
215
+ res.end(body);
216
+ }
217
+ function ipv4Number(address) {
218
+ const parts = address.split('.').map(Number);
219
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
220
+ return undefined;
221
+ return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
222
+ }
223
+ /** Pick the host's Wi-Fi address only when it shares the device subnet. This
224
+ * avoids the measured Ethernet→AP return-path loss on dual-homed Macs without
225
+ * changing global routes or affecting non-OTA traffic. */
226
+ function preferredPullOtaIp(remoteAddress, localAddress, interfaces = networkInterfaces()) {
227
+ const remote = ipv4Number(normalizeClientIp(remoteAddress));
228
+ const local = normalizeClientIp(localAddress);
229
+ if (remote === undefined)
230
+ return undefined;
231
+ const candidates = [];
232
+ for (const [name, entries] of Object.entries(interfaces)) {
233
+ for (const entry of entries ?? []) {
234
+ if (entry.internal || entry.family !== 'IPv4')
235
+ continue;
236
+ const address = ipv4Number(entry.address);
237
+ const mask = ipv4Number(entry.netmask);
238
+ if (address === undefined || mask === undefined || (address & mask) !== (remote & mask))
239
+ continue;
240
+ candidates.push({ name, address: entry.address });
241
+ }
242
+ }
243
+ if (candidates.length < 2)
244
+ return undefined;
245
+ candidates.sort((a, b) => {
246
+ const wireless = (name) => /^(en1|wlan)|wi-?fi/i.test(name) ? 0 : 1;
247
+ return wireless(a.name) - wireless(b.name) || a.name.localeCompare(b.name);
248
+ });
249
+ const preferred = candidates[0]?.address;
250
+ return preferred && preferred !== local ? preferred : undefined;
251
+ }
252
+ const legacyStagedFwByBoard = new Map();
253
+ const stagedFwByIdentity = new Map();
254
+ const stagedFwStatePath = () => join(getDataDir(), 'staged-fw.json');
255
+ function otaIdentityKey(identity) {
256
+ return JSON.stringify([identity.productId, identity.board, identity.updateChannel]);
257
+ }
258
+ /** Read the NUL-terminated build identity already present in Pocket images.
259
+ * This avoids inventing another manifest field and also repairs older persisted
260
+ * stages that predate firmwareVersion. */
261
+ function embeddedFirmwareVersion(firmware) {
262
+ const marker = Buffer.from('CrossPoint version: ', 'ascii');
263
+ const start = firmware.indexOf(marker);
264
+ if (start < 0)
265
+ return undefined;
266
+ const valueStart = start + marker.length;
267
+ let end = valueStart;
268
+ while (end < firmware.length && end - valueStart < 96) {
269
+ const byte = firmware[end];
270
+ if (byte === 0 || byte === 0x0a || byte === 0x0d)
271
+ break;
272
+ if (byte < 0x20 || byte > 0x7e)
273
+ return undefined;
274
+ end++;
275
+ }
276
+ if (end === valueStart || end - valueStart >= 96)
277
+ return undefined;
278
+ return firmware.subarray(valueStart, end).toString('ascii');
279
+ }
185
280
  function loadStagedFw() {
186
281
  try {
187
282
  const raw = JSON.parse(readFileSync(stagedFwStatePath(), 'utf8'));
283
+ if ('version' in raw && raw.version === 2 && Array.isArray(raw.identities)) {
284
+ for (const candidate of raw.identities) {
285
+ try {
286
+ const identity = validateSurfaceOtaIdentity(candidate);
287
+ if (typeof candidate.firmwarePath === 'string' && typeof candidate.md5 === 'string' && typeof candidate.size === 'number') {
288
+ stagedFwByIdentity.set(otaIdentityKey(identity), { ...candidate, ...identity });
289
+ }
290
+ }
291
+ catch { /* invalid/stale registration: do not widen its namespace */ }
292
+ }
293
+ for (const [board, s] of Object.entries(raw.legacy ?? {})) {
294
+ if (s && typeof s.firmwarePath === 'string' && typeof s.md5 === 'string' && typeof s.size === 'number') {
295
+ legacyStagedFwByBoard.set(board, s);
296
+ }
297
+ }
298
+ return;
299
+ }
300
+ // Version 1 was a board-keyed object. Preserve it only in the legacy
301
+ // namespace; product-aware requests must never inherit those images.
188
302
  for (const [board, s] of Object.entries(raw)) {
189
303
  if (s && typeof s.firmwarePath === 'string' && typeof s.md5 === 'string' && typeof s.size === 'number') {
190
- stagedFwByBoard.set(board, s);
304
+ legacyStagedFwByBoard.set(board, s);
191
305
  }
192
306
  }
193
307
  }
@@ -196,7 +310,12 @@ function loadStagedFw() {
196
310
  loadStagedFw();
197
311
  function saveStagedFw() {
198
312
  try {
199
- writeFileSync(stagedFwStatePath(), JSON.stringify(Object.fromEntries(stagedFwByBoard), null, 2));
313
+ const state = {
314
+ version: 2,
315
+ identities: [...stagedFwByIdentity.values()],
316
+ legacy: Object.fromEntries(legacyStagedFwByBoard),
317
+ };
318
+ writeFileSync(stagedFwStatePath(), JSON.stringify(state, null, 2));
200
319
  }
201
320
  catch (err) {
202
321
  log(`[agentdeck] staged-fw persist failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -227,9 +346,9 @@ function resolveStagedFwBoard(target) {
227
346
  const puller = feedPulls.clients().find((c) => c.client === target || c.board === target);
228
347
  return puller?.board ?? target;
229
348
  }
230
- /** Stage a build for a board. Reads the file once to fingerprint it; the file
231
- * itself is re-read at device download time (a rebuild at the same path is
232
- * re-fingerprinted by re-staging).
349
+ /** Stage a build for a board. Reads the file once to fingerprint it. The Feed
350
+ * advert path re-fingerprints a rebuild at the same path before advertising,
351
+ * and the download path re-reads the bytes that are actually served.
233
352
  *
234
353
  * `pullSeen` reports whether this daemon has actually observed a feed pull
235
354
  * from the board, because "staged" proves neither transfer nor install and a
@@ -237,33 +356,68 @@ function resolveStagedFwBoard(target) {
237
356
  * image; a board that holds a live WS never asks, so its stage sits forever
238
357
  * while the CLI says it is handled. It is daemon-lifetime state, so `false`
239
358
  * means "not seen since this daemon started" — not "cannot pull". */
240
- function stageEsp32Fw(target, firmwarePath) {
359
+ function stageEsp32Fw(target, firmwarePath, offeredIdentity) {
241
360
  const board = resolveStagedFwBoard(target);
361
+ const identity = offeredIdentity ? validateSurfaceOtaIdentity(offeredIdentity) : undefined;
362
+ if (identity && identity.board !== board) {
363
+ throw new SurfaceProtocolError(409, 'surface_stage_target_mismatch', 'Stage target does not match OTA identity board');
364
+ }
242
365
  const firmware = readFileSync(firmwarePath);
243
366
  const md5 = createHash('md5').update(firmware).digest('hex');
244
- stagedFwByBoard.set(board, { firmwarePath, md5, size: firmware.length, stagedAt: Date.now() });
367
+ const staged = {
368
+ firmwarePath,
369
+ md5,
370
+ size: firmware.length,
371
+ stagedAt: Date.now(),
372
+ firmwareVersion: embeddedFirmwareVersion(firmware),
373
+ };
374
+ if (identity)
375
+ stagedFwByIdentity.set(otaIdentityKey(identity), { ...staged, ...identity });
376
+ else
377
+ legacyStagedFwByBoard.set(board, staged);
245
378
  saveStagedFw();
246
- const pullSeen = feedPulls.clients().some((c) => c.board === board);
247
- log(`[agentdeck] staged firmware for ${board}: ${firmware.length} bytes md5=${md5} — ${pullSeen ? 'installs on its next feed pull' : 'NO feed pull seen from this board since daemon start'}`);
248
- return { board, bytes: firmware.length, md5, pullSeen };
379
+ const pullSeen = feedPulls.clients().some((c) => c.board === board
380
+ && (!identity || c.productId === identity.productId));
381
+ const namespace = identity ? `${identity.productId}/${board}/${identity.updateChannel}` : `legacy/${board}`;
382
+ log(`[agentdeck] staged firmware for ${namespace}: ${firmware.length} bytes md5=${md5} — ${pullSeen ? 'installs on its next feed pull' : 'NO feed pull seen from this board since daemon start'}`);
383
+ return { board, bytes: firmware.length, md5, pullSeen,
384
+ ...(identity ? { productId: identity.productId, updateChannel: identity.updateChannel } : {}) };
249
385
  }
250
386
  /** The staged advert for a feed response, re-validated against the file on
251
387
  * disk so a rebuilt-in-place binary never adverts a stale md5. */
252
- function stagedFwAdvert(board) {
388
+ function stagedFwAdvert(board, identity, clientVersion) {
253
389
  if (!board)
254
390
  return undefined;
255
- const staged = stagedFwByBoard.get(board);
391
+ const validatedIdentity = identity ? validateSurfaceOtaIdentity(identity) : undefined;
392
+ const staged = validatedIdentity
393
+ ? stagedFwByIdentity.get(otaIdentityKey(validatedIdentity))
394
+ : legacyStagedFwByBoard.get(board);
256
395
  if (!staged)
257
396
  return undefined;
258
397
  try {
259
398
  const firmware = readFileSync(staged.firmwarePath);
260
399
  const md5 = createHash('md5').update(firmware).digest('hex');
261
- if (md5 !== staged.md5 || firmware.length !== staged.size) {
262
- stagedFwByBoard.set(board, { ...staged, md5, size: firmware.length });
400
+ const firmwareVersion = embeddedFirmwareVersion(firmware);
401
+ if (md5 !== staged.md5 || firmware.length !== staged.size || firmwareVersion !== staged.firmwareVersion) {
402
+ if (validatedIdentity)
403
+ stagedFwByIdentity.set(otaIdentityKey(validatedIdentity), {
404
+ ...staged, ...validatedIdentity, md5, size: firmware.length, firmwareVersion,
405
+ });
406
+ else
407
+ legacyStagedFwByBoard.set(board, { ...staged, md5, size: firmware.length, firmwareVersion });
263
408
  saveStagedFw();
264
- return { size: firmware.length, md5 };
409
+ return { size: firmware.length, md5, ...(validatedIdentity ?? {}) };
265
410
  }
266
- return { size: staged.size, md5: staged.md5 };
411
+ if (clientVersion && firmwareVersion && clientVersion === firmwareVersion) {
412
+ if (validatedIdentity)
413
+ stagedFwByIdentity.delete(otaIdentityKey(validatedIdentity));
414
+ else
415
+ legacyStagedFwByBoard.delete(board);
416
+ saveStagedFw();
417
+ log(`[agentdeck] staged firmware acknowledged by ${validatedIdentity?.productId ?? 'legacy'}/${board}: v${clientVersion}`);
418
+ return undefined;
419
+ }
420
+ return { size: staged.size, md5: staged.md5, ...(validatedIdentity ?? {}) };
267
421
  }
268
422
  catch {
269
423
  return undefined; // file gone — advert nothing rather than a dead download
@@ -335,6 +489,11 @@ function registerWifiEsp32(d, ws) {
335
489
  const resetReasonCode = typeof d.resetReasonCode === 'number' ? d.resetReasonCode : undefined;
336
490
  wifiEsp32Devices.set(key, {
337
491
  board: typeof d.board === 'string' ? d.board : 'unknown',
492
+ productId: typeof d.productId === 'string' ? d.productId : undefined,
493
+ productName: typeof d.productName === 'string' ? d.productName : undefined,
494
+ surfaceProfile: typeof d.surfaceProfile === 'string' ? d.surfaceProfile : undefined,
495
+ surfaceProtocol: typeof d.surfaceProtocol === 'number' ? d.surfaceProtocol : undefined,
496
+ updateChannel: typeof d.updateChannel === 'string' ? d.updateChannel : undefined,
338
497
  version: typeof d.version === 'string' ? d.version : undefined,
339
498
  buildHash: typeof d.buildHash === 'string' ? d.buildHash : undefined,
340
499
  buildEpoch: typeof d.buildEpoch === 'number' ? d.buildEpoch : undefined,
@@ -361,6 +520,8 @@ function registerWifiEsp32(d, ws) {
361
520
  touchDownSamples: typeof d.touchDownSamples === 'number' ? d.touchDownSamples : undefined,
362
521
  touchGestures: typeof d.touchGestures === 'number' ? d.touchGestures : undefined,
363
522
  alsReady: typeof d.alsReady === 'boolean' ? d.alsReady : undefined,
523
+ repaintCount: typeof d.repaintCount === 'number' ? d.repaintCount : undefined,
524
+ fullRefreshCount: typeof d.fullRefreshCount === 'number' ? d.fullRefreshCount : undefined,
364
525
  lastSeenMs: Date.now(),
365
526
  });
366
527
  wifiEsp32Sockets.set(key, ws);
@@ -528,8 +689,12 @@ export const __wifiOtaTestApi = {
528
689
  resolveStagedFwBoard,
529
690
  stageEsp32Fw,
530
691
  stagedFwAdvert,
692
+ embeddedFirmwareVersion,
693
+ pullOtaSegment,
694
+ preferredPullOtaIp,
531
695
  feedPulls,
532
- clearStagedFwForTest: () => { stagedFwByBoard.clear(); },
696
+ getStagedFw: (identity) => stagedFwByIdentity.get(otaIdentityKey(identity)),
697
+ clearStagedFwForTest: () => { legacyStagedFwByBoard.clear(); stagedFwByIdentity.clear(); },
533
698
  };
534
699
  async function performWifiEsp32Ota(core, target, firmwarePath) {
535
700
  const { key, device } = findWifiOtaTarget(target);
@@ -887,6 +1052,7 @@ function buildNodeModuleHealth(startedModules) {
887
1052
  port: status.port,
888
1053
  connected: status.connected,
889
1054
  transportOpen: status.transportOpen,
1055
+ deviceInfoFresh: status.deviceInfoFresh,
890
1056
  deviceInfo: status.board ? {
891
1057
  board: status.board,
892
1058
  version: status.version,
@@ -905,6 +1071,9 @@ function buildNodeModuleHealth(startedModules) {
905
1071
  sessionCount: status.sessionCount,
906
1072
  usageFiveH: status.usageFiveH,
907
1073
  processingCount: status.processingCount,
1074
+ repaintCount: status.repaintCount,
1075
+ fullRefreshCount: status.fullRefreshCount,
1076
+ deviceInfoFresh: status.deviceInfoFresh,
908
1077
  } : null,
909
1078
  lastReadAt: status.lastReadAt,
910
1079
  lastWriteAt: status.lastWriteAt,
@@ -922,7 +1091,15 @@ function buildNodeModuleHealth(startedModules) {
922
1091
  return modules;
923
1092
  }
924
1093
  // ===== startDaemon =====
1094
+ /**
1095
+ * The build this daemon process started with — the answer `/health` gives for
1096
+ * as long as it runs. Captured once in `startDaemon`, never refreshed: a
1097
+ * daemon that recomputed it would start reporting the newest build on disk the
1098
+ * moment someone rebuilt, which is precisely the state a caller asks about.
1099
+ */
1100
+ let startupBuildId = null;
925
1101
  export async function startDaemon(opts) {
1102
+ startupBuildId = distBuildId();
926
1103
  if (opts.debug) {
927
1104
  enableDebugLog();
928
1105
  log('[agentdeck] Debug logging enabled');
@@ -1135,6 +1312,7 @@ export async function startDaemon(opts) {
1135
1312
  /** Produces live timeline rows for observed Kiro sessions — see the class
1136
1313
  * doc for why Kiro needs a producer when hook agents do not. */
1137
1314
  const kiroTimelineFeed = new KiroTimelineFeed();
1315
+ const openclawTimelineFeed = new OpenClawTimelineFeed();
1138
1316
  /** Kiro has no CLI hooks, so transcript rows also feed APME. These maps are
1139
1317
  * bounded by the observer's current live set and discarded on disappearance. */
1140
1318
  const kiroApmeSessions = new Set();
@@ -1258,6 +1436,29 @@ export async function startDaemon(opts) {
1258
1436
  // Assigned after BridgeCore exists. The HTTP handler reads it only after
1259
1437
  // startup; nullable keeps the tiny listen→core initialization window safe.
1260
1438
  let subagentTimeline = null;
1439
+ // The learning pack is immutable for one daemon lifetime. Package upgrades
1440
+ // arrive with a new AgentDeck build; validating once keeps every sleeping
1441
+ // reader pull cheap while a corrupt/mislicensed bundle fails closed.
1442
+ let surfaceLearningPack = null;
1443
+ try {
1444
+ surfaceLearningPack = loadLearningPack();
1445
+ log(`[agentdeck] Surface learning pack ready: ${surfaceLearningPack.advert.id}`
1446
+ + ` v${surfaceLearningPack.advert.version} (${surfaceLearningPack.advert.size} bytes,`
1447
+ + ` ${surfaceLearningPack.advert.licenseSpdx})`);
1448
+ }
1449
+ catch (err) {
1450
+ log(`[agentdeck] Surface learning pack disabled: ${err instanceof Error ? err.message : String(err)}`);
1451
+ }
1452
+ let surfaceFontPack = null;
1453
+ try {
1454
+ surfaceFontPack = loadFontPack();
1455
+ log(`[agentdeck] Surface font pack ready: ${surfaceFontPack.advert.id}`
1456
+ + ` v${surfaceFontPack.advert.version} (${surfaceFontPack.advert.size} bytes,`
1457
+ + ` ${surfaceFontPack.advert.licenseSpdx})`);
1458
+ }
1459
+ catch (err) {
1460
+ log(`[agentdeck] Surface font pack disabled: ${err instanceof Error ? err.message : String(err)}`);
1461
+ }
1261
1462
  // ===== HTTP server =====
1262
1463
  const httpServer = createServer((req, res) => {
1263
1464
  // LAN default-deny (issue #145): a request that is neither same-machine
@@ -1358,6 +1559,12 @@ export async function startDaemon(opts) {
1358
1559
  status: 'ok', mode: 'daemon', state: snap.state,
1359
1560
  gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
1360
1561
  uptime: process.uptime(), port, pid: process.pid,
1562
+ // Which build is SERVING this port. Captured when this process started
1563
+ // (see daemon-build-identity.ts) and never recomputed, so a rebuild
1564
+ // underneath a running daemon reads as a mismatch instead of being
1565
+ // absorbed — `dist/cli.js` is overwritten in place, and nothing else
1566
+ // here distinguishes the code on disk from the code in memory.
1567
+ build: startupBuildId,
1361
1568
  pairingToken: core.authToken,
1362
1569
  // Capability: this daemon drives remote sessions down their own push
1363
1570
  // socket (session_focus_down / session_command_down / session_event_up).
@@ -1449,6 +1656,82 @@ export async function startDaemon(opts) {
1449
1656
  res.end(JSON.stringify({ open: false }));
1450
1657
  return;
1451
1658
  }
1659
+ // The operator-approval half of the credential path. A code asks the DEVICE
1660
+ // to prove something; these ask the OPERATOR, and only the second one a
1661
+ // camera-less reader can complete. Like the window routes above they sit
1662
+ // BEHIND the normal auth gate — a remote peer able to approve itself would
1663
+ // be granting itself a credential.
1664
+ //
1665
+ // GET /pair/knocks → { knocks, approvals }
1666
+ // POST /pair/approve { key } → { approved: true }
1667
+ // POST /pair/revoke { key } → { revoked: bool }
1668
+ // POST /pair/dismiss { key } → { dismissed: bool }
1669
+ // The wire shape is the Swift daemon's, field for field: the Mac app in
1670
+ // CLIENT mode calls these routes against whichever daemon holds 9120, so a
1671
+ // shape of our own would break its approval UI on exactly the machines that
1672
+ // run both. `deviceId` is "" rather than null when a row is address-scoped
1673
+ // (the client maps "" back to nil), and every timestamp is epoch ms.
1674
+ if (req.method === 'GET' && pathname === '/pair/knocks') {
1675
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1676
+ res.end(JSON.stringify({
1677
+ knocks: pairingKnocks().map((k) => ({
1678
+ key: k.key,
1679
+ ip: k.ip,
1680
+ deviceId: k.deviceId ?? '',
1681
+ attempts: k.attempts,
1682
+ firstSeen: k.firstSeen,
1683
+ lastSeen: k.lastSeen,
1684
+ staleToken: k.staleToken,
1685
+ })),
1686
+ approved: pairingApprovals().map((a) => ({
1687
+ key: a.key,
1688
+ deviceId: a.deviceId ?? '',
1689
+ lastIP: a.lastIP,
1690
+ approvedAt: a.approvedAt,
1691
+ })),
1692
+ }));
1693
+ return;
1694
+ }
1695
+ if (req.method === 'POST' && (pathname === '/pair/approve'
1696
+ || pathname === '/pair/revoke' || pathname === '/pair/dismiss')) {
1697
+ void (async () => {
1698
+ let body = {};
1699
+ try {
1700
+ body = await readJsonBody(req, 4096);
1701
+ }
1702
+ catch { /* defaults */ }
1703
+ const key = typeof body.key === 'string' ? body.key : '';
1704
+ if (!key) {
1705
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1706
+ res.end(JSON.stringify({ error: 'key required' }));
1707
+ return;
1708
+ }
1709
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1710
+ if (pathname === '/pair/approve') {
1711
+ approvePeer(key);
1712
+ log(`[agentdeck] Operator approved peer ${key}`);
1713
+ res.end(JSON.stringify({ approved: key }));
1714
+ }
1715
+ else if (pathname === '/pair/revoke') {
1716
+ revokePeer(key);
1717
+ log(`[agentdeck] Operator revoked peer ${key}`);
1718
+ res.end(JSON.stringify({ revoked: key }));
1719
+ }
1720
+ else {
1721
+ // Dismiss is silent, as in Swift — it is not an operator decision
1722
+ // about trust, just a row cleared off a list.
1723
+ dismissKnock(key);
1724
+ res.end(JSON.stringify({ dismissed: key }));
1725
+ }
1726
+ })().catch(() => {
1727
+ try {
1728
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1729
+ res.end(JSON.stringify({ error: 'could not update approvals' }));
1730
+ }
1731
+ catch { /* client gone */ }
1732
+ });
1733
+ return;
1734
+ }
1452
1735
  if (req.method === 'GET' && pathname === '/status') {
1453
1736
  const snap = core.stateMachine.getSnapshot();
1454
1737
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1717,17 +2000,32 @@ export async function startDaemon(opts) {
1717
2000
  throw new Error('target and firmwarePath are required');
1718
2001
  }
1719
2002
  // Pull-OTA staging: remember the build; the board installs it on its
1720
- // next feed pull (no live WS needed). See stagedFwByBoard.
2003
+ // next feed pull (no live WS needed). Product-aware stages live in an
2004
+ // exact productId + board + updateChannel namespace.
1721
2005
  if (body.stage === true) {
1722
- return { ok: true, staged: true, ...stageEsp32Fw(target, firmwarePath) };
2006
+ const tupleValues = [body.productId, body.board, body.updateChannel];
2007
+ const tuplePresent = tupleValues.filter((value) => typeof value === 'string' && value.length > 0).length;
2008
+ if (tuplePresent !== 0 && tuplePresent !== 3) {
2009
+ throw new SurfaceProtocolError(400, 'surface_stage_identity_incomplete', 'productId, board, and updateChannel are required together');
2010
+ }
2011
+ if (tuplePresent === 0 && (target === 'xteink_x3' || target === 'xteink_x4')) {
2012
+ throw new SurfaceProtocolError(400, 'surface_stage_identity_required', 'XTeink pull OTA requires productId, board, and updateChannel');
2013
+ }
2014
+ const identity = tuplePresent === 3 ? {
2015
+ productId: String(body.productId), board: String(body.board), updateChannel: String(body.updateChannel),
2016
+ } : undefined;
2017
+ return { ok: true, staged: true, ...stageEsp32Fw(target, firmwarePath, identity) };
1723
2018
  }
1724
2019
  return performWifiEsp32Ota(core, target, firmwarePath);
1725
2020
  })().then((result) => {
1726
2021
  res.writeHead(200, { 'Content-Type': 'application/json' });
1727
2022
  res.end(JSON.stringify(result));
1728
2023
  }).catch((err) => {
1729
- res.writeHead(500, { 'Content-Type': 'application/json' });
1730
- res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
2024
+ const status = err instanceof SurfaceProtocolError ? err.status : 500;
2025
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2026
+ res.end(JSON.stringify(err instanceof SurfaceProtocolError
2027
+ ? { ok: false, ...surfaceErrorBody(err) }
2028
+ : { ok: false, error: err instanceof Error ? err.message : String(err) }));
1731
2029
  });
1732
2030
  return;
1733
2031
  }
@@ -1744,11 +2042,52 @@ export async function startDaemon(opts) {
1744
2042
  return;
1745
2043
  }
1746
2044
  }
1747
- const board = parsedUrl.searchParams.get('board') ?? '';
1748
- const staged = stagedFwByBoard.get(board);
2045
+ let surfaceIdentity;
2046
+ try {
2047
+ surfaceIdentity = parseHttpSurfaceIdentity(req.headers, 'ota.feed');
2048
+ if (surfaceIdentity)
2049
+ validateSurfaceQueryTuple(surfaceIdentity, parsedUrl.searchParams);
2050
+ }
2051
+ catch (err) {
2052
+ if (err instanceof SurfaceProtocolError) {
2053
+ res.writeHead(err.status, { 'Content-Type': 'application/json' });
2054
+ res.end(JSON.stringify(surfaceErrorBody(err)));
2055
+ return;
2056
+ }
2057
+ throw err;
2058
+ }
2059
+ const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
2060
+ if (preferredIp) {
2061
+ const port = req.socket.localPort ?? 9120;
2062
+ const redirectBoard = surfaceIdentity?.board ?? parsedUrl.searchParams.get('board') ?? 'unknown';
2063
+ log(`[agentdeck] pull-OTA path redirect: ${redirectBoard} (${ip}) via ${preferredIp}`);
2064
+ res.writeHead(307, {
2065
+ Location: `http://${preferredIp}:${port}${parsedUrl.pathname}${parsedUrl.search}`,
2066
+ 'Cache-Control': 'no-store',
2067
+ });
2068
+ res.end();
2069
+ return;
2070
+ }
2071
+ const board = surfaceIdentity?.board ?? parsedUrl.searchParams.get('board') ?? '';
2072
+ const hasProductQuery = parsedUrl.searchParams.has('productId') || parsedUrl.searchParams.has('updateChannel');
2073
+ if (!surfaceIdentity && hasProductQuery) {
2074
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2075
+ res.end(JSON.stringify({ error: 'surface_identity_required', message: 'Product-aware OTA requires all Surface identity headers' }));
2076
+ return;
2077
+ }
2078
+ const otaIdentity = surfaceIdentity ? {
2079
+ productId: surfaceIdentity.productId,
2080
+ board: surfaceIdentity.board,
2081
+ updateChannel: surfaceIdentity.updateChannel,
2082
+ } : undefined;
2083
+ const staged = otaIdentity
2084
+ ? stagedFwByIdentity.get(otaIdentityKey(otaIdentity))
2085
+ : legacyStagedFwByBoard.get(board);
1749
2086
  if (!staged) {
1750
2087
  res.writeHead(404, { 'Content-Type': 'application/json' });
1751
- res.end(JSON.stringify({ error: `nothing staged for board "${board}"` }));
2088
+ res.end(JSON.stringify({ error: 'firmware_not_staged', message: otaIdentity
2089
+ ? `Nothing staged for ${otaIdentity.productId}/${board}/${otaIdentity.updateChannel}`
2090
+ : `Nothing staged for legacy board "${board}"` }));
1752
2091
  return;
1753
2092
  }
1754
2093
  try {
@@ -1760,20 +2099,30 @@ export async function startDaemon(opts) {
1760
2099
  // never converge there). The device appends whatever arrives and
1761
2100
  // re-asks from where it stopped, so partial transfers accumulate.
1762
2101
  const fromRaw = Number(parsedUrl.searchParams.get('from') ?? 0);
1763
- const from = Number.isFinite(fromRaw) && fromRaw > 0 ? Math.min(Math.floor(fromRaw), firmware.length) : 0;
1764
- const body = from > 0 ? firmware.subarray(from) : firmware;
2102
+ const limitRaw = parsedUrl.searchParams.has('limit')
2103
+ ? Number(parsedUrl.searchParams.get('limit')) : undefined;
2104
+ const { from, body } = pullOtaSegment(firmware, fromRaw, limitRaw);
1765
2105
  log(`[agentdeck] pull-OTA download: ${board} (${ip}) ← ${body.length} bytes`
1766
2106
  + (from > 0 ? ` (resume from ${from}/${firmware.length})` : ''));
1767
- res.writeHead(from > 0 ? 206 : 200, {
2107
+ // Early Pocket Daily builds persisted partial bodies but treated 206
2108
+ // as an error. Keep their private `?from=` resume convergent with a
2109
+ // 200 response; clients that explicitly negotiate ota.resume-206 get
2110
+ // the standards-shaped partial response. This is capability-gated,
2111
+ // not version-sniffed, so independent reader implementations have an
2112
+ // unambiguous migration path.
2113
+ const partialStatus = pullOtaResponseStatus(from, surfaceIdentity);
2114
+ res.writeHead(partialStatus, {
1768
2115
  'Content-Type': 'application/octet-stream',
1769
2116
  'Content-Length': String(body.length),
1770
2117
  ...(from > 0
1771
- ? { 'Content-Range': `bytes ${from}-${Math.max(from, firmware.length - 1)}/${firmware.length}` }
2118
+ ? { 'Content-Range': body.length > 0
2119
+ ? `bytes ${from}-${from + body.length - 1}/${firmware.length}`
2120
+ : `bytes */${firmware.length}` }
1772
2121
  : {}),
1773
2122
  'X-FW-MD5': staged.md5,
1774
2123
  'Cache-Control': 'no-store',
1775
2124
  });
1776
- res.end(body);
2125
+ sendPullOtaBody(res, body);
1777
2126
  }
1778
2127
  catch (err) {
1779
2128
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1781,6 +2130,138 @@ export async function startDaemon(opts) {
1781
2130
  }
1782
2131
  return;
1783
2132
  }
2133
+ // ===== Licensed learning-pack delivery — portable-reader/v1 =====
2134
+ if (req.method === 'GET' && pathname === LEARNING_PACK_PATH) {
2135
+ const ip = req.socket.remoteAddress ?? '';
2136
+ let surfaceIdentity;
2137
+ try {
2138
+ surfaceIdentity = parseHttpSurfaceIdentity(req.headers, 'learning.pack.read');
2139
+ if (!surfaceIdentity) {
2140
+ throw new SurfaceProtocolError(400, 'surface_identity_required', 'Learning-pack delivery requires all Surface identity headers');
2141
+ }
2142
+ validateSurfaceQueryTuple(surfaceIdentity, parsedUrl.searchParams);
2143
+ if (surfaceIdentity.productId !== 'io.pocketdaily.reader') {
2144
+ throw new SurfaceProtocolError(409, 'surface_learning_product_mismatch', 'This learning pack is registered for Pocket Daily Reader');
2145
+ }
2146
+ }
2147
+ catch (err) {
2148
+ if (err instanceof SurfaceProtocolError) {
2149
+ res.writeHead(err.status, { 'Content-Type': 'application/json' });
2150
+ res.end(JSON.stringify(surfaceErrorBody(err)));
2151
+ return;
2152
+ }
2153
+ throw err;
2154
+ }
2155
+ if (!surfaceLearningPack) {
2156
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
2157
+ res.end(JSON.stringify({ error: 'learning_pack_unavailable' }));
2158
+ return;
2159
+ }
2160
+ if (!matchesLearningPackRequest(surfaceLearningPack, parsedUrl.searchParams.get('id'), parsedUrl.searchParams.get('version'))) {
2161
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
2162
+ res.end(JSON.stringify({ error: 'learning_pack_not_found' }));
2163
+ return;
2164
+ }
2165
+ const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
2166
+ if (preferredIp) {
2167
+ const routePort = req.socket.localPort ?? 9120;
2168
+ log(`[agentdeck] Surface path redirect: learning-pack ${surfaceIdentity.board} (${ip}) via ${preferredIp}`);
2169
+ res.writeHead(307, {
2170
+ Location: `http://${preferredIp}:${routePort}${parsedUrl.pathname}${parsedUrl.search}`,
2171
+ 'Cache-Control': 'no-store',
2172
+ 'Connection': 'close',
2173
+ });
2174
+ res.end();
2175
+ return;
2176
+ }
2177
+ log(`[agentdeck] learning-pack download: ${surfaceIdentity.board} (${ip})`
2178
+ + ` ← ${surfaceLearningPack.advert.id} v${surfaceLearningPack.advert.version}`);
2179
+ const requestedFrom = Number(parsedUrl.searchParams.get('from') ?? '0');
2180
+ const requestedLimitRaw = parsedUrl.searchParams.get('limit');
2181
+ const requestedLimit = requestedLimitRaw == null ? undefined : Number(requestedLimitRaw);
2182
+ const { from, body } = pullOtaSegment(surfaceLearningPack.bytes, requestedFrom, requestedLimit);
2183
+ res.writeHead(200, {
2184
+ 'Content-Type': 'application/vnd.pocketdaily.learning-pack',
2185
+ 'Content-Length': String(body.length),
2186
+ ...(from > 0
2187
+ ? { 'Content-Range': body.length > 0
2188
+ ? `bytes ${from}-${from + body.length - 1}/${surfaceLearningPack.bytes.length}`
2189
+ : `bytes */${surfaceLearningPack.bytes.length}` }
2190
+ : {}),
2191
+ 'X-Learning-Pack-MD5': surfaceLearningPack.advert.md5,
2192
+ 'X-Learning-Pack-License': surfaceLearningPack.advert.licenseSpdx,
2193
+ 'Cache-Control': 'no-store',
2194
+ 'Connection': 'close',
2195
+ });
2196
+ res.end(body);
2197
+ return;
2198
+ }
2199
+ // ===== Licensed automatic font-pack delivery — portable-reader/v1 =====
2200
+ if (req.method === 'GET' && pathname === FONT_PACK_PATH) {
2201
+ const ip = req.socket.remoteAddress ?? '';
2202
+ let surfaceIdentity;
2203
+ try {
2204
+ surfaceIdentity = parseHttpSurfaceIdentity(req.headers, 'font.pack.read');
2205
+ if (!surfaceIdentity) {
2206
+ throw new SurfaceProtocolError(400, 'surface_identity_required', 'Font-pack delivery requires all Surface identity headers');
2207
+ }
2208
+ validateSurfaceQueryTuple(surfaceIdentity, parsedUrl.searchParams);
2209
+ if (surfaceIdentity.productId !== 'io.pocketdaily.reader') {
2210
+ throw new SurfaceProtocolError(409, 'surface_font_product_mismatch', 'This font pack is registered for Pocket Daily Reader');
2211
+ }
2212
+ }
2213
+ catch (err) {
2214
+ if (err instanceof SurfaceProtocolError) {
2215
+ res.writeHead(err.status, { 'Content-Type': 'application/json' });
2216
+ res.end(JSON.stringify(surfaceErrorBody(err)));
2217
+ return;
2218
+ }
2219
+ throw err;
2220
+ }
2221
+ if (!surfaceFontPack) {
2222
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
2223
+ res.end(JSON.stringify({ error: 'font_pack_unavailable' }));
2224
+ return;
2225
+ }
2226
+ if (!matchesFontPackRequest(surfaceFontPack, parsedUrl.searchParams.get('id'), parsedUrl.searchParams.get('version'))) {
2227
+ res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
2228
+ res.end(JSON.stringify({ error: 'font_pack_not_found' }));
2229
+ return;
2230
+ }
2231
+ const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
2232
+ if (preferredIp) {
2233
+ const routePort = req.socket.localPort ?? 9120;
2234
+ log(`[agentdeck] Surface path redirect: font-pack ${surfaceIdentity.board} (${ip}) via ${preferredIp}`);
2235
+ res.writeHead(307, {
2236
+ Location: `http://${preferredIp}:${routePort}${parsedUrl.pathname}${parsedUrl.search}`,
2237
+ 'Cache-Control': 'no-store',
2238
+ 'Connection': 'close',
2239
+ });
2240
+ res.end();
2241
+ return;
2242
+ }
2243
+ log(`[agentdeck] font-pack download: ${surfaceIdentity.board} (${ip})`
2244
+ + ` ← ${surfaceFontPack.advert.id} v${surfaceFontPack.advert.version}`);
2245
+ const requestedFrom = Number(parsedUrl.searchParams.get('from') ?? '0');
2246
+ const requestedLimitRaw = parsedUrl.searchParams.get('limit');
2247
+ const requestedLimit = requestedLimitRaw == null ? undefined : Number(requestedLimitRaw);
2248
+ const { from, body } = pullOtaSegment(surfaceFontPack.bytes, requestedFrom, requestedLimit);
2249
+ res.writeHead(200, {
2250
+ 'Content-Type': 'application/vnd.pocketdaily.cpfont',
2251
+ 'Content-Length': String(body.length),
2252
+ ...(from > 0
2253
+ ? { 'Content-Range': body.length > 0
2254
+ ? `bytes ${from}-${from + body.length - 1}/${surfaceFontPack.bytes.length}`
2255
+ : `bytes */${surfaceFontPack.bytes.length}` }
2256
+ : {}),
2257
+ 'X-Font-Pack-MD5': surfaceFontPack.advert.md5,
2258
+ 'X-Font-Pack-License': surfaceFontPack.advert.licenseSpdx,
2259
+ 'Cache-Control': 'no-store',
2260
+ 'Connection': 'close',
2261
+ });
2262
+ res.end(body);
2263
+ return;
2264
+ }
1784
2265
  // ===== Card Feed pull sync (M6) — wake-sync-sleep battery clients =====
1785
2266
  // Auth mirrors /apme: local connections are free; anything else needs the
1786
2267
  // pairing token (?token=). Devices hold it from provisioning — since
@@ -1799,8 +2280,36 @@ export async function startDaemon(opts) {
1799
2280
  return;
1800
2281
  }
1801
2282
  }
2283
+ let surfaceIdentity;
2284
+ try {
2285
+ surfaceIdentity = parseHttpSurfaceIdentity(req.headers, 'glance.read');
2286
+ if (surfaceIdentity)
2287
+ validateSurfaceQueryTuple(surfaceIdentity, parsedUrl.searchParams);
2288
+ }
2289
+ catch (err) {
2290
+ if (err instanceof SurfaceProtocolError) {
2291
+ res.writeHead(err.status, { 'Content-Type': 'application/json' });
2292
+ res.end(JSON.stringify(surfaceErrorBody(err)));
2293
+ return;
2294
+ }
2295
+ throw err;
2296
+ }
2297
+ if (surfaceIdentity) {
2298
+ const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
2299
+ if (preferredIp) {
2300
+ const port = req.socket.localPort ?? 9120;
2301
+ log(`[agentdeck] Surface path redirect: glance-frame ${surfaceIdentity.board} (${ip}) via ${preferredIp}`);
2302
+ res.writeHead(307, {
2303
+ Location: `http://${preferredIp}:${port}${parsedUrl.pathname}${parsedUrl.search}`,
2304
+ 'Cache-Control': 'no-store',
2305
+ 'Connection': 'close',
2306
+ });
2307
+ res.end();
2308
+ return;
2309
+ }
2310
+ }
1802
2311
  (async () => {
1803
- const board = parsedUrl.searchParams.get('board') ?? 'xteink_x3';
2312
+ const board = surfaceIdentity?.board ?? parsedUrl.searchParams.get('board') ?? 'xteink_x3';
1804
2313
  const preset = GLANCE_FRAME_BOARDS[board];
1805
2314
  const w = Number(parsedUrl.searchParams.get('w'));
1806
2315
  const h = Number(parsedUrl.searchParams.get('h'));
@@ -1854,6 +2363,39 @@ export async function startDaemon(opts) {
1854
2363
  return;
1855
2364
  }
1856
2365
  }
2366
+ let surfaceIdentity;
2367
+ try {
2368
+ surfaceIdentity = parseHttpSurfaceIdentity(req.headers, req.method === 'GET' ? 'feed.pull' : 'outbox.push');
2369
+ if (surfaceIdentity)
2370
+ validateSurfaceQueryTuple(surfaceIdentity, parsedUrl.searchParams);
2371
+ }
2372
+ catch (err) {
2373
+ if (err instanceof SurfaceProtocolError) {
2374
+ res.writeHead(err.status, { 'Content-Type': 'application/json' });
2375
+ res.end(JSON.stringify(surfaceErrorBody(err)));
2376
+ return;
2377
+ }
2378
+ throw err;
2379
+ }
2380
+ // Redirect bodyless Feed GETs only. Pocket builds predating the explicit
2381
+ // POST replay support cannot follow a 307 Outbox response and stall the
2382
+ // whole Sync before Feed/OTA. Outbox is tiny and idempotency-keyed, so
2383
+ // serving it on the accepted local interface is both compatible and
2384
+ // safe; current clients still learn/promote the Wi-Fi origin from Feed.
2385
+ if (surfaceIdentity && req.method === 'GET') {
2386
+ const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
2387
+ if (preferredIp) {
2388
+ const port = req.socket.localPort ?? 9120;
2389
+ log(`[agentdeck] Surface path redirect: ${pathname.slice(1)} ${surfaceIdentity.board} (${ip}) via ${preferredIp}`);
2390
+ res.writeHead(307, {
2391
+ Location: `http://${preferredIp}:${port}${parsedUrl.pathname}${parsedUrl.search}`,
2392
+ 'Cache-Control': 'no-store',
2393
+ 'Connection': 'close',
2394
+ });
2395
+ res.end();
2396
+ return;
2397
+ }
2398
+ }
1857
2399
  (async () => {
1858
2400
  if (req.method === 'GET') {
1859
2401
  const sessions = await core.buildSessionsSnapshot();
@@ -1869,45 +2411,81 @@ export async function startDaemon(opts) {
1869
2411
  events: await glanceCalendar.get(parseCalendarSettings(settings)),
1870
2412
  });
1871
2413
  const now = Date.now();
1872
- const pocketReader = parsedUrl.searchParams.get('surface') === 'pocket-reader';
2414
+ const pocketReader = usesPortableReaderProjection(surfaceIdentity, parsedUrl.searchParams);
2415
+ const includeWeatherOutlook = surfaceIdentity?.capabilities.includes('weather.snapshot.read') === true;
2416
+ const feedGlance = pocketReader ? projectPortableReaderGlance(glance, includeWeatherOutlook) : glance;
1873
2417
  const feed = buildCardFeed(sessions, now, pocketReader ? pocketReaderModules : pocketCardModules, {
1874
- glance,
2418
+ glance: feedGlance,
1875
2419
  echoSig: parsedUrl.searchParams.get('sig') ?? undefined,
1876
2420
  includeSessions: !pocketReader,
1877
2421
  });
1878
2422
  // Pull-OTA advert: rides full AND `unchanged` responses, so a
1879
2423
  // sleeping board learns about a staged build on any pull. Board
1880
2424
  // identity from the query (newer firmware) or the IP memory.
1881
- const pullBoard = parsedUrl.searchParams.get('board') ?? boardForClientIp(ip);
1882
- if (!feed.unchanged)
1883
- pocketAutonomy.observeDelivery(feed.cards, now, pullBoard ?? undefined);
1884
- const fwAdvert = stagedFwAdvert(pullBoard ?? undefined);
2425
+ const pullBoard = surfaceIdentity?.board ?? parsedUrl.searchParams.get('board') ?? boardForClientIp(ip);
2426
+ const otaIdentity = surfaceIdentity ? {
2427
+ productId: surfaceIdentity.productId,
2428
+ board: surfaceIdentity.board,
2429
+ updateChannel: surfaceIdentity.updateChannel,
2430
+ } : undefined;
2431
+ const fwAdvert = stagedFwAdvert(pullBoard ?? undefined, otaIdentity, surfaceIdentity?.clientVersion);
1885
2432
  if (fwAdvert)
1886
2433
  feed.fw = fwAdvert;
2434
+ if (!fwAdvert && surfaceLearningPack
2435
+ && surfaceIdentity?.productId === 'io.pocketdaily.reader'
2436
+ && surfaceIdentity.capabilities.includes('learning.pack.update')) {
2437
+ // The advert deliberately rides unchanged responses. It is omitted
2438
+ // during OTA-first bootstrap so the device installs firmware before
2439
+ // spending its battery/link budget on content bytes.
2440
+ feed.learningPack = surfaceLearningPack.advert;
2441
+ }
2442
+ if (!fwAdvert && surfaceFontPack
2443
+ && surfaceIdentity?.productId === 'io.pocketdaily.reader'
2444
+ && surfaceIdentity.capabilities.includes('font.pack.update')) {
2445
+ feed.fontPack = surfaceFontPack.advert;
2446
+ }
2447
+ const telemetry = parsePullTelemetry(parsedUrl.searchParams);
2448
+ if (applyPullOtaBootstrap(feed, fwAdvert !== undefined)) {
2449
+ // Do not make a weak-link X3 receive and parse a full content deck
2450
+ // before it can discover the resumable image. `unchanged` tells it
2451
+ // to retain the durable cards already on SD; after the update its
2452
+ // next ordinary pull receives the complete current deck.
2453
+ log(`[agentdeck] OTA-first Feed bootstrap: ${pullBoard ?? 'unknown'} (${ip})${telemetry.rssiDbm !== undefined ? ` rssi ${telemetry.rssiDbm}dBm` : ''}`);
2454
+ }
2455
+ if (!feed.unchanged)
2456
+ pocketAutonomy.observeDelivery(feed.cards, now, pullBoard ?? undefined);
1887
2457
  // A sleeping client's whole visit is this one request: no body, no
1888
2458
  // board id, nothing pushed. Record it, because the gap between two
1889
2459
  // pulls is the only evidence that the timer wake fired at all.
1890
- feedPulls.noteBoard(ip, boardForClientIp(ip));
2460
+ feedPulls.noteBoard(ip, pullBoard);
2461
+ if (surfaceIdentity)
2462
+ feedPulls.noteIdentity(ip, surfaceIdentity);
1891
2463
  log(`[agentdeck] ${formatFeedPull(feedPulls.record(ip, {
1892
2464
  cards: feed.cards.length,
1893
2465
  nextPullSec: feed.nextPullSec,
1894
2466
  unchanged: feed.unchanged === true,
1895
- telemetry: parsePullTelemetry(parsedUrl.searchParams),
2467
+ telemetry,
1896
2468
  }))}`);
1897
2469
  return feed;
1898
2470
  }
1899
2471
  const body = await readJsonBody(req);
1900
2472
  const board = typeof body.board === 'string' ? body.board : 'unknown';
2473
+ if (surfaceIdentity && board !== surfaceIdentity.board) {
2474
+ throw new SurfaceProtocolError(409, 'surface_outbox_board_mismatch', 'Outbox board does not match Surface identity');
2475
+ }
1901
2476
  // An outbox push is the one request that names the board — bind it to
1902
2477
  // the IP so the (anonymous) feed pulls that follow are attributable.
1903
2478
  if (board !== 'unknown')
1904
2479
  feedPulls.noteBoard(ip, board);
2480
+ if (surfaceIdentity)
2481
+ feedPulls.noteIdentity(ip, surfaceIdentity);
1905
2482
  const sessions = await core.buildSessionsSnapshot();
1906
2483
  const result = applyOutboxDecisions(body, {
1907
2484
  sessions: sessions,
1908
2485
  isPendingRequest,
1909
2486
  dispatch: (cmd) => handleDeviceCommand(cmd),
1910
2487
  modules: pocketCardModules,
2488
+ idempotency: outboxIdempotency,
1911
2489
  });
1912
2490
  const applied = result.results.filter((r) => r.status === 'applied').length;
1913
2491
  if (result.results.length > 0) {
@@ -1915,11 +2493,26 @@ export async function startDaemon(opts) {
1915
2493
  }
1916
2494
  return result;
1917
2495
  })().then((payload) => {
1918
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1919
- res.end(JSON.stringify(payload));
2496
+ const body = JSON.stringify(payload);
2497
+ // A fixed length lets esp_http_client prove completion without relying
2498
+ // on a keep-alive chunk terminator. This is especially important when
2499
+ // the daemon host has Ethernet and Wi-Fi on the same LAN: the request
2500
+ // may arrive while a later return segment is lost on the competing
2501
+ // route. Portable-reader projection above normally keeps this body in
2502
+ // one TCP payload; Content-Length makes any short read fail explicitly.
2503
+ res.writeHead(200, {
2504
+ 'Content-Type': 'application/json',
2505
+ 'Content-Length': String(Buffer.byteLength(body)),
2506
+ 'Cache-Control': 'no-store',
2507
+ 'Connection': 'close',
2508
+ });
2509
+ res.end(body);
1920
2510
  }).catch((err) => {
1921
- res.writeHead(500, { 'Content-Type': 'application/json' });
1922
- res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
2511
+ const status = err instanceof SurfaceProtocolError ? err.status : 500;
2512
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2513
+ res.end(JSON.stringify(err instanceof SurfaceProtocolError
2514
+ ? surfaceErrorBody(err)
2515
+ : { error: err instanceof Error ? err.message : String(err) }));
1923
2516
  });
1924
2517
  return;
1925
2518
  }
@@ -3026,6 +3619,15 @@ export async function startDaemon(opts) {
3026
3619
  'device_info_request',
3027
3620
  ]);
3028
3621
  core.wsServer.setEventTransformer((event, client) => {
3622
+ const surface = surfaceNegotiations.get(client);
3623
+ if (surface && isPortableReaderProfile(surface.profile)) {
3624
+ // portable-reader/v1 is pull-first. A negotiated socket gets only its
3625
+ // acknowledgement and bounded liveness/device probes; it never inherits
3626
+ // the daemon's private dashboard state burst or command plane.
3627
+ if (!surfaceAllowsEvent(surface.profile, event.type))
3628
+ return null;
3629
+ return event;
3630
+ }
3029
3631
  if (!core.wsServer.isEsp32Client(client))
3030
3632
  return event;
3031
3633
  if (!esp32WifiEvents.has(event.type))
@@ -3240,7 +3842,6 @@ export async function startDaemon(opts) {
3240
3842
  else if (evt.type === 'usage_update') {
3241
3843
  // Sync daemon cache with relay's already-adjusted values (prevents oscillation)
3242
3844
  const u = evt;
3243
- const hasClaudeData = u.fiveHourPercent != null || u.sevenDayPercent != null;
3244
3845
  if (core.cachedApiUsage && u.fiveHourPercent != null) {
3245
3846
  core.cachedApiUsage.fiveHourPercent = u.fiveHourPercent;
3246
3847
  core.cachedApiUsage.fiveHourResetsAt = u.fiveHourResetsAt ?? null;
@@ -3253,43 +3854,16 @@ export async function startDaemon(opts) {
3253
3854
  core.cachedApiUsage.scopedLimits = Array.isArray(u.scopedLimits) ? u.scopedLimits : [];
3254
3855
  core.apiUsagePreAdjusted = true;
3255
3856
  }
3256
- // A session bridge broadcasts usage on EVERY state change. In daemon-first
3257
- // mode its snapshot usually has no model context, so the Claude quota model
3258
- // gate (isClaudeSubscriptionModel) yields no percents and the event carries
3259
- // usageStale=true. Forwarding that bare event verbatim clobbered the daemon's
3260
- // own authoritative aggregate on the dashboard: the Claude gauge blanked to
3261
- // "No usage data" on every state tick (the Codex gauge, which ignores global
3262
- // staleness, stayed put — hence the Claude-only flicker).
3263
- //
3264
- // Suppressing the relay instead is NOT an option: usage_update is the ONLY
3265
- // carrier of per-session tokens / cost / duration (state_update has none of
3266
- // those fields), and the daemon's own buildUsage() reads a UsageTracker that
3267
- // no PTY ever feeds, so its session half is zeros. Dropping the event would
3268
- // blank the session numbers for every focus that legitimately carries no
3269
- // Claude quota — Codex, OpenCode, API billing.
3270
- //
3271
- // So split the event by half instead of by event: the ACCOUNT half (quota,
3272
- // scoped caps, extra usage, subscriptions, Codex/Ollama, usageStale) comes
3273
- // from the daemon's authoritative aggregate, the SESSION half from the relay.
3274
- //
3275
- // That split applies on the `else` branch ONLY. When the relay carries
3276
- // Claude percentages its event goes out verbatim, so the account half —
3277
- // `codexRateLimits` included — is the BRIDGE's. Usually identical: both
3278
- // sides run the same plan-aware rollout read. They can differ only when no
3279
- // plan-matching rollout exists at all, since the daemon additionally holds
3280
- // a `codex app-server` reading that a session bridge structurally cannot
3281
- // (no host processes there) — then successive events alternate between a
3282
- // real Codex window and a voided one. Left as is deliberately: the only
3283
- // targeted fix is to overwrite the block from `core.buildUsage()` here, and
3284
- // that is the call which arms the throttled spawn, on the one path whose
3285
- // whole history is about not clobbering the dashboard. Tracked as issue
3286
- // #253, not just as this comment.
3287
- if (hasClaudeData) {
3288
- core.wsServer.broadcast(evt);
3289
- }
3290
- else {
3291
- core.wsServer.broadcast(mergeRelayedSessionUsage(core.buildUsage(), u));
3292
- }
3857
+ // WHICH half of the relayed event survives, and why, lives in
3858
+ // `resolveRelayedUsageEvent`. It was inlined here until issue #253: this is
3859
+ // the daemon's most flicker-sensitive path, its whole history is about not
3860
+ // clobbering the dashboard, and an inline branch had no test seam.
3861
+ core.wsServer.broadcast(resolveRelayedUsageEvent({
3862
+ relayed: u,
3863
+ ownCodexRateLimits: core.lastBuiltCodexRateLimits,
3864
+ ownLiveFamilyAuthorityExpiresAtMs: core.lastBuiltCodexLiveFamilyAuthorityExpiresAtMs,
3865
+ buildOwnUsage: () => core.buildUsage(),
3866
+ }));
3293
3867
  }
3294
3868
  else {
3295
3869
  // prompt_options — relay as-is
@@ -3341,6 +3915,8 @@ export async function startDaemon(opts) {
3341
3915
  version: d.version ?? null,
3342
3916
  stale: d.stale,
3343
3917
  serialActive: d.serialActive,
3918
+ repaintCount: d.repaintCount ?? null,
3919
+ fullRefreshCount: d.fullRefreshCount ?? null,
3344
3920
  })),
3345
3921
  };
3346
3922
  }
@@ -3408,6 +3984,14 @@ export async function startDaemon(opts) {
3408
3984
  bridgeIp: getLanIp(),
3409
3985
  bridgePort: port,
3410
3986
  }));
3987
+ // Operator-approval authentication (daemon only, for the same reason). The
3988
+ // roster belongs to the process that serves the operator's approval routes;
3989
+ // a session bridge sharing WsServer must not answer "is this peer approved?"
3990
+ // from a second, unowned copy of it.
3991
+ core.wsServer.setPairingApprovalHooks({
3992
+ isApproved: (ip, deviceId) => isApprovedPeer(ip, deviceId),
3993
+ record: (ip, deviceId, staleToken) => recordKnock(ip, deviceId, staleToken),
3994
+ });
3411
3995
  // WS-path display_state re-sync: the 5s serial heartbeat above only covers
3412
3996
  // USB-attached boards. WiFi boards (InkDeck) receive display_state edge-
3413
3997
  // triggered over the plugin WS — a missed wake edge would leave an e-ink
@@ -3443,6 +4027,9 @@ export async function startDaemon(opts) {
3443
4027
  // fires once per board per token, only after the silence, and only when the
3444
4028
  // user has auto-provisioning on (it carries their WiFi credentials).
3445
4029
  const legacyRearmTimers = new Map();
4030
+ // Bounds the fallback to once per board per token. The rule and the
4031
+ // measurement that forced it live in the module.
4032
+ const legacyRearm = new LegacyRearmLedger();
3446
4033
  const cancelLegacyRearm = (portPath) => {
3447
4034
  const timer = legacyRearmTimers.get(portPath);
3448
4035
  if (!timer)
@@ -3468,11 +4055,20 @@ export async function startDaemon(opts) {
3468
4055
  if (armed.length > 0)
3469
4056
  log(`[agentdeck] Pairing token armed on ${armed.length} ESP32 device(s) after ${portPath}`);
3470
4057
  if (wifiConfig?.autoProvision) {
4058
+ const currentToken = getOrCreateToken();
3471
4059
  for (const armedPort of armed) {
3472
4060
  if (legacyRearmTimers.has(armedPort))
3473
4061
  continue;
4062
+ if (!legacyRearm.needsRearm(armedPort, currentToken))
4063
+ continue;
3474
4064
  const timer = setTimeout(() => {
3475
4065
  legacyRearmTimers.delete(armedPort);
4066
+ // Record BEFORE the send: a write that fails is still an attempt
4067
+ // this token made, and retrying it on the next device_info is the
4068
+ // loop this map exists to end. A board that stays unreachable is
4069
+ // re-armed again when the token next changes, or when the daemon
4070
+ // restarts — both of which are real events, unlike a 30s poll.
4071
+ legacyRearm.markRearmed(armedPort, currentToken);
3476
4072
  const sent = sendWifiProvision(armedPort, {
3477
4073
  type: 'wifi_provision',
3478
4074
  ssid: wifiConfig.ssid,
@@ -3491,7 +4087,11 @@ export async function startDaemon(opts) {
3491
4087
  }
3492
4088
  }
3493
4089
  else if (msg.type === 'auth_provision_ack') {
4090
+ // The board understood `auth_provision`, so the legacy WiFi re-provision
4091
+ // is not needed for it at all. Mark the current token as handled so a
4092
+ // later device_info cannot schedule one behind the cancelled timer.
3494
4093
  cancelLegacyRearm(portPath);
4094
+ legacyRearm.markUnderstood(portPath, getOrCreateToken());
3495
4095
  if (msg.changed)
3496
4096
  log(`[agentdeck] ESP32 pairing token re-armed on ${portPath}`);
3497
4097
  }
@@ -3618,6 +4218,8 @@ export async function startDaemon(opts) {
3618
4218
  catch (err) {
3619
4219
  debug('daemon', `kiro timeline feed failed: ${String(err)}`);
3620
4220
  }
4221
+ // NOTE: the OpenClaw transcript feed used to pump from here and must not —
4222
+ // see `openclawFeedTimer` below for why this callback cannot drive it.
3621
4223
  core.maybeBroadcastSessionsList();
3622
4224
  };
3623
4225
  // OTel turn boundaries arrive in milliseconds where the observer only re-reads
@@ -3720,6 +4322,44 @@ export async function startDaemon(opts) {
3720
4322
  // `connectGatewayAdapter` open + `disconnectGatewayAdapter` close paths
3721
4323
  // can reference the same id.
3722
4324
  let openclawApmeSessionId = null;
4325
+ /** Gateway session key → APME session id. One APME run per OpenClaw session
4326
+ * key, opened on that key's first event and closed when the link drops.
4327
+ *
4328
+ * Before this map the run was scoped to the Gateway CONNECTION, so a user
4329
+ * chat, every cron job and every model-eval run (a suite opens a fresh key
4330
+ * per run) shared one run, one `model_id` and one idle-gap boundary. */
4331
+ const openclawRunsBySessionKey = new Map();
4332
+ /** Open (or return) the APME run for one Gateway session key.
4333
+ *
4334
+ * `projectName` is the key itself rather than the bare literal "openclaw":
4335
+ * that literal is what made an approval raised by a model-eval harness look
4336
+ * like it came from a conversation the user could not find. */
4337
+ function openclawRunForSessionKey(sessionKey) {
4338
+ const existing = openclawRunsBySessionKey.get(sessionKey);
4339
+ if (existing)
4340
+ return existing;
4341
+ if (!apme)
4342
+ return null;
4343
+ const sessionId = `openclaw:${sessionKey}`;
4344
+ try {
4345
+ apme.collector.openRun({
4346
+ sessionId,
4347
+ agentType: 'openclaw',
4348
+ projectName: sessionKey,
4349
+ // Seed with whatever the catalog last reported. Runs open lazily on a
4350
+ // key's first event, so a run opened AFTER `model_info` fired would
4351
+ // otherwise persist `model_id=NULL` — the defect that per-run
4352
+ // `updateModel` was added to fix, reintroduced by the lazy open.
4353
+ ...(gatewayModelName ? { modelId: gatewayModelName } : {}),
4354
+ });
4355
+ }
4356
+ catch (err) {
4357
+ debug('APME', `openRun for OpenClaw ${sessionKey} failed: ${String(err)}`);
4358
+ return null;
4359
+ }
4360
+ openclawRunsBySessionKey.set(sessionKey, sessionId);
4361
+ return sessionId;
4362
+ }
3723
4363
  function connectGatewayAdapter() {
3724
4364
  if (gatewayAdapter || gatewayConnecting)
3725
4365
  return;
@@ -3739,6 +4379,7 @@ export async function startDaemon(opts) {
3739
4379
  projectName: 'openclaw',
3740
4380
  });
3741
4381
  adapter.setApmeSession(openclawApmeSessionId, process.cwd());
4382
+ adapter.setApmeRunResolver(openclawRunForSessionKey);
3742
4383
  }
3743
4384
  catch (err) {
3744
4385
  debug('APME', `openRun for OpenClaw failed: ${String(err)}`);
@@ -3787,8 +4428,19 @@ export async function startDaemon(opts) {
3787
4428
  const model = evt.data?.model;
3788
4429
  if (model) {
3789
4430
  gatewayModelName = model;
3790
- if (apme && openclawApmeSessionId)
3791
- apme.collector.updateModel(openclawApmeSessionId, model);
4431
+ // `model_info` rides the connection, not a session key — the
4432
+ // Gateway's `chat` frame reports no model, so this is the
4433
+ // catalog default rather than the model that answered any one
4434
+ // turn. Apply it to every open run as a FLOOR; a per-turn
4435
+ // `session_meta` span (which does name the model that answered)
4436
+ // overwrites it wherever one arrives.
4437
+ if (apme) {
4438
+ if (openclawApmeSessionId)
4439
+ apme.collector.updateModel(openclawApmeSessionId, model);
4440
+ for (const sessionId of openclawRunsBySessionKey.values()) {
4441
+ apme.collector.updateModel(sessionId, model);
4442
+ }
4443
+ }
3792
4444
  }
3793
4445
  }
3794
4446
  break;
@@ -3841,7 +4493,14 @@ export async function startDaemon(opts) {
3841
4493
  // still read as outstanding in the timeline. `updateEntryStatus`
3842
4494
  // existed for exactly this and had no production caller.
3843
4495
  if (enriched.type === 'tool_resolved' && enriched.approvalId) {
3844
- core.bridgeTimeline.updateEntryStatus(enriched.approvalId, enriched.status === 'approved' ? 'approved' : 'denied');
4496
+ // Carry the resolution's own status onto the request row rather
4497
+ // than collapsing everything-not-approved into `denied`: an
4498
+ // abandoned approval (run cancelled / turn ended / expired) is a
4499
+ // non-decision, and flattening it here would undo the split at
4500
+ // the one place the two rows are reconciled.
4501
+ core.bridgeTimeline.updateEntryStatus(enriched.approvalId, enriched.status === 'approved' ? 'approved'
4502
+ : enriched.status === 'abandoned' ? 'abandoned'
4503
+ : 'denied');
3845
4504
  }
3846
4505
  }
3847
4506
  break;
@@ -3909,15 +4568,30 @@ export async function startDaemon(opts) {
3909
4568
  // APME: close the OpenClaw run so the collector fires its
3910
4569
  // session_end boundary and the run becomes eligible for the layer-2
3911
4570
  // judge queue.
3912
- if (apme && openclawApmeSessionId) {
3913
- try {
3914
- apme.collector.closeRun(openclawApmeSessionId);
4571
+ if (apme) {
4572
+ // Every per-session-key run, not just the connection-scoped one. A run
4573
+ // left open is never finalized, and a task that never closes is never
4574
+ // evaluated — the failure the abandoned-run reaper exists to clean up
4575
+ // after, hours later and with `boundary_signal='orphaned'`.
4576
+ for (const sessionId of openclawRunsBySessionKey.values()) {
4577
+ try {
4578
+ apme.collector.closeRun(sessionId);
4579
+ }
4580
+ catch (err) {
4581
+ debug('APME', `closeRun for OpenClaw ${sessionId} failed: ${String(err)}`);
4582
+ }
3915
4583
  }
3916
- catch (err) {
3917
- debug('APME', `closeRun for OpenClaw failed: ${String(err)}`);
4584
+ openclawRunsBySessionKey.clear();
4585
+ if (openclawApmeSessionId) {
4586
+ try {
4587
+ apme.collector.closeRun(openclawApmeSessionId);
4588
+ }
4589
+ catch (err) {
4590
+ debug('APME', `closeRun for OpenClaw failed: ${String(err)}`);
4591
+ }
3918
4592
  }
3919
- openclawApmeSessionId = null;
3920
4593
  }
4594
+ openclawApmeSessionId = null;
3921
4595
  core.cachedGatewayConnected = false;
3922
4596
  core.cachedGatewayAuthStatus = 'gateway_not_found';
3923
4597
  core.cachedModelCatalog = null;
@@ -4009,6 +4683,28 @@ export async function startDaemon(opts) {
4009
4683
  // ===== Commands from WS clients =====
4010
4684
  // ===== Internal WS: session push channel =====
4011
4685
  core.wsServer.onRawMessage((msg, sender) => {
4686
+ if (msg.type === 'client_register' && msg.surface !== undefined) {
4687
+ try {
4688
+ const negotiation = negotiateSurface(msg.surface);
4689
+ surfaceNegotiations.set(sender, negotiation);
4690
+ core.wsServer.sendTo(sender, negotiation.welcome);
4691
+ debug('daemon', `Surface negotiated ${negotiation.profile} client=${negotiation.clientId} product=${negotiation.productId}`);
4692
+ }
4693
+ catch (err) {
4694
+ if (err instanceof SurfaceProtocolError) {
4695
+ try {
4696
+ sender.send(JSON.stringify({ type: 'surface_error', ...surfaceErrorBody(err) }));
4697
+ }
4698
+ catch { /* gone */ }
4699
+ try {
4700
+ sender.close(1008, err.code.slice(0, 120));
4701
+ }
4702
+ catch { /* gone */ }
4703
+ return true;
4704
+ }
4705
+ throw err;
4706
+ }
4707
+ }
4012
4708
  // WiFi ESP32 boards announce device_info over WS on connect. Capture the
4013
4709
  // sender here so OTA can address the exact socket instead of broadcasting.
4014
4710
  if (msg.type === 'device_info') {
@@ -5414,19 +6110,33 @@ export async function startDaemon(opts) {
5414
6110
  setSerialCommandSink((c) => handleDeviceCommand(c));
5415
6111
  // ===== Client connect =====
5416
6112
  core.wsServer.onClientConnect((ws) => {
5417
- const gwAlive = gatewayAdapter?.isAlive() ?? false;
5418
- core.sendInitialState(ws, {
5419
- agentType: gwAlive ? 'openclaw' : 'daemon',
5420
- agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
5421
- isAlive: true, // WS client IS connected to daemon — gateway status conveyed via state_update
5422
- });
5423
- // Fetch usage on connect if stale
5424
- const cacheAge = Date.now() - core.lastApiFetchTime;
5425
- if (!core.cachedApiUsage || (core.lastApiFetchTime > 0 && cacheAge > 5 * 60 * 1000)) {
5426
- fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
5427
- }
6113
+ // The WS class invokes this before installing its message listener. Give a
6114
+ // Surface client one short turn to send client_register; legacy clients
6115
+ // still receive the exact same initial payload, only 75 ms later.
6116
+ const initialTimer = setTimeout(() => {
6117
+ if (ws.readyState !== WebSocket.OPEN)
6118
+ return;
6119
+ const surface = surfaceNegotiations.get(ws);
6120
+ const gwAlive = gatewayAdapter?.isAlive() ?? false;
6121
+ if (surface && isPortableReaderProfile(surface.profile)) {
6122
+ core.wsServer.sendTo(ws, { type: 'connection', status: 'connected' });
6123
+ return;
6124
+ }
6125
+ core.sendInitialState(ws, {
6126
+ agentType: gwAlive ? 'openclaw' : 'daemon',
6127
+ agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
6128
+ isAlive: true,
6129
+ });
6130
+ // Fetch usage on connect if stale
6131
+ const cacheAge = Date.now() - core.lastApiFetchTime;
6132
+ if (!core.cachedApiUsage || (core.lastApiFetchTime > 0 && cacheAge > 5 * 60 * 1000)) {
6133
+ fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
6134
+ }
6135
+ }, 75);
6136
+ initialTimer.unref?.();
5428
6137
  });
5429
6138
  core.wsServer.onClientDisconnect((ws) => {
6139
+ surfaceNegotiations.delete(ws);
5430
6140
  unregisterWifiEsp32Socket(ws);
5431
6141
  });
5432
6142
  // ===== Probes & polling =====
@@ -5647,10 +6357,64 @@ export async function startDaemon(opts) {
5647
6357
  // socket can't leak and Claude's own prompt isn't lost forever.
5648
6358
  const permissionSweepTimer = setInterval(() => { sweepStalePending(60_000); }, 30_000);
5649
6359
  permissionSweepTimer.unref?.();
6360
+ // OpenClaw is Gateway-observed, and that stream carries the prompt, the final
6361
+ // response and the approvals it has to block on — never the tool calls. The
6362
+ // only tools that ever reached the strip were the ones that happened to need
6363
+ // approval, which is a biased sample rather than a log. This reads the
6364
+ // transcript OpenClaw writes anyway and emits the rest.
6365
+ //
6366
+ // **This needs a timer of its own, and that is the whole point.** The pump
6367
+ // originally hung off `passiveSessionObserver.onRefreshed`, which fires only
6368
+ // when the observed PROCESS set changes (`scan()` compares `next` against the
6369
+ // cache and calls back only on a difference). Every other transcript feed can
6370
+ // ride that edge because its subject is in that set — a Kiro turn moves its
6371
+ // own row's `state`/`lastActivityAt`, so Kiro activity wakes the Kiro feed.
6372
+ // OpenClaw is reached over the Gateway WebSocket and has NO process row at
6373
+ // all, so an OpenClaw turn moves nothing the observer can see: the feed woke
6374
+ // only when some unrelated agent happened to start or stop, and on the first
6375
+ // scan after boot (empty cache → populated, which always counts as a change).
6376
+ // Measured 2026-08-23 on a live daemon: three consecutive OpenClaw turns each
6377
+ // wrote a tool call to the store, the reader returned it, and the pump ran
6378
+ // exactly twice — both at startup — so the timeline showed zero `tool_exec`
6379
+ // rows. A producer must be driven by a signal its own subject can move.
6380
+ //
6381
+ // Gated on the SSOT presence predicate, not on the store: the rows are
6382
+ // attributed to the virtual `openclaw-gateway` session, and that row exists
6383
+ // only while the Gateway is authenticated. Emitting them otherwise would put
6384
+ // activity under a session no surface is showing.
6385
+ //
6386
+ // Cadence matches the passive observer's own `SCAN_INTERVAL_MS`, which is the
6387
+ // "seconds late, never instant" contract every transcript-observed agent
6388
+ // already carries. The pump is synchronous fs work; measured against a real
6389
+ // store it costs ~7 ms typically and ~28 ms in its worst case (the index
6390
+ // parse plus the eight largest transcripts), so the duty cycle stays well
6391
+ // under 1% of the interval it shares with the observer.
6392
+ const openclawFeedTimer = setInterval(() => {
6393
+ try {
6394
+ if (!core.cachedGatewayConnected)
6395
+ return;
6396
+ const rows = openclawTimelineFeed.pump();
6397
+ // Logged because the silent path and the broken path look identical from
6398
+ // outside: a tick that emits nothing writes nothing, so "no rows on the
6399
+ // strip" cannot be told from "the pump never ran" without this line. That
6400
+ // ambiguity is exactly what hid the `onRefreshed` coupling.
6401
+ if (rows.length > 0) {
6402
+ debug('daemon', `openclaw-feed emitting ${rows.length} row(s)`);
6403
+ }
6404
+ for (const entry of rows) {
6405
+ core.bridgeTimeline.addEntry(entry);
6406
+ }
6407
+ }
6408
+ catch (err) {
6409
+ debug('daemon', `openclaw timeline feed failed: ${String(err)}`);
6410
+ }
6411
+ }, OPENCLAW_FEED_INTERVAL_MS);
6412
+ openclawFeedTimer.unref?.();
5650
6413
  // ===== Shutdown =====
5651
6414
  core.onShutdown(async () => {
5652
6415
  clearInterval(permissionSweepTimer);
5653
6416
  clearInterval(daemonInfoHealTimer);
6417
+ clearInterval(openclawFeedTimer);
5654
6418
  drainAllPending();
5655
6419
  removeDaemonInfo();
5656
6420
  focusRelay.stop();
@@ -5677,6 +6441,9 @@ export async function startDaemon(opts) {
5677
6441
  // Trigger initial state broadcast so display hardware modules (D200H, Pixoo, Serial) get populated
5678
6442
  broadcastFocusedState();
5679
6443
  core.broadcastUsage();
5680
- log(`[agentdeck] Daemon running. Gateway probe active.`);
6444
+ // Name the build in the log, once. A daemon log that does not say which build
6445
+ // wrote it cannot answer "was this fixed before or after that restart?" —
6446
+ // and on a dev checkout the answer changes several times a day.
6447
+ log(`[agentdeck] Daemon running (build ${startupBuildId ?? 'unknown'}). Gateway probe active.`);
5681
6448
  }
5682
6449
  //# sourceMappingURL=daemon-server.js.map