@agentdeck/bridge 1.0.4 → 1.0.5
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/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +3 -0
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adb-reverse.d.ts.map +1 -1
- package/dist/adb-reverse.js +4 -2
- package/dist/adb-reverse.js.map +1 -1
- package/dist/apme/collector.js +2 -2
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/outcome.js +2 -2
- package/dist/apme/outcome.js.map +1 -1
- package/dist/apme/runner.js +3 -3
- package/dist/apme/runner.js.map +1 -1
- package/dist/ble-sync-spawn.js +1 -1
- package/dist/ble-sync-spawn.js.map +1 -1
- package/dist/calendar.d.ts +82 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +324 -0
- package/dist/calendar.js.map +1 -0
- package/dist/card-feed.d.ts +63 -5
- package/dist/card-feed.d.ts.map +1 -1
- package/dist/card-feed.js +218 -14
- package/dist/card-feed.js.map +1 -1
- package/dist/card-modules.d.ts +4 -0
- package/dist/card-modules.d.ts.map +1 -1
- package/dist/card-modules.js +5 -3
- package/dist/card-modules.js.map +1 -1
- package/dist/check-deps.js +4 -4
- package/dist/check-deps.js.map +1 -1
- package/dist/cli.d.ts +35 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +269 -9
- package/dist/cli.js.map +1 -1
- package/dist/codex-otel.d.ts +89 -0
- package/dist/codex-otel.d.ts.map +1 -0
- package/dist/codex-otel.js +487 -0
- package/dist/codex-otel.js.map +1 -0
- package/dist/codex-rate-limits.d.ts.map +1 -1
- package/dist/codex-rate-limits.js +15 -3
- package/dist/codex-rate-limits.js.map +1 -1
- package/dist/daemon-server.d.ts +4 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +797 -54
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon-ws-client.d.ts +108 -24
- package/dist/daemon-ws-client.d.ts.map +1 -1
- package/dist/daemon-ws-client.js +175 -39
- package/dist/daemon-ws-client.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/device-voice-reply.d.ts.map +1 -1
- package/dist/device-voice-reply.js +15 -3
- package/dist/device-voice-reply.js.map +1 -1
- package/dist/device-voice.d.ts +37 -0
- package/dist/device-voice.d.ts.map +1 -1
- package/dist/device-voice.js +130 -1
- package/dist/device-voice.js.map +1 -1
- package/dist/diag-analyzer.d.ts.map +1 -1
- package/dist/diag-analyzer.js +1 -0
- package/dist/diag-analyzer.js.map +1 -1
- package/dist/display-monitor.d.ts.map +1 -1
- package/dist/display-monitor.js +1 -0
- package/dist/display-monitor.js.map +1 -1
- package/dist/esp32-serial.d.ts +4 -0
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +6 -3
- package/dist/esp32-serial.js.map +1 -1
- package/dist/foundation-models-helper.d.ts +23 -0
- package/dist/foundation-models-helper.d.ts.map +1 -1
- package/dist/foundation-models-helper.js +32 -0
- package/dist/foundation-models-helper.js.map +1 -1
- package/dist/gateway-probe.js +1 -1
- package/dist/gateway-probe.js.map +1 -1
- package/dist/glance-frame.d.ts +64 -0
- package/dist/glance-frame.d.ts.map +1 -0
- package/dist/glance-frame.js +364 -0
- package/dist/glance-frame.js.map +1 -0
- package/dist/hook-codex-sessions.d.ts +40 -0
- package/dist/hook-codex-sessions.d.ts.map +1 -0
- package/dist/hook-codex-sessions.js +174 -0
- package/dist/hook-codex-sessions.js.map +1 -0
- package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -1
- package/dist/idotmatrix/idotmatrix-discover.js +7 -4
- package/dist/idotmatrix/idotmatrix-discover.js.map +1 -1
- package/dist/idotmatrix/idotmatrix-settings.d.ts +15 -0
- package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -1
- package/dist/idotmatrix/idotmatrix-settings.js +21 -0
- package/dist/idotmatrix/idotmatrix-settings.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +88 -7
- package/dist/index.js.map +1 -1
- package/dist/linux-service.d.ts +52 -0
- package/dist/linux-service.d.ts.map +1 -0
- package/dist/linux-service.js +206 -0
- package/dist/linux-service.js.map +1 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -1
- package/dist/mdns-discover.d.ts +19 -0
- package/dist/mdns-discover.d.ts.map +1 -0
- package/dist/mdns-discover.js +109 -0
- package/dist/mdns-discover.js.map +1 -0
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +1 -0
- package/dist/model-catalog.js.map +1 -1
- package/dist/modules/adb-module.js +1 -1
- package/dist/modules/adb-module.js.map +1 -1
- package/dist/output-parser.d.ts +9 -0
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +67 -16
- package/dist/output-parser.js.map +1 -1
- package/dist/passive-observer.d.ts +57 -8
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +210 -74
- package/dist/passive-observer.js.map +1 -1
- package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
- package/dist/pixoo/pixoo-bridge.js +47 -6
- package/dist/pixoo/pixoo-bridge.js.map +1 -1
- package/dist/pixoo/pixoo-renderer.d.ts +2 -1
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +137 -8
- package/dist/pixoo/pixoo-renderer.js.map +1 -1
- package/dist/pocket-autonomy.d.ts +78 -0
- package/dist/pocket-autonomy.d.ts.map +1 -0
- package/dist/pocket-autonomy.js +529 -0
- package/dist/pocket-autonomy.js.map +1 -0
- package/dist/pty-manager.d.ts.map +1 -1
- package/dist/pty-manager.js +3 -2
- package/dist/pty-manager.js.map +1 -1
- package/dist/python-ble-runtime.d.ts +2 -0
- package/dist/python-ble-runtime.d.ts.map +1 -1
- package/dist/python-ble-runtime.js +2 -1
- package/dist/python-ble-runtime.js.map +1 -1
- package/dist/remote-sessions.d.ts +64 -0
- package/dist/remote-sessions.d.ts.map +1 -0
- package/dist/remote-sessions.js +81 -0
- package/dist/remote-sessions.js.map +1 -0
- package/dist/review-runner.js +2 -2
- package/dist/review-runner.js.map +1 -1
- package/dist/session-aggregator.d.ts +2 -0
- package/dist/session-aggregator.d.ts.map +1 -1
- package/dist/session-aggregator.js +6 -1
- package/dist/session-aggregator.js.map +1 -1
- package/dist/session-focus-relay.d.ts +47 -1
- package/dist/session-focus-relay.d.ts.map +1 -1
- package/dist/session-focus-relay.js +130 -15
- package/dist/session-focus-relay.js.map +1 -1
- package/dist/session-push-channel.d.ts +31 -0
- package/dist/session-push-channel.d.ts.map +1 -0
- package/dist/session-push-channel.js +94 -0
- package/dist/session-push-channel.js.map +1 -0
- package/dist/session-registry.d.ts +126 -0
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +180 -3
- package/dist/session-registry.js.map +1 -1
- package/dist/subagent-timeline.d.ts +34 -0
- package/dist/subagent-timeline.d.ts.map +1 -0
- package/dist/subagent-timeline.js +156 -0
- package/dist/subagent-timeline.js.map +1 -0
- package/dist/timebox/timebox-discover.js +1 -1
- package/dist/timebox/timebox-discover.js.map +1 -1
- package/dist/tui/gauge.d.ts +4 -2
- package/dist/tui/gauge.d.ts.map +1 -1
- package/dist/tui/gauge.js +8 -4
- package/dist/tui/gauge.js.map +1 -1
- package/dist/tui/renderer.d.ts.map +1 -1
- package/dist/tui/renderer.js +28 -0
- package/dist/tui/renderer.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/usage-api.d.ts +43 -0
- package/dist/usage-api.d.ts.map +1 -1
- package/dist/usage-api.js +114 -9
- package/dist/usage-api.js.map +1 -1
- package/dist/usage-event.d.ts +19 -0
- package/dist/usage-event.d.ts.map +1 -1
- package/dist/usage-event.js +35 -0
- package/dist/usage-event.js.map +1 -1
- package/dist/utils/project-name.d.ts.map +1 -1
- package/dist/utils/project-name.js +1 -0
- package/dist/utils/project-name.js.map +1 -1
- package/dist/version-check.d.ts.map +1 -1
- package/dist/version-check.js +3 -2
- package/dist/version-check.js.map +1 -1
- package/dist/voice-assistant.d.ts.map +1 -1
- package/dist/voice-assistant.js +2 -1
- package/dist/voice-assistant.js.map +1 -1
- package/dist/voice.d.ts.map +1 -1
- package/dist/voice.js +1 -0
- package/dist/voice.js.map +1 -1
- package/dist/weather.d.ts +72 -0
- package/dist/weather.d.ts.map +1 -0
- package/dist/weather.js +206 -0
- package/dist/weather.js.map +1 -0
- package/dist/windows-service.js +5 -5
- package/dist/windows-service.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +200 -0
- package/package.json +15 -13
- package/src/idotmatrix/identity_generated.py +50 -0
- package/src/idotmatrix/scan.py +33 -13
package/dist/daemon-server.js
CHANGED
|
@@ -22,7 +22,9 @@ import { BridgeLogStream } from './log-stream.js';
|
|
|
22
22
|
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
|
-
import {
|
|
25
|
+
import { SubagentTimelineTracker } from './subagent-timeline.js';
|
|
26
|
+
import { getRemoteSession, getRemoteSender, listRemoteEnriched, } from './remote-sessions.js';
|
|
27
|
+
import { createPushChannelHandler } from './session-push-channel.js';
|
|
26
28
|
import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
|
|
27
29
|
import { registerPending, resolvePending, sweepStalePending, drainAllPending, isPendingRequest } from './permission-resolver.js';
|
|
28
30
|
import { shouldHoldPreToolUse, gateReleased, buildGateQuestion, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, } from './observed-steering.js';
|
|
@@ -33,7 +35,7 @@ import { setSerialCommandSink, setSerialVoiceSink, setSerialQuiesceCheck, sendSe
|
|
|
33
35
|
import { parsePeripheralMappings, resolvePeripheralAction, commandForAction } from './peripheral-mapping.js';
|
|
34
36
|
import { DeviceVoiceCollector } from './device-voice.js';
|
|
35
37
|
import { DevicePhotoCollector } from './device-photo.js';
|
|
36
|
-
import { transcribeWithHelper, synthesizeWavWithHelper } from './foundation-models-helper.js';
|
|
38
|
+
import { transcribeWithHelper, synthesizeWavWithHelper, recordWithHelper, stopHelperRecording, speakWithHelper, } from './foundation-models-helper.js';
|
|
37
39
|
import { enqueueOpenCodeCommand, pollOpenCodeCommands } from './opencode-steering.js';
|
|
38
40
|
import { runSessionReview, reviewSnapshot } from './review-runner.js';
|
|
39
41
|
/** Observed-session device approval gate (PreToolUse hold). Default ON — the
|
|
@@ -54,13 +56,15 @@ import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
|
|
|
54
56
|
import { handlePixooWake } from './pixoo/pixoo-client.js';
|
|
55
57
|
import { triggerMdnsRecovery } from './mdns.js';
|
|
56
58
|
import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
|
|
57
|
-
import { enableDebugLog, debug } from './logger.js';
|
|
59
|
+
import { enableDebugLog, debug, debugThrottled } from './logger.js';
|
|
60
|
+
import { CodexOtelTracker, CODEX_OTEL_TRACES_PATH, spanNameSummary } from './codex-otel.js';
|
|
61
|
+
import { HookCodexSessions } from './hook-codex-sessions.js';
|
|
58
62
|
import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
|
|
59
63
|
import { FallbackTaskTimeline } from './fallback-task-timeline.js';
|
|
60
64
|
import { handleApmeRequest } from './apme/http.js';
|
|
61
65
|
import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
|
|
62
66
|
import { transcriptTimelineForSession, lastAssistantTextFromTranscript, lastAssistantTextForSession, } from './session-transcript-timeline.js';
|
|
63
|
-
import { DeviceVoiceReplyRouter, speakableReply, } from './device-voice-reply.js';
|
|
67
|
+
import { DeviceVoiceReplyRouter, speakableReply, pcmFromWav, } from './device-voice-reply.js';
|
|
64
68
|
import { rawSessionId } from '@agentdeck/shared';
|
|
65
69
|
import { lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
|
|
66
70
|
import { callFoundationModelsHelper } from './foundation-models-helper.js';
|
|
@@ -72,9 +76,15 @@ import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js
|
|
|
72
76
|
import { loadTimeboxDevices } from './timebox/timebox-settings.js';
|
|
73
77
|
import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
|
|
74
78
|
import { injectOpenClawSession } from './openclaw-session.js';
|
|
75
|
-
import { buildCardFeed, applyOutboxDecisions, FeedPullTracker, formatFeedPull, normalizeClientIp, } from './card-feed.js';
|
|
76
|
-
import {
|
|
77
|
-
import {
|
|
79
|
+
import { buildCardFeed, buildGlance, applyOutboxDecisions, FeedPullTracker, formatFeedPull, normalizeClientIp, parsePullTelemetry, } from './card-feed.js';
|
|
80
|
+
import { threadModule } from './card-modules.js';
|
|
81
|
+
import { AutonomousPocketEngine, createAutonomousPocketModules, parsePocketAutonomyConfig, } from './pocket-autonomy.js';
|
|
82
|
+
import { WeatherProvider, parseWeatherSettings } from './weather.js';
|
|
83
|
+
import { CalendarProvider, parseCalendarSettings } from './calendar.js';
|
|
84
|
+
import { renderGlanceFrame, GLANCE_FRAME_BOARDS } from './glance-frame.js';
|
|
85
|
+
import { mergeRelayedSessionUsage } from './usage-event.js';
|
|
86
|
+
import { CARD_FEED_PATH, CARD_OUTBOX_PATH, GLANCE_FRAME_PATH } from '@agentdeck/shared';
|
|
87
|
+
import { readFileSync, statSync, writeFileSync, appendFileSync } from 'fs';
|
|
78
88
|
import { readFile, rm } from 'fs/promises';
|
|
79
89
|
import { tmpdir } from 'os';
|
|
80
90
|
import { join } from 'path';
|
|
@@ -121,6 +131,76 @@ function listWifiEsp32Devices() {
|
|
|
121
131
|
* other observable — a battery client with an empty outbox never identifies
|
|
122
132
|
* itself and never opens a socket. */
|
|
123
133
|
const feedPulls = new FeedPullTracker();
|
|
134
|
+
/** Daemon-owned personal feed. It persists aggregate bandit state only — no
|
|
135
|
+
* card copy, session names, calendar titles or weather locations. */
|
|
136
|
+
const pocketAutonomy = new AutonomousPocketEngine({ onError: (message) => log(`[agentdeck] ${message}`) });
|
|
137
|
+
const pocketCardModules = [threadModule, ...createAutonomousPocketModules(pocketAutonomy)];
|
|
138
|
+
// THREAD is a live AgentDeck session digest. It remains available to legacy
|
|
139
|
+
// feed consumers, while the dedicated Pocket surface receives only portable,
|
|
140
|
+
// daemon-authored reading cards.
|
|
141
|
+
const pocketReaderModules = pocketCardModules.filter((module) => module.id !== 'thread');
|
|
142
|
+
/** Glance weather for the card-feed sleep dashboard. Config is read from
|
|
143
|
+
* settings.json per pull (cheap; honors edits without a restart); the
|
|
144
|
+
* provider caches the upstream fetch itself. */
|
|
145
|
+
const glanceWeather = new WeatherProvider();
|
|
146
|
+
/** Glance schedule (today's ICS events) — same config/caching contract as
|
|
147
|
+
* weather: settings.json `calendar: {ics}`, 30 min cache, never throws. */
|
|
148
|
+
const glanceCalendar = new CalendarProvider();
|
|
149
|
+
const stagedFwByBoard = new Map();
|
|
150
|
+
const stagedFwStatePath = () => join(homedir(), '.agentdeck', 'staged-fw.json');
|
|
151
|
+
function loadStagedFw() {
|
|
152
|
+
try {
|
|
153
|
+
const raw = JSON.parse(readFileSync(stagedFwStatePath(), 'utf8'));
|
|
154
|
+
for (const [board, s] of Object.entries(raw)) {
|
|
155
|
+
if (s && typeof s.firmwarePath === 'string' && typeof s.md5 === 'string' && typeof s.size === 'number') {
|
|
156
|
+
stagedFwByBoard.set(board, s);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* no staging file — nothing staged */ }
|
|
161
|
+
}
|
|
162
|
+
loadStagedFw();
|
|
163
|
+
function saveStagedFw() {
|
|
164
|
+
try {
|
|
165
|
+
writeFileSync(stagedFwStatePath(), JSON.stringify(Object.fromEntries(stagedFwByBoard), null, 2));
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
log(`[agentdeck] staged-fw persist failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Stage a build for a board. Reads the file once to fingerprint it; the file
|
|
172
|
+
* itself is re-read at device download time (a rebuild at the same path is
|
|
173
|
+
* re-fingerprinted by re-staging). */
|
|
174
|
+
function stageEsp32Fw(board, firmwarePath) {
|
|
175
|
+
const firmware = readFileSync(firmwarePath);
|
|
176
|
+
const md5 = createHash('md5').update(firmware).digest('hex');
|
|
177
|
+
stagedFwByBoard.set(board, { firmwarePath, md5, size: firmware.length, stagedAt: Date.now() });
|
|
178
|
+
saveStagedFw();
|
|
179
|
+
log(`[agentdeck] staged firmware for ${board}: ${firmware.length} bytes md5=${md5} — installs on its next feed pull`);
|
|
180
|
+
return { board, bytes: firmware.length, md5 };
|
|
181
|
+
}
|
|
182
|
+
/** The staged advert for a feed response, re-validated against the file on
|
|
183
|
+
* disk so a rebuilt-in-place binary never adverts a stale md5. */
|
|
184
|
+
function stagedFwAdvert(board) {
|
|
185
|
+
if (!board)
|
|
186
|
+
return undefined;
|
|
187
|
+
const staged = stagedFwByBoard.get(board);
|
|
188
|
+
if (!staged)
|
|
189
|
+
return undefined;
|
|
190
|
+
try {
|
|
191
|
+
const firmware = readFileSync(staged.firmwarePath);
|
|
192
|
+
const md5 = createHash('md5').update(firmware).digest('hex');
|
|
193
|
+
if (md5 !== staged.md5 || firmware.length !== staged.size) {
|
|
194
|
+
stagedFwByBoard.set(board, { ...staged, md5, size: firmware.length });
|
|
195
|
+
saveStagedFw();
|
|
196
|
+
return { size: firmware.length, md5 };
|
|
197
|
+
}
|
|
198
|
+
return { size: staged.size, md5: staged.md5 };
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return undefined; // file gone — advert nothing rather than a dead download
|
|
202
|
+
}
|
|
203
|
+
}
|
|
124
204
|
/** Name a pulling client from the WiFi WS roster. A board that has been
|
|
125
205
|
* deep-sleeping may have aged out of that roster, which is why the tracker
|
|
126
206
|
* keeps its own IP→board memory once it has learned one. */
|
|
@@ -219,6 +299,12 @@ function registerWifiEsp32(d, ws) {
|
|
|
219
299
|
if (previous?.uptimeSec != null && uptimeSec != null && uptimeSec + 10 < previous.uptimeSec) {
|
|
220
300
|
log(`[agentdeck] ESP32 reboot observed: ${key} uptime ${previous.uptimeSec}s -> ${uptimeSec}s reset=${resetReason ?? 'unknown'} code=${resetReasonCode ?? 'unknown'}`);
|
|
221
301
|
}
|
|
302
|
+
// Plain-log a board APPEARING on WS (absent or stale before) — a sleeping
|
|
303
|
+
// battery board's brief interactive window is exactly when a WS OTA can
|
|
304
|
+
// land, and this line is the only wake signal an operator/script can tail.
|
|
305
|
+
if (!previous || Date.now() - previous.lastSeenMs > 90_000) {
|
|
306
|
+
log(`[agentdeck] ESP32 WiFi registered: ${key}`);
|
|
307
|
+
}
|
|
222
308
|
debug('daemon', `WiFi ESP32 registered: ${key} v${String(d.version ?? '')} build=${String(d.buildHash ?? '')} uptime=${String(uptimeSec ?? '')} reset=${String(resetReason ?? '')}`);
|
|
223
309
|
}
|
|
224
310
|
function touchWifiEsp32Socket(ws) {
|
|
@@ -563,6 +649,7 @@ async function fetchUsageViaWs(siblings) {
|
|
|
563
649
|
extraUsageMonthlyLimit: evt.extraUsageMonthlyLimit ?? null,
|
|
564
650
|
extraUsageUsedCredits: evt.extraUsageUsedCredits ?? null,
|
|
565
651
|
extraUsageUtilization: evt.extraUsageUtilization ?? null,
|
|
652
|
+
scopedLimits: Array.isArray(evt.scopedLimits) ? evt.scopedLimits : [],
|
|
566
653
|
inferredBillingType: null,
|
|
567
654
|
});
|
|
568
655
|
}
|
|
@@ -579,7 +666,8 @@ async function fetchUsageViaWs(siblings) {
|
|
|
579
666
|
}
|
|
580
667
|
return null;
|
|
581
668
|
}
|
|
582
|
-
|
|
669
|
+
/** @internal Exported for the relay-fallback regression test. */
|
|
670
|
+
export async function fetchUsageRelayed(selfPort) {
|
|
583
671
|
const sessions = listActiveSessions();
|
|
584
672
|
const siblings = sessions.filter(s => s.port !== selfPort && s.agentType !== 'daemon');
|
|
585
673
|
if (siblings.length > 0) {
|
|
@@ -589,8 +677,15 @@ async function fetchUsageRelayed(selfPort) {
|
|
|
589
677
|
const wsResult = await fetchUsageViaWs(siblings);
|
|
590
678
|
if (wsResult)
|
|
591
679
|
return wsResult;
|
|
592
|
-
|
|
593
|
-
|
|
680
|
+
// Relay is only an optimization to avoid multiple API pollers. A PTY session
|
|
681
|
+
// bridge only refreshes usage when it has *direct* inbound WS clients, but
|
|
682
|
+
// dashboards/Stream Deck connect to the daemon (sole entry point) — so a
|
|
683
|
+
// sibling with no direct clients never populates its /usage and the relay
|
|
684
|
+
// yields null forever, freezing the daemon's usage at its startup value.
|
|
685
|
+
// Fall back to the direct API; the shared usage-cache.json (120s TTL) dedupes
|
|
686
|
+
// so this can't cause a 429 storm even if a sibling is also actively fetching.
|
|
687
|
+
debug('daemon', 'Siblings exist but relay yielded nothing fresh — falling back to direct API (shared file cache dedupes)');
|
|
688
|
+
return fetchUsageFromApi();
|
|
594
689
|
}
|
|
595
690
|
debug('daemon', 'No siblings, using direct API');
|
|
596
691
|
return fetchUsageFromApi();
|
|
@@ -610,6 +705,11 @@ export function enrichGatewayTimelineEntry(entry) {
|
|
|
610
705
|
...entry,
|
|
611
706
|
agentType: entry.agentType ?? 'openclaw',
|
|
612
707
|
projectName: entry.projectName ?? 'OpenClaw',
|
|
708
|
+
// Devices and the voice-reply router address the gateway session as
|
|
709
|
+
// 'openclaw-gateway'; without this stamp its chat_response rows carry no
|
|
710
|
+
// sessionId, so a board armed for a spoken reply never matches the turn
|
|
711
|
+
// completion and the reply silently never plays (observed 2026-07-31).
|
|
712
|
+
sessionId: entry.sessionId ?? 'openclaw-gateway',
|
|
613
713
|
};
|
|
614
714
|
}
|
|
615
715
|
/**
|
|
@@ -638,6 +738,9 @@ export function enrichGatewayTimelineEntry(entry) {
|
|
|
638
738
|
* daemon change.
|
|
639
739
|
*/
|
|
640
740
|
export function classifyObservedHookEvent(eventName, mapped) {
|
|
741
|
+
if (eventName === 'codex_subagent_start' || eventName === 'codex_subagent_stop') {
|
|
742
|
+
return { boundary: eventName, agentType: 'codex-cli' };
|
|
743
|
+
}
|
|
641
744
|
const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
|
|
642
745
|
.exec(eventName);
|
|
643
746
|
if (!prefixed)
|
|
@@ -858,6 +961,13 @@ export async function startDaemon(opts) {
|
|
|
858
961
|
// that is provably alive (agentic turns regularly outlive any fixed age
|
|
859
962
|
// threshold). Declared before the HTTP handlers that populate it.
|
|
860
963
|
const hookSessionsSeen = new Set();
|
|
964
|
+
// Codex OTel span state. Declared before the HTTP server because the
|
|
965
|
+
// `/otel/v1/traces` handler closes over it — Codex's exporter can POST before
|
|
966
|
+
// the rest of daemon startup finishes.
|
|
967
|
+
const codexOtel = new CodexOtelTracker();
|
|
968
|
+
// Codex sessions known only from `codex_*` hooks — the backstop for when the
|
|
969
|
+
// process scan can't see one (lsof timeout, no rollout held open).
|
|
970
|
+
const hookCodexSessions = new HookCodexSessions();
|
|
861
971
|
// Declare early — HTTP /health handler references this in its closure.
|
|
862
972
|
// Must be declared before the HTTP server so it's initialized (not in TDZ)
|
|
863
973
|
// when the first /health request arrives.
|
|
@@ -937,6 +1047,9 @@ export async function startDaemon(opts) {
|
|
|
937
1047
|
* read this binding at request time rather than close over one in its TDZ.
|
|
938
1048
|
*/
|
|
939
1049
|
let speakToBoard = null;
|
|
1050
|
+
// Assigned after BridgeCore exists. The HTTP handler reads it only after
|
|
1051
|
+
// startup; nullable keeps the tiny listen→core initialization window safe.
|
|
1052
|
+
let subagentTimeline = null;
|
|
940
1053
|
// ===== HTTP server =====
|
|
941
1054
|
const httpServer = createServer((req, res) => {
|
|
942
1055
|
// APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
|
|
@@ -971,6 +1084,11 @@ export async function startDaemon(opts) {
|
|
|
971
1084
|
gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
|
|
972
1085
|
uptime: process.uptime(), port, pid: process.pid,
|
|
973
1086
|
pairingToken: core.authToken,
|
|
1087
|
+
// Capability: this daemon drives remote sessions down their own push
|
|
1088
|
+
// socket (session_focus_down / session_command_down / session_event_up).
|
|
1089
|
+
// Workers require this flag before remote-attaching; the Swift daemon
|
|
1090
|
+
// does not advertise it and is therefore never selected as a remote hub.
|
|
1091
|
+
sameSocketControl: true,
|
|
974
1092
|
modules: moduleHealthProvider(),
|
|
975
1093
|
apme: apme
|
|
976
1094
|
? {
|
|
@@ -1067,7 +1185,7 @@ export async function startDaemon(opts) {
|
|
|
1067
1185
|
// Reply on the board's own transport (serial when it is cabled), the
|
|
1068
1186
|
// same way the chunked path did — the HTTP response only confirms
|
|
1069
1187
|
// receipt of the bytes.
|
|
1070
|
-
await finishPhotoCapture(saved,
|
|
1188
|
+
await finishPhotoCapture(saved, boardResultSinkFor(board));
|
|
1071
1189
|
return { ok: true, bytes: saved.bytes, path: saved.path };
|
|
1072
1190
|
})().then((result) => {
|
|
1073
1191
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1078,6 +1196,113 @@ export async function startDaemon(opts) {
|
|
|
1078
1196
|
});
|
|
1079
1197
|
return;
|
|
1080
1198
|
}
|
|
1199
|
+
// Device voice upload — a whole utterance of raw PCM16LE as one request
|
|
1200
|
+
// body. Live streaming lost audio on both of the ips10's transports
|
|
1201
|
+
// (115200-baud serial ceiling; hosted-C6 WS stalls overflowing the DRAM
|
|
1202
|
+
// ring), and transcription only starts once the utterance is complete, so
|
|
1203
|
+
// buffering on-device and letting TCP handle delivery loses nothing.
|
|
1204
|
+
// Mirrors /esp32/photo, including replying on the board's own transport.
|
|
1205
|
+
if (req.method === 'POST' && pathname === '/esp32/voice') {
|
|
1206
|
+
const ip = req.socket.remoteAddress ?? '';
|
|
1207
|
+
if (!isLocalConnection(ip)) {
|
|
1208
|
+
const token = parsedUrl.searchParams.get('token') ?? '';
|
|
1209
|
+
if (!validateToken(token)) {
|
|
1210
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1211
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
(async () => {
|
|
1216
|
+
const chunks = [];
|
|
1217
|
+
let total = 0;
|
|
1218
|
+
for await (const chunk of req) {
|
|
1219
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1220
|
+
total += buf.length;
|
|
1221
|
+
// 16 kHz mono PCM16 for the firmware's 30 s runaway cap is 960 KB.
|
|
1222
|
+
if (total > 2 * 1024 * 1024)
|
|
1223
|
+
throw new Error('utterance_too_large');
|
|
1224
|
+
chunks.push(buf);
|
|
1225
|
+
}
|
|
1226
|
+
if (total === 0)
|
|
1227
|
+
throw new Error('empty_body');
|
|
1228
|
+
const board = parsedUrl.searchParams.get('board') ?? 'esp32';
|
|
1229
|
+
const saved = deviceVoice.saveDirect(Buffer.concat(chunks), {
|
|
1230
|
+
board,
|
|
1231
|
+
sessionId: parsedUrl.searchParams.get('sessionId') ?? '',
|
|
1232
|
+
sampleRate: Number(parsedUrl.searchParams.get('rate') ?? 0) || 16000,
|
|
1233
|
+
durationMs: Number(parsedUrl.searchParams.get('ms') ?? 0) || 0,
|
|
1234
|
+
});
|
|
1235
|
+
// The HTTP response only confirms receipt of the bytes; the
|
|
1236
|
+
// transcript/outcome goes to the board as a voice_result frame.
|
|
1237
|
+
await finishVoiceCapture(saved, boardResultSinkFor(board));
|
|
1238
|
+
return { ok: true, bytes: total };
|
|
1239
|
+
})().then((result) => {
|
|
1240
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1241
|
+
res.end(JSON.stringify(result));
|
|
1242
|
+
}).catch((err) => {
|
|
1243
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1244
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
1245
|
+
});
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
// Staged spoken reply for an `audio_http_pull` board — raw PCM16LE body.
|
|
1249
|
+
// The board fetches at its own pace (TCP flow control), which is what
|
|
1250
|
+
// keeps the hosted-C6 RX path from being burst-fed and asserting.
|
|
1251
|
+
if (req.method === 'GET' && pathname === '/esp32/voice-reply') {
|
|
1252
|
+
const ip = req.socket.remoteAddress ?? '';
|
|
1253
|
+
if (!isLocalConnection(ip)) {
|
|
1254
|
+
const token = parsedUrl.searchParams.get('token') ?? '';
|
|
1255
|
+
if (!validateToken(token)) {
|
|
1256
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1257
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const board = parsedUrl.searchParams.get('board') ?? '';
|
|
1262
|
+
const staged = board ? stagedVoiceReplies.get(board) : undefined;
|
|
1263
|
+
if (!staged) {
|
|
1264
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1265
|
+
res.end(JSON.stringify({ error: 'no_staged_reply' }));
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
// Kept until TTL rather than deleted here: an aborted download may retry.
|
|
1269
|
+
res.writeHead(200, {
|
|
1270
|
+
'Content-Type': 'application/octet-stream',
|
|
1271
|
+
'Content-Length': staged.pcm.length,
|
|
1272
|
+
'X-Sample-Rate': String(staged.sampleRate),
|
|
1273
|
+
});
|
|
1274
|
+
// Paced writes, NOT res.end(pcm): a single end() hands the whole body
|
|
1275
|
+
// to the kernel, which blasts a TCP-initial-window-sized segment burst.
|
|
1276
|
+
// TCP flow control lives in lwIP — it cannot protect the hosted C6→P4
|
|
1277
|
+
// handoff underneath it, whose RX pool exhausted and ASSERTED the board
|
|
1278
|
+
// one second into the first pull (`sdio_rx_get_buffer (*buf)`,
|
|
1279
|
+
// 2026-07-31 12:06). 2 KB per 15 ms ≈ 133 KB/s keeps at most a couple
|
|
1280
|
+
// of segments in flight while still beating playback rate 4x.
|
|
1281
|
+
{
|
|
1282
|
+
// 8 KB / 10 ms ≈ 800 KB/s — a 2 MB answer lands in ~2.5 s. The
|
|
1283
|
+
// original 2 KB / 15 ms (~133 KB/s) predated the PSRAM-mempool core
|
|
1284
|
+
// fix and made long replies take 15+ s to start, which read as "no
|
|
1285
|
+
// playback"; with the hosted mempool in PSRAM the burst constraint is
|
|
1286
|
+
// gone and only gentle pacing is kept as hygiene.
|
|
1287
|
+
const CHUNK = 8192;
|
|
1288
|
+
const GAP_MS = 10;
|
|
1289
|
+
let off = 0;
|
|
1290
|
+
const writeNext = () => {
|
|
1291
|
+
if (res.destroyed)
|
|
1292
|
+
return;
|
|
1293
|
+
if (off >= staged.pcm.length) {
|
|
1294
|
+
res.end();
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
const end = Math.min(off + CHUNK, staged.pcm.length);
|
|
1298
|
+
res.write(staged.pcm.subarray(off, end));
|
|
1299
|
+
off = end;
|
|
1300
|
+
setTimeout(writeNext, GAP_MS).unref?.();
|
|
1301
|
+
};
|
|
1302
|
+
writeNext();
|
|
1303
|
+
}
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1081
1306
|
if (req.method === 'POST' && pathname === '/esp32/ota') {
|
|
1082
1307
|
(async () => {
|
|
1083
1308
|
const body = await readJsonBody(req);
|
|
@@ -1086,6 +1311,11 @@ export async function startDaemon(opts) {
|
|
|
1086
1311
|
if (!target || !firmwarePath) {
|
|
1087
1312
|
throw new Error('target and firmwarePath are required');
|
|
1088
1313
|
}
|
|
1314
|
+
// Pull-OTA staging: remember the build; the board installs it on its
|
|
1315
|
+
// next feed pull (no live WS needed). See stagedFwByBoard.
|
|
1316
|
+
if (body.stage === true) {
|
|
1317
|
+
return { ok: true, staged: true, ...stageEsp32Fw(target, firmwarePath) };
|
|
1318
|
+
}
|
|
1089
1319
|
return performWifiEsp32Ota(core, target, firmwarePath);
|
|
1090
1320
|
})().then((result) => {
|
|
1091
1321
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1096,10 +1326,118 @@ export async function startDaemon(opts) {
|
|
|
1096
1326
|
});
|
|
1097
1327
|
return;
|
|
1098
1328
|
}
|
|
1329
|
+
// ===== Pull OTA (feed-carried) — staged firmware download =====
|
|
1330
|
+
// The board saw `fw: {size, md5}` on its feed pull and fetches the image
|
|
1331
|
+
// here. Auth mirrors the feed routes (LAN needs the pairing token).
|
|
1332
|
+
if (req.method === 'GET' && pathname === '/esp32/fw') {
|
|
1333
|
+
const ip = req.socket.remoteAddress ?? '';
|
|
1334
|
+
if (!isLocalConnection(ip)) {
|
|
1335
|
+
const token = parsedUrl.searchParams.get('token') ?? '';
|
|
1336
|
+
if (!validateToken(token)) {
|
|
1337
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1338
|
+
res.end(JSON.stringify({ error: 'Unauthorized — token required for firmware download' }));
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
const board = parsedUrl.searchParams.get('board') ?? '';
|
|
1343
|
+
const staged = stagedFwByBoard.get(board);
|
|
1344
|
+
if (!staged) {
|
|
1345
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1346
|
+
res.end(JSON.stringify({ error: `nothing staged for board "${board}"` }));
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
try {
|
|
1350
|
+
const firmware = readFileSync(staged.firmwarePath);
|
|
1351
|
+
// Resumable download: `?from=<offset>` serves the remainder. A 5 MB
|
|
1352
|
+
// image over a marginal link never survives one connection (this
|
|
1353
|
+
// bridge host's two-NICs-on-one-subnet setup black-holes full-MTU
|
|
1354
|
+
// segments in one direction, and a restart-from-zero download can
|
|
1355
|
+
// never converge there). The device appends whatever arrives and
|
|
1356
|
+
// re-asks from where it stopped, so partial transfers accumulate.
|
|
1357
|
+
const fromRaw = Number(parsedUrl.searchParams.get('from') ?? 0);
|
|
1358
|
+
const from = Number.isFinite(fromRaw) && fromRaw > 0 ? Math.min(Math.floor(fromRaw), firmware.length) : 0;
|
|
1359
|
+
const body = from > 0 ? firmware.subarray(from) : firmware;
|
|
1360
|
+
log(`[agentdeck] pull-OTA download: ${board} (${ip}) ← ${body.length} bytes`
|
|
1361
|
+
+ (from > 0 ? ` (resume from ${from}/${firmware.length})` : ''));
|
|
1362
|
+
res.writeHead(from > 0 ? 206 : 200, {
|
|
1363
|
+
'Content-Type': 'application/octet-stream',
|
|
1364
|
+
'Content-Length': String(body.length),
|
|
1365
|
+
...(from > 0
|
|
1366
|
+
? { 'Content-Range': `bytes ${from}-${Math.max(from, firmware.length - 1)}/${firmware.length}` }
|
|
1367
|
+
: {}),
|
|
1368
|
+
'X-FW-MD5': staged.md5,
|
|
1369
|
+
'Cache-Control': 'no-store',
|
|
1370
|
+
});
|
|
1371
|
+
res.end(body);
|
|
1372
|
+
}
|
|
1373
|
+
catch (err) {
|
|
1374
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1375
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
1376
|
+
}
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1099
1379
|
// ===== Card Feed pull sync (M6) — wake-sync-sleep battery clients =====
|
|
1100
1380
|
// Auth mirrors /apme: local connections are free; anything else needs the
|
|
1101
1381
|
// pairing token (?token=). Devices hold it from provisioning; /health
|
|
1102
1382
|
// exposes it for LAN pairing.
|
|
1383
|
+
// ===== Glance Frame (M8) — daemon-rendered pixels of the glance =====
|
|
1384
|
+
// The device-side glance renderer is the offline fallback; this is the
|
|
1385
|
+
// rich face. `?format=png` returns the exact dithered panel pixels, so a
|
|
1386
|
+
// browser preview IS the production frame (protocol.ts § Glance Frame).
|
|
1387
|
+
if (req.method === 'GET' && pathname === GLANCE_FRAME_PATH) {
|
|
1388
|
+
const ip = req.socket.remoteAddress ?? '';
|
|
1389
|
+
if (!isLocalConnection(ip)) {
|
|
1390
|
+
const token = parsedUrl.searchParams.get('token') ?? '';
|
|
1391
|
+
if (!validateToken(token)) {
|
|
1392
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1393
|
+
res.end(JSON.stringify({ error: 'Unauthorized — token required for glance frame' }));
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
(async () => {
|
|
1398
|
+
const board = parsedUrl.searchParams.get('board') ?? 'xteink_x3';
|
|
1399
|
+
const preset = GLANCE_FRAME_BOARDS[board];
|
|
1400
|
+
const w = Number(parsedUrl.searchParams.get('w'));
|
|
1401
|
+
const h = Number(parsedUrl.searchParams.get('h'));
|
|
1402
|
+
const geometry = Number.isFinite(w) && Number.isFinite(h) && w >= 128 && h >= 128 && w <= 1600 && h <= 1600
|
|
1403
|
+
? { width: Math.round(w), height: Math.round(h), landscape: w > h }
|
|
1404
|
+
: preset ?? GLANCE_FRAME_BOARDS.xteink_x3;
|
|
1405
|
+
const sessions = await core.buildSessionsSnapshot();
|
|
1406
|
+
const glance = buildGlance({
|
|
1407
|
+
sessions: sessions,
|
|
1408
|
+
usage: core.buildUsage(),
|
|
1409
|
+
weather: await glanceWeather.get(parseWeatherSettings(loadDaemonSettings())),
|
|
1410
|
+
events: await glanceCalendar.get(parseCalendarSettings(loadDaemonSettings())),
|
|
1411
|
+
});
|
|
1412
|
+
const d = new Date();
|
|
1413
|
+
const serverHm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
1414
|
+
const frame = await renderGlanceFrame({ glance, serverHm, geometry });
|
|
1415
|
+
const headers = {
|
|
1416
|
+
'X-Frame-Width': String(frame.width),
|
|
1417
|
+
'X-Frame-Height': String(frame.height),
|
|
1418
|
+
'X-Frame-Sig': frame.sig,
|
|
1419
|
+
'Cache-Control': 'no-store',
|
|
1420
|
+
};
|
|
1421
|
+
// Conditional fetch, one layer down from the feed's deckSig: matching
|
|
1422
|
+
// sig → 304, no body, the panel keeps holding the frame it has.
|
|
1423
|
+
if (parsedUrl.searchParams.get('sig') === frame.sig) {
|
|
1424
|
+
res.writeHead(304, headers);
|
|
1425
|
+
res.end();
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (parsedUrl.searchParams.get('format') === 'png') {
|
|
1429
|
+
res.writeHead(200, { ...headers, 'Content-Type': 'image/png' });
|
|
1430
|
+
res.end(await frame.png());
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
res.writeHead(200, { ...headers, 'Content-Type': 'application/octet-stream' });
|
|
1434
|
+
res.end(frame.packed);
|
|
1435
|
+
})().catch((err) => {
|
|
1436
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1437
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
1438
|
+
});
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1103
1441
|
if ((req.method === 'GET' && pathname === CARD_FEED_PATH)
|
|
1104
1442
|
|| (req.method === 'POST' && pathname === CARD_OUTBOX_PATH)) {
|
|
1105
1443
|
const ip = req.socket.remoteAddress ?? '';
|
|
@@ -1114,7 +1452,33 @@ export async function startDaemon(opts) {
|
|
|
1114
1452
|
(async () => {
|
|
1115
1453
|
if (req.method === 'GET') {
|
|
1116
1454
|
const sessions = await core.buildSessionsSnapshot();
|
|
1117
|
-
const
|
|
1455
|
+
const settings = loadDaemonSettings();
|
|
1456
|
+
pocketAutonomy.configure(parsePocketAutonomyConfig(settings));
|
|
1457
|
+
// Sleep-dashboard glance: provider quota + work wrap-up + weather.
|
|
1458
|
+
// Weather resolves from cache almost always (30 min window) and is
|
|
1459
|
+
// bounded by its own fetch timeout, so the pull stays fast.
|
|
1460
|
+
const glance = buildGlance({
|
|
1461
|
+
sessions: sessions,
|
|
1462
|
+
usage: core.buildUsage(),
|
|
1463
|
+
weather: await glanceWeather.get(parseWeatherSettings(settings)),
|
|
1464
|
+
events: await glanceCalendar.get(parseCalendarSettings(settings)),
|
|
1465
|
+
});
|
|
1466
|
+
const now = Date.now();
|
|
1467
|
+
const pocketReader = parsedUrl.searchParams.get('surface') === 'pocket-reader';
|
|
1468
|
+
const feed = buildCardFeed(sessions, now, pocketReader ? pocketReaderModules : pocketCardModules, {
|
|
1469
|
+
glance,
|
|
1470
|
+
echoSig: parsedUrl.searchParams.get('sig') ?? undefined,
|
|
1471
|
+
includeSessions: !pocketReader,
|
|
1472
|
+
});
|
|
1473
|
+
// Pull-OTA advert: rides full AND `unchanged` responses, so a
|
|
1474
|
+
// sleeping board learns about a staged build on any pull. Board
|
|
1475
|
+
// identity from the query (newer firmware) or the IP memory.
|
|
1476
|
+
const pullBoard = parsedUrl.searchParams.get('board') ?? boardForClientIp(ip);
|
|
1477
|
+
if (!feed.unchanged)
|
|
1478
|
+
pocketAutonomy.observeDelivery(feed.cards, now, pullBoard ?? undefined);
|
|
1479
|
+
const fwAdvert = stagedFwAdvert(pullBoard ?? undefined);
|
|
1480
|
+
if (fwAdvert)
|
|
1481
|
+
feed.fw = fwAdvert;
|
|
1118
1482
|
// A sleeping client's whole visit is this one request: no body, no
|
|
1119
1483
|
// board id, nothing pushed. Record it, because the gap between two
|
|
1120
1484
|
// pulls is the only evidence that the timer wake fired at all.
|
|
@@ -1122,6 +1486,8 @@ export async function startDaemon(opts) {
|
|
|
1122
1486
|
log(`[agentdeck] ${formatFeedPull(feedPulls.record(ip, {
|
|
1123
1487
|
cards: feed.cards.length,
|
|
1124
1488
|
nextPullSec: feed.nextPullSec,
|
|
1489
|
+
unchanged: feed.unchanged === true,
|
|
1490
|
+
telemetry: parsePullTelemetry(parsedUrl.searchParams),
|
|
1125
1491
|
}))}`);
|
|
1126
1492
|
return feed;
|
|
1127
1493
|
}
|
|
@@ -1136,6 +1502,7 @@ export async function startDaemon(opts) {
|
|
|
1136
1502
|
sessions: sessions,
|
|
1137
1503
|
isPendingRequest,
|
|
1138
1504
|
dispatch: (cmd) => handleDeviceCommand(cmd),
|
|
1505
|
+
modules: pocketCardModules,
|
|
1139
1506
|
});
|
|
1140
1507
|
const applied = result.results.filter((r) => r.status === 'applied').length;
|
|
1141
1508
|
if (result.results.length > 0) {
|
|
@@ -1175,6 +1542,12 @@ export async function startDaemon(opts) {
|
|
|
1175
1542
|
billingType: snap.billingType,
|
|
1176
1543
|
},
|
|
1177
1544
|
wsClients: core.wsServer.getClientCount(),
|
|
1545
|
+
// Codex observation backstops — which sessions each source believes in.
|
|
1546
|
+
// A session present here but absent from sessions_list means the ps
|
|
1547
|
+
// observer found it too and won, which is the expected steady state.
|
|
1548
|
+
hookCodexSessions: hookCodexSessions.snapshot(),
|
|
1549
|
+
codexOtelThreads: codexOtel.snapshot(),
|
|
1550
|
+
pocketAutonomy: pocketAutonomy.diagnostics(),
|
|
1178
1551
|
recentJournal: [],
|
|
1179
1552
|
ptyTail: '',
|
|
1180
1553
|
journalDir: join(homedir(), '.agentdeck'),
|
|
@@ -1272,6 +1645,41 @@ export async function startDaemon(opts) {
|
|
|
1272
1645
|
// codex/opencode *state* is owned by the passive observer's turn
|
|
1273
1646
|
// semantics, not these hooks.
|
|
1274
1647
|
const { boundary, agentType: hookAgentType } = classifyObservedHookEvent(eventName, mapped);
|
|
1648
|
+
const earlyHookSid = typeof json.session_id === 'string' && json.session_id
|
|
1649
|
+
? json.session_id : 'daemon-hook';
|
|
1650
|
+
const earlyHookCwd = (typeof json.cwd === 'string' ? json.cwd
|
|
1651
|
+
: (typeof json.project_path === 'string' ? json.project_path : '')) || '';
|
|
1652
|
+
const earlyHookProject = (typeof json.project_name === 'string' && json.project_name)
|
|
1653
|
+
? json.project_name
|
|
1654
|
+
: (earlyHookCwd ? earlyHookCwd.split('/').filter(Boolean).pop() : undefined);
|
|
1655
|
+
const childHook = subagentTimeline?.handle({
|
|
1656
|
+
eventName,
|
|
1657
|
+
payload: json,
|
|
1658
|
+
sessionId: earlyHookSid,
|
|
1659
|
+
agentType: hookAgentType,
|
|
1660
|
+
projectName: earlyHookProject,
|
|
1661
|
+
}).childOnly === true;
|
|
1662
|
+
if (childHook) {
|
|
1663
|
+
// Child activity is observation-only. Never let child PreToolUse
|
|
1664
|
+
// reach the parent's approval, STOP, state, or APME paths.
|
|
1665
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1666
|
+
res.end(eventName === 'PreToolUse' || eventName === 'Stop'
|
|
1667
|
+
? ''
|
|
1668
|
+
: JSON.stringify({ received: true }));
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
// Hook-derived Codex session rows. Placed after the child-hook return so
|
|
1672
|
+
// subagent lifecycle never drives the parent row, and kept independent
|
|
1673
|
+
// of the state machine: this only decides whether a Codex the process
|
|
1674
|
+
// scan can't see still has a session. See bridge/src/hook-codex-sessions.ts.
|
|
1675
|
+
if (eventName.startsWith('codex_')) {
|
|
1676
|
+
hookCodexSessions.note(eventName, {
|
|
1677
|
+
sessionId: typeof json.session_id === 'string' ? json.session_id : undefined,
|
|
1678
|
+
cwd: earlyHookCwd || undefined,
|
|
1679
|
+
projectName: earlyHookProject,
|
|
1680
|
+
toolName: typeof json.tool_name === 'string' ? json.tool_name : undefined,
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1275
1683
|
// State machine
|
|
1276
1684
|
if (mapped === 'session_start')
|
|
1277
1685
|
core.stateMachine.handleHookEvent('SessionStart', json);
|
|
@@ -1724,6 +2132,42 @@ export async function startDaemon(opts) {
|
|
|
1724
2132
|
});
|
|
1725
2133
|
return;
|
|
1726
2134
|
}
|
|
2135
|
+
// Codex OTel exporter target. Codex (CLI and the ChatGPT.app desktop app)
|
|
2136
|
+
// POSTs OTLP/HTTP JSON spans here when `[otel.trace_exporter.otlp-http]`
|
|
2137
|
+
// points at the daemon. Only the Swift daemon used to implement this, so a
|
|
2138
|
+
// Node-daemon host answered 404 and dropped every span. Always 200: an OTel
|
|
2139
|
+
// exporter that sees errors backs off, and nothing here is worth telling
|
|
2140
|
+
// Codex about. See bridge/src/codex-otel.ts.
|
|
2141
|
+
if (req.method === 'POST' && (pathname === CODEX_OTEL_TRACES_PATH || pathname === '/v1/traces')) {
|
|
2142
|
+
let body = '';
|
|
2143
|
+
req.on('data', (c) => { body += c; if (body.length > 8_000_000)
|
|
2144
|
+
req.destroy(); });
|
|
2145
|
+
req.on('end', () => {
|
|
2146
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2147
|
+
res.end('{}');
|
|
2148
|
+
try {
|
|
2149
|
+
const json = body ? JSON.parse(body) : {};
|
|
2150
|
+
// Dev capture. Codex's span vocabulary is not a stable API, so when it
|
|
2151
|
+
// shifts the only way to re-derive the dispatch table is to look at
|
|
2152
|
+
// real batches: `AGENTDECK_CODEX_OTEL_DUMP=/tmp/otel.jsonl agentdeck
|
|
2153
|
+
// daemon start`. Bodies are pretty-printed and large — point it at a
|
|
2154
|
+
// scratch path and delete it afterwards.
|
|
2155
|
+
if (process.env.AGENTDECK_CODEX_OTEL_DUMP) {
|
|
2156
|
+
try {
|
|
2157
|
+
appendFileSync(process.env.AGENTDECK_CODEX_OTEL_DUMP, `${body}\n`);
|
|
2158
|
+
}
|
|
2159
|
+
catch { /* dev only */ }
|
|
2160
|
+
}
|
|
2161
|
+
if (codexOtel.ingest(json) === 0) {
|
|
2162
|
+
debugThrottled('codex-otel', 'no-recognized-spans', 60_000, `no recognized spans; len=${body.length} names=${spanNameSummary(json)}`);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
catch {
|
|
2166
|
+
debugThrottled('codex-otel', 'unparsable-body', 60_000, `unparsable body len=${body.length}`);
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
1727
2171
|
// OpenCode observer-plugin steering: the plugin long-polls here and
|
|
1728
2172
|
// executes returned commands via its in-process SDK client (abort /
|
|
1729
2173
|
// prompt). See bridge/src/opencode-steering.ts.
|
|
@@ -1955,6 +2399,9 @@ export async function startDaemon(opts) {
|
|
|
1955
2399
|
httpServer,
|
|
1956
2400
|
isDaemon: true,
|
|
1957
2401
|
});
|
|
2402
|
+
subagentTimeline = new SubagentTimelineTracker((entry) => {
|
|
2403
|
+
core.bridgeTimeline.addEntry(entry, { bypassSuppression: true });
|
|
2404
|
+
});
|
|
1958
2405
|
// Hooks resolve a fallback daemon port through daemon.json. Another process
|
|
1959
2406
|
// can delete that file while this daemon remains healthy, which makes every
|
|
1960
2407
|
// standalone Claude/Codex hook silently fall back to an empty :9120. Mirror
|
|
@@ -2097,6 +2544,12 @@ export async function startDaemon(opts) {
|
|
|
2097
2544
|
timelineRelay.start();
|
|
2098
2545
|
// Session focus relay — allows SD plugin to interact with a specific session via daemon
|
|
2099
2546
|
const focusRelay = new SessionFocusRelay();
|
|
2547
|
+
// Remote (cross-machine) sessions aren't in sessions.json. Their only
|
|
2548
|
+
// reverse path is same-socket: drive the session down the live push socket
|
|
2549
|
+
// it opened — no inbound dial, so NAT'd / SSH-only workers just work.
|
|
2550
|
+
focusRelay.setSameSocketResolver((sessionId) => getRemoteSender(sessionId) ?? null);
|
|
2551
|
+
// Push-channel frames from session bridges (register / state / reverse events).
|
|
2552
|
+
const handlePushChannelMessage = createPushChannelHandler({ core, focusRelay });
|
|
2100
2553
|
focusRelay.setEventHandler((evt) => {
|
|
2101
2554
|
if (evt.type === 'state_update') {
|
|
2102
2555
|
const focusedId = focusRelay.getFocusedSessionId();
|
|
@@ -2121,14 +2574,43 @@ export async function startDaemon(opts) {
|
|
|
2121
2574
|
else if (evt.type === 'usage_update') {
|
|
2122
2575
|
// Sync daemon cache with relay's already-adjusted values (prevents oscillation)
|
|
2123
2576
|
const u = evt;
|
|
2577
|
+
const hasClaudeData = u.fiveHourPercent != null || u.sevenDayPercent != null;
|
|
2124
2578
|
if (core.cachedApiUsage && u.fiveHourPercent != null) {
|
|
2125
2579
|
core.cachedApiUsage.fiveHourPercent = u.fiveHourPercent;
|
|
2126
2580
|
core.cachedApiUsage.fiveHourResetsAt = u.fiveHourResetsAt ?? null;
|
|
2127
2581
|
core.cachedApiUsage.sevenDayPercent = u.sevenDayPercent ?? core.cachedApiUsage.sevenDayPercent;
|
|
2128
2582
|
core.cachedApiUsage.sevenDayResetsAt = u.sevenDayResetsAt ?? core.cachedApiUsage.sevenDayResetsAt ?? null;
|
|
2583
|
+
// Scoped caps ride the same relay snapshot. The Swift daemon syncs them on
|
|
2584
|
+
// its equivalent live-update path; leaving them out here made the daemon's
|
|
2585
|
+
// own next broadcastUsage re-emit a stale scoped set until the next full
|
|
2586
|
+
// fetch — the exact drift DaemonServer.swift documents.
|
|
2587
|
+
core.cachedApiUsage.scopedLimits = Array.isArray(u.scopedLimits) ? u.scopedLimits : [];
|
|
2129
2588
|
core.apiUsagePreAdjusted = true;
|
|
2130
2589
|
}
|
|
2131
|
-
|
|
2590
|
+
// A session bridge broadcasts usage on EVERY state change. In daemon-first
|
|
2591
|
+
// mode its snapshot usually has no model context, so the Claude quota model
|
|
2592
|
+
// gate (isClaudeSubscriptionModel) yields no percents and the event carries
|
|
2593
|
+
// usageStale=true. Forwarding that bare event verbatim clobbered the daemon's
|
|
2594
|
+
// own authoritative aggregate on the dashboard: the Claude gauge blanked to
|
|
2595
|
+
// "No usage data" on every state tick (the Codex gauge, which ignores global
|
|
2596
|
+
// staleness, stayed put — hence the Claude-only flicker).
|
|
2597
|
+
//
|
|
2598
|
+
// Suppressing the relay instead is NOT an option: usage_update is the ONLY
|
|
2599
|
+
// carrier of per-session tokens / cost / duration (state_update has none of
|
|
2600
|
+
// those fields), and the daemon's own buildUsage() reads a UsageTracker that
|
|
2601
|
+
// no PTY ever feeds, so its session half is zeros. Dropping the event would
|
|
2602
|
+
// blank the session numbers for every focus that legitimately carries no
|
|
2603
|
+
// Claude quota — Codex, OpenCode, API billing.
|
|
2604
|
+
//
|
|
2605
|
+
// So split the event by half instead of by event: the ACCOUNT half (quota,
|
|
2606
|
+
// scoped caps, extra usage, subscriptions, Codex/Ollama, usageStale) comes
|
|
2607
|
+
// from the daemon's authoritative aggregate, the SESSION half from the relay.
|
|
2608
|
+
if (hasClaudeData) {
|
|
2609
|
+
core.wsServer.broadcast(evt);
|
|
2610
|
+
}
|
|
2611
|
+
else {
|
|
2612
|
+
core.wsServer.broadcast(mergeRelayedSessionUsage(core.buildUsage(), u));
|
|
2613
|
+
}
|
|
2132
2614
|
}
|
|
2133
2615
|
else {
|
|
2134
2616
|
// prompt_options — relay as-is
|
|
@@ -2333,6 +2815,11 @@ export async function startDaemon(opts) {
|
|
|
2333
2815
|
// immediately). When a scan lands fresh observations, push them out via
|
|
2334
2816
|
// the debounced broadcast so clients don't wait for the next 10 s poll.
|
|
2335
2817
|
passiveSessionObserver.onRefreshed = () => core.maybeBroadcastSessionsList();
|
|
2818
|
+
// OTel turn boundaries arrive in milliseconds where the observer only re-reads
|
|
2819
|
+
// the rollout tail every scan interval, so a span that changed thread state is
|
|
2820
|
+
// worth a broadcast of its own.
|
|
2821
|
+
codexOtel.onChanged = () => core.maybeBroadcastSessionsList();
|
|
2822
|
+
hookCodexSessions.onChanged = () => core.maybeBroadcastSessionsList();
|
|
2336
2823
|
// ===== Gateway adapter lifecycle =====
|
|
2337
2824
|
// (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
|
|
2338
2825
|
// Inject OpenClaw virtual session only after Gateway authentication succeeds.
|
|
@@ -2344,7 +2831,11 @@ export async function startDaemon(opts) {
|
|
|
2344
2831
|
// the 5s-throttled observer, so a Notification arriving mid-window still
|
|
2345
2832
|
// surfaces within one frame. Key = the Claude session UUID embedded in
|
|
2346
2833
|
// `observed:claude:<uuid>`.
|
|
2347
|
-
|
|
2834
|
+
// Codex OTel spans overlay state/tool onto the matching ps-observed row and
|
|
2835
|
+
// synthesize a `codex-app` row for any thread the process scan missed —
|
|
2836
|
+
// Swift's only Codex-app source, here a second opinion on top of the
|
|
2837
|
+
// observer. See bridge/src/codex-otel.ts.
|
|
2838
|
+
const observed = applyAwaitingOverlayToObserved(hookCodexSessions.applyTo(codexOtel.applyTo(passiveSessionObserver.collect(sessions))))
|
|
2348
2839
|
.map((s) => {
|
|
2349
2840
|
// Steering feedback for observed Claude sessions: devices render
|
|
2350
2841
|
// "stopping at next tool" / queued-directive badges from these.
|
|
@@ -2363,10 +2854,15 @@ export async function startDaemon(opts) {
|
|
|
2363
2854
|
...(snap.queuedDirectives > 0 ? { queuedDirectives: snap.queuedDirectives } : {}),
|
|
2364
2855
|
};
|
|
2365
2856
|
});
|
|
2857
|
+
// Sessions that attached from another machine over the push channel. They
|
|
2858
|
+
// aren't in this daemon's sessions.json, so inject them here (dedup against
|
|
2859
|
+
// any id already present from the local registry).
|
|
2860
|
+
const localIds = new Set(sessions.map((s) => s.id));
|
|
2861
|
+
const remote = listRemoteEnriched().filter((s) => !localIds.has(s.id));
|
|
2366
2862
|
// Derive per-session elapsed seconds from startedAt so NTP-less devices
|
|
2367
2863
|
// (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
|
|
2368
2864
|
const now = Date.now();
|
|
2369
|
-
const enrichedSessions = [...sessions, ...observed].map((s) => {
|
|
2865
|
+
const enrichedSessions = [...sessions, ...observed, ...remote].map((s) => {
|
|
2370
2866
|
// On-demand review badge (REVIEW tile verdict / REVIEWING state) —
|
|
2371
2867
|
// applies to every session type, managed included.
|
|
2372
2868
|
const review = reviewSnapshot(s.id);
|
|
@@ -2736,24 +3232,10 @@ export async function startDaemon(opts) {
|
|
|
2736
3232
|
if (handleEsp32OtaReply(msg)) {
|
|
2737
3233
|
return true;
|
|
2738
3234
|
}
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
try {
|
|
2744
|
-
sender.send(JSON.stringify({ type: 'session_push_ack', sessionId }));
|
|
2745
|
-
}
|
|
2746
|
-
catch { /* client disconnecting */ }
|
|
2747
|
-
return true; // consumed
|
|
2748
|
-
}
|
|
2749
|
-
if (msg.type === 'session_push_state') {
|
|
2750
|
-
const { sessionId, state, modelName, effortLevel, permissionMode } = msg;
|
|
2751
|
-
if (sessionId && state) {
|
|
2752
|
-
updatePushState(sessionId, state, modelName, effortLevel, permissionMode);
|
|
2753
|
-
// Trigger sessions list broadcast so clients get fresh state
|
|
2754
|
-
core.maybeBroadcastSessionsList();
|
|
2755
|
-
}
|
|
2756
|
-
return true; // consumed
|
|
3235
|
+
// Push-channel frames (session_push_register / session_push_state /
|
|
3236
|
+
// session_event_up) — extracted handler, integration-tested directly.
|
|
3237
|
+
if (handlePushChannelMessage(msg, sender)) {
|
|
3238
|
+
return true;
|
|
2757
3239
|
}
|
|
2758
3240
|
if (msg.type === 'deck_slot_map') {
|
|
2759
3241
|
// Plugin pushed its keypad layout. Forward to other viewers (extra
|
|
@@ -2770,8 +3252,24 @@ export async function startDaemon(opts) {
|
|
|
2770
3252
|
}
|
|
2771
3253
|
if (msg.type === 'voice_end') {
|
|
2772
3254
|
const captured = deviceVoice.end(sender, msg);
|
|
2773
|
-
if (captured)
|
|
3255
|
+
if (captured) {
|
|
2774
3256
|
void finishVoiceCapture(captured, stableSink(sender));
|
|
3257
|
+
}
|
|
3258
|
+
else if (msg.cancel !== true) {
|
|
3259
|
+
// Nothing was captured but the user did hold the button — answer
|
|
3260
|
+
// anyway, or the board's "Sending…" state has nothing to resolve it.
|
|
3261
|
+
try {
|
|
3262
|
+
sender.send(JSON.stringify({ type: 'voice_result', text: '', error: 'empty_capture' }));
|
|
3263
|
+
}
|
|
3264
|
+
catch { /* client disconnecting */ }
|
|
3265
|
+
}
|
|
3266
|
+
return true; // consumed
|
|
3267
|
+
}
|
|
3268
|
+
if (msg.type === 'voice_abort') {
|
|
3269
|
+
// The board tried to upload an utterance over HTTP and failed; the
|
|
3270
|
+
// reason would otherwise only exist on its serial console.
|
|
3271
|
+
log(`[agentdeck] voice: device upload aborted (${String(msg.reason ?? 'unknown')}, `
|
|
3272
|
+
+ `${String(msg.total ?? '?')} bytes)`);
|
|
2775
3273
|
return true; // consumed
|
|
2776
3274
|
}
|
|
2777
3275
|
if (msg.type === 'photo_begin') {
|
|
@@ -2895,6 +3393,13 @@ export async function startDaemon(opts) {
|
|
|
2895
3393
|
}
|
|
2896
3394
|
const handleDeviceCommand = (cmd) => {
|
|
2897
3395
|
debug('daemon', `cmd: ${cmd.type}`);
|
|
3396
|
+
// Host push-to-talk is the daemon's own capability (host mic + speakers),
|
|
3397
|
+
// so claim it ahead of the gateway-first-dibs routing below — it must not
|
|
3398
|
+
// depend on what any adapter chooses to do with the legacy `voice` shape.
|
|
3399
|
+
if (cmd.type === 'voice') {
|
|
3400
|
+
handleHostVoicePtt(cmd);
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
2898
3403
|
// Session-scoped commands are NEVER OpenClaw's to consume: a device
|
|
2899
3404
|
// answering a named session (select_option{sessionId}) must reach the
|
|
2900
3405
|
// session routing below — the gateway swallowing it as an exec-approval
|
|
@@ -3049,6 +3554,16 @@ export async function startDaemon(opts) {
|
|
|
3049
3554
|
if (!rawSessionId || !command)
|
|
3050
3555
|
return;
|
|
3051
3556
|
const sessionId = resolveDeviceSessionId(rawSessionId);
|
|
3557
|
+
// The gateway session has no bridge port — the focus-relay path below
|
|
3558
|
+
// would connect to nothing and silently drop the command. Hand it to
|
|
3559
|
+
// the adapter, the same way the wake-word assistant does.
|
|
3560
|
+
if (sessionId === 'openclaw-gateway') {
|
|
3561
|
+
if (gatewayAdapter?.isAlive())
|
|
3562
|
+
gatewayAdapter.handleCommand(command);
|
|
3563
|
+
else
|
|
3564
|
+
debug('daemon', 'session_command for openclaw-gateway but gateway not alive');
|
|
3565
|
+
return;
|
|
3566
|
+
}
|
|
3052
3567
|
// Observed Claude sessions have no bridge/PTY — route to the hook-based
|
|
3053
3568
|
// steering primitives instead (soft STOP, turn-end directive queue,
|
|
3054
3569
|
// gate approval). This replaces the old silent drop.
|
|
@@ -3070,7 +3585,7 @@ export async function startDaemon(opts) {
|
|
|
3070
3585
|
return;
|
|
3071
3586
|
}
|
|
3072
3587
|
const sessions = listActiveSessions();
|
|
3073
|
-
const target = sessions.find(s => s.id === sessionId);
|
|
3588
|
+
const target = sessions.find(s => s.id === sessionId) ?? getRemoteSession(sessionId);
|
|
3074
3589
|
if (!target) {
|
|
3075
3590
|
debug('daemon', `session_command: session ${sessionId} not found`);
|
|
3076
3591
|
return;
|
|
@@ -3096,7 +3611,7 @@ export async function startDaemon(opts) {
|
|
|
3096
3611
|
handleObservedClaudeCommand(sessionId.slice('observed:claude:'.length), cmd);
|
|
3097
3612
|
return;
|
|
3098
3613
|
}
|
|
3099
|
-
const target = listActiveSessions().find(s => s.id === sessionId);
|
|
3614
|
+
const target = listActiveSessions().find(s => s.id === sessionId) ?? getRemoteSession(sessionId);
|
|
3100
3615
|
if (target) {
|
|
3101
3616
|
userFocusedSessionId = sessionId;
|
|
3102
3617
|
broadcastFocusedState();
|
|
@@ -3180,20 +3695,46 @@ export async function startDaemon(opts) {
|
|
|
3180
3695
|
* drops the WebSocket it dictated over, and the answer must still arrive.
|
|
3181
3696
|
* Serial first, mirroring the single-path dedup policy used elsewhere.
|
|
3182
3697
|
*/
|
|
3698
|
+
/**
|
|
3699
|
+
* Replies staged for `audio_http_pull` boards, keyed by device (board id) —
|
|
3700
|
+
* the board fetches via GET /esp32/voice-reply at its own pace. Swept by
|
|
3701
|
+
* TTL: a board that reboots before fetching must not hear a stale answer
|
|
3702
|
+
* minutes later.
|
|
3703
|
+
*/
|
|
3704
|
+
const stagedVoiceReplies = new Map();
|
|
3705
|
+
const STAGED_REPLY_TTL_MS = 2 * 60_000;
|
|
3706
|
+
const stagedReplySweep = setInterval(() => {
|
|
3707
|
+
const cutoff = Date.now() - STAGED_REPLY_TTL_MS;
|
|
3708
|
+
for (const [key, entry] of stagedVoiceReplies) {
|
|
3709
|
+
if (entry.ts < cutoff)
|
|
3710
|
+
stagedVoiceReplies.delete(key);
|
|
3711
|
+
}
|
|
3712
|
+
}, 30_000);
|
|
3713
|
+
stagedReplySweep.unref?.();
|
|
3183
3714
|
const voiceReply = new DeviceVoiceReplyRouter(() => Date.now(), (ms) => new Promise((r) => setTimeout(r, ms)), (deviceKey) => {
|
|
3715
|
+
// This resolver serves the spoken-reply router only, so unlike the
|
|
3716
|
+
// general serial-first dedup it prefers whichever live transport can
|
|
3717
|
+
// actually PLAY audio (advertises audio_out): a CH340 serial link
|
|
3718
|
+
// physically cannot carry PCM even when it is the primary state path.
|
|
3719
|
+
const candidates = [];
|
|
3184
3720
|
const serialPort = liveSerialPortForBoard(deviceKey);
|
|
3185
|
-
if (serialPort)
|
|
3186
|
-
|
|
3187
|
-
return serialSinkFor(serialPort);
|
|
3188
|
-
}
|
|
3721
|
+
if (serialPort)
|
|
3722
|
+
candidates.push(serialSinkFor(serialPort));
|
|
3189
3723
|
for (const [key, sock] of wifiEsp32Sockets) {
|
|
3190
3724
|
if (sock.readyState !== WebSocket.OPEN)
|
|
3191
3725
|
continue;
|
|
3192
3726
|
const board = wifiEsp32Devices.get(key)?.board;
|
|
3193
|
-
if (board === deviceKey || key === deviceKey)
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3727
|
+
if (board === deviceKey || key === deviceKey)
|
|
3728
|
+
candidates.push(stableSink(sock));
|
|
3729
|
+
}
|
|
3730
|
+
const pick = candidates.find((s) => {
|
|
3731
|
+
const caps = s.capabilities();
|
|
3732
|
+
return caps.includes('audio_out') || caps.includes('audio_http_pull');
|
|
3733
|
+
}) ?? candidates[0];
|
|
3734
|
+
if (pick) {
|
|
3735
|
+
log(`[agentdeck] voice: routing reply for ${deviceKey} via ${pick.describe()}`
|
|
3736
|
+
+ ` (caps=${pick.capabilities().join('|') || 'none'})`);
|
|
3737
|
+
return pick;
|
|
3197
3738
|
}
|
|
3198
3739
|
log(`[agentdeck] voice: no live transport for ${deviceKey}`
|
|
3199
3740
|
+ ` (serial boards: ${serialBoardsAttached().join(',') || 'none'})`);
|
|
@@ -3276,12 +3817,59 @@ export async function startDaemon(opts) {
|
|
|
3276
3817
|
}
|
|
3277
3818
|
const locale = typeof voiceCfg?.locale === 'string' && voiceCfg.locale
|
|
3278
3819
|
? voiceCfg.locale : undefined;
|
|
3820
|
+
// Host-armed dictations (deck push-to-talk) play through the host's own
|
|
3821
|
+
// speakers — AVSpeechSynthesizer speaks directly, no WAV/PCM streaming
|
|
3822
|
+
// detour. Split them out before synthesis so a host-only reply skips the
|
|
3823
|
+
// file round trip entirely.
|
|
3824
|
+
const hostTargets = targets.filter((s) => s.capabilities().includes('audio_host'));
|
|
3825
|
+
const boardTargets = targets.filter((s) => !s.capabilities().includes('audio_host'));
|
|
3826
|
+
if (hostTargets.length > 0) {
|
|
3827
|
+
for (const sink of hostTargets)
|
|
3828
|
+
voiceReply.disarm(sink);
|
|
3829
|
+
void (async () => {
|
|
3830
|
+
try {
|
|
3831
|
+
await speakWithHelper(spoken, locale ? { locale } : {});
|
|
3832
|
+
log(`[agentdeck] voice: spoke reply on host speakers (${spoken.length} chars)`);
|
|
3833
|
+
}
|
|
3834
|
+
catch (err) {
|
|
3835
|
+
log(`[agentdeck] voice: host speak failed: ${String(err).slice(0, 140)}`);
|
|
3836
|
+
}
|
|
3837
|
+
})();
|
|
3838
|
+
}
|
|
3839
|
+
if (boardTargets.length === 0)
|
|
3840
|
+
return;
|
|
3279
3841
|
void (async () => {
|
|
3280
3842
|
const out = join(tmpdir(), `agentdeck-reply-${process.pid}-${Date.now()}.wav`);
|
|
3281
3843
|
try {
|
|
3282
3844
|
await synthesizeWavWithHelper(spoken, out, locale ? { locale } : {});
|
|
3283
3845
|
const wav = await readFile(out);
|
|
3284
|
-
for (const sink of
|
|
3846
|
+
for (const sink of boardTargets) {
|
|
3847
|
+
// Pull-capable boards fetch the audio themselves over HTTP — the WS
|
|
3848
|
+
// push stream both stuttered and could crash the hosted-link RX
|
|
3849
|
+
// path (pkt_rxbuff assert). Stage the PCM, send a tiny notify
|
|
3850
|
+
// frame, and let TCP flow control pace the transfer.
|
|
3851
|
+
if (sink.capabilities().includes('audio_http_pull')) {
|
|
3852
|
+
const parsed = pcmFromWav(wav);
|
|
3853
|
+
if (parsed) {
|
|
3854
|
+
stagedVoiceReplies.set(sink.deviceKey(), {
|
|
3855
|
+
pcm: parsed.pcm, sampleRate: parsed.sampleRate, ts: Date.now(),
|
|
3856
|
+
});
|
|
3857
|
+
try {
|
|
3858
|
+
sink.send(JSON.stringify({
|
|
3859
|
+
type: 'audio_reply_ready',
|
|
3860
|
+
bytes: parsed.pcm.length,
|
|
3861
|
+
sampleRate: parsed.sampleRate,
|
|
3862
|
+
durationMs: Math.round((parsed.pcm.length / 2) / parsed.sampleRate * 1000),
|
|
3863
|
+
text: spoken.slice(0, 120),
|
|
3864
|
+
}));
|
|
3865
|
+
log(`[agentdeck] voice: staged reply for ${sink.deviceKey()}`
|
|
3866
|
+
+ ` (${parsed.pcm.length}B pull, ${spoken.length} chars)`);
|
|
3867
|
+
}
|
|
3868
|
+
catch { /* sink closing — TTL sweep reclaims the staging */ }
|
|
3869
|
+
voiceReply.disarm(sink);
|
|
3870
|
+
continue;
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3285
3873
|
const ok = await voiceReply.stream(sink, wav, spoken);
|
|
3286
3874
|
log(`[agentdeck] voice: ${ok ? 'spoke' : 'FAILED to speak'} reply`
|
|
3287
3875
|
+ ` (${wav.length}B, ${spoken.length} chars)`);
|
|
@@ -3451,7 +4039,7 @@ export async function startDaemon(opts) {
|
|
|
3451
4039
|
* no socket to answer on, so resolve the board's live transport the same
|
|
3452
4040
|
* way a queued voice reply does (serial first, then its WiFi socket).
|
|
3453
4041
|
*/
|
|
3454
|
-
const
|
|
4042
|
+
const boardResultSinkFor = (board) => {
|
|
3455
4043
|
const port = liveSerialPortForBoard(board);
|
|
3456
4044
|
if (port)
|
|
3457
4045
|
return serialSinkFor(port);
|
|
@@ -3482,8 +4070,19 @@ export async function startDaemon(opts) {
|
|
|
3482
4070
|
}
|
|
3483
4071
|
else if (msg.type === 'voice_end') {
|
|
3484
4072
|
const captured = deviceVoice.end(port, msg);
|
|
3485
|
-
if (captured)
|
|
4073
|
+
if (captured) {
|
|
3486
4074
|
void finishVoiceCapture(captured, serialSinkFor(port));
|
|
4075
|
+
}
|
|
4076
|
+
else if (msg.cancel !== true) {
|
|
4077
|
+
try {
|
|
4078
|
+
serialSinkFor(port).send(JSON.stringify({ type: 'voice_result', text: '', error: 'empty_capture' }));
|
|
4079
|
+
}
|
|
4080
|
+
catch { /* port closing */ }
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
else if (msg.type === 'voice_abort') {
|
|
4084
|
+
log(`[agentdeck] voice: device upload aborted (${String(msg.reason ?? 'unknown')}, `
|
|
4085
|
+
+ `${String(msg.total ?? '?')} bytes) [serial ${port}]`);
|
|
3487
4086
|
}
|
|
3488
4087
|
else if (msg.type === 'photo_begin') {
|
|
3489
4088
|
// Photo over USB: the strip is a desk fixture and usually powered from
|
|
@@ -3519,8 +4118,38 @@ export async function startDaemon(opts) {
|
|
|
3519
4118
|
* like the same board on WiFi, which is only true if there is one
|
|
3520
4119
|
* implementation.
|
|
3521
4120
|
*/
|
|
4121
|
+
/**
|
|
4122
|
+
* The sink to ARM for the spoken reply, which is not always the sink the
|
|
4123
|
+
* result JSON goes to: an HTTP-uploaded utterance resolves its result sink
|
|
4124
|
+
* serial-first (guaranteed delivery), but the ips10 advertises `audio_out`
|
|
4125
|
+
* on its WiFi device_info only — its 115200-baud serial physically cannot
|
|
4126
|
+
* carry PCM, so arming the serial sink would make voiceReply.arm() decline
|
|
4127
|
+
* and the reply silently never play. Prefer the sink itself when it can
|
|
4128
|
+
* play audio; otherwise find the board's live WS socket that can.
|
|
4129
|
+
*/
|
|
4130
|
+
const canPlayReply = (s) => {
|
|
4131
|
+
const caps = s.capabilities();
|
|
4132
|
+
return caps.includes('audio_out') || caps.includes('audio_http_pull');
|
|
4133
|
+
};
|
|
4134
|
+
const audioArmSinkFor = (sink, board) => {
|
|
4135
|
+
if (canPlayReply(sink))
|
|
4136
|
+
return sink;
|
|
4137
|
+
for (const [key, sock] of wifiEsp32Sockets) {
|
|
4138
|
+
if (sock.readyState !== WebSocket.OPEN)
|
|
4139
|
+
continue;
|
|
4140
|
+
if (wifiEsp32Devices.get(key)?.board === board || key === board) {
|
|
4141
|
+
const s = stableSink(sock);
|
|
4142
|
+
if (canPlayReply(s))
|
|
4143
|
+
return s;
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
return sink;
|
|
4147
|
+
};
|
|
3522
4148
|
const finishVoiceCapture = async (captured, sink) => {
|
|
3523
4149
|
try {
|
|
4150
|
+
if (captured.integrityError) {
|
|
4151
|
+
throw new Error(captured.integrityError);
|
|
4152
|
+
}
|
|
3524
4153
|
// Locale matters: the recognizer falls back to the system locale,
|
|
3525
4154
|
// which mangles speech in another language. settings.json
|
|
3526
4155
|
// `voice.locale` (BCP-47, e.g. "en-US") overrides it.
|
|
@@ -3537,7 +4166,20 @@ export async function startDaemon(opts) {
|
|
|
3537
4166
|
let via;
|
|
3538
4167
|
let deliverReason;
|
|
3539
4168
|
if (text && sessionId) {
|
|
3540
|
-
if (sessionId
|
|
4169
|
+
if (sessionId === 'openclaw-gateway') {
|
|
4170
|
+
// The gateway session has no bridge port: the generic
|
|
4171
|
+
// session_command path focuses a relay that can never connect
|
|
4172
|
+
// and silently drops the prompt while this function reported
|
|
4173
|
+
// delivered=true (observed 2026-07-31 — "transcript showed,
|
|
4174
|
+
// nothing answered"). Route straight to the adapter and report
|
|
4175
|
+
// what actually happened.
|
|
4176
|
+
delivered = !!(gatewayAdapter?.isAlive()
|
|
4177
|
+
&& gatewayAdapter.handleCommand({ type: 'send_prompt', text }));
|
|
4178
|
+
via = 'gateway';
|
|
4179
|
+
if (!delivered)
|
|
4180
|
+
deliverReason = 'gateway_unavailable';
|
|
4181
|
+
}
|
|
4182
|
+
else if (sessionId.startsWith('observed:')) {
|
|
3541
4183
|
// Any observed agent (claude, codex, …): type the dictated line
|
|
3542
4184
|
// into the terminal that owns the session. session_command only
|
|
3543
4185
|
// knew how to route observed *Claude*, so a Codex target was
|
|
@@ -3560,15 +4202,17 @@ export async function startDaemon(opts) {
|
|
|
3560
4202
|
}
|
|
3561
4203
|
// Only a delivered prompt earns a spoken reply: arming on a
|
|
3562
4204
|
// dropped one would read out whatever that session happened to
|
|
3563
|
-
// answer someone else.
|
|
3564
|
-
|
|
4205
|
+
// answer someone else. Armed on an audio-capable sink, which may
|
|
4206
|
+
// differ from the result sink — see audioArmSinkFor.
|
|
4207
|
+
const armSink = audioArmSinkFor(sink, captured.board);
|
|
4208
|
+
const armed = delivered ? voiceReply.arm(armSink, sessionId) : false;
|
|
3565
4209
|
// Logged at info level rather than debug: these are rare,
|
|
3566
4210
|
// user-initiated events, and the silence made "the board said sent
|
|
3567
4211
|
// but nothing happened" undiagnosable without a rebuild.
|
|
3568
4212
|
log(`[agentdeck] voice: "${text.slice(0, 40)}" -> ${sessionId.slice(0, 32)}`
|
|
3569
4213
|
+ ` delivered=${delivered}${via ? ` via ${via}` : ''}`
|
|
3570
4214
|
+ `${deliverReason ? ` (${deliverReason})` : ''}`
|
|
3571
|
-
+ ` spokenReply=${armed ? `armed(${
|
|
4215
|
+
+ ` spokenReply=${armed ? `armed(${armSink.describe()})` : 'no'}`
|
|
3572
4216
|
+ ` [${sink.describe()} key=${sink.deviceKey()}`
|
|
3573
4217
|
+ ` caps=${sink.capabilities().join('|') || 'none'}]`);
|
|
3574
4218
|
}
|
|
@@ -3582,10 +4226,24 @@ export async function startDaemon(opts) {
|
|
|
3582
4226
|
catch { /* client disconnecting */ }
|
|
3583
4227
|
}
|
|
3584
4228
|
catch (err) {
|
|
3585
|
-
const reason = String(err).slice(0,
|
|
3586
|
-
|
|
4229
|
+
const reason = String(err).slice(0, 300);
|
|
4230
|
+
// Voice turns are rare and user-initiated. Keep failures at info
|
|
4231
|
+
// level so a board's short "Voice error" banner can always be
|
|
4232
|
+
// diagnosed after the temporary WAV has been cleaned up.
|
|
4233
|
+
log(`[agentdeck] voice: transcription failed on ${sink.describe()}: ${reason}`);
|
|
4234
|
+
// The board renders `error` on a one-line banner at glance distance —
|
|
4235
|
+
// a short stable code it can translate beats 160 chars of
|
|
4236
|
+
// NSError soup. The full reason still ships as `detail` and lives in
|
|
4237
|
+
// the log above.
|
|
4238
|
+
const code = /No speech detected|kAFAssistantErrorDomain Code=1110/.test(reason)
|
|
4239
|
+
? 'no_speech'
|
|
4240
|
+
: /audio_transport_overflow/.test(reason) ? 'audio_lost'
|
|
4241
|
+
: /audio_transport_incomplete/.test(reason) ? 'audio_incomplete'
|
|
4242
|
+
: 'voice_failed';
|
|
3587
4243
|
try {
|
|
3588
|
-
sink.send(JSON.stringify({
|
|
4244
|
+
sink.send(JSON.stringify({
|
|
4245
|
+
type: 'voice_result', text: '', error: code, detail: reason.slice(0, 160),
|
|
4246
|
+
}));
|
|
3589
4247
|
}
|
|
3590
4248
|
catch { /* client disconnecting */ }
|
|
3591
4249
|
}
|
|
@@ -3593,6 +4251,91 @@ export async function startDaemon(opts) {
|
|
|
3593
4251
|
captured.cleanup();
|
|
3594
4252
|
}
|
|
3595
4253
|
};
|
|
4254
|
+
// ── Host push-to-talk (deck surfaces: Stream Deck, D200H) ────────────────
|
|
4255
|
+
// A deck key contributes only a button; the host contributes the mic, the
|
|
4256
|
+
// recognizer and the speakers. This restores the capability of the retired
|
|
4257
|
+
// Voice dial (pulled pre-Marketplace because capture meant borrowing
|
|
4258
|
+
// iTerm2's mic grant via AppleScript + Homebrew sox + a whisper model) on a
|
|
4259
|
+
// dependency-free stack: capture, STT and TTS all live in the bundled Swift
|
|
4260
|
+
// helper, and delivery/reply reuse the device-voice pipeline unchanged.
|
|
4261
|
+
const HOST_SPEAKER_KEY = 'host-speaker';
|
|
4262
|
+
const broadcastVoiceState = (state, extra = {}) => {
|
|
4263
|
+
core.broadcast({ type: 'voice_state', state, ...extra });
|
|
4264
|
+
};
|
|
4265
|
+
/**
|
|
4266
|
+
* ReplySink whose "board" is the host itself. `voice_result` frames become
|
|
4267
|
+
* broadcast `voice_state` events so every deck surface can render the
|
|
4268
|
+
* outcome; the spoken reply is played by the audio_host branch in
|
|
4269
|
+
* speakReplyTo rather than streamed as PCM frames.
|
|
4270
|
+
*/
|
|
4271
|
+
const hostSpeakerSink = {
|
|
4272
|
+
send: (data) => {
|
|
4273
|
+
try {
|
|
4274
|
+
const msg = JSON.parse(data);
|
|
4275
|
+
if (msg.type === 'voice_result') {
|
|
4276
|
+
broadcastVoiceState(msg.error ? 'error' : 'idle', {
|
|
4277
|
+
...(msg.text ? { text: msg.text } : {}),
|
|
4278
|
+
...(msg.error ? { error: msg.error } : {}),
|
|
4279
|
+
...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
|
|
4280
|
+
});
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
catch { /* nothing renderable */ }
|
|
4284
|
+
},
|
|
4285
|
+
sendBinary: () => { },
|
|
4286
|
+
isOpen: () => true,
|
|
4287
|
+
capabilities: () => ['audio_out', 'audio_host'],
|
|
4288
|
+
describe: () => 'host:speaker',
|
|
4289
|
+
deviceKey: () => HOST_SPEAKER_KEY,
|
|
4290
|
+
};
|
|
4291
|
+
/** One capture at a time — PTT models a single held key. */
|
|
4292
|
+
let hostPttBusy = false;
|
|
4293
|
+
function handleHostVoicePtt(cmd) {
|
|
4294
|
+
if (process.platform !== 'darwin') {
|
|
4295
|
+
broadcastVoiceState('error', { error: 'host voice capture requires macOS' });
|
|
4296
|
+
return;
|
|
4297
|
+
}
|
|
4298
|
+
if (cmd.action === 'stop' || cmd.action === 'cancel') {
|
|
4299
|
+
if (!hostPttBusy)
|
|
4300
|
+
return;
|
|
4301
|
+
// Resolves the in-flight recordWithHelper below; a release that races
|
|
4302
|
+
// the max-duration stop is a harmless no-op.
|
|
4303
|
+
void stopHelperRecording({ cancel: cmd.action === 'cancel' }).catch(() => { });
|
|
4304
|
+
return;
|
|
4305
|
+
}
|
|
4306
|
+
if (hostPttBusy)
|
|
4307
|
+
return;
|
|
4308
|
+
hostPttBusy = true;
|
|
4309
|
+
const sessionId = resolveDeviceSessionId(cmd.sessionId) || userFocusedSessionId || '';
|
|
4310
|
+
const sessionExtra = sessionId ? { sessionId } : {};
|
|
4311
|
+
const wavPath = join(tmpdir(), `agentdeck-ptt-${process.pid}-${Date.now()}.wav`);
|
|
4312
|
+
broadcastVoiceState('recording', sessionExtra);
|
|
4313
|
+
void (async () => {
|
|
4314
|
+
try {
|
|
4315
|
+
const rec = await recordWithHelper(wavPath);
|
|
4316
|
+
broadcastVoiceState('transcribing', sessionExtra);
|
|
4317
|
+
await finishVoiceCapture({
|
|
4318
|
+
wavPath: rec.wav,
|
|
4319
|
+
sessionId,
|
|
4320
|
+
board: HOST_SPEAKER_KEY,
|
|
4321
|
+
cleanup: () => { void rm(rec.wav, { force: true }).catch(() => { }); },
|
|
4322
|
+
}, hostSpeakerSink);
|
|
4323
|
+
}
|
|
4324
|
+
catch (err) {
|
|
4325
|
+
const reason = String(err);
|
|
4326
|
+
if (/record_cancelled/.test(reason)) {
|
|
4327
|
+
broadcastVoiceState('idle', sessionExtra);
|
|
4328
|
+
}
|
|
4329
|
+
else {
|
|
4330
|
+
log(`[agentdeck] voice: host PTT failed: ${reason.slice(0, 200)}`);
|
|
4331
|
+
broadcastVoiceState('error', { error: reason.slice(0, 160), ...sessionExtra });
|
|
4332
|
+
}
|
|
4333
|
+
}
|
|
4334
|
+
finally {
|
|
4335
|
+
hostPttBusy = false;
|
|
4336
|
+
}
|
|
4337
|
+
})();
|
|
4338
|
+
}
|
|
3596
4339
|
/**
|
|
3597
4340
|
* Finish one captured photo: build the prompt that points the target session
|
|
3598
4341
|
* at the saved file, deliver it through the same ladder as dictated text
|