@agentdeck/bridge 1.2.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 (123) hide show
  1. package/dist/apme/classifier.d.ts.map +1 -1
  2. package/dist/apme/classifier.js +18 -10
  3. package/dist/apme/classifier.js.map +1 -1
  4. package/dist/apme/claude-transcript-reader.d.ts +36 -0
  5. package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
  6. package/dist/apme/claude-transcript-reader.js +78 -2
  7. package/dist/apme/claude-transcript-reader.js.map +1 -1
  8. package/dist/apme/collector.d.ts +109 -3
  9. package/dist/apme/collector.d.ts.map +1 -1
  10. package/dist/apme/collector.js +408 -44
  11. package/dist/apme/collector.js.map +1 -1
  12. package/dist/apme/dashboard-html.d.ts.map +1 -1
  13. package/dist/apme/dashboard-html.js +4 -2
  14. package/dist/apme/dashboard-html.js.map +1 -1
  15. package/dist/apme/graph.d.ts +1 -1
  16. package/dist/apme/graph.d.ts.map +1 -1
  17. package/dist/apme/graph.js +51 -1
  18. package/dist/apme/graph.js.map +1 -1
  19. package/dist/apme/http.d.ts.map +1 -1
  20. package/dist/apme/http.js +4 -0
  21. package/dist/apme/http.js.map +1 -1
  22. package/dist/apme/index.d.ts +4 -0
  23. package/dist/apme/index.d.ts.map +1 -1
  24. package/dist/apme/index.js +13 -1
  25. package/dist/apme/index.js.map +1 -1
  26. package/dist/apme/runner.d.ts +90 -0
  27. package/dist/apme/runner.d.ts.map +1 -1
  28. package/dist/apme/runner.js +524 -48
  29. package/dist/apme/runner.js.map +1 -1
  30. package/dist/apme/sample-to-timeline.d.ts.map +1 -1
  31. package/dist/apme/sample-to-timeline.js +5 -0
  32. package/dist/apme/sample-to-timeline.js.map +1 -1
  33. package/dist/apme/settings.d.ts +8 -0
  34. package/dist/apme/settings.d.ts.map +1 -1
  35. package/dist/apme/settings.js +4 -0
  36. package/dist/apme/settings.js.map +1 -1
  37. package/dist/apme/store.d.ts +77 -8
  38. package/dist/apme/store.d.ts.map +1 -1
  39. package/dist/apme/store.js +189 -17
  40. package/dist/apme/store.js.map +1 -1
  41. package/dist/apme/task-gradeability.d.ts +82 -0
  42. package/dist/apme/task-gradeability.d.ts.map +1 -0
  43. package/dist/apme/task-gradeability.js +126 -0
  44. package/dist/apme/task-gradeability.js.map +1 -0
  45. package/dist/awaiting-overlay.d.ts +13 -26
  46. package/dist/awaiting-overlay.d.ts.map +1 -1
  47. package/dist/awaiting-overlay.js +7 -40
  48. package/dist/awaiting-overlay.js.map +1 -1
  49. package/dist/claude-permission-rules.d.ts +20 -32
  50. package/dist/claude-permission-rules.d.ts.map +1 -1
  51. package/dist/claude-permission-rules.js +19 -102
  52. package/dist/claude-permission-rules.js.map +1 -1
  53. package/dist/claude-usage-recovery.d.ts +54 -0
  54. package/dist/claude-usage-recovery.d.ts.map +1 -0
  55. package/dist/claude-usage-recovery.js +200 -0
  56. package/dist/claude-usage-recovery.js.map +1 -0
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +28 -8
  59. package/dist/cli.js.map +1 -1
  60. package/dist/codex-rollout-response.d.ts +43 -0
  61. package/dist/codex-rollout-response.d.ts.map +1 -1
  62. package/dist/codex-rollout-response.js +67 -0
  63. package/dist/codex-rollout-response.js.map +1 -1
  64. package/dist/coordination-evidence.d.ts +150 -0
  65. package/dist/coordination-evidence.d.ts.map +1 -0
  66. package/dist/coordination-evidence.js +455 -0
  67. package/dist/coordination-evidence.js.map +1 -0
  68. package/dist/daemon-server.d.ts.map +1 -1
  69. package/dist/daemon-server.js +329 -34
  70. package/dist/daemon-server.js.map +1 -1
  71. package/dist/daemon.js +1 -1
  72. package/dist/deferred-reply-read.d.ts +24 -0
  73. package/dist/deferred-reply-read.d.ts.map +1 -0
  74. package/dist/deferred-reply-read.js +42 -0
  75. package/dist/deferred-reply-read.js.map +1 -0
  76. package/dist/esp32-serial.d.ts +11 -0
  77. package/dist/esp32-serial.d.ts.map +1 -1
  78. package/dist/esp32-serial.js +57 -2
  79. package/dist/esp32-serial.js.map +1 -1
  80. package/dist/hook-codex-sessions.d.ts +9 -0
  81. package/dist/hook-codex-sessions.d.ts.map +1 -1
  82. package/dist/hook-codex-sessions.js +35 -2
  83. package/dist/hook-codex-sessions.js.map +1 -1
  84. package/dist/hook-opencode-sessions.d.ts +62 -0
  85. package/dist/hook-opencode-sessions.d.ts.map +1 -0
  86. package/dist/hook-opencode-sessions.js +180 -0
  87. package/dist/hook-opencode-sessions.js.map +1 -0
  88. package/dist/hook-response-source.d.ts +40 -0
  89. package/dist/hook-response-source.d.ts.map +1 -0
  90. package/dist/hook-response-source.js +31 -0
  91. package/dist/hook-response-source.js.map +1 -0
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +4 -1
  94. package/dist/index.js.map +1 -1
  95. package/dist/native-dependency-diagnostics.d.ts +29 -0
  96. package/dist/native-dependency-diagnostics.d.ts.map +1 -0
  97. package/dist/native-dependency-diagnostics.js +95 -0
  98. package/dist/native-dependency-diagnostics.js.map +1 -0
  99. package/dist/observed-steering.d.ts +4 -4
  100. package/dist/observed-steering.d.ts.map +1 -1
  101. package/dist/observed-steering.js +28 -32
  102. package/dist/observed-steering.js.map +1 -1
  103. package/dist/openclaw-gateway-stability.d.ts +94 -0
  104. package/dist/openclaw-gateway-stability.d.ts.map +1 -0
  105. package/dist/openclaw-gateway-stability.js +176 -0
  106. package/dist/openclaw-gateway-stability.js.map +1 -0
  107. package/dist/passive-observer.d.ts +5 -0
  108. package/dist/passive-observer.d.ts.map +1 -1
  109. package/dist/passive-observer.js +6 -0
  110. package/dist/passive-observer.js.map +1 -1
  111. package/dist/review-runner.js +1 -1
  112. package/dist/subagent-timeline.d.ts +27 -0
  113. package/dist/subagent-timeline.d.ts.map +1 -1
  114. package/dist/subagent-timeline.js +52 -5
  115. package/dist/subagent-timeline.js.map +1 -1
  116. package/dist/usage-api.d.ts +3 -0
  117. package/dist/usage-api.d.ts.map +1 -1
  118. package/dist/usage-api.js +101 -36
  119. package/dist/usage-api.js.map +1 -1
  120. package/dist/usage-event.d.ts.map +1 -1
  121. package/dist/usage-event.js +2 -1
  122. package/dist/usage-event.js.map +1 -1
  123. package/package.json +5 -5
