@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.
Files changed (199) hide show
  1. package/README.md +14 -6
  2. package/assets/fonts/NOTICE-PocketSansWorld.md +10 -0
  3. package/assets/fonts/OFL-PocketSansWorld.txt +93 -0
  4. package/assets/fonts/PocketSansWorld_12.cpfont +0 -0
  5. package/assets/fonts/pocket-sans-world.manifest.json +49 -0
  6. package/assets/learning/LICENSE-CC-BY-SA-4.0.txt +10 -0
  7. package/assets/learning/NOTICE.md +33 -0
  8. package/assets/learning/jp-n3-ko.manifest.json +27 -0
  9. package/assets/learning/jp-n3-ko.pdl +0 -0
  10. package/dist/agent-cli-diagnostics.d.ts +20 -0
  11. package/dist/agent-cli-diagnostics.d.ts.map +1 -0
  12. package/dist/agent-cli-diagnostics.js +100 -0
  13. package/dist/agent-cli-diagnostics.js.map +1 -0
  14. package/dist/apme/classifier.d.ts.map +1 -1
  15. package/dist/apme/classifier.js +18 -10
  16. package/dist/apme/classifier.js.map +1 -1
  17. package/dist/apme/claude-transcript-reader.d.ts +36 -0
  18. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  19. package/dist/apme/claude-transcript-reader.js +78 -2
  20. package/dist/apme/claude-transcript-reader.js.map +1 -1
  21. package/dist/apme/collector.d.ts +126 -3
  22. package/dist/apme/collector.d.ts.map +1 -1
  23. package/dist/apme/collector.js +450 -41
  24. package/dist/apme/collector.js.map +1 -1
  25. package/dist/apme/dashboard-html.d.ts.map +1 -1
  26. package/dist/apme/dashboard-html.js +278 -36
  27. package/dist/apme/dashboard-html.js.map +1 -1
  28. package/dist/apme/graph.d.ts +1 -1
  29. package/dist/apme/graph.d.ts.map +1 -1
  30. package/dist/apme/graph.js +51 -1
  31. package/dist/apme/graph.js.map +1 -1
  32. package/dist/apme/http.d.ts.map +1 -1
  33. package/dist/apme/http.js +37 -5
  34. package/dist/apme/http.js.map +1 -1
  35. package/dist/apme/index.d.ts +4 -0
  36. package/dist/apme/index.d.ts.map +1 -1
  37. package/dist/apme/index.js +19 -3
  38. package/dist/apme/index.js.map +1 -1
  39. package/dist/apme/runner.d.ts +90 -0
  40. package/dist/apme/runner.d.ts.map +1 -1
  41. package/dist/apme/runner.js +524 -48
  42. package/dist/apme/runner.js.map +1 -1
  43. package/dist/apme/sample-to-timeline.d.ts.map +1 -1
  44. package/dist/apme/sample-to-timeline.js +5 -0
  45. package/dist/apme/sample-to-timeline.js.map +1 -1
  46. package/dist/apme/settings.d.ts +8 -0
  47. package/dist/apme/settings.d.ts.map +1 -1
  48. package/dist/apme/settings.js +4 -0
  49. package/dist/apme/settings.js.map +1 -1
  50. package/dist/apme/store.d.ts +118 -9
  51. package/dist/apme/store.d.ts.map +1 -1
  52. package/dist/apme/store.js +342 -44
  53. package/dist/apme/store.js.map +1 -1
  54. package/dist/apme/task-gradeability.d.ts +82 -0
  55. package/dist/apme/task-gradeability.d.ts.map +1 -0
  56. package/dist/apme/task-gradeability.js +126 -0
  57. package/dist/apme/task-gradeability.js.map +1 -0
  58. package/dist/awaiting-overlay.d.ts +13 -26
  59. package/dist/awaiting-overlay.d.ts.map +1 -1
  60. package/dist/awaiting-overlay.js +7 -40
  61. package/dist/awaiting-overlay.js.map +1 -1
  62. package/dist/bridge-core.d.ts +15 -0
  63. package/dist/bridge-core.d.ts.map +1 -1
  64. package/dist/bridge-core.js +30 -4
  65. package/dist/bridge-core.js.map +1 -1
  66. package/dist/claude-permission-rules.d.ts +20 -32
  67. package/dist/claude-permission-rules.d.ts.map +1 -1
  68. package/dist/claude-permission-rules.js +19 -102
  69. package/dist/claude-permission-rules.js.map +1 -1
  70. package/dist/claude-usage-recovery.d.ts +54 -0
  71. package/dist/claude-usage-recovery.d.ts.map +1 -0
  72. package/dist/claude-usage-recovery.js +200 -0
  73. package/dist/claude-usage-recovery.js.map +1 -0
  74. package/dist/cli.d.ts +11 -0
  75. package/dist/cli.d.ts.map +1 -1
  76. package/dist/cli.js +81 -14
  77. package/dist/cli.js.map +1 -1
  78. package/dist/codex-rate-limits-live.d.ts +180 -10
  79. package/dist/codex-rate-limits-live.d.ts.map +1 -1
  80. package/dist/codex-rate-limits-live.js +475 -22
  81. package/dist/codex-rate-limits-live.js.map +1 -1
  82. package/dist/codex-rollout-response.d.ts +43 -0
  83. package/dist/codex-rollout-response.d.ts.map +1 -1
  84. package/dist/codex-rollout-response.js +67 -0
  85. package/dist/codex-rollout-response.js.map +1 -1
  86. package/dist/coordination-evidence.d.ts +150 -0
  87. package/dist/coordination-evidence.d.ts.map +1 -0
  88. package/dist/coordination-evidence.js +455 -0
  89. package/dist/coordination-evidence.js.map +1 -0
  90. package/dist/daemon-server.d.ts.map +1 -1
  91. package/dist/daemon-server.js +592 -35
  92. package/dist/daemon-server.js.map +1 -1
  93. package/dist/daemon-ws-client.d.ts +1 -0
  94. package/dist/daemon-ws-client.d.ts.map +1 -1
  95. package/dist/daemon-ws-client.js +25 -16
  96. package/dist/daemon-ws-client.js.map +1 -1
  97. package/dist/daemon.js +1 -1
  98. package/dist/deferred-reply-read.d.ts +24 -0
  99. package/dist/deferred-reply-read.d.ts.map +1 -0
  100. package/dist/deferred-reply-read.js +42 -0
  101. package/dist/deferred-reply-read.js.map +1 -0
  102. package/dist/esp32-serial.d.ts +15 -0
  103. package/dist/esp32-serial.d.ts.map +1 -1
  104. package/dist/esp32-serial.js +61 -2
  105. package/dist/esp32-serial.js.map +1 -1
  106. package/dist/font-pack.d.ts +15 -0
  107. package/dist/font-pack.d.ts.map +1 -0
  108. package/dist/font-pack.js +75 -0
  109. package/dist/font-pack.js.map +1 -0
  110. package/dist/glance-frame.d.ts.map +1 -1
  111. package/dist/glance-frame.js +2 -0
  112. package/dist/glance-frame.js.map +1 -1
  113. package/dist/hook-codex-sessions.d.ts +9 -0
  114. package/dist/hook-codex-sessions.d.ts.map +1 -1
  115. package/dist/hook-codex-sessions.js +35 -2
  116. package/dist/hook-codex-sessions.js.map +1 -1
  117. package/dist/hook-opencode-sessions.d.ts +62 -0
  118. package/dist/hook-opencode-sessions.d.ts.map +1 -0
  119. package/dist/hook-opencode-sessions.js +180 -0
  120. package/dist/hook-opencode-sessions.js.map +1 -0
  121. package/dist/hook-response-source.d.ts +40 -0
  122. package/dist/hook-response-source.d.ts.map +1 -0
  123. package/dist/hook-response-source.js +31 -0
  124. package/dist/hook-response-source.js.map +1 -0
  125. package/dist/http-auth-gate.d.ts.map +1 -1
  126. package/dist/http-auth-gate.js +9 -1
  127. package/dist/http-auth-gate.js.map +1 -1
  128. package/dist/index.d.ts.map +1 -1
  129. package/dist/index.js +6 -3
  130. package/dist/index.js.map +1 -1
  131. package/dist/learning-pack.d.ts +24 -0
  132. package/dist/learning-pack.d.ts.map +1 -0
  133. package/dist/learning-pack.js +125 -0
  134. package/dist/learning-pack.js.map +1 -0
  135. package/dist/mdns.js +1 -1
  136. package/dist/mdns.js.map +1 -1
  137. package/dist/native-dependency-diagnostics.d.ts +29 -0
  138. package/dist/native-dependency-diagnostics.d.ts.map +1 -0
  139. package/dist/native-dependency-diagnostics.js +95 -0
  140. package/dist/native-dependency-diagnostics.js.map +1 -0
  141. package/dist/observed-steering.d.ts +4 -4
  142. package/dist/observed-steering.d.ts.map +1 -1
  143. package/dist/observed-steering.js +28 -32
  144. package/dist/observed-steering.js.map +1 -1
  145. package/dist/openclaw-gateway-stability.d.ts +94 -0
  146. package/dist/openclaw-gateway-stability.d.ts.map +1 -0
  147. package/dist/openclaw-gateway-stability.js +176 -0
  148. package/dist/openclaw-gateway-stability.js.map +1 -0
  149. package/dist/pairing-knocks.d.ts +80 -0
  150. package/dist/pairing-knocks.d.ts.map +1 -0
  151. package/dist/pairing-knocks.js +312 -0
  152. package/dist/pairing-knocks.js.map +1 -0
  153. package/dist/passive-observer.d.ts +5 -0
  154. package/dist/passive-observer.d.ts.map +1 -1
  155. package/dist/passive-observer.js +6 -0
  156. package/dist/passive-observer.js.map +1 -1
  157. package/dist/pixoo/micro-glyphs.d.ts +4 -0
  158. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  159. package/dist/pixoo/micro-glyphs.js +29 -0
  160. package/dist/pixoo/micro-glyphs.js.map +1 -1
  161. package/dist/pixoo/pixoo-font.d.ts.map +1 -1
  162. package/dist/pixoo/pixoo-font.js +3 -1
  163. package/dist/pixoo/pixoo-font.js.map +1 -1
  164. package/dist/pixoo/pixoo-renderer.d.ts +1 -1
  165. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  166. package/dist/pixoo/pixoo-renderer.js +17 -2
  167. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  168. package/dist/pty-manager.d.ts +9 -0
  169. package/dist/pty-manager.d.ts.map +1 -1
  170. package/dist/pty-manager.js +63 -4
  171. package/dist/pty-manager.js.map +1 -1
  172. package/dist/relayed-usage.d.ts +30 -0
  173. package/dist/relayed-usage.d.ts.map +1 -1
  174. package/dist/relayed-usage.js +25 -2
  175. package/dist/relayed-usage.js.map +1 -1
  176. package/dist/review-runner.js +1 -1
  177. package/dist/subagent-timeline.d.ts +27 -0
  178. package/dist/subagent-timeline.d.ts.map +1 -1
  179. package/dist/subagent-timeline.js +52 -5
  180. package/dist/subagent-timeline.js.map +1 -1
  181. package/dist/surface-protocol.d.ts.map +1 -1
  182. package/dist/surface-protocol.js +2 -0
  183. package/dist/surface-protocol.js.map +1 -1
  184. package/dist/tui/dashboard.d.ts.map +1 -1
  185. package/dist/tui/dashboard.js +8 -1
  186. package/dist/tui/dashboard.js.map +1 -1
  187. package/dist/usage-api.d.ts +3 -0
  188. package/dist/usage-api.d.ts.map +1 -1
  189. package/dist/usage-api.js +101 -36
  190. package/dist/usage-api.js.map +1 -1
  191. package/dist/usage-event.d.ts.map +1 -1
  192. package/dist/usage-event.js +2 -1
  193. package/dist/usage-event.js.map +1 -1
  194. package/dist/ws-server.d.ts +18 -0
  195. package/dist/ws-server.d.ts.map +1 -1
  196. package/dist/ws-server.js +40 -2
  197. package/dist/ws-server.js.map +1 -1
  198. package/package.json +6 -5
  199. package/src/timebox/sync_ble.py +35 -16
