@agentdeck/bridge 1.0.24 → 1.1.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 (117) hide show
  1. package/dist/adapters/openclaw.d.ts +74 -4
  2. package/dist/adapters/openclaw.d.ts.map +1 -1
  3. package/dist/adapters/openclaw.js +235 -43
  4. package/dist/adapters/openclaw.js.map +1 -1
  5. package/dist/apme/adapters/openclaw-hook.d.ts +38 -10
  6. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
  7. package/dist/apme/adapters/openclaw-hook.js +181 -37
  8. package/dist/apme/adapters/openclaw-hook.js.map +1 -1
  9. package/dist/apme/collector.d.ts +37 -1
  10. package/dist/apme/collector.d.ts.map +1 -1
  11. package/dist/apme/collector.js +277 -35
  12. package/dist/apme/collector.js.map +1 -1
  13. package/dist/apme/pareto.d.ts +2 -1
  14. package/dist/apme/pareto.d.ts.map +1 -1
  15. package/dist/apme/pareto.js +15 -4
  16. package/dist/apme/pareto.js.map +1 -1
  17. package/dist/apme/recommend.d.ts +24 -1
  18. package/dist/apme/recommend.d.ts.map +1 -1
  19. package/dist/apme/recommend.js +43 -7
  20. package/dist/apme/recommend.js.map +1 -1
  21. package/dist/apme/store.d.ts +6 -0
  22. package/dist/apme/store.d.ts.map +1 -1
  23. package/dist/apme/store.js +244 -72
  24. package/dist/apme/store.js.map +1 -1
  25. package/dist/bridge-core.d.ts +10 -1
  26. package/dist/bridge-core.d.ts.map +1 -1
  27. package/dist/bridge-core.js +20 -5
  28. package/dist/bridge-core.js.map +1 -1
  29. package/dist/card-feed.d.ts +46 -1
  30. package/dist/card-feed.d.ts.map +1 -1
  31. package/dist/card-feed.js +166 -6
  32. package/dist/card-feed.js.map +1 -1
  33. package/dist/cli.d.ts +45 -0
  34. package/dist/cli.d.ts.map +1 -1
  35. package/dist/cli.js +388 -27
  36. package/dist/cli.js.map +1 -1
  37. package/dist/daemon-build-identity.d.ts +124 -0
  38. package/dist/daemon-build-identity.d.ts.map +1 -0
  39. package/dist/daemon-build-identity.js +310 -0
  40. package/dist/daemon-build-identity.js.map +1 -0
  41. package/dist/daemon-server.d.ts +34 -6
  42. package/dist/daemon-server.d.ts.map +1 -1
  43. package/dist/daemon-server.js +613 -108
  44. package/dist/daemon-server.js.map +1 -1
  45. package/dist/daemon-takeover.d.ts +11 -0
  46. package/dist/daemon-takeover.d.ts.map +1 -1
  47. package/dist/daemon-takeover.js +62 -17
  48. package/dist/daemon-takeover.js.map +1 -1
  49. package/dist/daemon.js +1 -1
  50. package/dist/daemon.js.map +1 -1
  51. package/dist/esp32-serial.d.ts +14 -0
  52. package/dist/esp32-serial.d.ts.map +1 -1
  53. package/dist/esp32-serial.js +19 -1
  54. package/dist/esp32-serial.js.map +1 -1
  55. package/dist/glance-frame.d.ts.map +1 -1
  56. package/dist/glance-frame.js +3 -1
  57. package/dist/glance-frame.js.map +1 -1
  58. package/dist/hook-codex-sessions.d.ts +3 -1
  59. package/dist/hook-codex-sessions.d.ts.map +1 -1
  60. package/dist/hook-codex-sessions.js +27 -7
  61. package/dist/hook-codex-sessions.js.map +1 -1
  62. package/dist/legacy-rearm-ledger.d.ts +56 -0
  63. package/dist/legacy-rearm-ledger.d.ts.map +1 -0
  64. package/dist/legacy-rearm-ledger.js +66 -0
  65. package/dist/legacy-rearm-ledger.js.map +1 -0
  66. package/dist/mdns.d.ts +33 -0
  67. package/dist/mdns.d.ts.map +1 -1
  68. package/dist/mdns.js +91 -2
  69. package/dist/mdns.js.map +1 -1
  70. package/dist/openclaw-session.d.ts.map +1 -1
  71. package/dist/openclaw-session.js +13 -0
  72. package/dist/openclaw-session.js.map +1 -1
  73. package/dist/openclaw-timeline-feed.d.ts +84 -0
  74. package/dist/openclaw-timeline-feed.d.ts.map +1 -0
  75. package/dist/openclaw-timeline-feed.js +162 -0
  76. package/dist/openclaw-timeline-feed.js.map +1 -0
  77. package/dist/openclaw-transcript-timeline.d.ts +103 -0
  78. package/dist/openclaw-transcript-timeline.d.ts.map +1 -0
  79. package/dist/openclaw-transcript-timeline.js +328 -0
  80. package/dist/openclaw-transcript-timeline.js.map +1 -0
  81. package/dist/passive-observer.d.ts +21 -1
  82. package/dist/passive-observer.d.ts.map +1 -1
  83. package/dist/passive-observer.js +37 -3
  84. package/dist/passive-observer.js.map +1 -1
  85. package/dist/relayed-usage.d.ts +68 -0
  86. package/dist/relayed-usage.d.ts.map +1 -0
  87. package/dist/relayed-usage.js +80 -0
  88. package/dist/relayed-usage.js.map +1 -0
  89. package/dist/session-aggregator.d.ts +3 -0
  90. package/dist/session-aggregator.d.ts.map +1 -1
  91. package/dist/session-aggregator.js.map +1 -1
  92. package/dist/session-registry.d.ts +1 -0
  93. package/dist/session-registry.d.ts.map +1 -1
  94. package/dist/surface-protocol.d.ts +84 -0
  95. package/dist/surface-protocol.d.ts.map +1 -0
  96. package/dist/surface-protocol.js +235 -0
  97. package/dist/surface-protocol.js.map +1 -0
  98. package/dist/timeline-store.d.ts +1 -1
  99. package/dist/timeline-store.d.ts.map +1 -1
  100. package/dist/timeline-store.js +16 -1
  101. package/dist/timeline-store.js.map +1 -1
  102. package/dist/tui/renderer.d.ts.map +1 -1
  103. package/dist/tui/renderer.js +35 -0
  104. package/dist/tui/renderer.js.map +1 -1
  105. package/dist/weather-config.d.ts +17 -0
  106. package/dist/weather-config.d.ts.map +1 -0
  107. package/dist/weather-config.js +54 -0
  108. package/dist/weather-config.js.map +1 -0
  109. package/dist/weather.d.ts +44 -4
  110. package/dist/weather.d.ts.map +1 -1
  111. package/dist/weather.js +223 -15
  112. package/dist/weather.js.map +1 -1
  113. package/dist/ws-server.d.ts.map +1 -1
  114. package/dist/ws-server.js +55 -5
  115. package/dist/ws-server.js.map +1 -1
  116. package/fm-helper/AgentDeckFMHelper.swift +51 -5
  117. package/package.json +3 -3
@@ -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';
@@ -86,6 +87,7 @@ import { handlePixooWake } from './pixoo/pixoo-client.js';
86
87
  import { triggerMdnsRecovery } from './mdns.js';
87
88
  import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
88
89
  import { enableDebugLog, debug, debugThrottled } from './logger.js';
90
+ import { LegacyRearmLedger } from './legacy-rearm-ledger.js';
89
91
  import { CodexOtelTracker, CODEX_OTEL_TRACES_PATH, spanNameSummary } from './codex-otel.js';
90
92
  import { HookCodexSessions } from './hook-codex-sessions.js';
91
93
  import { ObservedTurnWatchdogs } from './observed-turn-watchdogs.js';
@@ -96,6 +98,7 @@ import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
96
98
  import { transcriptTimelineForSession, lastAssistantTextFromTranscript, lastAssistantTextForSession, } from './session-transcript-timeline.js';
97
99
  import { kiroTimelineForSession } from './kiro-transcript-timeline.js';
98
100
  import { KiroTimelineFeed } from './kiro-timeline-feed.js';
101
+ import { OpenClawTimelineFeed, OPENCLAW_FEED_INTERVAL_MS } from './openclaw-timeline-feed.js';
99
102
  import { DeviceVoiceReplyRouter, speakableReply, spokenDigest, pcmFromWav, } from './device-voice-reply.js';