@@ -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,13 +73,28 @@ 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';
@@ -90,9 +107,12 @@ import { rgbToBmp, pixooLiveHtml } from './hook-server.js';
90
107
  import { enableDebugLog, debug, debugThrottled } from './logger.js';
91
108
  import { LegacyRearmLedger } from './legacy-rearm-ledger.js';
92
109
  import { CodexOtelTracker, CODEX_OTEL_TRACES_PATH, spanNameSummary } from './codex-otel.js';
93
- import { HookCodexSessions } from './hook-codex-sessions.js';
110
+ import { HookCodexSessions, buildCodexPermissionQuestion } from './hook-codex-sessions.js';
94
111
  import { ObservedTurnWatchdogs } from './observed-turn-watchdogs.js';
95
- 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';
96
116
  import { FallbackTaskTimeline } from './fallback-task-timeline.js';
97
117
  import { handleApmeRequest } from './apme/http.js';
98
118
  import { readModelFromTranscript } from './apme/claude-transcript-reader.js';
@@ -102,7 +122,7 @@ import { KiroTimelineFeed } from './kiro-timeline-feed.js';
102
122
  import { OpenClawTimelineFeed, OPENCLAW_FEED_INTERVAL_MS } from './openclaw-timeline-feed.js';
103
123
  import { DeviceVoiceReplyRouter, speakableReply, spokenDigest, pcmFromWav, } from './device-voice-reply.js';
