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