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