104
124
  import { rawSessionId } from '@agentdeck/shared';
105
- import { codexTurnOutcomeFromRollout, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
125
+ import { codexTurnOutcomeFromRollout, codexTurnOutcomeFromRolloutPath, lastAgentMessageFromCodexRollout } from './codex-rollout-response.js';
106
126
  import { callFoundationModelsHelper } from './foundation-models-helper.js';
107
127
  import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
108
128
  import { bindHostFor, daemonModuleConfigs, describeDaemonPosture, resolveDaemonPosture, } from './network-posture.js';
@@ -114,6 +134,7 @@ import { getPixooDeviceDetails, pixooDeviceCount } from './pixoo/pixoo-bridge.js
114
134
  import { loadTimeboxDevices } from './timebox/timebox-settings.js';
115
135
  import { getLanIp, stripUnsafeText, cleanRawText, prepareMarkdownDetail, normalizeCommandPrompt, formatDurationSec } from '@agentdeck/shared';
116
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';
117
138
  import { buildCardFeed, buildGlance, projectPortableReaderGlance, applyOutboxDecisions, FeedPullTracker, formatFeedPull, applyPullOtaBootstrap, OutboxIdempotencyLedger, normalizeClientIp, parsePullTelemetry, } from './card-feed.js';
118
139
  import { threadModule } from './card-modules.js';
119
140
  import { AutonomousPocketEngine, createAutonomousPocketModules, parsePocketAutonomyConfig, } from './pocket-autonomy.js';
@@ -957,12 +978,15 @@ export function classifyObservedHookEvent(eventName, mapped) {
957
978
  if (eventName === 'codex_subagent_start' || eventName === 'codex_subagent_stop') {
958
979
  return { boundary: eventName, agentType: 'codex-cli' };
959
980
  }
960
- 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)$/
961
982
  .exec(eventName);
962
983
  if (!prefixed)
963
984
  return { boundary: mapped, agentType: 'claude-code' };
964
985
  return {
965
- 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'
966
990
  : prefixed[2] === 'agent_spawn' ? 'session_start'
967
991
  : prefixed[2],
968
992
  agentType: prefixed[1] === 'codex' ? 'codex-cli'
@@ -1303,6 +1327,7 @@ export async function startDaemon(opts) {
1303
1327
  // Codex sessions known only from `codex_*` hooks — the backstop for when the
1304
1328
  // process scan can't see one (lsof timeout, no rollout held open).
1305
1329
  const hookCodexSessions = new HookCodexSessions();
1330
+ const hookOpenCodeSessions = new HookOpenCodeSessions();
1306
1331
  // Declared before the HTTP server: the PreToolUse route reads it to decide
1307
1332
  // whether this daemon can type into a session's terminal, and hooks start
1308
1333
  // arriving the moment the port binds — several hundred milliseconds before
@@ -1353,6 +1378,55 @@ export async function startDaemon(opts) {
1353
1378
  // `gatewaySessionState` / `gatewayModelName` dedicated fields.
1354
1379
  let gatewaySessionState = 'idle';
1355
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
+ }
1356
1430
  /**
1357
1431
  * Snapshot for an `openclaw` state event: the global machine with the
1358
1432
  * Gateway-local model pinned back on.
@@ -1436,6 +1510,9 @@ export async function startDaemon(opts) {
1436
1510
  // Assigned after BridgeCore exists. The HTTP handler reads it only after
1437
1511
  // startup; nullable keeps the tiny listen→core initialization window safe.
1438
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();
1439
1516
  // The learning pack is immutable for one daemon lifetime. Package upgrades
1440
1517
  // arrive with a new AgentDeck build; validating once keeps every sleeping
1441
1518
  // reader pull cheap while a corrupt/mislicensed bundle fails closed.
@@ -1558,6 +1635,9 @@ export async function startDaemon(opts) {
1558
1635
  res.end(JSON.stringify({
1559
1636
  status: 'ok', mode: 'daemon', state: snap.state,
1560
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,
1561
1641
  uptime: process.uptime(), port, pid: process.pid,
1562
1642
  // Which build is SERVING this port. Captured when this process started
1563
1643
  // (see daemon-build-identity.ts) and never recomputed, so a rebuild
@@ -1583,7 +1663,10 @@ export async function startDaemon(opts) {
1583
1663
  dbPath: apme.store.dbPath,
1584
1664
  judgeBackend: apme.runner.lastBackendProbe ?? { status: 'unknown', backend: loadApmeConfig().judge.backend },
1585
1665
  }
1586
- : { enabled: false, error: apme === null ? 'see startup logs (initApme returned null)' : 'unknown' },
1666
+ : {
1667
+ enabled: false,
1668
+ error: getApmeInitFailure() ?? 'unknown init failure',
1669
+ },
1587
1670
  }));
1588
1671
  return;
1589
1672
  }
@@ -1743,6 +1826,7 @@ export async function startDaemon(opts) {
1743
1826
  available: core.cachedGatewayAvailable,
1744
1827
  connected: core.cachedGatewayConnected,
1745
1828
  hasError: core.cachedGatewayHasError,
1829
+ instability: gatewayInstability,
1746
1830
  },
1747
1831
  clients: core.wsServer.getClientCount(),
1748
1832
  modules: moduleHealthProvider(),
@@ -2627,6 +2711,15 @@ export async function startDaemon(opts) {
2627
2711
  json = body ? JSON.parse(body) : {};
2628
2712
  }
2629
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
+ }
2630
2723
  // Map PascalCase event names to snake_case for state machine + APME
2631
2724
  const eventMap = {
2632
2725
  SessionStart: 'session_start', SessionEnd: 'session_end',
@@ -2643,6 +2736,10 @@ export async function startDaemon(opts) {
2643
2736
  // codex/opencode *state* is owned by the passive observer's turn
2644
2737
  // semantics, not these hooks.
2645
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;
2646
2743
  const earlyHookSid = typeof json.session_id === 'string' && json.session_id
2647
2744
  ? json.session_id : 'daemon-hook';
2648
2745
  const earlyHookCwd = (typeof json.cwd === 'string' ? json.cwd
@@ -2665,6 +2762,12 @@ export async function startDaemon(opts) {
2665
2762
  if (childResult?.censusChangedFor) {
2666
2763
  core.broadcastSessionsList().catch(() => { });
2667
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
+ }
2668
2771
  const childHook = childResult?.childOnly === true;
2669
2772
  if (childHook) {
2670
2773
  // Child activity is observation-only. Never let child PreToolUse
@@ -2686,6 +2789,39 @@ export async function startDaemon(opts) {
2686
2789
  projectName: earlyHookProject,
2687
2790
  toolName: typeof json.tool_name === 'string' ? json.tool_name : undefined,
2688
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
+ });
2689
2825
  }
2690
2826
  // State machine
2691
2827
  if (mapped === 'session_start')
@@ -2751,7 +2887,7 @@ export async function startDaemon(opts) {
2751
2887
  // never held again this session. User prompt / session end clear
2752
2888
  // pending STOP + queued directives (the user took over).
2753
2889
  if (mapped === 'tool_end') {
2754
- noteToolEnd(claudeSid, toolName || undefined);
2890
+ noteToolEnd(claudeSid, toolName || undefined, toolUseId);
2755
2891
  }
2756
2892
  else if (mapped === 'user_prompt_submit') {
2757
2893
  if (clearOnUserPrompt(claudeSid))
@@ -2850,6 +2986,10 @@ export async function startDaemon(opts) {
2850
2986
  // Mirrors the Swift daemon's session_end force-close (`hasOpenTurn`
2851
2987
  // → interrupted chat_end).
2852
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);
2853
2993
  const rows = core.bridgeTimeline.getHistoryForSession(hookSid, undefined, 24);
2854
2994
  let lastStart;
2855
2995
  let lastCompletionTs = 0;
@@ -2909,13 +3049,35 @@ export async function startDaemon(opts) {
2909
3049
  // codex_* / opencode_* names would silently skip turn management.
2910
3050
  apme.collector.ingestHook(hookSid, boundary, json);
2911
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
+ }
2912
3066
  // Direct `claude` runs reach the daemon only via these hooks, which
2913
3067
  // never carry the model — so every such run persisted model_id=NULL.
2914
3068
  // Recover it from the transcript Claude writes. Must run before
2915
3069
  // closeRun tears down the session→run mapping.
2916
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() : '';
2917
3076
  const tp = json.transcript_path;
2918
- 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) {
2919
3081
  const model = readModelFromTranscript(tp);
2920
3082
  if (model)
2921
3083
  apme.collector.updateModel(hookSid, model);
@@ -2994,18 +3156,31 @@ export async function startDaemon(opts) {
2994
3156
  json.result,
2995
3157
  ]
2996
3158
  .find((v) => typeof v === 'string' && v.trim().length > 0) ?? '';
2997
- // Codex's stop payload rarely carries the text inline; its rollout
2998
- // JSONL (agent_message / task_complete records) is the
2999
- // authoritative source — the Codex counterpart of Claude's
3000
- // transcript tail. Read it whenever a completion row might be
3001
- // emitted (open turn, or a response-only close below).
3002
- const rolloutOutcome = hookAgentType === 'codex-cli' && !tp && !inlineResponse
3003
- ? codexTurnOutcomeFromRollout(hookSid)
3004
- : { text: '', error: undefined, errorKind: undefined };
3005
- const rolloutResponse = rolloutOutcome.text;
3006
- const responseText = tp
3007
- ? stripUnsafeText(lastAssistantTextFromTranscript(tp))
3008
- : 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
+ }
3009
3184
  const respRaw = responseText.length > 0
3010
3185
  ? cleanRawText(responseText.length > 200 ? responseText.slice(0, 197) + '...' : responseText)
3011
3186
  : '';
@@ -3223,6 +3398,7 @@ export async function startDaemon(opts) {
3223
3398
  // the signature is suppressed for this session.
3224
3399
  gateReleased(claudeSid, requestId, {
3225
3400
  undecided: decision === 'pass', tool: toolName, toolInput,
3401
+ toolUseId: typeof json.tool_use_id === 'string' ? json.tool_use_id : undefined,
3226
3402
  });
3227
3403
  clearAwaitingOverlay(claudeSid);
3228
3404
  core.broadcastSessionsList().catch(() => { });
@@ -3583,6 +3759,7 @@ export async function startDaemon(opts) {
3583
3759
  // Keep the original startedAt stable when the self-heal timer rewrites it.
3584
3760
  const daemonInfo = { port, pid: process.pid, startedAt: new Date().toISOString() };
3585
3761
  writeDaemonInfo(daemonInfo);
3762
+ enableClaudeUsageRecovery();
3586
3763
  // ===== BridgeCore =====
3587
3764
  const core = new BridgeCore({
3588
3765
  port,
@@ -3640,7 +3817,8 @@ export async function startDaemon(opts) {
3640
3817
  // event and WiFi resumes automatically — no state migration needed.
3641
3818
  if (SERIAL_FORWARDED_EVENTS.has(event.type) && isWifiEsp32RedundantWithSerial(client))
3642
3819
  return null;
3643
- return prepareForSerial(event);
3820
+ const identity = wifiEsp32IdentityForSocket(client);
3821
+ return prepareForSerial(event, identity?.board ? { deviceInfo: { board: identity.board } } : undefined);
3644
3822
  });
3645
3823
  // Timeline
3646
3824
  //
@@ -4139,6 +4317,41 @@ export async function startDaemon(opts) {
4139
4317
  log('[agentdeck] APME timeline projection ENABLED — chat/tool rows derive from SessionSample');
4140
4318
  }
4141
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
+ }
4142
4355
  // Fire-and-forget judge backend probe. Result is cached on
4143
4356
  // apme.runner.lastBackendProbe and surfaced on /health so users discover
4144
4357
  // misconfiguration (no MLX server running, missing API key, etc.) without
@@ -4167,6 +4380,33 @@ export async function startDaemon(opts) {
4167
4380
  // The observer scans in the background now (collect() returns the cache
4168
4381
  // immediately). When a scan lands fresh observations, push them out via
4169
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?.();
4170
4410
  passiveSessionObserver.onRefreshed = () => {
4171
4411
  // Kiro pushes nothing: no `kiro_*` hook has ever reached this daemon, so
4172
4412
  // without a producer here its session shows in the HUD while the timeline
@@ -4227,6 +4467,7 @@ export async function startDaemon(opts) {
4227
4467
  // worth a broadcast of its own.
4228
4468
  codexOtel.onChanged = () => core.maybeBroadcastSessionsList();
4229
4469
  hookCodexSessions.onChanged = () => core.maybeBroadcastSessionsList();
4470
+ hookOpenCodeSessions.onChanged = () => core.maybeBroadcastSessionsList();
4230
4471
  // ===== Gateway adapter lifecycle =====
4231
4472
  // (gatewayAdapter + gatewayConnecting declared earlier, before HTTP server)
4232
4473
  // Inject OpenClaw virtual session only after Gateway authentication succeeds.
@@ -4242,7 +4483,7 @@ export async function startDaemon(opts) {
4242
4483
  // synthesize a `codex-app` row for any thread the process scan missed —
4243
4484
  // Swift's only Codex-app source, here a second opinion on top of the
4244
4485
  // observer. See bridge/src/codex-otel.ts.
4245
- const observed = applyAwaitingOverlayToObserved(hookCodexSessions.applyTo(codexOtel.applyTo(passiveSessionObserver.collect(sessions))))
4486
+ const observed = applyAwaitingOverlayToObserved(hookOpenCodeSessions.applyTo(hookCodexSessions.applyTo(codexOtel.applyTo(passiveSessionObserver.collect(sessions)))))
4246
4487
  .map((s) => {
4247
4488
  // Steering feedback for observed Claude sessions: devices render
4248
4489
  // "stopping at next tool" / queued-directive badges from these.
@@ -4276,6 +4517,7 @@ export async function startDaemon(opts) {
4276
4517
  // while these rows are `observed:<agent>:<uuid>`, so normalize before the
4277
4518
  // lookup — the two id forms are the standing trap here.
4278
4519
  const subagentCensus = subagentTimeline?.summaries() ?? new Map();
4520
+ const coordinationCensus = coordination.summaries();
4279
4521
  const enrichedSessions = [...sessions, ...observed, ...remote].map((s) => {
4280
4522
  // On-demand review badge (REVIEW tile verdict / REVIEWING state) —
4281
4523
  // applies to every session type, managed included.
@@ -4285,7 +4527,10 @@ export async function startDaemon(opts) {
4285
4527
  // field that disappears when the last child exits latches "8 running" on
4286
4528
  // every client that merges retain-on-absent.
4287
4529
  const census = subagentCensus.get(rawSessionId(withReview.id));
4288
- 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;
4289
4534
  if (withCensus.elapsedSec != null || !withCensus.startedAt)
4290
4535
  return withCensus;
4291
4536
  const sec = Math.round((now - Date.parse(withCensus.startedAt)) / 1000);
@@ -4516,6 +4761,7 @@ export async function startDaemon(opts) {
4516
4761
  gatewaySessionState = 'idle';
4517
4762
  bridgeLogStream.start();
4518
4763
  log('[agentdeck] OpenClaw Gateway connected');
4764
+ settleGatewayInstability(Date.now());
4519
4765
  if (core.stateMachine.getSnapshot().state === 'disconnected') {
4520
4766
  core.stateMachine.handleHookEvent('SessionStart', {});
4521
4767
  }
@@ -4541,6 +4787,11 @@ export async function startDaemon(opts) {
4541
4787
  log('[agentdeck] OpenClaw Gateway disconnected');
4542
4788
  core.stateMachine.emit('state_changed', core.stateMachine.getSnapshot());
4543
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
+ });
4544
4795
  }
4545
4796
  break;
4546
4797
  }
@@ -6254,12 +6505,49 @@ export async function startDaemon(opts) {
6254
6505
  };
6255
6506
  core.broadcast(evalEvent);
6256
6507
  });
6508
+ let judgeReprobeInFlight = false;
6257
6509
  const apmeEvalTimer = setInterval(() => {
6258
6510
  // 1. Enqueue unevaluated runs for deterministic + judge
6259
6511
  const pending = apme.store.listUnevaluatedRuns(5);
6260
6512
  for (const run of pending) {
6261
6513
  apme.runner.enqueue({ runId: run.id, projectPath: run.projectPath ?? undefined });
6262
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
+ }
6263
6551
  // 2. Run outcome detection + composite scoring on recently closed runs
6264
6552
  // that don't have an outcome yet.
6265
6553
  const closedRuns = apme.store.listRuns({ limit: 20 });
@@ -6321,26 +6609,29 @@ export async function startDaemon(opts) {
6321
6609
  // Batched small: `enqueueTask` dispatches immediately with no
6322
6610
  // concurrency cap, so a backlog is drained a few per tick rather than
6323
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.
6324
6616
  const abandoned = apme.store.listAbandonedRuns(APME_ABANDONED_RUN_STALE_SEC, 5);
6325
6617
  for (const run of abandoned) {
6326
6618
  if (apme.collector.isLiveRun(run.id))
6327
6619
  continue; // still owned by us
6328
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);
6329
6624
  for (const task of closedTasks) {
6330
- // Judge only what there is something to judge. The backlog this
6331
- // reaper drains predates the response-capture fix, so most of those
6332
- // tasks hold prompts and tool calls but no reply scoring them would
6333
- // push hundreds of "judged against silence" rows into the scorecard,
6334
- // the same noise `response_kind` exists to keep out. Closing the row
6335
- // is the data-hygiene win; the eval is a bonus when it can be earned.
6336
- const hasReply = apme.store.listTurnsForTask(task.id)
6337
- .some((t) => typeof t.response === 'string' && t.response.trim().length > 0);
6338
- 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)
6339
6630
  continue;
6340
6631
  apme.runner.enqueueTask({
6341
6632
  runId: run.id, taskId: task.id,
6342
6633
  ...(task.category ? { category: task.category } : {}),
6343
- boundarySignal: 'orphaned',
6634
+ boundarySignal: task.boundarySignal,
6344
6635
  });
6345
6636
  }
6346
6637
  debug('APME', `reaped abandoned run ${run.id.slice(0, 8)} — ${closedTasks.length} task(s) closed`);
@@ -6423,6 +6714,10 @@ export async function startDaemon(opts) {
6423
6714
  // would keep firing self-POSTs at a port this daemon no longer owns.
6424
6715
  observedStopWatchdogs.stop();
6425
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();
6426
6721
  bridgeLogStream.stop();
6427
6722
  // Flush synchronously — the process exits a few lines below and a pending
6428
6723
  // debounce timer would take the last turn's entries with it.