100
103
  import { rawSessionId } from '@agentdeck/shared';
101
104
  import { codexTurnOutcomeFromRollout, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
@@ -110,17 +113,18 @@ import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js
110
113
  import { loadTimeboxDevices } from './timebox/timebox-settings.js';
111
114
  import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
112
115
  import { injectOpenClawSession, OPENCLAW_SESSION_ID } from './openclaw-session.js';
113
- import { buildCardFeed, buildGlance, applyOutboxDecisions, FeedPullTracker, formatFeedPull, normalizeClientIp, parsePullTelemetry, } from './card-feed.js';
116
+ import { buildCardFeed, buildGlance, projectPortableReaderGlance, applyOutboxDecisions, FeedPullTracker, formatFeedPull, applyPullOtaBootstrap, OutboxIdempotencyLedger, normalizeClientIp, parsePullTelemetry, } from './card-feed.js';
114
117
  import { threadModule } from './card-modules.js';
115
118
  import { AutonomousPocketEngine, createAutonomousPocketModules, parsePocketAutonomyConfig, } from './pocket-autonomy.js';
116
119
  import { WeatherProvider, parseWeatherSettings } from './weather.js';
117
120
  import { CalendarProvider, parseCalendarSettings } from './calendar.js';
118
121
  import { renderGlanceFrame, GLANCE_FRAME_BOARDS } from './glance-frame.js';
119
- import { mergeRelayedSessionUsage } from './usage-event.js';
122
+ import { isPortableReaderProfile, negotiateSurface, parseHttpSurfaceIdentity, pullOtaResponseStatus, surfaceErrorBody, surfaceAllowsEvent, SurfaceProtocolError, validateSurfaceOtaIdentity, validateSurfaceQueryTuple, usesPortableReaderProjection, } from './surface-protocol.js';
123
+ import { resolveRelayedUsageEvent } from './relayed-usage.js';
120
124
  import { CARD_FEED_PATH, CARD_OUTBOX_PATH, GLANCE_FRAME_PATH } from '@agentdeck/shared';
121
125
  import { readFileSync, statSync, writeFileSync, appendFileSync } from 'fs';
122
126
  import { readFile, rm } from 'fs/promises';
123
- import { tmpdir } from 'os';
127
+ import { tmpdir, networkInterfaces } from 'os';
124
128
  import { join } from 'path';
125
129
  import { homedir } from 'os';
126
130
  import { OPENCLAW_CAPABILITIES, State, } from './types.js';
@@ -142,6 +146,7 @@ const wifiEsp32Sockets = new Map();
142
146
  * single-path dedup kept it in the registry above. */
143
147
  const wsSelfBoard = new Map();
144
148
  const wsSelfCaps = new Map();
149
+ const surfaceNegotiations = new Map();
145
150
  const otaWaiters = new Map();
146
151
  function listWifiEsp32Devices() {
147
152
  const now = Date.now();
@@ -165,6 +170,7 @@ function listWifiEsp32Devices() {
165
170
  * other observable — a battery client with an empty outbox never identifies
166
171
  * itself and never opens a socket. */
167
172
  const feedPulls = new FeedPullTracker();
173
+ const outboxIdempotency = new OutboxIdempotencyLedger();
168
174
  /** Daemon-owned personal feed. It persists aggregate bandit state only — no
169
175
  * card copy, session names, calendar titles or weather locations. */
170
176
  const pocketAutonomy = new AutonomousPocketEngine({ onError: (message) => log(`[agentdeck] ${message}`) });
@@ -176,18 +182,123 @@ const pocketReaderModules = pocketCardModules.filter((module) => module.id !== '
176
182
  /** Glance weather for the card-feed sleep dashboard. Config is read from
177
183
  * settings.json per pull (cheap; honors edits without a restart); the
178
184
  * provider caches the upstream fetch itself. */
179
- const glanceWeather = new WeatherProvider();
185
+ const glanceWeather = new WeatherProvider(fetch, (message) => log(`[agentdeck] ${message}`), join(getDataDir(), 'weather-cache-v1.json'));
180
186
  /** Glance schedule (today's ICS events) — same config/caching contract as
181
187
  * weather: settings.json `calendar: {ics}`, 30 min cache, never throws. */
182
188
  const glanceCalendar = new CalendarProvider();
183
- const stagedFwByBoard = new Map();
184
- const stagedFwStatePath = () => join(homedir(), '.agentdeck', 'staged-fw.json');
189
+ // Legacy firmware does only six GETs per awake pass, so its previous 128 KiB
190
+ // response cap made at most 768 KiB progress before sleeping. 256 KiB was
191
+ // measured to complete in 1–2 seconds on X3 and halves those reconnects. New
192
+ // cooperative clients explicitly ask for a smaller `limit` when they need a
193
+ // tighter UI-yield budget.
194
+ const PULL_OTA_SEGMENT_BYTES = 256 * 1024;
195
+ const PULL_OTA_MIN_SEGMENT_BYTES = 32 * 1024;
196
+ const PULL_OTA_MAX_SEGMENT_BYTES = 512 * 1024;
197
+ function pullOtaSegment(firmware, requestedFrom, requestedLimit) {
198
+ const from = Number.isFinite(requestedFrom) && requestedFrom > 0
199
+ ? Math.min(Math.floor(requestedFrom), firmware.length) : 0;
200
+ const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
201
+ ? Math.max(PULL_OTA_MIN_SEGMENT_BYTES, Math.min(Math.floor(requestedLimit), PULL_OTA_MAX_SEGMENT_BYTES))
202
+ : PULL_OTA_SEGMENT_BYTES;
203
+ return { from, body: firmware.subarray(from, Math.min(from + limit, firmware.length)) };
204
+ }
205
+ /** The route above keeps dual-homed hosts on the device-side Wi-Fi interface.
206
+ * Once there, hand the bounded remainder to Node directly: timer-paced 1 KiB
207
+ * writes stretched a 1.6 MB transfer past the device's 60-second read timeout.
208
+ * TCP backpressure still governs the socket; res.end does not retain another
209
+ * firmware copy because `body` is a view over the already-loaded image. */
210
+ function sendPullOtaBody(res, body) {
211
+ res.socket?.setNoDelay(true);
212
+ res.end(body);
213
+ }
214
+ function ipv4Number(address) {
215
+ const parts = address.split('.').map(Number);
216
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
217
+ return undefined;
218
+ return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
219
+ }
220
+ /** Pick the host's Wi-Fi address only when it shares the device subnet. This
221
+ * avoids the measured Ethernet→AP return-path loss on dual-homed Macs without
222
+ * changing global routes or affecting non-OTA traffic. */
223
+ function preferredPullOtaIp(remoteAddress, localAddress, interfaces = networkInterfaces()) {
224
+ const remote = ipv4Number(normalizeClientIp(remoteAddress));
225
+ const local = normalizeClientIp(localAddress);
226
+ if (remote === undefined)
227
+ return undefined;
228
+ const candidates = [];
229
+ for (const [name, entries] of Object.entries(interfaces)) {
230
+ for (const entry of entries ?? []) {
231
+ if (entry.internal || entry.family !== 'IPv4')
232
+ continue;
233
+ const address = ipv4Number(entry.address);
234
+ const mask = ipv4Number(entry.netmask);
235
+ if (address === undefined || mask === undefined || (address & mask) !== (remote & mask))
236
+ continue;
237
+ candidates.push({ name, address: entry.address });
238
+ }
239
+ }
240
+ if (candidates.length < 2)
241
+ return undefined;
242
+ candidates.sort((a, b) => {
243
+ const wireless = (name) => /^(en1|wlan)|wi-?fi/i.test(name) ? 0 : 1;
244
+ return wireless(a.name) - wireless(b.name) || a.name.localeCompare(b.name);
245
+ });
246
+ const preferred = candidates[0]?.address;
247
+ return preferred && preferred !== local ? preferred : undefined;
248
+ }
249
+ const legacyStagedFwByBoard = new Map();
250
+ const stagedFwByIdentity = new Map();
251
+ const stagedFwStatePath = () => join(getDataDir(), 'staged-fw.json');
252
+ function otaIdentityKey(identity) {
253
+ return JSON.stringify([identity.productId, identity.board, identity.updateChannel]);
254
+ }
255
+ /** Read the NUL-terminated build identity already present in Pocket images.
256
+ * This avoids inventing another manifest field and also repairs older persisted
257
+ * stages that predate firmwareVersion. */
258
+ function embeddedFirmwareVersion(firmware) {
259
+ const marker = Buffer.from('CrossPoint version: ', 'ascii');
260
+ const start = firmware.indexOf(marker);
261
+ if (start < 0)
262
+ return undefined;
263
+ const valueStart = start + marker.length;
264
+ let end = valueStart;
265
+ while (end < firmware.length && end - valueStart < 96) {
266
+ const byte = firmware[end];
267
+ if (byte === 0 || byte === 0x0a || byte === 0x0d)
268
+ break;
269
+ if (byte < 0x20 || byte > 0x7e)
270
+ return undefined;
271
+ end++;
272
+ }
273
+ if (end === valueStart || end - valueStart >= 96)
274
+ return undefined;
275
+ return firmware.subarray(valueStart, end).toString('ascii');
276
+ }
185
277
  function loadStagedFw() {
186
278
  try {
187
279
  const raw = JSON.parse(readFileSync(stagedFwStatePath(), 'utf8'));
280
+ if ('version' in raw && raw.version === 2 && Array.isArray(raw.identities)) {
281
+ for (const candidate of raw.identities) {
282
+ try {
283
+ const identity = validateSurfaceOtaIdentity(candidate);
284
+ if (typeof candidate.firmwarePath === 'string' && typeof candidate.md5 === 'string' && typeof candidate.size === 'number') {
285
+ stagedFwByIdentity.set(otaIdentityKey(identity), { ...candidate, ...identity });
286
+ }
287
+ }
288
+ catch { /* invalid/stale registration: do not widen its namespace */ }
289
+ }
290
+ for (const [board, s] of Object.entries(raw.legacy ?? {})) {
291
+ if (s && typeof s.firmwarePath === 'string' && typeof s.md5 === 'string' && typeof s.size === 'number') {
292
+ legacyStagedFwByBoard.set(board, s);
293
+ }
294
+ }
295
+ return;
296
+ }
297
+ // Version 1 was a board-keyed object. Preserve it only in the legacy
298
+ // namespace; product-aware requests must never inherit those images.
188
299
  for (const [board, s] of Object.entries(raw)) {
189
300
  if (s && typeof s.firmwarePath === 'string' && typeof s.md5 === 'string' && typeof s.size === 'number') {
190
- stagedFwByBoard.set(board, s);
301
+ legacyStagedFwByBoard.set(board, s);
191
302
  }
192
303
  }
193
304
  }
@@ -196,7 +307,12 @@ function loadStagedFw() {
196
307
  loadStagedFw();
197
308
  function saveStagedFw() {
198
309
  try {
199
- writeFileSync(stagedFwStatePath(), JSON.stringify(Object.fromEntries(stagedFwByBoard), null, 2));
310
+ const state = {
311
+ version: 2,
312
+ identities: [...stagedFwByIdentity.values()],
313
+ legacy: Object.fromEntries(legacyStagedFwByBoard),
314
+ };
315
+ writeFileSync(stagedFwStatePath(), JSON.stringify(state, null, 2));
200
316
  }
201
317
  catch (err) {
202
318
  log(`[agentdeck] staged-fw persist failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -227,9 +343,9 @@ function resolveStagedFwBoard(target) {
227
343
  const puller = feedPulls.clients().find((c) => c.client === target || c.board === target);
228
344
  return puller?.board ?? target;
229
345
  }
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).
346
+ /** Stage a build for a board. Reads the file once to fingerprint it. The Feed
347
+ * advert path re-fingerprints a rebuild at the same path before advertising,
348
+ * and the download path re-reads the bytes that are actually served.
233
349
  *
234
350
  * `pullSeen` reports whether this daemon has actually observed a feed pull
235
351
  * from the board, because "staged" proves neither transfer nor install and a
@@ -237,33 +353,68 @@ function resolveStagedFwBoard(target) {
237
353
  * image; a board that holds a live WS never asks, so its stage sits forever
238
354
  * while the CLI says it is handled. It is daemon-lifetime state, so `false`
239
355
  * means "not seen since this daemon started" — not "cannot pull". */
240
- function stageEsp32Fw(target, firmwarePath) {
356
+ function stageEsp32Fw(target, firmwarePath, offeredIdentity) {
241
357
  const board = resolveStagedFwBoard(target);
358
+ const identity = offeredIdentity ? validateSurfaceOtaIdentity(offeredIdentity) : undefined;
359
+ if (identity && identity.board !== board) {
360
+ throw new SurfaceProtocolError(409, 'surface_stage_target_mismatch', 'Stage target does not match OTA identity board');
361
+ }
242
362
  const firmware = readFileSync(firmwarePath);
243
363
  const md5 = createHash('md5').update(firmware).digest('hex');
244
- stagedFwByBoard.set(board, { firmwarePath, md5, size: firmware.length, stagedAt: Date.now() });
364
+ const staged = {
365
+ firmwarePath,
366
+ md5,
367
+ size: firmware.length,
368
+ stagedAt: Date.now(),
369
+ firmwareVersion: embeddedFirmwareVersion(firmware),
370
+ };
371
+ if (identity)
372
+ stagedFwByIdentity.set(otaIdentityKey(identity), { ...staged, ...identity });
373
+ else
374
+ legacyStagedFwByBoard.set(board, staged);
245
375
  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 };
376
+ const pullSeen = feedPulls.clients().some((c) => c.board === board
377
+ && (!identity || c.productId === identity.productId));
378
+ const namespace = identity ? `${identity.productId}/${board}/${identity.updateChannel}` : `legacy/${board}`;
379
+ 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'}`);
380
+ return { board, bytes: firmware.length, md5, pullSeen,
381
+ ...(identity ? { productId: identity.productId, updateChannel: identity.updateChannel } : {}) };
249
382
  }
250
383
  /** The staged advert for a feed response, re-validated against the file on
251
384
  * disk so a rebuilt-in-place binary never adverts a stale md5. */
252
- function stagedFwAdvert(board) {
385
+ function stagedFwAdvert(board, identity, clientVersion) {
253
386
  if (!board)
254
387
  return undefined;
255
- const staged = stagedFwByBoard.get(board);
388
+ const validatedIdentity = identity ? validateSurfaceOtaIdentity(identity) : undefined;
389
+ const staged = validatedIdentity
390
+ ? stagedFwByIdentity.get(otaIdentityKey(validatedIdentity))
391
+ : legacyStagedFwByBoard.get(board);
256
392
  if (!staged)
257
393
  return undefined;
258
394
  try {
259
395
  const firmware = readFileSync(staged.firmwarePath);
260
396
  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 });
397
+ const firmwareVersion = embeddedFirmwareVersion(firmware);
398
+ if (md5 !== staged.md5 || firmware.length !== staged.size || firmwareVersion !== staged.firmwareVersion) {
399
+ if (validatedIdentity)
400
+ stagedFwByIdentity.set(otaIdentityKey(validatedIdentity), {
401
+ ...staged, ...validatedIdentity, md5, size: firmware.length, firmwareVersion,
402
+ });
403
+ else
404
+ legacyStagedFwByBoard.set(board, { ...staged, md5, size: firmware.length, firmwareVersion });
263
405
  saveStagedFw();
264
- return { size: firmware.length, md5 };
406
+ return { size: firmware.length, md5, ...(validatedIdentity ?? {}) };
265
407
  }
266
- return { size: staged.size, md5: staged.md5 };
408
+ if (clientVersion && firmwareVersion && clientVersion === firmwareVersion) {
409
+ if (validatedIdentity)
410
+ stagedFwByIdentity.delete(otaIdentityKey(validatedIdentity));
411
+ else
412
+ legacyStagedFwByBoard.delete(board);
413
+ saveStagedFw();
414
+ log(`[agentdeck] staged firmware acknowledged by ${validatedIdentity?.productId ?? 'legacy'}/${board}: v${clientVersion}`);
415
+ return undefined;
416
+ }
417
+ return { size: staged.size, md5: staged.md5, ...(validatedIdentity ?? {}) };
267
418
  }
268
419
  catch {
269
420
  return undefined; // file gone — advert nothing rather than a dead download
@@ -335,6 +486,11 @@ function registerWifiEsp32(d, ws) {
335
486
  const resetReasonCode = typeof d.resetReasonCode === 'number' ? d.resetReasonCode : undefined;
336
487
  wifiEsp32Devices.set(key, {
337
488
  board: typeof d.board === 'string' ? d.board : 'unknown',
489
+ productId: typeof d.productId === 'string' ? d.productId : undefined,
490
+ productName: typeof d.productName === 'string' ? d.productName : undefined,
491
+ surfaceProfile: typeof d.surfaceProfile === 'string' ? d.surfaceProfile : undefined,
492
+ surfaceProtocol: typeof d.surfaceProtocol === 'number' ? d.surfaceProtocol : undefined,
493
+ updateChannel: typeof d.updateChannel === 'string' ? d.updateChannel : undefined,
338
494
  version: typeof d.version === 'string' ? d.version : undefined,
339
495
  buildHash: typeof d.buildHash === 'string' ? d.buildHash : undefined,
340
496
  buildEpoch: typeof d.buildEpoch === 'number' ? d.buildEpoch : undefined,
@@ -528,8 +684,12 @@ export const __wifiOtaTestApi = {
528
684
  resolveStagedFwBoard,
529
685
  stageEsp32Fw,
530
686
  stagedFwAdvert,
687
+ embeddedFirmwareVersion,
688
+ pullOtaSegment,
689
+ preferredPullOtaIp,
531
690
  feedPulls,
532
- clearStagedFwForTest: () => { stagedFwByBoard.clear(); },
691
+ getStagedFw: (identity) => stagedFwByIdentity.get(otaIdentityKey(identity)),
692
+ clearStagedFwForTest: () => { legacyStagedFwByBoard.clear(); stagedFwByIdentity.clear(); },
533
693
  };
534
694
  async function performWifiEsp32Ota(core, target, firmwarePath) {
535
695
  const { key, device } = findWifiOtaTarget(target);
@@ -887,6 +1047,7 @@ function buildNodeModuleHealth(startedModules) {
887
1047
  port: status.port,
888
1048
  connected: status.connected,
889
1049
  transportOpen: status.transportOpen,
1050
+ deviceInfoFresh: status.deviceInfoFresh,
890
1051
  deviceInfo: status.board ? {
891
1052
  board: status.board,
892
1053
  version: status.version,
@@ -905,6 +1066,7 @@ function buildNodeModuleHealth(startedModules) {
905
1066
  sessionCount: status.sessionCount,
906
1067
  usageFiveH: status.usageFiveH,
907
1068
  processingCount: status.processingCount,
1069
+ deviceInfoFresh: status.deviceInfoFresh,
908
1070
  } : null,
909
1071
  lastReadAt: status.lastReadAt,
910
1072
  lastWriteAt: status.lastWriteAt,
@@ -922,7 +1084,15 @@ function buildNodeModuleHealth(startedModules) {
922
1084
  return modules;
923
1085
  }
924
1086
  // ===== startDaemon =====
1087
+ /**
1088
+ * The build this daemon process started with — the answer `/health` gives for
1089
+ * as long as it runs. Captured once in `startDaemon`, never refreshed: a
1090
+ * daemon that recomputed it would start reporting the newest build on disk the
1091
+ * moment someone rebuilt, which is precisely the state a caller asks about.
1092
+ */
1093
+ let startupBuildId = null;
925
1094
  export async function startDaemon(opts) {
1095
+ startupBuildId = distBuildId();
926
1096
  if (opts.debug) {
927
1097
  enableDebugLog();
928
1098
  log('[agentdeck] Debug logging enabled');
@@ -1135,6 +1305,7 @@ export async function startDaemon(opts) {
1135
1305
  /** Produces live timeline rows for observed Kiro sessions — see the class
1136
1306
  * doc for why Kiro needs a producer when hook agents do not. */
1137
1307
  const kiroTimelineFeed = new KiroTimelineFeed();
1308
+ const openclawTimelineFeed = new OpenClawTimelineFeed();
1138
1309
  /** Kiro has no CLI hooks, so transcript rows also feed APME. These maps are
1139
1310
  * bounded by the observer's current live set and discarded on disappearance. */
1140
1311
  const kiroApmeSessions = new Set();
@@ -1358,6 +1529,12 @@ export async function startDaemon(opts) {
1358
1529
  status: 'ok', mode: 'daemon', state: snap.state,
1359
1530
  gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
1360
1531
  uptime: process.uptime(), port, pid: process.pid,
1532
+ // Which build is SERVING this port. Captured when this process started
1533
+ // (see daemon-build-identity.ts) and never recomputed, so a rebuild
1534
+ // underneath a running daemon reads as a mismatch instead of being
1535
+ // absorbed — `dist/cli.js` is overwritten in place, and nothing else
1536
+ // here distinguishes the code on disk from the code in memory.
1537
+ build: startupBuildId,
1361
1538
  pairingToken: core.authToken,
1362
1539
  // Capability: this daemon drives remote sessions down their own push
1363
1540
  // socket (session_focus_down / session_command_down / session_event_up).
@@ -1717,17 +1894,32 @@ export async function startDaemon(opts) {
1717
1894
  throw new Error('target and firmwarePath are required');
1718
1895
  }
1719
1896
  // Pull-OTA staging: remember the build; the board installs it on its
1720
- // next feed pull (no live WS needed). See stagedFwByBoard.
1897
+ // next feed pull (no live WS needed). Product-aware stages live in an
1898
+ // exact productId + board + updateChannel namespace.
1721
1899
  if (body.stage === true) {
1722
- return { ok: true, staged: true, ...stageEsp32Fw(target, firmwarePath) };
1900
+ const tupleValues = [body.productId, body.board, body.updateChannel];
1901
+ const tuplePresent = tupleValues.filter((value) => typeof value === 'string' && value.length > 0).length;
1902
+ if (tuplePresent !== 0 && tuplePresent !== 3) {
1903
+ throw new SurfaceProtocolError(400, 'surface_stage_identity_incomplete', 'productId, board, and updateChannel are required together');
1904
+ }
1905
+ if (tuplePresent === 0 && (target === 'xteink_x3' || target === 'xteink_x4')) {
1906
+ throw new SurfaceProtocolError(400, 'surface_stage_identity_required', 'XTeink pull OTA requires productId, board, and updateChannel');
1907
+ }
1908
+ const identity = tuplePresent === 3 ? {
1909
+ productId: String(body.productId), board: String(body.board), updateChannel: String(body.updateChannel),
1910
+ } : undefined;
1911
+ return { ok: true, staged: true, ...stageEsp32Fw(target, firmwarePath, identity) };
1723
1912
  }
1724
1913
  return performWifiEsp32Ota(core, target, firmwarePath);
1725
1914
  })().then((result) => {
1726
1915
  res.writeHead(200, { 'Content-Type': 'application/json' });
1727
1916
  res.end(JSON.stringify(result));
1728
1917
  }).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) }));
1918
+ const status = err instanceof SurfaceProtocolError ? err.status : 500;
1919
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1920
+ res.end(JSON.stringify(err instanceof SurfaceProtocolError
1921
+ ? { ok: false, ...surfaceErrorBody(err) }
1922
+ : { ok: false, error: err instanceof Error ? err.message : String(err) }));
1731
1923
  });
1732
1924
  return;
1733
1925
  }
@@ -1744,11 +1936,52 @@ export async function startDaemon(opts) {
1744
1936
  return;
1745
1937
  }
1746
1938
  }
1747
- const board = parsedUrl.searchParams.get('board') ?? '';
1748
- const staged = stagedFwByBoard.get(board);
1939
+ let surfaceIdentity;
1940
+ try {
1941
+ surfaceIdentity = parseHttpSurfaceIdentity(req.headers, 'ota.feed');
1942
+ if (surfaceIdentity)
1943
+ validateSurfaceQueryTuple(surfaceIdentity, parsedUrl.searchParams);
1944
+ }
1945
+ catch (err) {
1946
+ if (err instanceof SurfaceProtocolError) {
1947
+ res.writeHead(err.status, { 'Content-Type': 'application/json' });
1948
+ res.end(JSON.stringify(surfaceErrorBody(err)));
1949
+ return;
1950
+ }
1951
+ throw err;
1952
+ }
1953
+ const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
1954
+ if (preferredIp) {
1955
+ const port = req.socket.localPort ?? 9120;
1956
+ const redirectBoard = surfaceIdentity?.board ?? parsedUrl.searchParams.get('board') ?? 'unknown';
1957
+ log(`[agentdeck] pull-OTA path redirect: ${redirectBoard} (${ip}) via ${preferredIp}`);
1958
+ res.writeHead(307, {
1959
+ Location: `http://${preferredIp}:${port}${parsedUrl.pathname}${parsedUrl.search}`,
1960
+ 'Cache-Control': 'no-store',
1961
+ });
1962
+ res.end();
1963
+ return;
1964
+ }
1965
+ const board = surfaceIdentity?.board ?? parsedUrl.searchParams.get('board') ?? '';
1966
+ const hasProductQuery = parsedUrl.searchParams.has('productId') || parsedUrl.searchParams.has('updateChannel');
1967
+ if (!surfaceIdentity && hasProductQuery) {
1968
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1969
+ res.end(JSON.stringify({ error: 'surface_identity_required', message: 'Product-aware OTA requires all Surface identity headers' }));
1970
+ return;
1971
+ }
1972
+ const otaIdentity = surfaceIdentity ? {
1973
+ productId: surfaceIdentity.productId,
1974
+ board: surfaceIdentity.board,
1975
+ updateChannel: surfaceIdentity.updateChannel,
1976
+ } : undefined;
1977
+ const staged = otaIdentity
1978
+ ? stagedFwByIdentity.get(otaIdentityKey(otaIdentity))
1979
+ : legacyStagedFwByBoard.get(board);
1749
1980
  if (!staged) {
1750
1981
  res.writeHead(404, { 'Content-Type': 'application/json' });
1751
- res.end(JSON.stringify({ error: `nothing staged for board "${board}"` }));
1982
+ res.end(JSON.stringify({ error: 'firmware_not_staged', message: otaIdentity
1983
+ ? `Nothing staged for ${otaIdentity.productId}/${board}/${otaIdentity.updateChannel}`
1984
+ : `Nothing staged for legacy board "${board}"` }));
1752
1985
  return;
1753
1986
  }
1754
1987
  try {
@@ -1760,20 +1993,30 @@ export async function startDaemon(opts) {
1760
1993
  // never converge there). The device appends whatever arrives and
1761
1994
  // re-asks from where it stopped, so partial transfers accumulate.
1762
1995
  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;
1996
+ const limitRaw = parsedUrl.searchParams.has('limit')
1997
+ ? Number(parsedUrl.searchParams.get('limit')) : undefined;
1998
+ const { from, body } = pullOtaSegment(firmware, fromRaw, limitRaw);
1765
1999
  log(`[agentdeck] pull-OTA download: ${board} (${ip}) ← ${body.length} bytes`
1766
2000
  + (from > 0 ? ` (resume from ${from}/${firmware.length})` : ''));
1767
- res.writeHead(from > 0 ? 206 : 200, {
2001
+ // Early Pocket Daily builds persisted partial bodies but treated 206
2002
+ // as an error. Keep their private `?from=` resume convergent with a
2003
+ // 200 response; clients that explicitly negotiate ota.resume-206 get
2004
+ // the standards-shaped partial response. This is capability-gated,
2005
+ // not version-sniffed, so independent reader implementations have an
2006
+ // unambiguous migration path.
2007
+ const partialStatus = pullOtaResponseStatus(from, surfaceIdentity);
2008
+ res.writeHead(partialStatus, {
1768
2009
  'Content-Type': 'application/octet-stream',
1769
2010
  'Content-Length': String(body.length),
1770
2011
  ...(from > 0
1771
- ? { 'Content-Range': `bytes ${from}-${Math.max(from, firmware.length - 1)}/${firmware.length}` }
2012
+ ? { 'Content-Range': body.length > 0
2013
+ ? `bytes ${from}-${from + body.length - 1}/${firmware.length}`
2014
+ : `bytes */${firmware.length}` }
1772
2015
  : {}),
1773
2016
  'X-FW-MD5': staged.md5,
1774
2017
  'Cache-Control': 'no-store',
1775
2018
  });
1776
- res.end(body);
2019
+ sendPullOtaBody(res, body);
1777
2020
  }
1778
2021
  catch (err) {
1779
2022
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -1799,8 +2042,36 @@ export async function startDaemon(opts) {
1799
2042
  return;
1800
2043
  }
1801
2044
  }
2045
+ let surfaceIdentity;
2046
+ try {
2047
+ surfaceIdentity = parseHttpSurfaceIdentity(req.headers, 'glance.read');
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
+ if (surfaceIdentity) {
2060
+ const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
2061
+ if (preferredIp) {
2062
+ const port = req.socket.localPort ?? 9120;
2063
+ log(`[agentdeck] Surface path redirect: glance-frame ${surfaceIdentity.board} (${ip}) via ${preferredIp}`);
2064
+ res.writeHead(307, {
2065
+ Location: `http://${preferredIp}:${port}${parsedUrl.pathname}${parsedUrl.search}`,
2066
+ 'Cache-Control': 'no-store',
2067
+ 'Connection': 'close',
2068
+ });
2069
+ res.end();
2070
+ return;
2071
+ }
2072
+ }
1802
2073
  (async () => {
1803
- const board = parsedUrl.searchParams.get('board') ?? 'xteink_x3';
2074
+ const board = surfaceIdentity?.board ?? parsedUrl.searchParams.get('board') ?? 'xteink_x3';
1804
2075
  const preset = GLANCE_FRAME_BOARDS[board];
1805
2076
  const w = Number(parsedUrl.searchParams.get('w'));
1806
2077
  const h = Number(parsedUrl.searchParams.get('h'));
@@ -1854,6 +2125,39 @@ export async function startDaemon(opts) {
1854
2125
  return;
1855
2126
  }
1856
2127
  }
2128
+ let surfaceIdentity;
2129
+ try {
2130
+ surfaceIdentity = parseHttpSurfaceIdentity(req.headers, req.method === 'GET' ? 'feed.pull' : 'outbox.push');
2131
+ if (surfaceIdentity)
2132
+ validateSurfaceQueryTuple(surfaceIdentity, parsedUrl.searchParams);
2133
+ }
2134
+ catch (err) {
2135
+ if (err instanceof SurfaceProtocolError) {
2136
+ res.writeHead(err.status, { 'Content-Type': 'application/json' });
2137
+ res.end(JSON.stringify(surfaceErrorBody(err)));
2138
+ return;
2139
+ }
2140
+ throw err;
2141
+ }
2142
+ // Redirect bodyless Feed GETs only. Pocket builds predating the explicit
2143
+ // POST replay support cannot follow a 307 Outbox response and stall the
2144
+ // whole Sync before Feed/OTA. Outbox is tiny and idempotency-keyed, so
2145
+ // serving it on the accepted local interface is both compatible and
2146
+ // safe; current clients still learn/promote the Wi-Fi origin from Feed.
2147
+ if (surfaceIdentity && req.method === 'GET') {
2148
+ const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
2149
+ if (preferredIp) {
2150
+ const port = req.socket.localPort ?? 9120;
2151
+ log(`[agentdeck] Surface path redirect: ${pathname.slice(1)} ${surfaceIdentity.board} (${ip}) via ${preferredIp}`);
2152
+ res.writeHead(307, {
2153
+ Location: `http://${preferredIp}:${port}${parsedUrl.pathname}${parsedUrl.search}`,
2154
+ 'Cache-Control': 'no-store',
2155
+ 'Connection': 'close',
2156
+ });
2157
+ res.end();
2158
+ return;
2159
+ }
2160
+ }
1857
2161
  (async () => {
1858
2162
  if (req.method === 'GET') {
1859
2163
  const sessions = await core.buildSessionsSnapshot();
@@ -1869,45 +2173,68 @@ export async function startDaemon(opts) {
1869
2173
  events: await glanceCalendar.get(parseCalendarSettings(settings)),
1870
2174
  });
1871
2175
  const now = Date.now();
1872
- const pocketReader = parsedUrl.searchParams.get('surface') === 'pocket-reader';
2176
+ const pocketReader = usesPortableReaderProjection(surfaceIdentity, parsedUrl.searchParams);
2177
+ const includeWeatherOutlook = surfaceIdentity?.capabilities.includes('weather.snapshot.read') === true;
2178
+ const feedGlance = pocketReader ? projectPortableReaderGlance(glance, includeWeatherOutlook) : glance;
1873
2179
  const feed = buildCardFeed(sessions, now, pocketReader ? pocketReaderModules : pocketCardModules, {
1874
- glance,
2180
+ glance: feedGlance,
1875
2181
  echoSig: parsedUrl.searchParams.get('sig') ?? undefined,
1876
2182
  includeSessions: !pocketReader,
1877
2183
  });
1878
2184
  // Pull-OTA advert: rides full AND `unchanged` responses, so a
1879
2185
  // sleeping board learns about a staged build on any pull. Board
1880
2186
  // 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);
2187
+ const pullBoard = surfaceIdentity?.board ?? parsedUrl.searchParams.get('board') ?? boardForClientIp(ip);
2188
+ const otaIdentity = surfaceIdentity ? {
2189
+ productId: surfaceIdentity.productId,
2190
+ board: surfaceIdentity.board,
2191
+ updateChannel: surfaceIdentity.updateChannel,
2192
+ } : undefined;
2193
+ const fwAdvert = stagedFwAdvert(pullBoard ?? undefined, otaIdentity, surfaceIdentity?.clientVersion);
1885
2194
  if (fwAdvert)
1886
2195
  feed.fw = fwAdvert;
2196
+ const telemetry = parsePullTelemetry(parsedUrl.searchParams);
2197
+ if (applyPullOtaBootstrap(feed, fwAdvert !== undefined)) {
2198
+ // Do not make a weak-link X3 receive and parse a full content deck
2199
+ // before it can discover the resumable image. `unchanged` tells it
2200
+ // to retain the durable cards already on SD; after the update its
2201
+ // next ordinary pull receives the complete current deck.
2202
+ log(`[agentdeck] OTA-first Feed bootstrap: ${pullBoard ?? 'unknown'} (${ip})${telemetry.rssiDbm !== undefined ? ` rssi ${telemetry.rssiDbm}dBm` : ''}`);
2203
+ }
2204
+ if (!feed.unchanged)
2205
+ pocketAutonomy.observeDelivery(feed.cards, now, pullBoard ?? undefined);
1887
2206
  // A sleeping client's whole visit is this one request: no body, no
1888
2207
  // board id, nothing pushed. Record it, because the gap between two
1889
2208
  // pulls is the only evidence that the timer wake fired at all.
1890
- feedPulls.noteBoard(ip, boardForClientIp(ip));
2209
+ feedPulls.noteBoard(ip, pullBoard);
2210
+ if (surfaceIdentity)
2211
+ feedPulls.noteIdentity(ip, surfaceIdentity);
1891
2212
  log(`[agentdeck] ${formatFeedPull(feedPulls.record(ip, {
1892
2213
  cards: feed.cards.length,
1893
2214
  nextPullSec: feed.nextPullSec,
1894
2215
  unchanged: feed.unchanged === true,
1895
- telemetry: parsePullTelemetry(parsedUrl.searchParams),
2216
+ telemetry,
1896
2217
  }))}`);
1897
2218
  return feed;
1898
2219
  }
1899
2220
  const body = await readJsonBody(req);
1900
2221
  const board = typeof body.board === 'string' ? body.board : 'unknown';
2222
+ if (surfaceIdentity && board !== surfaceIdentity.board) {
2223
+ throw new SurfaceProtocolError(409, 'surface_outbox_board_mismatch', 'Outbox board does not match Surface identity');
2224
+ }
1901
2225
  // An outbox push is the one request that names the board — bind it to
1902
2226
  // the IP so the (anonymous) feed pulls that follow are attributable.
1903
2227
  if (board !== 'unknown')
1904
2228
  feedPulls.noteBoard(ip, board);
2229
+ if (surfaceIdentity)
2230
+ feedPulls.noteIdentity(ip, surfaceIdentity);
1905
2231
  const sessions = await core.buildSessionsSnapshot();
1906
2232
  const result = applyOutboxDecisions(body, {
1907
2233
  sessions: sessions,
1908
2234
  isPendingRequest,
1909
2235
  dispatch: (cmd) => handleDeviceCommand(cmd),
1910
2236
  modules: pocketCardModules,
2237
+ idempotency: outboxIdempotency,
1911
2238
  });
1912
2239
  const applied = result.results.filter((r) => r.status === 'applied').length;
1913
2240
  if (result.results.length > 0) {
@@ -1915,11 +2242,26 @@ export async function startDaemon(opts) {
1915
2242
  }
1916
2243
  return result;
1917
2244
  })().then((payload) => {
1918
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1919
- res.end(JSON.stringify(payload));
2245
+ const body = JSON.stringify(payload);
2246
+ // A fixed length lets esp_http_client prove completion without relying
2247
+ // on a keep-alive chunk terminator. This is especially important when
2248
+ // the daemon host has Ethernet and Wi-Fi on the same LAN: the request
2249
+ // may arrive while a later return segment is lost on the competing
2250
+ // route. Portable-reader projection above normally keeps this body in
2251
+ // one TCP payload; Content-Length makes any short read fail explicitly.
2252
+ res.writeHead(200, {
2253
+ 'Content-Type': 'application/json',
2254
+ 'Content-Length': String(Buffer.byteLength(body)),
2255
+ 'Cache-Control': 'no-store',
2256
+ 'Connection': 'close',
2257
+ });
2258
+ res.end(body);
1920
2259
  }).catch((err) => {
1921
- res.writeHead(500, { 'Content-Type': 'application/json' });
1922
- res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
2260
+ const status = err instanceof SurfaceProtocolError ? err.status : 500;
2261
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2262
+ res.end(JSON.stringify(err instanceof SurfaceProtocolError
2263
+ ? surfaceErrorBody(err)
2264
+ : { error: err instanceof Error ? err.message : String(err) }));
1923
2265
  });
1924
2266
  return;
1925
2267
  }
@@ -3026,6 +3368,15 @@ export async function startDaemon(opts) {
3026
3368
  'device_info_request',
3027
3369
  ]);
3028
3370
  core.wsServer.setEventTransformer((event, client) => {
3371
+ const surface = surfaceNegotiations.get(client);
3372
+ if (surface && isPortableReaderProfile(surface.profile)) {
3373
+ // portable-reader/v1 is pull-first. A negotiated socket gets only its
3374
+ // acknowledgement and bounded liveness/device probes; it never inherits
3375
+ // the daemon's private dashboard state burst or command plane.
3376
+ if (!surfaceAllowsEvent(surface.profile, event.type))
3377
+ return null;
3378
+ return event;
3379
+ }
3029
3380
  if (!core.wsServer.isEsp32Client(client))
3030
3381
  return event;
3031
3382
  if (!esp32WifiEvents.has(event.type))
@@ -3240,7 +3591,6 @@ export async function startDaemon(opts) {
3240
3591
  else if (evt.type === 'usage_update') {
3241
3592
  // Sync daemon cache with relay's already-adjusted values (prevents oscillation)
3242
3593
  const u = evt;
3243
- const hasClaudeData = u.fiveHourPercent != null || u.sevenDayPercent != null;
3244
3594
  if (core.cachedApiUsage && u.fiveHourPercent != null) {
3245
3595
  core.cachedApiUsage.fiveHourPercent = u.fiveHourPercent;
3246
3596
  core.cachedApiUsage.fiveHourResetsAt = u.fiveHourResetsAt ?? null;
@@ -3253,43 +3603,15 @@ export async function startDaemon(opts) {
3253
3603
  core.cachedApiUsage.scopedLimits = Array.isArray(u.scopedLimits) ? u.scopedLimits : [];
3254
3604
  core.apiUsagePreAdjusted = true;
3255
3605
  }
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
- }
3606
+ // WHICH half of the relayed event survives, and why, lives in
3607
+ // `resolveRelayedUsageEvent`. It was inlined here until issue #253: this is
3608
+ // the daemon's most flicker-sensitive path, its whole history is about not
3609
+ // clobbering the dashboard, and an inline branch had no test seam.
3610
+ core.wsServer.broadcast(resolveRelayedUsageEvent({
3611
+ relayed: u,
3612
+ ownCodexRateLimits: core.lastBuiltCodexRateLimits,
3613
+ buildOwnUsage: () => core.buildUsage(),
3614
+ }));
3293
3615
  }
3294
3616
  else {
3295
3617
  // prompt_options — relay as-is
@@ -3443,6 +3765,9 @@ export async function startDaemon(opts) {
3443
3765
  // fires once per board per token, only after the silence, and only when the
3444
3766
  // user has auto-provisioning on (it carries their WiFi credentials).
3445
3767
  const legacyRearmTimers = new Map();
3768
+ // Bounds the fallback to once per board per token. The rule and the
3769
+ // measurement that forced it live in the module.
3770
+ const legacyRearm = new LegacyRearmLedger();
3446
3771
  const cancelLegacyRearm = (portPath) => {
3447
3772
  const timer = legacyRearmTimers.get(portPath);
3448
3773
  if (!timer)
@@ -3468,11 +3793,20 @@ export async function startDaemon(opts) {
3468
3793
  if (armed.length > 0)
3469
3794
  log(`[agentdeck] Pairing token armed on ${armed.length} ESP32 device(s) after ${portPath}`);
3470
3795
  if (wifiConfig?.autoProvision) {
3796
+ const currentToken = getOrCreateToken();
3471
3797
  for (const armedPort of armed) {
3472
3798
  if (legacyRearmTimers.has(armedPort))
3473
3799
  continue;
3800
+ if (!legacyRearm.needsRearm(armedPort, currentToken))
3801
+ continue;
3474
3802
  const timer = setTimeout(() => {
3475
3803
  legacyRearmTimers.delete(armedPort);
3804
+ // Record BEFORE the send: a write that fails is still an attempt
3805
+ // this token made, and retrying it on the next device_info is the
3806
+ // loop this map exists to end. A board that stays unreachable is
3807
+ // re-armed again when the token next changes, or when the daemon
3808
+ // restarts — both of which are real events, unlike a 30s poll.
3809
+ legacyRearm.markRearmed(armedPort, currentToken);
3476
3810
  const sent = sendWifiProvision(armedPort, {
3477
3811
  type: 'wifi_provision',
3478
3812
  ssid: wifiConfig.ssid,
@@ -3491,7 +3825,11 @@ export async function startDaemon(opts) {
3491
3825
  }
3492
3826
  }
3493
3827
  else if (msg.type === 'auth_provision_ack') {
3828
+ // The board understood `auth_provision`, so the legacy WiFi re-provision
3829
+ // is not needed for it at all. Mark the current token as handled so a
3830
+ // later device_info cannot schedule one behind the cancelled timer.
3494
3831
  cancelLegacyRearm(portPath);
3832
+ legacyRearm.markUnderstood(portPath, getOrCreateToken());
3495
3833
  if (msg.changed)
3496
3834
  log(`[agentdeck] ESP32 pairing token re-armed on ${portPath}`);
3497
3835
  }
@@ -3618,6 +3956,8 @@ export async function startDaemon(opts) {
3618
3956
  catch (err) {
3619
3957
  debug('daemon', `kiro timeline feed failed: ${String(err)}`);
3620
3958
  }
3959
+ // NOTE: the OpenClaw transcript feed used to pump from here and must not —
3960
+ // see `openclawFeedTimer` below for why this callback cannot drive it.
3621
3961
  core.maybeBroadcastSessionsList();
3622
3962
  };
3623
3963
  // OTel turn boundaries arrive in milliseconds where the observer only re-reads
@@ -3720,6 +4060,44 @@ export async function startDaemon(opts) {
3720
4060
  // `connectGatewayAdapter` open + `disconnectGatewayAdapter` close paths
3721
4061
  // can reference the same id.
3722
4062
  let openclawApmeSessionId = null;
4063
+ /** Gateway session key → APME session id. One APME run per OpenClaw session
4064
+ * key, opened on that key's first event and closed when the link drops.
4065
+ *
4066
+ * Before this map the run was scoped to the Gateway CONNECTION, so a user
4067
+ * chat, every cron job and every model-eval run (a suite opens a fresh key
4068
+ * per run) shared one run, one `model_id` and one idle-gap boundary. */
4069
+ const openclawRunsBySessionKey = new Map();
4070
+ /** Open (or return) the APME run for one Gateway session key.
4071
+ *
4072
+ * `projectName` is the key itself rather than the bare literal "openclaw":
4073
+ * that literal is what made an approval raised by a model-eval harness look
4074
+ * like it came from a conversation the user could not find. */
4075
+ function openclawRunForSessionKey(sessionKey) {
4076
+ const existing = openclawRunsBySessionKey.get(sessionKey);
4077
+ if (existing)
4078
+ return existing;
4079
+ if (!apme)
4080
+ return null;
4081
+ const sessionId = `openclaw:${sessionKey}`;
4082
+ try {
4083
+ apme.collector.openRun({
4084
+ sessionId,
4085
+ agentType: 'openclaw',
4086
+ projectName: sessionKey,
4087
+ // Seed with whatever the catalog last reported. Runs open lazily on a
4088
+ // key's first event, so a run opened AFTER `model_info` fired would
4089
+ // otherwise persist `model_id=NULL` — the defect that per-run
4090
+ // `updateModel` was added to fix, reintroduced by the lazy open.
4091
+ ...(gatewayModelName ? { modelId: gatewayModelName } : {}),
4092
+ });
4093
+ }
4094
+ catch (err) {
4095
+ debug('APME', `openRun for OpenClaw ${sessionKey} failed: ${String(err)}`);
4096
+ return null;
4097
+ }
4098
+ openclawRunsBySessionKey.set(sessionKey, sessionId);
4099
+ return sessionId;
4100
+ }
3723
4101
  function connectGatewayAdapter() {
3724
4102
  if (gatewayAdapter || gatewayConnecting)
3725
4103
  return;
@@ -3739,6 +4117,7 @@ export async function startDaemon(opts) {
3739
4117
  projectName: 'openclaw',
3740
4118
  });
3741
4119
  adapter.setApmeSession(openclawApmeSessionId, process.cwd());
4120
+ adapter.setApmeRunResolver(openclawRunForSessionKey);
3742
4121
  }
3743
4122
  catch (err) {
3744
4123
  debug('APME', `openRun for OpenClaw failed: ${String(err)}`);
@@ -3787,8 +4166,19 @@ export async function startDaemon(opts) {
3787
4166
  const model = evt.data?.model;
3788
4167
  if (model) {
3789
4168
  gatewayModelName = model;
3790
- if (apme && openclawApmeSessionId)
3791
- apme.collector.updateModel(openclawApmeSessionId, model);
4169
+ // `model_info` rides the connection, not a session key — the
4170
+ // Gateway's `chat` frame reports no model, so this is the
4171
+ // catalog default rather than the model that answered any one
4172
+ // turn. Apply it to every open run as a FLOOR; a per-turn
4173
+ // `session_meta` span (which does name the model that answered)
4174
+ // overwrites it wherever one arrives.
4175
+ if (apme) {
4176
+ if (openclawApmeSessionId)
4177
+ apme.collector.updateModel(openclawApmeSessionId, model);
4178
+ for (const sessionId of openclawRunsBySessionKey.values()) {
4179
+ apme.collector.updateModel(sessionId, model);
4180
+ }
4181
+ }
3792
4182
  }
3793
4183
  }
3794
4184
  break;
@@ -3841,7 +4231,14 @@ export async function startDaemon(opts) {
3841
4231
  // still read as outstanding in the timeline. `updateEntryStatus`
3842
4232
  // existed for exactly this and had no production caller.
3843
4233
  if (enriched.type === 'tool_resolved' && enriched.approvalId) {
3844
- core.bridgeTimeline.updateEntryStatus(enriched.approvalId, enriched.status === 'approved' ? 'approved' : 'denied');
4234
+ // Carry the resolution's own status onto the request row rather
4235
+ // than collapsing everything-not-approved into `denied`: an
4236
+ // abandoned approval (run cancelled / turn ended / expired) is a
4237
+ // non-decision, and flattening it here would undo the split at
4238
+ // the one place the two rows are reconciled.
4239
+ core.bridgeTimeline.updateEntryStatus(enriched.approvalId, enriched.status === 'approved' ? 'approved'
4240
+ : enriched.status === 'abandoned' ? 'abandoned'
4241
+ : 'denied');
3845
4242
  }
3846
4243
  }
3847
4244
  break;
@@ -3909,15 +4306,30 @@ export async function startDaemon(opts) {
3909
4306
  // APME: close the OpenClaw run so the collector fires its
3910
4307
  // session_end boundary and the run becomes eligible for the layer-2
3911
4308
  // judge queue.
3912
- if (apme && openclawApmeSessionId) {
3913
- try {
3914
- apme.collector.closeRun(openclawApmeSessionId);
4309
+ if (apme) {
4310
+ // Every per-session-key run, not just the connection-scoped one. A run
4311
+ // left open is never finalized, and a task that never closes is never
4312
+ // evaluated — the failure the abandoned-run reaper exists to clean up
4313
+ // after, hours later and with `boundary_signal='orphaned'`.
4314
+ for (const sessionId of openclawRunsBySessionKey.values()) {
4315
+ try {
4316
+ apme.collector.closeRun(sessionId);
4317
+ }
4318
+ catch (err) {
4319
+ debug('APME', `closeRun for OpenClaw ${sessionId} failed: ${String(err)}`);
4320
+ }
3915
4321
  }
3916
- catch (err) {
3917
- debug('APME', `closeRun for OpenClaw failed: ${String(err)}`);
4322
+ openclawRunsBySessionKey.clear();
4323
+ if (openclawApmeSessionId) {
4324
+ try {
4325
+ apme.collector.closeRun(openclawApmeSessionId);
4326
+ }
4327
+ catch (err) {
4328
+ debug('APME', `closeRun for OpenClaw failed: ${String(err)}`);
4329
+ }
3918
4330
  }
3919
- openclawApmeSessionId = null;
3920
4331
  }
4332
+ openclawApmeSessionId = null;
3921
4333
  core.cachedGatewayConnected = false;
3922
4334
  core.cachedGatewayAuthStatus = 'gateway_not_found';
3923
4335
  core.cachedModelCatalog = null;
@@ -4009,6 +4421,28 @@ export async function startDaemon(opts) {
4009
4421
  // ===== Commands from WS clients =====
4010
4422
  // ===== Internal WS: session push channel =====
4011
4423
  core.wsServer.onRawMessage((msg, sender) => {
4424
+ if (msg.type === 'client_register' && msg.surface !== undefined) {
4425
+ try {
4426
+ const negotiation = negotiateSurface(msg.surface);
4427
+ surfaceNegotiations.set(sender, negotiation);
4428
+ core.wsServer.sendTo(sender, negotiation.welcome);
4429
+ debug('daemon', `Surface negotiated ${negotiation.profile} client=${negotiation.clientId} product=${negotiation.productId}`);
4430
+ }
4431
+ catch (err) {
4432
+ if (err instanceof SurfaceProtocolError) {
4433
+ try {
4434
+ sender.send(JSON.stringify({ type: 'surface_error', ...surfaceErrorBody(err) }));
4435
+ }
4436
+ catch { /* gone */ }
4437
+ try {
4438
+ sender.close(1008, err.code.slice(0, 120));
4439
+ }
4440
+ catch { /* gone */ }
4441
+ return true;
4442
+ }
4443
+ throw err;
4444
+ }
4445
+ }
4012
4446
  // WiFi ESP32 boards announce device_info over WS on connect. Capture the
4013
4447
  // sender here so OTA can address the exact socket instead of broadcasting.
4014
4448
  if (msg.type === 'device_info') {
@@ -5414,19 +5848,33 @@ export async function startDaemon(opts) {
5414
5848
  setSerialCommandSink((c) => handleDeviceCommand(c));
5415
5849
  // ===== Client connect =====
5416
5850
  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
- }
5851
+ // The WS class invokes this before installing its message listener. Give a
5852
+ // Surface client one short turn to send client_register; legacy clients
5853
+ // still receive the exact same initial payload, only 75 ms later.
5854
+ const initialTimer = setTimeout(() => {
5855
+ if (ws.readyState !== WebSocket.OPEN)
5856
+ return;
5857
+ const surface = surfaceNegotiations.get(ws);
5858
+ const gwAlive = gatewayAdapter?.isAlive() ?? false;
5859
+ if (surface && isPortableReaderProfile(surface.profile)) {
5860
+ core.wsServer.sendTo(ws, { type: 'connection', status: 'connected' });
5861
+ return;
5862
+ }
5863
+ core.sendInitialState(ws, {
5864
+ agentType: gwAlive ? 'openclaw' : 'daemon',
5865
+ agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
5866
+ isAlive: true,
5867
+ });
5868
+ // Fetch usage on connect if stale
5869
+ const cacheAge = Date.now() - core.lastApiFetchTime;
5870
+ if (!core.cachedApiUsage || (core.lastApiFetchTime > 0 && cacheAge > 5 * 60 * 1000)) {
5871
+ fetchUsageRelayed(port).then((result) => core.applyUsageResult(result));
5872
+ }
5873
+ }, 75);
5874
+ initialTimer.unref?.();
5428
5875
  });
5429
5876
  core.wsServer.onClientDisconnect((ws) => {
5877
+ surfaceNegotiations.delete(ws);
5430
5878
  unregisterWifiEsp32Socket(ws);
5431
5879
  });
5432
5880
  // ===== Probes & polling =====
@@ -5647,10 +6095,64 @@ export async function startDaemon(opts) {
5647
6095
  // socket can't leak and Claude's own prompt isn't lost forever.
5648
6096
  const permissionSweepTimer = setInterval(() => { sweepStalePending(60_000); }, 30_000);
5649
6097
  permissionSweepTimer.unref?.();
6098
+ // OpenClaw is Gateway-observed, and that stream carries the prompt, the final
6099
+ // response and the approvals it has to block on — never the tool calls. The
6100
+ // only tools that ever reached the strip were the ones that happened to need
6101
+ // approval, which is a biased sample rather than a log. This reads the
6102
+ // transcript OpenClaw writes anyway and emits the rest.
6103
+ //
6104
+ // **This needs a timer of its own, and that is the whole point.** The pump
6105
+ // originally hung off `passiveSessionObserver.onRefreshed`, which fires only
6106
+ // when the observed PROCESS set changes (`scan()` compares `next` against the
6107
+ // cache and calls back only on a difference). Every other transcript feed can
6108
+ // ride that edge because its subject is in that set — a Kiro turn moves its
6109
+ // own row's `state`/`lastActivityAt`, so Kiro activity wakes the Kiro feed.
6110
+ // OpenClaw is reached over the Gateway WebSocket and has NO process row at
6111
+ // all, so an OpenClaw turn moves nothing the observer can see: the feed woke
6112
+ // only when some unrelated agent happened to start or stop, and on the first
6113
+ // scan after boot (empty cache → populated, which always counts as a change).
6114
+ // Measured 2026-08-23 on a live daemon: three consecutive OpenClaw turns each
6115
+ // wrote a tool call to the store, the reader returned it, and the pump ran
6116
+ // exactly twice — both at startup — so the timeline showed zero `tool_exec`
6117
+ // rows. A producer must be driven by a signal its own subject can move.
6118
+ //
6119
+ // Gated on the SSOT presence predicate, not on the store: the rows are
6120
+ // attributed to the virtual `openclaw-gateway` session, and that row exists
6121
+ // only while the Gateway is authenticated. Emitting them otherwise would put
6122
+ // activity under a session no surface is showing.
6123
+ //
6124
+ // Cadence matches the passive observer's own `SCAN_INTERVAL_MS`, which is the
6125
+ // "seconds late, never instant" contract every transcript-observed agent
6126
+ // already carries. The pump is synchronous fs work; measured against a real
6127
+ // store it costs ~7 ms typically and ~28 ms in its worst case (the index
6128
+ // parse plus the eight largest transcripts), so the duty cycle stays well
6129
+ // under 1% of the interval it shares with the observer.
6130
+ const openclawFeedTimer = setInterval(() => {
6131
+ try {
6132
+ if (!core.cachedGatewayConnected)
6133
+ return;
6134
+ const rows = openclawTimelineFeed.pump();
6135
+ // Logged because the silent path and the broken path look identical from
6136
+ // outside: a tick that emits nothing writes nothing, so "no rows on the
6137
+ // strip" cannot be told from "the pump never ran" without this line. That
6138
+ // ambiguity is exactly what hid the `onRefreshed` coupling.
6139
+ if (rows.length > 0) {
6140
+ debug('daemon', `openclaw-feed emitting ${rows.length} row(s)`);
6141
+ }
6142
+ for (const entry of rows) {
6143
+ core.bridgeTimeline.addEntry(entry);
6144
+ }
6145
+ }
6146
+ catch (err) {
6147
+ debug('daemon', `openclaw timeline feed failed: ${String(err)}`);
6148
+ }
6149
+ }, OPENCLAW_FEED_INTERVAL_MS);
6150
+ openclawFeedTimer.unref?.();
5650
6151
  // ===== Shutdown =====
5651
6152
  core.onShutdown(async () => {
5652
6153
  clearInterval(permissionSweepTimer);
5653
6154
  clearInterval(daemonInfoHealTimer);
6155
+ clearInterval(openclawFeedTimer);
5654
6156
  drainAllPending();
5655
6157
  removeDaemonInfo();
5656
6158
  focusRelay.stop();
@@ -5677,6 +6179,9 @@ export async function startDaemon(opts) {
5677
6179
  // Trigger initial state broadcast so display hardware modules (D200H, Pixoo, Serial) get populated
5678
6180
  broadcastFocusedState();
5679
6181
  core.broadcastUsage();
5680
- log(`[agentdeck] Daemon running. Gateway probe active.`);
6182
+ // Name the build in the log, once. A daemon log that does not say which build
6183
+ // wrote it cannot answer "was this fixed before or after that restart?" —
6184
+ // and on a dev checkout the answer changes several times a day.
6185
+ log(`[agentdeck] Daemon running (build ${startupBuildId ?? 'unknown'}). Gateway probe active.`);
5681
6186
  }
5682
6187
  //# sourceMappingURL=daemon-server.js.map