@agentdeck/bridge 1.1.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -6
- package/assets/fonts/NOTICE-PocketSansWorld.md +10 -0
- package/assets/fonts/OFL-PocketSansWorld.txt +93 -0
- package/assets/fonts/PocketSansWorld_12.cpfont +0 -0
- package/assets/fonts/pocket-sans-world.manifest.json +49 -0
- package/assets/learning/LICENSE-CC-BY-SA-4.0.txt +10 -0
- package/assets/learning/NOTICE.md +33 -0
- package/assets/learning/jp-n3-ko.manifest.json +27 -0
- package/assets/learning/jp-n3-ko.pdl +0 -0
- package/dist/agent-cli-diagnostics.d.ts +20 -0
- package/dist/agent-cli-diagnostics.d.ts.map +1 -0
- package/dist/agent-cli-diagnostics.js +100 -0
- package/dist/agent-cli-diagnostics.js.map +1 -0
- package/dist/apme/classifier.d.ts.map +1 -1
- package/dist/apme/classifier.js +18 -10
- package/dist/apme/classifier.js.map +1 -1
- package/dist/apme/claude-transcript-reader.d.ts +36 -0
- package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
- package/dist/apme/claude-transcript-reader.js +78 -2
- package/dist/apme/claude-transcript-reader.js.map +1 -1
- package/dist/apme/collector.d.ts +126 -3
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +450 -41
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/dashboard-html.d.ts.map +1 -1
- package/dist/apme/dashboard-html.js +278 -36
- package/dist/apme/dashboard-html.js.map +1 -1
- package/dist/apme/graph.d.ts +1 -1
- package/dist/apme/graph.d.ts.map +1 -1
- package/dist/apme/graph.js +51 -1
- package/dist/apme/graph.js.map +1 -1
- package/dist/apme/http.d.ts.map +1 -1
- package/dist/apme/http.js +37 -5
- package/dist/apme/http.js.map +1 -1
- package/dist/apme/index.d.ts +4 -0
- package/dist/apme/index.d.ts.map +1 -1
- package/dist/apme/index.js +19 -3
- package/dist/apme/index.js.map +1 -1
- package/dist/apme/runner.d.ts +90 -0
- package/dist/apme/runner.d.ts.map +1 -1
- package/dist/apme/runner.js +524 -48
- package/dist/apme/runner.js.map +1 -1
- package/dist/apme/sample-to-timeline.d.ts.map +1 -1
- package/dist/apme/sample-to-timeline.js +5 -0
- package/dist/apme/sample-to-timeline.js.map +1 -1
- package/dist/apme/settings.d.ts +8 -0
- package/dist/apme/settings.d.ts.map +1 -1
- package/dist/apme/settings.js +4 -0
- package/dist/apme/settings.js.map +1 -1
- package/dist/apme/store.d.ts +118 -9
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +342 -44
- package/dist/apme/store.js.map +1 -1
- package/dist/apme/task-gradeability.d.ts +82 -0
- package/dist/apme/task-gradeability.d.ts.map +1 -0
- package/dist/apme/task-gradeability.js +126 -0
- package/dist/apme/task-gradeability.js.map +1 -0
- package/dist/awaiting-overlay.d.ts +13 -26
- package/dist/awaiting-overlay.d.ts.map +1 -1
- package/dist/awaiting-overlay.js +7 -40
- package/dist/awaiting-overlay.js.map +1 -1
- package/dist/bridge-core.d.ts +15 -0
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +30 -4
- package/dist/bridge-core.js.map +1 -1
- package/dist/claude-permission-rules.d.ts +20 -32
- package/dist/claude-permission-rules.d.ts.map +1 -1
- package/dist/claude-permission-rules.js +19 -102
- package/dist/claude-permission-rules.js.map +1 -1
- package/dist/claude-usage-recovery.d.ts +54 -0
- package/dist/claude-usage-recovery.d.ts.map +1 -0
- package/dist/claude-usage-recovery.js +200 -0
- package/dist/claude-usage-recovery.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +81 -14
- package/dist/cli.js.map +1 -1
- package/dist/codex-rate-limits-live.d.ts +180 -10
- package/dist/codex-rate-limits-live.d.ts.map +1 -1
- package/dist/codex-rate-limits-live.js +475 -22
- package/dist/codex-rate-limits-live.js.map +1 -1
- package/dist/codex-rollout-response.d.ts +43 -0
- package/dist/codex-rollout-response.d.ts.map +1 -1
- package/dist/codex-rollout-response.js +67 -0
- package/dist/codex-rollout-response.js.map +1 -1
- package/dist/coordination-evidence.d.ts +150 -0
- package/dist/coordination-evidence.d.ts.map +1 -0
- package/dist/coordination-evidence.js +455 -0
- package/dist/coordination-evidence.js.map +1 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +592 -35
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon-ws-client.d.ts +1 -0
- package/dist/daemon-ws-client.d.ts.map +1 -1
- package/dist/daemon-ws-client.js +25 -16
- package/dist/daemon-ws-client.js.map +1 -1
- package/dist/daemon.js +1 -1
- package/dist/deferred-reply-read.d.ts +24 -0
- package/dist/deferred-reply-read.d.ts.map +1 -0
- package/dist/deferred-reply-read.js +42 -0
- package/dist/deferred-reply-read.js.map +1 -0
- package/dist/esp32-serial.d.ts +15 -0
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +61 -2
- package/dist/esp32-serial.js.map +1 -1
- package/dist/font-pack.d.ts +15 -0
- package/dist/font-pack.d.ts.map +1 -0
- package/dist/font-pack.js +75 -0
- package/dist/font-pack.js.map +1 -0
- package/dist/glance-frame.d.ts.map +1 -1
- package/dist/glance-frame.js +2 -0
- package/dist/glance-frame.js.map +1 -1
- package/dist/hook-codex-sessions.d.ts +9 -0
- package/dist/hook-codex-sessions.d.ts.map +1 -1
- package/dist/hook-codex-sessions.js +35 -2
- package/dist/hook-codex-sessions.js.map +1 -1
- package/dist/hook-opencode-sessions.d.ts +62 -0
- package/dist/hook-opencode-sessions.d.ts.map +1 -0
- package/dist/hook-opencode-sessions.js +180 -0
- package/dist/hook-opencode-sessions.js.map +1 -0
- package/dist/hook-response-source.d.ts +40 -0
- package/dist/hook-response-source.d.ts.map +1 -0
- package/dist/hook-response-source.js +31 -0
- package/dist/hook-response-source.js.map +1 -0
- package/dist/http-auth-gate.d.ts.map +1 -1
- package/dist/http-auth-gate.js +9 -1
- package/dist/http-auth-gate.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/learning-pack.d.ts +24 -0
- package/dist/learning-pack.d.ts.map +1 -0
- package/dist/learning-pack.js +125 -0
- package/dist/learning-pack.js.map +1 -0
- package/dist/mdns.js +1 -1
- package/dist/mdns.js.map +1 -1
- package/dist/native-dependency-diagnostics.d.ts +29 -0
- package/dist/native-dependency-diagnostics.d.ts.map +1 -0
- package/dist/native-dependency-diagnostics.js +95 -0
- package/dist/native-dependency-diagnostics.js.map +1 -0
- package/dist/observed-steering.d.ts +4 -4
- package/dist/observed-steering.d.ts.map +1 -1
- package/dist/observed-steering.js +28 -32
- package/dist/observed-steering.js.map +1 -1
- package/dist/openclaw-gateway-stability.d.ts +94 -0
- package/dist/openclaw-gateway-stability.d.ts.map +1 -0
- package/dist/openclaw-gateway-stability.js +176 -0
- package/dist/openclaw-gateway-stability.js.map +1 -0
- package/dist/pairing-knocks.d.ts +80 -0
- package/dist/pairing-knocks.d.ts.map +1 -0
- package/dist/pairing-knocks.js +312 -0
- package/dist/pairing-knocks.js.map +1 -0
- package/dist/passive-observer.d.ts +5 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +6 -0
- package/dist/passive-observer.js.map +1 -1
- package/dist/pixoo/micro-glyphs.d.ts +4 -0
- package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
- package/dist/pixoo/micro-glyphs.js +29 -0
- package/dist/pixoo/micro-glyphs.js.map +1 -1
- package/dist/pixoo/pixoo-font.d.ts.map +1 -1
- package/dist/pixoo/pixoo-font.js +3 -1
- package/dist/pixoo/pixoo-font.js.map +1 -1
- package/dist/pixoo/pixoo-renderer.d.ts +1 -1
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +17 -2
- package/dist/pixoo/pixoo-renderer.js.map +1 -1
- package/dist/pty-manager.d.ts +9 -0
- package/dist/pty-manager.d.ts.map +1 -1
- package/dist/pty-manager.js +63 -4
- package/dist/pty-manager.js.map +1 -1
- package/dist/relayed-usage.d.ts +30 -0
- package/dist/relayed-usage.d.ts.map +1 -1
- package/dist/relayed-usage.js +25 -2
- package/dist/relayed-usage.js.map +1 -1
- package/dist/review-runner.js +1 -1
- package/dist/subagent-timeline.d.ts +27 -0
- package/dist/subagent-timeline.d.ts.map +1 -1
- package/dist/subagent-timeline.js +52 -5
- package/dist/subagent-timeline.js.map +1 -1
- package/dist/surface-protocol.d.ts.map +1 -1
- package/dist/surface-protocol.js +2 -0
- package/dist/surface-protocol.js.map +1 -1
- package/dist/tui/dashboard.d.ts.map +1 -1
- package/dist/tui/dashboard.js +8 -1
- package/dist/tui/dashboard.js.map +1 -1
- package/dist/usage-api.d.ts +3 -0
- package/dist/usage-api.d.ts.map +1 -1
- package/dist/usage-api.js +101 -36
- package/dist/usage-api.js.map +1 -1
- package/dist/usage-event.d.ts.map +1 -1
- package/dist/usage-event.js +2 -1
- package/dist/usage-event.js.map +1 -1
- package/dist/ws-server.d.ts +18 -0
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +40 -2
- package/dist/ws-server.js.map +1 -1
- package/package.json +6 -5
- package/src/timebox/sync_ble.py +35 -16
package/dist/daemon-server.js
CHANGED
|
@@ -24,6 +24,8 @@ import { PassiveSessionObserver } from './passive-observer.js';
|
|
|
24
24
|
import { SessionTimelineRelay } from './session-timeline-relay.js';
|
|
25
25
|
import { SessionFocusRelay } from './session-focus-relay.js';
|
|
26
26
|
import { SubagentTimelineTracker } from './subagent-timeline.js';
|
|
27
|
+
import { CoordinationTracker } from './coordination-evidence.js';
|
|
28
|
+
import { HookOpenCodeSessions } from './hook-opencode-sessions.js';
|
|
27
29
|
import { getRemoteSession, getRemoteSender, listRemoteEnriched, } from './remote-sessions.js';
|
|
28
30
|
import { createPushChannelHandler } from './session-push-channel.js';
|
|
29
31
|
import { setAwaitingOverlay, setPermissionNotificationOverlay, setAskUserQuestionOverlay, advanceAskUserQuestionOverlay, getAskUserQuestionAnswers, clearAwaitingOverlay, clearAskUserQuestionOverlay, getAwaitingOverlay, isPermissionNotification, applyAwaitingOverlayToObserved, } from './awaiting-overlay.js';
|
|
@@ -71,16 +73,32 @@ const ASK_GATE_HOLD_MS = Math.min(50_000, Math.max(5_000, Number(process.env.AGE
|
|
|
71
73
|
* invisible to this daemon, so only elapsed silence protects them (runs this
|
|
72
74
|
* daemon still holds are skipped via `collector.isLiveRun`). */
|
|
73
75
|
const APME_ABANDONED_RUN_STALE_SEC = Math.max(600, Number(process.env.AGENTDECK_APME_ABANDON_SEC) || 7200);
|
|
76
|
+
/** Backlog tasks handed to the judge per eval tick when it is idle — see the drain. */
|
|
77
|
+
const APME_TASK_JUDGE_DRAIN_PER_TICK = 1;
|
|
78
|
+
/** How many backlog candidates the drain looks at to find one it may feed.
|
|
79
|
+
* Much larger than the per-tick budget on purpose — see the drain for why.
|
|
80
|
+
* Reading rows is free (one indexed SELECT); only the judge call is serial.
|
|
81
|
+
* Sized so a run of consecutive poison tasks at the head cannot fill the whole
|
|
82
|
+
* window and starve the older backlog behind them the way one task did. */
|
|
83
|
+
const APME_TASK_JUDGE_DRAIN_WINDOW = 200;
|
|
84
|
+
/** How often a judge backend that last probed unavailable is probed again.
|
|
85
|
+
* The probe used to run once, at startup; a local MLX server busy with
|
|
86
|
+
* someone else's inference at that moment answered nothing inside the
|
|
87
|
+
* probe's 8 s, and the backlog drain — gated on that one answer — stayed
|
|
88
|
+
* off for the life of the process (2026-09-03, 289 tasks pending). */
|
|
89
|
+
const APME_JUDGE_REPROBE_MS = 5 * 60_000;
|
|
74
90
|
import { VoiceManager } from './voice.js';
|
|
75
91
|
import { VoiceAssistantManager } from './voice-assistant.js';
|
|
76
92
|
import { listActive as listActiveSessions, findAvailablePort, findExistingDaemon, probeDaemonHealth, requestDaemonShutdown, scanDaemonPortWindow, shouldConcedePortToOccupant, waitForDaemonExit, waitForPortBindable, writeDaemonInfo, ensureDaemonInfo, removeDaemonInfo, readDaemonInfo, removeDaemonSession, getCandidateDataDirs, getDataDir, getOwnTimelineFile, } from './session-registry.js';
|
|
77
93
|
import { isForeignDaemon } from './daemon-takeover.js';
|
|
78
94
|
import { loadDaemonSettings } from './daemon-settings.js';
|
|
79
95
|
import { resolveDaemonPort, describeDaemonPortSource, PREFERRED_PORT_RECLAIM_MS, } from './daemon-port.js';
|
|
80
|
-
import { fetchUsageFromApi, resetConsecutiveFailures } from './usage-api.js';
|
|
96
|
+
import { enableClaudeUsageRecovery, fetchUsageFromApi, resetConsecutiveFailures } from './usage-api.js';
|
|
97
|
+
import { stopClaudeUsageRecoveryChildren } from './claude-usage-recovery.js';
|
|
81
98
|
import { getOrCreateToken, isLocalConnection, validateToken } from './auth.js';
|
|
82
99
|
import { buildPublicHealth, gateHttpRequest, isAuthorizedHttpRequest } from './http-auth-gate.js';
|
|
83
100
|
import { closePairingWindow, getPairingWindowStatus, openPairingWindow, pairingWindowOpen, redeemPairingCode, } from './pairing-window.js';
|
|
101
|
+
import { approvePeer, dismissKnock, isApprovedPeer, pairingApprovals, pairingKnocks, recordKnock, revokePeer, } from './pairing-knocks.js';
|
|
84
102
|
import { getLastFrame, renderPreviewFrame, onFrameRendered, offFrameRendered } from './pixoo/pixoo-bridge.js';
|
|
85
103
|
import { loadIDotMatrixDevices } from './idotmatrix/idotmatrix-settings.js';
|
|
86
104
|
import { handlePixooWake } from './pixoo/pixoo-client.js';
|
|
@@ -89,9 +107,12 @@ import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
|
|
|
89
107
|
import { enableDebugLog, debug, debugThrottled } from './logger.js';
|
|
90
108
|
import { LegacyRearmLedger } from './legacy-rearm-ledger.js';
|
|
91
109
|
import { CodexOtelTracker, CODEX_OTEL_TRACES_PATH, spanNameSummary } from './codex-otel.js';
|
|
92
|
-
import { HookCodexSessions } from './hook-codex-sessions.js';
|
|
110
|
+
import { HookCodexSessions, buildCodexPermissionQuestion } from './hook-codex-sessions.js';
|
|
93
111
|
import { ObservedTurnWatchdogs } from './observed-turn-watchdogs.js';
|
|
94
|
-
import { initApme, isTimelineProjectionEnabled, loadApmeConfig } from './apme/index.js';
|
|
112
|
+
import { getApmeInitFailure, initApme, isTimelineProjectionEnabled, loadApmeConfig, } from './apme/index.js';
|
|
113
|
+
import { taskGradeability, retractUngradeableVerdicts } from './apme/task-gradeability.js';
|
|
114
|
+
import { resolveStopResponse } from './hook-response-source.js';
|
|
115
|
+
import { scheduleDeferredReplyRead } from './deferred-reply-read.js';
|
|
95
116
|
import { FallbackTaskTimeline } from './fallback-task-timeline.js';
|
|
96
117
|
import { handleApmeRequest } from './apme/http.js';
|
|
97
118
|
import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
|
|
@@ -101,7 +122,7 @@ import { KiroTimelineFeed } from './kiro-timeline-feed.js';
|
|
|
101
122
|
import { OpenClawTimelineFeed, OPENCLAW_FEED_INTERVAL_MS } from './openclaw-timeline-feed.js';
|
|
102
123
|
import { DeviceVoiceReplyRouter, speakableReply, spokenDigest, pcmFromWav, } from './device-voice-reply.js';
|
|
103
124
|
import { rawSessionId } from '@agentdeck/shared';
|
|
104
|
-
import { codexTurnOutcomeFromRollout, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
|
|
125
|
+
import { codexTurnOutcomeFromRollout, codexTurnOutcomeFromRolloutPath, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
|
|
105
126
|
import { callFoundationModelsHelper } from './foundation-models-helper.js';
|
|
106
127
|
import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
|
|
107
128
|
import { bindHostFor, daemonModuleConfigs, describeDaemonPosture, resolveDaemonPosture, } from './network-posture.js';
|
|
@@ -113,15 +134,18 @@ import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js
|
|
|
113
134
|
import { loadTimeboxDevices } from './timebox/timebox-settings.js';
|
|
114
135
|
import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
|
|
115
136
|
import { injectOpenClawSession, OPENCLAW_SESSION_ID } from './openclaw-session.js';
|
|
137
|
+
import { GatewayFlapTracker, GATEWAY_FLAP_WINDOW_MS, diagnoseGatewayLog, formatGatewayInstability, readGatewayLogTail, } from './openclaw-gateway-stability.js';
|
|
116
138
|
import { buildCardFeed, buildGlance, projectPortableReaderGlance, applyOutboxDecisions, FeedPullTracker, formatFeedPull, applyPullOtaBootstrap, OutboxIdempotencyLedger, normalizeClientIp, parsePullTelemetry, } from './card-feed.js';
|
|
117
139
|
import { threadModule } from './card-modules.js';
|
|
118
140
|
import { AutonomousPocketEngine, createAutonomousPocketModules, parsePocketAutonomyConfig, } from './pocket-autonomy.js';
|
|
119
141
|
import { WeatherProvider, parseWeatherSettings } from './weather.js';
|
|
120
142
|
import { CalendarProvider, parseCalendarSettings } from './calendar.js';
|
|
143
|
+
import { loadLearningPack, matchesLearningPackRequest } from './learning-pack.js';
|
|
144
|
+
import { loadFontPack, matchesFontPackRequest } from './font-pack.js';
|
|
121
145
|
import { renderGlanceFrame, GLANCE_FRAME_BOARDS } from './glance-frame.js';
|
|
122
146
|
import { isPortableReaderProfile, negotiateSurface, parseHttpSurfaceIdentity, pullOtaResponseStatus, surfaceErrorBody, surfaceAllowsEvent, SurfaceProtocolError, validateSurfaceOtaIdentity, validateSurfaceQueryTuple, usesPortableReaderProjection, } from './surface-protocol.js';
|
|
123
147
|
import { resolveRelayedUsageEvent } from './relayed-usage.js';
|
|
124
|
-
import { CARD_FEED_PATH, CARD_OUTBOX_PATH, GLANCE_FRAME_PATH } from '@agentdeck/shared';
|
|
148
|
+
import { CARD_FEED_PATH, CARD_OUTBOX_PATH, FONT_PACK_PATH, GLANCE_FRAME_PATH, LEARNING_PACK_PATH } from '@agentdeck/shared';
|
|
125
149
|
import { readFileSync, statSync, writeFileSync, appendFileSync } from 'fs';
|
|
126
150
|
import { readFile, rm } from 'fs/promises';
|
|
127
151
|
import { tmpdir, networkInterfaces } from 'os';
|
|
@@ -517,6 +541,8 @@ function registerWifiEsp32(d, ws) {
|
|
|
517
541
|
touchDownSamples: typeof d.touchDownSamples === 'number' ? d.touchDownSamples : undefined,
|
|
518
542
|
touchGestures: typeof d.touchGestures === 'number' ? d.touchGestures : undefined,
|
|
519
543
|
alsReady: typeof d.alsReady === 'boolean' ? d.alsReady : undefined,
|
|
544
|
+
repaintCount: typeof d.repaintCount === 'number' ? d.repaintCount : undefined,
|
|
545
|
+
fullRefreshCount: typeof d.fullRefreshCount === 'number' ? d.fullRefreshCount : undefined,
|
|
520
546
|
lastSeenMs: Date.now(),
|
|
521
547
|
});
|
|
522
548
|
wifiEsp32Sockets.set(key, ws);
|
|
@@ -952,12 +978,15 @@ export function classifyObservedHookEvent(eventName, mapped) {
|
|
|
952
978
|
if (eventName === 'codex_subagent_start' || eventName === 'codex_subagent_stop') {
|
|
953
979
|
return { boundary: eventName, agentType: 'codex-cli' };
|
|
954
980
|
}
|
|
955
|
-
const prefixed = /^(codex|opencode|antigravity|kiro|kiro_ide)_(agent_spawn|session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|notification|permission_asked|permission_replied)$/
|
|
981
|
+
const prefixed = /^(codex|opencode|antigravity|kiro|kiro_ide)_(agent_spawn|session_start|session_end|user_prompt_submit|tool_start|tool_end|stop|turn_complete|interrupt|notification|permission_request|permission_asked|permission_replied)$/
|
|
956
982
|
.exec(eventName);
|
|
957
983
|
if (!prefixed)
|
|
958
984
|
return { boundary: mapped, agentType: 'claude-code' };
|
|
959
985
|
return {
|
|
960
|
-
|
|
986
|
+
// `interrupt` (Codex Ctrl+C) is a turn END with no Stop coming, so it
|
|
987
|
+
// rides the stop boundary; the caller stamps `interrupted` on the payload
|
|
988
|
+
// so the collector records `end_source='interrupted'`.
|
|
989
|
+
boundary: prefixed[2] === 'turn_complete' || prefixed[2] === 'interrupt' ? 'stop'
|
|
961
990
|
: prefixed[2] === 'agent_spawn' ? 'session_start'
|
|
962
991
|
: prefixed[2],
|
|
963
992
|
agentType: prefixed[1] === 'codex' ? 'codex-cli'
|
|
@@ -1066,6 +1095,8 @@ function buildNodeModuleHealth(startedModules) {
|
|
|
1066
1095
|
sessionCount: status.sessionCount,
|
|
1067
1096
|
usageFiveH: status.usageFiveH,
|
|
1068
1097
|
processingCount: status.processingCount,
|
|
1098
|
+
repaintCount: status.repaintCount,
|
|
1099
|
+
fullRefreshCount: status.fullRefreshCount,
|
|
1069
1100
|
deviceInfoFresh: status.deviceInfoFresh,
|
|
1070
1101
|
} : null,
|
|
1071
1102
|
lastReadAt: status.lastReadAt,
|
|
@@ -1296,6 +1327,7 @@ export async function startDaemon(opts) {
|
|
|
1296
1327
|
// Codex sessions known only from `codex_*` hooks — the backstop for when the
|
|
1297
1328
|
// process scan can't see one (lsof timeout, no rollout held open).
|
|
1298
1329
|
const hookCodexSessions = new HookCodexSessions();
|
|
1330
|
+
const hookOpenCodeSessions = new HookOpenCodeSessions();
|
|
1299
1331
|
// Declared before the HTTP server: the PreToolUse route reads it to decide
|
|
1300
1332
|
// whether this daemon can type into a session's terminal, and hooks start
|
|
1301
1333
|
// arriving the moment the port binds — several hundred milliseconds before
|
|
@@ -1346,6 +1378,55 @@ export async function startDaemon(opts) {
|
|
|
1346
1378
|
// `gatewaySessionState` / `gatewayModelName` dedicated fields.
|
|
1347
1379
|
let gatewaySessionState = 'idle';
|
|
1348
1380
|
let gatewayModelName;
|
|
1381
|
+
// Restart-storm detector for the Gateway link. Each disconnect is a flap;
|
|
1382
|
+
// three inside ten minutes is an instability, and the reason is read off
|
|
1383
|
+
// OpenClaw's own log tail because nothing on this side can know it. The
|
|
1384
|
+
// finding rides `/status` + `/health` (`gateway.instability`) and one
|
|
1385
|
+
// `error` timeline row per escalation, so a link that keeps "recovering"
|
|
1386
|
+
// cannot pass for healthy on any surface. Cleared once the window drains.
|
|
1387
|
+
const gatewayFlaps = new GatewayFlapTracker();
|
|
1388
|
+
let gatewayInstability = null;
|
|
1389
|
+
let gatewayInstabilityAssessing = false;
|
|
1390
|
+
async function assessGatewayInstability(nowMs) {
|
|
1391
|
+
if (gatewayInstabilityAssessing)
|
|
1392
|
+
return;
|
|
1393
|
+
const a = gatewayFlaps.assess(nowMs);
|
|
1394
|
+
if (!a.unstable)
|
|
1395
|
+
return;
|
|
1396
|
+
// Escalate only when the count grows; a stable count is old news.
|
|
1397
|
+
if (gatewayInstability && gatewayInstability.flapsInWindow >= a.flapsInWindow)
|
|
1398
|
+
return;
|
|
1399
|
+
gatewayInstabilityAssessing = true;
|
|
1400
|
+
try {
|
|
1401
|
+
const diagnosis = diagnoseGatewayLog(await readGatewayLogTail());
|
|
1402
|
+
gatewayInstability = {
|
|
1403
|
+
flapsInWindow: a.flapsInWindow,
|
|
1404
|
+
windowMs: GATEWAY_FLAP_WINDOW_MS,
|
|
1405
|
+
since: a.since ?? nowMs,
|
|
1406
|
+
lastFlapAt: nowMs,
|
|
1407
|
+
diagnosis,
|
|
1408
|
+
};
|
|
1409
|
+
const line = formatGatewayInstability(gatewayInstability);
|
|
1410
|
+
log(`[agentdeck] ${line}${diagnosis ? ` — ${diagnosis.hint}` : ''}`);
|
|
1411
|
+
core.bridgeTimeline.addEntry({
|
|
1412
|
+
ts: nowMs, type: 'error',
|
|
1413
|
+
raw: line.slice(0, 197),
|
|
1414
|
+
detail: diagnosis ? `${diagnosis.hint}\n\n${diagnosis.line}` : undefined,
|
|
1415
|
+
sessionId: OPENCLAW_SESSION_ID,
|
|
1416
|
+
agentType: 'openclaw',
|
|
1417
|
+
});
|
|
1418
|
+
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
1419
|
+
}
|
|
1420
|
+
finally {
|
|
1421
|
+
gatewayInstabilityAssessing = false;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function settleGatewayInstability(nowMs) {
|
|
1425
|
+
if (gatewayInstability && !gatewayFlaps.assess(nowMs).unstable) {
|
|
1426
|
+
log('[agentdeck] OpenClaw Gateway stable again — instability cleared');
|
|
1427
|
+
gatewayInstability = null;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1349
1430
|
/**
|
|
1350
1431
|
* Snapshot for an `openclaw` state event: the global machine with the
|
|
1351
1432
|
* Gateway-local model pinned back on.
|
|
@@ -1429,6 +1510,32 @@ export async function startDaemon(opts) {
|
|
|
1429
1510
|
// Assigned after BridgeCore exists. The HTTP handler reads it only after
|
|
1430
1511
|
// startup; nullable keeps the tiny listen→core initialization window safe.
|
|
1431
1512
|
let subagentTimeline = null;
|
|
1513
|
+
// Cross-session coordination (spawned workers, peer messages, background
|
|
1514
|
+
// jobs) — the second census axis beside `subagents`. See coordination-evidence.ts.
|
|
1515
|
+
const coordination = new CoordinationTracker();
|
|
1516
|
+
// The learning pack is immutable for one daemon lifetime. Package upgrades
|
|
1517
|
+
// arrive with a new AgentDeck build; validating once keeps every sleeping
|
|
1518
|
+
// reader pull cheap while a corrupt/mislicensed bundle fails closed.
|
|
1519
|
+
let surfaceLearningPack = null;
|
|
1520
|
+
try {
|
|
1521
|
+
surfaceLearningPack = loadLearningPack();
|
|
1522
|
+
log(`[agentdeck] Surface learning pack ready: ${surfaceLearningPack.advert.id}`
|
|
1523
|
+
+ ` v${surfaceLearningPack.advert.version} (${surfaceLearningPack.advert.size} bytes,`
|
|
1524
|
+
+ ` ${surfaceLearningPack.advert.licenseSpdx})`);
|
|
1525
|
+
}
|
|
1526
|
+
catch (err) {
|
|
1527
|
+
log(`[agentdeck] Surface learning pack disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
1528
|
+
}
|
|
1529
|
+
let surfaceFontPack = null;
|
|
1530
|
+
try {
|
|
1531
|
+
surfaceFontPack = loadFontPack();
|
|
1532
|
+
log(`[agentdeck] Surface font pack ready: ${surfaceFontPack.advert.id}`
|
|
1533
|
+
+ ` v${surfaceFontPack.advert.version} (${surfaceFontPack.advert.size} bytes,`
|
|
1534
|
+
+ ` ${surfaceFontPack.advert.licenseSpdx})`);
|
|
1535
|
+
}
|
|
1536
|
+
catch (err) {
|
|
1537
|
+
log(`[agentdeck] Surface font pack disabled: ${err instanceof Error ? err.message : String(err)}`);
|
|
1538
|
+
}
|
|
1432
1539
|
// ===== HTTP server =====
|
|
1433
1540
|
const httpServer = createServer((req, res) => {
|
|
1434
1541
|
// LAN default-deny (issue #145): a request that is neither same-machine
|
|
@@ -1528,6 +1635,9 @@ export async function startDaemon(opts) {
|
|
|
1528
1635
|
res.end(JSON.stringify({
|
|
1529
1636
|
status: 'ok', mode: 'daemon', state: snap.state,
|
|
1530
1637
|
gateway: gatewayAdapter?.isAlive() ? 'connected' : 'disconnected',
|
|
1638
|
+
// A link that keeps reconnecting reads `connected` at every sample.
|
|
1639
|
+
// This is the field that says the samples were lying (null = stable).
|
|
1640
|
+
gatewayInstability,
|
|
1531
1641
|
uptime: process.uptime(), port, pid: process.pid,
|
|
1532
1642
|
// Which build is SERVING this port. Captured when this process started
|
|
1533
1643
|
// (see daemon-build-identity.ts) and never recomputed, so a rebuild
|
|
@@ -1553,7 +1663,10 @@ export async function startDaemon(opts) {
|
|
|
1553
1663
|
dbPath: apme.store.dbPath,
|
|
1554
1664
|
judgeBackend: apme.runner.lastBackendProbe ?? { status: 'unknown', backend: loadApmeConfig().judge.backend },
|
|
1555
1665
|
}
|
|
1556
|
-
: {
|
|
1666
|
+
: {
|
|
1667
|
+
enabled: false,
|
|
1668
|
+
error: getApmeInitFailure() ?? 'unknown init failure',
|
|
1669
|
+
},
|
|
1557
1670
|
}));
|
|
1558
1671
|
return;
|
|
1559
1672
|
}
|
|
@@ -1626,6 +1739,82 @@ export async function startDaemon(opts) {
|
|
|
1626
1739
|
res.end(JSON.stringify({ open: false }));
|
|
1627
1740
|
return;
|
|
1628
1741
|
}
|
|
1742
|
+
// The operator-approval half of the credential path. A code asks the DEVICE
|
|
1743
|
+
// to prove something; these ask the OPERATOR, and only the second one a
|
|
1744
|
+
// camera-less reader can complete. Like the window routes above they sit
|
|
1745
|
+
// BEHIND the normal auth gate — a remote peer able to approve itself would
|
|
1746
|
+
// be granting itself a credential.
|
|
1747
|
+
//
|
|
1748
|
+
// GET /pair/knocks → { knocks, approvals }
|
|
1749
|
+
// POST /pair/approve { key } → { approved: true }
|
|
1750
|
+
// POST /pair/revoke { key } → { revoked: bool }
|
|
1751
|
+
// POST /pair/dismiss { key } → { dismissed: bool }
|
|
1752
|
+
// The wire shape is the Swift daemon's, field for field: the Mac app in
|
|
1753
|
+
// CLIENT mode calls these routes against whichever daemon holds 9120, so a
|
|
1754
|
+
// shape of our own would break its approval UI on exactly the machines that
|
|
1755
|
+
// run both. `deviceId` is "" rather than null when a row is address-scoped
|
|
1756
|
+
// (the client maps "" back to nil), and every timestamp is epoch ms.
|
|
1757
|
+
if (req.method === 'GET' && pathname === '/pair/knocks') {
|
|
1758
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
1759
|
+
res.end(JSON.stringify({
|
|
1760
|
+
knocks: pairingKnocks().map((k) => ({
|
|
1761
|
+
key: k.key,
|
|
1762
|
+
ip: k.ip,
|
|
1763
|
+
deviceId: k.deviceId ?? '',
|
|
1764
|
+
attempts: k.attempts,
|
|
1765
|
+
firstSeen: k.firstSeen,
|
|
1766
|
+
lastSeen: k.lastSeen,
|
|
1767
|
+
staleToken: k.staleToken,
|
|
1768
|
+
})),
|
|
1769
|
+
approved: pairingApprovals().map((a) => ({
|
|
1770
|
+
key: a.key,
|
|
1771
|
+
deviceId: a.deviceId ?? '',
|
|
1772
|
+
lastIP: a.lastIP,
|
|
1773
|
+
approvedAt: a.approvedAt,
|
|
1774
|
+
})),
|
|
1775
|
+
}));
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
if (req.method === 'POST' && (pathname === '/pair/approve'
|
|
1779
|
+
|| pathname === '/pair/revoke' || pathname === '/pair/dismiss')) {
|
|
1780
|
+
void (async () => {
|
|
1781
|
+
let body = {};
|
|
1782
|
+
try {
|
|
1783
|
+
body = await readJsonBody(req, 4096);
|
|
1784
|
+
}
|
|
1785
|
+
catch { /* defaults */ }
|
|
1786
|
+
const key = typeof body.key === 'string' ? body.key : '';
|
|
1787
|
+
if (!key) {
|
|
1788
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1789
|
+
res.end(JSON.stringify({ error: 'key required' }));
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
1793
|
+
if (pathname === '/pair/approve') {
|
|
1794
|
+
approvePeer(key);
|
|
1795
|
+
log(`[agentdeck] Operator approved peer ${key}`);
|
|
1796
|
+
res.end(JSON.stringify({ approved: key }));
|
|
1797
|
+
}
|
|
1798
|
+
else if (pathname === '/pair/revoke') {
|
|
1799
|
+
revokePeer(key);
|
|
1800
|
+
log(`[agentdeck] Operator revoked peer ${key}`);
|
|
1801
|
+
res.end(JSON.stringify({ revoked: key }));
|
|
1802
|
+
}
|
|
1803
|
+
else {
|
|
1804
|
+
// Dismiss is silent, as in Swift — it is not an operator decision
|
|
1805
|
+
// about trust, just a row cleared off a list.
|
|
1806
|
+
dismissKnock(key);
|
|
1807
|
+
res.end(JSON.stringify({ dismissed: key }));
|
|
1808
|
+
}
|
|
1809
|
+
})().catch(() => {
|
|
1810
|
+
try {
|
|
1811
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1812
|
+
res.end(JSON.stringify({ error: 'could not update approvals' }));
|
|
1813
|
+
}
|
|
1814
|
+
catch { /* client gone */ }
|
|
1815
|
+
});
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1629
1818
|
if (req.method === 'GET' && pathname === '/status') {
|
|
1630
1819
|
const snap = core.stateMachine.getSnapshot();
|
|
1631
1820
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1637,6 +1826,7 @@ export async function startDaemon(opts) {
|
|
|
1637
1826
|
available: core.cachedGatewayAvailable,
|
|
1638
1827
|
connected: core.cachedGatewayConnected,
|
|
1639
1828
|
hasError: core.cachedGatewayHasError,
|
|
1829
|
+
instability: gatewayInstability,
|
|
1640
1830
|
},
|
|
1641
1831
|
clients: core.wsServer.getClientCount(),
|
|
1642
1832
|
modules: moduleHealthProvider(),
|
|
@@ -2024,6 +2214,138 @@ export async function startDaemon(opts) {
|
|
|
2024
2214
|
}
|
|
2025
2215
|
return;
|
|
2026
2216
|
}
|
|
2217
|
+
// ===== Licensed learning-pack delivery — portable-reader/v1 =====
|
|
2218
|
+
if (req.method === 'GET' && pathname === LEARNING_PACK_PATH) {
|
|
2219
|
+
const ip = req.socket.remoteAddress ?? '';
|
|
2220
|
+
let surfaceIdentity;
|
|
2221
|
+
try {
|
|
2222
|
+
surfaceIdentity = parseHttpSurfaceIdentity(req.headers, 'learning.pack.read');
|
|
2223
|
+
if (!surfaceIdentity) {
|
|
2224
|
+
throw new SurfaceProtocolError(400, 'surface_identity_required', 'Learning-pack delivery requires all Surface identity headers');
|
|
2225
|
+
}
|
|
2226
|
+
validateSurfaceQueryTuple(surfaceIdentity, parsedUrl.searchParams);
|
|
2227
|
+
if (surfaceIdentity.productId !== 'io.pocketdaily.reader') {
|
|
2228
|
+
throw new SurfaceProtocolError(409, 'surface_learning_product_mismatch', 'This learning pack is registered for Pocket Daily Reader');
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
catch (err) {
|
|
2232
|
+
if (err instanceof SurfaceProtocolError) {
|
|
2233
|
+
res.writeHead(err.status, { 'Content-Type': 'application/json' });
|
|
2234
|
+
res.end(JSON.stringify(surfaceErrorBody(err)));
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
throw err;
|
|
2238
|
+
}
|
|
2239
|
+
if (!surfaceLearningPack) {
|
|
2240
|
+
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
2241
|
+
res.end(JSON.stringify({ error: 'learning_pack_unavailable' }));
|
|
2242
|
+
return;
|
|
2243
|
+
}
|
|
2244
|
+
if (!matchesLearningPackRequest(surfaceLearningPack, parsedUrl.searchParams.get('id'), parsedUrl.searchParams.get('version'))) {
|
|
2245
|
+
res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
2246
|
+
res.end(JSON.stringify({ error: 'learning_pack_not_found' }));
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
|
|
2250
|
+
if (preferredIp) {
|
|
2251
|
+
const routePort = req.socket.localPort ?? 9120;
|
|
2252
|
+
log(`[agentdeck] Surface path redirect: learning-pack ${surfaceIdentity.board} (${ip}) via ${preferredIp}`);
|
|
2253
|
+
res.writeHead(307, {
|
|
2254
|
+
Location: `http://${preferredIp}:${routePort}${parsedUrl.pathname}${parsedUrl.search}`,
|
|
2255
|
+
'Cache-Control': 'no-store',
|
|
2256
|
+
'Connection': 'close',
|
|
2257
|
+
});
|
|
2258
|
+
res.end();
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
log(`[agentdeck] learning-pack download: ${surfaceIdentity.board} (${ip})`
|
|
2262
|
+
+ ` ← ${surfaceLearningPack.advert.id} v${surfaceLearningPack.advert.version}`);
|
|
2263
|
+
const requestedFrom = Number(parsedUrl.searchParams.get('from') ?? '0');
|
|
2264
|
+
const requestedLimitRaw = parsedUrl.searchParams.get('limit');
|
|
2265
|
+
const requestedLimit = requestedLimitRaw == null ? undefined : Number(requestedLimitRaw);
|
|
2266
|
+
const { from, body } = pullOtaSegment(surfaceLearningPack.bytes, requestedFrom, requestedLimit);
|
|
2267
|
+
res.writeHead(200, {
|
|
2268
|
+
'Content-Type': 'application/vnd.pocketdaily.learning-pack',
|
|
2269
|
+
'Content-Length': String(body.length),
|
|
2270
|
+
...(from > 0
|
|
2271
|
+
? { 'Content-Range': body.length > 0
|
|
2272
|
+
? `bytes ${from}-${from + body.length - 1}/${surfaceLearningPack.bytes.length}`
|
|
2273
|
+
: `bytes */${surfaceLearningPack.bytes.length}` }
|
|
2274
|
+
: {}),
|
|
2275
|
+
'X-Learning-Pack-MD5': surfaceLearningPack.advert.md5,
|
|
2276
|
+
'X-Learning-Pack-License': surfaceLearningPack.advert.licenseSpdx,
|
|
2277
|
+
'Cache-Control': 'no-store',
|
|
2278
|
+
'Connection': 'close',
|
|
2279
|
+
});
|
|
2280
|
+
res.end(body);
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
// ===== Licensed automatic font-pack delivery — portable-reader/v1 =====
|
|
2284
|
+
if (req.method === 'GET' && pathname === FONT_PACK_PATH) {
|
|
2285
|
+
const ip = req.socket.remoteAddress ?? '';
|
|
2286
|
+
let surfaceIdentity;
|
|
2287
|
+
try {
|
|
2288
|
+
surfaceIdentity = parseHttpSurfaceIdentity(req.headers, 'font.pack.read');
|
|
2289
|
+
if (!surfaceIdentity) {
|
|
2290
|
+
throw new SurfaceProtocolError(400, 'surface_identity_required', 'Font-pack delivery requires all Surface identity headers');
|
|
2291
|
+
}
|
|
2292
|
+
validateSurfaceQueryTuple(surfaceIdentity, parsedUrl.searchParams);
|
|
2293
|
+
if (surfaceIdentity.productId !== 'io.pocketdaily.reader') {
|
|
2294
|
+
throw new SurfaceProtocolError(409, 'surface_font_product_mismatch', 'This font pack is registered for Pocket Daily Reader');
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
catch (err) {
|
|
2298
|
+
if (err instanceof SurfaceProtocolError) {
|
|
2299
|
+
res.writeHead(err.status, { 'Content-Type': 'application/json' });
|
|
2300
|
+
res.end(JSON.stringify(surfaceErrorBody(err)));
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
throw err;
|
|
2304
|
+
}
|
|
2305
|
+
if (!surfaceFontPack) {
|
|
2306
|
+
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
2307
|
+
res.end(JSON.stringify({ error: 'font_pack_unavailable' }));
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
if (!matchesFontPackRequest(surfaceFontPack, parsedUrl.searchParams.get('id'), parsedUrl.searchParams.get('version'))) {
|
|
2311
|
+
res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
2312
|
+
res.end(JSON.stringify({ error: 'font_pack_not_found' }));
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
const preferredIp = preferredPullOtaIp(ip, req.socket.localAddress ?? '');
|
|
2316
|
+
if (preferredIp) {
|
|
2317
|
+
const routePort = req.socket.localPort ?? 9120;
|
|
2318
|
+
log(`[agentdeck] Surface path redirect: font-pack ${surfaceIdentity.board} (${ip}) via ${preferredIp}`);
|
|
2319
|
+
res.writeHead(307, {
|
|
2320
|
+
Location: `http://${preferredIp}:${routePort}${parsedUrl.pathname}${parsedUrl.search}`,
|
|
2321
|
+
'Cache-Control': 'no-store',
|
|
2322
|
+
'Connection': 'close',
|
|
2323
|
+
});
|
|
2324
|
+
res.end();
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
log(`[agentdeck] font-pack download: ${surfaceIdentity.board} (${ip})`
|
|
2328
|
+
+ ` ← ${surfaceFontPack.advert.id} v${surfaceFontPack.advert.version}`);
|
|
2329
|
+
const requestedFrom = Number(parsedUrl.searchParams.get('from') ?? '0');
|
|
2330
|
+
const requestedLimitRaw = parsedUrl.searchParams.get('limit');
|
|
2331
|
+
const requestedLimit = requestedLimitRaw == null ? undefined : Number(requestedLimitRaw);
|
|
2332
|
+
const { from, body } = pullOtaSegment(surfaceFontPack.bytes, requestedFrom, requestedLimit);
|
|
2333
|
+
res.writeHead(200, {
|
|
2334
|
+
'Content-Type': 'application/vnd.pocketdaily.cpfont',
|
|
2335
|
+
'Content-Length': String(body.length),
|
|
2336
|
+
...(from > 0
|
|
2337
|
+
? { 'Content-Range': body.length > 0
|
|
2338
|
+
? `bytes ${from}-${from + body.length - 1}/${surfaceFontPack.bytes.length}`
|
|
2339
|
+
: `bytes */${surfaceFontPack.bytes.length}` }
|
|
2340
|
+
: {}),
|
|
2341
|
+
'X-Font-Pack-MD5': surfaceFontPack.advert.md5,
|
|
2342
|
+
'X-Font-Pack-License': surfaceFontPack.advert.licenseSpdx,
|
|
2343
|
+
'Cache-Control': 'no-store',
|
|
2344
|
+
'Connection': 'close',
|
|
2345
|
+
});
|
|
2346
|
+
res.end(body);
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2027
2349
|
// ===== Card Feed pull sync (M6) — wake-sync-sleep battery clients =====
|
|
2028
2350
|
// Auth mirrors /apme: local connections are free; anything else needs the
|
|
2029
2351
|
// pairing token (?token=). Devices hold it from provisioning — since
|
|
@@ -2193,6 +2515,19 @@ export async function startDaemon(opts) {
|
|
|
2193
2515
|
const fwAdvert = stagedFwAdvert(pullBoard ?? undefined, otaIdentity, surfaceIdentity?.clientVersion);
|
|
2194
2516
|
if (fwAdvert)
|
|
2195
2517
|
feed.fw = fwAdvert;
|
|
2518
|
+
if (!fwAdvert && surfaceLearningPack
|
|
2519
|
+
&& surfaceIdentity?.productId === 'io.pocketdaily.reader'
|
|
2520
|
+
&& surfaceIdentity.capabilities.includes('learning.pack.update')) {
|
|
2521
|
+
// The advert deliberately rides unchanged responses. It is omitted
|
|
2522
|
+
// during OTA-first bootstrap so the device installs firmware before
|
|
2523
|
+
// spending its battery/link budget on content bytes.
|
|
2524
|
+
feed.learningPack = surfaceLearningPack.advert;
|
|
2525
|
+
}
|
|
2526
|
+
if (!fwAdvert && surfaceFontPack
|
|
2527
|
+
&& surfaceIdentity?.productId === 'io.pocketdaily.reader'
|
|
2528
|
+
&& surfaceIdentity.capabilities.includes('font.pack.update')) {
|
|
2529
|
+
feed.fontPack = surfaceFontPack.advert;
|
|
2530
|
+
}
|
|
2196
2531
|
const telemetry = parsePullTelemetry(parsedUrl.searchParams);
|
|
2197
2532
|
if (applyPullOtaBootstrap(feed, fwAdvert !== undefined)) {
|
|
2198
2533
|
// Do not make a weak-link X3 receive and parse a full content deck
|
|
@@ -2376,6 +2711,15 @@ export async function startDaemon(opts) {
|
|
|
2376
2711
|
json = body ? JSON.parse(body) : {};
|
|
2377
2712
|
}
|
|
2378
2713
|
catch { /* ignore */ }
|
|
2714
|
+
// The hook shell's parent pid (`X-AgentDeck-Pid: $PPID` in the
|
|
2715
|
+
// installed snippet) — the only consent-free session→process link.
|
|
2716
|
+
// Carried inside the payload as `agentdeck_pid` so every consumer
|
|
2717
|
+
// downstream sees one shape; a payload that already names one wins.
|
|
2718
|
+
const pidHeader = req.headers['x-agentdeck-pid'];
|
|
2719
|
+
const headerPid = Number(Array.isArray(pidHeader) ? pidHeader[0] : pidHeader);
|
|
2720
|
+
if (Number.isInteger(headerPid) && headerPid > 1 && json.agentdeck_pid == null) {
|
|
2721
|
+
json.agentdeck_pid = headerPid;
|
|
2722
|
+
}
|
|
2379
2723
|
// Map PascalCase event names to snake_case for state machine + APME
|
|
2380
2724
|
const eventMap = {
|
|
2381
2725
|
SessionStart: 'session_start', SessionEnd: 'session_end',
|
|
@@ -2392,6 +2736,10 @@ export async function startDaemon(opts) {
|
|
|
2392
2736
|
// codex/opencode *state* is owned by the passive observer's turn
|
|
2393
2737
|
// semantics, not these hooks.
|
|
2394
2738
|
const { boundary, agentType: hookAgentType } = classifyObservedHookEvent(eventName, mapped);
|
|
2739
|
+
// A Codex/OpenCode Interrupt is the user's Ctrl+C: the turn ended with
|
|
2740
|
+
// no Stop, and the collector must not read it as a normal stop.
|
|
2741
|
+
if (/_interrupt$/.test(eventName))
|
|
2742
|
+
json.interrupted = true;
|
|
2395
2743
|
const earlyHookSid = typeof json.session_id === 'string' && json.session_id
|
|
2396
2744
|
? json.session_id : 'daemon-hook';
|
|
2397
2745
|
const earlyHookCwd = (typeof json.cwd === 'string' ? json.cwd
|
|
@@ -2414,6 +2762,12 @@ export async function startDaemon(opts) {
|
|
|
2414
2762
|
if (childResult?.censusChangedFor) {
|
|
2415
2763
|
core.broadcastSessionsList().catch(() => { });
|
|
2416
2764
|
}
|
|
2765
|
+
if (childResult?.sampleEvent) {
|
|
2766
|
+
apme?.collector.noteSubagentLifecycle(earlyHookSid, childResult.sampleEvent);
|
|
2767
|
+
}
|
|
2768
|
+
if (childResult?.infoEvent) {
|
|
2769
|
+
apme?.collector.noteInfo(earlyHookSid, childResult.infoEvent);
|
|
2770
|
+
}
|
|
2417
2771
|
const childHook = childResult?.childOnly === true;
|
|
2418
2772
|
if (childHook) {
|
|
2419
2773
|
// Child activity is observation-only. Never let child PreToolUse
|
|
@@ -2435,6 +2789,39 @@ export async function startDaemon(opts) {
|
|
|
2435
2789
|
projectName: earlyHookProject,
|
|
2436
2790
|
toolName: typeof json.tool_name === 'string' ? json.tool_name : undefined,
|
|
2437
2791
|
});
|
|
2792
|
+
// Codex PERM: `PermissionRequest` fires only when Codex is about to
|
|
2793
|
+
// ask the user (approval_policy on-request), so it is a genuine
|
|
2794
|
+
// awaiting signal with no prediction — display-only, respond in the
|
|
2795
|
+
// terminal. Any later lifecycle hook on the session ends the wait:
|
|
2796
|
+
// an approval runs the tool (tool_start/tool_end), a denial or
|
|
2797
|
+
// Ctrl+C ends the turn (stop/interrupt), a new prompt supersedes it.
|
|
2798
|
+
const codexSid = typeof json.session_id === 'string' ? json.session_id : '';
|
|
2799
|
+
if (codexSid) {
|
|
2800
|
+
if (eventName === 'codex_permission_request') {
|
|
2801
|
+
// `sticky`: the transcript-recency drop is a Claude ESC heuristic;
|
|
2802
|
+
// a Codex rollout may be appended while the approval waits, and
|
|
2803
|
+
// the explicit hook clears below already end this wait.
|
|
2804
|
+
setAwaitingOverlay(codexSid, buildCodexPermissionQuestion(json), undefined, { sticky: true });
|
|
2805
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2806
|
+
}
|
|
2807
|
+
else if (getAwaitingOverlay(codexSid)?.kind === 'permission') {
|
|
2808
|
+
if (clearAwaitingOverlay(codexSid))
|
|
2809
|
+
core.broadcastSessionsList().catch(() => { });
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
// OpenCode observed rows: the process scan keys them by PID and knows
|
|
2814
|
+
// nothing about turns or permissions; the observer plugin's hooks key
|
|
2815
|
+
// by OpenCode session id and carry both. See hook-opencode-sessions.ts.
|
|
2816
|
+
if (eventName.startsWith('opencode_')) {
|
|
2817
|
+
hookOpenCodeSessions.note(eventName, {
|
|
2818
|
+
sessionId: typeof json.session_id === 'string' ? json.session_id : undefined,
|
|
2819
|
+
cwd: earlyHookCwd || undefined,
|
|
2820
|
+
projectName: earlyHookProject,
|
|
2821
|
+
toolName: typeof json.tool_name === 'string' ? json.tool_name : undefined,
|
|
2822
|
+
permissionId: typeof json.permission_id === 'string' ? json.permission_id : undefined,
|
|
2823
|
+
title: typeof json.title === 'string' ? json.title : undefined,
|
|
2824
|
+
});
|
|
2438
2825
|
}
|
|
2439
2826
|
// State machine
|
|
2440
2827
|
if (mapped === 'session_start')
|
|
@@ -2500,7 +2887,7 @@ export async function startDaemon(opts) {
|
|
|
2500
2887
|
// never held again this session. User prompt / session end clear
|
|
2501
2888
|
// pending STOP + queued directives (the user took over).
|
|
2502
2889
|
if (mapped === 'tool_end') {
|
|
2503
|
-
noteToolEnd(claudeSid, toolName || undefined);
|
|
2890
|
+
noteToolEnd(claudeSid, toolName || undefined, toolUseId);
|
|
2504
2891
|
}
|
|
2505
2892
|
else if (mapped === 'user_prompt_submit') {
|
|
2506
2893
|
if (clearOnUserPrompt(claudeSid))
|
|
@@ -2599,6 +2986,10 @@ export async function startDaemon(opts) {
|
|
|
2599
2986
|
// Mirrors the Swift daemon's session_end force-close (`hasOpenTurn`
|
|
2600
2987
|
// → interrupted chat_end).
|
|
2601
2988
|
if (boundary === 'session_end') {
|
|
2989
|
+
// Every child the session had ends with it — a lost SubagentStop
|
|
2990
|
+
// must not pin "+N" on a row that no longer exists.
|
|
2991
|
+
subagentTimeline?.forget(hookSid);
|
|
2992
|
+
coordination.forget(hookSid);
|
|
2602
2993
|
const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
|
|
2603
2994
|
let lastStart;
|
|
2604
2995
|
let lastCompletionTs = 0;
|
|
@@ -2658,13 +3049,35 @@ export async function startDaemon(opts) {
|
|
|
2658
3049
|
// codex_* / opencode_* names would silently skip turn management.
|
|
2659
3050
|
apme.collector.ingestHook(hookSid, boundary, json);
|
|
2660
3051
|
}
|
|
3052
|
+
// Coordination evidence carried BY the hook itself: a received
|
|
3053
|
+
// cross-session envelope, a SendMessage call, a `claude -p` launch.
|
|
3054
|
+
// After ingestHook so a prompt's turn is open before the relation
|
|
3055
|
+
// is attached to it. Process-table evidence (ancestry, background
|
|
3056
|
+
// jobs) arrives on the observer tick instead.
|
|
3057
|
+
if (typeof json.agentdeck_pid === 'number') {
|
|
3058
|
+
coordination.registerPid(hookSid, json.agentdeck_pid, passiveSessionObserver.processes());
|
|
3059
|
+
}
|
|
3060
|
+
if (boundary === 'user_prompt_submit' && hookPromptText) {
|
|
3061
|
+
persistRelation(coordination.noteMessageIn(hookSid, hookPromptText));
|
|
3062
|
+
}
|
|
3063
|
+
else if (eventName === 'PostToolUse' || boundary === 'tool_end') {
|
|
3064
|
+
persistRelation(coordination.noteToolCall(hookSid, json.tool_name, json.tool_input));
|
|
3065
|
+
}
|
|
2661
3066
|
// Direct `claude` runs reach the daemon only via these hooks, which
|
|
2662
3067
|
// never carry the model — so every such run persisted model_id=NULL.
|
|
2663
3068
|
// Recover it from the transcript Claude writes. Must run before
|
|
2664
3069
|
// closeRun tears down the session→run mapping.
|
|
2665
3070
|
if (boundary === 'stop' || boundary === 'session_end') {
|
|
3071
|
+
// Codex states its model on the hook itself (`model`); Claude's
|
|
3072
|
+
// transcript holds it. `transcript_path` on a Codex hook is its
|
|
3073
|
+
// ROLLOUT, which the Claude reader cannot parse — so every Codex
|
|
3074
|
+
// run persisted model_id=NULL while the payload said `gpt-5.6`.
|
|
3075
|
+
const inlineModel = typeof json.model === 'string' ? json.model.trim() : '';
|
|
2666
3076
|
const tp = json.transcript_path;
|
|
2667
|
-
if (
|
|
3077
|
+
if (inlineModel) {
|
|
3078
|
+
apme.collector.updateModel(hookSid, inlineModel);
|
|
3079
|
+
}
|
|
3080
|
+
else if (hookAgentType === 'claude-code' && typeof tp === 'string' && tp) {
|
|
2668
3081
|
const model = readModelFromTranscript(tp);
|
|
2669
3082
|
if (model)
|
|
2670
3083
|
apme.collector.updateModel(hookSid, model);
|
|
@@ -2743,18 +3156,31 @@ export async function startDaemon(opts) {
|
|
|
2743
3156
|
json.result,
|
|
2744
3157
|
]
|
|
2745
3158
|
.find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
|
|
2746
|
-
//
|
|
2747
|
-
//
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
//
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
const
|
|
2756
|
-
|
|
2757
|
-
:
|
|
3159
|
+
// Where the reply comes from is decided per AGENT, and inline text
|
|
3160
|
+
// wins wherever it exists — it is the hook's own statement of what
|
|
3161
|
+
// the agent said. Otherwise Claude's transcript is read by the
|
|
3162
|
+
// Claude reader and Codex's rollout by the Codex reader. The old
|
|
3163
|
+
// rule was "transcript_path ⇒ Claude reader", and a Codex hook
|
|
3164
|
+
// carries a transcript_path too — its rollout — so every Codex turn
|
|
3165
|
+
// read '' while `last_assistant_message` sat in the same payload
|
|
3166
|
+
// (3 of 128 codex stop-turns held a response in one week, measured
|
|
3167
|
+
// 2026-09-03; the judge scored the rest against silence).
|
|
3168
|
+
const resolved = resolveStopResponse({ agentType: hookAgentType, sessionId: hookSid, inlineResponse, transcriptPath: tp }, {
|
|
3169
|
+
readClaudeTranscript: lastAssistantTextFromTranscript,
|
|
3170
|
+
readCodexRolloutPath: codexTurnOutcomeFromRolloutPath,
|
|
3171
|
+
readCodexRolloutById: codexTurnOutcomeFromRollout,
|
|
3172
|
+
});
|
|
3173
|
+
const rolloutOutcome = resolved.rollout;
|
|
3174
|
+
const responseText = stripUnsafeText(resolved.text);
|
|
3175
|
+
// An empty transcript read AT the Stop is not an answer — Claude is
|
|
3176
|
+
// still writing the record the hook refers to. Re-read shortly and
|
|
3177
|
+
// hand it to the last-closed turn (see deferred-reply-read.ts).
|
|
3178
|
+
if (!responseText && apme && hookAgentType === 'claude-code' && tp) {
|
|
3179
|
+
scheduleDeferredReplyRead(() => stripUnsafeText(lastAssistantTextFromTranscript(tp)), (text) => {
|
|
3180
|
+
apme?.collector.setLastClosedTurnResponse(hookSid, text);
|
|
3181
|
+
debug('APME', `deferred transcript read recovered ${text.length} chars for ${hookSid.slice(0, 8)}`);
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
2758
3184
|
const respRaw = responseText.length > 0
|
|
2759
3185
|
? cleanRawText(responseText.length > 200 ? responseText.slice(0, 197) + '...' : responseText)
|
|
2760
3186
|
: '';
|
|
@@ -2972,6 +3398,7 @@ export async function startDaemon(opts) {
|
|
|
2972
3398
|
// the signature is suppressed for this session.
|
|
2973
3399
|
gateReleased(claudeSid, requestId, {
|
|
2974
3400
|
undecided: decision === 'pass', tool: toolName, toolInput,
|
|
3401
|
+
toolUseId: typeof json.tool_use_id === 'string' ? json.tool_use_id : undefined,
|
|
2975
3402
|
});
|
|
2976
3403
|
clearAwaitingOverlay(claudeSid);
|
|
2977
3404
|
core.broadcastSessionsList().catch(() => { });
|
|
@@ -3332,6 +3759,7 @@ export async function startDaemon(opts) {
|
|
|
3332
3759
|
// Keep the original startedAt stable when the self-heal timer rewrites it.
|
|
3333
3760
|
const daemonInfo = { port, pid: process.pid, startedAt: new Date().toISOString() };
|
|
3334
3761
|
writeDaemonInfo(daemonInfo);
|
|
3762
|
+
enableClaudeUsageRecovery();
|
|
3335
3763
|
// ===== BridgeCore =====
|
|
3336
3764
|
const core = new BridgeCore({
|
|
3337
3765
|
port,
|
|
@@ -3389,7 +3817,8 @@ export async function startDaemon(opts) {
|
|
|
3389
3817
|
// event and WiFi resumes automatically — no state migration needed.
|
|
3390
3818
|
if (SERIAL_FORWARDED_EVENTS.has(event.type) && isWifiEsp32RedundantWithSerial(client))
|
|
3391
3819
|
return null;
|
|
3392
|
-
|
|
3820
|
+
const identity = wifiEsp32IdentityForSocket(client);
|
|
3821
|
+
return prepareForSerial(event, identity?.board ? { deviceInfo: { board: identity.board } } : undefined);
|
|
3393
3822
|
});
|
|
3394
3823
|
// Timeline
|
|
3395
3824
|
//
|
|
@@ -3610,6 +4039,7 @@ export async function startDaemon(opts) {
|
|
|
3610
4039
|
core.wsServer.broadcast(resolveRelayedUsageEvent({
|
|
3611
4040
|
relayed: u,
|
|
3612
4041
|
ownCodexRateLimits: core.lastBuiltCodexRateLimits,
|
|
4042
|
+
ownLiveFamilyAuthorityExpiresAtMs: core.lastBuiltCodexLiveFamilyAuthorityExpiresAtMs,
|
|
3613
4043
|
buildOwnUsage: () => core.buildUsage(),
|
|
3614
4044
|
}));
|
|
3615
4045
|
}
|
|
@@ -3663,6 +4093,8 @@ export async function startDaemon(opts) {
|
|
|
3663
4093
|
version: d.version ?? null,
|
|
3664
4094
|
stale: d.stale,
|
|
3665
4095
|
serialActive: d.serialActive,
|
|
4096
|
+
repaintCount: d.repaintCount ?? null,
|
|
4097
|
+
fullRefreshCount: d.fullRefreshCount ?? null,
|
|
3666
4098
|
})),
|
|
3667
4099
|
};
|
|
3668
4100
|
}
|
|
@@ -3730,6 +4162,14 @@ export async function startDaemon(opts) {
|
|
|
3730
4162
|
bridgeIp: getLanIp(),
|
|
3731
4163
|
bridgePort: port,
|
|
3732
4164
|
}));
|
|
4165
|
+
// Operator-approval authentication (daemon only, for the same reason). The
|
|
4166
|
+
// roster belongs to the process that serves the operator's approval routes;
|
|
4167
|
+
// a session bridge sharing WsServer must not answer "is this peer approved?"
|
|
4168
|
+
// from a second, unowned copy of it.
|
|
4169
|
+
core.wsServer.setPairingApprovalHooks({
|
|
4170
|
+
isApproved: (ip, deviceId) => isApprovedPeer(ip, deviceId),
|
|
4171
|
+
record: (ip, deviceId, staleToken) => recordKnock(ip, deviceId, staleToken),
|
|
4172
|
+
});
|
|
3733
4173
|
// WS-path display_state re-sync: the 5s serial heartbeat above only covers
|
|
3734
4174
|
// USB-attached boards. WiFi boards (InkDeck) receive display_state edge-
|
|
3735
4175
|
// triggered over the plugin WS — a missed wake edge would leave an e-ink
|
|
@@ -3877,6 +4317,41 @@ export async function startDaemon(opts) {
|
|
|
3877
4317
|
log('[agentdeck] APME timeline projection ENABLED — chat/tool rows derive from SessionSample');
|
|
3878
4318
|
}
|
|
3879
4319
|
log(`[agentdeck] APME enabled — store=${apme.store.dbPath} routes=/apme/*`);
|
|
4320
|
+
// Re-adopt the runs the previous daemon left open BEFORE any hook is
|
|
4321
|
+
// served: a session that carried on across the restart continues its run
|
|
4322
|
+
// instead of stranding it for the reaper (see `rehydrateOpenRuns`).
|
|
4323
|
+
// Rows the OLD reaper stamped `orphaned` on evidence that now reads
|
|
4324
|
+
// `idle_gap` — one idempotent pass, evidence-gated (see the store).
|
|
4325
|
+
try {
|
|
4326
|
+
const reclassified = apme.store.reclassifyReapedTasks();
|
|
4327
|
+
if (reclassified > 0)
|
|
4328
|
+
log(`[agentdeck] APME re-stamped ${reclassified} reaped task(s) as idle_gap — every turn had closed normally`);
|
|
4329
|
+
}
|
|
4330
|
+
catch { /* store logs it */ }
|
|
4331
|
+
// Verdicts the judge reached on tasks it would now decline — scored
|
|
4332
|
+
// against silence, or on a usage-limit notice — are withdrawn so they
|
|
4333
|
+
// stop ranking real work. Bounded to the same 30-day window the backlog
|
|
4334
|
+
// drain reads; idempotent after the first pass.
|
|
4335
|
+
try {
|
|
4336
|
+
const r = retractUngradeableVerdicts(apme.store, Date.now() - 30 * 86_400_000);
|
|
4337
|
+
const n = r.no_reply + r.aborted_only + r.trivial;
|
|
4338
|
+
if (n > 0)
|
|
4339
|
+
log(`[agentdeck] APME withdrew ${n} verdict(s) reached without the agent's work — no reply ${r.no_reply}, client-ended ${r.aborted_only}, trivial ${r.trivial}`);
|
|
4340
|
+
if (r.readmitted > 0)
|
|
4341
|
+
log(`[agentdeck] APME re-admitted ${r.readmitted} declined task(s) to the judge backlog — their tool trajectory is the agent's work`);
|
|
4342
|
+
}
|
|
4343
|
+
catch (err) {
|
|
4344
|
+
log(`[agentdeck] APME verdict retraction failed: ${String(err)}`);
|
|
4345
|
+
}
|
|
4346
|
+
try {
|
|
4347
|
+
const r = apme.collector.rehydrateOpenRuns();
|
|
4348
|
+
if (r.runs > 0) {
|
|
4349
|
+
log(`[agentdeck] APME resumed ${r.runs} open run(s) from the store — ${r.tasks} task(s), ${r.turns} open turn(s), ${r.armed} idle-gap timer(s) re-armed${r.recovered > 0 ? `, ${r.recovered} turn(s) closed from the agent's own record` : ''}`);
|
|
4350
|
+
}
|
|
4351
|
+
}
|
|
4352
|
+
catch (err) {
|
|
4353
|
+
log(`[agentdeck] APME rehydrate failed: ${String(err)}`);
|
|
4354
|
+
}
|
|
3880
4355
|
// Fire-and-forget judge backend probe. Result is cached on
|
|
3881
4356
|
// apme.runner.lastBackendProbe and surfaced on /health so users discover
|
|
3882
4357
|
// misconfiguration (no MLX server running, missing API key, etc.) without
|
|
@@ -3905,6 +4380,33 @@ export async function startDaemon(opts) {
|
|
|
3905
4380
|
// The observer scans in the background now (collect() returns the cache
|
|
3906
4381
|
// immediately). When a scan lands fresh observations, push them out via
|
|
3907
4382
|
// the debounced broadcast so clients don't wait for the next 10 s poll.
|
|
4383
|
+
// A relation lands on the session's active APME task; a change to any
|
|
4384
|
+
// census rebroadcasts the roster, because a background job starting or a
|
|
4385
|
+
// spawned worker exiting moves nothing else a client could notice.
|
|
4386
|
+
const persistRelation = (rel) => {
|
|
4387
|
+
if (!rel)
|
|
4388
|
+
return false;
|
|
4389
|
+
apme?.collector.noteRelation(rel.sessionId, rel);
|
|
4390
|
+
return true;
|
|
4391
|
+
};
|
|
4392
|
+
// Driven by its own timer, not by `onRefreshed`: that edge fires only when
|
|
4393
|
+
// the observed PROCESS SET changes, and a background job appearing or a
|
|
4394
|
+
// worker finishing changes the process table without changing that set —
|
|
4395
|
+
// the same trap the OpenClaw transcript feed fell into.
|
|
4396
|
+
const coordinationTick = () => {
|
|
4397
|
+
const peers = coordination.mergePeers(passiveSessionObserver.collect([])
|
|
4398
|
+
.filter((s) => typeof s.pid === 'number' && s.pid > 0)
|
|
4399
|
+
.map((s) => ({ sessionId: rawSessionId(s.id), pid: s.pid })));
|
|
4400
|
+
let changed = false;
|
|
4401
|
+
for (const rel of coordination.observe(passiveSessionObserver.processes(), peers)) {
|
|
4402
|
+
if (persistRelation(rel))
|
|
4403
|
+
changed = true;
|
|
4404
|
+
}
|
|
4405
|
+
if (changed)
|
|
4406
|
+
core.broadcastSessionsList().catch(() => { });
|
|
4407
|
+
};
|
|
4408
|
+
const coordinationTimer = setInterval(coordinationTick, 5_000);
|
|
4409
|
+
coordinationTimer.unref?.();
|
|
3908
4410
|
passiveSessionObserver.onRefreshed = () => {
|
|
3909
4411
|
// Kiro pushes nothing: no `kiro_*` hook has ever reached this daemon, so
|
|
3910
4412
|
// without a producer here its session shows in the HUD while the timeline
|
|
@@ -3965,6 +4467,7 @@ export async function startDaemon(opts) {
|
|
|
3965
4467
|
// worth a broadcast of its own.
|
|
3966
4468
|
codexOtel.onChanged = () => core.maybeBroadcastSessionsList();
|
|
3967
4469
|
hookCodexSessions.onChanged = () => core.maybeBroadcastSessionsList();
|
|
4470
|
+
hookOpenCodeSessions.onChanged = () => core.maybeBroadcastSessionsList();
|
|
3968
4471
|
// ===== Gateway adapter lifecycle =====
|
|
3969
4472
|
// (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
|
|
3970
4473
|
// Inject OpenClaw virtual session only after Gateway authentication succeeds.
|
|
@@ -3980,7 +4483,7 @@ export async function startDaemon(opts) {
|
|
|
3980
4483
|
// synthesize a `codex-app` row for any thread the process scan missed —
|
|
3981
4484
|
// Swift's only Codex-app source, here a second opinion on top of the
|
|
3982
4485
|
// observer. See bridge/src/codex-otel.ts.
|
|
3983
|
-
const observed = applyAwaitingOverlayToObserved(hookCodexSessions.applyTo(codexOtel.applyTo(passiveSessionObserver.collect(sessions))))
|
|
4486
|
+
const observed = applyAwaitingOverlayToObserved(hookOpenCodeSessions.applyTo(hookCodexSessions.applyTo(codexOtel.applyTo(passiveSessionObserver.collect(sessions)))))
|
|
3984
4487
|
.map((s) => {
|
|
3985
4488
|
// Steering feedback for observed Claude sessions: devices render
|
|
3986
4489
|
// "stopping at next tool" / queued-directive badges from these.
|
|
@@ -4014,6 +4517,7 @@ export async function startDaemon(opts) {
|
|
|
4014
4517
|
// while these rows are `observed:<agent>:<uuid>`, so normalize before the
|
|
4015
4518
|
// lookup — the two id forms are the standing trap here.
|
|
4016
4519
|
const subagentCensus = subagentTimeline?.summaries() ?? new Map();
|
|
4520
|
+
const coordinationCensus = coordination.summaries();
|
|
4017
4521
|
const enrichedSessions = [...sessions, ...observed, ...remote].map((s) => {
|
|
4018
4522
|
// On-demand review badge (REVIEW tile verdict / REVIEWING state) —
|
|
4019
4523
|
// applies to every session type, managed included.
|
|
@@ -4023,7 +4527,10 @@ export async function startDaemon(opts) {
|
|
|
4023
4527
|
// field that disappears when the last child exits latches "8 running" on
|
|
4024
4528
|
// every client that merges retain-on-absent.
|
|
4025
4529
|
const census = subagentCensus.get(rawSessionId(withReview.id));
|
|
4026
|
-
const
|
|
4530
|
+
const withSubagents = census ? { ...withReview, subagents: census } : withReview;
|
|
4531
|
+
// Same emission rule for the coordination census: zeros once observed.
|
|
4532
|
+
const coord = coordinationCensus.get(rawSessionId(withReview.id));
|
|
4533
|
+
const withCensus = coord ? { ...withSubagents, coordination: coord } : withSubagents;
|
|
4027
4534
|
if (withCensus.elapsedSec != null || !withCensus.startedAt)
|
|
4028
4535
|
return withCensus;
|
|
4029
4536
|
const sec = Math.round((now - Date.parse(withCensus.startedAt)) / 1000);
|
|
@@ -4254,6 +4761,7 @@ export async function startDaemon(opts) {
|
|
|
4254
4761
|
gatewaySessionState = 'idle';
|
|
4255
4762
|
bridgeLogStream.start();
|
|
4256
4763
|
log('[agentdeck] OpenClaw Gateway connected');
|
|
4764
|
+
settleGatewayInstability(Date.now());
|
|
4257
4765
|
if (core.stateMachine.getSnapshot().state === 'disconnected') {
|
|
4258
4766
|
core.stateMachine.handleHookEvent('SessionStart', {});
|
|
4259
4767
|
}
|
|
@@ -4279,6 +4787,11 @@ export async function startDaemon(opts) {
|
|
|
4279
4787
|
log('[agentdeck] OpenClaw Gateway disconnected');
|
|
4280
4788
|
core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
|
|
4281
4789
|
core.broadcastSessionsList().catch(() => { });
|
|
4790
|
+
const flapAt = Date.now();
|
|
4791
|
+
gatewayFlaps.record(flapAt);
|
|
4792
|
+
assessGatewayInstability(flapAt).catch((err) => {
|
|
4793
|
+
debug('Gateway', `instability assessment failed: ${String(err)}`);
|
|
4794
|
+
});
|
|
4282
4795
|
}
|
|
4283
4796
|
break;
|
|
4284
4797
|
}
|
|
@@ -5992,12 +6505,49 @@ export async function startDaemon(opts) {
|
|
|
5992
6505
|
};
|
|
5993
6506
|
core.broadcast(evalEvent);
|
|
5994
6507
|
});
|
|
6508
|
+
let judgeReprobeInFlight = false;
|
|
5995
6509
|
const apmeEvalTimer = setInterval(() => {
|
|
5996
6510
|
// 1. Enqueue unevaluated runs for deterministic + judge
|
|
5997
6511
|
const pending = apme.store.listUnevaluatedRuns(5);
|
|
5998
6512
|
for (const run of pending) {
|
|
5999
6513
|
apme.runner.enqueue({ runId: run.id, projectPath: run.projectPath ?? undefined });
|
|
6000
6514
|
}
|
|
6515
|
+
// 1b. Drain the TASK judge backlog: closed, unjudged, not declined.
|
|
6516
|
+
// A task's one judge call fires from the collector's close path, so
|
|
6517
|
+
// a daemon restart mid-eval, an offline judge, or a backend probe
|
|
6518
|
+
// not yet ready lost it for good — 1,060 closed tasks had piled up
|
|
6519
|
+
// unjudged (2026-09-03). Batched small (the judge is a local model
|
|
6520
|
+
// answering one prompt at a time) and only while the judge is
|
|
6521
|
+
// actually reachable, so an outage does not turn into a retry storm.
|
|
6522
|
+
// Bounded to 30 days: older rows predate response capture and would
|
|
6523
|
+
// mostly be declined anyway.
|
|
6524
|
+
// One at a time, and only when nothing is already at the judge:
|
|
6525
|
+
// the local backends answer serially, so three per tick queued
|
|
6526
|
+
// past the call timeout, burned every task's attempts, and parked
|
|
6527
|
+
// the whole backlog (289 tasks, 2026-09-03). The live close path
|
|
6528
|
+
// still enqueues on its own; the drain yields to it.
|
|
6529
|
+
const probe = apme.runner.lastBackendProbe;
|
|
6530
|
+
if (probe && probe.status !== 'ready' && Date.now() - probe.checkedAt >= APME_JUDGE_REPROBE_MS && !judgeReprobeInFlight) {
|
|
6531
|
+
judgeReprobeInFlight = true;
|
|
6532
|
+
void apme.runner.refreshBackendProbe(loadApmeConfig().judge).then((status) => {
|
|
6533
|
+
if (status.status === 'ready') {
|
|
6534
|
+
log(`[agentdeck] APME judge ready again: ${status.backend}${status.model ? ` (${status.model})` : ''} — the task backlog resumes`);
|
|
6535
|
+
}
|
|
6536
|
+
}).catch(() => { }).finally(() => { judgeReprobeInFlight = false; });
|
|
6537
|
+
}
|
|
6538
|
+
if (probe?.status === 'ready' && apme.runner.inFlightTaskEvals === 0) {
|
|
6539
|
+
// The backlog query cannot see a park, so ask for a WINDOW and let
|
|
6540
|
+
// `pickBacklogTasks` choose. Asking for one row fed the head of
|
|
6541
|
+
// `ended_at DESC` and nothing else: a task whose judge call fails
|
|
6542
|
+
// every attempt owns that head, and `enqueueTask` drops a parked task
|
|
6543
|
+
// silently, so every tick spent its one slot on it and the backlog
|
|
6544
|
+
// behind it starved (156 tasks, 2026-08-07..23, measured 2026-09-06).
|
|
6545
|
+
// Still one task per tick — the window only decides WHICH one.
|
|
6546
|
+
const backlog = apme.store.listTasksNeedingSummary(APME_TASK_JUDGE_DRAIN_WINDOW, Date.now() - 30 * 86_400_000);
|
|
6547
|
+
for (const t of apme.runner.pickBacklogTasks(backlog, APME_TASK_JUDGE_DRAIN_PER_TICK)) {
|
|
6548
|
+
apme.runner.enqueueTask({ runId: t.runId, taskId: t.id, ...(t.taskCategory ? { category: t.taskCategory } : {}) });
|
|
6549
|
+
}
|
|
6550
|
+
}
|
|
6001
6551
|
// 2. Run outcome detection + composite scoring on recently closed runs
|
|
6002
6552
|
// that don't have an outcome yet.
|
|
6003
6553
|
const closedRuns = apme.store.listRuns({ limit: 20 });
|
|
@@ -6059,26 +6609,29 @@ export async function startDaemon(opts) {
|
|
|
6059
6609
|
// Batched small: `enqueueTask` dispatches immediately with no
|
|
6060
6610
|
// concurrency cap, so a backlog is drained a few per tick rather than
|
|
6061
6611
|
// fired at the judge all at once.
|
|
6612
|
+
// Since `rehydrateOpenRuns` (2026-09-03) a restart no longer feeds
|
|
6613
|
+
// this path — the new daemon adopts the open runs — so what reaches
|
|
6614
|
+
// it is a run nobody resumed: a session bridge that died, or a
|
|
6615
|
+
// daemon that was down for longer than the stale window.
|
|
6062
6616
|
const abandoned = apme.store.listAbandonedRuns(APME_ABANDONED_RUN_STALE_SEC, 5);
|
|
6063
6617
|
for (const run of abandoned) {
|
|
6064
6618
|
if (apme.collector.isLiveRun(run.id))
|
|
6065
6619
|
continue; // still owned by us
|
|
6066
6620
|
const closedTasks = apme.store.reapAbandonedRun(run.id, run.lastActivity);
|
|
6621
|
+
// A run adopted at startup whose session never spoke is reapable
|
|
6622
|
+
// (see `isLiveRun`), and its in-memory edges must go with the row.
|
|
6623
|
+
apme.collector.releaseRun(run.id);
|
|
6067
6624
|
for (const task of closedTasks) {
|
|
6068
|
-
// Judge only what there is something to judge
|
|
6069
|
-
//
|
|
6070
|
-
//
|
|
6071
|
-
//
|
|
6072
|
-
|
|
6073
|
-
// is the data-hygiene win; the eval is a bonus when it can be earned.
|
|
6074
|
-
const hasReply = apme.store.listTurnsForTask(task.id)
|
|
6075
|
-
.some((t) => typeof t.response === 'string' && t.response.trim().length > 0);
|
|
6076
|
-
if (!hasReply)
|
|
6625
|
+
// Judge only what there is something to judge — the one rule in
|
|
6626
|
+
// task-gradeability.ts, which the runner applies again and which
|
|
6627
|
+
// also stamps the reason on the row. Closing the row is the
|
|
6628
|
+
// data-hygiene win; the eval is a bonus when it can be earned.
|
|
6629
|
+
if (!taskGradeability(apme.store.listTurnsForTask(task.id)).gradeable)
|
|
6077
6630
|
continue;
|
|
6078
6631
|
apme.runner.enqueueTask({
|
|
6079
6632
|
runId: run.id, taskId: task.id,
|
|
6080
6633
|
...(task.category ? { category: task.category } : {}),
|
|
6081
|
-
boundarySignal:
|
|
6634
|
+
boundarySignal: task.boundarySignal,
|
|
6082
6635
|
});
|
|
6083
6636
|
}
|
|
6084
6637
|
debug('APME', `reaped abandoned run ${run.id.slice(0, 8)} — ${closedTasks.length} task(s) closed`);
|
|
@@ -6161,6 +6714,10 @@ export async function startDaemon(opts) {
|
|
|
6161
6714
|
// would keep firing self-POSTs at a port this daemon no longer owns.
|
|
6162
6715
|
observedStopWatchdogs.stop();
|
|
6163
6716
|
voiceAssistant?.stop();
|
|
6717
|
+
// A recovery turn in flight is a `claude` child whose own timeout dies with
|
|
6718
|
+
// this process; ending it here is the difference between a bounded 25s
|
|
6719
|
+
// request and an orphan.
|
|
6720
|
+
stopClaudeUsageRecoveryChildren();
|
|
6164
6721
|
bridgeLogStream.stop();
|
|
6165
6722
|
// Flush synchronously — the process exits a few lines below and a pending
|
|
6166
6723
|
// debounce timer would take the last turn's entries with it.
|