@agentdeck/bridge 0.2.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/fm-helper/agentdeck-fm-helper +0 -0
- 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 +133 -6
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +471 -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 +117 -11
- package/dist/apme/dashboard-html.js.map +1 -1
- package/dist/apme/http.d.ts.map +1 -1
- package/dist/apme/http.js +52 -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 +146 -9
- package/dist/apme/index.js.map +1 -1
- package/dist/apme/judge-detect.d.ts +26 -0
- package/dist/apme/judge-detect.d.ts.map +1 -0
- package/dist/apme/judge-detect.js +69 -0
- package/dist/apme/judge-detect.js.map +1 -0
- 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 +99 -4
- package/dist/apme/runner.d.ts.map +1 -1
- package/dist/apme/runner.js +743 -72
- 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 +17 -11
- package/dist/apme/settings.d.ts.map +1 -1
- package/dist/apme/settings.js +30 -9
- 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 +112 -0
- package/dist/awaiting-overlay.d.ts.map +1 -0
- package/dist/awaiting-overlay.js +179 -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 +34 -1
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +207 -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/claude-permission-rules.d.ts +55 -0
- package/dist/claude-permission-rules.d.ts.map +1 -0
- package/dist/claude-permission-rules.js +197 -0
- package/dist/claude-permission-rules.js.map +1 -0
- package/dist/cli.d.ts +15 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1030 -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/d200h/image-renderer.d.ts +4 -16
- package/dist/d200h/image-renderer.d.ts.map +1 -1
- package/dist/d200h/image-renderer.js +46 -212
- package/dist/d200h/image-renderer.js.map +1 -1
- package/dist/daemon-server.d.ts +85 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +1975 -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 +190 -1
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +1090 -156
- 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 +119 -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 +23 -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 +217 -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 +3 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +18 -5
- 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/model-catalog.d.ts +1 -1
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +2 -4
- package/dist/model-catalog.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/d200h-module.d.ts +22 -0
- package/dist/modules/d200h-module.d.ts.map +1 -1
- package/dist/modules/d200h-module.js +200 -65
- package/dist/modules/d200h-module.js.map +1 -1
- 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/trmnl-module.d.ts +38 -0
- package/dist/modules/trmnl-module.d.ts.map +1 -0
- package/dist/modules/trmnl-module.js +111 -0
- package/dist/modules/trmnl-module.js.map +1 -0
- package/dist/modules/types.d.ts +4 -2
- package/dist/modules/types.d.ts.map +1 -1
- package/dist/observed-steering.d.ts +93 -0
- package/dist/observed-steering.d.ts.map +1 -0
- package/dist/observed-steering.js +247 -0
- package/dist/observed-steering.js.map +1 -0
- 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/opencode-steering.d.ts +36 -0
- package/dist/opencode-steering.d.ts.map +1 -0
- package/dist/opencode-steering.js +84 -0
- package/dist/opencode-steering.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 +172 -18
- package/dist/output-parser.js.map +1 -1
- package/dist/passive-observer.d.ts +32 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +288 -49
- package/dist/passive-observer.js.map +1 -1
- package/dist/permission-resolver.d.ts +55 -0
- package/dist/permission-resolver.d.ts.map +1 -0
- package/dist/permission-resolver.js +112 -0
- package/dist/permission-resolver.js.map +1 -0
- package/dist/pixoo/micro-glyphs.d.ts +18 -0
- package/dist/pixoo/micro-glyphs.d.ts.map +1 -0
- package/dist/pixoo/micro-glyphs.js +143 -0
- package/dist/pixoo/micro-glyphs.js.map +1 -0
- package/dist/pixoo/official-dot-glyphs.generated.d.ts +8 -0
- package/dist/pixoo/official-dot-glyphs.generated.d.ts.map +1 -0
- package/dist/pixoo/official-dot-glyphs.generated.js +247 -0
- package/dist/pixoo/official-dot-glyphs.generated.js.map +1 -0
- package/dist/pixoo/pixoo-bridge.d.ts +16 -8
- package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
- package/dist/pixoo/pixoo-bridge.js +127 -35
- 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 +27 -3
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +533 -135
- 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 +84 -11
- package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
- package/dist/pixoo/pixoo-sprites.js +620 -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/review-runner.d.ts +113 -0
- package/dist/review-runner.d.ts.map +1 -0
- package/dist/review-runner.js +428 -0
- package/dist/review-runner.js.map +1 -0
- 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 +25 -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-focus-relay.d.ts.map +1 -1
- package/dist/session-focus-relay.js +7 -1
- package/dist/session-focus-relay.js.map +1 -1
- package/dist/session-registry.d.ts +74 -3
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +140 -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 +112 -3
- package/dist/timeline-store.d.ts.map +1 -1
- package/dist/timeline-store.js +460 -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/trmnl/byos-server.d.ts +39 -0
- package/dist/trmnl/byos-server.d.ts.map +1 -0
- package/dist/trmnl/byos-server.js +301 -0
- package/dist/trmnl/byos-server.js.map +1 -0
- package/dist/trmnl/frame-cache.d.ts +37 -0
- package/dist/trmnl/frame-cache.d.ts.map +1 -0
- package/dist/trmnl/frame-cache.js +157 -0
- package/dist/trmnl/frame-cache.js.map +1 -0
- package/dist/trmnl/image-renderer.d.ts +27 -0
- package/dist/trmnl/image-renderer.d.ts.map +1 -0
- package/dist/trmnl/image-renderer.js +235 -0
- package/dist/trmnl/image-renderer.js.map +1 -0
- package/dist/trmnl/trmnl-settings.d.ts +96 -0
- package/dist/trmnl/trmnl-settings.d.ts.map +1 -0
- package/dist/trmnl/trmnl-settings.js +177 -0
- package/dist/trmnl/trmnl-settings.js.map +1 -0
- package/dist/trmnl/trmnl-telemetry.d.ts +47 -0
- package/dist/trmnl/trmnl-telemetry.d.ts.map +1 -0
- package/dist/trmnl/trmnl-telemetry.js +56 -0
- package/dist/trmnl/trmnl-telemetry.js.map +1 -0
- 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 +38 -11
- 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 +355 -121
- package/dist/tui/renderer.js.map +1 -1
- package/dist/tui/terrarium.d.ts.map +1 -1
- package/dist/tui/terrarium.js +44 -51
- 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 +98 -13
- 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/version-check.d.ts.map +1 -1
- package/dist/version-check.js +1 -17
- package/dist/version-check.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 +17 -3
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +78 -8
- package/dist/ws-server.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +112 -0
- package/package.json +10 -4
- 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/daemon-server.js
CHANGED
|
@@ -11,46 +11,466 @@
|
|
|
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, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved } from './awaiting-overlay.js';
|
|
27
|
+
import { registerPending, resolvePending, sweepStalePending, drainAllPending, isPendingRequest } from './permission-resolver.js';
|
|
28
|
+
import { shouldHoldPreToolUse, gateReleased, buildGateQuestion, consumeStop, requestStop, clearStop, STOP_DENY_REASON, queueDirective, takeDirective, clearOnUserPrompt, clearSession as clearSteeringSession, notePermissionPromptShown, noteToolEnd, steeringSnapshot, } from './observed-steering.js';
|
|
29
|
+
import { enqueueOpenCodeCommand, pollOpenCodeCommands } from './opencode-steering.js';
|
|
30
|
+
import { runSessionReview, reviewSnapshot } from './review-runner.js';
|
|
31
|
+
/** Observed-session device approval gate (PreToolUse hold). Default ON — the
|
|
32
|
+
* precision guards in observed-steering.ts/claude-permission-rules.ts ensure
|
|
33
|
+
* only genuine would-prompt calls are held. Kill switch for field issues. */
|
|
34
|
+
const OBSERVED_APPROVAL_ENABLED = process.env.AGENTDECK_OBSERVED_APPROVAL !== '0';
|
|
35
|
+
/** How long a held gate waits for a device decision before releasing the tool
|
|
36
|
+
* call to Claude's own permission flow. Must stay well under the hook curl's
|
|
37
|
+
* --max-time 60 so the release reaches Claude before curl gives up. */
|
|
38
|
+
const OBSERVED_APPROVAL_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGENTDECK_APPROVAL_HOLD_MS) || 25_000));
|
|
22
39
|
import { VoiceManager } from './voice.js';
|
|
23
40
|
import { VoiceAssistantManager } from './voice-assistant.js';
|
|
24
|
-
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, } from './session-registry.js';
|
|
41
|
+
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, DAEMON_DEFAULT_PORT, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, writeDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getOwnTimelineFile, } from './session-registry.js';
|
|
25
42
|
import { fetchUsageFromApi, hasOAuthToken, resetConsecutiveFailures } from './usage-api.js';
|
|
26
43
|
import { isLocalConnection, validateToken } from './auth.js';
|
|
27
44
|
import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
|
|
45
|
+
import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
|
|
28
46
|
import { handlePixooWake } from './pixoo/pixoo-client.js';
|
|
29
47
|
import { triggerMdnsRecovery } from './mdns.js';
|
|
30
48
|
import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
|
|
31
49
|
import { enableDebugLog, debug } from './logger.js';
|
|
32
|
-
import { initApme } from './apme/index.js';
|
|
50
|
+
import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
|
|
51
|
+
import { FallbackTaskTimeline } from './fallback-task-timeline.js';
|
|
33
52
|
import { handleApmeRequest } from './apme/http.js';
|
|
53
|
+
import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
|
|
54
|
+
import { transcriptTimelineForSession, lastAssistantTextFromTranscript } from './session-transcript-timeline.js';
|
|
55
|
+
import { lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
|
|
56
|
+
import { callFoundationModelsHelper } from './foundation-models-helper.js';
|
|
34
57
|
import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
|
|
35
|
-
import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendWifiProvisionToAll, handleESP32Wake } from './esp32-serial.js';
|
|
58
|
+
import { esp32ConnectionCount, getESP32DeviceInfo, onESP32Message, sendWifiProvisionToAll, handleESP32Wake, getESP32Ports, getSerialConnectionStatus, getSerialLastError, getSerialReachableBoards } from './esp32-serial.js';
|
|
36
59
|
import { loadWifiConfig } from './wifi-config.js';
|
|
37
|
-
import { getConnectedAdbDevices, hasAdb } from './adb-reverse.js';
|
|
60
|
+
import { getConnectedAdbDevices, hasAdb, getAdbDeviceCount } from './adb-reverse.js';
|
|
38
61
|
import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js';
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
62
|
+
import { loadTimeboxDevices } from './timebox/timebox-settings.js';
|
|
63
|
+
import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
|
|
64
|
+
import { injectOpenClawSession } from './openclaw-session.js';
|
|
65
|
+
import { readFileSync, statSync } from 'fs';
|
|
41
66
|
import { join } from 'path';
|
|
42
67
|
import { homedir } from 'os';
|
|
43
68
|
import { OPENCLAW_CAPABILITIES, State, } from './types.js';
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
69
|
+
function exitProcessNow(code = 0) {
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
try {
|
|
72
|
+
process.kill(process.pid, 'SIGKILL');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// fall through
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
process.exit(code);
|
|
80
|
+
}
|
|
81
|
+
const wifiEsp32Devices = new Map();
|
|
82
|
+
const wifiEsp32Sockets = new Map();
|
|
83
|
+
const otaWaiters = new Map();
|
|
84
|
+
function listWifiEsp32Devices() {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const serialBoards = getSerialReachableBoards();
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const [key, d] of wifiEsp32Devices) {
|
|
89
|
+
if (now - d.lastSeenMs > 60 * 60 * 1000) {
|
|
90
|
+
wifiEsp32Devices.delete(key);
|
|
91
|
+
wifiEsp32Sockets.delete(key);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// serialActive: this WiFi board is also live on USB serial, so its WiFi path
|
|
95
|
+
// is a hot standby — display events are driven over serial (single path).
|
|
96
|
+
const serialActive = isWifiTransportRedundant({ board: d.board, ip: d.ip }, serialBoards);
|
|
97
|
+
out.push({ ...d, stale: now - d.lastSeenMs > 90_000, serialActive });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
function wifiEsp32Key(d) {
|
|
102
|
+
const board = typeof d.board === 'string' && d.board ? d.board : 'unknown';
|
|
103
|
+
const ip = typeof d.ip === 'string' && d.ip ? d.ip : 'no-ip';
|
|
104
|
+
return `${board}:${ip}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Single-path transport dedup. A physical ESP32 can be reachable over BOTH a
|
|
108
|
+
* USB serial connection and a WiFi WebSocket at once (e.g. inkdeck/ttgo/tc001
|
|
109
|
+
* plugged in for flashing while still joined to the AP). Serial is the more
|
|
110
|
+
* reliable, lower-latency path, so when a board is live on serial we drive it
|
|
111
|
+
* over serial only and suppress the redundant WiFi copy — no board receives the
|
|
112
|
+
* same event twice.
|
|
113
|
+
*
|
|
114
|
+
* Match is by board id, confirmed by the board's WiFi IP when both sides report
|
|
115
|
+
* one. If the serial side reports no IP (radio parked / pre-DHCP) the board id
|
|
116
|
+
* is the only shared key and we still prefer serial. Pure + exported for tests.
|
|
117
|
+
*/
|
|
118
|
+
export function isWifiTransportRedundant(wifi, serialBoards) {
|
|
119
|
+
if (!wifi || !wifi.board || wifi.board === 'unknown')
|
|
120
|
+
return false;
|
|
121
|
+
for (const s of serialBoards) {
|
|
122
|
+
if (s.board !== wifi.board)
|
|
123
|
+
continue;
|
|
124
|
+
// Same board type on serial: dedup unless both report IPs that differ
|
|
125
|
+
// (two distinct physical units of the same board model).
|
|
126
|
+
if (!s.ip || !wifi.ip || s.ip === wifi.ip)
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
/** Reverse-lookup a WiFi ESP32 socket to its registered {board, ip} identity. */
|
|
132
|
+
function wifiEsp32IdentityForSocket(ws) {
|
|
133
|
+
for (const [key, registeredWs] of wifiEsp32Sockets) {
|
|
134
|
+
if (registeredWs !== ws)
|
|
135
|
+
continue;
|
|
136
|
+
const d = wifiEsp32Devices.get(key);
|
|
137
|
+
return d ? { board: d.board, ip: d.ip } : null;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
/** True when this WiFi ESP32 socket's board is also live on USB serial, so its
|
|
142
|
+
* WiFi event copy should be suppressed in favour of the serial path. */
|
|
143
|
+
function isWifiEsp32RedundantWithSerial(ws) {
|
|
144
|
+
return isWifiTransportRedundant(wifiEsp32IdentityForSocket(ws), getSerialReachableBoards());
|
|
145
|
+
}
|
|
146
|
+
function registerWifiEsp32(d, ws) {
|
|
147
|
+
const key = wifiEsp32Key(d);
|
|
148
|
+
const previous = wifiEsp32Devices.get(key);
|
|
149
|
+
const uptimeSec = typeof d.uptimeSec === 'number' ? d.uptimeSec : undefined;
|
|
150
|
+
const resetReason = typeof d.resetReason === 'string' ? d.resetReason : undefined;
|
|
151
|
+
const resetReasonCode = typeof d.resetReasonCode === 'number' ? d.resetReasonCode : undefined;
|
|
152
|
+
wifiEsp32Devices.set(key, {
|
|
153
|
+
board: typeof d.board === 'string' ? d.board : 'unknown',
|
|
154
|
+
version: typeof d.version === 'string' ? d.version : undefined,
|
|
155
|
+
buildHash: typeof d.buildHash === 'string' ? d.buildHash : undefined,
|
|
156
|
+
buildEpoch: typeof d.buildEpoch === 'number' ? d.buildEpoch : undefined,
|
|
157
|
+
ip: typeof d.ip === 'string' ? d.ip : undefined,
|
|
158
|
+
protocolRevision: typeof d.protocolRevision === 'number' ? d.protocolRevision : undefined,
|
|
159
|
+
uptimeSec,
|
|
160
|
+
resetReason,
|
|
161
|
+
resetReasonCode,
|
|
162
|
+
otaSupported: typeof d.otaSupported === 'boolean' ? d.otaSupported : undefined,
|
|
163
|
+
otaSlotCount: typeof d.otaSlotCount === 'number' ? d.otaSlotCount : undefined,
|
|
164
|
+
otaSlotSize: typeof d.otaSlotSize === 'number' ? d.otaSlotSize : undefined,
|
|
165
|
+
otaFreeSketchSpace: typeof d.otaFreeSketchSpace === 'number' ? d.otaFreeSketchSpace : undefined,
|
|
166
|
+
otaReason: typeof d.otaReason === 'string' ? d.otaReason : undefined,
|
|
167
|
+
lastSeenMs: Date.now(),
|
|
168
|
+
});
|
|
169
|
+
wifiEsp32Sockets.set(key, ws);
|
|
170
|
+
if (previous?.uptimeSec != null && uptimeSec != null && uptimeSec + 10 < previous.uptimeSec) {
|
|
171
|
+
log(`[agentdeck] ESP32 reboot observed: ${key} uptime ${previous.uptimeSec}s -> ${uptimeSec}s reset=${resetReason ?? 'unknown'} code=${resetReasonCode ?? 'unknown'}`);
|
|
172
|
+
}
|
|
173
|
+
debug('daemon', `WiFi ESP32 registered: ${key} v${String(d.version ?? '')} build=${String(d.buildHash ?? '')} uptime=${String(uptimeSec ?? '')} reset=${String(resetReason ?? '')}`);
|
|
174
|
+
}
|
|
175
|
+
function touchWifiEsp32Socket(ws) {
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
for (const [key, registeredWs] of wifiEsp32Sockets) {
|
|
178
|
+
if (registeredWs !== ws)
|
|
179
|
+
continue;
|
|
180
|
+
const device = wifiEsp32Devices.get(key);
|
|
181
|
+
if (device)
|
|
182
|
+
device.lastSeenMs = now;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function unregisterWifiEsp32Socket(ws) {
|
|
187
|
+
for (const [key, registeredWs] of wifiEsp32Sockets) {
|
|
188
|
+
if (registeredWs !== ws)
|
|
189
|
+
continue;
|
|
190
|
+
wifiEsp32Sockets.delete(key);
|
|
191
|
+
// Keep the last device_info snapshot until normal age-out. A reconnect after
|
|
192
|
+
// a board reboot must compare the new uptime against the previous one; if we
|
|
193
|
+
// delete here every crash looks like a fresh device.
|
|
194
|
+
debug('daemon', `WiFi ESP32 disconnected: ${key}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function handleEsp32OtaReply(msg) {
|
|
198
|
+
if (msg.type !== 'esp32_ota_ack' && msg.type !== 'esp32_ota_error')
|
|
199
|
+
return false;
|
|
200
|
+
const otaId = typeof msg.otaId === 'string' ? msg.otaId : '';
|
|
201
|
+
const waiter = otaId ? otaWaiters.get(otaId) : undefined;
|
|
202
|
+
if (!waiter)
|
|
203
|
+
return true;
|
|
204
|
+
if (msg.type === 'esp32_ota_error') {
|
|
205
|
+
clearTimeout(waiter.timer);
|
|
206
|
+
otaWaiters.delete(otaId);
|
|
207
|
+
waiter.reject(new Error(typeof msg.error === 'string' ? msg.error : 'esp32_ota_error'));
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
const stage = typeof msg.stage === 'string' ? msg.stage : '';
|
|
211
|
+
const seq = typeof msg.seq === 'number' ? msg.seq : undefined;
|
|
212
|
+
if (stage !== waiter.stage)
|
|
213
|
+
return true;
|
|
214
|
+
if (waiter.seq != null && seq !== waiter.seq)
|
|
215
|
+
return true;
|
|
216
|
+
clearTimeout(waiter.timer);
|
|
217
|
+
otaWaiters.delete(otaId);
|
|
218
|
+
waiter.resolve(msg);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
function waitForOtaAck(otaId, stage, seq, timeoutMs) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const timer = setTimeout(() => {
|
|
224
|
+
otaWaiters.delete(otaId);
|
|
225
|
+
reject(new Error(`OTA ${stage}${seq != null ? ` #${seq}` : ''} timed out`));
|
|
226
|
+
}, timeoutMs);
|
|
227
|
+
otaWaiters.set(otaId, { stage, seq, resolve, reject, timer });
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function findWifiOtaTarget(target) {
|
|
231
|
+
const matches = [];
|
|
232
|
+
for (const [key, device] of wifiEsp32Devices) {
|
|
233
|
+
const ws = wifiEsp32Sockets.get(key);
|
|
234
|
+
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
235
|
+
continue;
|
|
236
|
+
if (key === target || device.board === target || device.ip === target) {
|
|
237
|
+
matches.push({ key, device, ws });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (matches.length === 0)
|
|
241
|
+
throw new Error(`No online WiFi ESP32 target matches "${target}"`);
|
|
242
|
+
if (matches.length > 1)
|
|
243
|
+
throw new Error(`Target "${target}" is ambiguous: ${matches.map(m => m.key).join(', ')}`);
|
|
244
|
+
return matches[0];
|
|
245
|
+
}
|
|
246
|
+
async function readJsonBody(req, maxBytes = 64 * 1024) {
|
|
247
|
+
const chunks = [];
|
|
248
|
+
let total = 0;
|
|
249
|
+
for await (const chunk of req) {
|
|
250
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
251
|
+
total += buf.length;
|
|
252
|
+
if (total > maxBytes)
|
|
253
|
+
throw new Error('request_body_too_large');
|
|
254
|
+
chunks.push(buf);
|
|
47
255
|
}
|
|
48
|
-
|
|
256
|
+
if (chunks.length === 0)
|
|
49
257
|
return {};
|
|
258
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
259
|
+
}
|
|
260
|
+
// WiFi OTA ack timeouts. The transport is a WebSocket over TCP, so a chunk/ack
|
|
261
|
+
// is never lost while the connection is alive — a "timeout" means the board is
|
|
262
|
+
// slow (a classic ESP32 like TTGO/TC001 can stall past several seconds on a
|
|
263
|
+
// flash-sector erase or a brief WiFi-stack starvation) or the WiFi link briefly
|
|
264
|
+
// blipped and TCP is retransmitting. Both recover if we wait, so the timeouts
|
|
265
|
+
// are generous: a healthy board still acks in milliseconds, and these only cap
|
|
266
|
+
// how long we ride out a stall before declaring the transfer dead. Resending on
|
|
267
|
+
// the SAME live socket would be wrong — it desyncs the firmware's strict
|
|
268
|
+
// seq/offset cursor (handleOtaChunk → "unexpected_offset"); a timeout followed
|
|
269
|
+
// by a board RECONNECT is the one case where resend is correct (see
|
|
270
|
+
// performWifiEsp32Ota — the board never processed the lost chunk, and its otaRx
|
|
271
|
+
// cursor persisted across the reconnect). `let` (not const) so tests can shrink
|
|
272
|
+
// them via __setOtaTimeoutsForTest; production never mutates them.
|
|
273
|
+
let OTA_BEGIN_ACK_TIMEOUT_MS = 15_000; // erasing/preparing the target OTA slot
|
|
274
|
+
let OTA_CHUNK_ACK_TIMEOUT_MS = 30_000; // slow classic-ESP32 flash write / WiFi stall
|
|
275
|
+
let OTA_END_ACK_TIMEOUT_MS = 30_000; // whole-image MD5 verify + set-boot-partition
|
|
276
|
+
// How long to wait for a board to re-register a live WS after it drops its
|
|
277
|
+
// socket mid-OTA, and the cap on such reconnect-driven resends per transfer.
|
|
278
|
+
// Serial-backtrace diagnosis of the TTGO 2.5MB WiFi OTA showed the board does
|
|
279
|
+
// NOT reset/brownout/watchdog mid-transfer — it survives, prints "[WS]
|
|
280
|
+
// Disconnected", and re-registers under the SAME board:ip key with a NEW socket
|
|
281
|
+
// ~3-4s later (classic single-core ESP32 WiFi-flash coexistence briefly starves
|
|
282
|
+
// the TCP task → the connection drops, not the board). The old code captured the
|
|
283
|
+
// socket once and kept sending into the dead one, so the transfer stalled at the
|
|
284
|
+
// disconnect and failed 30s later on the chunk-ack timeout. Following the board
|
|
285
|
+
// to its reconnected socket lets the transfer resume where it stalled, because
|
|
286
|
+
// acks route by otaId (socket-independent) and the firmware keeps its otaRx
|
|
287
|
+
// cursor across the reconnect.
|
|
288
|
+
let OTA_RECONNECT_WAIT_MS = 20_000;
|
|
289
|
+
const OTA_MAX_RECONNECT_RESENDS = 12;
|
|
290
|
+
// ── Test-only seams for the WiFi-OTA reconnect-follow path ───────────────────
|
|
291
|
+
// The reconnect-follow bug is a socket-lifecycle race that hardware reproduces
|
|
292
|
+
// only probabilistically (a WiFi-flash coexistence drop). These exports let a
|
|
293
|
+
// deterministic unit test register/replace a board's socket mid-transfer and
|
|
294
|
+
// drive acks by otaId, with the ack timeouts shrunk so the drop→timeout→resend
|
|
295
|
+
// cycle runs in milliseconds. Not used by production code.
|
|
296
|
+
/** @internal */
|
|
297
|
+
export function __setOtaTimeoutsForTest(t) {
|
|
298
|
+
if (t.begin != null)
|
|
299
|
+
OTA_BEGIN_ACK_TIMEOUT_MS = t.begin;
|
|
300
|
+
if (t.chunk != null)
|
|
301
|
+
OTA_CHUNK_ACK_TIMEOUT_MS = t.chunk;
|
|
302
|
+
if (t.end != null)
|
|
303
|
+
OTA_END_ACK_TIMEOUT_MS = t.end;
|
|
304
|
+
if (t.reconnectWait != null)
|
|
305
|
+
OTA_RECONNECT_WAIT_MS = t.reconnectWait;
|
|
306
|
+
}
|
|
307
|
+
/** @internal */
|
|
308
|
+
export function __resetWifiEsp32OtaState() {
|
|
309
|
+
for (const [, w] of otaWaiters)
|
|
310
|
+
clearTimeout(w.timer);
|
|
311
|
+
otaWaiters.clear();
|
|
312
|
+
wifiEsp32Devices.clear();
|
|
313
|
+
wifiEsp32Sockets.clear();
|
|
314
|
+
OTA_BEGIN_ACK_TIMEOUT_MS = 15_000;
|
|
315
|
+
OTA_CHUNK_ACK_TIMEOUT_MS = 30_000;
|
|
316
|
+
OTA_END_ACK_TIMEOUT_MS = 30_000;
|
|
317
|
+
OTA_RECONNECT_WAIT_MS = 20_000;
|
|
318
|
+
}
|
|
319
|
+
/** @internal */
|
|
320
|
+
export const __wifiOtaTestApi = {
|
|
321
|
+
registerWifiEsp32,
|
|
322
|
+
unregisterWifiEsp32Socket,
|
|
323
|
+
handleEsp32OtaReply,
|
|
324
|
+
performWifiEsp32Ota,
|
|
325
|
+
};
|
|
326
|
+
async function performWifiEsp32Ota(core, target, firmwarePath) {
|
|
327
|
+
const { key, device } = findWifiOtaTarget(target);
|
|
328
|
+
if (device.otaSupported !== true) {
|
|
329
|
+
throw new Error(`Target ${key} does not report OTA support${device.otaReason ? ` (${device.otaReason})` : ''}`);
|
|
330
|
+
}
|
|
331
|
+
const firmware = readFileSync(firmwarePath);
|
|
332
|
+
if (device.otaSlotSize && firmware.length > device.otaSlotSize) {
|
|
333
|
+
throw new Error(`Firmware is ${firmware.length} bytes, OTA slot is ${device.otaSlotSize} bytes`);
|
|
334
|
+
}
|
|
335
|
+
const otaId = randomUUID();
|
|
336
|
+
const md5 = createHash('md5').update(firmware).digest('hex');
|
|
337
|
+
// Always resolve the board's CURRENTLY-registered socket, never a captured
|
|
338
|
+
// reference — the board re-registers a fresh socket on a mid-OTA reconnect.
|
|
339
|
+
const liveSocket = () => {
|
|
340
|
+
const s = wifiEsp32Sockets.get(key);
|
|
341
|
+
return s && s.readyState === WebSocket.OPEN ? s : undefined;
|
|
342
|
+
};
|
|
343
|
+
// Wait up to ms for a live socket (rides out the ~3-4s reconnect gap).
|
|
344
|
+
const awaitLiveSocket = async (ms) => {
|
|
345
|
+
const deadline = Date.now() + ms;
|
|
346
|
+
let s = liveSocket();
|
|
347
|
+
while (!s && Date.now() < deadline) {
|
|
348
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
349
|
+
s = liveSocket();
|
|
350
|
+
}
|
|
351
|
+
return s;
|
|
352
|
+
};
|
|
353
|
+
const sendVia = (sock, evt) => core.wsServer.sendTo(sock, evt);
|
|
354
|
+
let reconnectResends = 0;
|
|
355
|
+
// Send one OTA frame and wait for its ack, following the board to a fresh
|
|
356
|
+
// socket if it reconnects mid-flight. On an ack timeout, if a NEW live socket
|
|
357
|
+
// has appeared (the board reconnected), the frame was lost to the dead socket
|
|
358
|
+
// — resend the same seq on the new socket once. The firmware's otaRx cursor
|
|
359
|
+
// persists across the reconnect, so a resend of the not-yet-processed seq is
|
|
360
|
+
// accepted; a genuinely-gone board still fails after OTA_RECONNECT_WAIT_MS.
|
|
361
|
+
const sendAndAck = async (evt, stage, seq, timeoutMs) => {
|
|
362
|
+
let sock = await awaitLiveSocket(OTA_RECONNECT_WAIT_MS);
|
|
363
|
+
if (!sock)
|
|
364
|
+
throw new Error(`OTA ${stage}${seq != null ? ` #${seq}` : ''}: board ${key} offline (no live WS)`);
|
|
365
|
+
sendVia(sock, evt);
|
|
366
|
+
try {
|
|
367
|
+
await waitForOtaAck(otaId, stage, seq, timeoutMs);
|
|
368
|
+
}
|
|
369
|
+
catch (ackErr) {
|
|
370
|
+
const fresh = await awaitLiveSocket(OTA_RECONNECT_WAIT_MS);
|
|
371
|
+
if (fresh && fresh !== sock && reconnectResends < OTA_MAX_RECONNECT_RESENDS) {
|
|
372
|
+
reconnectResends++;
|
|
373
|
+
debug('daemon', `OTA ${key}: ${stage}${seq != null ? ` #${seq}` : ''} ack lost on dropped socket — resending on reconnected WS (resend ${reconnectResends})`);
|
|
374
|
+
sendVia(fresh, evt);
|
|
375
|
+
await waitForOtaAck(otaId, stage, seq, timeoutMs);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
throw ackErr;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
await sendAndAck({ type: 'esp32_ota_begin', otaId, size: firmware.length, md5 }, 'begin', undefined, OTA_BEGIN_ACK_TIMEOUT_MS);
|
|
383
|
+
const chunkSize = 1024;
|
|
384
|
+
let offset = 0;
|
|
385
|
+
let seq = 0;
|
|
386
|
+
while (offset < firmware.length) {
|
|
387
|
+
const chunk = firmware.subarray(offset, Math.min(offset + chunkSize, firmware.length));
|
|
388
|
+
await sendAndAck({
|
|
389
|
+
type: 'esp32_ota_chunk',
|
|
390
|
+
otaId,
|
|
391
|
+
seq,
|
|
392
|
+
offset,
|
|
393
|
+
data: chunk.toString('base64'),
|
|
394
|
+
}, 'chunk', seq, OTA_CHUNK_ACK_TIMEOUT_MS);
|
|
395
|
+
offset += chunk.length;
|
|
396
|
+
seq++;
|
|
397
|
+
}
|
|
398
|
+
await sendAndAck({ type: 'esp32_ota_end', otaId }, 'end', undefined, OTA_END_ACK_TIMEOUT_MS);
|
|
399
|
+
wifiEsp32Sockets.delete(key);
|
|
400
|
+
wifiEsp32Devices.delete(key);
|
|
401
|
+
return {
|
|
402
|
+
ok: true,
|
|
403
|
+
target: key,
|
|
404
|
+
board: device.board,
|
|
405
|
+
bytes: firmware.length,
|
|
406
|
+
chunks: seq,
|
|
407
|
+
reconnectResends,
|
|
408
|
+
md5,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
const sock = liveSocket();
|
|
413
|
+
if (sock) {
|
|
414
|
+
try {
|
|
415
|
+
sendVia(sock, { type: 'esp32_ota_abort', otaId });
|
|
416
|
+
}
|
|
417
|
+
catch { /* best effort */ }
|
|
418
|
+
}
|
|
419
|
+
throw err;
|
|
50
420
|
}
|
|
51
421
|
}
|
|
422
|
+
function loadDaemonSettings() {
|
|
423
|
+
// Newest settings.json across candidate data dirs — mirrors the
|
|
424
|
+
// daemon.json/sessions.json cross-dir discovery (and honors the
|
|
425
|
+
// AGENTDECK_DATA_DIR test override, which the old hardcoded
|
|
426
|
+
// ~/.agentdeck path ignored). The App Store sandbox container is
|
|
427
|
+
// intentionally NOT a candidate (TCC hang risk — see
|
|
428
|
+
// getCandidateDataDirs), so settings written by the sandboxed Swift
|
|
429
|
+
// app stay invisible here; that coexistence limit is documented in
|
|
430
|
+
// docs/appstore-feature-matrix.md.
|
|
431
|
+
let best = null;
|
|
432
|
+
for (const dir of getCandidateDataDirs()) {
|
|
433
|
+
try {
|
|
434
|
+
const path = join(dir, 'settings.json');
|
|
435
|
+
const mtime = statSync(path).mtimeMs;
|
|
436
|
+
if (best && best.mtime >= mtime)
|
|
437
|
+
continue;
|
|
438
|
+
best = { mtime, parsed: JSON.parse(readFileSync(path, 'utf-8')) };
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// Missing or unreadable — skip this candidate.
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return best?.parsed ?? {};
|
|
445
|
+
}
|
|
446
|
+
function latestTimelinePath() {
|
|
447
|
+
let best = null;
|
|
448
|
+
for (const dir of getCandidateDataDirs()) {
|
|
449
|
+
try {
|
|
450
|
+
const path = join(dir, 'timeline.json');
|
|
451
|
+
const mtime = statSync(path).mtimeMs;
|
|
452
|
+
if (!best || mtime > best.mtime)
|
|
453
|
+
best = { path, mtime };
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Missing or unreadable — skip this candidate.
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return best?.path ?? null;
|
|
460
|
+
}
|
|
461
|
+
// Observed-session attention history: the held PreToolUse device-approval gate
|
|
462
|
+
// was removed on 2026-06-27 and stays removed — PreToolUse fires even for tools
|
|
463
|
+
// Claude auto-approves, so gating on it produced false attention + a fabricated
|
|
464
|
+
// Allow/Deny that never matched Claude's real prompt. The *display-only*
|
|
465
|
+
// Notification overlay was restored on 2026-07-05: `notification_type:
|
|
466
|
+
// "permission_prompt"` fires only when a permission prompt is actually shown to
|
|
467
|
+
// the user, so it is a genuine awaiting signal. Observed sessions surface
|
|
468
|
+
// awaiting + question with NO requestId (respond-in-terminal UX); steering with
|
|
469
|
+
// real options still exists only on PTY-managed sessions (`agentdeck claude`).
|
|
52
470
|
function log(msg) {
|
|
53
|
-
|
|
471
|
+
// Timestamped like logger.ts — daemon stderr lands in a long-lived log file,
|
|
472
|
+
// and un-datable restart/incident lines repeatedly blocked root-cause work.
|
|
473
|
+
process.stderr.write(`${new Date().toISOString()} ${msg}\n`);
|
|
54
474
|
}
|
|
55
475
|
async function fetchUsageViaHttp(siblings) {
|
|
56
476
|
for (const sibling of siblings) {
|
|
@@ -126,6 +546,60 @@ async function fetchUsageRelayed(selfPort) {
|
|
|
126
546
|
debug('daemon', 'No siblings, using direct API');
|
|
127
547
|
return fetchUsageFromApi();
|
|
128
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* Stamp OpenClaw origin onto a timeline entry emitted by the Gateway adapter.
|
|
551
|
+
*
|
|
552
|
+
* The adapter emits bare entries (no agentType/projectName); without this the
|
|
553
|
+
* BridgeCore attributor falls back to the daemon's hardcoded projectName
|
|
554
|
+
* ('AgentDeck') and leaves agentType null, so OpenClaw cron activity gets
|
|
555
|
+
* mis-grouped under AgentDeck and never renders as OpenClaw. `?? ` fallbacks
|
|
556
|
+
* preserve any value the adapter did set, and the downstream attributor's own
|
|
557
|
+
* `?? ` keeps these in turn.
|
|
558
|
+
*/
|
|
559
|
+
export function enrichGatewayTimelineEntry(entry) {
|
|
560
|
+
return {
|
|
561
|
+
...entry,
|
|
562
|
+
agentType: entry.agentType ?? 'openclaw',
|
|
563
|
+
projectName: entry.projectName ?? 'OpenClaw',
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Classify a `/hooks/<eventName>` POST for the observed-session pipeline.
|
|
568
|
+
*
|
|
569
|
+
* Claude hooks arrive as PascalCase names (already snake_cased by the
|
|
570
|
+
* caller's eventMap → `mapped`); Codex lifecycle hooks (installed into
|
|
571
|
+
* `~/.codex/config.toml`) arrive as `codex_*`; the AgentDeck OpenCode
|
|
572
|
+
* observer plugin posts `opencode_*`. All three share one pipeline —
|
|
573
|
+
* APME run/turn management, the chat_start row, and the stop-completion
|
|
574
|
+
* row — keyed off the returned agent-neutral `boundary`, so a direct
|
|
575
|
+
* `codex` or standalone `opencode` run gets the same prompt → response
|
|
576
|
+
* turn shape on the timeline as a direct `claude` run. (The Swift daemon
|
|
577
|
+
* has carried Codex parity since its observation pass —
|
|
578
|
+
* DaemonServer.swift appendCodexChatStart/End; this closes the same gap
|
|
579
|
+
* on the Node daemon.)
|
|
580
|
+
*
|
|
581
|
+
* Codex notify's `turn_complete` maps to `stop`: it closes a turn the
|
|
582
|
+
* same way, and the caller's open-turn guard collapses a stop +
|
|
583
|
+
* turn_complete pair into a single completion row.
|
|
584
|
+
*
|
|
585
|
+
* `antigravity_*` is accepted for forward-compatibility: Antigravity has
|
|
586
|
+
* no hook installer yet (the IDE exposes no lifecycle hook config; its
|
|
587
|
+
* sessions surface via PassiveSessionObserver transcript polling), but
|
|
588
|
+
* anything that starts POSTing these names gets full turn parity with no
|
|
589
|
+
* daemon change.
|
|
590
|
+
*/
|
|
591
|
+
export function classifyObservedHookEvent(eventName, mapped) {
|
|
592
|
+
const prefixed = /^(codex|opencode|antigravity)_(session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
|
|
593
|
+
.exec(eventName);
|
|
594
|
+
if (!prefixed)
|
|
595
|
+
return { boundary: mapped, agentType: 'claude-code' };
|
|
596
|
+
return {
|
|
597
|
+
boundary: prefixed[2] === 'turn_complete' ? 'stop' : prefixed[2],
|
|
598
|
+
agentType: prefixed[1] === 'codex' ? 'codex-cli'
|
|
599
|
+
: prefixed[1] === 'opencode' ? 'opencode'
|
|
600
|
+
: 'antigravity',
|
|
601
|
+
};
|
|
602
|
+
}
|
|
129
603
|
function buildNodeModuleHealth(startedModules) {
|
|
130
604
|
const started = new Set(startedModules.map((m) => m.name));
|
|
131
605
|
const modules = {};
|
|
@@ -140,10 +614,6 @@ function buildNodeModuleHealth(startedModules) {
|
|
|
140
614
|
lastError: adbAvailable ? null : 'adb not found',
|
|
141
615
|
};
|
|
142
616
|
}
|
|
143
|
-
const d200h = startedModules.find((m) => m.name === 'd200h');
|
|
144
|
-
if (d200h?.statusSnapshot) {
|
|
145
|
-
modules.d200h = d200h.statusSnapshot();
|
|
146
|
-
}
|
|
147
617
|
if (started.has('pixoo') || pixooDeviceCount() > 0) {
|
|
148
618
|
const details = getPixooDeviceDetails();
|
|
149
619
|
modules.pixoo = {
|
|
@@ -160,21 +630,72 @@ function buildNodeModuleHealth(startedModules) {
|
|
|
160
630
|
})),
|
|
161
631
|
};
|
|
162
632
|
}
|
|
633
|
+
const timebox = startedModules.find((m) => m.name === 'timebox');
|
|
634
|
+
const configuredTimebox = loadTimeboxDevices();
|
|
635
|
+
if (timebox?.statusSnapshot) {
|
|
636
|
+
modules.timebox = timebox.statusSnapshot();
|
|
637
|
+
}
|
|
638
|
+
else if (configuredTimebox.length > 0) {
|
|
639
|
+
modules.timebox = {
|
|
640
|
+
configuredDeviceCount: configuredTimebox.length,
|
|
641
|
+
devices: configuredTimebox.map((d) => ({
|
|
642
|
+
address: d.address,
|
|
643
|
+
name: d.name ?? 'Timebox Mini',
|
|
644
|
+
brightness: d.brightness ?? 100,
|
|
645
|
+
})),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const idotmatrix = startedModules.find((m) => m.name === 'idotmatrix');
|
|
649
|
+
const configuredIDotMatrix = loadIDotMatrixDevices();
|
|
650
|
+
if (idotmatrix?.statusSnapshot) {
|
|
651
|
+
modules.idotmatrix = idotmatrix.statusSnapshot();
|
|
652
|
+
}
|
|
653
|
+
else if (configuredIDotMatrix.length > 0) {
|
|
654
|
+
modules.idotmatrix = {
|
|
655
|
+
configuredDeviceCount: configuredIDotMatrix.length,
|
|
656
|
+
devices: configuredIDotMatrix.map((d) => ({
|
|
657
|
+
address: d.address,
|
|
658
|
+
name: d.name ?? 'iDotMatrix',
|
|
659
|
+
brightness: d.brightness ?? 100,
|
|
660
|
+
})),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
163
663
|
if (started.has('serial')) {
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
664
|
+
const connectionStatus = getSerialConnectionStatus();
|
|
665
|
+
const connections = connectionStatus.map((status) => ({
|
|
666
|
+
port: status.port,
|
|
667
|
+
connected: status.connected,
|
|
668
|
+
transportOpen: status.transportOpen,
|
|
669
|
+
deviceInfo: status.board ? {
|
|
670
|
+
board: status.board,
|
|
671
|
+
version: status.version,
|
|
672
|
+
buildHash: status.buildHash,
|
|
673
|
+
buildEpoch: status.buildEpoch,
|
|
674
|
+
wifiConfigured: status.wifiConfigured,
|
|
675
|
+
wifiConnected: status.wifiConnected,
|
|
676
|
+
wifiRadioParked: status.wifiRadioParked,
|
|
677
|
+
uptimeSec: status.uptimeSec,
|
|
678
|
+
otaSupported: status.otaSupported,
|
|
679
|
+
otaSlotCount: status.otaSlotCount,
|
|
680
|
+
otaSlotSize: status.otaSlotSize,
|
|
681
|
+
otaFreeSketchSpace: status.otaFreeSketchSpace,
|
|
682
|
+
otaReason: status.otaReason,
|
|
683
|
+
timelineCount: status.timelineCount,
|
|
684
|
+
sessionCount: status.sessionCount,
|
|
685
|
+
usageFiveH: status.usageFiveH,
|
|
686
|
+
processingCount: status.processingCount,
|
|
687
|
+
} : null,
|
|
688
|
+
lastReadAt: status.lastReadAt,
|
|
689
|
+
lastWriteAt: status.lastWriteAt,
|
|
690
|
+
lastReadSecondsAgo: status.lastReadSecondsAgo,
|
|
691
|
+
lastWriteSecondsAgo: status.lastWriteSecondsAgo,
|
|
692
|
+
stale: status.stale,
|
|
173
693
|
}));
|
|
174
694
|
modules.serial = {
|
|
175
|
-
connectedPorts: connections.map((c) => c.port),
|
|
695
|
+
connectedPorts: connections.filter((c) => c.connected).map((c) => c.port),
|
|
176
696
|
connections,
|
|
177
|
-
lastError:
|
|
697
|
+
lastError: getSerialLastError(),
|
|
698
|
+
connectionCount: connections.filter((c) => c.connected).length,
|
|
178
699
|
};
|
|
179
700
|
}
|
|
180
701
|
return modules;
|
|
@@ -182,7 +703,7 @@ function buildNodeModuleHealth(startedModules) {
|
|
|
182
703
|
// ===== startDaemon =====
|
|
183
704
|
export async function startDaemon(opts) {
|
|
184
705
|
if (opts.debug) {
|
|
185
|
-
enableDebugLog(
|
|
706
|
+
enableDebugLog();
|
|
186
707
|
log('[agentdeck] Debug logging enabled');
|
|
187
708
|
}
|
|
188
709
|
// CLI --wake-word flag OR settings.json wakeWord: true
|
|
@@ -192,13 +713,44 @@ export async function startDaemon(opts) {
|
|
|
192
713
|
// 1. Check daemon.json and sessions.json for existing daemon
|
|
193
714
|
const existingInfo = readDaemonInfo();
|
|
194
715
|
if (existingInfo) {
|
|
195
|
-
|
|
196
|
-
|
|
716
|
+
const probePort = existingInfo.httpPort ?? existingInfo.port;
|
|
717
|
+
const health = await probeDaemonHealth(probePort);
|
|
718
|
+
if (health?.mode === 'daemon') {
|
|
719
|
+
if (health.isSwift) {
|
|
720
|
+
log(`[agentdeck] Swift daemon detected on port ${probePort}. Requesting shutdown to take over...`);
|
|
721
|
+
await requestDaemonShutdown(probePort);
|
|
722
|
+
await waitForDaemonExit(probePort);
|
|
723
|
+
removeDaemonInfo();
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
log(`[agentdeck] Daemon already running on port ${existingInfo.port} (PID ${existingInfo.pid}).`);
|
|
727
|
+
process.exit(0);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
log(`[agentdeck] Ignoring stale daemon entry on port ${existingInfo.port} (PID ${existingInfo.pid}; /health did not respond).`);
|
|
732
|
+
removeDaemonInfo();
|
|
733
|
+
}
|
|
197
734
|
}
|
|
198
735
|
const existingSession = findExistingDaemon();
|
|
199
736
|
if (existingSession) {
|
|
200
|
-
|
|
201
|
-
|
|
737
|
+
const health = await probeDaemonHealth(existingSession.port);
|
|
738
|
+
if (health?.mode === 'daemon') {
|
|
739
|
+
if (health.isSwift) {
|
|
740
|
+
log(`[agentdeck] Swift daemon detected on port ${existingSession.port}. Requesting shutdown to take over...`);
|
|
741
|
+
await requestDaemonShutdown(existingSession.port);
|
|
742
|
+
await waitForDaemonExit(existingSession.port);
|
|
743
|
+
removeDaemonSession(existingSession);
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
log(`[agentdeck] Daemon already running on port ${existingSession.port} (PID ${existingSession.pid}).`);
|
|
747
|
+
process.exit(0);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
log(`[agentdeck] Ignoring stale daemon session on port ${existingSession.port} (PID ${existingSession.pid}; /health did not respond).`);
|
|
752
|
+
removeDaemonSession(existingSession);
|
|
753
|
+
}
|
|
202
754
|
}
|
|
203
755
|
// 2. Determine port — try default first, fallback if occupied by non-daemon
|
|
204
756
|
const requestedPort = opts.port ?? DAEMON_DEFAULT_PORT;
|
|
@@ -208,24 +760,114 @@ export async function startDaemon(opts) {
|
|
|
208
760
|
const health = await probeDaemonHealth(requestedPort);
|
|
209
761
|
if (health) {
|
|
210
762
|
if (health.mode === 'daemon') {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
763
|
+
if (health.isSwift) {
|
|
764
|
+
log(`[agentdeck] Swift daemon detected on port ${requestedPort} via /health. Requesting shutdown to take over...`);
|
|
765
|
+
await requestDaemonShutdown(requestedPort);
|
|
766
|
+
await waitForDaemonExit(requestedPort);
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
// Daemon alive but not in our registry — race condition or stale state
|
|
770
|
+
log(`[agentdeck] Daemon already running on port ${requestedPort} (detected via /health).`);
|
|
771
|
+
process.exit(0);
|
|
772
|
+
}
|
|
214
773
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
774
|
+
else {
|
|
775
|
+
// Port occupied by non-daemon (e.g. session bridge) — find alternative
|
|
776
|
+
log(`[agentdeck] Port ${requestedPort} occupied (${health.mode ?? 'unknown'}), finding alternative...`);
|
|
777
|
+
port = await findAvailablePort();
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// 3. Cross-implementation sweep of the daemon port window. File discovery
|
|
782
|
+
// has two blind spots that the checks above never cover: the App Store
|
|
783
|
+
// Swift daemon writes daemon.json into its private sandbox container (this
|
|
784
|
+
// process cannot read it), and transient 9120 contention can leave a daemon
|
|
785
|
+
// sitting on a fallback port (9121+). Missing it here means two live
|
|
786
|
+
// daemons: double mDNS advertising, duplicate Gateway/timeline relay, and
|
|
787
|
+
// adb-reverse flapping. Swift occupants are evicted (same policy as above);
|
|
788
|
+
// a live Node daemon wins and we exit.
|
|
789
|
+
const strayDaemons = await scanDaemonPortWindow(new Set([requestedPort]));
|
|
790
|
+
for (const stray of strayDaemons) {
|
|
791
|
+
if (stray.health.isSwift) {
|
|
792
|
+
log(`[agentdeck] Swift daemon detected on fallback port ${stray.port}. Requesting shutdown to take over...`);
|
|
793
|
+
await requestDaemonShutdown(stray.port);
|
|
794
|
+
await waitForDaemonExit(stray.port);
|
|
795
|
+
}
|
|
796
|
+
else if (shouldConcedePortToOccupant(stray.health, process.pid)) {
|
|
797
|
+
log(`[agentdeck] Daemon already running on port ${stray.port} (detected via port scan).`);
|
|
798
|
+
process.exit(0);
|
|
218
799
|
}
|
|
219
800
|
}
|
|
220
801
|
log(`[agentdeck] Starting daemon on port ${port}...`);
|
|
221
802
|
// ===== APME (lazy — may be null if better-sqlite3 isn't installed) =====
|
|
222
803
|
let apme = null;
|
|
804
|
+
// Fallback task-row emitter, created only when initApme() returns null —
|
|
805
|
+
// keeps task_start/task_end rows on the timeline without the collector.
|
|
806
|
+
let fallbackTasks = null;
|
|
807
|
+
// Sessions that posted any hook since this daemon started — the delayed
|
|
808
|
+
// orphan-chat reaper must never force-close a turn belonging to a session
|
|
809
|
+
// that is provably alive (agentic turns regularly outlive any fixed age
|
|
810
|
+
// threshold). Declared before the HTTP handlers that populate it.
|
|
811
|
+
const hookSessionsSeen = new Set();
|
|
223
812
|
// Declare early — HTTP /health handler references this in its closure.
|
|
224
813
|
// Must be declared before the HTTP server so it's initialized (not in TDZ)
|
|
225
814
|
// when the first /health request arrives.
|
|
226
815
|
let gatewayAdapter = null;
|
|
227
816
|
let gatewayConnecting = false;
|
|
228
817
|
let moduleHealthProvider = () => ({});
|
|
818
|
+
// Wi-Fi WebSocket e-ink panels (XTeink X3/X4 CrossPoint fork) self-register
|
|
819
|
+
// their device roster via `client_register{clientType:"eink-device"}` — the
|
|
820
|
+
// same volunteer model as the Stream Deck plugin. This handler lived ONLY in
|
|
821
|
+
// the Swift daemon, so when the dashboard talked to the Node daemon (the
|
|
822
|
+
// common case) these devices never surfaced on the Downstream E-ink rail.
|
|
823
|
+
// Keyed by socket; a registration drops the moment its socket closes
|
|
824
|
+
// (filtered lazily in `collectEinkDevices` on readyState), so no separate
|
|
825
|
+
// TTL sweep is needed.
|
|
826
|
+
const einkRegistrations = new Map();
|
|
827
|
+
const collectEinkDevices = () => {
|
|
828
|
+
const out = [];
|
|
829
|
+
for (const [ws, reg] of einkRegistrations) {
|
|
830
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
831
|
+
einkRegistrations.delete(ws);
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
for (const d of reg.devices)
|
|
835
|
+
out.push(d);
|
|
836
|
+
}
|
|
837
|
+
return out;
|
|
838
|
+
};
|
|
839
|
+
// Android dashboard apps (tablet / e-ink launcher) over Wi-Fi WS —
|
|
840
|
+
// `client_register{clientType:"android-dashboard"}`, same volunteer model.
|
|
841
|
+
const androidDashboardRegistrations = new Map();
|
|
842
|
+
const collectAndroidDashboards = () => {
|
|
843
|
+
const out = [];
|
|
844
|
+
for (const [ws, reg] of androidDashboardRegistrations) {
|
|
845
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
846
|
+
androidDashboardRegistrations.delete(ws);
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
for (const d of reg.devices)
|
|
850
|
+
out.push(d);
|
|
851
|
+
}
|
|
852
|
+
return out;
|
|
853
|
+
};
|
|
854
|
+
// Elgato Stream Deck plugin — `client_register{clientType:"streamdeck-plugin"}`
|
|
855
|
+
// with the physical device roster. The Swift daemon has handled this since the
|
|
856
|
+
// Stream Deck topology row shipped; the Node daemon silently dropped it, so on
|
|
857
|
+
// the external-CLI tier the dashboard never showed a Stream Deck row at all.
|
|
858
|
+
const streamDeckRegistrations = new Map();
|
|
859
|
+
const collectStreamDeckDevices = () => {
|
|
860
|
+
const out = [];
|
|
861
|
+
for (const [ws, reg] of streamDeckRegistrations) {
|
|
862
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
863
|
+
streamDeckRegistrations.delete(ws);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
for (const d of reg.devices)
|
|
867
|
+
out.push(d);
|
|
868
|
+
}
|
|
869
|
+
return out;
|
|
870
|
+
};
|
|
229
871
|
// ===== HTTP server =====
|
|
230
872
|
const httpServer = createServer((req, res) => {
|
|
231
873
|
// APME routes: auth-gated (task prompts, project paths, hook payloads are sensitive).
|
|
@@ -249,7 +891,8 @@ export async function startDaemon(opts) {
|
|
|
249
891
|
});
|
|
250
892
|
return;
|
|
251
893
|
}
|
|
252
|
-
const
|
|
894
|
+
const parsedUrl = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
895
|
+
const pathname = parsedUrl.pathname;
|
|
253
896
|
// Health check is public (no auth) — used by iOS/Android for pairing token discovery
|
|
254
897
|
if (req.method === 'GET' && pathname === '/health') {
|
|
255
898
|
const snap = core.stateMachine.getSnapshot();
|
|
@@ -257,9 +900,16 @@ export async function startDaemon(opts) {
|
|
|
257
900
|
res.end(JSON.stringify({
|
|
258
901
|
status: 'ok', mode: 'daemon', state: snap.state,
|
|
259
902
|
gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
|
|
260
|
-
uptime: process.uptime(), port,
|
|
903
|
+
uptime: process.uptime(), port, pid: process.pid,
|
|
261
904
|
pairingToken: core.authToken,
|
|
262
905
|
modules: moduleHealthProvider(),
|
|
906
|
+
apme: apme
|
|
907
|
+
? {
|
|
908
|
+
enabled: true,
|
|
909
|
+
dbPath: apme.store.dbPath,
|
|
910
|
+
judgeBackend: apme.runner.lastBackendProbe ?? { status: 'unknown', backend: loadApmeConfig().judge.backend },
|
|
911
|
+
}
|
|
912
|
+
: { enabled: false, error: apme === null ? 'see startup logs (initApme returned null)' : 'unknown' },
|
|
263
913
|
}));
|
|
264
914
|
return;
|
|
265
915
|
}
|
|
@@ -282,7 +932,76 @@ export async function startDaemon(opts) {
|
|
|
282
932
|
}
|
|
283
933
|
if (req.method === 'GET' && pathname === '/devices') {
|
|
284
934
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
285
|
-
|
|
935
|
+
// Direct-HID D200H control was retired; the D200H is driven exclusively by
|
|
936
|
+
// the Ulanzi Studio plugin over WebSocket. Report it connected iff that
|
|
937
|
+
// plugin is attached.
|
|
938
|
+
const ulanziPluginConnected = core.wsServer.getUlanziClientCount() > 0;
|
|
939
|
+
res.end(JSON.stringify({
|
|
940
|
+
devices: [
|
|
941
|
+
{ type: 'websocket', count: core.wsServer.getClientCount() },
|
|
942
|
+
{ type: 'tui', devices: core.wsServer.getTuiClients() },
|
|
943
|
+
{ type: 'esp32', count: esp32ConnectionCount(), ports: getESP32Ports(), devices: getESP32DeviceInfo() },
|
|
944
|
+
{ type: 'esp32-wifi', devices: listWifiEsp32Devices() },
|
|
945
|
+
{ type: 'pixoo', details: getPixooDeviceDetails() },
|
|
946
|
+
{ type: 'timebox', devices: loadTimeboxDevices() },
|
|
947
|
+
{ type: 'idotmatrix', devices: loadIDotMatrixDevices() },
|
|
948
|
+
{ type: 'adb', count: getAdbDeviceCount() },
|
|
949
|
+
{
|
|
950
|
+
type: 'd200h',
|
|
951
|
+
connected: ulanziPluginConnected,
|
|
952
|
+
ulanziPluginConnected,
|
|
953
|
+
},
|
|
954
|
+
],
|
|
955
|
+
modules: moduleHealthProvider(),
|
|
956
|
+
}));
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (req.method === 'POST' && pathname === '/esp32/ota') {
|
|
960
|
+
(async () => {
|
|
961
|
+
const body = await readJsonBody(req);
|
|
962
|
+
const target = typeof body.target === 'string' ? body.target : '';
|
|
963
|
+
const firmwarePath = typeof body.firmwarePath === 'string' ? body.firmwarePath : '';
|
|
964
|
+
if (!target || !firmwarePath) {
|
|
965
|
+
throw new Error('target and firmwarePath are required');
|
|
966
|
+
}
|
|
967
|
+
return performWifiEsp32Ota(core, target, firmwarePath);
|
|
968
|
+
})().then((result) => {
|
|
969
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
970
|
+
res.end(JSON.stringify(result));
|
|
971
|
+
}).catch((err) => {
|
|
972
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
973
|
+
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }));
|
|
974
|
+
});
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
if (req.method === 'GET' && pathname === '/display-state') {
|
|
978
|
+
res.writeHead(200, {
|
|
979
|
+
'Content-Type': 'application/json',
|
|
980
|
+
'Cache-Control': 'no-store',
|
|
981
|
+
'Access-Control-Allow-Origin': '*',
|
|
982
|
+
});
|
|
983
|
+
res.end(JSON.stringify(buildDisplayStateEvent(core.displayMonitor.isDisplayOn())));
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (req.method === 'GET' && pathname === '/diag') {
|
|
987
|
+
const snap = core.stateMachine.getSnapshot();
|
|
988
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
989
|
+
res.end(JSON.stringify({
|
|
990
|
+
timestamp: new Date().toISOString(),
|
|
991
|
+
sessionInfo: {
|
|
992
|
+
state: snap.state,
|
|
993
|
+
permissionMode: snap.permissionMode,
|
|
994
|
+
suggestedPrompt: snap.suggestedPrompt,
|
|
995
|
+
lastValidSuggestedPrompt: core.stateMachine.getLastValidSuggestedPrompt(),
|
|
996
|
+
projectName: snap.projectName,
|
|
997
|
+
modelName: snap.modelName,
|
|
998
|
+
billingType: snap.billingType,
|
|
999
|
+
},
|
|
1000
|
+
wsClients: core.wsServer.getClientCount(),
|
|
1001
|
+
recentJournal: [],
|
|
1002
|
+
ptyTail: '',
|
|
1003
|
+
journalDir: join(homedir(), '.agentdeck'),
|
|
1004
|
+
}));
|
|
286
1005
|
return;
|
|
287
1006
|
}
|
|
288
1007
|
if (req.method === 'GET' && pathname === '/pixoo/stream') {
|
|
@@ -319,8 +1038,14 @@ export async function startDaemon(opts) {
|
|
|
319
1038
|
return;
|
|
320
1039
|
}
|
|
321
1040
|
if (req.method === 'GET' && pathname === '/pixoo/frame') {
|
|
322
|
-
const
|
|
323
|
-
const
|
|
1041
|
+
const sizeParam = parsedUrl.searchParams.get('size');
|
|
1042
|
+
const size = sizeParam === '11' ? 11 : sizeParam === '32' ? 32 : 64;
|
|
1043
|
+
const layout = parsedUrl.searchParams.get('layout') === 'micro' ? 'micro' : 'standard';
|
|
1044
|
+
// The frame cache holds the standard terrarium, so render micro fresh.
|
|
1045
|
+
const rgb = layout === 'micro'
|
|
1046
|
+
? renderPreviewFrame(size, 'micro')
|
|
1047
|
+
: (getLastFrame(size) ?? renderPreviewFrame(size));
|
|
1048
|
+
const bmp = rgbToBmp(rgb, size, size);
|
|
324
1049
|
res.writeHead(200, {
|
|
325
1050
|
'Content-Type': 'image/bmp',
|
|
326
1051
|
'Cache-Control': 'no-store',
|
|
@@ -362,6 +1087,13 @@ export async function startDaemon(opts) {
|
|
|
362
1087
|
Notification: 'notification',
|
|
363
1088
|
};
|
|
364
1089
|
const mapped = eventMap[eventName] ?? eventName;
|
|
1090
|
+
// Agent-prefixed lifecycle hooks (codex_* / opencode_*) reuse the
|
|
1091
|
+
// same observed-session pipeline as Claude's PascalCase hooks —
|
|
1092
|
+
// `boundary` is the agent-neutral semantic every block below keys
|
|
1093
|
+
// off. State-machine calls stay Claude-only (`mapped`): observed
|
|
1094
|
+
// codex/opencode *state* is owned by the passive observer's turn
|
|
1095
|
+
// semantics, not these hooks.
|
|
1096
|
+
const { boundary, agentType: hookAgentType } = classifyObservedHookEvent(eventName, mapped);
|
|
365
1097
|
// State machine
|
|
366
1098
|
if (mapped === 'session_start')
|
|
367
1099
|
core.stateMachine.handleHookEvent('SessionStart', json);
|
|
@@ -377,36 +1109,534 @@ export async function startDaemon(opts) {
|
|
|
377
1109
|
else if (mapped === 'tool_end') {
|
|
378
1110
|
core.stateMachine.handleHookEvent('PostToolUse', json);
|
|
379
1111
|
}
|
|
380
|
-
//
|
|
1112
|
+
// Per-session awaiting overlay (observed/direct-`claude` sessions),
|
|
1113
|
+
// display-only. Claude emits Notification with `notification_type:
|
|
1114
|
+
// "permission_prompt"` only when a permission prompt is actually shown
|
|
1115
|
+
// to the user (auto-approved tools fire PreToolUse but never this), so
|
|
1116
|
+
// it is a genuine "waiting for explicit response" signal — unlike the
|
|
1117
|
+
// removed PreToolUse gate. No requestId is ever set: surfaces render
|
|
1118
|
+
// awaiting + question with the respond-in-terminal fallback, never a
|
|
1119
|
+
// fabricated Allow/Deny. Keyed by Claude's own session_id and merged
|
|
1120
|
+
// into the observed-session list at enrich time (awaiting-overlay.ts).
|
|
1121
|
+
const claudeSid = typeof json.session_id === 'string' ? json.session_id : undefined;
|
|
1122
|
+
if (claudeSid) {
|
|
1123
|
+
if (mapped === 'notification') {
|
|
1124
|
+
const message = typeof json.message === 'string' ? json.message : '';
|
|
1125
|
+
const notificationType = typeof json.notification_type === 'string' ? json.notification_type : undefined;
|
|
1126
|
+
if (isPermissionNotification(notificationType, message)) {
|
|
1127
|
+
// A genuine terminal prompt appeared — recent undecided gate
|
|
1128
|
+
// releases were real prompts, so nothing gets learned as
|
|
1129
|
+
// auto-approved (observed-steering learner).
|
|
1130
|
+
notePermissionPromptShown(claudeSid);
|
|
1131
|
+
setAwaitingOverlay(claudeSid, message);
|
|
1132
|
+
// Broadcast immediately rather than waiting for the 2s debounce
|
|
1133
|
+
// or the 5s observer tick, so the prompt surfaces within one frame.
|
|
1134
|
+
core.broadcastSessionsList().catch(() => { });
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
else if (mapped === 'tool_start' || mapped === 'tool_end' ||
|
|
1138
|
+
mapped === 'user_prompt_submit' || mapped === 'stop' ||
|
|
1139
|
+
mapped === 'session_start' || mapped === 'session_end') {
|
|
1140
|
+
// Steering lifecycle: a PostToolUse right after an undecided gate
|
|
1141
|
+
// release (no permission_prompt Notification in between) means
|
|
1142
|
+
// Claude auto-approved the call — learn the signature so it is
|
|
1143
|
+
// never held again this session. User prompt / session end clear
|
|
1144
|
+
// pending STOP + queued directives (the user took over).
|
|
1145
|
+
if (mapped === 'tool_end') {
|
|
1146
|
+
noteToolEnd(claudeSid, typeof json.tool_name === 'string' ? json.tool_name : undefined);
|
|
1147
|
+
}
|
|
1148
|
+
else if (mapped === 'user_prompt_submit') {
|
|
1149
|
+
if (clearOnUserPrompt(claudeSid))
|
|
1150
|
+
core.broadcastSessionsList().catch(() => { });
|
|
1151
|
+
}
|
|
1152
|
+
else if (mapped === 'session_end') {
|
|
1153
|
+
clearSteeringSession(claudeSid);
|
|
1154
|
+
}
|
|
1155
|
+
// Any subsequent hook means the prompt was answered — drop the
|
|
1156
|
+
// overlay. Only rebroadcast if there was actually one to clear
|
|
1157
|
+
// (direct-`claude` sessions fire tool hooks constantly).
|
|
1158
|
+
// EXCEPT while a held gate is still pending: parallel tool calls
|
|
1159
|
+
// fire their own PreToolUse/PostToolUse hooks, and clearing here
|
|
1160
|
+
// would strip the Allow/Deny UI out from under the open request
|
|
1161
|
+
// (its own onResolved clears the overlay when it settles).
|
|
1162
|
+
const heldOverlay = getAwaitingOverlay(claudeSid);
|
|
1163
|
+
const gateStillPending = Boolean(heldOverlay?.requestId && isPendingRequest(heldOverlay.requestId));
|
|
1164
|
+
if (!gateStillPending && clearAwaitingOverlay(claudeSid)) {
|
|
1165
|
+
core.broadcastSessionsList().catch(() => { });
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
// Observed-session activity log. Managed sessions relay their timeline
|
|
1170
|
+
// from the session bridge (AGENTDECK_PORT routes their hooks there),
|
|
1171
|
+
// but direct `claude`/`codex` runs only reach the daemon through these
|
|
1172
|
+
// hooks — without emitting here the daemon timeline (device ticker,
|
|
1173
|
+
// Android/Apple lists) goes silent whenever no managed session runs.
|
|
1174
|
+
//
|
|
1175
|
+
// Task GROUPING is fully hook-driven and TTY-independent: the boundary
|
|
1176
|
+
// state machine keys only off `user_prompt_submit` / `session_end` /
|
|
1177
|
+
// `/clear`, so it never depends on parsing the agent's terminal output
|
|
1178
|
+
// and stays stable across agent CLI UI changes. Per-tool rows are still
|
|
1179
|
+
// suppressed here (they drowned the glance surfaces); the `task_start` /
|
|
1180
|
+
// `task_end` headers + taskId-tagged `chat_start` rows now carry the
|
|
1181
|
+
// work-unit structure.
|
|
1182
|
+
//
|
|
1183
|
+
// Real per-session id shared by the collector, fallback, and the
|
|
1184
|
+
// chat_start row. Concurrent direct `claude`/`codex` runs must NOT
|
|
1185
|
+
// collapse onto one bucket: a shared id interleaves their turns into a
|
|
1186
|
+
// single task and lets one session's `session_end` tear down another's
|
|
1187
|
+
// run mapping. Falls back to a stable id only when a hook omits it.
|
|
1188
|
+
const hookSid = typeof json.session_id === 'string' && json.session_id
|
|
1189
|
+
? json.session_id : 'daemon-hook';
|
|
1190
|
+
// Liveness marker for the delayed orphan-chat reaper: a session that
|
|
1191
|
+
// posts ANY hook after daemon startup is alive, and its open turn
|
|
1192
|
+
// (even one persisted before the restart) must not be force-closed.
|
|
1193
|
+
hookSessionsSeen.add(hookSid);
|
|
1194
|
+
// Claude hooks carry `cwd` (the worktree dir), not project_name/path —
|
|
1195
|
+
// capture it so APME runs are attributable to a specific worktree.
|
|
1196
|
+
const hookCwd = (typeof json.cwd === 'string' ? json.cwd
|
|
1197
|
+
: (typeof json.project_path === 'string' ? json.project_path : '')) || '';
|
|
1198
|
+
const hookProject = (typeof json.project_name === 'string' && json.project_name)
|
|
1199
|
+
? json.project_name
|
|
1200
|
+
: (hookCwd ? hookCwd.split('/').filter(Boolean).pop() : undefined);
|
|
1201
|
+
const hookMessage = json.message;
|
|
1202
|
+
// Prompt shapes: Claude `{prompt}` / `{message:{content}}`; some Codex
|
|
1203
|
+
// builds send `{user_prompt}` (same fallback chain as codex-hook.ts).
|
|
1204
|
+
// Command XML envelopes (`<command-name>/merge</command-name>` …)
|
|
1205
|
+
// collapse to their "/merge args" form so a slash-command turn reads
|
|
1206
|
+
// as the command the user typed, not an XML blob.
|
|
1207
|
+
const hookPromptText = normalizeCommandPrompt(typeof json.prompt === 'string' ? json.prompt
|
|
1208
|
+
: (typeof hookMessage?.content === 'string' ? hookMessage.content
|
|
1209
|
+
: (typeof json.user_prompt === 'string' ? json.user_prompt : '')));
|
|
1210
|
+
const isClearBoundary = boundary === 'user_prompt_submit' && /^\s*\/clear\s*$/i.test(hookPromptText);
|
|
1211
|
+
// Session ended with a turn still open (the user interrupted the turn
|
|
1212
|
+
// then exited, or the process was torn down before its Stop could
|
|
1213
|
+
// fire): close the row honestly so dashboards stop the spinner instead
|
|
1214
|
+
// of showing "in progress" forever. Runs BEFORE the collector so the
|
|
1215
|
+
// interrupted chat_end sorts above the session_end task_end row.
|
|
1216
|
+
// Mirrors the Swift daemon's session_end force-close (`hasOpenTurn`
|
|
1217
|
+
// → interrupted chat_end).
|
|
1218
|
+
if (boundary === 'session_end') {
|
|
1219
|
+
const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
|
|
1220
|
+
let lastStart;
|
|
1221
|
+
let lastCompletionTs = 0;
|
|
1222
|
+
for (const row of rows) {
|
|
1223
|
+
if (row.type === 'chat_start')
|
|
1224
|
+
lastStart = row;
|
|
1225
|
+
else if (row.type === 'chat_response' || row.type === 'chat_end') {
|
|
1226
|
+
lastCompletionTs = Math.max(lastCompletionTs, row.ts);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
if (lastStart && lastStart.ts > lastCompletionTs) {
|
|
1230
|
+
const now = Date.now();
|
|
1231
|
+
const durS = Math.max(0, Math.round((now - (lastStart.startedAt ?? lastStart.ts)) / 1000));
|
|
1232
|
+
core.bridgeTimeline.addEntry({
|
|
1233
|
+
ts: now, type: 'chat_end',
|
|
1234
|
+
raw: `Interrupted · ${formatDurationSec(durS)}`,
|
|
1235
|
+
summaryKind: 'none',
|
|
1236
|
+
sessionId: hookSid,
|
|
1237
|
+
agentType: hookAgentType,
|
|
1238
|
+
startedAt: lastStart.startedAt ?? lastStart.ts,
|
|
1239
|
+
endedAt: now,
|
|
1240
|
+
...(lastStart.taskId ? { taskId: lastStart.taskId } : {}),
|
|
1241
|
+
...(hookProject ? { projectName: hookProject } : {}),
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
// ── APME collector (task/turn segmentation) ──
|
|
381
1246
|
if (apme) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
1247
|
+
if (isClearBoundary) {
|
|
1248
|
+
// `/clear` closes the active task + run and opens a fresh run, so
|
|
1249
|
+
// the next prompt starts a new task 0 — the work-unit boundary.
|
|
1250
|
+
apme.collector.splitRun(hookSid, hookCwd || undefined);
|
|
1251
|
+
}
|
|
1252
|
+
else {
|
|
1253
|
+
// Lazy openRun: open a run when a turn-STARTING hook arrives with no
|
|
1254
|
+
// active run — either a normal `session_start`, or (crucially) the
|
|
1255
|
+
// first `user_prompt_submit` when `session_start` was missed/late.
|
|
1256
|
+
// Without this, `ingestHook` no-ops for want of a run, which is
|
|
1257
|
+
// exactly why direct-`claude` timelines showed zero task headers
|
|
1258
|
+
// (every follow-up looked like a new top-level chat). Gating on
|
|
1259
|
+
// start/prompt avoids two hazards: a stray post-`session_end` tool /
|
|
1260
|
+
// stop hook won't spawn a phantom run, and a late `session_start`
|
|
1261
|
+
// arriving after a lazy open won't double-open (the run already
|
|
1262
|
+
// exists → skip).
|
|
1263
|
+
if (!apme.collector.getRunId(hookSid)
|
|
1264
|
+
&& (boundary === 'session_start' || boundary === 'user_prompt_submit')) {
|
|
1265
|
+
apme.collector.openRun({
|
|
1266
|
+
sessionId: hookSid,
|
|
1267
|
+
agentType: hookAgentType,
|
|
1268
|
+
projectName: hookProject,
|
|
1269
|
+
projectPath: hookCwd || undefined,
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
// Feed the agent-neutral boundary name: the collector's
|
|
1273
|
+
// normalizeHookEventName only understands the Claude-shaped
|
|
1274
|
+
// vocabulary (user_prompt_submit / tool_start / …), so raw
|
|
1275
|
+
// codex_* / opencode_* names would silently skip turn management.
|
|
1276
|
+
apme.collector.ingestHook(hookSid, boundary, json);
|
|
1277
|
+
}
|
|
1278
|
+
// Direct `claude` runs reach the daemon only via these hooks, which
|
|
1279
|
+
// never carry the model — so every such run persisted model_id=NULL.
|
|
1280
|
+
// Recover it from the transcript Claude writes. Must run before
|
|
1281
|
+
// closeRun tears down the session→run mapping.
|
|
1282
|
+
if (boundary === 'stop' || boundary === 'session_end') {
|
|
1283
|
+
const tp = json.transcript_path;
|
|
1284
|
+
if (typeof tp === 'string' && tp) {
|
|
1285
|
+
const model = readModelFromTranscript(tp);
|
|
1286
|
+
if (model)
|
|
1287
|
+
apme.collector.updateModel(hookSid, model);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (boundary === 'session_end') {
|
|
1291
|
+
apme.collector.closeRun(hookSid);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
else if (fallbackTasks) {
|
|
1295
|
+
// APME is down — keep the timeline's task hierarchy alive from the
|
|
1296
|
+
// same boundary hooks (open on first prompt, close on /clear +
|
|
1297
|
+
// session_end), keyed by the real session id.
|
|
1298
|
+
fallbackTasks.ingestHook(hookSid, boundary, json);
|
|
1299
|
+
}
|
|
1300
|
+
// Timeline `chat_start`, emitted AFTER the collector so the active task
|
|
1301
|
+
// id is known — tag the row with it so the prompt nests under the
|
|
1302
|
+
// (possibly deferred) task_start header instead of reading as its own
|
|
1303
|
+
// top-level task. `/clear` is a boundary command, not a turn: no row.
|
|
1304
|
+
if (boundary === 'user_prompt_submit' && !isClearBoundary
|
|
1305
|
+
&& hookPromptText.trim()
|
|
1306
|
+
// Codex's OTel turnStarted pseudo-prompt is a turn marker, not a
|
|
1307
|
+
// user prompt (same filter as the Swift daemon's chat_start path).
|
|
1308
|
+
&& hookPromptText.trim() !== 'Codex turn started') {
|
|
1309
|
+
const taskId = (apme
|
|
1310
|
+
? apme.collector.getActiveTaskId(hookSid)
|
|
1311
|
+
: fallbackTasks?.getActiveTaskId(hookSid)) ?? undefined;
|
|
1312
|
+
const promptText = stripUnsafeText(hookPromptText.trim());
|
|
1313
|
+
const now = Date.now();
|
|
1314
|
+
core.bridgeTimeline.addEntry({
|
|
1315
|
+
ts: now, type: 'chat_start',
|
|
1316
|
+
raw: promptText.slice(0, 160),
|
|
1317
|
+
// Long prompts keep their body reachable via the detail pane —
|
|
1318
|
+
// the 160-char raw alone read as clipped on the dashboards.
|
|
1319
|
+
...(promptText.length > 160 ? { detail: promptText.slice(0, 1000) } : {}),
|
|
1320
|
+
sessionId: hookSid,
|
|
1321
|
+
agentType: hookAgentType,
|
|
1322
|
+
startedAt: now,
|
|
1323
|
+
...(taskId ? { taskId } : {}),
|
|
1324
|
+
...(hookProject ? { projectName: hookProject } : {}),
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
// Timeline turn completion. Managed sessions relay chat_response from
|
|
1328
|
+
// their session bridge, but hook-observed (direct `claude` / `codex` /
|
|
1329
|
+
// standalone `opencode`) sessions only reach the daemon here — without
|
|
1330
|
+
// a completion row their chat_start rows spin as "running" forever on
|
|
1331
|
+
// every dashboard surface. Claude's Stop carries `transcript_path`
|
|
1332
|
+
// (the transcript tail's last assistant text is the turn's response —
|
|
1333
|
+
// same source the model reader uses above); Codex/OpenCode carry the
|
|
1334
|
+
// response inline (`last_assistant_message` / `response` / `output` /
|
|
1335
|
+
// `result`, the Swift daemon's field chain). Only emit while a turn is
|
|
1336
|
+
// actually open (a chat_start newer than the session's last
|
|
1337
|
+
// completion) so a duplicate/late Stop or an interrupted turn doesn't
|
|
1338
|
+
// re-emit the previous response.
|
|
1339
|
+
if (boundary === 'stop') {
|
|
1340
|
+
const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
|
|
1341
|
+
let lastStart;
|
|
1342
|
+
let lastResponse;
|
|
1343
|
+
let lastCompletionTs = 0;
|
|
1344
|
+
for (const row of rows) {
|
|
1345
|
+
if (row.type === 'chat_start')
|
|
1346
|
+
lastStart = row;
|
|
1347
|
+
else if (row.type === 'chat_response' || row.type === 'chat_end') {
|
|
1348
|
+
if (row.type === 'chat_response')
|
|
1349
|
+
lastResponse = row;
|
|
1350
|
+
lastCompletionTs = Math.max(lastCompletionTs, row.ts);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
const turnOpen = Boolean(lastStart && lastStart.ts > lastCompletionTs);
|
|
1354
|
+
const tp = typeof json.transcript_path === 'string' ? json.transcript_path : '';
|
|
1355
|
+
const inlineResponse = [json.last_assistant_message, json.response, json.output, json.result]
|
|
1356
|
+
.find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
|
|
1357
|
+
// Codex's stop payload rarely carries the text inline; its rollout
|
|
1358
|
+
// JSONL (agent_message / task_complete records) is the
|
|
1359
|
+
// authoritative source — the Codex counterpart of Claude's
|
|
1360
|
+
// transcript tail. Read it whenever a completion row might be
|
|
1361
|
+
// emitted (open turn, or a response-only close below).
|
|
1362
|
+
const rolloutResponse = !tp && !inlineResponse && hookAgentType === 'codex-cli'
|
|
1363
|
+
? lastAgentMessageFromCodexRollout(hookSid)
|
|
1364
|
+
: '';
|
|
1365
|
+
const responseText = tp
|
|
1366
|
+
? stripUnsafeText(lastAssistantTextFromTranscript(tp))
|
|
1367
|
+
: stripUnsafeText(inlineResponse || rolloutResponse);
|
|
1368
|
+
const respRaw = responseText.length > 0
|
|
1369
|
+
? cleanRawText(responseText.length > 200 ? responseText.slice(0, 197) + '...' : responseText)
|
|
1370
|
+
: '';
|
|
1371
|
+
if (turnOpen && lastStart) {
|
|
1372
|
+
const now = Date.now();
|
|
1373
|
+
const taskId = (apme
|
|
1374
|
+
? apme.collector.getActiveTaskId(hookSid)
|
|
1375
|
+
: fallbackTasks?.getActiveTaskId(hookSid)) ?? lastStart.taskId ?? undefined;
|
|
1376
|
+
const base = {
|
|
1377
|
+
sessionId: hookSid,
|
|
1378
|
+
agentType: hookAgentType,
|
|
1379
|
+
startedAt: lastStart.startedAt ?? lastStart.ts,
|
|
1380
|
+
endedAt: now,
|
|
1381
|
+
...(taskId ? { taskId } : {}),
|
|
1382
|
+
...(hookProject ? { projectName: hookProject } : {}),
|
|
1383
|
+
};
|
|
1384
|
+
if (responseText.length > 0) {
|
|
1385
|
+
core.bridgeTimeline.addEntry({
|
|
1386
|
+
ts: now, type: 'chat_response',
|
|
1387
|
+
raw: respRaw,
|
|
1388
|
+
detail: prepareMarkdownDetail(responseText.slice(0, 3000)) || undefined,
|
|
1389
|
+
...base,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
// No readable response (interrupted / tool-only turn) — still
|
|
1394
|
+
// close the row so the dashboards stop the spinner.
|
|
1395
|
+
const durS = Math.max(0, Math.round((now - (lastStart.startedAt ?? lastStart.ts)) / 1000));
|
|
1396
|
+
core.bridgeTimeline.addEntry({
|
|
1397
|
+
ts: now, type: 'chat_end',
|
|
1398
|
+
raw: `Completed · ${formatDurationSec(durS)}`,
|
|
1399
|
+
summaryKind: 'none',
|
|
1400
|
+
...base,
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
else if (respRaw && lastResponse?.raw !== respRaw) {
|
|
1405
|
+
// Response-only close (Swift daemon parity): the turn's prompt
|
|
1406
|
+
// hook was missed — notify-only Codex configs emit just
|
|
1407
|
+
// turn_complete, and an OpenCode prompt can surface empty — so
|
|
1408
|
+
// there is no open chat_start to anchor on, but the response
|
|
1409
|
+
// text is still the turn's result and worth a row. The
|
|
1410
|
+
// raw-equality guard keeps a duplicate/late stop from
|
|
1411
|
+
// re-emitting the same response.
|
|
1412
|
+
const now = Date.now();
|
|
1413
|
+
const taskId = (apme
|
|
1414
|
+
? apme.collector.getActiveTaskId(hookSid)
|
|
1415
|
+
: fallbackTasks?.getActiveTaskId(hookSid)) ?? undefined;
|
|
1416
|
+
core.bridgeTimeline.addEntry({
|
|
1417
|
+
ts: now, type: 'chat_response',
|
|
1418
|
+
raw: respRaw,
|
|
1419
|
+
detail: prepareMarkdownDetail(responseText.slice(0, 3000)) || undefined,
|
|
1420
|
+
sessionId: hookSid,
|
|
1421
|
+
agentType: hookAgentType,
|
|
1422
|
+
endedAt: now,
|
|
1423
|
+
...(taskId ? { taskId } : {}),
|
|
1424
|
+
...(hookProject ? { projectName: hookProject } : {}),
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
// ── Response ──
|
|
1429
|
+
// PreToolUse and Stop are request-response steering channels (the hook
|
|
1430
|
+
// script echoes our body to Claude's stdout); everything else acks.
|
|
1431
|
+
if (eventName === 'PreToolUse') {
|
|
1432
|
+
const toolName = typeof json.tool_name === 'string' ? json.tool_name : '';
|
|
1433
|
+
const toolInput = (json.tool_input && typeof json.tool_input === 'object')
|
|
1434
|
+
? json.tool_input : undefined;
|
|
1435
|
+
// 1. Soft STOP: the user pressed STOP on a device — deny this tool
|
|
1436
|
+
// call with a halt instruction. One-shot (flag consumed).
|
|
1437
|
+
if (claudeSid && consumeStop(claudeSid)) {
|
|
1438
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1439
|
+
res.end(JSON.stringify({
|
|
1440
|
+
hookSpecificOutput: {
|
|
1441
|
+
hookEventName: 'PreToolUse',
|
|
1442
|
+
permissionDecision: 'deny',
|
|
1443
|
+
permissionDecisionReason: STOP_DENY_REASON,
|
|
1444
|
+
},
|
|
1445
|
+
}));
|
|
1446
|
+
core.broadcastSessionsList().catch(() => { });
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
// 2. Device-approval gate: hold the response for a device decision,
|
|
1450
|
+
// but ONLY for calls the precision guards say Claude would
|
|
1451
|
+
// genuinely prompt for (see shouldHoldPreToolUse — mode gate,
|
|
1452
|
+
// never-prompt/prompt-prone sets, allowlist prediction, learned
|
|
1453
|
+
// auto-approvals, connected-client check). Anything uncertain
|
|
1454
|
+
// falls through to the empty pass-through below.
|
|
1455
|
+
if (claudeSid) {
|
|
1456
|
+
const gate = shouldHoldPreToolUse({
|
|
1457
|
+
sessionId: claudeSid,
|
|
1458
|
+
tool: toolName,
|
|
1459
|
+
toolInput,
|
|
1460
|
+
permissionMode: typeof json.permission_mode === 'string' ? json.permission_mode : undefined,
|
|
1461
|
+
cwd: hookCwd || undefined,
|
|
1462
|
+
clientCount: core.wsServer.getClientCount(),
|
|
1463
|
+
enabled: OBSERVED_APPROVAL_ENABLED,
|
|
392
1464
|
});
|
|
1465
|
+
if (gate.hold && gate.requestId) {
|
|
1466
|
+
const requestId = gate.requestId;
|
|
1467
|
+
debug('daemon', `PreToolUse gate held: ${toolName} (${gate.reason}) req=${requestId}`);
|
|
1468
|
+
setAwaitingOverlay(claudeSid, buildGateQuestion(toolName, toolInput), requestId);
|
|
1469
|
+
core.broadcastSessionsList().catch(() => { });
|
|
1470
|
+
registerPending(requestId, res, {
|
|
1471
|
+
sessionId: claudeSid,
|
|
1472
|
+
tool: toolName,
|
|
1473
|
+
timeoutMs: OBSERVED_APPROVAL_HOLD_MS,
|
|
1474
|
+
onResolved: (decision) => {
|
|
1475
|
+
// `pass` (timeout) arms the auto-approval learner: if the
|
|
1476
|
+
// tool then runs with no permission_prompt Notification,
|
|
1477
|
+
// the signature is suppressed for this session.
|
|
1478
|
+
gateReleased(claudeSid, requestId, {
|
|
1479
|
+
undecided: decision === 'pass', tool: toolName, toolInput,
|
|
1480
|
+
});
|
|
1481
|
+
clearAwaitingOverlay(claudeSid);
|
|
1482
|
+
core.broadcastSessionsList().catch(() => { });
|
|
1483
|
+
},
|
|
1484
|
+
});
|
|
1485
|
+
return; // response held open — resolved by device / timeout
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
// Pass-through: empty body → Claude's normal permission flow runs
|
|
1489
|
+
// untouched (zero added latency, no daemon-side gate).
|
|
1490
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1491
|
+
res.end('');
|
|
1492
|
+
}
|
|
1493
|
+
else if (eventName === 'Stop') {
|
|
1494
|
+
// Turn-end directive queue: deliver at most one queued deck command
|
|
1495
|
+
// by blocking the stop with the directive as the continuation
|
|
1496
|
+
// reason. Empty queue (the overwhelmingly common case) → empty body,
|
|
1497
|
+
// Claude ends the turn normally — no stop_hook_active loop possible.
|
|
1498
|
+
const directive = claudeSid ? takeDirective(claudeSid) : undefined;
|
|
1499
|
+
if (claudeSid)
|
|
1500
|
+
clearStop(claudeSid); // turn ended — a pending soft-stop is moot
|
|
1501
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1502
|
+
if (directive) {
|
|
1503
|
+
debug('daemon', `Stop hook delivering queued directive for ${claudeSid}: "${directive.slice(0, 60)}"`);
|
|
1504
|
+
res.end(JSON.stringify({
|
|
1505
|
+
decision: 'block',
|
|
1506
|
+
reason: `The user sent this follow-up from their AgentDeck controller: "${directive}". Carry out this instruction now.`,
|
|
1507
|
+
}));
|
|
1508
|
+
core.broadcastSessionsList().catch(() => { });
|
|
393
1509
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
apme.collector.closeRun(hookSessionId);
|
|
1510
|
+
else {
|
|
1511
|
+
res.end('');
|
|
397
1512
|
}
|
|
398
1513
|
}
|
|
1514
|
+
else {
|
|
1515
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1516
|
+
res.end(JSON.stringify({ received: true }));
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
// OpenCode observer-plugin steering: the plugin long-polls here and
|
|
1522
|
+
// executes returned commands via its in-process SDK client (abort /
|
|
1523
|
+
// prompt). See bridge/src/opencode-steering.ts.
|
|
1524
|
+
if (req.method === 'GET' && pathname === '/opencode/commands') {
|
|
1525
|
+
const sid = parsedUrl.searchParams.get('sid') ?? '';
|
|
1526
|
+
if (!sid) {
|
|
1527
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1528
|
+
res.end(JSON.stringify({ error: 'sid required' }));
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
const waitMs = Math.round(Number(parsedUrl.searchParams.get('wait') ?? '25') * 1000);
|
|
1532
|
+
pollOpenCodeCommands(sid, waitMs).then((commands) => {
|
|
1533
|
+
try {
|
|
1534
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1535
|
+
res.end(JSON.stringify({ commands }));
|
|
1536
|
+
}
|
|
1537
|
+
catch { /* client disconnected */ }
|
|
399
1538
|
});
|
|
400
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
401
|
-
res.end(JSON.stringify({ received: true }));
|
|
402
1539
|
return;
|
|
403
1540
|
}
|
|
404
1541
|
if (req.method === 'POST' && pathname === '/shutdown') {
|
|
405
1542
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
406
1543
|
res.end(JSON.stringify({ status: 'shutting_down' }));
|
|
1544
|
+
const hardExitTimer = setTimeout(() => {
|
|
1545
|
+
log('[agentdeck] Shutdown route timeout — forcing exit.');
|
|
1546
|
+
exitProcessNow(0);
|
|
1547
|
+
}, 5000);
|
|
407
1548
|
core.shutdown();
|
|
408
1549
|
return;
|
|
409
1550
|
}
|
|
1551
|
+
// Manual task-close endpoint — drives `closeTaskExternal` on the APME
|
|
1552
|
+
// collector. Used by the CLI (`agentdeck task done` / `task cancel`)
|
|
1553
|
+
// and the macOS detail-pane "Mark task complete" button. Body:
|
|
1554
|
+
// { sessionId: string, signal?: 'manual', outcome?: 'success'|'fail'|'abandoned' }
|
|
1555
|
+
// sessionId defaults to the active session derived from the daemon's
|
|
1556
|
+
// registry; supplying it explicitly is the contract the CLI uses.
|
|
1557
|
+
if (req.method === 'POST' && pathname === '/task/close') {
|
|
1558
|
+
if (!apme) {
|
|
1559
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1560
|
+
res.end(JSON.stringify({ error: 'APME not initialized' }));
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
let body = '';
|
|
1564
|
+
req.on('data', (chunk) => { body += chunk; if (body.length > 4096)
|
|
1565
|
+
req.destroy(); });
|
|
1566
|
+
req.on('end', () => {
|
|
1567
|
+
try {
|
|
1568
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
1569
|
+
const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId
|
|
1570
|
+
? parsed.sessionId
|
|
1571
|
+
: (openclawApmeSessionId ?? '');
|
|
1572
|
+
if (!sessionId) {
|
|
1573
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1574
|
+
res.end(JSON.stringify({ error: 'sessionId required (no active session to default to)' }));
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
const signalRaw = typeof parsed.signal === 'string' ? parsed.signal : 'manual';
|
|
1578
|
+
const outcomeRaw = typeof parsed.outcome === 'string' ? parsed.outcome : undefined;
|
|
1579
|
+
const outcome = (outcomeRaw === 'success' || outcomeRaw === 'fail' || outcomeRaw === 'partial' || outcomeRaw === 'abandoned')
|
|
1580
|
+
? outcomeRaw
|
|
1581
|
+
: undefined;
|
|
1582
|
+
// closeTaskExternal accepts TaskBoundarySignal (open union with
|
|
1583
|
+
// string fallback). Narrow only the well-known signals; pass
|
|
1584
|
+
// unknown strings through as-is (the runner labels them
|
|
1585
|
+
// generically).
|
|
1586
|
+
const apmeRef = apme;
|
|
1587
|
+
if (!apmeRef) {
|
|
1588
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1589
|
+
res.end(JSON.stringify({ error: 'APME not initialized' }));
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const closed = apmeRef.collector.closeTaskExternal(sessionId, signalRaw, outcome);
|
|
1593
|
+
res.writeHead(closed ? 200 : 404, { 'Content-Type': 'application/json' });
|
|
1594
|
+
res.end(JSON.stringify({ closed, sessionId, signal: signalRaw, outcome: outcome ?? null }));
|
|
1595
|
+
}
|
|
1596
|
+
catch (err) {
|
|
1597
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1598
|
+
res.end(JSON.stringify({ error: `bad body: ${String(err)}` }));
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
// On-device Apple Intelligence (FoundationModels) text generation via the
|
|
1604
|
+
// daemon-managed *persistent* helper. Keeping it warm in the daemon avoids
|
|
1605
|
+
// the ~7s per-process cold start callers would otherwise pay each time
|
|
1606
|
+
// (e.g. wtcp branch naming). localhost only; lazily spawns the helper.
|
|
1607
|
+
if (req.method === 'POST' && pathname === '/generate') {
|
|
1608
|
+
let body = '';
|
|
1609
|
+
req.on('data', (chunk) => { body += chunk; if (body.length > 16384)
|
|
1610
|
+
req.destroy(); });
|
|
1611
|
+
req.on('end', () => {
|
|
1612
|
+
let prompt = '', instructions;
|
|
1613
|
+
try {
|
|
1614
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
1615
|
+
prompt = typeof parsed.prompt === 'string' ? parsed.prompt : '';
|
|
1616
|
+
instructions = typeof parsed.instructions === 'string' ? parsed.instructions : undefined;
|
|
1617
|
+
}
|
|
1618
|
+
catch {
|
|
1619
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1620
|
+
res.end(JSON.stringify({ error: 'bad JSON' }));
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
if (!prompt) {
|
|
1624
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1625
|
+
res.end(JSON.stringify({ error: 'prompt required' }));
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
callFoundationModelsHelper(prompt, instructions)
|
|
1629
|
+
.then((text) => {
|
|
1630
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1631
|
+
res.end(JSON.stringify({ text }));
|
|
1632
|
+
})
|
|
1633
|
+
.catch((err) => {
|
|
1634
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
1635
|
+
res.end(JSON.stringify({ error: `generate failed: ${String(err)}` }));
|
|
1636
|
+
});
|
|
1637
|
+
});
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
410
1640
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
411
1641
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
412
1642
|
});
|
|
@@ -428,10 +1658,30 @@ export async function startDaemon(opts) {
|
|
|
428
1658
|
});
|
|
429
1659
|
httpServer.listen(port, '0.0.0.0', () => resolve());
|
|
430
1660
|
}).catch(async (err) => {
|
|
431
|
-
// Handle race condition: port became unavailable after probe
|
|
1661
|
+
// Handle race condition: port became unavailable after our pre-bind probe.
|
|
1662
|
+
// This is the concurrent-start case (e.g. two logon-trigger fires landing
|
|
1663
|
+
// within ~1s): both processes pass the singleton guard because neither has
|
|
1664
|
+
// bound yet, then exactly one wins the OS bind and the rest get EADDRINUSE.
|
|
432
1665
|
if (err.message.startsWith('EADDRINUSE:') && port === requestedPort) {
|
|
1666
|
+
// Re-probe the occupant. If ANOTHER daemon grabbed the port, we lost the
|
|
1667
|
+
// race — exit instead of falling back to a new port, or we'd leave two
|
|
1668
|
+
// daemons running (and clobber daemon.json to point at the wrong one).
|
|
1669
|
+
// Only fall back when a *non-daemon* (e.g. a session bridge) holds it.
|
|
1670
|
+
const occupant = await probeDaemonHealth(requestedPort);
|
|
1671
|
+
// Harden against a forged/stale `mode:'daemon'` response squatting the
|
|
1672
|
+
// port: concede (exit) only to a verified live distinct daemon. A claim
|
|
1673
|
+
// backed by a dead/own PID is treated as stale → fall through to a fresh
|
|
1674
|
+
// port and keep running. See `shouldConcedePortToOccupant`.
|
|
1675
|
+
if (shouldConcedePortToOccupant(occupant, process.pid)) {
|
|
1676
|
+
const who = typeof occupant?.pid === 'number' ? `PID ${occupant.pid}` : 'a daemon';
|
|
1677
|
+
log(`[agentdeck] Lost startup race for port ${requestedPort} (${who} already serving). Exiting.`);
|
|
1678
|
+
process.exit(0);
|
|
1679
|
+
}
|
|
1680
|
+
if (occupant?.mode === 'daemon') {
|
|
1681
|
+
log(`[agentdeck] Port ${requestedPort} reports a daemon but PID ${occupant.pid} is not a live distinct process; treating as stale and falling back.`);
|
|
1682
|
+
}
|
|
433
1683
|
port = await findAvailablePort();
|
|
434
|
-
log(`[agentdeck] Port ${requestedPort} grabbed, retrying on ${port}...`);
|
|
1684
|
+
log(`[agentdeck] Port ${requestedPort} grabbed by ${occupant?.mode ?? 'a non-daemon'}, retrying on ${port}...`);
|
|
435
1685
|
await new Promise((resolve, reject) => {
|
|
436
1686
|
httpServer.on('error', (e) => reject(e));
|
|
437
1687
|
httpServer.listen(port, '0.0.0.0', () => resolve());
|
|
@@ -448,11 +1698,88 @@ export async function startDaemon(opts) {
|
|
|
448
1698
|
port,
|
|
449
1699
|
projectName: 'AgentDeck',
|
|
450
1700
|
httpServer,
|
|
1701
|
+
isDaemon: true,
|
|
1702
|
+
});
|
|
1703
|
+
const esp32WifiEvents = new Set([
|
|
1704
|
+
...SERIAL_FORWARDED_EVENTS,
|
|
1705
|
+
'esp32_ota_begin',
|
|
1706
|
+
'esp32_ota_chunk',
|
|
1707
|
+
'esp32_ota_end',
|
|
1708
|
+
'esp32_ota_abort',
|
|
1709
|
+
'device_info_request',
|
|
1710
|
+
]);
|
|
1711
|
+
core.wsServer.setEventTransformer((event, client) => {
|
|
1712
|
+
if (!core.wsServer.isEsp32Client(client))
|
|
1713
|
+
return event;
|
|
1714
|
+
if (!esp32WifiEvents.has(event.type))
|
|
1715
|
+
return null;
|
|
1716
|
+
// Single-path guard: only the duplicated *display* payloads are deduped.
|
|
1717
|
+
// When the same board is live on USB serial, serial drives it and the WiFi
|
|
1718
|
+
// copy is redundant — drop it here. OTA control (esp32_ota_*, WiFi-only) and
|
|
1719
|
+
// device_info_request keep flowing so the standby WiFi socket stays live and
|
|
1720
|
+
// its lastSeen fresh. When serial disconnects, this flips false on the next
|
|
1721
|
+
// event and WiFi resumes automatically — no state migration needed.
|
|
1722
|
+
if (SERIAL_FORWARDED_EVENTS.has(event.type) && isWifiEsp32RedundantWithSerial(client))
|
|
1723
|
+
return null;
|
|
1724
|
+
return prepareForSerial(event);
|
|
451
1725
|
});
|
|
452
1726
|
// Timeline
|
|
1727
|
+
//
|
|
1728
|
+
// The daemon owns timeline persistence: every surface's entries flow through
|
|
1729
|
+
// it, so it is the only process positioned to hold the complete history and
|
|
1730
|
+
// the only one allowed to write the file. Reads still sweep every candidate
|
|
1731
|
+
// data dir (`latestTimelinePath`) so a Node daemon can pick up where a Swift
|
|
1732
|
+
// daemon left off, and vice versa — the two implementations share one
|
|
1733
|
+
// on-disk format.
|
|
453
1734
|
const bridgeLogStream = new BridgeLogStream();
|
|
454
1735
|
core.wireTimeline(bridgeLogStream);
|
|
1736
|
+
const timelinePath = latestTimelinePath();
|
|
1737
|
+
if (timelinePath) {
|
|
1738
|
+
const loaded = core.bridgeTimeline.loadPersistedFile(timelinePath);
|
|
1739
|
+
if (loaded > 0)
|
|
1740
|
+
log(`[agentdeck] Loaded ${loaded} persisted timeline entries`);
|
|
1741
|
+
// Close task_start rows orphaned by a previous daemon killed mid-task —
|
|
1742
|
+
// clients would otherwise spin their in-flight marker forever.
|
|
1743
|
+
const reaped = core.bridgeTimeline.reapOrphanTaskStarts();
|
|
1744
|
+
if (reaped > 0)
|
|
1745
|
+
log(`[agentdeck] Closed ${reaped} orphaned task_start rows as interrupted`);
|
|
1746
|
+
// Same for chat turns: a session killed mid-turn (no Stop / SessionEnd)
|
|
1747
|
+
// leaves its chat_start spinning "in progress" on every surface. Runs
|
|
1748
|
+
// DELAYED (5 min) so live sessions have posted a hook and registered in
|
|
1749
|
+
// `hookSessionsSeen` — only stale rows (30 min+) of sessions that stayed
|
|
1750
|
+
// silent since startup close; a live long-running turn is untouched and
|
|
1751
|
+
// its real Stop still lands normally.
|
|
1752
|
+
const chatReaperTimer = setTimeout(() => {
|
|
1753
|
+
const reapedTurns = core.bridgeTimeline.reapOrphanChatStarts(undefined, undefined, hookSessionsSeen);
|
|
1754
|
+
if (reapedTurns > 0)
|
|
1755
|
+
log(`[agentdeck] Closed ${reapedTurns} orphaned chat_start rows as interrupted`);
|
|
1756
|
+
}, 5 * 60_000);
|
|
1757
|
+
chatReaperTimer.unref?.();
|
|
1758
|
+
}
|
|
1759
|
+
// Enabled AFTER rehydration + reaping so the first write carries the restored
|
|
1760
|
+
// history instead of truncating the file to whatever this run has seen.
|
|
1761
|
+
core.bridgeTimeline.enablePersistence(getOwnTimelineFile());
|
|
455
1762
|
core.wireDisplayMonitor();
|
|
1763
|
+
let lastStateEvent = null;
|
|
1764
|
+
let userFocusedSessionId = null;
|
|
1765
|
+
const attachFocusedSessionId = (event) => {
|
|
1766
|
+
if (event.type !== 'state_update')
|
|
1767
|
+
return event;
|
|
1768
|
+
return {
|
|
1769
|
+
...event,
|
|
1770
|
+
focusedSessionId: userFocusedSessionId ?? '',
|
|
1771
|
+
};
|
|
1772
|
+
};
|
|
1773
|
+
const broadcastFocusedState = () => {
|
|
1774
|
+
const gwAlive = gatewayAdapter?.isAlive() ?? false;
|
|
1775
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
1776
|
+
agentType: gwAlive ? 'openclaw' : 'daemon',
|
|
1777
|
+
agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
|
|
1778
|
+
snapshot: core.stateMachine.getSnapshot(),
|
|
1779
|
+
}));
|
|
1780
|
+
lastStateEvent = stateEvent;
|
|
1781
|
+
core.wsServer.broadcast(stateEvent);
|
|
1782
|
+
};
|
|
456
1783
|
// System wake recovery — re-publish mDNS, reconnect devices, refresh usage
|
|
457
1784
|
core.onSystemWake(() => {
|
|
458
1785
|
log('[daemon] System wake detected — recovering devices');
|
|
@@ -491,11 +1818,11 @@ export async function startDaemon(opts) {
|
|
|
491
1818
|
core.cachedModelCatalog = merged;
|
|
492
1819
|
debug('daemon', `Model catalog merged from sibling: ${merged.length} models total`);
|
|
493
1820
|
const snap = core.stateMachine.getSnapshot();
|
|
494
|
-
const stateEvent = core.buildStateEvent({
|
|
1821
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
495
1822
|
agentType: gatewayAdapter?.isAlive() ? 'openclaw' : 'daemon',
|
|
496
1823
|
agentCapabilities: gatewayAdapter?.isAlive() ? OPENCLAW_CAPABILITIES : undefined,
|
|
497
1824
|
snapshot: snap,
|
|
498
|
-
});
|
|
1825
|
+
}));
|
|
499
1826
|
lastStateEvent = stateEvent;
|
|
500
1827
|
core.broadcast(stateEvent);
|
|
501
1828
|
core.broadcastUsage();
|
|
@@ -506,13 +1833,18 @@ export async function startDaemon(opts) {
|
|
|
506
1833
|
const focusRelay = new SessionFocusRelay();
|
|
507
1834
|
focusRelay.setEventHandler((evt) => {
|
|
508
1835
|
if (evt.type === 'state_update') {
|
|
1836
|
+
const focusedId = focusRelay.getFocusedSessionId();
|
|
1837
|
+
if (focusedId)
|
|
1838
|
+
userFocusedSessionId = focusedId;
|
|
509
1839
|
// Merge daemon metadata into the session's state_update
|
|
510
1840
|
const merged = {
|
|
511
1841
|
...evt,
|
|
512
|
-
sessionId:
|
|
1842
|
+
sessionId: focusedId,
|
|
1843
|
+
focusedSessionId: userFocusedSessionId ?? '',
|
|
513
1844
|
modelCatalog: evt.modelCatalog ?? core.cachedModelCatalog,
|
|
514
1845
|
gatewayAvailable: core.cachedGatewayAvailable,
|
|
515
1846
|
gatewayConnected: core.cachedGatewayConnected,
|
|
1847
|
+
gatewayAuthStatus: core.cachedGatewayAuthStatus,
|
|
516
1848
|
ollamaStatus: core.cachedOllamaStatus,
|
|
517
1849
|
gatewayHasError: evt.gatewayHasError ?? core.cachedGatewayHasError,
|
|
518
1850
|
moduleHealth: moduleHealthProvider(),
|
|
@@ -539,11 +1871,69 @@ export async function startDaemon(opts) {
|
|
|
539
1871
|
});
|
|
540
1872
|
// mDNS + device modules
|
|
541
1873
|
const deviceModules = createDefaultModules('daemon');
|
|
542
|
-
|
|
543
|
-
|
|
1874
|
+
// AGENTDECK_DAEMON_NO_SERIAL=1 leaves the daemon fully functional over WiFi
|
|
1875
|
+
// (mDNS, WiFi WS, WiFi OTA) but never opens the USB serial ports. This frees
|
|
1876
|
+
// a board's /dev/cu.* for an exclusive external serial capture (e.g. reading a
|
|
1877
|
+
// panic backtrace during a WiFi OTA) that otherwise fights the daemon's serial
|
|
1878
|
+
// reader for bytes. Diagnostic gate only; normal daemons keep serial on.
|
|
1879
|
+
const serialMode = process.env.AGENTDECK_DAEMON_NO_SERIAL === '1' ? false : 'auto';
|
|
1880
|
+
// Note: the D200H has no device module — direct-HID control was retired. It is
|
|
1881
|
+
// driven exclusively by the Ulanzi Studio plugin (`ulanzi-plugin`) over WebSocket;
|
|
1882
|
+
// the daemon never opens it over HID. Its health is reported via the Ulanzi client
|
|
1883
|
+
// count in moduleHealthProvider below.
|
|
1884
|
+
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 });
|
|
1885
|
+
moduleHealthProvider = () => {
|
|
1886
|
+
const modules = buildNodeModuleHealth(startedModules);
|
|
1887
|
+
const einkDevices = collectEinkDevices();
|
|
1888
|
+
if (einkDevices.length > 0) {
|
|
1889
|
+
modules.einkDevices = { available: true, devices: einkDevices };
|
|
1890
|
+
}
|
|
1891
|
+
const androidDashboards = collectAndroidDashboards();
|
|
1892
|
+
if (androidDashboards.length > 0) {
|
|
1893
|
+
modules.androidDashboards = { available: true, devices: androidDashboards };
|
|
1894
|
+
}
|
|
1895
|
+
const streamDeckDevices = collectStreamDeckDevices();
|
|
1896
|
+
if (streamDeckDevices.length > 0) {
|
|
1897
|
+
modules.streamDeck = { available: true, devices: streamDeckDevices };
|
|
1898
|
+
}
|
|
1899
|
+
// WiFi-WS ESP32 boards. Same data as /devices `esp32-wifi` — without this
|
|
1900
|
+
// the dashboards (macOS/iOS/Android) never learn a WiFi-only board exists,
|
|
1901
|
+
// since they read moduleHealth off state_update, not /devices.
|
|
1902
|
+
const wifiEsp32 = listWifiEsp32Devices();
|
|
1903
|
+
if (wifiEsp32.length > 0) {
|
|
1904
|
+
modules.esp32Wifi = {
|
|
1905
|
+
available: true,
|
|
1906
|
+
devices: wifiEsp32.map((d) => ({
|
|
1907
|
+
board: d.board,
|
|
1908
|
+
ip: d.ip ?? null,
|
|
1909
|
+
version: d.version ?? null,
|
|
1910
|
+
stale: d.stale,
|
|
1911
|
+
serialActive: d.serialActive,
|
|
1912
|
+
})),
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
const tuiClients = core.wsServer.getTuiClients();
|
|
1916
|
+
if (tuiClients.length > 0) {
|
|
1917
|
+
modules.tuiDashboards = {
|
|
1918
|
+
available: true,
|
|
1919
|
+
devices: tuiClients.map((c) => ({ id: c.id, name: c.name, kind: 'tui' })),
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
const ulanziPluginConnected = core.wsServer.getUlanziClientCount() > 0;
|
|
1923
|
+
if (ulanziPluginConnected) {
|
|
1924
|
+
modules.d200h = {
|
|
1925
|
+
connected: true,
|
|
1926
|
+
driver: 'ulanzi-plugin',
|
|
1927
|
+
};
|
|
1928
|
+
}
|
|
1929
|
+
return modules;
|
|
1930
|
+
};
|
|
544
1931
|
core.setModuleHealthProvider(moduleHealthProvider);
|
|
1932
|
+
// iDotMatrix BLE is now driven by IDotMatrixModule (registered in
|
|
1933
|
+
// createDefaultModules): the module owns spawning the Python sync client,
|
|
1934
|
+
// zero-config auto-discovery, and teardown — so the daemon doesn't start it
|
|
1935
|
+
// directly here anymore (avoids double-spawn).
|
|
545
1936
|
// Serial module state provider (heartbeat needs cached state)
|
|
546
|
-
let lastStateEvent = null;
|
|
547
1937
|
const serialModule = startedModules.find(m => m.name === 'serial');
|
|
548
1938
|
if (serialModule) {
|
|
549
1939
|
serialModule.setStateProvider(() => lastStateEvent);
|
|
@@ -554,20 +1944,62 @@ export async function startDaemon(opts) {
|
|
|
554
1944
|
if (lastStateEvent)
|
|
555
1945
|
events.push(lastStateEvent);
|
|
556
1946
|
events.push(core.buildUsage());
|
|
557
|
-
events.push(
|
|
1947
|
+
events.push(buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
|
|
1948
|
+
// Seed the last few timeline entries so a freshly (re)connected board's
|
|
1949
|
+
// ticker shows the real latest event instead of whatever its ring last
|
|
1950
|
+
// held. Kept to 6 entries — the whole line must stay well under the
|
|
1951
|
+
// small (4KB) serial RX buffers on non-InkDeck boards; prepareForSerial
|
|
1952
|
+
// byte-caps each entry's raw/detail at send time.
|
|
1953
|
+
const recent = core.bridgeTimeline.getHistory().slice(-6);
|
|
1954
|
+
if (recent.length > 0) {
|
|
1955
|
+
events.push({ type: 'timeline_history', entries: recent });
|
|
1956
|
+
}
|
|
558
1957
|
// Sessions list (async enrichment runs synchronously from cache here)
|
|
559
1958
|
core.broadcastSessionsList().catch(() => { });
|
|
560
1959
|
return events;
|
|
561
1960
|
});
|
|
1961
|
+
// Heartbeat re-sync: display_state is otherwise edge-triggered, and a board
|
|
1962
|
+
// that misses the wake edge stays dark until power-cycled.
|
|
1963
|
+
serialModule.setDisplayStateProvider(() => buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
|
|
1964
|
+
// Heartbeat re-sync: sessions_list is edge-triggered too, so a board that
|
|
1965
|
+
// reconnects across a daemon handoff during a quiet window would otherwise
|
|
1966
|
+
// stay on "no active sessions" until the next session change.
|
|
1967
|
+
serialModule.setSessionsListProvider(() => core.getLastSessionsListEvent());
|
|
562
1968
|
// Include ESP32 serial connections in client count for polling guards
|
|
563
1969
|
core.setExternalClientCountProvider(() => esp32ConnectionCount());
|
|
1970
|
+
}
|
|
1971
|
+
// WS-path display_state re-sync: the 5s serial heartbeat above only covers
|
|
1972
|
+
// USB-attached boards. WiFi boards (InkDeck) receive display_state edge-
|
|
1973
|
+
// triggered over the plugin WS — a missed wake edge would leave an e-ink
|
|
1974
|
+
// panel showing the sleep card forever. Re-broadcast at a slow cadence so
|
|
1975
|
+
// any board that missed the edge self-heals within 15s.
|
|
1976
|
+
{
|
|
1977
|
+
const displayResync = setInterval(() => {
|
|
1978
|
+
if (core.wsServer.getClientCount() > 0) {
|
|
1979
|
+
core.wsServer.broadcast(buildDisplayStateEvent(core.displayMonitor.isDisplayOn()));
|
|
1980
|
+
}
|
|
1981
|
+
}, 15_000);
|
|
1982
|
+
displayResync.unref?.();
|
|
1983
|
+
// WiFi ESP32 diagnostics: boards only announce device_info on connect unless
|
|
1984
|
+
// asked. Poll slowly so /devices can show uptime/reset changes and distinguish
|
|
1985
|
+
// a real reboot from a display/WS redraw.
|
|
1986
|
+
const wifiDeviceInfoPoll = setInterval(() => {
|
|
1987
|
+
if (core.wsServer.getClientCount() > 0) {
|
|
1988
|
+
core.wsServer.broadcast({ type: 'device_info_request' });
|
|
1989
|
+
}
|
|
1990
|
+
}, 30_000);
|
|
1991
|
+
wifiDeviceInfoPoll.unref?.();
|
|
564
1992
|
// WiFi auto-provisioning for ESP32 (enables independent WiFi operation)
|
|
565
1993
|
const wifiConfig = loadWifiConfig();
|
|
566
1994
|
if (wifiConfig?.autoProvision) {
|
|
567
1995
|
const lanIp = getLanIp();
|
|
568
1996
|
onESP32Message((portPath, msg) => {
|
|
569
|
-
|
|
570
|
-
|
|
1997
|
+
const shouldRefreshIps10Endpoint = msg.type === 'device_info' &&
|
|
1998
|
+
msg.board === 'ips_10' &&
|
|
1999
|
+
msg.wifiConnected &&
|
|
2000
|
+
!msg.wifiRadioParked;
|
|
2001
|
+
if (msg.type === 'device_info' && (!msg.wifiConnected || shouldRefreshIps10Endpoint)) {
|
|
2002
|
+
const sent = sendWifiProvisionToAll({
|
|
571
2003
|
type: 'wifi_provision',
|
|
572
2004
|
ssid: wifiConfig.ssid,
|
|
573
2005
|
password: wifiConfig.password,
|
|
@@ -575,7 +2007,8 @@ export async function startDaemon(opts) {
|
|
|
575
2007
|
bridgePort: port,
|
|
576
2008
|
authToken: core.authToken,
|
|
577
2009
|
});
|
|
578
|
-
|
|
2010
|
+
if (sent > 0)
|
|
2011
|
+
log(`[agentdeck] WiFi provision sent to ${sent} ESP32 device(s) after ${portPath}`);
|
|
579
2012
|
}
|
|
580
2013
|
else if (msg.type === 'wifi_provision_ack') {
|
|
581
2014
|
log(msg.success ? `[agentdeck] ESP32 WiFi connected: ${msg.ip} ✓` : `[agentdeck] ESP32 WiFi failed: ${msg.error || 'unknown'}`);
|
|
@@ -589,43 +2022,135 @@ export async function startDaemon(opts) {
|
|
|
589
2022
|
// routes. `setApme` on core is gated against the `daemon` meta-session so
|
|
590
2023
|
// register/deregister won't open a bogus run. Session bridges opening their
|
|
591
2024
|
// own connection to the same sqlite file is safe under WAL mode.
|
|
592
|
-
|
|
2025
|
+
// emitTimeline: forward task hierarchy entries (task_start / task_end) into
|
|
2026
|
+
// the daemon's bridgeTimeline so downstream dashboards see task headers.
|
|
2027
|
+
const daemonProjectTimeline = isTimelineProjectionEnabled();
|
|
2028
|
+
apme = await initApme(undefined, {
|
|
2029
|
+
emitTimeline: (entry) => core.bridgeTimeline.addEntry(entry),
|
|
2030
|
+
projectSampleTimeline: daemonProjectTimeline,
|
|
2031
|
+
emitProjectedTimeline: (entry) => core.bridgeTimeline.addEntry(entry, { bypassSuppression: true }),
|
|
2032
|
+
});
|
|
593
2033
|
if (apme) {
|
|
594
2034
|
core.setApme(apme);
|
|
595
|
-
|
|
2035
|
+
if (daemonProjectTimeline) {
|
|
2036
|
+
core.bridgeTimeline.setSuppressLocalChatTool(true);
|
|
2037
|
+
log('[agentdeck] APME timeline projection ENABLED — chat/tool rows derive from SessionSample');
|
|
2038
|
+
}
|
|
2039
|
+
log(`[agentdeck] APME enabled — store=${apme.store.dbPath} routes=/apme/*`);
|
|
2040
|
+
// Fire-and-forget judge backend probe. Result is cached on
|
|
2041
|
+
// apme.runner.lastBackendProbe and surfaced on /health so users discover
|
|
2042
|
+
// misconfiguration (no MLX server running, missing API key, etc.) without
|
|
2043
|
+
// having to wait for the first eval to fail.
|
|
2044
|
+
void apme.runner.refreshBackendProbe(loadApmeConfig().judge).then(status => {
|
|
2045
|
+
if (status.status === 'ready') {
|
|
2046
|
+
log(`[agentdeck] APME judge ready: ${status.backend}${status.model ? ` (${status.model})` : ''}${status.latencyMs !== undefined ? ` ${status.latencyMs}ms` : ''}`);
|
|
2047
|
+
}
|
|
2048
|
+
else {
|
|
2049
|
+
log(`[agentdeck] APME judge ${status.backend} ${status.status} — ${status.reason ?? 'no reason'}. Deterministic layer (lint/build/test) keeps running.`);
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
else {
|
|
2054
|
+
// initApme() already logged the specific reason via logError. This second
|
|
2055
|
+
// line tells the user where to look + what's lost so the gap doesn't pass
|
|
2056
|
+
// for "everything's fine, just no /apme/ routes".
|
|
2057
|
+
log('[agentdeck] APME unavailable — no run/turn/task evals will be recorded for sessions on this daemon.');
|
|
2058
|
+
// Keep the timeline's task hierarchy alive without APME: emit fallback
|
|
2059
|
+
// task_start/task_end rows from the hook boundary signals so the device
|
|
2060
|
+
// timeline doesn't degrade to a flat chat/tool stream.
|
|
2061
|
+
fallbackTasks = new FallbackTaskTimeline((entry) => core.bridgeTimeline.addEntry(entry), { agentType: 'claude-code' });
|
|
596
2062
|
}
|
|
597
2063
|
// Register session
|
|
598
2064
|
core.registerSession('daemon');
|
|
599
2065
|
const passiveSessionObserver = new PassiveSessionObserver();
|
|
2066
|
+
// The observer scans in the background now (collect() returns the cache
|
|
2067
|
+
// immediately). When a scan lands fresh observations, push them out via
|
|
2068
|
+
// the debounced broadcast so clients don't wait for the next 10 s poll.
|
|
2069
|
+
passiveSessionObserver.onRefreshed = () => core.maybeBroadcastSessionsList();
|
|
600
2070
|
// ===== Gateway adapter lifecycle =====
|
|
601
2071
|
// (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
|
|
602
2072
|
// Inject OpenClaw virtual session only after Gateway authentication succeeds.
|
|
603
2073
|
// Reachability alone is a topology signal, not proof that commands can route.
|
|
604
2074
|
core.setSessionsEnricher((sessions) => {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
2075
|
+
// Overlay hook-driven awaiting state onto observed (direct-`claude`)
|
|
2076
|
+
// sessions — display-only (no requestId, respond-in-terminal UX). Done here
|
|
2077
|
+
// in the synchronous enricher (runs on every broadcast) rather than inside
|
|
2078
|
+
// the 5s-throttled observer, so a Notification arriving mid-window still
|
|
2079
|
+
// surfaces within one frame. Key = the Claude session UUID embedded in
|
|
2080
|
+
// `observed:claude:<uuid>`.
|
|
2081
|
+
const observed = applyAwaitingOverlayToObserved(passiveSessionObserver.collect(sessions))
|
|
2082
|
+
.map((s) => {
|
|
2083
|
+
// Steering feedback for observed Claude sessions: devices render
|
|
2084
|
+
// "stopping at next tool" / queued-directive badges from these.
|
|
2085
|
+
if (!s.id.startsWith('observed:claude:'))
|
|
2086
|
+
return s;
|
|
2087
|
+
const snap = steeringSnapshot(s.id.slice('observed:claude:'.length));
|
|
2088
|
+
if (!snap.stopRequested && snap.queuedDirectives === 0)
|
|
2089
|
+
return s;
|
|
2090
|
+
return {
|
|
2091
|
+
...s,
|
|
2092
|
+
...(snap.stopRequested ? { stopRequested: true } : {}),
|
|
2093
|
+
...(snap.queuedDirectives > 0 ? { queuedDirectives: snap.queuedDirectives } : {}),
|
|
2094
|
+
};
|
|
2095
|
+
});
|
|
2096
|
+
// Derive per-session elapsed seconds from startedAt so NTP-less devices
|
|
2097
|
+
// (ESP32 IPS10 mosaic) render an elapsed value per cell without a wall clock.
|
|
2098
|
+
const now = Date.now();
|
|
2099
|
+
const enrichedSessions = [...sessions, ...observed].map((s) => {
|
|
2100
|
+
// On-demand review badge (REVIEW tile verdict / REVIEWING state) —
|
|
2101
|
+
// applies to every session type, managed included.
|
|
2102
|
+
const review = reviewSnapshot(s.id);
|
|
2103
|
+
const withReview = Object.keys(review).length > 0 ? { ...s, ...review } : s;
|
|
2104
|
+
if (withReview.elapsedSec != null || !withReview.startedAt)
|
|
2105
|
+
return withReview;
|
|
2106
|
+
const sec = Math.round((now - Date.parse(withReview.startedAt)) / 1000);
|
|
2107
|
+
return Number.isFinite(sec) && sec >= 0 ? { ...withReview, elapsedSec: sec } : withReview;
|
|
2108
|
+
});
|
|
2109
|
+
// SSOT: inject iff Gateway is authenticated (gatewayConnected). Reachability
|
|
2110
|
+
// / adapter-liveness alone must not materialize a session — that kept a
|
|
2111
|
+
// phantom OpenClaw alive on devices after it was effectively off. Shared
|
|
2112
|
+
// injector with bridge/src/index.ts; mirror of Swift buildSessionsListEvent.
|
|
611
2113
|
const snap = core.stateMachine.getSnapshot();
|
|
612
|
-
return
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
modelName: adapterAlive ? (snap.modelName ?? undefined) : undefined,
|
|
620
|
-
controlMode: 'managed',
|
|
621
|
-
}];
|
|
2114
|
+
return injectOpenClawSession(enrichedSessions, {
|
|
2115
|
+
gatewayConnected: core.cachedGatewayConnected,
|
|
2116
|
+
state: snap.state,
|
|
2117
|
+
projectName: snap.projectName ?? 'OpenClaw',
|
|
2118
|
+
modelName: snap.modelName ?? undefined,
|
|
2119
|
+
controlMode: 'managed',
|
|
2120
|
+
});
|
|
622
2121
|
});
|
|
2122
|
+
// OpenClaw-specific APME session id. Distinct from `core.sessionId`
|
|
2123
|
+
// (the daemon meta-session, which doesn't open a run) and rotates on
|
|
2124
|
+
// every connect/disconnect cycle so each Gateway lifetime gets its own
|
|
2125
|
+
// APME run with its own task hierarchy. Captured here so both the
|
|
2126
|
+
// `connectGatewayAdapter` open + `disconnectGatewayAdapter` close paths
|
|
2127
|
+
// can reference the same id.
|
|
2128
|
+
let openclawApmeSessionId = null;
|
|
623
2129
|
function connectGatewayAdapter() {
|
|
624
2130
|
if (gatewayAdapter || gatewayConnecting)
|
|
625
2131
|
return;
|
|
626
2132
|
gatewayConnecting = true;
|
|
627
2133
|
log('[agentdeck] OpenClaw Gateway detected, connecting...');
|
|
628
2134
|
const adapter = new OpenClawAdapter({ autoReconnect: false });
|
|
2135
|
+
// Bind APME — without this OpenClaw never reaches the collector and
|
|
2136
|
+
// every chat session collapses to a single session_end task. The
|
|
2137
|
+
// adapter's idle-gap timer + chat.send/final ingestion needs an active
|
|
2138
|
+
// run to attach turns and task boundaries to.
|
|
2139
|
+
if (apme) {
|
|
2140
|
+
openclawApmeSessionId = `openclaw-${randomUUID()}`;
|
|
2141
|
+
try {
|
|
2142
|
+
apme.collector.openRun({
|
|
2143
|
+
sessionId: openclawApmeSessionId,
|
|
2144
|
+
agentType: 'openclaw',
|
|
2145
|
+
projectName: 'openclaw',
|
|
2146
|
+
});
|
|
2147
|
+
adapter.setApmeSession(openclawApmeSessionId, process.cwd());
|
|
2148
|
+
}
|
|
2149
|
+
catch (err) {
|
|
2150
|
+
debug('APME', `openRun for OpenClaw failed: ${String(err)}`);
|
|
2151
|
+
openclawApmeSessionId = null;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
629
2154
|
adapter.on('event', (evt) => {
|
|
630
2155
|
switch (evt.source) {
|
|
631
2156
|
case 'hook':
|
|
@@ -636,6 +2161,14 @@ export async function startDaemon(opts) {
|
|
|
636
2161
|
break;
|
|
637
2162
|
case 'parser':
|
|
638
2163
|
core.stateMachine.handleParserEvent(evt.event, evt.data);
|
|
2164
|
+
// The OpenClaw adapter emits the real model via a model_info parser
|
|
2165
|
+
// event, but it only ever updated the display StateMachine — the APME
|
|
2166
|
+
// run was never told, so every openclaw run persisted model_id=NULL.
|
|
2167
|
+
if (evt.event === 'model_info' && apme && openclawApmeSessionId) {
|
|
2168
|
+
const model = evt.data?.model;
|
|
2169
|
+
if (model)
|
|
2170
|
+
apme.collector.updateModel(openclawApmeSessionId, model);
|
|
2171
|
+
}
|
|
639
2172
|
break;
|
|
640
2173
|
case 'metadata':
|
|
641
2174
|
if (evt.event === 'model_catalog') {
|
|
@@ -643,11 +2176,11 @@ export async function startDaemon(opts) {
|
|
|
643
2176
|
if (models) {
|
|
644
2177
|
core.cachedModelCatalog = models;
|
|
645
2178
|
const snap = core.stateMachine.getSnapshot();
|
|
646
|
-
const stateEvent = core.buildStateEvent({
|
|
2179
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
647
2180
|
agentType: 'openclaw',
|
|
648
2181
|
agentCapabilities: OPENCLAW_CAPABILITIES,
|
|
649
2182
|
snapshot: snap,
|
|
650
|
-
});
|
|
2183
|
+
}));
|
|
651
2184
|
lastStateEvent = stateEvent;
|
|
652
2185
|
core.broadcast(stateEvent);
|
|
653
2186
|
core.broadcastUsage();
|
|
@@ -668,12 +2201,18 @@ export async function startDaemon(opts) {
|
|
|
668
2201
|
break;
|
|
669
2202
|
case 'timeline':
|
|
670
2203
|
if (evt.entry) {
|
|
2204
|
+
// This handler is wired exclusively to the OpenClaw Gateway adapter
|
|
2205
|
+
// (see agentType:'openclaw' at the model_catalog case above), but the
|
|
2206
|
+
// adapter emits bare timeline entries without agentType/projectName.
|
|
2207
|
+
// Stamp the OpenClaw origin so the attributor doesn't default them to
|
|
2208
|
+
// 'AgentDeck'/null. See enrichGatewayTimelineEntry.
|
|
2209
|
+
const enriched = enrichGatewayTimelineEntry(evt.entry);
|
|
671
2210
|
if (evt.upsert)
|
|
672
|
-
core.bridgeTimeline.upsertEntry(
|
|
2211
|
+
core.bridgeTimeline.upsertEntry(enriched);
|
|
673
2212
|
else
|
|
674
|
-
core.bridgeTimeline.addEntry(
|
|
675
|
-
if (
|
|
676
|
-
bridgeLogStream.trackToolRequest(
|
|
2213
|
+
core.bridgeTimeline.addEntry(enriched);
|
|
2214
|
+
if (enriched.type === 'tool_request')
|
|
2215
|
+
bridgeLogStream.trackToolRequest(enriched.raw);
|
|
677
2216
|
}
|
|
678
2217
|
break;
|
|
679
2218
|
case 'connection': {
|
|
@@ -684,6 +2223,7 @@ export async function startDaemon(opts) {
|
|
|
684
2223
|
if (evt.status === 'connected') {
|
|
685
2224
|
core.cachedGatewayAvailable = true;
|
|
686
2225
|
core.cachedGatewayConnected = true;
|
|
2226
|
+
core.cachedGatewayAuthStatus = 'connected';
|
|
687
2227
|
bridgeLogStream.start();
|
|
688
2228
|
log('[agentdeck] OpenClaw Gateway connected');
|
|
689
2229
|
if (core.stateMachine.getSnapshot().state === 'disconnected') {
|
|
@@ -691,11 +2231,11 @@ export async function startDaemon(opts) {
|
|
|
691
2231
|
}
|
|
692
2232
|
// Force full state broadcast
|
|
693
2233
|
const snap = core.stateMachine.getSnapshot();
|
|
694
|
-
const gwStateEvent = core.buildStateEvent({
|
|
2234
|
+
const gwStateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
695
2235
|
agentType: 'openclaw',
|
|
696
2236
|
agentCapabilities: OPENCLAW_CAPABILITIES,
|
|
697
2237
|
snapshot: snap,
|
|
698
|
-
});
|
|
2238
|
+
}));
|
|
699
2239
|
lastStateEvent = gwStateEvent;
|
|
700
2240
|
core.wsServer.broadcast(gwStateEvent);
|
|
701
2241
|
core.broadcastUsage();
|
|
@@ -703,8 +2243,10 @@ export async function startDaemon(opts) {
|
|
|
703
2243
|
}
|
|
704
2244
|
else {
|
|
705
2245
|
core.cachedGatewayConnected = false;
|
|
2246
|
+
core.cachedGatewayAuthStatus = 'gateway_not_found';
|
|
706
2247
|
bridgeLogStream.stop();
|
|
707
2248
|
log('[agentdeck] OpenClaw Gateway disconnected');
|
|
2249
|
+
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
708
2250
|
core.broadcastSessionsList().catch(() => { });
|
|
709
2251
|
}
|
|
710
2252
|
break;
|
|
@@ -719,6 +2261,7 @@ export async function startDaemon(opts) {
|
|
|
719
2261
|
log(`[agentdeck] Failed to connect to Gateway: ${err}`);
|
|
720
2262
|
gatewayConnecting = false;
|
|
721
2263
|
core.cachedGatewayConnected = false;
|
|
2264
|
+
core.cachedGatewayAuthStatus = 'gateway_not_found';
|
|
722
2265
|
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
723
2266
|
});
|
|
724
2267
|
}
|
|
@@ -729,10 +2272,25 @@ export async function startDaemon(opts) {
|
|
|
729
2272
|
const wasAlive = gatewayAdapter.isAlive();
|
|
730
2273
|
gatewayAdapter.shutdown().catch(() => { });
|
|
731
2274
|
gatewayAdapter = null;
|
|
2275
|
+
// APME: close the OpenClaw run so the collector fires its
|
|
2276
|
+
// session_end boundary and the run becomes eligible for the layer-2
|
|
2277
|
+
// judge queue.
|
|
2278
|
+
if (apme && openclawApmeSessionId) {
|
|
2279
|
+
try {
|
|
2280
|
+
apme.collector.closeRun(openclawApmeSessionId);
|
|
2281
|
+
}
|
|
2282
|
+
catch (err) {
|
|
2283
|
+
debug('APME', `closeRun for OpenClaw failed: ${String(err)}`);
|
|
2284
|
+
}
|
|
2285
|
+
openclawApmeSessionId = null;
|
|
2286
|
+
}
|
|
732
2287
|
core.cachedGatewayConnected = false;
|
|
2288
|
+
core.cachedGatewayAuthStatus = 'gateway_not_found';
|
|
733
2289
|
core.cachedModelCatalog = null;
|
|
734
2290
|
if (wasAlive)
|
|
735
2291
|
core.stateMachine.handleHookEvent('SessionEnd', {});
|
|
2292
|
+
else
|
|
2293
|
+
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
736
2294
|
// Do NOT broadcast connection:disconnected — that would make WS clients
|
|
737
2295
|
// think they lost their bridge connection. State change to 'daemon' agentType
|
|
738
2296
|
// and updated sessions_list convey the gateway loss.
|
|
@@ -789,11 +2347,11 @@ export async function startDaemon(opts) {
|
|
|
789
2347
|
// ===== State changed → broadcast =====
|
|
790
2348
|
core.stateMachine.on('state_changed', (snapshot) => {
|
|
791
2349
|
const gwAlive = gatewayAdapter?.isAlive() ?? false;
|
|
792
|
-
const stateEvent = core.buildStateEvent({
|
|
2350
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
793
2351
|
agentType: gwAlive ? 'openclaw' : 'daemon',
|
|
794
2352
|
agentCapabilities: gwAlive ? OPENCLAW_CAPABILITIES : undefined,
|
|
795
2353
|
snapshot,
|
|
796
|
-
});
|
|
2354
|
+
}));
|
|
797
2355
|
lastStateEvent = stateEvent;
|
|
798
2356
|
core.wsServer.broadcast(stateEvent);
|
|
799
2357
|
core.maybeBroadcastSessionsList();
|
|
@@ -816,6 +2374,62 @@ export async function startDaemon(opts) {
|
|
|
816
2374
|
// ===== Commands from WS clients =====
|
|
817
2375
|
// ===== Internal WS: session push channel =====
|
|
818
2376
|
core.wsServer.onRawMessage((msg, sender) => {
|
|
2377
|
+
// WiFi ESP32 boards announce device_info over WS on connect. Capture the
|
|
2378
|
+
// sender here so OTA can address the exact socket instead of broadcasting.
|
|
2379
|
+
if (msg.type === 'device_info') {
|
|
2380
|
+
core.wsServer.markEsp32Client(sender);
|
|
2381
|
+
registerWifiEsp32(msg, sender);
|
|
2382
|
+
return true;
|
|
2383
|
+
}
|
|
2384
|
+
touchWifiEsp32Socket(sender);
|
|
2385
|
+
if (msg.type === 'client_register'
|
|
2386
|
+
&& msg.clientType === 'eink-device') {
|
|
2387
|
+
// XTeink X3/X4 (fork) volunteering its E-ink roster. Store per-socket so
|
|
2388
|
+
// the Downstream E-ink rail can render it; drops on socket close.
|
|
2389
|
+
const devices = Array.isArray(msg.devices)
|
|
2390
|
+
? msg.devices : [];
|
|
2391
|
+
einkRegistrations.set(sender, { devices, updatedAt: Date.now() });
|
|
2392
|
+
debug('daemon', `client_register eink-device devices=${devices.length}`);
|
|
2393
|
+
// Push a fresh state so the rail appears within one frame instead of on
|
|
2394
|
+
// the next periodic tick.
|
|
2395
|
+
if (lastStateEvent && lastStateEvent.type === 'state_update') {
|
|
2396
|
+
lastStateEvent.moduleHealth = moduleHealthProvider();
|
|
2397
|
+
core.wsServer.broadcast(lastStateEvent);
|
|
2398
|
+
}
|
|
2399
|
+
return true;
|
|
2400
|
+
}
|
|
2401
|
+
if (msg.type === 'client_register'
|
|
2402
|
+
&& msg.clientType === 'android-dashboard') {
|
|
2403
|
+
// Android dashboard app volunteering its identity (tablet / e-ink
|
|
2404
|
+
// launcher on Wi-Fi) so the topology can show an Android row even when
|
|
2405
|
+
// the device is not ADB-bridged; drops on socket close.
|
|
2406
|
+
const devices = Array.isArray(msg.devices)
|
|
2407
|
+
? msg.devices : [];
|
|
2408
|
+
androidDashboardRegistrations.set(sender, { devices, updatedAt: Date.now() });
|
|
2409
|
+
debug('daemon', `client_register android-dashboard devices=${devices.length}`);
|
|
2410
|
+
if (lastStateEvent && lastStateEvent.type === 'state_update') {
|
|
2411
|
+
lastStateEvent.moduleHealth = moduleHealthProvider();
|
|
2412
|
+
core.wsServer.broadcast(lastStateEvent);
|
|
2413
|
+
}
|
|
2414
|
+
return true;
|
|
2415
|
+
}
|
|
2416
|
+
if (msg.type === 'client_register'
|
|
2417
|
+
&& msg.clientType === 'streamdeck-plugin') {
|
|
2418
|
+
// Elgato plugin volunteering the physical Stream Decks it drives — the
|
|
2419
|
+
// topology's Stream Deck rows. Swift-daemon parity; drops on socket close.
|
|
2420
|
+
const devices = Array.isArray(msg.devices)
|
|
2421
|
+
? msg.devices : [];
|
|
2422
|
+
streamDeckRegistrations.set(sender, { devices, updatedAt: Date.now() });
|
|
2423
|
+
debug('daemon', `client_register streamdeck-plugin devices=${devices.length}`);
|
|
2424
|
+
if (lastStateEvent && lastStateEvent.type === 'state_update') {
|
|
2425
|
+
lastStateEvent.moduleHealth = moduleHealthProvider();
|
|
2426
|
+
core.wsServer.broadcast(lastStateEvent);
|
|
2427
|
+
}
|
|
2428
|
+
return true;
|
|
2429
|
+
}
|
|
2430
|
+
if (handleEsp32OtaReply(msg)) {
|
|
2431
|
+
return true;
|
|
2432
|
+
}
|
|
819
2433
|
if (msg.type === 'session_push_register') {
|
|
820
2434
|
const { sessionId, port: sessionPort, agentType: at, projectName: pn } = msg;
|
|
821
2435
|
debug('daemon', `session_push_register: ${sessionId} port=${sessionPort} agent=${at}`);
|
|
@@ -835,8 +2449,77 @@ export async function startDaemon(opts) {
|
|
|
835
2449
|
}
|
|
836
2450
|
return true; // consumed
|
|
837
2451
|
}
|
|
2452
|
+
if (msg.type === 'deck_slot_map') {
|
|
2453
|
+
// Plugin pushed its keypad layout. Forward to other viewers (extra
|
|
2454
|
+
// plugin instance, dashboard) and re-broadcast sessions_list so slot
|
|
2455
|
+
// buttons populate immediately — without this they would stay "Empty"
|
|
2456
|
+
// until the next 10 s sessions polling tick after the plugin connect.
|
|
2457
|
+
core.wsServer.broadcastExcept(msg, sender);
|
|
2458
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2459
|
+
return true; // consumed
|
|
2460
|
+
}
|
|
2461
|
+
if (msg.type === 'query_session_timeline') {
|
|
2462
|
+
// Reply to THIS requester only with the session's recent timeline so a
|
|
2463
|
+
// device that connected mid-session can fill its Detail view on demand
|
|
2464
|
+
// (the live timeline_event stream is forward-only). Useful for any
|
|
2465
|
+
// reconnecting surface, not just the XTeink X3.
|
|
2466
|
+
const sessionId = msg.sessionId;
|
|
2467
|
+
const since = msg.since;
|
|
2468
|
+
if (typeof sessionId === 'string' && sessionId) {
|
|
2469
|
+
const sinceMs = typeof since === 'number' ? since : undefined;
|
|
2470
|
+
let entries = core.bridgeTimeline.getHistoryForSession(sessionId, sinceMs);
|
|
2471
|
+
// Passively-observed sessions never push timeline rows (the relay only
|
|
2472
|
+
// covers managed + hook sessions), so the store comes back empty for
|
|
2473
|
+
// them. Reconstruct a recent-activity timeline from their transcript so
|
|
2474
|
+
// the device's Detail view isn't stuck on "No recent activity yet".
|
|
2475
|
+
if (entries.length === 0) {
|
|
2476
|
+
try {
|
|
2477
|
+
entries = transcriptTimelineForSession(sessionId, { since: sinceMs });
|
|
2478
|
+
}
|
|
2479
|
+
catch { /* read-only best effort */ }
|
|
2480
|
+
}
|
|
2481
|
+
const historyEvent = buildCappedTimelineHistory(entries, undefined, { sessionId });
|
|
2482
|
+
if (historyEvent) {
|
|
2483
|
+
try {
|
|
2484
|
+
sender.send(JSON.stringify(historyEvent));
|
|
2485
|
+
}
|
|
2486
|
+
catch { /* client disconnecting */ }
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
return true; // consumed
|
|
2490
|
+
}
|
|
838
2491
|
return false; // not consumed — pass to command handler
|
|
839
2492
|
});
|
|
2493
|
+
// Steering commands for observed (hook-only, no PTY) Claude sessions —
|
|
2494
|
+
// shared by the session_command route and the bare-command fallback below.
|
|
2495
|
+
// Semantics differ from managed PTY on purpose:
|
|
2496
|
+
// interrupt/escape → soft STOP (deny at the next tool call)
|
|
2497
|
+
// send_prompt → queued, delivered when the current turn ends
|
|
2498
|
+
// respond/select_option → resolve an open device-approval gate
|
|
2499
|
+
function handleObservedClaudeCommand(uuid, command) {
|
|
2500
|
+
const type = typeof command?.type === 'string' ? command.type : '';
|
|
2501
|
+
if (type === 'interrupt' || type === 'escape') {
|
|
2502
|
+
requestStop(uuid);
|
|
2503
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
if (type === 'send_prompt' && typeof command.text === 'string') {
|
|
2507
|
+
if (queueDirective(uuid, command.text))
|
|
2508
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
if (type === 'respond' || type === 'select_option') {
|
|
2512
|
+
const ov = getAwaitingOverlay(uuid);
|
|
2513
|
+
if (ov?.requestId) {
|
|
2514
|
+
const allow = type === 'select_option'
|
|
2515
|
+
? command.index === 0
|
|
2516
|
+
: command.value === 'y' || command.value === 'yes';
|
|
2517
|
+
resolvePending(ov.requestId, allow ? 'allow' : 'deny');
|
|
2518
|
+
}
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
debug('daemon', `observed steering: unsupported command ${type} for ${uuid}`);
|
|
2522
|
+
}
|
|
840
2523
|
core.wsServer.onCommand((cmd) => {
|
|
841
2524
|
debug('daemon', `cmd: ${cmd.type}`);
|
|
842
2525
|
if (gatewayAdapter?.isAlive() && gatewayAdapter.handleCommand(cmd)) {
|
|
@@ -860,26 +2543,27 @@ export async function startDaemon(opts) {
|
|
|
860
2543
|
return;
|
|
861
2544
|
}
|
|
862
2545
|
if (cmd.type === 'switch_agent') {
|
|
2546
|
+
userFocusedSessionId = null;
|
|
863
2547
|
focusRelay.unfocus(); // Clear session focus on agent switch
|
|
864
2548
|
const target = cmd.agent;
|
|
865
2549
|
if (target === 'openclaw' && gatewayAdapter?.isAlive()) {
|
|
866
2550
|
// Force broadcast OpenClaw state to all clients
|
|
867
2551
|
const snap = core.stateMachine.getSnapshot();
|
|
868
|
-
const gwStateEvent = core.buildStateEvent({
|
|
2552
|
+
const gwStateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
869
2553
|
agentType: 'openclaw',
|
|
870
2554
|
agentCapabilities: OPENCLAW_CAPABILITIES,
|
|
871
2555
|
snapshot: snap,
|
|
872
|
-
});
|
|
2556
|
+
}));
|
|
873
2557
|
lastStateEvent = gwStateEvent;
|
|
874
2558
|
core.wsServer.broadcast(gwStateEvent);
|
|
875
2559
|
}
|
|
876
2560
|
else if (target === 'claude-code') {
|
|
877
2561
|
// Broadcast daemon/claude-code state — clients reconnect to session bridges independently
|
|
878
2562
|
const snap = core.stateMachine.getSnapshot();
|
|
879
|
-
const stateEvent = core.buildStateEvent({
|
|
2563
|
+
const stateEvent = attachFocusedSessionId(core.buildStateEvent({
|
|
880
2564
|
agentType: 'daemon',
|
|
881
2565
|
snapshot: snap,
|
|
882
|
-
});
|
|
2566
|
+
}));
|
|
883
2567
|
lastStateEvent = stateEvent;
|
|
884
2568
|
core.wsServer.broadcast(stateEvent);
|
|
885
2569
|
}
|
|
@@ -889,14 +2573,121 @@ export async function startDaemon(opts) {
|
|
|
889
2573
|
const sessionId = cmd.sessionId;
|
|
890
2574
|
if (!sessionId)
|
|
891
2575
|
return;
|
|
2576
|
+
userFocusedSessionId = sessionId;
|
|
2577
|
+
broadcastFocusedState();
|
|
2578
|
+
if (sessionId === 'openclaw-gateway' && gatewayAdapter?.isAlive()) {
|
|
2579
|
+
focusRelay.unfocus();
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
892
2582
|
focusRelay.focus(sessionId);
|
|
893
2583
|
return;
|
|
894
2584
|
}
|
|
2585
|
+
if (cmd.type === 'clear_session_focus') {
|
|
2586
|
+
userFocusedSessionId = null;
|
|
2587
|
+
focusRelay.unfocus();
|
|
2588
|
+
broadcastFocusedState();
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
// Independent on-demand review (REVIEW deck button): judge the session's
|
|
2592
|
+
// working-tree delta with an independent model. Valid for every session
|
|
2593
|
+
// type — no agent control involved. Result: WS events + badge fields +
|
|
2594
|
+
// HTML report opened in the browser (this daemon's "popup" tier).
|
|
2595
|
+
if (cmd.type === 'review_run') {
|
|
2596
|
+
const sessionId = cmd.sessionId;
|
|
2597
|
+
if (!sessionId)
|
|
2598
|
+
return;
|
|
2599
|
+
const managed = listActiveSessions().find(s => s.id === sessionId);
|
|
2600
|
+
const observed = managed ? undefined
|
|
2601
|
+
: passiveSessionObserver.collect([]).find(s => s.id === sessionId);
|
|
2602
|
+
const target = (managed ?? observed);
|
|
2603
|
+
// cwd sources: observed rows carry it from the process scan; managed
|
|
2604
|
+
// registry rows don't — fall back to the session's APME run projectPath.
|
|
2605
|
+
const cwd = (typeof target?.cwd === 'string' && target.cwd) ? target.cwd
|
|
2606
|
+
: (apme ? (() => {
|
|
2607
|
+
const runId = apme.collector.getRunId(sessionId);
|
|
2608
|
+
return runId ? apme.store.getRun(runId)?.projectPath ?? undefined : undefined;
|
|
2609
|
+
})() : undefined);
|
|
2610
|
+
// Recent request↔response lines so the judge evaluates the diff
|
|
2611
|
+
// against what the user actually asked (not just code in isolation).
|
|
2612
|
+
const recentActivity = core.bridgeTimeline.getHistoryForSession(sessionId, undefined, 16)
|
|
2613
|
+
.filter((e) => e.type === 'chat_start' || e.type === 'chat_response')
|
|
2614
|
+
.map((e) => `${e.type === 'chat_start' ? 'USER' : 'AGENT'}: ${e.raw}`)
|
|
2615
|
+
.join('\n');
|
|
2616
|
+
void runSessionReview({
|
|
2617
|
+
sessionId,
|
|
2618
|
+
cwd,
|
|
2619
|
+
projectName: target?.projectName ?? cwd?.split('/').filter(Boolean).pop() ?? 'unknown',
|
|
2620
|
+
recentActivity,
|
|
2621
|
+
onEvent: (event) => {
|
|
2622
|
+
core.wsServer.broadcast(event);
|
|
2623
|
+
core.broadcastSessionsList().catch(() => { }); // badge refresh
|
|
2624
|
+
},
|
|
2625
|
+
recordEval: apme ? (record) => {
|
|
2626
|
+
// Record into the same eval store as the automatic pipeline,
|
|
2627
|
+
// flagged `manual_review`, on the session's active task (if any).
|
|
2628
|
+
const runId = apme.collector.getRunId(sessionId);
|
|
2629
|
+
const taskId = apme.collector.getActiveTaskId(sessionId);
|
|
2630
|
+
if (!runId || !taskId)
|
|
2631
|
+
return;
|
|
2632
|
+
apme.store.insertEvalForTask({
|
|
2633
|
+
runId, taskId, layer: 'manual_review', metric: 'risk',
|
|
2634
|
+
score: record.score, raw: record.raw,
|
|
2635
|
+
judgeModel: record.judgeModel, createdAt: Date.now(),
|
|
2636
|
+
});
|
|
2637
|
+
} : undefined,
|
|
2638
|
+
});
|
|
2639
|
+
core.broadcastSessionsList().catch(() => { }); // REVIEWING tile
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
// Device approval for a gated PreToolUse permission request (observed session).
|
|
2643
|
+
// Resolves the held hook HTTP response → Claude allows/denies the tool.
|
|
2644
|
+
if (cmd.type === 'permission_decision') {
|
|
2645
|
+
const { requestId, decision } = cmd;
|
|
2646
|
+
if (typeof requestId !== 'string' || (decision !== 'allow' && decision !== 'deny'))
|
|
2647
|
+
return;
|
|
2648
|
+
// OpenCode permission (requestId = "ocperm:<rawSid>:<permissionID>"):
|
|
2649
|
+
// route through the observer-plugin queue; the plugin resolves it via
|
|
2650
|
+
// POST /session/{id}/permissions/{permissionID}.
|
|
2651
|
+
if (requestId.startsWith('ocperm:')) {
|
|
2652
|
+
const rest = requestId.slice('ocperm:'.length);
|
|
2653
|
+
const sep = rest.indexOf(':');
|
|
2654
|
+
if (sep > 0) {
|
|
2655
|
+
enqueueOpenCodeCommand(rest.slice(0, sep), {
|
|
2656
|
+
type: 'permission_respond', permissionId: rest.slice(sep + 1), response: decision,
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
// resolvePending → onResolved clears the overlay + rebroadcasts (sessions_list
|
|
2662
|
+
// + focused state), so all surfaces drop the gate. No-op if already resolved.
|
|
2663
|
+
resolvePending(requestId, decision);
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
895
2666
|
// Session-scoped command: forward inner command to a specific session's bridge
|
|
896
2667
|
if (cmd.type === 'session_command') {
|
|
897
2668
|
const { sessionId, command } = cmd;
|
|
898
2669
|
if (!sessionId || !command)
|
|
899
2670
|
return;
|
|
2671
|
+
// Observed Claude sessions have no bridge/PTY — route to the hook-based
|
|
2672
|
+
// steering primitives instead (soft STOP, turn-end directive queue,
|
|
2673
|
+
// gate approval). This replaces the old silent drop.
|
|
2674
|
+
if (typeof sessionId === 'string' && sessionId.startsWith('observed:claude:')) {
|
|
2675
|
+
handleObservedClaudeCommand(sessionId.slice('observed:claude:'.length), command);
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2678
|
+
// Observed OpenCode sessions steer directly through the observer
|
|
2679
|
+
// plugin's command queue (immediate abort / prompt injection).
|
|
2680
|
+
if (typeof sessionId === 'string' && (sessionId.startsWith('opencode:') || sessionId.startsWith('observed:opencode:'))) {
|
|
2681
|
+
const rawSid = sessionId.replace(/^(?:observed:)?opencode:/, '');
|
|
2682
|
+
const type = typeof command?.type === 'string' ? command.type : '';
|
|
2683
|
+
if (type === 'interrupt' || type === 'escape') {
|
|
2684
|
+
enqueueOpenCodeCommand(rawSid, { type: 'interrupt' });
|
|
2685
|
+
}
|
|
2686
|
+
else if (type === 'send_prompt' && typeof command.text === 'string') {
|
|
2687
|
+
enqueueOpenCodeCommand(rawSid, { type: 'send_prompt', text: command.text });
|
|
2688
|
+
}
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
900
2691
|
const sessions = listActiveSessions();
|
|
901
2692
|
const target = sessions.find(s => s.id === sessionId);
|
|
902
2693
|
if (!target) {
|
|
@@ -904,15 +2695,40 @@ export async function startDaemon(opts) {
|
|
|
904
2695
|
return;
|
|
905
2696
|
}
|
|
906
2697
|
// Focus the session first, then route the command
|
|
2698
|
+
userFocusedSessionId = sessionId;
|
|
2699
|
+
broadcastFocusedState();
|
|
907
2700
|
focusRelay.focus(sessionId);
|
|
908
2701
|
// Small delay to let focus take effect, then route
|
|
909
2702
|
setTimeout(() => focusRelay.routeCommand(command), 100);
|
|
910
2703
|
return;
|
|
911
2704
|
}
|
|
2705
|
+
// Session-scoped option select from a multi-up panel (IPS10 D1 mosaic):
|
|
2706
|
+
// any awaiting cell can be answered, not just the focused one. Focus the
|
|
2707
|
+
// named session, then route a plain select_option to its bridge.
|
|
2708
|
+
if (cmd.type === 'select_option' && typeof cmd.sessionId === 'string') {
|
|
2709
|
+
const sessionId = cmd.sessionId;
|
|
2710
|
+
const target = listActiveSessions().find(s => s.id === sessionId);
|
|
2711
|
+
if (target) {
|
|
2712
|
+
userFocusedSessionId = sessionId;
|
|
2713
|
+
broadcastFocusedState();
|
|
2714
|
+
focusRelay.focus(sessionId);
|
|
2715
|
+
setTimeout(() => focusRelay.routeCommand({ type: 'select_option', index: cmd.index }), 100);
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
912
2719
|
// Route interactive commands to focused session (if any)
|
|
913
2720
|
if (focusRelay.getFocusedSessionId() && focusRelay.routeCommand(cmd)) {
|
|
914
2721
|
return;
|
|
915
2722
|
}
|
|
2723
|
+
// Bare-command fallback for an observed-focused session: older device
|
|
2724
|
+
// clients send unwrapped commands after focus_session. The focus relay
|
|
2725
|
+
// can't route them (no bridge), so map them to the steering primitives
|
|
2726
|
+
// instead of dropping them silently.
|
|
2727
|
+
if (userFocusedSessionId?.startsWith('observed:claude:')
|
|
2728
|
+
&& ['interrupt', 'escape', 'send_prompt', 'respond', 'select_option'].includes(cmd.type)) {
|
|
2729
|
+
handleObservedClaudeCommand(userFocusedSessionId.slice('observed:claude:'.length), cmd);
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
916
2732
|
if (cmd.type === 'query_usage') {
|
|
917
2733
|
fetchUsageRelayed(port).then((usage) => {
|
|
918
2734
|
if (usage)
|
|
@@ -944,6 +2760,9 @@ export async function startDaemon(opts) {
|
|
|
944
2760
|
});
|
|
945
2761
|
}
|
|
946
2762
|
});
|
|
2763
|
+
core.wsServer.onClientDisconnect((ws) => {
|
|
2764
|
+
unregisterWifiEsp32Socket(ws);
|
|
2765
|
+
});
|
|
947
2766
|
// ===== Probes & polling =====
|
|
948
2767
|
core.startOllamaProbe();
|
|
949
2768
|
core.startMlxProbe();
|
|
@@ -962,7 +2781,39 @@ export async function startDaemon(opts) {
|
|
|
962
2781
|
const { classifyRunSmart } = await import('./apme/classifier.js');
|
|
963
2782
|
const { aggregateOverall } = await import('./apme/http.js');
|
|
964
2783
|
// Broadcast eval results to all WS clients + timeline when runner completes
|
|
965
|
-
apme.runner.onResult(({ runId, turnId }) => {
|
|
2784
|
+
apme.runner.onResult(({ runId, turnId, taskId, layer1Ran, layer2Ran, overall }) => {
|
|
2785
|
+
// Task-level eval: a group of turns between boundary signals
|
|
2786
|
+
// (TodoWrite all-completed / /clear / session_end). Distinct timeline
|
|
2787
|
+
// entry so users see "★ task 85%" rather than another run-level pulse.
|
|
2788
|
+
// The task's summary axis is the most user-readable signal and goes
|
|
2789
|
+
// into `detail` ahead of the per-axis breakdown.
|
|
2790
|
+
if (taskId) {
|
|
2791
|
+
const run = apme.store.getRun(runId);
|
|
2792
|
+
const task = apme.store.getTask(taskId);
|
|
2793
|
+
if (!run || !task)
|
|
2794
|
+
return;
|
|
2795
|
+
const taskEvals = apme.store.listEvalsForTask(taskId);
|
|
2796
|
+
const overallEval = taskEvals.find(e => e.metric === 'overall');
|
|
2797
|
+
if (!overallEval)
|
|
2798
|
+
return;
|
|
2799
|
+
const taskEvalEvent = {
|
|
2800
|
+
type: 'apme_eval',
|
|
2801
|
+
run: {
|
|
2802
|
+
runId: run.id, sessionId: run.sessionId, agentType: run.agentType, startedAt: run.startedAt,
|
|
2803
|
+
modelId: run.modelId ?? undefined, projectName: run.projectName ?? undefined,
|
|
2804
|
+
taskPrompt: run.taskPrompt ?? undefined, taskCategory: run.taskCategory ?? undefined,
|
|
2805
|
+
outcome: 'committed',
|
|
2806
|
+
compositeScore: overallEval.score,
|
|
2807
|
+
overallScore: overallEval.score,
|
|
2808
|
+
evals: taskEvals.map(e => ({
|
|
2809
|
+
layer: e.layer, metric: e.metric, score: e.score,
|
|
2810
|
+
judgeModel: e.judgeModel ?? undefined, createdAt: e.createdAt,
|
|
2811
|
+
})),
|
|
2812
|
+
},
|
|
2813
|
+
};
|
|
2814
|
+
core.broadcast(taskEvalEvent);
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
966
2817
|
// Turn-level eval: broadcast and add timeline entry with turn score
|
|
967
2818
|
if (turnId) {
|
|
968
2819
|
const run = apme.store.getRun(runId);
|
|
@@ -981,7 +2832,6 @@ export async function startDaemon(opts) {
|
|
|
981
2832
|
});
|
|
982
2833
|
}
|
|
983
2834
|
catch { /* ignore */ }
|
|
984
|
-
const pct = Math.round(overall.score * 100);
|
|
985
2835
|
// WS broadcast — reuse apme_eval event for turn eval so dashboards pick it up
|
|
986
2836
|
const turnEvalEvent = {
|
|
987
2837
|
type: 'apme_eval',
|
|
@@ -999,30 +2849,6 @@ export async function startDaemon(opts) {
|
|
|
999
2849
|
},
|
|
1000
2850
|
};
|
|
1001
2851
|
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
2852
|
return;
|
|
1027
2853
|
}
|
|
1028
2854
|
const run = apme.store.getRun(runId);
|
|
@@ -1030,6 +2856,10 @@ export async function startDaemon(opts) {
|
|
|
1030
2856
|
return;
|
|
1031
2857
|
const evals = apme.store.listEvalsForRun(runId);
|
|
1032
2858
|
const overallScore = aggregateOverall(evals);
|
|
2859
|
+
if (!layer1Ran && !layer2Ran && overall === undefined && evals.length === 0) {
|
|
2860
|
+
debug('APME', `skip run eval timeline for ${runId.slice(0, 8)} — no eval rows produced`);
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
1033
2863
|
// WS broadcast: apme_eval event (type already in protocol.ts)
|
|
1034
2864
|
const evalEvent = {
|
|
1035
2865
|
type: 'apme_eval',
|
|
@@ -1047,34 +2877,6 @@ export async function startDaemon(opts) {
|
|
|
1047
2877
|
},
|
|
1048
2878
|
};
|
|
1049
2879
|
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
2880
|
});
|
|
1079
2881
|
const apmeEvalTimer = setInterval(() => {
|
|
1080
2882
|
// 1. Enqueue unevaluated runs for deterministic + judge
|
|
@@ -1145,24 +2947,38 @@ export async function startDaemon(opts) {
|
|
|
1145
2947
|
}
|
|
1146
2948
|
});
|
|
1147
2949
|
}, 10_000));
|
|
2950
|
+
// Backstop sweep for held PreToolUse approval responses whose per-entry timer
|
|
2951
|
+
// somehow didn't fire — resolves anything older than 60s to "ask" so a held
|
|
2952
|
+
// socket can't leak and Claude's own prompt isn't lost forever.
|
|
2953
|
+
const permissionSweepTimer = setInterval(() => { sweepStalePending(60_000); }, 30_000);
|
|
2954
|
+
permissionSweepTimer.unref?.();
|
|
1148
2955
|
// ===== Shutdown =====
|
|
1149
2956
|
core.onShutdown(async () => {
|
|
2957
|
+
clearInterval(permissionSweepTimer);
|
|
2958
|
+
drainAllPending();
|
|
1150
2959
|
removeDaemonInfo();
|
|
1151
2960
|
focusRelay.stop();
|
|
1152
2961
|
timelineRelay.stop();
|
|
1153
2962
|
voiceAssistant?.stop();
|
|
1154
2963
|
voiceManager?.disconnectFromServer();
|
|
1155
2964
|
bridgeLogStream.stop();
|
|
2965
|
+
// Flush synchronously — the process exits a few lines below and a pending
|
|
2966
|
+
// debounce timer would take the last turn's entries with it.
|
|
2967
|
+
core.bridgeTimeline.stopPersistence();
|
|
2968
|
+
// iDotMatrix BLE sync is stopped by IDotMatrixModule.stop() via stopModules below.
|
|
1156
2969
|
await Promise.all([
|
|
1157
2970
|
gatewayAdapter ? gatewayAdapter.shutdown().catch(() => { }) : Promise.resolve(),
|
|
1158
2971
|
stopModules(startedModules)
|
|
1159
2972
|
]);
|
|
1160
2973
|
gatewayAdapter = null;
|
|
1161
|
-
httpServer.close(() =>
|
|
2974
|
+
httpServer.close(() => exitProcessNow(0));
|
|
1162
2975
|
// Force exit if httpServer.close() hangs on CLOSE_WAIT connections
|
|
1163
|
-
setTimeout(() =>
|
|
2976
|
+
setTimeout(() => exitProcessNow(0), 5000).unref();
|
|
1164
2977
|
});
|
|
1165
2978
|
core.registerProcessHandlers('agentdeck');
|
|
2979
|
+
// Trigger initial state broadcast so display hardware modules (D200H, Pixoo, Serial) get populated
|
|
2980
|
+
broadcastFocusedState();
|
|
2981
|
+
core.broadcastUsage();
|
|
1166
2982
|
log(`[agentdeck] Daemon running. Gateway probe active.`);
|
|
1167
2983
|
}
|
|
1168
2984
|
//# sourceMappingURL=daemon-server.js.map
|