@@ -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
- boundary: prefixed[2] === 'turn_complete' ? 'stop'
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
- : { enabled: false, error: apme === null ? 'see startup logs (initApme returned null)' : 'unknown' },
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 (typeof tp === 'string' && tp) {
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
- // Codex's stop payload rarely carries the text inline; its rollout
2747
- // JSONL (agent_message / task_complete records) is the
2748
- // authoritative source — the Codex counterpart of Claude's
2749
- // transcript tail. Read it whenever a completion row might be
2750
- // emitted (open turn, or a response-only close below).
2751
- const rolloutOutcome = hookAgentType === 'codex-cli' && !tp && !inlineResponse
2752
- ? codexTurnOutcomeFromRollout(hookSid)
2753
- : { text: '', error: undefined, errorKind: undefined };
2754
- const rolloutResponse = rolloutOutcome.text;
2755
- const responseText = tp
2756
- ? stripUnsafeText(lastAssistantTextFromTranscript(tp))
2757
- : stripUnsafeText(inlineResponse || rolloutResponse);
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
- return prepareForSerial(event);
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 withCensus = census ? { ...withReview, subagents: census } : withReview;
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. The backlog this
6069
- // reaper drains predates the response-capture fix, so most of those
6070
- // tasks hold prompts and tool calls but no reply scoring them would
6071
- // push hundreds of "judged against silence" rows into the scorecard,
6072
- // the same noise `response_kind` exists to keep out. Closing the row
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: 'orphaned',
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.