@agentdeck/bridge 0.2.2 → 0.2.3
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/fonts/IBMPlexSans-Bold.ttf +0 -0
- package/assets/fonts/IBMPlexSans-Regular.ttf +0 -0
- package/assets/fonts/JetBrainsMono-Bold.ttf +0 -0
- package/assets/fonts/JetBrainsMono-Regular.ttf +0 -0
- package/assets/fonts/LICENSES.md +22 -0
- package/dist/adapters/claude-code.d.ts +6 -0
- package/dist/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +8 -0
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adapters/openclaw.d.ts +24 -0
- package/dist/adapters/openclaw.d.ts.map +1 -1
- package/dist/adapters/openclaw.js +291 -35
- package/dist/adapters/openclaw.js.map +1 -1
- package/dist/adapters/opencode-adapter.d.ts +59 -0
- package/dist/adapters/opencode-adapter.d.ts.map +1 -1
- package/dist/adapters/opencode-adapter.js +297 -35
- package/dist/adapters/opencode-adapter.js.map +1 -1
- package/dist/adb-reverse.d.ts +1 -1
- package/dist/adb-reverse.js +1 -1
- package/dist/antigravity-local.d.ts +1 -0
- package/dist/antigravity-local.d.ts.map +1 -1
- package/dist/antigravity-local.js +33 -0
- package/dist/antigravity-local.js.map +1 -1
- package/dist/apme/adapters/claude-hook.d.ts.map +1 -1
- package/dist/apme/adapters/claude-hook.js +5 -0
- package/dist/apme/adapters/claude-hook.js.map +1 -1
- package/dist/apme/adapters/codex-hook.d.ts +17 -0
- package/dist/apme/adapters/codex-hook.d.ts.map +1 -0
- package/dist/apme/adapters/codex-hook.js +95 -0
- package/dist/apme/adapters/codex-hook.js.map +1 -0
- package/dist/apme/adapters/codex-turn-manager.d.ts +86 -0
- package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -0
- package/dist/apme/adapters/codex-turn-manager.js +496 -0
- package/dist/apme/adapters/codex-turn-manager.js.map +1 -0
- package/dist/apme/adapters/openclaw-hook.d.ts +99 -0
- package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -0
- package/dist/apme/adapters/openclaw-hook.js +234 -0
- package/dist/apme/adapters/openclaw-hook.js.map +1 -0
- package/dist/apme/adapters/opencode-hook.d.ts +56 -0
- package/dist/apme/adapters/opencode-hook.d.ts.map +1 -0
- package/dist/apme/adapters/opencode-hook.js +182 -0
- package/dist/apme/adapters/opencode-hook.js.map +1 -0
- package/dist/apme/adapters/timeline.d.ts.map +1 -1
- package/dist/apme/adapters/timeline.js +6 -1
- package/dist/apme/adapters/timeline.js.map +1 -1
- package/dist/apme/antigravity-transcript.d.ts +18 -0
- package/dist/apme/antigravity-transcript.d.ts.map +1 -0
- package/dist/apme/antigravity-transcript.js +94 -0
- package/dist/apme/antigravity-transcript.js.map +1 -0
- package/dist/apme/classify-turn.d.ts +13 -0
- package/dist/apme/classify-turn.d.ts.map +1 -0
- package/dist/apme/classify-turn.js +49 -0
- package/dist/apme/classify-turn.js.map +1 -0
- package/dist/apme/claude-transcript-reader.d.ts +10 -0
- package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
- package/dist/apme/claude-transcript-reader.js +35 -0
- package/dist/apme/claude-transcript-reader.js.map +1 -1
- package/dist/apme/collector.d.ts +129 -6
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +470 -23
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/dashboard-html.d.ts.map +1 -1
- package/dist/apme/dashboard-html.js +87 -11
- package/dist/apme/dashboard-html.js.map +1 -1
- package/dist/apme/http.d.ts.map +1 -1
- package/dist/apme/http.js +43 -6
- package/dist/apme/http.js.map +1 -1
- package/dist/apme/index.d.ts +26 -5
- package/dist/apme/index.d.ts.map +1 -1
- package/dist/apme/index.js +135 -9
- package/dist/apme/index.js.map +1 -1
- package/dist/apme/pareto.d.ts +39 -0
- package/dist/apme/pareto.d.ts.map +1 -0
- package/dist/apme/pareto.js +59 -0
- package/dist/apme/pareto.js.map +1 -0
- package/dist/apme/recommend.d.ts +3 -0
- package/dist/apme/recommend.d.ts.map +1 -1
- package/dist/apme/recommend.js +43 -1
- package/dist/apme/recommend.js.map +1 -1
- package/dist/apme/runner.d.ts +92 -4
- package/dist/apme/runner.d.ts.map +1 -1
- package/dist/apme/runner.js +586 -70
- package/dist/apme/runner.js.map +1 -1
- package/dist/apme/sample-to-timeline.d.ts +26 -0
- package/dist/apme/sample-to-timeline.d.ts.map +1 -0
- package/dist/apme/sample-to-timeline.js +95 -0
- package/dist/apme/sample-to-timeline.js.map +1 -0
- package/dist/apme/scorers/index.d.ts +47 -0
- package/dist/apme/scorers/index.d.ts.map +1 -0
- package/dist/apme/scorers/index.js +96 -0
- package/dist/apme/scorers/index.js.map +1 -0
- package/dist/apme/settings.d.ts +14 -10
- package/dist/apme/settings.d.ts.map +1 -1
- package/dist/apme/settings.js +34 -8
- package/dist/apme/settings.js.map +1 -1
- package/dist/apme/store.d.ts +33 -1
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +334 -25
- package/dist/apme/store.js.map +1 -1
- package/dist/awaiting-overlay.d.ts +99 -0
- package/dist/awaiting-overlay.d.ts.map +1 -0
- package/dist/awaiting-overlay.js +148 -0
- package/dist/awaiting-overlay.js.map +1 -0
- package/dist/ble-sync-spawn.d.ts +63 -0
- package/dist/ble-sync-spawn.d.ts.map +1 -0
- package/dist/ble-sync-spawn.js +196 -0
- package/dist/ble-sync-spawn.js.map +1 -0
- package/dist/bridge-core.d.ts +24 -1
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +157 -24
- package/dist/bridge-core.js.map +1 -1
- package/dist/broadcast.d.ts +3 -0
- package/dist/broadcast.d.ts.map +1 -0
- package/dist/broadcast.js +122 -0
- package/dist/broadcast.js.map +1 -0
- package/dist/check-deps.d.ts.map +1 -1
- package/dist/check-deps.js +25 -9
- package/dist/check-deps.js.map +1 -1
- package/dist/cli.d.ts +15 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1021 -93
- package/dist/cli.js.map +1 -1
- package/dist/codex-auth.d.ts +21 -0
- package/dist/codex-auth.d.ts.map +1 -1
- package/dist/codex-auth.js +52 -1
- package/dist/codex-auth.js.map +1 -1
- package/dist/codex-output-parser.d.ts +8 -0
- package/dist/codex-output-parser.d.ts.map +1 -1
- package/dist/codex-output-parser.js +110 -25
- package/dist/codex-output-parser.js.map +1 -1
- package/dist/codex-rate-limits.d.ts +16 -0
- package/dist/codex-rate-limits.d.ts.map +1 -0
- package/dist/codex-rate-limits.js +197 -0
- package/dist/codex-rate-limits.js.map +1 -0
- package/dist/codex-rollout-response.d.ts +31 -0
- package/dist/codex-rollout-response.d.ts.map +1 -0
- package/dist/codex-rollout-response.js +135 -0
- package/dist/codex-rollout-response.js.map +1 -0
- package/dist/daemon-server.d.ts +85 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +1627 -159
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon.js +15 -6
- package/dist/daemon.js.map +1 -1
- package/dist/display-dim.d.ts +9 -0
- package/dist/display-dim.d.ts.map +1 -0
- package/dist/display-dim.js +53 -0
- package/dist/display-dim.js.map +1 -0
- package/dist/display-monitor.d.ts +15 -2
- package/dist/display-monitor.d.ts.map +1 -1
- package/dist/display-monitor.js +104 -26
- package/dist/display-monitor.js.map +1 -1
- package/dist/esp32-serial.d.ts +160 -1
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +950 -153
- package/dist/esp32-serial.js.map +1 -1
- package/dist/fallback-task-timeline.d.ts +42 -0
- package/dist/fallback-task-timeline.d.ts.map +1 -0
- package/dist/fallback-task-timeline.js +112 -0
- package/dist/fallback-task-timeline.js.map +1 -0
- package/dist/foundation-models-helper.d.ts +11 -0
- package/dist/foundation-models-helper.d.ts.map +1 -0
- package/dist/foundation-models-helper.js +241 -0
- package/dist/foundation-models-helper.js.map +1 -0
- package/dist/hook-migration.js +1 -1
- package/dist/hook-migration.js.map +1 -1
- package/dist/hook-server.d.ts +2 -3
- package/dist/hook-server.d.ts.map +1 -1
- package/dist/hook-server.js +22 -10
- package/dist/hook-server.js.map +1 -1
- package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts +33 -0
- package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -0
- package/dist/idotmatrix/idotmatrix-daemon-sync.js +164 -0
- package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -0
- package/dist/idotmatrix/idotmatrix-discover.d.ts +18 -0
- package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -0
- package/dist/idotmatrix/idotmatrix-discover.js +85 -0
- package/dist/idotmatrix/idotmatrix-discover.js.map +1 -0
- package/dist/idotmatrix/idotmatrix-settings.d.ts +20 -0
- package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -0
- package/dist/idotmatrix/idotmatrix-settings.js +57 -0
- package/dist/idotmatrix/idotmatrix-settings.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +215 -179
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts +15 -16
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +18 -96
- package/dist/log-stream.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +12 -3
- package/dist/logger.js.map +1 -1
- package/dist/mdns.d.ts +17 -0
- package/dist/mdns.d.ts.map +1 -1
- package/dist/mdns.js +52 -18
- package/dist/mdns.js.map +1 -1
- package/dist/modules/adb-module.d.ts +1 -1
- package/dist/modules/adb-module.d.ts.map +1 -1
- package/dist/modules/adb-module.js +4 -3
- package/dist/modules/adb-module.js.map +1 -1
- package/dist/modules/broadcast-module.d.ts +17 -0
- package/dist/modules/broadcast-module.d.ts.map +1 -0
- package/dist/modules/broadcast-module.js +26 -0
- package/dist/modules/broadcast-module.js.map +1 -0
- package/dist/modules/idotmatrix-module.d.ts +9 -0
- package/dist/modules/idotmatrix-module.d.ts.map +1 -0
- package/dist/modules/idotmatrix-module.js +46 -0
- package/dist/modules/idotmatrix-module.js.map +1 -0
- package/dist/modules/index.d.ts +3 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +9 -3
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/pixoo-module.d.ts.map +1 -1
- package/dist/modules/pixoo-module.js +14 -4
- package/dist/modules/pixoo-module.js.map +1 -1
- package/dist/modules/serial-module.d.ts +4 -0
- package/dist/modules/serial-module.d.ts.map +1 -1
- package/dist/modules/serial-module.js +14 -12
- package/dist/modules/serial-module.js.map +1 -1
- package/dist/modules/timebox-module.d.ts +9 -0
- package/dist/modules/timebox-module.d.ts.map +1 -0
- package/dist/modules/timebox-module.js +46 -0
- package/dist/modules/timebox-module.js.map +1 -0
- package/dist/modules/types.d.ts +4 -2
- package/dist/modules/types.d.ts.map +1 -1
- package/dist/openclaw-session.d.ts +18 -0
- package/dist/openclaw-session.d.ts.map +1 -0
- package/dist/openclaw-session.js +34 -0
- package/dist/openclaw-session.js.map +1 -0
- package/dist/output-parser.d.ts +30 -0
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +125 -9
- package/dist/output-parser.js.map +1 -1
- package/dist/passive-observer.d.ts +26 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +258 -49
- package/dist/passive-observer.js.map +1 -1
- package/dist/permission-resolver.d.ts +47 -0
- package/dist/permission-resolver.d.ts.map +1 -0
- package/dist/permission-resolver.js +103 -0
- package/dist/permission-resolver.js.map +1 -0
- package/dist/pixoo/micro-glyphs.d.ts +49 -0
- package/dist/pixoo/micro-glyphs.d.ts.map +1 -0
- package/dist/pixoo/micro-glyphs.js +276 -0
- package/dist/pixoo/micro-glyphs.js.map +1 -0
- package/dist/pixoo/pixoo-bridge.d.ts +2 -2
- package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
- package/dist/pixoo/pixoo-bridge.js +93 -25
- package/dist/pixoo/pixoo-bridge.js.map +1 -1
- package/dist/pixoo/pixoo-camera.d.ts +19 -3
- package/dist/pixoo/pixoo-camera.d.ts.map +1 -1
- package/dist/pixoo/pixoo-camera.js +62 -27
- package/dist/pixoo/pixoo-camera.js.map +1 -1
- package/dist/pixoo/pixoo-client.d.ts +13 -3
- package/dist/pixoo/pixoo-client.d.ts.map +1 -1
- package/dist/pixoo/pixoo-client.js +51 -22
- package/dist/pixoo/pixoo-client.js.map +1 -1
- package/dist/pixoo/pixoo-discover.d.ts +37 -0
- package/dist/pixoo/pixoo-discover.d.ts.map +1 -0
- package/dist/pixoo/pixoo-discover.js +105 -0
- package/dist/pixoo/pixoo-discover.js.map +1 -0
- package/dist/pixoo/pixoo-renderer.d.ts +8 -3
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +189 -60
- package/dist/pixoo/pixoo-renderer.js.map +1 -1
- package/dist/pixoo/pixoo-settings.d.ts +6 -0
- package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
- package/dist/pixoo/pixoo-settings.js +9 -0
- package/dist/pixoo/pixoo-settings.js.map +1 -1
- package/dist/pixoo/pixoo-sprites.d.ts +60 -11
- package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
- package/dist/pixoo/pixoo-sprites.js +512 -209
- package/dist/pixoo/pixoo-sprites.js.map +1 -1
- package/dist/pty-manager.d.ts.map +1 -1
- package/dist/pty-manager.js +8 -2
- package/dist/pty-manager.js.map +1 -1
- package/dist/session-activity.d.ts +14 -0
- package/dist/session-activity.d.ts.map +1 -0
- package/dist/session-activity.js +160 -0
- package/dist/session-activity.js.map +1 -0
- package/dist/session-aggregator.d.ts +17 -0
- package/dist/session-aggregator.d.ts.map +1 -1
- package/dist/session-aggregator.js +48 -9
- package/dist/session-aggregator.js.map +1 -1
- package/dist/session-registry.d.ts +71 -3
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +137 -8
- package/dist/session-registry.js.map +1 -1
- package/dist/session-timeline-relay.d.ts.map +1 -1
- package/dist/session-timeline-relay.js +13 -4
- package/dist/session-timeline-relay.js.map +1 -1
- package/dist/session-transcript-timeline.d.ts +49 -0
- package/dist/session-transcript-timeline.d.ts.map +1 -0
- package/dist/session-transcript-timeline.js +276 -0
- package/dist/session-transcript-timeline.js.map +1 -0
- package/dist/state-machine.d.ts +6 -1
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +86 -28
- package/dist/state-machine.js.map +1 -1
- package/dist/timebox/timebox-daemon-sync.d.ts +19 -0
- package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -0
- package/dist/timebox/timebox-daemon-sync.js +135 -0
- package/dist/timebox/timebox-daemon-sync.js.map +1 -0
- package/dist/timebox/timebox-discover.d.ts +19 -0
- package/dist/timebox/timebox-discover.d.ts.map +1 -0
- package/dist/timebox/timebox-discover.js +87 -0
- package/dist/timebox/timebox-discover.js.map +1 -0
- package/dist/timebox/timebox-settings.d.ts +32 -0
- package/dist/timebox/timebox-settings.d.ts.map +1 -0
- package/dist/timebox/timebox-settings.js +80 -0
- package/dist/timebox/timebox-settings.js.map +1 -0
- package/dist/timeline-store.d.ts +63 -3
- package/dist/timeline-store.d.ts.map +1 -1
- package/dist/timeline-store.js +292 -14
- package/dist/timeline-store.js.map +1 -1
- package/dist/timeline-summarizer.d.ts.map +1 -1
- package/dist/timeline-summarizer.js +29 -5
- package/dist/timeline-summarizer.js.map +1 -1
- package/dist/tui/ansi.d.ts +0 -4
- package/dist/tui/ansi.d.ts.map +1 -1
- package/dist/tui/ansi.js +14 -7
- package/dist/tui/ansi.js.map +1 -1
- package/dist/tui/dashboard.d.ts +1 -0
- package/dist/tui/dashboard.d.ts.map +1 -1
- package/dist/tui/dashboard.js +24 -7
- package/dist/tui/dashboard.js.map +1 -1
- package/dist/tui/renderer.d.ts +36 -0
- package/dist/tui/renderer.d.ts.map +1 -1
- package/dist/tui/renderer.js +303 -119
- package/dist/tui/renderer.js.map +1 -1
- package/dist/tui/terrarium.d.ts.map +1 -1
- package/dist/tui/terrarium.js +37 -49
- package/dist/tui/terrarium.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/types.js.map +1 -1
- package/dist/usage-api.d.ts.map +1 -1
- package/dist/usage-api.js +7 -1
- package/dist/usage-api.js.map +1 -1
- package/dist/usage-event.d.ts +10 -3
- package/dist/usage-event.d.ts.map +1 -1
- package/dist/usage-event.js +74 -12
- package/dist/usage-event.js.map +1 -1
- package/dist/utility-proxy.d.ts.map +1 -1
- package/dist/utility-proxy.js +8 -0
- package/dist/utility-proxy.js.map +1 -1
- package/dist/utils/project-name.d.ts +3 -0
- package/dist/utils/project-name.d.ts.map +1 -1
- package/dist/utils/project-name.js +44 -2
- package/dist/utils/project-name.js.map +1 -1
- package/dist/windows-service.d.ts +26 -0
- package/dist/windows-service.d.ts.map +1 -0
- package/dist/windows-service.js +145 -0
- package/dist/windows-service.js.map +1 -0
- package/dist/ws-server.d.ts +22 -1
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +89 -4
- package/dist/ws-server.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +112 -0
- package/package.json +9 -3
- package/scripts/build-fm-helper.mjs +26 -0
- package/LICENSE +0 -21
- package/dist/apme/tuner.d.ts +0 -64
- package/dist/apme/tuner.d.ts.map +0 -1
- package/dist/apme/tuner.js +0 -297
- package/dist/apme/tuner.js.map +0 -1
- 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 -36
- package/dist/d200h/image-renderer.d.ts.map +0 -1
- package/dist/d200h/image-renderer.js +0 -487
- package/dist/d200h/image-renderer.js.map +0 -1
- package/dist/modules/d200h-module.d.ts +0 -43
- package/dist/modules/d200h-module.d.ts.map +0 -1
- package/dist/modules/d200h-module.js +0 -360
- 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/daemon-server.js
CHANGED
|
@@ -11,46 +11,455 @@
|
|
|
11
11
|
* Exports `startDaemon()` called by cli.ts.
|
|
12
12
|
*/
|
|
13
13
|
import { createServer } from 'http';
|
|
14
|
+
import { createHash, randomUUID } from 'crypto';
|
|
14
15
|
import WebSocket from 'ws';
|
|
15
|
-
import { BridgeCore } from './bridge-core.js';
|
|
16
|
+
import { BridgeCore, buildCappedTimelineHistory } from './bridge-core.js';
|
|
17
|
+
import { buildDisplayStateEvent } from './display-dim.js';
|
|
18
|
+
import { SERIAL_FORWARDED_EVENTS } from '@agentdeck/shared/protocol';
|
|
19
|
+
import { prepareForSerial } from './esp32-serial.js';
|
|
16
20
|
import { OpenClawAdapter } from './adapters/openclaw.js';
|
|
17
21
|
import { BridgeLogStream } from './log-stream.js';
|
|
18
22
|
import { PassiveSessionObserver } from './passive-observer.js';
|
|
19
23
|
import { SessionTimelineRelay } from './session-timeline-relay.js';
|
|
20
24
|
import { SessionFocusRelay } from './session-focus-relay.js';
|
|
21
25
|
import { updatePushState } from './session-aggregator.js';
|
|
26
|
+
import { setAwaitingOverlay, clearAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved } from './awaiting-overlay.js';
|
|
27
|
+
import { resolvePending, sweepStalePending, drainAllPending } from './permission-resolver.js';
|
|
22
28
|
import { VoiceManager } from './voice.js';
|
|
23
29
|
import { VoiceAssistantManager } from './voice-assistant.js';
|
|
24
|
-
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, } from './session-registry.js';
|
|
30
|
+
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, } from './session-registry.js';
|
|
25
31
|
import { fetchUsageFromApi, hasOAuthToken, resetConsecutiveFailures } from './usage-api.js';
|
|
26
32
|
import { isLocalConnection, validateToken } from './auth.js';
|
|
27
33
|
import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
|
|
34
|
+
import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
|
|
28
35
|
import { handlePixooWake } from './pixoo/pixoo-client.js';
|
|
29
36
|
import { triggerMdnsRecovery } from './mdns.js';
|
|
30
37
|
import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
|
|
31
38
|
import { enableDebugLog, debug } from './logger.js';
|
|
32
|
-
import { initApme } from './apme/index.js';
|
|
39
|
+
import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
|
|
40
|
+
import { FallbackTaskTimeline } from './fallback-task-timeline.js';
|
|
33
41
|
import { handleApmeRequest } from './apme/http.js';
|
|
42
|
+
import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
|
|
43
|
+
import { transcriptTimelineForSession, lastAssistantTextFromTranscript } from './session-transcript-timeline.js';
|
|
44
|
+
import { lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
|
|
45
|
+
import { callFoundationModelsHelper } from './foundation-models-helper.js';
|
|
34
46
|
import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
|
|
35
|
-
import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendWifiProvisionToAll, handleESP32Wake } from './esp32-serial.js';
|
|
47
|
+
import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendWifiProvisionToAll, handleESP32Wake, getESP32Ports, getSerialConnectionStatus, getSerialLastError, getSerialReachableBoards } from './esp32-serial.js';
|
|
36
48
|
import { loadWifiConfig } from './wifi-config.js';
|
|
37
|
-
import { getConnectedAdbDevices, hasAdb } from './adb-reverse.js';
|
|
49
|
+
import { getConnectedAdbDevices, hasAdb, getAdbDeviceCount } from './adb-reverse.js';
|
|
38
50
|
import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js';
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
51
|
+
import { loadTimeboxDevices } from './timebox/timebox-settings.js';
|
|
52
|
+
import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail } from '@agentdeck/shared';
|
|
53
|
+
import { injectOpenClawSession } from './openclaw-session.js';
|
|
54
|
+
import { readFileSync, statSync } from 'fs';
|
|
41
55
|
import { join } from 'path';
|
|
42
56
|
import { homedir } from 'os';
|
|
43
57
|
import { OPENCLAW_CAPABILITIES, State, } from './types.js';
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
function exitProcessNow(code = 0) {
|
|
59
|
+
if (code === 0) {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(process.pid, 'SIGKILL');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// fall through
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
process.exit(code);
|
|
69
|
+
}
|
|
70
|
+
const wifiEsp32Devices = new Map();
|
|
71
|
+
const wifiEsp32Sockets = new Map();
|
|
72
|
+
const otaWaiters = new Map();
|
|
73
|
+
function listWifiEsp32Devices() {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const serialBoards = getSerialReachableBoards();
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const [key, d] of wifiEsp32Devices) {
|
|
78
|
+
if (now - d.lastSeenMs > 60 * 60 * 1000) {
|
|
79
|
+
wifiEsp32Devices.delete(key);
|
|
80
|
+
wifiEsp32Sockets.delete(key);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// serialActive: this WiFi board is also live on USB serial, so its WiFi path
|
|
84
|
+
// is a hot standby — display events are driven over serial (single path).
|
|
85
|
+
const serialActive = isWifiTransportRedundant({ board: d.board, ip: d.ip }, serialBoards);
|
|
86
|
+
out.push({ ...d, stale: now - d.lastSeenMs > 90_000, serialActive });
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
function wifiEsp32Key(d) {
|
|
91
|
+
const board = typeof d.board === 'string' && d.board ? d.board : 'unknown';
|
|
92
|
+
const ip = typeof d.ip === 'string' && d.ip ? d.ip : 'no-ip';
|
|
93
|
+
return `${board}:${ip}`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Single-path transport dedup. A physical ESP32 can be reachable over BOTH a
|
|
97
|
+
* USB serial connection and a WiFi WebSocket at once (e.g. inkdeck/ttgo/tc001
|
|
98
|
+
* plugged in for flashing while still joined to the AP). Serial is the more
|
|
99
|
+
* reliable, lower-latency path, so when a board is live on serial we drive it
|
|
100
|
+
* over serial only and suppress the redundant WiFi copy — no board receives the
|
|
101
|
+
* same event twice.
|
|
102
|
+
*
|
|
103
|
+
* Match is by board id, confirmed by the board's WiFi IP when both sides report
|
|
104
|
+
* one. If the serial side reports no IP (radio parked / pre-DHCP) the board id
|
|
105
|
+
* is the only shared key and we still prefer serial. Pure + exported for tests.
|
|
106
|
+
*/
|
|
107
|
+
export function isWifiTransportRedundant(wifi, serialBoards) {
|
|
108
|
+
if (!wifi || !wifi.board || wifi.board === 'unknown')
|
|
109
|
+
return false;
|
|
110
|
+
for (const s of serialBoards) {
|
|
111
|
+
if (s.board !== wifi.board)
|
|
112
|
+
continue;
|
|
113
|
+
// Same board type on serial: dedup unless both report IPs that differ
|
|
114
|
+
// (two distinct physical units of the same board model).
|
|
115
|
+
if (!s.ip || !wifi.ip || s.ip === wifi.ip)
|
|
116
|
+
return true;
|
|
47
117
|
}
|
|
48
|
-
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
/** Reverse-lookup a WiFi ESP32 socket to its registered {board, ip} identity. */
|
|
121
|
+
function wifiEsp32IdentityForSocket(ws) {
|
|
122
|
+
for (const [key, registeredWs] of wifiEsp32Sockets) {
|
|
123
|
+
if (registeredWs !== ws)
|
|
124
|
+
continue;
|
|
125
|
+
const d = wifiEsp32Devices.get(key);
|
|
126
|
+
return d ? { board: d.board, ip: d.ip } : null;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/** True when this WiFi ESP32 socket's board is also live on USB serial, so its
|
|
131
|
+
* WiFi event copy should be suppressed in favour of the serial path. */
|
|
132
|
+
function isWifiEsp32RedundantWithSerial(ws) {
|
|
133
|
+
return isWifiTransportRedundant(wifiEsp32IdentityForSocket(ws), getSerialReachableBoards());
|
|
134
|
+
}
|
|
135
|
+
function registerWifiEsp32(d, ws) {
|
|
136
|
+
const key = wifiEsp32Key(d);
|
|
137
|
+
const previous = wifiEsp32Devices.get(key);
|
|
138
|
+
const uptimeSec = typeof d.uptimeSec === 'number' ? d.uptimeSec : undefined;
|
|
139
|
+
const resetReason = typeof d.resetReason === 'string' ? d.resetReason : undefined;
|
|
140
|
+
const resetReasonCode = typeof d.resetReasonCode === 'number' ? d.resetReasonCode : undefined;
|
|
141
|
+
wifiEsp32Devices.set(key, {
|
|
142
|
+
board: typeof d.board === 'string' ? d.board : 'unknown',
|
|
143
|
+
version: typeof d.version === 'string' ? d.version : undefined,
|
|
144
|
+
buildHash: typeof d.buildHash === 'string' ? d.buildHash : undefined,
|
|
145
|
+
buildEpoch: typeof d.buildEpoch === 'number' ? d.buildEpoch : undefined,
|
|
146
|
+
ip: typeof d.ip === 'string' ? d.ip : undefined,
|
|
147
|
+
protocolRevision: typeof d.protocolRevision === 'number' ? d.protocolRevision : undefined,
|
|
148
|
+
uptimeSec,
|
|
149
|
+
resetReason,
|
|
150
|
+
resetReasonCode,
|
|
151
|
+
otaSupported: typeof d.otaSupported === 'boolean' ? d.otaSupported : undefined,
|
|
152
|
+
otaSlotCount: typeof d.otaSlotCount === 'number' ? d.otaSlotCount : undefined,
|
|
153
|
+
otaSlotSize: typeof d.otaSlotSize === 'number' ? d.otaSlotSize : undefined,
|
|
154
|
+
otaFreeSketchSpace: typeof d.otaFreeSketchSpace === 'number' ? d.otaFreeSketchSpace : undefined,
|
|
155
|
+
otaReason: typeof d.otaReason === 'string' ? d.otaReason : undefined,
|
|
156
|
+
lastSeenMs: Date.now(),
|
|
157
|
+
});
|
|
158
|
+
wifiEsp32Sockets.set(key, ws);
|
|
159
|
+
if (previous?.uptimeSec != null && uptimeSec != null && uptimeSec + 10 < previous.uptimeSec) {
|
|
160
|
+
log(`[agentdeck] ESP32 reboot observed: ${key} uptime ${previous.uptimeSec}s -> ${uptimeSec}s reset=${resetReason ?? 'unknown'} code=${resetReasonCode ?? 'unknown'}`);
|
|
161
|
+
}
|
|
162
|
+
debug('daemon', `WiFi ESP32 registered: ${key} v${String(d.version ?? '')} build=${String(d.buildHash ?? '')} uptime=${String(uptimeSec ?? '')} reset=${String(resetReason ?? '')}`);
|
|
163
|
+
}
|
|
164
|
+
function touchWifiEsp32Socket(ws) {
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
for (const [key, registeredWs] of wifiEsp32Sockets) {
|
|
167
|
+
if (registeredWs !== ws)
|
|
168
|
+
continue;
|
|
169
|
+
const device = wifiEsp32Devices.get(key);
|
|
170
|
+
if (device)
|
|
171
|
+
device.lastSeenMs = now;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function unregisterWifiEsp32Socket(ws) {
|
|
176
|
+
for (const [key, registeredWs] of wifiEsp32Sockets) {
|
|
177
|
+
if (registeredWs !== ws)
|
|
178
|
+
continue;
|
|
179
|
+
wifiEsp32Sockets.delete(key);
|
|
180
|
+
// Keep the last device_info snapshot until normal age-out. A reconnect after
|
|
181
|
+
// a board reboot must compare the new uptime against the previous one; if we
|
|
182
|
+
// delete here every crash looks like a fresh device.
|
|
183
|
+
debug('daemon', `WiFi ESP32 disconnected: ${key}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function handleEsp32OtaReply(msg) {
|
|
187
|
+
if (msg.type !== 'esp32_ota_ack' && msg.type !== 'esp32_ota_error')
|
|
188
|
+
return false;
|
|
189
|
+
const otaId = typeof msg.otaId === 'string' ? msg.otaId : '';
|
|
190
|
+
const waiter = otaId ? otaWaiters.get(otaId) : undefined;
|
|
191
|
+
if (!waiter)
|
|
192
|
+
return true;
|
|
193
|
+
if (msg.type === 'esp32_ota_error') {
|
|
194
|
+
clearTimeout(waiter.timer);
|
|
195
|
+
otaWaiters.delete(otaId);
|
|
196
|
+
waiter.reject(new Error(typeof msg.error === 'string' ? msg.error : 'esp32_ota_error'));
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const stage = typeof msg.stage === 'string' ? msg.stage : '';
|
|
200
|
+
const seq = typeof msg.seq === 'number' ? msg.seq : undefined;
|
|
201
|
+
if (stage !== waiter.stage)
|
|
202
|
+
return true;
|
|
203
|
+
if (waiter.seq != null && seq !== waiter.seq)
|
|
204
|
+
return true;
|
|
205
|
+
clearTimeout(waiter.timer);
|
|
206
|
+
otaWaiters.delete(otaId);
|
|
207
|
+
waiter.resolve(msg);
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
function waitForOtaAck(otaId, stage, seq, timeoutMs) {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const timer = setTimeout(() => {
|
|
213
|
+
otaWaiters.delete(otaId);
|
|
214
|
+
reject(new Error(`OTA ${stage}${seq != null ? ` #${seq}` : ''} timed out`));
|
|
215
|
+
}, timeoutMs);
|
|
216
|
+
otaWaiters.set(otaId, { stage, seq, resolve, reject, timer });
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function findWifiOtaTarget(target) {
|
|
220
|
+
const matches = [];
|
|
221
|
+
for (const [key, device] of wifiEsp32Devices) {
|
|
222
|
+
const ws = wifiEsp32Sockets.get(key);
|
|
223
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
224
|
+
continue;
|
|
225
|
+
if (key === target || device.board === target || device.ip === target) {
|
|
226
|
+
matches.push({ key, device, ws });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (matches.length === 0)
|
|
230
|
+
throw new Error(`No online WiFi ESP32 target matches "${target}"`);
|
|
231
|
+
if (matches.length > 1)
|
|
232
|
+
throw new Error(`Target "${target}" is ambiguous: ${matches.map(m => m.key).join(', ')}`);
|
|
233
|
+
return matches[0];
|
|
234
|
+
}
|
|
235
|
+
async function readJsonBody(req, maxBytes = 64 * 1024) {
|
|
236
|
+
const chunks = [];
|
|
237
|
+
let total = 0;
|
|
238
|
+
for await (const chunk of req) {
|
|
239
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
240
|
+
total += buf.length;
|
|
241
|
+
if (total > maxBytes)
|
|
242
|
+
throw new Error('request_body_too_large');
|
|
243
|
+
chunks.push(buf);
|
|
244
|
+
}
|
|
245
|
+
if (chunks.length === 0)
|
|
49
246
|
return {};
|
|
247
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
248
|
+
}
|
|
249
|
+
// WiFi OTA ack timeouts. The transport is a WebSocket over TCP, so a chunk/ack
|
|
250
|
+
// is never lost while the connection is alive — a "timeout" means the board is
|
|
251
|
+
// slow (a classic ESP32 like TTGO/TC001 can stall past several seconds on a
|
|
252
|
+
// flash-sector erase or a brief WiFi-stack starvation) or the WiFi link briefly
|
|
253
|
+
// blipped and TCP is retransmitting. Both recover if we wait, so the timeouts
|
|
254
|
+
// are generous: a healthy board still acks in milliseconds, and these only cap
|
|
255
|
+
// how long we ride out a stall before declaring the transfer dead. Resending on
|
|
256
|
+
// the SAME live socket would be wrong — it desyncs the firmware's strict
|
|
257
|
+
// seq/offset cursor (handleOtaChunk → "unexpected_offset"); a timeout followed
|
|
258
|
+
// by a board RECONNECT is the one case where resend is correct (see
|
|
259
|
+
// performWifiEsp32Ota — the board never processed the lost chunk, and its otaRx
|
|
260
|
+
// cursor persisted across the reconnect). `let` (not const) so tests can shrink
|
|
261
|
+
// them via __setOtaTimeoutsForTest; production never mutates them.
|
|
262
|
+
let OTA_BEGIN_ACK_TIMEOUT_MS = 15_000; // erasing/preparing the target OTA slot
|
|
263
|
+
let OTA_CHUNK_ACK_TIMEOUT_MS = 30_000; // slow classic-ESP32 flash write / WiFi stall
|
|
264
|
+
let OTA_END_ACK_TIMEOUT_MS = 30_000; // whole-image MD5 verify + set-boot-partition
|
|
265
|
+
// How long to wait for a board to re-register a live WS after it drops its
|
|
266
|
+
// socket mid-OTA, and the cap on such reconnect-driven resends per transfer.
|
|
267
|
+
// Serial-backtrace diagnosis of the TTGO 2.5MB WiFi OTA showed the board does
|
|
268
|
+
// NOT reset/brownout/watchdog mid-transfer — it survives, prints "[WS]
|
|
269
|
+
// Disconnected", and re-registers under the SAME board:ip key with a NEW socket
|
|
270
|
+
// ~3-4s later (classic single-core ESP32 WiFi-flash coexistence briefly starves
|
|
271
|
+
// the TCP task → the connection drops, not the board). The old code captured the
|
|
272
|
+
// socket once and kept sending into the dead one, so the transfer stalled at the
|
|
273
|
+
// disconnect and failed 30s later on the chunk-ack timeout. Following the board
|
|
274
|
+
// to its reconnected socket lets the transfer resume where it stalled, because
|
|
275
|
+
// acks route by otaId (socket-independent) and the firmware keeps its otaRx
|
|
276
|
+
// cursor across the reconnect.
|
|
277
|
+
let OTA_RECONNECT_WAIT_MS = 20_000;
|
|
278
|
+
const OTA_MAX_RECONNECT_RESENDS = 12;
|
|
279
|
+
// ── Test-only seams for the WiFi-OTA reconnect-follow path ───────────────────
|
|
280
|
+
// The reconnect-follow bug is a socket-lifecycle race that hardware reproduces
|
|
281
|
+
// only probabilistically (a WiFi-flash coexistence drop). These exports let a
|
|
282
|
+
// deterministic unit test register/replace a board's socket mid-transfer and
|
|
283
|
+
// drive acks by otaId, with the ack timeouts shrunk so the drop→timeout→resend
|
|
284
|
+
// cycle runs in milliseconds. Not used by production code.
|
|
285
|
+
/** @internal */
|
|
286
|
+
export function __setOtaTimeoutsForTest(t) {
|
|
287
|
+
if (t.begin != null)
|
|
288
|
+
OTA_BEGIN_ACK_TIMEOUT_MS = t.begin;
|
|
289
|
+
if (t.chunk != null)
|
|
290
|
+
OTA_CHUNK_ACK_TIMEOUT_MS = t.chunk;
|
|
291
|
+
if (t.end != null)
|
|
292
|
+
OTA_END_ACK_TIMEOUT_MS = t.end;
|
|
293
|
+
if (t.reconnectWait != null)
|
|
294
|
+
OTA_RECONNECT_WAIT_MS = t.reconnectWait;
|
|
295
|
+
}
|
|
296
|
+
/** @internal */
|
|
297
|
+
export function __resetWifiEsp32OtaState() {
|
|
298
|
+
for (const [, w] of otaWaiters)
|
|
299
|
+
clearTimeout(w.timer);
|
|
300
|
+
otaWaiters.clear();
|
|
301
|
+
wifiEsp32Devices.clear();
|
|
302
|
+
wifiEsp32Sockets.clear();
|
|
303
|
+
OTA_BEGIN_ACK_TIMEOUT_MS = 15_000;
|
|
304
|
+
OTA_CHUNK_ACK_TIMEOUT_MS = 30_000;
|
|
305
|
+
OTA_END_ACK_TIMEOUT_MS = 30_000;
|
|
306
|
+
OTA_RECONNECT_WAIT_MS = 20_000;
|
|
307
|
+
}
|
|
308
|
+
/** @internal */
|
|
309
|
+
export const __wifiOtaTestApi = {
|
|
310
|
+
registerWifiEsp32,
|
|
311
|
+
unregisterWifiEsp32Socket,
|
|
312
|
+
handleEsp32OtaReply,
|
|
313
|
+
performWifiEsp32Ota,
|
|
314
|
+
};
|
|
315
|
+
async function performWifiEsp32Ota(core, target, firmwarePath) {
|
|
316
|
+
const { key, device } = findWifiOtaTarget(target);
|
|
317
|
+
if (device.otaSupported !== true) {
|
|
318
|
+
throw new Error(`Target ${key} does not report OTA support${device.otaReason ? ` (${device.otaReason})` : ''}`);
|
|
319
|
+
}
|
|
320
|
+
const firmware = readFileSync(firmwarePath);
|
|
321
|
+
if (device.otaSlotSize && firmware.length > device.otaSlotSize) {
|
|
322
|
+
throw new Error(`Firmware is ${firmware.length} bytes, OTA slot is ${device.otaSlotSize} bytes`);
|
|
50
323
|
}
|
|
324
|
+
const otaId = randomUUID();
|
|
325
|
+
const md5 = createHash('md5').update(firmware).digest('hex');
|
|
326
|
+
// Always resolve the board's CURRENTLY-registered socket, never a captured
|
|
327
|
+
// reference — the board re-registers a fresh socket on a mid-OTA reconnect.
|
|
328
|
+
const liveSocket = () => {
|
|
329
|
+
const s = wifiEsp32Sockets.get(key);
|
|
330
|
+
return s && s.readyState === WebSocket.OPEN ? s : undefined;
|
|
331
|
+
};
|
|
332
|
+
// Wait up to ms for a live socket (rides out the ~3-4s reconnect gap).
|
|
333
|
+
const awaitLiveSocket = async (ms) => {
|
|
334
|
+
const deadline = Date.now() + ms;
|
|
335
|
+
let s = liveSocket();
|
|
336
|
+
while (!s && Date.now() < deadline) {
|
|
337
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
338
|
+
s = liveSocket();
|
|
339
|
+
}
|
|
340
|
+
return s;
|
|
341
|
+
};
|
|
342
|
+
const sendVia = (sock, evt) => core.wsServer.sendTo(sock, evt);
|
|
343
|
+
let reconnectResends = 0;
|
|
344
|
+
// Send one OTA frame and wait for its ack, following the board to a fresh
|
|
345
|
+
// socket if it reconnects mid-flight. On an ack timeout, if a NEW live socket
|
|
346
|
+
// has appeared (the board reconnected), the frame was lost to the dead socket
|
|
347
|
+
// — resend the same seq on the new socket once. The firmware's otaRx cursor
|
|
348
|
+
// persists across the reconnect, so a resend of the not-yet-processed seq is
|
|
349
|
+
// accepted; a genuinely-gone board still fails after OTA_RECONNECT_WAIT_MS.
|
|
350
|
+
const sendAndAck = async (evt, stage, seq, timeoutMs) => {
|
|
351
|
+
let sock = await awaitLiveSocket(OTA_RECONNECT_WAIT_MS);
|
|
352
|
+
if (!sock)
|
|
353
|
+
throw new Error(`OTA ${stage}${seq != null ? ` #${seq}` : ''}: board ${key} offline (no live WS)`);
|
|
354
|
+
sendVia(sock, evt);
|
|
355
|
+
try {
|
|
356
|
+
await waitForOtaAck(otaId, stage, seq, timeoutMs);
|
|
357
|
+
}
|
|
358
|
+
catch (ackErr) {
|
|
359
|
+
const fresh = await awaitLiveSocket(OTA_RECONNECT_WAIT_MS);
|
|
360
|
+
if (fresh && fresh !== sock && reconnectResends < OTA_MAX_RECONNECT_RESENDS) {
|
|
361
|
+
reconnectResends++;
|
|
362
|
+
debug('daemon', `OTA ${key}: ${stage}${seq != null ? ` #${seq}` : ''} ack lost on dropped socket — resending on reconnected WS (resend ${reconnectResends})`);
|
|
363
|
+
sendVia(fresh, evt);
|
|
364
|
+
await waitForOtaAck(otaId, stage, seq, timeoutMs);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
throw ackErr;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
try {
|
|
371
|
+
await sendAndAck({ type: 'esp32_ota_begin', otaId, size: firmware.length, md5 }, 'begin', undefined, OTA_BEGIN_ACK_TIMEOUT_MS);
|
|
372
|
+
const chunkSize = 1024;
|
|
373
|
+
let offset = 0;
|
|
374
|
+
let seq = 0;
|
|
375
|
+
while (offset < firmware.length) {
|
|
376
|
+
const chunk = firmware.subarray(offset, Math.min(offset + chunkSize, firmware.length));
|
|
377
|
+
await sendAndAck({
|
|
378
|
+
type: 'esp32_ota_chunk',
|
|
379
|
+
otaId,
|
|
380
|
+
seq,
|
|
381
|
+
offset,
|
|
382
|
+
data: chunk.toString('base64'),
|
|
383
|
+
}, 'chunk', seq, OTA_CHUNK_ACK_TIMEOUT_MS);
|
|
384
|
+
offset += chunk.length;
|
|
385
|
+
seq++;
|
|
386
|
+
}
|
|
387
|
+
await sendAndAck({ type: 'esp32_ota_end', otaId }, 'end', undefined, OTA_END_ACK_TIMEOUT_MS);
|
|
388
|
+
wifiEsp32Sockets.delete(key);
|
|
389
|
+
wifiEsp32Devices.delete(key);
|
|
390
|
+
return {
|
|
391
|
+
ok: true,
|
|
392
|
+
target: key,
|
|
393
|
+
board: device.board,
|
|
394
|
+
bytes: firmware.length,
|
|
395
|
+
chunks: seq,
|
|
396
|
+
reconnectResends,
|
|
397
|
+
md5,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
const sock = liveSocket();
|
|
402
|
+
if (sock) {
|
|
403
|
+
try {
|
|
404
|
+
sendVia(sock, { type: 'esp32_ota_abort', otaId });
|
|
405
|
+
}
|
|
406
|
+
catch { /* best effort */ }
|
|
407
|
+
}
|
|
408
|
+
throw err;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function loadDaemonSettings() {
|
|
412
|
+
// Newest settings.json across candidate data dirs — mirrors the
|
|
413
|
+
// daemon.json/sessions.json cross-dir discovery (and honors the
|
|
414
|
+
// AGENTDECK_DATA_DIR test override, which the old hardcoded
|
|
415
|
+
// ~/.agentdeck path ignored). The App Store sandbox container is
|
|
416
|
+
// intentionally NOT a candidate (TCC hang risk — see
|
|
417
|
+
// getCandidateDataDirs), so settings written by the sandboxed Swift
|
|
418
|
+
// app stay invisible here; that coexistence limit is documented in
|
|
419
|
+
// docs/appstore-feature-matrix.md.
|
|
420
|
+
let best = null;
|
|
421
|
+
for (const dir of getCandidateDataDirs()) {
|
|
422
|
+
try {
|
|
423
|
+
const path = join(dir, 'settings.json');
|
|
424
|
+
const mtime = statSync(path).mtimeMs;
|
|
425
|
+
if (best && best.mtime >= mtime)
|
|
426
|
+
continue;
|
|
427
|
+
best = { mtime, parsed: JSON.parse(readFileSync(path, 'utf-8')) };
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Missing or unreadable — skip this candidate.
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return best?.parsed ?? {};
|
|
434
|
+
}
|
|
435
|
+
function latestTimelinePath() {
|
|
436
|
+
let best = null;
|
|
437
|
+
for (const dir of getCandidateDataDirs()) {
|
|
438
|
+
try {
|
|
439
|
+
const path = join(dir, 'timeline.json');
|
|
440
|
+
const mtime = statSync(path).mtimeMs;
|
|
441
|
+
if (!best || mtime > best.mtime)
|
|
442
|
+
best = { path, mtime };
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// Missing or unreadable — skip this candidate.
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return best?.path ?? null;
|
|
51
449
|
}
|
|
450
|
+
// Observed-session attention history: the held PreToolUse device-approval gate
|
|
451
|
+
// was removed on 2026-06-27 and stays removed — PreToolUse fires even for tools
|
|
452
|
+
// Claude auto-approves, so gating on it produced false attention + a fabricated
|
|
453
|
+
// Allow/Deny that never matched Claude's real prompt. The *display-only*
|
|
454
|
+
// Notification overlay was restored on 2026-07-05: `notification_type:
|
|
455
|
+
// "permission_prompt"` fires only when a permission prompt is actually shown to
|
|
456
|
+
// the user, so it is a genuine awaiting signal. Observed sessions surface
|
|
457
|
+
// awaiting + question with NO requestId (respond-in-terminal UX); steering with
|
|
458
|
+
// real options still exists only on PTY-managed sessions (`agentdeck claude`).
|
|
52
459
|
function log(msg) {
|
|
53
|
-
|
|
460
|
+
// Timestamped like logger.ts — daemon stderr lands in a long-lived log file,
|
|
461
|
+
// and un-datable restart/incident lines repeatedly blocked root-cause work.
|
|
462
|
+
process.stderr.write(`${new Date().toISOString()} ${msg}\n`);
|
|
54
463
|
}
|
|
55
464
|
async function fetchUsageViaHttp(siblings) {
|
|
56
465
|
for (const sibling of siblings) {
|
|
@@ -126,6 +535,60 @@ async function fetchUsageRelayed(selfPort) {
|
|
|
126
535
|
debug('daemon', 'No siblings, using direct API');
|
|
127
536
|
return fetchUsageFromApi();
|
|
128
537
|
}
|
|
538
|
+
/**
|
|
539
|
+
* Stamp OpenClaw origin onto a timeline entry emitted by the Gateway adapter.
|
|
540
|
+
*
|
|
541
|
+
* The adapter emits bare entries (no agentType/projectName); without this the
|
|
542
|
+
* BridgeCore attributor falls back to the daemon's hardcoded projectName
|
|
543
|
+
* ('AgentDeck') and leaves agentType null, so OpenClaw cron activity gets
|
|
544
|
+
* mis-grouped under AgentDeck and never renders as OpenClaw. `?? ` fallbacks
|
|
545
|
+
* preserve any value the adapter did set, and the downstream attributor's own
|
|
546
|
+
* `?? ` keeps these in turn.
|
|
547
|
+
*/
|
|
548
|
+
export function enrichGatewayTimelineEntry(entry) {
|
|
549
|
+
return {
|
|
550
|
+
...entry,
|
|
551
|
+
agentType: entry.agentType ?? 'openclaw',
|
|
552
|
+
projectName: entry.projectName ?? 'OpenClaw',
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Classify a `/hooks/<eventName>` POST for the observed-session pipeline.
|
|
557
|
+
*
|
|
558
|
+
* Claude hooks arrive as PascalCase names (already snake_cased by the
|
|
559
|
+
* caller's eventMap → `mapped`); Codex lifecycle hooks (installed into
|
|
560
|
+
* `~/.codex/config.toml`) arrive as `codex_*`; the AgentDeck OpenCode
|
|
561
|
+
* observer plugin posts `opencode_*`. All three share one pipeline —
|
|
562
|
+
* APME run/turn management, the chat_start row, and the stop-completion
|
|
563
|
+
* row — keyed off the returned agent-neutral `boundary`, so a direct
|
|
564
|
+
* `codex` or standalone `opencode` run gets the same prompt → response
|
|
565
|
+
* turn shape on the timeline as a direct `claude` run. (The Swift daemon
|
|
566
|
+
* has carried Codex parity since its observation pass —
|
|
567
|
+
* DaemonServer.swift appendCodexChatStart/End; this closes the same gap
|
|
568
|
+
* on the Node daemon.)
|
|
569
|
+
*
|
|
570
|
+
* Codex notify's `turn_complete` maps to `stop`: it closes a turn the
|
|
571
|
+
* same way, and the caller's open-turn guard collapses a stop +
|
|
572
|
+
* turn_complete pair into a single completion row.
|
|
573
|
+
*
|
|
574
|
+
* `antigravity_*` is accepted for forward-compatibility: Antigravity has
|
|
575
|
+
* no hook installer yet (the IDE exposes no lifecycle hook config; its
|
|
576
|
+
* sessions surface via PassiveSessionObserver transcript polling), but
|
|
577
|
+
* anything that starts POSTing these names gets full turn parity with no
|
|
578
|
+
* daemon change.
|
|
579
|
+
*/
|
|
580
|
+
export function classifyObservedHookEvent(eventName, mapped) {
|
|
581
|
+
const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification)$/
|
|
582
|
+
.exec(eventName);
|
|
583
|
+
if (!prefixed)
|
|
584
|
+
return { boundary: mapped, agentType: 'claude-code' };
|
|
585
|
+
return {
|
|
586
|
+
boundary: prefixed[2] === 'turn_complete' ? 'stop' : prefixed[2],
|
|
587
|
+
agentType: prefixed[1] === 'codex' ? 'codex-cli'
|
|
588
|
+
: prefixed[1] === 'opencode' ? 'opencode'
|
|
589
|
+
: 'antigravity',
|
|
590
|
+
};
|
|
591
|
+
}
|
|
129
592
|
function buildNodeModuleHealth(startedModules) {
|
|
130
593
|
const started = new Set(startedModules.map((m) => m.name));
|
|
131
594
|
const modules = {};
|
|
@@ -140,10 +603,6 @@ function buildNodeModuleHealth(startedModules) {
|
|
|
140
603
|
lastError: adbAvailable ? null : 'adb not found',
|
|
141
604
|
};
|
|
142
605
|
}
|
|
143
|
-
const d200h = startedModules.find((m) => m.name === 'd200h');
|
|
144
|
-
if (d200h?.statusSnapshot) {
|
|
145
|
-
modules.d200h = d200h.statusSnapshot();
|
|
146
|
-
}
|
|
147
606
|
if (started.has('pixoo') || pixooDeviceCount() > 0) {
|
|
148
607
|
const details = getPixooDeviceDetails();
|
|
149
608
|
modules.pixoo = {
|
|
@@ -160,21 +619,72 @@ function buildNodeModuleHealth(startedModules) {
|
|
|
160
619
|
})),
|
|
161
620
|
};
|
|
162
621
|
}
|
|
622
|
+
const timebox = startedModules.find((m) => m.name === 'timebox');
|
|
623
|
+
const configuredTimebox = loadTimeboxDevices();
|
|
624
|
+
if (timebox?.statusSnapshot) {
|
|
625
|
+
modules.timebox = timebox.statusSnapshot();
|
|
626
|
+
}
|
|
627
|
+
else if (configuredTimebox.length > 0) {
|
|
628
|
+
modules.timebox = {
|
|
629
|
+
configuredDeviceCount: configuredTimebox.length,
|
|
630
|
+
devices: configuredTimebox.map((d) => ({
|
|
631
|
+
address: d.address,
|
|
632
|
+
name: d.name ?? 'Timebox Mini',
|
|
633
|
+
brightness: d.brightness ?? 100,
|
|
634
|
+
})),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
const idotmatrix = startedModules.find((m) => m.name === 'idotmatrix');
|
|
638
|
+
const configuredIDotMatrix = loadIDotMatrixDevices();
|
|
639
|
+
if (idotmatrix?.statusSnapshot) {
|
|
640
|
+
modules.idotmatrix = idotmatrix.statusSnapshot();
|
|
641
|
+
}
|
|
642
|
+
else if (configuredIDotMatrix.length > 0) {
|
|
643
|
+
modules.idotmatrix = {
|
|
644
|
+
configuredDeviceCount: configuredIDotMatrix.length,
|
|
645
|
+
devices: configuredIDotMatrix.map((d) => ({
|
|
646
|
+
address: d.address,
|
|
647
|
+
name: d.name ?? 'iDotMatrix',
|
|
648
|
+
brightness: d.brightness ?? 100,
|
|
649
|
+
})),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
163
652
|
if (started.has('serial')) {
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
653
|
+
const connectionStatus = getSerialConnectionStatus();
|
|
654
|
+
const connections = connectionStatus.map((status) => ({
|
|
655
|
+
port: status.port,
|
|
656
|
+
connected: status.connected,
|
|
657
|
+
transportOpen: status.transportOpen,
|
|
658
|
+
deviceInfo: status.board ? {
|
|
659
|
+
board: status.board,
|
|
660
|
+
version: status.version,
|
|
661
|
+
buildHash: status.buildHash,
|
|
662
|
+
buildEpoch: status.buildEpoch,
|
|
663
|
+
wifiConfigured: status.wifiConfigured,
|
|
664
|
+
wifiConnected: status.wifiConnected,
|
|
665
|
+
wifiRadioParked: status.wifiRadioParked,
|
|
666
|
+
uptimeSec: status.uptimeSec,
|
|
667
|
+
otaSupported: status.otaSupported,
|
|
668
|
+
otaSlotCount: status.otaSlotCount,
|
|
669
|
+
otaSlotSize: status.otaSlotSize,
|
|
670
|
+
otaFreeSketchSpace: status.otaFreeSketchSpace,
|
|
671
|
+
otaReason: status.otaReason,
|
|
672
|
+
timelineCount: status.timelineCount,
|
|
673
|
+
sessionCount: status.sessionCount,
|
|
674
|
+
usageFiveH: status.usageFiveH,
|
|
675
|
+
processingCount: status.processingCount,
|
|
676
|
+
} : null,
|
|
677
|
+
lastReadAt: status.lastReadAt,
|
|
678
|
+
lastWriteAt: status.lastWriteAt,
|
|
679
|
+
lastReadSecondsAgo: status.lastReadSecondsAgo,
|
|
680
|
+
lastWriteSecondsAgo: status.lastWriteSecondsAgo,
|
|
681
|
+
stale: status.stale,
|
|
173
682
|
}));
|
|
174
683
|
modules.serial = {
|
|
175
|
-
connectedPorts: connections.map((c) => c.port),
|
|
684
|
+
connectedPorts: connections.filter((c) => c.connected).map((c) => c.port),
|
|
176
685
|
connections,
|
|
177
|
-
lastError:
|
|
686
|
+
lastError: getSerialLastError(),
|
|
687
|
+
connectionCount: connections.filter((c) => c.connected).length,
|
|
178
688
|
};
|
|
179
689
|
}
|
|
180
690
|
return modules;
|
|
@@ -192,13 +702,44 @@ export async function startDaemon(opts) {
|
|
|
192
702
|
// 1. Check daemon.json and sessions.json for existing daemon
|
|
193
703
|
const existingInfo = readDaemonInfo();
|
|
194
704
|
if (existingInfo) {
|
|
195
|
-
|
|
196
|
-
|
|
705
|
+
const probePort = existingInfo.httpPort ?? existingInfo.port;
|
|
706
|
+
const health = await probeDaemonHealth(probePort);
|
|
707
|
+
if (health?.mode === 'daemon') {
|
|
708
|
+
if (health.isSwift) {
|
|
709
|
+
log(`[agentdeck] Swift daemon detected on port ${probePort}. Requesting shutdown to take over...`);
|
|
710
|
+
await requestDaemonShutdown(probePort);
|
|
711
|
+
await waitForDaemonExit(probePort);
|
|
712
|
+
removeDaemonInfo();
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
log(`[agentdeck] Daemon already running on port ${existingInfo.port} (PID ${existingInfo.pid}).`);
|
|
716
|
+
process.exit(0);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
log(`[agentdeck] Ignoring stale daemon entry on port ${existingInfo.port} (PID ${existingInfo.pid}; /health did not respond).`);
|
|
721
|
+
removeDaemonInfo();
|
|
722
|
+
}
|
|
197
723
|
}
|
|
198
724
|
const existingSession = findExistingDaemon();
|
|
199
725
|
if (existingSession) {
|
|
200
|
-
|
|
201
|
-
|
|
726
|
+
const health = await probeDaemonHealth(existingSession.port);
|
|
727
|
+
if (health?.mode === 'daemon') {
|
|
728
|
+
if (health.isSwift) {
|
|
729
|
+
log(`[agentdeck] Swift daemon detected on port ${existingSession.port}. Requesting shutdown to take over...`);
|
|
730
|
+
await requestDaemonShutdown(existingSession.port);
|
|
731
|
+
await waitForDaemonExit(existingSession.port);
|
|
732
|
+
removeDaemonSession(existingSession);
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
log(`[agentdeck] Daemon already running on port ${existingSession.port} (PID ${existingSession.pid}).`);
|
|
736
|
+
process.exit(0);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
log(`[agentdeck] Ignoring stale daemon session on port ${existingSession.port} (PID ${existingSession.pid}; /health did not respond).`);
|
|
741
|
+
removeDaemonSession(existingSession);
|
|
742
|
+
}
|
|
202
743
|
}
|
|
203
744
|
// 2. Determine port — try default first, fallback if occupied by non-daemon
|
|
204
745
|
const requestedPort = opts.port ?? DAEMON_DEFAULT_PORT;
|
|
@@ -208,24 +749,109 @@ export async function startDaemon(opts) {
|
|
|
208
749
|
const health = await probeDaemonHealth(requestedPort);
|
|
209
750
|
if (health) {
|
|
210
751
|
if (health.mode === 'daemon') {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
752
|
+
if (health.isSwift) {
|
|
753
|
+
log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
|
|
754
|
+
await requestDaemonShutdown(requestedPort);
|
|
755
|
+
await waitForDaemonExit(requestedPort);
|
|
756
|
+
}
|
|
757
|
+
else {
|
|
758
|
+
// Daemon alive but not in our registry — race condition or stale state
|
|
759
|
+
log(`[agentdeck] Daemon already running on port ${requestedPort} (detected via /health).`);
|
|
760
|
+
process.exit(0);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
// Port occupied by non-daemon (e.g. session bridge) — find alternative
|
|
765
|
+
log(`[agentdeck] Port ${requestedPort} occupied (${health.mode ?? 'unknown'}), finding alternative...`);
|
|
766
|
+
port = await findAvailablePort();
|
|
214
767
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// 3. Cross-implementation sweep of the daemon port window. File discovery
|
|
771
|
+
// has two blind spots that the checks above never cover: the App Store
|
|
772
|
+
// Swift daemon writes daemon.json into its private sandbox container (this
|
|
773
|
+
// process cannot read it), and transient 9120 contention can leave a daemon
|
|
774
|
+
// sitting on a fallback port (9121+). Missing it here means two live
|
|
775
|
+
// daemons: double mDNS advertising, duplicate Gateway/timeline relay, and
|
|
776
|
+
// adb-reverse flapping. Swift occupants are evicted (same policy as above);
|
|
777
|
+
// a live Node daemon wins and we exit.
|
|
778
|
+
const strayDaemons = await scanDaemonPortWindow(new Set([requestedPort]));
|
|
779
|
+
for (const stray of strayDaemons) {
|
|
780
|
+
if (stray.health.isSwift) {
|
|
781
|
+
log(`[agentdeck] Swift daemon detected on fallback port ${stray.port}. Requesting shutdown to take over...`);
|
|
782
|
+
await requestDaemonShutdown(stray.port);
|
|
783
|
+
await waitForDaemonExit(stray.port);
|
|
784
|
+
}
|
|
785
|
+
else if (shouldConcedePortToOccupant(stray.health, process.pid)) {
|
|
786
|
+
log(`[agentdeck] Daemon already running on port ${stray.port} (detected via port scan).`);
|
|
787
|
+
process.exit(0);
|
|
218
788
|
}
|
|
219
789
|
}
|
|
220
790
|
log(`[agentdeck] Starting daemon on port ${port}...`);
|
|
221
791
|
// ===== APME (lazy — may be null if better-sqlite3 isn't installed) =====
|
|
222
792
|
let apme = null;
|
|
793
|
+
// Fallback task-row emitter, created only when initApme() returns null —
|
|
794
|
+
// keeps task_start/task_end rows on the timeline without the collector.
|
|
795
|
+
let fallbackTasks = null;
|
|
223
796
|
// Declare early — HTTP /health handler references this in its closure.
|
|
224
797
|
// Must be declared before the HTTP server so it's initialized (not in TDZ)
|
|
225
798
|
// when the first /health request arrives.
|
|
226
799
|
let gatewayAdapter = null;
|
|
227
800
|
let gatewayConnecting = false;
|
|
228
801
|
let moduleHealthProvider = () => ({});
|
|
802
|
+
// Wi-Fi WebSocket e-ink panels (XTeink X3/X4 CrossPoint fork) self-register
|
|
803
|
+
// their device roster via `client_register{clientType:"eink-device"}` — the
|
|
804
|
+
// same volunteer model as the Stream Deck plugin. This handler lived ONLY in
|
|
805
|
+
// the Swift daemon, so when the dashboard talked to the Node daemon (the
|
|
806
|
+
// common case) these devices never surfaced on the Downstream E-ink rail.
|
|
807
|
+
// Keyed by socket; a registration drops the moment its socket closes
|
|
808
|
+
// (filtered lazily in `collectEinkDevices` on readyState), so no separate
|
|
809
|
+
// TTL sweep is needed.
|
|
810
|
+
const einkRegistrations = new Map();
|
|
811
|
+
const collectEinkDevices = () => {
|
|
812
|
+
const out = [];
|
|
813
|
+
for (const [ws, reg] of einkRegistrations) {
|
|
814
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
815
|
+
einkRegistrations.delete(ws);
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
for (const d of reg.devices)
|
|
819
|
+
out.push(d);
|
|
820
|
+
}
|
|
821
|
+
return out;
|
|
822
|
+
};
|
|
823
|
+
// Android dashboard apps (tablet / e-ink launcher) over Wi-Fi WS —
|
|
824
|
+
// `client_register{clientType:"android-dashboard"}`, same volunteer model.
|
|
825
|
+
const androidDashboardRegistrations = new Map();
|
|
826
|
+
const collectAndroidDashboards = () => {
|
|
827
|
+
const out = [];
|
|
828
|
+
for (const [ws, reg] of androidDashboardRegistrations) {
|
|
829
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
830
|
+
androidDashboardRegistrations.delete(ws);
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
for (const d of reg.devices)
|
|
834
|
+
out.push(d);
|
|
835
|
+
}
|
|
836
|
+
return out;
|
|
837
|
+
};
|
|
838
|
+
// Elgato Stream Deck plugin — `client_register{clientType:"streamdeck-plugin"}`
|
|
839
|
+
// with the physical device roster. The Swift daemon has handled this since the
|
|
840
|
+
// Stream Deck topology row shipped; the Node daemon silently dropped it, so on
|
|
841
|
+
// the external-CLI tier the dashboard never showed a Stream Deck row at all.
|
|
842
|
+
const streamDeckRegistrations = new Map();
|
|
843
|
+
const collectStreamDeckDevices = () => {
|
|
844
|
+
const out = [];
|
|
845
|
+
for (const [ws, reg] of streamDeckRegistrations) {
|
|
846
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
847
|
+
streamDeckRegistrations.delete(ws);
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
for (const d of reg.devices)
|
|
851
|
+
out.push(d);
|
|
852
|
+
}
|
|
853
|
+
return out;
|
|
854
|
+
};
|
|
229
855
|
// ===== HTTP server =====
|
|
230
856
|
const httpServer = createServer((req, res) => {
|
|
231
857
|
// APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
|
|
@@ -249,7 +875,8 @@ export async function startDaemon(opts) {
|
|
|
249
875
|
});
|
|
250
876
|
return;
|
|
251
877
|
}
|
|
252
|
-
const
|
|
878
|
+
const parsedUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
879
|
+
const pathname = parsedUrl.pathname;
|
|
253
880
|
// Health check is public (no auth) — used by iOS/Android for pairing token discovery
|
|
254
881
|
if (req.method === 'GET' && pathname === '/health') {
|
|
255
882
|
const snap = core.stateMachine.getSnapshot();
|
|
@@ -257,9 +884,16 @@ export async function startDaemon(opts) {
|
|
|
257
884
|
res.end(JSON.stringify({
|
|
258
885
|
status: 'ok', mode: 'daemon', state: snap.state,
|
|
259
886
|
gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
|
|
260
|
-
uptime: process.uptime(), port,
|
|
887
|
+
uptime: process.uptime(), port, pid: process.pid,
|
|
261
888
|
pairingToken: core.authToken,
|
|
262
889
|
modules: moduleHealthProvider(),
|
|
890
|
+
apme: apme
|
|
891
|
+
? {
|
|
892
|
+
enabled: true,
|
|
893
|
+
dbPath: apme.store.dbPath,
|
|
894
|
+
judgeBackend: apme.runner.lastBackendProbe ?? { status: 'unknown', backend: loadApmeConfig().judge.backend },
|
|
895
|
+
}
|
|
896
|
+
: { enabled: false, error: apme === null ? 'see startup logs (initApme returned null)' : 'unknown' },
|
|
263
897
|
}));
|
|
264
898
|
return;
|
|
265
899
|
}
|
|
@@ -282,7 +916,76 @@ export async function startDaemon(opts) {
|
|
|
282
916
|
}
|
|
283
917
|
if (req.method === 'GET' && pathname === '/devices') {
|
|
284
918
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
285
|
-
|
|
919
|
+
// Direct-HID D200H control was retired; the D200H is driven exclusively by
|
|
920
|
+
// the Ulanzi Studio plugin over WebSocket. Report it connected iff that
|
|
921
|
+
// plugin is attached.
|
|
922
|
+
const ulanziPluginConnected = core.wsServer.getUlanziClientCount() > 0;
|
|
923
|
+
res.end(JSON.stringify({
|
|
924
|
+
devices: [
|
|
925
|
+
{ type: 'websocket', count: core.wsServer.getClientCount() },
|
|
926
|
+
{ type: 'tui', devices: core.wsServer.getTuiClients() },
|
|
927
|
+
{ type: 'esp32', count: esp32ConnectionCount(), ports: getESP32Ports(), devices: getESP32DeviceInfo() },
|
|
928
|
+
{ type: 'esp32-wifi', devices: listWifiEsp32Devices() },
|
|
929
|
+
{ type: 'pixoo', details: getPixooDeviceDetails() },
|
|
930
|
+
{ type: 'timebox', devices: loadTimeboxDevices() },
|
|
931
|
+
{ type: 'idotmatrix', devices: loadIDotMatrixDevices() },
|
|
932
|
+
{ type: 'adb', count: getAdbDeviceCount() },
|
|
933
|
+
{
|
|
934
|
+
type: 'd200h',
|
|
935
|
+
connected: ulanziPluginConnected,
|
|
936
|
+
ulanziPluginConnected,
|
|
937
|
+
},
|
|
938
|
+
],
|
|
939
|
+
modules: moduleHealthProvider(),
|
|
940
|
+
}));
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
if (req.method === 'POST' && pathname === '/esp32/ota') {
|
|
944
|
+
(async () => {
|
|
945
|
+
const body = await readJsonBody(req);
|
|
946
|
+
const target = typeof body.target === 'string' ? body.target : '';
|
|
947
|
+
const firmwarePath = typeof body.firmwarePath === 'string' ? body.firmwarePath : '';
|
|
948
|
+
if (!target || !firmwarePath) {
|
|
949
|
+
throw new Error('target and firmwarePath are required');
|
|
950
|
+
}
|
|
951
|
+
return performWifiEsp32Ota(core, target, firmwarePath);
|
|
952
|
+
})().then((result) => {
|
|
953
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
954
|
+
res.end(JSON.stringify(result));
|
|
955
|
+
}).catch((err) => {
|
|
956
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
957
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
958
|
+
});
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
if (req.method === 'GET' && pathname === '/display-state') {
|
|
962
|
+
res.writeHead(200, {
|
|
963
|
+
'Content-Type': 'application/json',
|
|
964
|
+
'Cache-Control': 'no-store',
|
|
965
|
+
'Access-Control-Allow-Origin': '*',
|
|
966
|
+
});
|
|
967
|
+
res.end(JSON.stringify(buildDisplayStateEvent(core.displayMonitor.isDisplayOn())));
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (req.method === 'GET' && pathname === '/diag') {
|
|
971
|
+
const snap = core.stateMachine.getSnapshot();
|
|
972
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
973
|
+
res.end(JSON.stringify({
|
|
974
|
+
timestamp: new Date().toISOString(),
|
|
975
|
+
sessionInfo: {
|
|
976
|
+
state: snap.state,
|
|
977
|
+
permissionMode: snap.permissionMode,
|
|
978
|
+
suggestedPrompt: snap.suggestedPrompt,
|
|
979
|
+
lastValidSuggestedPrompt: core.stateMachine.getLastValidSuggestedPrompt(),
|
|
980
|
+
projectName: snap.projectName,
|
|
981
|
+
modelName: snap.modelName,
|
|
982
|
+
billingType: snap.billingType,
|
|
983
|
+
},
|
|
984
|
+
wsClients: core.wsServer.getClientCount(),
|
|
985
|
+
recentJournal: [],
|
|
986
|
+
ptyTail: '',
|
|
987
|
+
journalDir: join(homedir(), '.agentdeck'),
|
|
988
|
+
}));
|
|
286
989
|
return;
|
|
287
990
|
}
|
|
288
991
|
if (req.method === 'GET' && pathname === '/pixoo/stream') {
|
|
@@ -319,8 +1022,14 @@ export async function startDaemon(opts) {
|
|
|
319
1022
|
return;
|
|
320
1023
|
}
|
|
321
1024
|
if (req.method === 'GET' && pathname === '/pixoo/frame') {
|
|
322
|
-
const
|
|
323
|
-
const
|
|
1025
|
+
const sizeParam = parsedUrl.searchParams.get('size');
|
|
1026
|
+
const size = sizeParam === '11' ? 11 : sizeParam === '32' ? 32 : 64;
|
|
1027
|
+
const layout = parsedUrl.searchParams.get('layout') === 'micro' ? 'micro' : 'standard';
|
|
1028
|
+
// The frame cache holds the standard terrarium, so render micro fresh.
|
|
1029
|
+
const rgb = layout === 'micro'
|
|
1030
|
+
? renderPreviewFrame(size, 'micro')
|
|
1031
|
+
: (getLastFrame(size) ?? renderPreviewFrame(size));
|
|
1032
|
+
const bmp = rgbToBmp(rgb, size, size);
|
|
324
1033
|
res.writeHead(200, {
|
|
325
1034
|
'Content-Type': 'image/bmp',
|
|
326
1035
|
'Cache-Control': 'no-store',
|
|
@@ -362,6 +1071,13 @@ export async function startDaemon(opts) {
|
|
|
362
1071
|
Notification: 'notification',
|
|
363
1072
|
};
|
|
364
1073
|
const mapped = eventMap[eventName] ?? eventName;
|
|
1074
|
+
// Agent-prefixed lifecycle hooks (codex_* / opencode_*) reuse the
|
|
1075
|
+
// same observed-session pipeline as Claude's PascalCase hooks —
|
|
1076
|
+
// `boundary` is the agent-neutral semantic every block below keys
|
|
1077
|
+
// off. State-machine calls stay Claude-only (`mapped`): observed
|
|
1078
|
+
// codex/opencode *state* is owned by the passive observer's turn
|
|
1079
|
+
// semantics, not these hooks.
|
|
1080
|
+
const { boundary, agentType: hookAgentType } = classifyObservedHookEvent(eventName, mapped);
|
|
365
1081
|
// State machine
|
|
366
1082
|
if (mapped === 'session_start')
|
|
367
1083
|
core.stateMachine.handleHookEvent('SessionStart', json);
|
|
@@ -377,36 +1093,370 @@ export async function startDaemon(opts) {
|
|
|
377
1093
|
else if (mapped === 'tool_end') {
|
|
378
1094
|
core.stateMachine.handleHookEvent('PostToolUse', json);
|
|
379
1095
|
}
|
|
380
|
-
//
|
|
1096
|
+
// Per-session awaiting overlay (observed/direct-`claude` sessions),
|
|
1097
|
+
// display-only. Claude emits Notification with `notification_type:
|
|
1098
|
+
// "permission_prompt"` only when a permission prompt is actually shown
|
|
1099
|
+
// to the user (auto-approved tools fire PreToolUse but never this), so
|
|
1100
|
+
// it is a genuine "waiting for explicit response" signal — unlike the
|
|
1101
|
+
// removed PreToolUse gate. No requestId is ever set: surfaces render
|
|
1102
|
+
// awaiting + question with the respond-in-terminal fallback, never a
|
|
1103
|
+
// fabricated Allow/Deny. Keyed by Claude's own session_id and merged
|
|
1104
|
+
// into the observed-session list at enrich time (awaiting-overlay.ts).
|
|
1105
|
+
const claudeSid = typeof json.session_id === 'string' ? json.session_id : undefined;
|
|
1106
|
+
if (claudeSid) {
|
|
1107
|
+
if (mapped === 'notification') {
|
|
1108
|
+
const message = typeof json.message === 'string' ? json.message : '';
|
|
1109
|
+
const notificationType = typeof json.notification_type === 'string' ? json.notification_type : undefined;
|
|
1110
|
+
if (isPermissionNotification(notificationType, message)) {
|
|
1111
|
+
setAwaitingOverlay(claudeSid, message);
|
|
1112
|
+
// Broadcast immediately rather than waiting for the 2s debounce
|
|
1113
|
+
// or the 5s observer tick, so the prompt surfaces within one frame.
|
|
1114
|
+
core.broadcastSessionsList().catch(() => { });
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
else if (mapped === 'tool_start' || mapped === 'tool_end' ||
|
|
1118
|
+
mapped === 'user_prompt_submit' || mapped === 'stop' ||
|
|
1119
|
+
mapped === 'session_start' || mapped === 'session_end') {
|
|
1120
|
+
// Any subsequent hook means the prompt was answered — drop the
|
|
1121
|
+
// overlay. Only rebroadcast if there was actually one to clear
|
|
1122
|
+
// (direct-`claude` sessions fire tool hooks constantly).
|
|
1123
|
+
if (clearAwaitingOverlay(claudeSid)) {
|
|
1124
|
+
core.broadcastSessionsList().catch(() => { });
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
// Observed-session activity log. Managed sessions relay their timeline
|
|
1129
|
+
// from the session bridge (AGENTDECK_PORT routes their hooks there),
|
|
1130
|
+
// but direct `claude`/`codex` runs only reach the daemon through these
|
|
1131
|
+
// hooks — without emitting here the daemon timeline (device ticker,
|
|
1132
|
+
// Android/Apple lists) goes silent whenever no managed session runs.
|
|
1133
|
+
//
|
|
1134
|
+
// Task GROUPING is fully hook-driven and TTY-independent: the boundary
|
|
1135
|
+
// state machine keys only off `user_prompt_submit` / `session_end` /
|
|
1136
|
+
// `/clear`, so it never depends on parsing the agent's terminal output
|
|
1137
|
+
// and stays stable across agent CLI UI changes. Per-tool rows are still
|
|
1138
|
+
// suppressed here (they drowned the glance surfaces); the `task_start` /
|
|
1139
|
+
// `task_end` headers + taskId-tagged `chat_start` rows now carry the
|
|
1140
|
+
// work-unit structure.
|
|
1141
|
+
//
|
|
1142
|
+
// Real per-session id shared by the collector, fallback, and the
|
|
1143
|
+
// chat_start row. Concurrent direct `claude`/`codex` runs must NOT
|
|
1144
|
+
// collapse onto one bucket: a shared id interleaves their turns into a
|
|
1145
|
+
// single task and lets one session's `session_end` tear down another's
|
|
1146
|
+
// run mapping. Falls back to a stable id only when a hook omits it.
|
|
1147
|
+
const hookSid = typeof json.session_id === 'string' && json.session_id
|
|
1148
|
+
? json.session_id : 'daemon-hook';
|
|
1149
|
+
// Claude hooks carry `cwd` (the worktree dir), not project_name/path —
|
|
1150
|
+
// capture it so APME runs are attributable to a specific worktree.
|
|
1151
|
+
const hookCwd = (typeof json.cwd === 'string' ? json.cwd
|
|
1152
|
+
: (typeof json.project_path === 'string' ? json.project_path : '')) || '';
|
|
1153
|
+
const hookProject = (typeof json.project_name === 'string' && json.project_name)
|
|
1154
|
+
? json.project_name
|
|
1155
|
+
: (hookCwd ? hookCwd.split('/').filter(Boolean).pop() : undefined);
|
|
1156
|
+
const hookMessage = json.message;
|
|
1157
|
+
// Prompt shapes: Claude `{prompt}` / `{message:{content}}`; some Codex
|
|
1158
|
+
// builds send `{user_prompt}` (same fallback chain as codex-hook.ts).
|
|
1159
|
+
const hookPromptText = typeof json.prompt === 'string' ? json.prompt
|
|
1160
|
+
: (typeof hookMessage?.content === 'string' ? hookMessage.content
|
|
1161
|
+
: (typeof json.user_prompt === 'string' ? json.user_prompt : ''));
|
|
1162
|
+
const isClearBoundary = boundary === 'user_prompt_submit' && /^\s*\/clear\s*$/i.test(hookPromptText);
|
|
1163
|
+
// ── APME collector (task/turn segmentation) ──
|
|
381
1164
|
if (apme) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
1165
|
+
if (isClearBoundary) {
|
|
1166
|
+
// `/clear` closes the active task + run and opens a fresh run, so
|
|
1167
|
+
// the next prompt starts a new task 0 — the work-unit boundary.
|
|
1168
|
+
apme.collector.splitRun(hookSid, hookCwd || undefined);
|
|
1169
|
+
}
|
|
1170
|
+
else {
|
|
1171
|
+
// Lazy openRun: open a run when a turn-STARTING hook arrives with no
|
|
1172
|
+
// active run — either a normal `session_start`, or (crucially) the
|
|
1173
|
+
// first `user_prompt_submit` when `session_start` was missed/late.
|
|
1174
|
+
// Without this, `ingestHook` no-ops for want of a run, which is
|
|
1175
|
+
// exactly why direct-`claude` timelines showed zero task headers
|
|
1176
|
+
// (every follow-up looked like a new top-level chat). Gating on
|
|
1177
|
+
// start/prompt avoids two hazards: a stray post-`session_end` tool /
|
|
1178
|
+
// stop hook won't spawn a phantom run, and a late `session_start`
|
|
1179
|
+
// arriving after a lazy open won't double-open (the run already
|
|
1180
|
+
// exists → skip).
|
|
1181
|
+
if (!apme.collector.getRunId(hookSid)
|
|
1182
|
+
&& (boundary === 'session_start' || boundary === 'user_prompt_submit')) {
|
|
1183
|
+
apme.collector.openRun({
|
|
1184
|
+
sessionId: hookSid,
|
|
1185
|
+
agentType: hookAgentType,
|
|
1186
|
+
projectName: hookProject,
|
|
1187
|
+
projectPath: hookCwd || undefined,
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
// Feed the agent-neutral boundary name: the collector's
|
|
1191
|
+
// normalizeHookEventName only understands the Claude-shaped
|
|
1192
|
+
// vocabulary (user_prompt_submit / tool_start / …), so raw
|
|
1193
|
+
// codex_* / opencode_* names would silently skip turn management.
|
|
1194
|
+
apme.collector.ingestHook(hookSid, boundary, json);
|
|
1195
|
+
}
|
|
1196
|
+
// Direct `claude` runs reach the daemon only via these hooks, which
|
|
1197
|
+
// never carry the model — so every such run persisted model_id=NULL.
|
|
1198
|
+
// Recover it from the transcript Claude writes. Must run before
|
|
1199
|
+
// closeRun tears down the session→run mapping.
|
|
1200
|
+
if (boundary === 'stop' || boundary === 'session_end') {
|
|
1201
|
+
const tp = json.transcript_path;
|
|
1202
|
+
if (typeof tp === 'string' && tp) {
|
|
1203
|
+
const model = readModelFromTranscript(tp);
|
|
1204
|
+
if (model)
|
|
1205
|
+
apme.collector.updateModel(hookSid, model);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
if (boundary === 'session_end') {
|
|
1209
|
+
apme.collector.closeRun(hookSid);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
else if (fallbackTasks) {
|
|
1213
|
+
// APME is down — keep the timeline's task hierarchy alive from the
|
|
1214
|
+
// same boundary hooks (open on first prompt, close on /clear +
|
|
1215
|
+
// session_end), keyed by the real session id.
|
|
1216
|
+
fallbackTasks.ingestHook(hookSid, boundary, json);
|
|
1217
|
+
}
|
|
1218
|
+
// Timeline `chat_start`, emitted AFTER the collector so the active task
|
|
1219
|
+
// id is known — tag the row with it so the prompt nests under the
|
|
1220
|
+
// (possibly deferred) task_start header instead of reading as its own
|
|
1221
|
+
// top-level task. `/clear` is a boundary command, not a turn: no row.
|
|
1222
|
+
if (boundary === 'user_prompt_submit' && !isClearBoundary
|
|
1223
|
+
&& hookPromptText.trim()
|
|
1224
|
+
// Codex's OTel turnStarted pseudo-prompt is a turn marker, not a
|
|
1225
|
+
// user prompt (same filter as the Swift daemon's chat_start path).
|
|
1226
|
+
&& hookPromptText.trim() !== 'Codex turn started') {
|
|
1227
|
+
const taskId = (apme
|
|
1228
|
+
? apme.collector.getActiveTaskId(hookSid)
|
|
1229
|
+
: fallbackTasks?.getActiveTaskId(hookSid)) ?? undefined;
|
|
1230
|
+
const promptText = stripUnsafeText(hookPromptText.trim());
|
|
1231
|
+
const now = Date.now();
|
|
1232
|
+
core.bridgeTimeline.addEntry({
|
|
1233
|
+
ts: now, type: 'chat_start',
|
|
1234
|
+
raw: promptText.slice(0, 160),
|
|
1235
|
+
// Long prompts keep their body reachable via the detail pane —
|
|
1236
|
+
// the 160-char raw alone read as clipped on the dashboards.
|
|
1237
|
+
...(promptText.length > 160 ? { detail: promptText.slice(0, 1000) } : {}),
|
|
1238
|
+
sessionId: hookSid,
|
|
1239
|
+
agentType: hookAgentType,
|
|
1240
|
+
startedAt: now,
|
|
1241
|
+
...(taskId ? { taskId } : {}),
|
|
1242
|
+
...(hookProject ? { projectName: hookProject } : {}),
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
// Timeline turn completion. Managed sessions relay chat_response from
|
|
1246
|
+
// their session bridge, but hook-observed (direct `claude` / `codex` /
|
|
1247
|
+
// standalone `opencode`) sessions only reach the daemon here — without
|
|
1248
|
+
// a completion row their chat_start rows spin as "running" forever on
|
|
1249
|
+
// every dashboard surface. Claude's Stop carries `transcript_path`
|
|
1250
|
+
// (the transcript tail's last assistant text is the turn's response —
|
|
1251
|
+
// same source the model reader uses above); Codex/OpenCode carry the
|
|
1252
|
+
// response inline (`last_assistant_message` / `response` / `output` /
|
|
1253
|
+
// `result`, the Swift daemon's field chain). Only emit while a turn is
|
|
1254
|
+
// actually open (a chat_start newer than the session's last
|
|
1255
|
+
// completion) so a duplicate/late Stop or an interrupted turn doesn't
|
|
1256
|
+
// re-emit the previous response.
|
|
1257
|
+
if (boundary === 'stop') {
|
|
1258
|
+
const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
|
|
1259
|
+
let lastStart;
|
|
1260
|
+
let lastResponse;
|
|
1261
|
+
let lastCompletionTs = 0;
|
|
1262
|
+
for (const row of rows) {
|
|
1263
|
+
if (row.type === 'chat_start')
|
|
1264
|
+
lastStart = row;
|
|
1265
|
+
else if (row.type === 'chat_response' || row.type === 'chat_end') {
|
|
1266
|
+
if (row.type === 'chat_response')
|
|
1267
|
+
lastResponse = row;
|
|
1268
|
+
lastCompletionTs = Math.max(lastCompletionTs, row.ts);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const turnOpen = Boolean(lastStart && lastStart.ts > lastCompletionTs);
|
|
1272
|
+
const tp = typeof json.transcript_path === 'string' ? json.transcript_path : '';
|
|
1273
|
+
const inlineResponse = [json.last_assistant_message, json.response, json.output, json.result]
|
|
1274
|
+
.find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
|
|
1275
|
+
// Codex's stop payload rarely carries the text inline; its rollout
|
|
1276
|
+
// JSONL (agent_message / task_complete records) is the
|
|
1277
|
+
// authoritative source — the Codex counterpart of Claude's
|
|
1278
|
+
// transcript tail. Read it whenever a completion row might be
|
|
1279
|
+
// emitted (open turn, or a response-only close below).
|
|
1280
|
+
const rolloutResponse = !tp && !inlineResponse && hookAgentType === 'codex-cli'
|
|
1281
|
+
? lastAgentMessageFromCodexRollout(hookSid)
|
|
1282
|
+
: '';
|
|
1283
|
+
const responseText = tp
|
|
1284
|
+
? stripUnsafeText(lastAssistantTextFromTranscript(tp))
|
|
1285
|
+
: stripUnsafeText(inlineResponse || rolloutResponse);
|
|
1286
|
+
const respRaw = responseText.length > 0
|
|
1287
|
+
? cleanRawText(responseText.length > 200 ? responseText.slice(0, 197) + '...' : responseText)
|
|
1288
|
+
: '';
|
|
1289
|
+
if (turnOpen && lastStart) {
|
|
1290
|
+
const now = Date.now();
|
|
1291
|
+
const taskId = (apme
|
|
1292
|
+
? apme.collector.getActiveTaskId(hookSid)
|
|
1293
|
+
: fallbackTasks?.getActiveTaskId(hookSid)) ?? lastStart.taskId ?? undefined;
|
|
1294
|
+
const base = {
|
|
1295
|
+
sessionId: hookSid,
|
|
1296
|
+
agentType: hookAgentType,
|
|
1297
|
+
startedAt: lastStart.startedAt ?? lastStart.ts,
|
|
1298
|
+
endedAt: now,
|
|
1299
|
+
...(taskId ? { taskId } : {}),
|
|
1300
|
+
...(hookProject ? { projectName: hookProject } : {}),
|
|
1301
|
+
};
|
|
1302
|
+
if (responseText.length > 0) {
|
|
1303
|
+
core.bridgeTimeline.addEntry({
|
|
1304
|
+
ts: now, type: 'chat_response',
|
|
1305
|
+
raw: respRaw,
|
|
1306
|
+
detail: prepareMarkdownDetail(responseText.slice(0, 3000)) || undefined,
|
|
1307
|
+
...base,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
else {
|
|
1311
|
+
// No readable response (interrupted / tool-only turn) — still
|
|
1312
|
+
// close the row so the dashboards stop the spinner.
|
|
1313
|
+
const durS = Math.max(0, Math.round((now - (lastStart.startedAt ?? lastStart.ts)) / 1000));
|
|
1314
|
+
core.bridgeTimeline.addEntry({
|
|
1315
|
+
ts: now, type: 'chat_end',
|
|
1316
|
+
raw: `Completed · ${durS}s`,
|
|
1317
|
+
summaryKind: 'none',
|
|
1318
|
+
...base,
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
393
1321
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
1322
|
+
else if (respRaw && lastResponse?.raw !== respRaw) {
|
|
1323
|
+
// Response-only close (Swift daemon parity): the turn's prompt
|
|
1324
|
+
// hook was missed — notify-only Codex configs emit just
|
|
1325
|
+
// turn_complete, and an OpenCode prompt can surface empty — so
|
|
1326
|
+
// there is no open chat_start to anchor on, but the response
|
|
1327
|
+
// text is still the turn's result and worth a row. The
|
|
1328
|
+
// raw-equality guard keeps a duplicate/late stop from
|
|
1329
|
+
// re-emitting the same response.
|
|
1330
|
+
const now = Date.now();
|
|
1331
|
+
const taskId = (apme
|
|
1332
|
+
? apme.collector.getActiveTaskId(hookSid)
|
|
1333
|
+
: fallbackTasks?.getActiveTaskId(hookSid)) ?? undefined;
|
|
1334
|
+
core.bridgeTimeline.addEntry({
|
|
1335
|
+
ts: now, type: 'chat_response',
|
|
1336
|
+
raw: respRaw,
|
|
1337
|
+
detail: prepareMarkdownDetail(responseText.slice(0, 3000)) || undefined,
|
|
1338
|
+
sessionId: hookSid,
|
|
1339
|
+
agentType: hookAgentType,
|
|
1340
|
+
endedAt: now,
|
|
1341
|
+
...(taskId ? { taskId } : {}),
|
|
1342
|
+
...(hookProject ? { projectName: hookProject } : {}),
|
|
1343
|
+
});
|
|
397
1344
|
}
|
|
398
1345
|
}
|
|
1346
|
+
// ── Response ──
|
|
1347
|
+
// PreToolUse returns an empty body → Claude's normal permission flow
|
|
1348
|
+
// runs untouched (zero added latency, no daemon-side gate). Every other
|
|
1349
|
+
// event acks immediately.
|
|
1350
|
+
if (eventName === 'PreToolUse') {
|
|
1351
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1352
|
+
res.end('');
|
|
1353
|
+
}
|
|
1354
|
+
else {
|
|
1355
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1356
|
+
res.end(JSON.stringify({ received: true }));
|
|
1357
|
+
}
|
|
399
1358
|
});
|
|
400
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
401
|
-
res.end(JSON.stringify({ received: true }));
|
|
402
1359
|
return;
|
|
403
1360
|
}
|
|
404
1361
|
if (req.method === 'POST' && pathname === '/shutdown') {
|
|
405
1362
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
406
1363
|
res.end(JSON.stringify({ status: 'shutting_down' }));
|
|
1364
|
+
const hardExitTimer = setTimeout(() => {
|
|
1365
|
+
log('[agentdeck] Shutdown route timeout — forcing exit.');
|
|
1366
|
+
exitProcessNow(0);
|
|
1367
|
+
}, 5000);
|
|
407
1368
|
core.shutdown();
|
|
408
1369
|
return;
|
|
409
1370
|
}
|
|
1371
|
+
// Manual task-close endpoint — drives `closeTaskExternal` on the APME
|
|
1372
|
+
// collector. Used by the CLI (`agentdeck task done` / `task cancel`)
|
|
1373
|
+
// and the macOS detail-pane "Mark task complete" button. Body:
|
|
1374
|
+
// { sessionId: string, signal?: 'manual', outcome?: 'success'|'fail'|'abandoned' }
|
|
1375
|
+
// sessionId defaults to the active session derived from the daemon's
|
|
1376
|
+
// registry; supplying it explicitly is the contract the CLI uses.
|
|
1377
|
+
if (req.method === 'POST' && pathname === '/task/close') {
|
|
1378
|
+
if (!apme) {
|
|
1379
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1380
|
+
res.end(JSON.stringify({ error: 'APME not initialized' }));
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
let body = '';
|
|
1384
|
+
req.on('data', (chunk) => { body += chunk; if (body.length > 4096)
|
|
1385
|
+
req.destroy(); });
|
|
1386
|
+
req.on('end', () => {
|
|
1387
|
+
try {
|
|
1388
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
1389
|
+
const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId
|
|
1390
|
+
? parsed.sessionId
|
|
1391
|
+
: (openclawApmeSessionId ?? '');
|
|
1392
|
+
if (!sessionId) {
|
|
1393
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1394
|
+
res.end(JSON.stringify({ error: 'sessionId required (no active session to default to)' }));
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
const signalRaw = typeof parsed.signal === 'string' ? parsed.signal : 'manual';
|
|
1398
|
+
const outcomeRaw = typeof parsed.outcome === 'string' ? parsed.outcome : undefined;
|
|
1399
|
+
const outcome = (outcomeRaw === 'success' || outcomeRaw === 'fail' || outcomeRaw === 'partial' || outcomeRaw === 'abandoned')
|
|
1400
|
+
? outcomeRaw
|
|
1401
|
+
: undefined;
|
|
1402
|
+
// closeTaskExternal accepts TaskBoundarySignal (open union with
|
|
1403
|
+
// string fallback). Narrow only the well-known signals; pass
|
|
1404
|
+
// unknown strings through as-is (the runner labels them
|
|
1405
|
+
// generically).
|
|
1406
|
+
const apmeRef = apme;
|
|
1407
|
+
if (!apmeRef) {
|
|
1408
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1409
|
+
res.end(JSON.stringify({ error: 'APME not initialized' }));
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const closed = apmeRef.collector.closeTaskExternal(sessionId, signalRaw, outcome);
|
|
1413
|
+
res.writeHead(closed ? 200 : 404, { 'Content-Type': 'application/json' });
|
|
1414
|
+
res.end(JSON.stringify({ closed, sessionId, signal: signalRaw, outcome: outcome ?? null }));
|
|
1415
|
+
}
|
|
1416
|
+
catch (err) {
|
|
1417
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1418
|
+
res.end(JSON.stringify({ error: `bad body: ${String(err)}` }));
|
|
1419
|
+
}
|
|
1420
|
+
});
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
// On-device Apple Intelligence (FoundationModels) text generation via the
|
|
1424
|
+
// daemon-managed *persistent* helper. Keeping it warm in the daemon avoids
|
|
1425
|
+
// the ~7s per-process cold start callers would otherwise pay each time
|
|
1426
|
+
// (e.g. wtcp branch naming). localhost only; lazily spawns the helper.
|
|
1427
|
+
if (req.method === 'POST' && pathname === '/generate') {
|
|
1428
|
+
let body = '';
|
|
1429
|
+
req.on('data', (chunk) => { body += chunk; if (body.length > 16384)
|
|
1430
|
+
req.destroy(); });
|
|
1431
|
+
req.on('end', () => {
|
|
1432
|
+
let prompt = '', instructions;
|
|
1433
|
+
try {
|
|
1434
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
1435
|
+
prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
|
|
1436
|
+
instructions = typeof parsed.instructions === 'string' ? parsed.instructions : undefined;
|
|
1437
|
+
}
|
|
1438
|
+
catch {
|
|
1439
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1440
|
+
res.end(JSON.stringify({ error: 'bad JSON' }));
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
if (!prompt) {
|
|
1444
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1445
|
+
res.end(JSON.stringify({ error: 'prompt required' }));
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
callFoundationModelsHelper(prompt, instructions)
|
|
1449
|
+
.then((text) => {
|
|
1450
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1451
|
+
res.end(JSON.stringify({ text }));
|
|
1452
|
+
})
|
|
1453
|
+
.catch((err) => {
|
|
1454
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1455
|
+
res.end(JSON.stringify({ error: `generate failed: ${String(err)}` }));
|
|
1456
|
+
});
|
|
1457
|
+
});
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
410
1460
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
411
1461
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
412
1462
|
});
|
|
@@ -428,10 +1478,30 @@ export async function startDaemon(opts) {
|
|
|
428
1478
|
});
|
|
429
1479
|
httpServer.listen(port, '0.0.0.0', () => resolve());
|
|
430
1480
|
}).catch(async (err) => {
|
|
431
|
-
// Handle race condition: port became unavailable after probe
|
|
1481
|
+
// Handle race condition: port became unavailable after our pre-bind probe.
|
|
1482
|
+
// This is the concurrent-start case (e.g. two logon-trigger fires landing
|
|
1483
|
+
// within ~1s): both processes pass the singleton guard because neither has
|
|
1484
|
+
// bound yet, then exactly one wins the OS bind and the rest get EADDRINUSE.
|
|
432
1485
|
if (err.message.startsWith('EADDRINUSE:') && port === requestedPort) {
|
|
1486
|
+
// Re-probe the occupant. If ANOTHER daemon grabbed the port, we lost the
|
|
1487
|
+
// race — exit instead of falling back to a new port, or we'd leave two
|
|
1488
|
+
// daemons running (and clobber daemon.json to point at the wrong one).
|
|
1489
|
+
// Only fall back when a *non-daemon* (e.g. a session bridge) holds it.
|
|
1490
|
+
const occupant = await probeDaemonHealth(requestedPort);
|
|
1491
|
+
// Harden against a forged/stale `mode:'daemon'` response squatting the
|
|
1492
|
+
// port: concede (exit) only to a verified live distinct daemon. A claim
|
|
1493
|
+
// backed by a dead/own PID is treated as stale → fall through to a fresh
|
|
1494
|
+
// port and keep running. See `shouldConcedePortToOccupant`.
|
|
1495
|
+
if (shouldConcedePortToOccupant(occupant, process.pid)) {
|
|
1496
|
+
const who = typeof occupant?.pid === 'number' ? `PID ${occupant.pid}` : 'a daemon';
|
|
1497
|
+
log(`[agentdeck] Lost startup race for port ${requestedPort} (${who} already serving). Exiting.`);
|
|
1498
|
+
process.exit(0);
|
|
1499
|
+
}
|
|
1500
|
+
if (occupant?.mode === 'daemon') {
|
|
1501
|
+
log(`[agentdeck] Port ${requestedPort} reports a daemon but PID ${occupant.pid} is not a live distinct process; treating as stale and falling back.`);
|
|
1502
|
+
}
|
|
433
1503
|
port = await findAvailablePort();
|
|
434
|
-
log(`[agentdeck] Port ${requestedPort} grabbed, retrying on ${port}...`);
|
|
1504
|
+
log(`[agentdeck] Port ${requestedPort} grabbed by ${occupant?.mode ?? 'a non-daemon'}, retrying on ${port}...`);
|
|
435
1505
|
await new Promise((resolve, reject) => {
|
|
436
1506
|
httpServer.on('error', (e) => reject(e));
|
|
437
1507
|
httpServer.listen(port, '0.0.0.0', () => resolve());
|
|
@@ -448,11 +1518,66 @@ export async function startDaemon(opts) {
|
|
|
448
1518
|
port,
|
|
449
1519
|
projectName: 'AgentDeck',
|
|
450
1520
|
httpServer,
|
|
1521
|
+
isDaemon: true,
|
|
1522
|
+
});
|
|
1523
|
+
const esp32WifiEvents = new Set([
|
|
1524
|
+
...SERIAL_FORWARDED_EVENTS,
|
|
1525
|
+
'esp32_ota_begin',
|
|
1526
|
+
'esp32_ota_chunk',
|
|
1527
|
+
'esp32_ota_end',
|
|
1528
|
+
'esp32_ota_abort',
|
|
1529
|
+
'device_info_request',
|
|
1530
|
+
]);
|
|
1531
|
+
core.wsServer.setEventTransformer((event, client) => {
|
|
1532
|
+
if (!core.wsServer.isEsp32Client(client))
|
|
1533
|
+
return event;
|
|
1534
|
+
if (!esp32WifiEvents.has(event.type))
|
|
1535
|
+
return null;
|
|
1536
|
+
// Single-path guard: only the duplicated *display* payloads are deduped.
|
|
1537
|
+
// When the same board is live on USB serial, serial drives it and the WiFi
|
|
1538
|
+
// copy is redundant — drop it here. OTA control (esp32_ota_*, WiFi-only) and
|
|
1539
|
+
// device_info_request keep flowing so the standby WiFi socket stays live and
|
|
1540
|
+
// its lastSeen fresh. When serial disconnects, this flips false on the next
|
|
1541
|
+
// event and WiFi resumes automatically — no state migration needed.
|
|
1542
|
+
if (SERIAL_FORWARDED_EVENTS.has(event.type) && isWifiEsp32RedundantWithSerial(client))
|
|
1543
|
+
return null;
|
|
1544
|
+
return prepareForSerial(event);
|
|
451
1545
|
});
|
|
452
1546
|
// Timeline
|
|
453
1547
|
const bridgeLogStream = new BridgeLogStream();
|
|
454
1548
|
core.wireTimeline(bridgeLogStream);
|
|
1549
|
+
const timelinePath = latestTimelinePath();
|
|
1550
|
+
if (timelinePath) {
|
|
1551
|
+
const loaded = core.bridgeTimeline.loadPersistedFile(timelinePath);
|
|
1552
|
+
if (loaded > 0)
|
|
1553
|
+
log(`[agentdeck] Loaded ${loaded} persisted timeline entries`);
|
|
1554
|
+
// Close task_start rows orphaned by a previous daemon killed mid-task —
|
|
1555
|
+
// clients would otherwise spin their in-flight marker forever.
|
|
1556
|
+
const reaped = core.bridgeTimeline.reapOrphanTaskStarts();
|
|
1557
|
+
if (reaped > 0)
|
|
1558
|
+
log(`[agentdeck] Closed ${reaped} orphaned task_start rows as interrupted`);
|
|
1559
|
+
}
|
|
455
1560
|
core.wireDisplayMonitor();
|
|
1561
|
+
let lastStateEvent = null;
|
|
1562
|
+
let userFocusedSessionId = null;
|
|
1563
|
+
const attachFocusedSessionId = (event) => {
|
|
1564
|
+
if (event.type !== 'state_update')
|
|
1565
|
+
return event;
|
|
1566
|
+
return {
|
|
1567
|
+
...event,
|
|
1568
|
+
focusedSessionId: userFocusedSessionId ?? '',
|
|
1569
|
+
};
|
|
1570
|
+
};
|
|
1571
|
+
const broadcastFocusedState = () => {
|
|
1572
|
+
const gwAlive = gatewayAdapter?.isAlive() ?? false;
|
|
1573
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
1574
|
+
agentType: gwAlive ? 'openclaw' : 'daemon',
|
|
1575
|
+
agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
|
|
1576
|
+
snapshot: core.stateMachine.getSnapshot(),
|
|
1577
|
+
}));
|
|
1578
|
+
lastStateEvent = stateEvent;
|
|
1579
|
+
core.wsServer.broadcast(stateEvent);
|
|
1580
|
+
};
|
|
456
1581
|
// System wake recovery — re-publish mDNS, reconnect devices, refresh usage
|
|
457
1582
|
core.onSystemWake(() => {
|
|
458
1583
|
log('[daemon] System wake detected — recovering devices');
|
|
@@ -491,11 +1616,11 @@ export async function startDaemon(opts) {
|
|
|
491
1616
|
core.cachedModelCatalog = merged;
|
|
492
1617
|
debug('daemon', `Model catalog merged from sibling: ${merged.length} models total`);
|
|
493
1618
|
const snap = core.stateMachine.getSnapshot();
|
|
494
|
-
const stateEvent = core.buildStateEvent({
|
|
1619
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
495
1620
|
agentType: gatewayAdapter?.isAlive() ? 'openclaw' : 'daemon',
|
|
496
1621
|
agentCapabilities: gatewayAdapter?.isAlive() ? OPENCLAW_CAPABILITIES : undefined,
|
|
497
1622
|
snapshot: snap,
|
|
498
|
-
});
|
|
1623
|
+
}));
|
|
499
1624
|
lastStateEvent = stateEvent;
|
|
500
1625
|
core.broadcast(stateEvent);
|
|
501
1626
|
core.broadcastUsage();
|
|
@@ -506,13 +1631,18 @@ export async function startDaemon(opts) {
|
|
|
506
1631
|
const focusRelay = new SessionFocusRelay();
|
|
507
1632
|
focusRelay.setEventHandler((evt) => {
|
|
508
1633
|
if (evt.type === 'state_update') {
|
|
1634
|
+
const focusedId = focusRelay.getFocusedSessionId();
|
|
1635
|
+
if (focusedId)
|
|
1636
|
+
userFocusedSessionId = focusedId;
|
|
509
1637
|
// Merge daemon metadata into the session's state_update
|
|
510
1638
|
const merged = {
|
|
511
1639
|
...evt,
|
|
512
|
-
sessionId:
|
|
1640
|
+
sessionId: focusedId,
|
|
1641
|
+
focusedSessionId: userFocusedSessionId ?? '',
|
|
513
1642
|
modelCatalog: evt.modelCatalog ?? core.cachedModelCatalog,
|
|
514
1643
|
gatewayAvailable: core.cachedGatewayAvailable,
|
|
515
1644
|
gatewayConnected: core.cachedGatewayConnected,
|
|
1645
|
+
gatewayAuthStatus: core.cachedGatewayAuthStatus,
|
|
516
1646
|
ollamaStatus: core.cachedOllamaStatus,
|
|
517
1647
|
gatewayHasError: evt.gatewayHasError ?? core.cachedGatewayHasError,
|
|
518
1648
|
moduleHealth: moduleHealthProvider(),
|
|
@@ -539,11 +1669,68 @@ export async function startDaemon(opts) {
|
|
|
539
1669
|
});
|
|
540
1670
|
// mDNS + device modules
|
|
541
1671
|
const deviceModules = createDefaultModules('daemon');
|
|
542
|
-
|
|
543
|
-
|
|
1672
|
+
// AGENTDECK_DAEMON_NO_SERIAL=1 leaves the daemon fully functional over WiFi
|
|
1673
|
+
// (mDNS, WiFi WS, WiFi OTA) but never opens the USB serial ports. This frees
|
|
1674
|
+
// a board's /dev/cu.* for an exclusive external serial capture (e.g. reading a
|
|
1675
|
+
// panic backtrace during a WiFi OTA) that otherwise fights the daemon's serial
|
|
1676
|
+
// reader for bytes. Diagnostic gate only; normal daemons keep serial on.
|
|
1677
|
+
const serialMode = process.env.AGENTDECK_DAEMON_NO_SERIAL === '1' ? false : 'auto';
|
|
1678
|
+
// Note: the D200H has no device module — direct-HID control was retired. It is
|
|
1679
|
+
// driven exclusively by the Ulanzi Studio plugin (`ulanzi-plugin`) over WebSocket;
|
|
1680
|
+
// the daemon never opens it over HID. Its health is reported via the Ulanzi client
|
|
1681
|
+
// count in moduleHealthProvider below.
|
|
1682
|
+
const startedModules = await initModules(deviceModules, { mdns: true, broadcast: true, adb: 'auto', serial: serialMode, pixoo: 'auto', timebox: 'auto', idotmatrix: 'auto' }, { port, authToken: core.authToken, projectName: 'AgentDeck', wsServer: core.wsServer });
|
|
1683
|
+
moduleHealthProvider = () => {
|
|
1684
|
+
const modules = buildNodeModuleHealth(startedModules);
|
|
1685
|
+
const einkDevices = collectEinkDevices();
|
|
1686
|
+
if (einkDevices.length > 0) {
|
|
1687
|
+
modules.einkDevices = { available: true, devices: einkDevices };
|
|
1688
|
+
}
|
|
1689
|
+
const androidDashboards = collectAndroidDashboards();
|
|
1690
|
+
if (androidDashboards.length > 0) {
|
|
1691
|
+
modules.androidDashboards = { available: true, devices: androidDashboards };
|
|
1692
|
+
}
|
|
1693
|
+
const streamDeckDevices = collectStreamDeckDevices();
|
|
1694
|
+
if (streamDeckDevices.length > 0) {
|
|
1695
|
+
modules.streamDeck = { available: true, devices: streamDeckDevices };
|
|
1696
|
+
}
|
|
1697
|
+
// WiFi-WS ESP32 boards. Same data as /devices `esp32-wifi` — without this
|
|
1698
|
+
// the dashboards (macOS/iOS/Android) never learn a WiFi-only board exists,
|
|
1699
|
+
// since they read moduleHealth off state_update, not /devices.
|
|
1700
|
+
const wifiEsp32 = listWifiEsp32Devices();
|
|
1701
|
+
if (wifiEsp32.length > 0) {
|
|
1702
|
+
modules.esp32Wifi = {
|
|
1703
|
+
available: true,
|
|
1704
|
+
devices: wifiEsp32.map((d) => ({
|
|
1705
|
+
board: d.board,
|
|
1706
|
+
ip: d.ip ?? null,
|
|
1707
|
+
version: d.version ?? null,
|
|
1708
|
+
stale: d.stale,
|
|
1709
|
+
serialActive: d.serialActive,
|
|
1710
|
+
})),
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
const tuiClients = core.wsServer.getTuiClients();
|
|
1714
|
+
if (tuiClients.length > 0) {
|
|
1715
|
+
modules.tuiDashboards = {
|
|
1716
|
+
available: true,
|
|
1717
|
+
devices: tuiClients.map((c) => ({ id: c.id, name: c.name, kind: 'tui' })),
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
const ulanziPluginConnected = core.wsServer.getUlanziClientCount() > 0;
|
|
1721
|
+
modules.d200h = {
|
|
1722
|
+
connected: ulanziPluginConnected,
|
|
1723
|
+
externalOwner: ulanziPluginConnected,
|
|
1724
|
+
managerOpened: ulanziPluginConnected,
|
|
1725
|
+
};
|
|
1726
|
+
return modules;
|
|
1727
|
+
};
|
|
544
1728
|
core.setModuleHealthProvider(moduleHealthProvider);
|
|
1729
|
+
// iDotMatrix BLE is now driven by IDotMatrixModule (registered in
|
|
1730
|
+
// createDefaultModules): the module owns spawning the Python sync client,
|
|
1731
|
+
// zero-config auto-discovery, and teardown — so the daemon doesn't start it
|
|
1732
|
+
// directly here anymore (avoids double-spawn).
|
|
545
1733
|
// Serial module state provider (heartbeat needs cached state)
|
|
546
|
-
let lastStateEvent = null;
|
|
547
1734
|
const serialModule = startedModules.find(m => m.name === 'serial');
|
|
548
1735
|
if (serialModule) {
|
|
549
1736
|
serialModule.setStateProvider(() => lastStateEvent);
|
|
@@ -554,20 +1741,61 @@ export async function startDaemon(opts) {
|
|
|
554
1741
|
if (lastStateEvent)
|
|
555
1742
|
events.push(lastStateEvent);
|
|
556
1743
|
events.push(core.buildUsage());
|
|
557
|
-
events.push(
|
|
1744
|
+
events.push(buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
|
|
1745
|
+
// Seed the last few timeline entries so a freshly (re)connected board's
|
|
1746
|
+
// ticker shows the real latest event instead of whatever its ring last
|
|
1747
|
+
// held. Kept to 3 entries — the whole line must stay well under the
|
|
1748
|
+
// small serial RX buffers on non-InkDeck boards.
|
|
1749
|
+
const recent = core.bridgeTimeline.getHistory().slice(-3);
|
|
1750
|
+
if (recent.length > 0) {
|
|
1751
|
+
events.push({ type: 'timeline_history', entries: recent });
|
|
1752
|
+
}
|
|
558
1753
|
// Sessions list (async enrichment runs synchronously from cache here)
|
|
559
1754
|
core.broadcastSessionsList().catch(() => { });
|
|
560
1755
|
return events;
|
|
561
1756
|
});
|
|
1757
|
+
// Heartbeat re-sync: display_state is otherwise edge-triggered, and a board
|
|
1758
|
+
// that misses the wake edge stays dark until power-cycled.
|
|
1759
|
+
serialModule.setDisplayStateProvider(() => buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
|
|
1760
|
+
// Heartbeat re-sync: sessions_list is edge-triggered too, so a board that
|
|
1761
|
+
// reconnects across a daemon handoff during a quiet window would otherwise
|
|
1762
|
+
// stay on "no active sessions" until the next session change.
|
|
1763
|
+
serialModule.setSessionsListProvider(() => core.getLastSessionsListEvent());
|
|
562
1764
|
// Include ESP32 serial connections in client count for polling guards
|
|
563
1765
|
core.setExternalClientCountProvider(() => esp32ConnectionCount());
|
|
1766
|
+
}
|
|
1767
|
+
// WS-path display_state re-sync: the 5s serial heartbeat above only covers
|
|
1768
|
+
// USB-attached boards. WiFi boards (InkDeck) receive display_state edge-
|
|
1769
|
+
// triggered over the plugin WS — a missed wake edge would leave an e-ink
|
|
1770
|
+
// panel showing the sleep card forever. Re-broadcast at a slow cadence so
|
|
1771
|
+
// any board that missed the edge self-heals within 15s.
|
|
1772
|
+
{
|
|
1773
|
+
const displayResync = setInterval(() => {
|
|
1774
|
+
if (core.wsServer.getClientCount() > 0) {
|
|
1775
|
+
core.wsServer.broadcast(buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
|
|
1776
|
+
}
|
|
1777
|
+
}, 15_000);
|
|
1778
|
+
displayResync.unref?.();
|
|
1779
|
+
// WiFi ESP32 diagnostics: boards only announce device_info on connect unless
|
|
1780
|
+
// asked. Poll slowly so /devices can show uptime/reset changes and distinguish
|
|
1781
|
+
// a real reboot from a display/WS redraw.
|
|
1782
|
+
const wifiDeviceInfoPoll = setInterval(() => {
|
|
1783
|
+
if (core.wsServer.getClientCount() > 0) {
|
|
1784
|
+
core.wsServer.broadcast({ type: 'device_info_request' });
|
|
1785
|
+
}
|
|
1786
|
+
}, 30_000);
|
|
1787
|
+
wifiDeviceInfoPoll.unref?.();
|
|
564
1788
|
// WiFi auto-provisioning for ESP32 (enables independent WiFi operation)
|
|
565
1789
|
const wifiConfig = loadWifiConfig();
|
|
566
1790
|
if (wifiConfig?.autoProvision) {
|
|
567
1791
|
const lanIp = getLanIp();
|
|
568
1792
|
onESP32Message((portPath, msg) => {
|
|
569
|
-
|
|
570
|
-
|
|
1793
|
+
const shouldRefreshIps10Endpoint = msg.type === 'device_info' &&
|
|
1794
|
+
msg.board === 'ips_10' &&
|
|
1795
|
+
msg.wifiConnected &&
|
|
1796
|
+
!msg.wifiRadioParked;
|
|
1797
|
+
if (msg.type === 'device_info' && (!msg.wifiConnected || shouldRefreshIps10Endpoint)) {
|
|
1798
|
+
const sent = sendWifiProvisionToAll({
|
|
571
1799
|
type: 'wifi_provision',
|
|
572
1800
|
ssid: wifiConfig.ssid,
|
|
573
1801
|
password: wifiConfig.password,
|
|
@@ -575,7 +1803,8 @@ export async function startDaemon(opts) {
|
|
|
575
1803
|
bridgePort: port,
|
|
576
1804
|
authToken: core.authToken,
|
|
577
1805
|
});
|
|
578
|
-
|
|
1806
|
+
if (sent > 0)
|
|
1807
|
+
log(`[agentdeck] WiFi provision sent to ${sent} ESP32 device(s) after ${portPath}`);
|
|
579
1808
|
}
|
|
580
1809
|
else if (msg.type === 'wifi_provision_ack') {
|
|
581
1810
|
log(msg.success ? `[agentdeck] ESP32 WiFi connected: ${msg.ip} ✓` : `[agentdeck] ESP32 WiFi failed: ${msg.error || 'unknown'}`);
|
|
@@ -589,43 +1818,117 @@ export async function startDaemon(opts) {
|
|
|
589
1818
|
// routes. `setApme` on core is gated against the `daemon` meta-session so
|
|
590
1819
|
// register/deregister won't open a bogus run. Session bridges opening their
|
|
591
1820
|
// own connection to the same sqlite file is safe under WAL mode.
|
|
592
|
-
|
|
1821
|
+
// emitTimeline: forward task hierarchy entries (task_start / task_end) into
|
|
1822
|
+
// the daemon's bridgeTimeline so downstream dashboards see task headers.
|
|
1823
|
+
const daemonProjectTimeline = isTimelineProjectionEnabled();
|
|
1824
|
+
apme = await initApme(undefined, {
|
|
1825
|
+
emitTimeline: (entry) => core.bridgeTimeline.addEntry(entry),
|
|
1826
|
+
projectSampleTimeline: daemonProjectTimeline,
|
|
1827
|
+
emitProjectedTimeline: (entry) => core.bridgeTimeline.addEntry(entry, { bypassSuppression: true }),
|
|
1828
|
+
});
|
|
593
1829
|
if (apme) {
|
|
594
1830
|
core.setApme(apme);
|
|
595
|
-
|
|
1831
|
+
if (daemonProjectTimeline) {
|
|
1832
|
+
core.bridgeTimeline.setSuppressLocalChatTool(true);
|
|
1833
|
+
log('[agentdeck] APME timeline projection ENABLED — chat/tool rows derive from SessionSample');
|
|
1834
|
+
}
|
|
1835
|
+
log(`[agentdeck] APME enabled — store=${apme.store.dbPath} routes=/apme/*`);
|
|
1836
|
+
// Fire-and-forget judge backend probe. Result is cached on
|
|
1837
|
+
// apme.runner.lastBackendProbe and surfaced on /health so users discover
|
|
1838
|
+
// misconfiguration (no MLX server running, missing API key, etc.) without
|
|
1839
|
+
// having to wait for the first eval to fail.
|
|
1840
|
+
void apme.runner.refreshBackendProbe(loadApmeConfig().judge).then(status => {
|
|
1841
|
+
if (status.status === 'ready') {
|
|
1842
|
+
log(`[agentdeck] APME judge ready: ${status.backend}${status.model ? ` (${status.model})` : ''}${status.latencyMs !== undefined ? ` ${status.latencyMs}ms` : ''}`);
|
|
1843
|
+
}
|
|
1844
|
+
else {
|
|
1845
|
+
log(`[agentdeck] APME judge ${status.backend} ${status.status} — ${status.reason ?? 'no reason'}. Deterministic layer (lint/build/test) keeps running.`);
|
|
1846
|
+
}
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
else {
|
|
1850
|
+
// initApme() already logged the specific reason via logError. This second
|
|
1851
|
+
// line tells the user where to look + what's lost so the gap doesn't pass
|
|
1852
|
+
// for "everything's fine, just no /apme/ routes".
|
|
1853
|
+
log('[agentdeck] APME unavailable — no run/turn/task evals will be recorded for sessions on this daemon.');
|
|
1854
|
+
// Keep the timeline's task hierarchy alive without APME: emit fallback
|
|
1855
|
+
// task_start/task_end rows from the hook boundary signals so the device
|
|
1856
|
+
// timeline doesn't degrade to a flat chat/tool stream.
|
|
1857
|
+
fallbackTasks = new FallbackTaskTimeline((entry) => core.bridgeTimeline.addEntry(entry), { agentType: 'claude-code' });
|
|
596
1858
|
}
|
|
597
1859
|
// Register session
|
|
598
1860
|
core.registerSession('daemon');
|
|
599
1861
|
const passiveSessionObserver = new PassiveSessionObserver();
|
|
1862
|
+
// The observer scans in the background now (collect() returns the cache
|
|
1863
|
+
// immediately). When a scan lands fresh observations, push them out via
|
|
1864
|
+
// the debounced broadcast so clients don't wait for the next 10 s poll.
|
|
1865
|
+
passiveSessionObserver.onRefreshed = () => core.maybeBroadcastSessionsList();
|
|
600
1866
|
// ===== Gateway adapter lifecycle =====
|
|
601
1867
|
// (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
|
|
602
1868
|
// Inject OpenClaw virtual session only after Gateway authentication succeeds.
|
|
603
1869
|
// Reachability alone is a topology signal, not proof that commands can route.
|
|
604
1870
|
core.setSessionsEnricher((sessions) => {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
1871
|
+
// Overlay hook-driven awaiting state onto observed (direct-`claude`)
|
|
1872
|
+
// sessions — display-only (no requestId, respond-in-terminal UX). Done here
|
|
1873
|
+
// in the synchronous enricher (runs on every broadcast) rather than inside
|
|
1874
|
+
// the 5s-throttled observer, so a Notification arriving mid-window still
|
|
1875
|
+
// surfaces within one frame. Key = the Claude session UUID embedded in
|
|
1876
|
+
// `observed:claude:<uuid>`.
|
|
1877
|
+
const observed = applyAwaitingOverlayToObserved(passiveSessionObserver.collect(sessions));
|
|
1878
|
+
// Derive per-session elapsed seconds from startedAt so NTP-less devices
|
|
1879
|
+
// (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
|
|
1880
|
+
const now = Date.now();
|
|
1881
|
+
const enrichedSessions = [...sessions, ...observed].map((s) => {
|
|
1882
|
+
if (s.elapsedSec != null || !s.startedAt)
|
|
1883
|
+
return s;
|
|
1884
|
+
const sec = Math.round((now - Date.parse(s.startedAt)) / 1000);
|
|
1885
|
+
return Number.isFinite(sec) && sec >= 0 ? { ...s, elapsedSec: sec } : s;
|
|
1886
|
+
});
|
|
1887
|
+
// SSOT: inject iff Gateway is authenticated (gatewayConnected). Reachability
|
|
1888
|
+
// / adapter-liveness alone must not materialize a session — that kept a
|
|
1889
|
+
// phantom OpenClaw alive on devices after it was effectively off. Shared
|
|
1890
|
+
// injector with bridge/src/index.ts; mirror of Swift buildSessionsListEvent.
|
|
611
1891
|
const snap = core.stateMachine.getSnapshot();
|
|
612
|
-
return
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
modelName: adapterAlive ? (snap.modelName ?? undefined) : undefined,
|
|
620
|
-
controlMode: 'managed',
|
|
621
|
-
}];
|
|
1892
|
+
return injectOpenClawSession(enrichedSessions, {
|
|
1893
|
+
gatewayConnected: core.cachedGatewayConnected,
|
|
1894
|
+
state: snap.state,
|
|
1895
|
+
projectName: snap.projectName ?? 'OpenClaw',
|
|
1896
|
+
modelName: snap.modelName ?? undefined,
|
|
1897
|
+
controlMode: 'managed',
|
|
1898
|
+
});
|
|
622
1899
|
});
|
|
1900
|
+
// OpenClaw-specific APME session id. Distinct from `core.sessionId`
|
|
1901
|
+
// (the daemon meta-session, which doesn't open a run) and rotates on
|
|
1902
|
+
// every connect/disconnect cycle so each Gateway lifetime gets its own
|
|
1903
|
+
// APME run with its own task hierarchy. Captured here so both the
|
|
1904
|
+
// `connectGatewayAdapter` open + `disconnectGatewayAdapter` close paths
|
|
1905
|
+
// can reference the same id.
|
|
1906
|
+
let openclawApmeSessionId = null;
|
|
623
1907
|
function connectGatewayAdapter() {
|
|
624
1908
|
if (gatewayAdapter || gatewayConnecting)
|
|
625
1909
|
return;
|
|
626
1910
|
gatewayConnecting = true;
|
|
627
1911
|
log('[agentdeck] OpenClaw Gateway detected, connecting...');
|
|
628
1912
|
const adapter = new OpenClawAdapter({ autoReconnect: false });
|
|
1913
|
+
// Bind APME — without this OpenClaw never reaches the collector and
|
|
1914
|
+
// every chat session collapses to a single session_end task. The
|
|
1915
|
+
// adapter's idle-gap timer + chat.send/final ingestion needs an active
|
|
1916
|
+
// run to attach turns and task boundaries to.
|
|
1917
|
+
if (apme) {
|
|
1918
|
+
openclawApmeSessionId = `openclaw-${randomUUID()}`;
|
|
1919
|
+
try {
|
|
1920
|
+
apme.collector.openRun({
|
|
1921
|
+
sessionId: openclawApmeSessionId,
|
|
1922
|
+
agentType: 'openclaw',
|
|
1923
|
+
projectName: 'openclaw',
|
|
1924
|
+
});
|
|
1925
|
+
adapter.setApmeSession(openclawApmeSessionId, process.cwd());
|
|
1926
|
+
}
|
|
1927
|
+
catch (err) {
|
|
1928
|
+
debug('APME', `openRun for OpenClaw failed: ${String(err)}`);
|
|
1929
|
+
openclawApmeSessionId = null;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
629
1932
|
adapter.on('event', (evt) => {
|
|
630
1933
|
switch (evt.source) {
|
|
631
1934
|
case 'hook':
|
|
@@ -636,6 +1939,14 @@ export async function startDaemon(opts) {
|
|
|
636
1939
|
break;
|
|
637
1940
|
case 'parser':
|
|
638
1941
|
core.stateMachine.handleParserEvent(evt.event, evt.data);
|
|
1942
|
+
// The OpenClaw adapter emits the real model via a model_info parser
|
|
1943
|
+
// event, but it only ever updated the display StateMachine — the APME
|
|
1944
|
+
// run was never told, so every openclaw run persisted model_id=NULL.
|
|
1945
|
+
if (evt.event === 'model_info' && apme && openclawApmeSessionId) {
|
|
1946
|
+
const model = evt.data?.model;
|
|
1947
|
+
if (model)
|
|
1948
|
+
apme.collector.updateModel(openclawApmeSessionId, model);
|
|
1949
|
+
}
|
|
639
1950
|
break;
|
|
640
1951
|
case 'metadata':
|
|
641
1952
|
if (evt.event === 'model_catalog') {
|
|
@@ -643,11 +1954,11 @@ export async function startDaemon(opts) {
|
|
|
643
1954
|
if (models) {
|
|
644
1955
|
core.cachedModelCatalog = models;
|
|
645
1956
|
const snap = core.stateMachine.getSnapshot();
|
|
646
|
-
const stateEvent = core.buildStateEvent({
|
|
1957
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
647
1958
|
agentType: 'openclaw',
|
|
648
1959
|
agentCapabilities: OPENCLAW_CAPABILITIES,
|
|
649
1960
|
snapshot: snap,
|
|
650
|
-
});
|
|
1961
|
+
}));
|
|
651
1962
|
lastStateEvent = stateEvent;
|
|
652
1963
|
core.broadcast(stateEvent);
|
|
653
1964
|
core.broadcastUsage();
|
|
@@ -668,12 +1979,18 @@ export async function startDaemon(opts) {
|
|
|
668
1979
|
break;
|
|
669
1980
|
case 'timeline':
|
|
670
1981
|
if (evt.entry) {
|
|
1982
|
+
// This handler is wired exclusively to the OpenClaw Gateway adapter
|
|
1983
|
+
// (see agentType:'openclaw' at the model_catalog case above), but the
|
|
1984
|
+
// adapter emits bare timeline entries without agentType/projectName.
|
|
1985
|
+
// Stamp the OpenClaw origin so the attributor doesn't default them to
|
|
1986
|
+
// 'AgentDeck'/null. See enrichGatewayTimelineEntry.
|
|
1987
|
+
const enriched = enrichGatewayTimelineEntry(evt.entry);
|
|
671
1988
|
if (evt.upsert)
|
|
672
|
-
core.bridgeTimeline.upsertEntry(
|
|
1989
|
+
core.bridgeTimeline.upsertEntry(enriched);
|
|
673
1990
|
else
|
|
674
|
-
core.bridgeTimeline.addEntry(
|
|
675
|
-
if (
|
|
676
|
-
bridgeLogStream.trackToolRequest(
|
|
1991
|
+
core.bridgeTimeline.addEntry(enriched);
|
|
1992
|
+
if (enriched.type === 'tool_request')
|
|
1993
|
+
bridgeLogStream.trackToolRequest(enriched.raw);
|
|
677
1994
|
}
|
|
678
1995
|
break;
|
|
679
1996
|
case 'connection': {
|
|
@@ -684,6 +2001,7 @@ export async function startDaemon(opts) {
|
|
|
684
2001
|
if (evt.status === 'connected') {
|
|
685
2002
|
core.cachedGatewayAvailable = true;
|
|
686
2003
|
core.cachedGatewayConnected = true;
|
|
2004
|
+
core.cachedGatewayAuthStatus = 'connected';
|
|
687
2005
|
bridgeLogStream.start();
|
|
688
2006
|
log('[agentdeck] OpenClaw Gateway connected');
|
|
689
2007
|
if (core.stateMachine.getSnapshot().state === 'disconnected') {
|
|
@@ -691,11 +2009,11 @@ export async function startDaemon(opts) {
|
|
|
691
2009
|
}
|
|
692
2010
|
// Force full state broadcast
|
|
693
2011
|
const snap = core.stateMachine.getSnapshot();
|
|
694
|
-
const gwStateEvent = core.buildStateEvent({
|
|
2012
|
+
const gwStateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
695
2013
|
agentType: 'openclaw',
|
|
696
2014
|
agentCapabilities: OPENCLAW_CAPABILITIES,
|
|
697
2015
|
snapshot: snap,
|
|
698
|
-
});
|
|
2016
|
+
}));
|
|
699
2017
|
lastStateEvent = gwStateEvent;
|
|
700
2018
|
core.wsServer.broadcast(gwStateEvent);
|
|
701
2019
|
core.broadcastUsage();
|
|
@@ -703,8 +2021,10 @@ export async function startDaemon(opts) {
|
|
|
703
2021
|
}
|
|
704
2022
|
else {
|
|
705
2023
|
core.cachedGatewayConnected = false;
|
|
2024
|
+
core.cachedGatewayAuthStatus = 'gateway_not_found';
|
|
706
2025
|
bridgeLogStream.stop();
|
|
707
2026
|
log('[agentdeck] OpenClaw Gateway disconnected');
|
|
2027
|
+
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
708
2028
|
core.broadcastSessionsList().catch(() => { });
|
|
709
2029
|
}
|
|
710
2030
|
break;
|
|
@@ -719,6 +2039,7 @@ export async function startDaemon(opts) {
|
|
|
719
2039
|
log(`[agentdeck] Failed to connect to Gateway: ${err}`);
|
|
720
2040
|
gatewayConnecting = false;
|
|
721
2041
|
core.cachedGatewayConnected = false;
|
|
2042
|
+
core.cachedGatewayAuthStatus = 'gateway_not_found';
|
|
722
2043
|
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
723
2044
|
});
|
|
724
2045
|
}
|
|
@@ -729,10 +2050,25 @@ export async function startDaemon(opts) {
|
|
|
729
2050
|
const wasAlive = gatewayAdapter.isAlive();
|
|
730
2051
|
gatewayAdapter.shutdown().catch(() => { });
|
|
731
2052
|
gatewayAdapter = null;
|
|
2053
|
+
// APME: close the OpenClaw run so the collector fires its
|
|
2054
|
+
// session_end boundary and the run becomes eligible for the layer-2
|
|
2055
|
+
// judge queue.
|
|
2056
|
+
if (apme && openclawApmeSessionId) {
|
|
2057
|
+
try {
|
|
2058
|
+
apme.collector.closeRun(openclawApmeSessionId);
|
|
2059
|
+
}
|
|
2060
|
+
catch (err) {
|
|
2061
|
+
debug('APME', `closeRun for OpenClaw failed: ${String(err)}`);
|
|
2062
|
+
}
|
|
2063
|
+
openclawApmeSessionId = null;
|
|
2064
|
+
}
|
|
732
2065
|
core.cachedGatewayConnected = false;
|
|
2066
|
+
core.cachedGatewayAuthStatus = 'gateway_not_found';
|
|
733
2067
|
core.cachedModelCatalog = null;
|
|
734
2068
|
if (wasAlive)
|
|
735
2069
|
core.stateMachine.handleHookEvent('SessionEnd', {});
|
|
2070
|
+
else
|
|
2071
|
+
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
736
2072
|
// Do NOT broadcast connection:disconnected — that would make WS clients
|
|
737
2073
|
// think they lost their bridge connection. State change to 'daemon' agentType
|
|
738
2074
|
// and updated sessions_list convey the gateway loss.
|
|
@@ -789,11 +2125,11 @@ export async function startDaemon(opts) {
|
|
|
789
2125
|
// ===== State changed → broadcast =====
|
|
790
2126
|
core.stateMachine.on('state_changed', (snapshot) => {
|
|
791
2127
|
const gwAlive = gatewayAdapter?.isAlive() ?? false;
|
|
792
|
-
const stateEvent = core.buildStateEvent({
|
|
2128
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
793
2129
|
agentType: gwAlive ? 'openclaw' : 'daemon',
|
|
794
2130
|
agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
|
|
795
2131
|
snapshot,
|
|
796
|
-
});
|
|
2132
|
+
}));
|
|
797
2133
|
lastStateEvent = stateEvent;
|
|
798
2134
|
core.wsServer.broadcast(stateEvent);
|
|
799
2135
|
core.maybeBroadcastSessionsList();
|
|
@@ -816,6 +2152,62 @@ export async function startDaemon(opts) {
|
|
|
816
2152
|
// ===== Commands from WS clients =====
|
|
817
2153
|
// ===== Internal WS: session push channel =====
|
|
818
2154
|
core.wsServer.onRawMessage((msg, sender) => {
|
|
2155
|
+
// WiFi ESP32 boards announce device_info over WS on connect. Capture the
|
|
2156
|
+
// sender here so OTA can address the exact socket instead of broadcasting.
|
|
2157
|
+
if (msg.type === 'device_info') {
|
|
2158
|
+
core.wsServer.markEsp32Client(sender);
|
|
2159
|
+
registerWifiEsp32(msg, sender);
|
|
2160
|
+
return true;
|
|
2161
|
+
}
|
|
2162
|
+
touchWifiEsp32Socket(sender);
|
|
2163
|
+
if (msg.type === 'client_register'
|
|
2164
|
+
&& msg.clientType === 'eink-device') {
|
|
2165
|
+
// XTeink X3/X4 (fork) volunteering its E-ink roster. Store per-socket so
|
|
2166
|
+
// the Downstream E-ink rail can render it; drops on socket close.
|
|
2167
|
+
const devices = Array.isArray(msg.devices)
|
|
2168
|
+
? msg.devices : [];
|
|
2169
|
+
einkRegistrations.set(sender, { devices, updatedAt: Date.now() });
|
|
2170
|
+
debug('daemon', `client_register eink-device devices=${devices.length}`);
|
|
2171
|
+
// Push a fresh state so the rail appears within one frame instead of on
|
|
2172
|
+
// the next periodic tick.
|
|
2173
|
+
if (lastStateEvent && lastStateEvent.type === 'state_update') {
|
|
2174
|
+
lastStateEvent.moduleHealth = moduleHealthProvider();
|
|
2175
|
+
core.wsServer.broadcast(lastStateEvent);
|
|
2176
|
+
}
|
|
2177
|
+
return true;
|
|
2178
|
+
}
|
|
2179
|
+
if (msg.type === 'client_register'
|
|
2180
|
+
&& msg.clientType === 'android-dashboard') {
|
|
2181
|
+
// Android dashboard app volunteering its identity (tablet / e-ink
|
|
2182
|
+
// launcher on Wi-Fi) so the topology can show an Android row even when
|
|
2183
|
+
// the device is not ADB-bridged; drops on socket close.
|
|
2184
|
+
const devices = Array.isArray(msg.devices)
|
|
2185
|
+
? msg.devices : [];
|
|
2186
|
+
androidDashboardRegistrations.set(sender, { devices, updatedAt: Date.now() });
|
|
2187
|
+
debug('daemon', `client_register android-dashboard devices=${devices.length}`);
|
|
2188
|
+
if (lastStateEvent && lastStateEvent.type === 'state_update') {
|
|
2189
|
+
lastStateEvent.moduleHealth = moduleHealthProvider();
|
|
2190
|
+
core.wsServer.broadcast(lastStateEvent);
|
|
2191
|
+
}
|
|
2192
|
+
return true;
|
|
2193
|
+
}
|
|
2194
|
+
if (msg.type === 'client_register'
|
|
2195
|
+
&& msg.clientType === 'streamdeck-plugin') {
|
|
2196
|
+
// Elgato plugin volunteering the physical Stream Decks it drives — the
|
|
2197
|
+
// topology's Stream Deck rows. Swift-daemon parity; drops on socket close.
|
|
2198
|
+
const devices = Array.isArray(msg.devices)
|
|
2199
|
+
? msg.devices : [];
|
|
2200
|
+
streamDeckRegistrations.set(sender, { devices, updatedAt: Date.now() });
|
|
2201
|
+
debug('daemon', `client_register streamdeck-plugin devices=${devices.length}`);
|
|
2202
|
+
if (lastStateEvent && lastStateEvent.type === 'state_update') {
|
|
2203
|
+
lastStateEvent.moduleHealth = moduleHealthProvider();
|
|
2204
|
+
core.wsServer.broadcast(lastStateEvent);
|
|
2205
|
+
}
|
|
2206
|
+
return true;
|
|
2207
|
+
}
|
|
2208
|
+
if (handleEsp32OtaReply(msg)) {
|
|
2209
|
+
return true;
|
|
2210
|
+
}
|
|
819
2211
|
if (msg.type === 'session_push_register') {
|
|
820
2212
|
const { sessionId, port: sessionPort, agentType: at, projectName: pn } = msg;
|
|
821
2213
|
debug('daemon', `session_push_register: ${sessionId} port=${sessionPort} agent=${at}`);
|
|
@@ -835,6 +2227,45 @@ export async function startDaemon(opts) {
|
|
|
835
2227
|
}
|
|
836
2228
|
return true; // consumed
|
|
837
2229
|
}
|
|
2230
|
+
if (msg.type === 'deck_slot_map') {
|
|
2231
|
+
// Plugin pushed its keypad layout. Forward to other viewers (extra
|
|
2232
|
+
// plugin instance, dashboard) and re-broadcast sessions_list so slot
|
|
2233
|
+
// buttons populate immediately — without this they would stay "Empty"
|
|
2234
|
+
// until the next 10 s sessions polling tick after the plugin connect.
|
|
2235
|
+
core.wsServer.broadcastExcept(msg, sender);
|
|
2236
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2237
|
+
return true; // consumed
|
|
2238
|
+
}
|
|
2239
|
+
if (msg.type === 'query_session_timeline') {
|
|
2240
|
+
// Reply to THIS requester only with the session's recent timeline so a
|
|
2241
|
+
// device that connected mid-session can fill its Detail view on demand
|
|
2242
|
+
// (the live timeline_event stream is forward-only). Useful for any
|
|
2243
|
+
// reconnecting surface, not just the XTeink X3.
|
|
2244
|
+
const sessionId = msg.sessionId;
|
|
2245
|
+
const since = msg.since;
|
|
2246
|
+
if (typeof sessionId === 'string' && sessionId) {
|
|
2247
|
+
const sinceMs = typeof since === 'number' ? since : undefined;
|
|
2248
|
+
let entries = core.bridgeTimeline.getHistoryForSession(sessionId, sinceMs);
|
|
2249
|
+
// Passively-observed sessions never push timeline rows (the relay only
|
|
2250
|
+
// covers managed + hook sessions), so the store comes back empty for
|
|
2251
|
+
// them. Reconstruct a recent-activity timeline from their transcript so
|
|
2252
|
+
// the device's Detail view isn't stuck on "No recent activity yet".
|
|
2253
|
+
if (entries.length === 0) {
|
|
2254
|
+
try {
|
|
2255
|
+
entries = transcriptTimelineForSession(sessionId, { since: sinceMs });
|
|
2256
|
+
}
|
|
2257
|
+
catch { /* read-only best effort */ }
|
|
2258
|
+
}
|
|
2259
|
+
const historyEvent = buildCappedTimelineHistory(entries, undefined, { sessionId });
|
|
2260
|
+
if (historyEvent) {
|
|
2261
|
+
try {
|
|
2262
|
+
sender.send(JSON.stringify(historyEvent));
|
|
2263
|
+
}
|
|
2264
|
+
catch { /* client disconnecting */ }
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
return true; // consumed
|
|
2268
|
+
}
|
|
838
2269
|
return false; // not consumed — pass to command handler
|
|
839
2270
|
});
|
|
840
2271
|
core.wsServer.onCommand((cmd) => {
|
|
@@ -860,26 +2291,27 @@ export async function startDaemon(opts) {
|
|
|
860
2291
|
return;
|
|
861
2292
|
}
|
|
862
2293
|
if (cmd.type === 'switch_agent') {
|
|
2294
|
+
userFocusedSessionId = null;
|
|
863
2295
|
focusRelay.unfocus(); // Clear session focus on agent switch
|
|
864
2296
|
const target = cmd.agent;
|
|
865
2297
|
if (target === 'openclaw' && gatewayAdapter?.isAlive()) {
|
|
866
2298
|
// Force broadcast OpenClaw state to all clients
|
|
867
2299
|
const snap = core.stateMachine.getSnapshot();
|
|
868
|
-
const gwStateEvent = core.buildStateEvent({
|
|
2300
|
+
const gwStateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
869
2301
|
agentType: 'openclaw',
|
|
870
2302
|
agentCapabilities: OPENCLAW_CAPABILITIES,
|
|
871
2303
|
snapshot: snap,
|
|
872
|
-
});
|
|
2304
|
+
}));
|
|
873
2305
|
lastStateEvent = gwStateEvent;
|
|
874
2306
|
core.wsServer.broadcast(gwStateEvent);
|
|
875
2307
|
}
|
|
876
2308
|
else if (target === 'claude-code') {
|
|
877
2309
|
// Broadcast daemon/claude-code state — clients reconnect to session bridges independently
|
|
878
2310
|
const snap = core.stateMachine.getSnapshot();
|
|
879
|
-
const stateEvent = core.buildStateEvent({
|
|
2311
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
880
2312
|
agentType: 'daemon',
|
|
881
2313
|
snapshot: snap,
|
|
882
|
-
});
|
|
2314
|
+
}));
|
|
883
2315
|
lastStateEvent = stateEvent;
|
|
884
2316
|
core.wsServer.broadcast(stateEvent);
|
|
885
2317
|
}
|
|
@@ -889,9 +2321,32 @@ export async function startDaemon(opts) {
|
|
|
889
2321
|
const sessionId = cmd.sessionId;
|
|
890
2322
|
if (!sessionId)
|
|
891
2323
|
return;
|
|
2324
|
+
userFocusedSessionId = sessionId;
|
|
2325
|
+
broadcastFocusedState();
|
|
2326
|
+
if (sessionId === 'openclaw-gateway' && gatewayAdapter?.isAlive()) {
|
|
2327
|
+
focusRelay.unfocus();
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
892
2330
|
focusRelay.focus(sessionId);
|
|
893
2331
|
return;
|
|
894
2332
|
}
|
|
2333
|
+
if (cmd.type === 'clear_session_focus') {
|
|
2334
|
+
userFocusedSessionId = null;
|
|
2335
|
+
focusRelay.unfocus();
|
|
2336
|
+
broadcastFocusedState();
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
// Device approval for a gated PreToolUse permission request (observed session).
|
|
2340
|
+
// Resolves the held hook HTTP response → Claude allows/denies the tool.
|
|
2341
|
+
if (cmd.type === 'permission_decision') {
|
|
2342
|
+
const { requestId, decision } = cmd;
|
|
2343
|
+
if (typeof requestId !== 'string' || (decision !== 'allow' && decision !== 'deny'))
|
|
2344
|
+
return;
|
|
2345
|
+
// resolvePending → onResolved clears the overlay + rebroadcasts (sessions_list
|
|
2346
|
+
// + focused state), so all surfaces drop the gate. No-op if already resolved.
|
|
2347
|
+
resolvePending(requestId, decision);
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
895
2350
|
// Session-scoped command: forward inner command to a specific session's bridge
|
|
896
2351
|
if (cmd.type === 'session_command') {
|
|
897
2352
|
const { sessionId, command } = cmd;
|
|
@@ -904,11 +2359,27 @@ export async function startDaemon(opts) {
|
|
|
904
2359
|
return;
|
|
905
2360
|
}
|
|
906
2361
|
// Focus the session first, then route the command
|
|
2362
|
+
userFocusedSessionId = sessionId;
|
|
2363
|
+
broadcastFocusedState();
|
|
907
2364
|
focusRelay.focus(sessionId);
|
|
908
2365
|
// Small delay to let focus take effect, then route
|
|
909
2366
|
setTimeout(() => focusRelay.routeCommand(command), 100);
|
|
910
2367
|
return;
|
|
911
2368
|
}
|
|
2369
|
+
// Session-scoped option select from a multi-up panel (IPS10 D1 mosaic):
|
|
2370
|
+
// any awaiting cell can be answered, not just the focused one. Focus the
|
|
2371
|
+
// named session, then route a plain select_option to its bridge.
|
|
2372
|
+
if (cmd.type === 'select_option' && typeof cmd.sessionId === 'string') {
|
|
2373
|
+
const sessionId = cmd.sessionId;
|
|
2374
|
+
const target = listActiveSessions().find(s => s.id === sessionId);
|
|
2375
|
+
if (target) {
|
|
2376
|
+
userFocusedSessionId = sessionId;
|
|
2377
|
+
broadcastFocusedState();
|
|
2378
|
+
focusRelay.focus(sessionId);
|
|
2379
|
+
setTimeout(() => focusRelay.routeCommand({ type: 'select_option', index: cmd.index }), 100);
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
912
2383
|
// Route interactive commands to focused session (if any)
|
|
913
2384
|
if (focusRelay.getFocusedSessionId() && focusRelay.routeCommand(cmd)) {
|
|
914
2385
|
return;
|
|
@@ -944,6 +2415,9 @@ export async function startDaemon(opts) {
|
|
|
944
2415
|
});
|
|
945
2416
|
}
|
|
946
2417
|
});
|
|
2418
|
+
core.wsServer.onClientDisconnect((ws) => {
|
|
2419
|
+
unregisterWifiEsp32Socket(ws);
|
|
2420
|
+
});
|
|
947
2421
|
// ===== Probes & polling =====
|
|
948
2422
|
core.startOllamaProbe();
|
|
949
2423
|
core.startMlxProbe();
|
|
@@ -962,7 +2436,39 @@ export async function startDaemon(opts) {
|
|
|
962
2436
|
const { classifyRunSmart } = await import('./apme/classifier.js');
|
|
963
2437
|
const { aggregateOverall } = await import('./apme/http.js');
|
|
964
2438
|
// Broadcast eval results to all WS clients + timeline when runner completes
|
|
965
|
-
apme.runner.onResult(({ runId, turnId }) => {
|
|
2439
|
+
apme.runner.onResult(({ runId, turnId, taskId, layer1Ran, layer2Ran, overall }) => {
|
|
2440
|
+
// Task-level eval: a group of turns between boundary signals
|
|
2441
|
+
// (TodoWrite all-completed / /clear / session_end). Distinct timeline
|
|
2442
|
+
// entry so users see "★ task 85%" rather than another run-level pulse.
|
|
2443
|
+
// The task's summary axis is the most user-readable signal and goes
|
|
2444
|
+
// into `detail` ahead of the per-axis breakdown.
|
|
2445
|
+
if (taskId) {
|
|
2446
|
+
const run = apme.store.getRun(runId);
|
|
2447
|
+
const task = apme.store.getTask(taskId);
|
|
2448
|
+
if (!run || !task)
|
|
2449
|
+
return;
|
|
2450
|
+
const taskEvals = apme.store.listEvalsForTask(taskId);
|
|
2451
|
+
const overallEval = taskEvals.find(e => e.metric === 'overall');
|
|
2452
|
+
if (!overallEval)
|
|
2453
|
+
return;
|
|
2454
|
+
const taskEvalEvent = {
|
|
2455
|
+
type: 'apme_eval',
|
|
2456
|
+
run: {
|
|
2457
|
+
runId: run.id, sessionId: run.sessionId, agentType: run.agentType, startedAt: run.startedAt,
|
|
2458
|
+
modelId: run.modelId ?? undefined, projectName: run.projectName ?? undefined,
|
|
2459
|
+
taskPrompt: run.taskPrompt ?? undefined, taskCategory: run.taskCategory ?? undefined,
|
|
2460
|
+
outcome: 'committed',
|
|
2461
|
+
compositeScore: overallEval.score,
|
|
2462
|
+
overallScore: overallEval.score,
|
|
2463
|
+
evals: taskEvals.map(e => ({
|
|
2464
|
+
layer: e.layer, metric: e.metric, score: e.score,
|
|
2465
|
+
judgeModel: e.judgeModel ?? undefined, createdAt: e.createdAt,
|
|
2466
|
+
})),
|
|
2467
|
+
},
|
|
2468
|
+
};
|
|
2469
|
+
core.broadcast(taskEvalEvent);
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
966
2472
|
// Turn-level eval: broadcast and add timeline entry with turn score
|
|
967
2473
|
if (turnId) {
|
|
968
2474
|
const run = apme.store.getRun(runId);
|
|
@@ -981,7 +2487,6 @@ export async function startDaemon(opts) {
|
|
|
981
2487
|
});
|
|
982
2488
|
}
|
|
983
2489
|
catch { /* ignore */ }
|
|
984
|
-
const pct = Math.round(overall.score * 100);
|
|
985
2490
|
// WS broadcast — reuse apme_eval event for turn eval so dashboards pick it up
|
|
986
2491
|
const turnEvalEvent = {
|
|
987
2492
|
type: 'apme_eval',
|
|
@@ -999,30 +2504,6 @@ export async function startDaemon(opts) {
|
|
|
999
2504
|
},
|
|
1000
2505
|
};
|
|
1001
2506
|
core.broadcast(turnEvalEvent);
|
|
1002
|
-
// Change 8: include axis scores + judge reasoning in turn eval detail
|
|
1003
|
-
const turnAxes = turnEvals.filter(e => e.metric !== 'overall');
|
|
1004
|
-
const turnAxisStr = turnAxes.map(e => `${e.metric}=${Math.round(e.score * 100)}%`).join(' ');
|
|
1005
|
-
let turnDetail = turnAxisStr
|
|
1006
|
-
? `${turnAxisStr}\n${run.taskPrompt?.slice(0, 80) ?? ''}`
|
|
1007
|
-
: `Turn eval · ${run.taskPrompt?.slice(0, 80) ?? ''}`;
|
|
1008
|
-
if (overall.raw) {
|
|
1009
|
-
try {
|
|
1010
|
-
const parsed = JSON.parse(overall.raw);
|
|
1011
|
-
const done = parsed.done?.slice(0, 2).join(', ') || '';
|
|
1012
|
-
const missed = parsed.missed?.slice(0, 2).join(', ') || '';
|
|
1013
|
-
if (done)
|
|
1014
|
-
turnDetail += `\n✓ ${done}`;
|
|
1015
|
-
if (missed)
|
|
1016
|
-
turnDetail += `\n✗ ${missed}`;
|
|
1017
|
-
}
|
|
1018
|
-
catch { /* ignore */ }
|
|
1019
|
-
}
|
|
1020
|
-
core.bridgeTimeline.addEntry({
|
|
1021
|
-
ts: Date.now(), type: 'eval_result',
|
|
1022
|
-
raw: `★ turn ${pct}% [${run.taskCategory ?? '?'}]`,
|
|
1023
|
-
detail: turnDetail,
|
|
1024
|
-
agentType: run.agentType,
|
|
1025
|
-
});
|
|
1026
2507
|
return;
|
|
1027
2508
|
}
|
|
1028
2509
|
const run = apme.store.getRun(runId);
|
|
@@ -1030,6 +2511,10 @@ export async function startDaemon(opts) {
|
|
|
1030
2511
|
return;
|
|
1031
2512
|
const evals = apme.store.listEvalsForRun(runId);
|
|
1032
2513
|
const overallScore = aggregateOverall(evals);
|
|
2514
|
+
if (!layer1Ran && !layer2Ran && overall === undefined && evals.length === 0) {
|
|
2515
|
+
debug('APME', `skip run eval timeline for ${runId.slice(0, 8)} — no eval rows produced`);
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
1033
2518
|
// WS broadcast: apme_eval event (type already in protocol.ts)
|
|
1034
2519
|
const evalEvent = {
|
|
1035
2520
|
type: 'apme_eval',
|
|
@@ -1047,34 +2532,6 @@ export async function startDaemon(opts) {
|
|
|
1047
2532
|
},
|
|
1048
2533
|
};
|
|
1049
2534
|
core.broadcast(evalEvent);
|
|
1050
|
-
// Change 9: separate deterministic layer timeline entry (lint/build/test)
|
|
1051
|
-
const detEvals = evals.filter(e => e.layer === 'deterministic');
|
|
1052
|
-
if (detEvals.length > 0) {
|
|
1053
|
-
const detResults = detEvals.map(e => `${e.metric}=${e.score ? '✓' : '✗'}`).join(' ');
|
|
1054
|
-
core.bridgeTimeline.addEntry({
|
|
1055
|
-
ts: Date.now(), type: 'eval_result',
|
|
1056
|
-
raw: `⚡ ${detResults}`,
|
|
1057
|
-
detail: `Deterministic eval · ${run.projectName ?? ''}`,
|
|
1058
|
-
agentType: run.agentType,
|
|
1059
|
-
});
|
|
1060
|
-
}
|
|
1061
|
-
// Change 7: enriched run-level eval_result with axis scores + deterministic summary
|
|
1062
|
-
const pct = Math.round((overallScore ?? run.compositeScore ?? 0) * 100);
|
|
1063
|
-
const judgeEvals = evals.filter(e => e.layer === 'llm_judge' && e.metric !== 'overall');
|
|
1064
|
-
const axisStr = judgeEvals.map(e => `${e.metric}=${Math.round(e.score * 100)}%`).join(' ');
|
|
1065
|
-
const detStr = detEvals.length > 0
|
|
1066
|
-
? detEvals.map(e => `${e.metric}=${e.score ? '✓' : '✗'}`).join(' ') + ' · '
|
|
1067
|
-
: '';
|
|
1068
|
-
const enrichedDetail = axisStr
|
|
1069
|
-
? `${detStr}${axisStr}\n${run.projectName ?? ''} · ${run.taskPrompt?.slice(0, 100) ?? ''}`
|
|
1070
|
-
: `${run.projectName ?? ''} · ${run.taskPrompt?.slice(0, 100) ?? ''}`;
|
|
1071
|
-
core.bridgeTimeline.addEntry({
|
|
1072
|
-
ts: Date.now(),
|
|
1073
|
-
type: 'eval_result',
|
|
1074
|
-
raw: `★ [${run.taskCategory ?? '?'}] ${pct}% · ${run.outcome ?? 'pending'}`,
|
|
1075
|
-
detail: enrichedDetail,
|
|
1076
|
-
agentType: run.agentType,
|
|
1077
|
-
});
|
|
1078
2535
|
});
|
|
1079
2536
|
const apmeEvalTimer = setInterval(() => {
|
|
1080
2537
|
// 1. Enqueue unevaluated runs for deterministic + judge
|
|
@@ -1145,24 +2602,35 @@ export async function startDaemon(opts) {
|
|
|
1145
2602
|
}
|
|
1146
2603
|
});
|
|
1147
2604
|
}, 10_000));
|
|
2605
|
+
// Backstop sweep for held PreToolUse approval responses whose per-entry timer
|
|
2606
|
+
// somehow didn't fire — resolves anything older than 60s to "ask" so a held
|
|
2607
|
+
// socket can't leak and Claude's own prompt isn't lost forever.
|
|
2608
|
+
const permissionSweepTimer = setInterval(() => { sweepStalePending(60_000); }, 30_000);
|
|
2609
|
+
permissionSweepTimer.unref?.();
|
|
1148
2610
|
// ===== Shutdown =====
|
|
1149
2611
|
core.onShutdown(async () => {
|
|
2612
|
+
clearInterval(permissionSweepTimer);
|
|
2613
|
+
drainAllPending();
|
|
1150
2614
|
removeDaemonInfo();
|
|
1151
2615
|
focusRelay.stop();
|
|
1152
2616
|
timelineRelay.stop();
|
|
1153
2617
|
voiceAssistant?.stop();
|
|
1154
2618
|
voiceManager?.disconnectFromServer();
|
|
1155
2619
|
bridgeLogStream.stop();
|
|
2620
|
+
// iDotMatrix BLE sync is stopped by IDotMatrixModule.stop() via stopModules below.
|
|
1156
2621
|
await Promise.all([
|
|
1157
2622
|
gatewayAdapter ? gatewayAdapter.shutdown().catch(() => { }) : Promise.resolve(),
|
|
1158
2623
|
stopModules(startedModules)
|
|
1159
2624
|
]);
|
|
1160
2625
|
gatewayAdapter = null;
|
|
1161
|
-
httpServer.close(() =>
|
|
2626
|
+
httpServer.close(() => exitProcessNow(0));
|
|
1162
2627
|
// Force exit if httpServer.close() hangs on CLOSE_WAIT connections
|
|
1163
|
-
setTimeout(() =>
|
|
2628
|
+
setTimeout(() => exitProcessNow(0), 5000).unref();
|
|
1164
2629
|
});
|
|
1165
2630
|
core.registerProcessHandlers('agentdeck');
|
|
2631
|
+
// Trigger initial state broadcast so display hardware modules (D200H, Pixoo, Serial) get populated
|
|
2632
|
+
broadcastFocusedState();
|
|
2633
|
+
core.broadcastUsage();
|
|
1166
2634
|
log(`[agentdeck] Daemon running. Gateway probe active.`);
|
|
1167
2635
|
}
|
|
1168
2636
|
//# sourceMappingURL=daemon-server.js.map
|