@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.
- package/dist/adapters/openclaw.d.ts +74 -4
- package/dist/adapters/openclaw.d.ts.map +1 -1
- package/dist/adapters/openclaw.js +235 -43
- package/dist/adapters/openclaw.js.map +1 -1
- package/dist/apme/adapters/openclaw-hook.d.ts +38 -10
- package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
- package/dist/apme/adapters/openclaw-hook.js +181 -37
- package/dist/apme/adapters/openclaw-hook.js.map +1 -1
- package/dist/apme/collector.d.ts +37 -1
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +277 -35
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/pareto.d.ts +2 -1
- package/dist/apme/pareto.d.ts.map +1 -1
- package/dist/apme/pareto.js +15 -4
- package/dist/apme/pareto.js.map +1 -1
- package/dist/apme/recommend.d.ts +24 -1
- package/dist/apme/recommend.d.ts.map +1 -1
- package/dist/apme/recommend.js +43 -7
- package/dist/apme/recommend.js.map +1 -1
- package/dist/apme/store.d.ts +6 -0
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +244 -72
- package/dist/apme/store.js.map +1 -1
- package/dist/bridge-core.d.ts +10 -1
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +20 -5
- package/dist/bridge-core.js.map +1 -1
- package/dist/card-feed.d.ts +46 -1
- package/dist/card-feed.d.ts.map +1 -1
- package/dist/card-feed.js +166 -6
- package/dist/card-feed.js.map +1 -1
- package/dist/cli.d.ts +45 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +388 -27
- package/dist/cli.js.map +1 -1
- package/dist/daemon-build-identity.d.ts +124 -0
- package/dist/daemon-build-identity.d.ts.map +1 -0
- package/dist/daemon-build-identity.js +310 -0
- package/dist/daemon-build-identity.js.map +1 -0
- package/dist/daemon-server.d.ts +34 -6
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +613 -108
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon-takeover.d.ts +11 -0
- package/dist/daemon-takeover.d.ts.map +1 -1
- package/dist/daemon-takeover.js +62 -17
- package/dist/daemon-takeover.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/daemon.js.map +1 -1
- package/dist/esp32-serial.d.ts +14 -0
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +19 -1
- package/dist/esp32-serial.js.map +1 -1
- package/dist/glance-frame.d.ts.map +1 -1
- package/dist/glance-frame.js +3 -1
- package/dist/glance-frame.js.map +1 -1
- package/dist/hook-codex-sessions.d.ts +3 -1
- package/dist/hook-codex-sessions.d.ts.map +1 -1
- package/dist/hook-codex-sessions.js +27 -7
- package/dist/hook-codex-sessions.js.map +1 -1
- package/dist/legacy-rearm-ledger.d.ts +56 -0
- package/dist/legacy-rearm-ledger.d.ts.map +1 -0
- package/dist/legacy-rearm-ledger.js +66 -0
- package/dist/legacy-rearm-ledger.js.map +1 -0
- package/dist/mdns.d.ts +33 -0
- package/dist/mdns.d.ts.map +1 -1
- package/dist/mdns.js +91 -2
- package/dist/mdns.js.map +1 -1
- package/dist/openclaw-session.d.ts.map +1 -1
- package/dist/openclaw-session.js +13 -0
- package/dist/openclaw-session.js.map +1 -1
- package/dist/openclaw-timeline-feed.d.ts +84 -0
- package/dist/openclaw-timeline-feed.d.ts.map +1 -0
- package/dist/openclaw-timeline-feed.js +162 -0
- package/dist/openclaw-timeline-feed.js.map +1 -0
- package/dist/openclaw-transcript-timeline.d.ts +103 -0
- package/dist/openclaw-transcript-timeline.d.ts.map +1 -0
- package/dist/openclaw-transcript-timeline.js +328 -0
- package/dist/openclaw-transcript-timeline.js.map +1 -0
- package/dist/passive-observer.d.ts +21 -1
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +37 -3
- package/dist/passive-observer.js.map +1 -1
- package/dist/relayed-usage.d.ts +68 -0
- package/dist/relayed-usage.d.ts.map +1 -0
- package/dist/relayed-usage.js +80 -0
- package/dist/relayed-usage.js.map +1 -0
- package/dist/session-aggregator.d.ts +3 -0
- package/dist/session-aggregator.d.ts.map +1 -1
- package/dist/session-aggregator.js.map +1 -1
- package/dist/session-registry.d.ts +1 -0
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/surface-protocol.d.ts +84 -0
- package/dist/surface-protocol.d.ts.map +1 -0
- package/dist/surface-protocol.js +235 -0
- package/dist/surface-protocol.js.map +1 -0
- package/dist/timeline-store.d.ts +1 -1
- package/dist/timeline-store.d.ts.map +1 -1
- package/dist/timeline-store.js +16 -1
- package/dist/timeline-store.js.map +1 -1
- package/dist/tui/renderer.d.ts.map +1 -1
- package/dist/tui/renderer.js +35 -0
- package/dist/tui/renderer.js.map +1 -1
- package/dist/weather-config.d.ts +17 -0
- package/dist/weather-config.d.ts.map +1 -0
- package/dist/weather-config.js +54 -0
- package/dist/weather-config.js.map +1 -0
- package/dist/weather.d.ts +44 -4
- package/dist/weather.d.ts.map +1 -1
- package/dist/weather.js +223 -15
- package/dist/weather.js.map +1 -1
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +55 -5
- package/dist/ws-server.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +51 -5
- package/package.json +3 -3
package/dist/daemon-server.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
231
|
-
*
|
|
232
|
-
* re-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
1730
|
-
res.
|
|
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
|
-
|
|
1748
|
-
|
|
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:
|
|
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
|
|
1764
|
-
|
|
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
|
-
|
|
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':
|
|
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
|
|
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
|
|
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
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
1919
|
-
|
|
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
|
-
|
|
1922
|
-
res.
|
|
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
|
-
//
|
|
3257
|
-
//
|
|
3258
|
-
//
|
|
3259
|
-
//
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
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
|
-
|
|
3791
|
-
|
|
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
|
-
|
|
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
|
|
3913
|
-
|
|
3914
|
-
|
|
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
|
-
|
|
3917
|
-
|
|
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
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
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
|
|
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
|