@adhdev/daemon-core 0.9.82-rc.34 → 0.9.82-rc.341

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 (318) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +8 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/build-info.d.ts +37 -0
  4. package/dist/chat/source-machine.d.ts +166 -0
  5. package/dist/chat/source-resolver.d.ts +104 -0
  6. package/dist/chat/subscription-updates.d.ts +1 -0
  7. package/dist/cli-adapter-types.d.ts +46 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +198 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +116 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +53 -0
  14. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  15. package/dist/cli-adapters/resolve-executable.d.ts +14 -0
  16. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  17. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  18. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  19. package/dist/commands/cli-manager.d.ts +2 -1
  20. package/dist/commands/handler.d.ts +110 -0
  21. package/dist/commands/mesh-coordinator.d.ts +85 -1
  22. package/dist/commands/router.d.ts +308 -1
  23. package/dist/commands/upgrade-helper.d.ts +12 -0
  24. package/dist/config/chat-history.d.ts +9 -0
  25. package/dist/config/config.d.ts +5 -0
  26. package/dist/config/mesh-config.d.ts +77 -1
  27. package/dist/detection/ide-detector.d.ts +13 -0
  28. package/dist/detection/win32-ide-version.d.ts +37 -0
  29. package/dist/git/change-impact-config.d.ts +159 -0
  30. package/dist/git/git-commands.d.ts +11 -1
  31. package/dist/git/git-diff.d.ts +6 -0
  32. package/dist/git/git-executor.d.ts +11 -0
  33. package/dist/git/git-status.d.ts +23 -0
  34. package/dist/git/git-types.d.ts +2 -50
  35. package/dist/git/index.d.ts +2 -0
  36. package/dist/index.d.ts +52 -13
  37. package/dist/index.js +42012 -17098
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +41683 -16890
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/installer.d.ts +1 -4
  42. package/dist/ipc/local-ipc-server.d.ts +91 -0
  43. package/dist/launch.d.ts +1 -1
  44. package/dist/logging/async-batch-writer.d.ts +10 -0
  45. package/dist/logging/log-redactor.d.ts +24 -0
  46. package/dist/logging/log-tail-reader.d.ts +46 -0
  47. package/dist/mesh/contracts.d.ts +164 -0
  48. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  49. package/dist/mesh/coordinator-registry.d.ts +59 -0
  50. package/dist/mesh/mesh-active-work.d.ts +174 -0
  51. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  52. package/dist/mesh/mesh-events-coordinator.d.ts +169 -0
  53. package/dist/mesh/mesh-events-pending.d.ts +45 -0
  54. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  55. package/dist/mesh/mesh-events-utils.d.ts +66 -0
  56. package/dist/mesh/mesh-events.d.ts +6 -49
  57. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  58. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  59. package/dist/mesh/mesh-init.d.ts +86 -0
  60. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  61. package/dist/mesh/mesh-ledger.d.ts +77 -1
  62. package/dist/mesh/mesh-missions.d.ts +116 -0
  63. package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
  64. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  65. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  66. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  67. package/dist/mesh/mesh-routing.d.ts +70 -0
  68. package/dist/mesh/mesh-runtime-store.d.ts +418 -0
  69. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  70. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  71. package/dist/mesh/mesh-work-queue.d.ts +208 -5
  72. package/dist/mesh/preview-freshness.d.ts +18 -0
  73. package/dist/mesh/refine-config.d.ts +216 -0
  74. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  75. package/dist/providers/acp-provider-instance.d.ts +2 -0
  76. package/dist/providers/approval-utils.d.ts +13 -0
  77. package/dist/providers/chat-message-normalization.d.ts +12 -0
  78. package/dist/providers/cli-provider-instance.d.ts +76 -3
  79. package/dist/providers/contracts.d.ts +139 -6
  80. package/dist/providers/external-sources.d.ts +71 -0
  81. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  82. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  83. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  84. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  85. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  86. package/dist/providers/native-history/index.d.ts +13 -0
  87. package/dist/providers/provider-instance-manager.d.ts +13 -0
  88. package/dist/providers/provider-instance.d.ts +13 -1
  89. package/dist/providers/provider-loader.d.ts +26 -4
  90. package/dist/providers/provider-trust.d.ts +31 -0
  91. package/dist/providers/read-chat-contract.d.ts +29 -0
  92. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  93. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  94. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  95. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  96. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  97. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  98. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  99. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  100. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  101. package/dist/providers/sdk/v1/index.d.ts +30 -0
  102. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  103. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  104. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  105. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  106. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  107. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  108. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  109. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  110. package/dist/providers/spec/adapter.d.ts +69 -0
  111. package/dist/providers/spec/cli-adapter.d.ts +217 -0
  112. package/dist/providers/spec/evaluator.d.ts +23 -0
  113. package/dist/providers/spec/fsm-driver.d.ts +306 -0
  114. package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
  115. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  116. package/dist/providers/spec/fsm-types.d.ts +144 -0
  117. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  118. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  119. package/dist/providers/spec/route.d.ts +4 -0
  120. package/dist/providers/spec/types.d.ts +180 -0
  121. package/dist/providers/status-monitor.d.ts +7 -7
  122. package/dist/providers/transcript-v2.d.ts +176 -0
  123. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  124. package/dist/providers/working-dir.d.ts +17 -0
  125. package/dist/repo-mesh-types.d.ts +295 -13
  126. package/dist/sessions/registry.d.ts +6 -0
  127. package/dist/shared-types-extra.d.ts +2 -4
  128. package/dist/shared-types.d.ts +59 -2
  129. package/dist/status/normalize.d.ts +1 -1
  130. package/dist/status/normalize.js +1 -0
  131. package/dist/status/normalize.js.map +1 -1
  132. package/dist/status/normalize.mjs +1 -0
  133. package/dist/status/normalize.mjs.map +1 -1
  134. package/dist/status/reporter.d.ts +2 -0
  135. package/dist/status/snapshot.d.ts +26 -0
  136. package/dist/system/load-better-sqlite3.d.ts +21 -0
  137. package/dist/types.d.ts +5 -0
  138. package/package.json +7 -3
  139. package/src/agent-stream/poller.ts +2 -3
  140. package/src/agent-stream/provider-adapter.ts +1 -1
  141. package/src/boot/daemon-lifecycle.ts +55 -12
  142. package/src/boot/process-hardening.ts +89 -0
  143. package/src/build-info.ts +73 -0
  144. package/src/chat/source-machine.ts +534 -0
  145. package/src/chat/source-resolver.ts +0 -0
  146. package/src/chat/subscription-updates.ts +20 -1
  147. package/src/cli-adapter-types.d.ts +1 -0
  148. package/src/cli-adapter-types.ts +47 -2
  149. package/src/cli-adapters/cli-script-runner.ts +421 -0
  150. package/src/cli-adapters/cli-state-engine.ts +1215 -0
  151. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  152. package/src/cli-adapters/provider-cli-adapter.ts +774 -1141
  153. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  154. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  155. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  156. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  157. package/src/cli-adapters/provider-cli-shared.ts +100 -11
  158. package/src/cli-adapters/pty-transport.ts +2 -1
  159. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  160. package/src/cli-adapters/resolve-executable.ts +90 -0
  161. package/src/cli-adapters/session-host-transport.ts +2 -1
  162. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  163. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  164. package/src/cli-adapters/terminal-screen.ts +16 -81
  165. package/src/commands/chat-commands.ts +1823 -62
  166. package/src/commands/cli-manager.ts +286 -14
  167. package/src/commands/handler.ts +809 -2
  168. package/src/commands/mesh-coordinator.ts +332 -122
  169. package/src/commands/router.ts +7433 -1317
  170. package/src/commands/stream-commands.ts +8 -0
  171. package/src/commands/upgrade-helper.ts +81 -30
  172. package/src/config/chat-history.ts +483 -24
  173. package/src/config/config.ts +12 -0
  174. package/src/config/mesh-config.ts +388 -6
  175. package/src/config/recent-activity.ts +8 -2
  176. package/src/daemon/dev-auto-implement.ts +3 -2
  177. package/src/daemon/dev-cli-debug.ts +10 -1
  178. package/src/detection/cli-detector.ts +28 -9
  179. package/src/detection/ide-detector.ts +55 -16
  180. package/src/detection/win32-ide-version.ts +106 -0
  181. package/src/git/change-impact-config.ts +354 -0
  182. package/src/git/git-commands.ts +55 -14
  183. package/src/git/git-diff.ts +53 -0
  184. package/src/git/git-executor.ts +12 -0
  185. package/src/git/git-status.ts +574 -43
  186. package/src/git/git-types.ts +14 -62
  187. package/src/git/git-worktree.ts +8 -1
  188. package/src/git/index.ts +16 -0
  189. package/src/index.ts +146 -12
  190. package/src/installer.d.ts +1 -1
  191. package/src/installer.ts +8 -6
  192. package/src/ipc/local-ipc-server.ts +278 -0
  193. package/src/launch.d.ts +1 -1
  194. package/src/launch.ts +37 -28
  195. package/src/logging/async-batch-writer.ts +55 -0
  196. package/src/logging/log-redactor.ts +100 -0
  197. package/src/logging/log-tail-reader.ts +220 -0
  198. package/src/logging/logger.ts +2 -1
  199. package/src/mesh/contracts.ts +329 -0
  200. package/src/mesh/coordinator-prompt.ts +234 -31
  201. package/src/mesh/coordinator-registry.ts +121 -0
  202. package/src/mesh/mesh-active-work.ts +623 -0
  203. package/src/mesh/mesh-delivery-policy.ts +315 -0
  204. package/src/mesh/mesh-events-coordinator.ts +2144 -0
  205. package/src/mesh/mesh-events-pending.ts +508 -0
  206. package/src/mesh/mesh-events-stale.ts +290 -0
  207. package/src/mesh/mesh-events-utils.ts +300 -0
  208. package/src/mesh/mesh-events.ts +30 -1035
  209. package/src/mesh/mesh-fast-forward.ts +839 -0
  210. package/src/mesh/mesh-host-ownership.ts +73 -0
  211. package/src/mesh/mesh-init.ts +260 -0
  212. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  213. package/src/mesh/mesh-ledger.ts +589 -102
  214. package/src/mesh/mesh-missions.ts +248 -0
  215. package/src/mesh/mesh-reconcile-loop.ts +822 -0
  216. package/src/mesh/mesh-refine-batch.ts +197 -0
  217. package/src/mesh/mesh-refine-status.ts +231 -0
  218. package/src/mesh/mesh-review-inbox.ts +307 -0
  219. package/src/mesh/mesh-routing.ts +291 -0
  220. package/src/mesh/mesh-runtime-store.ts +1626 -0
  221. package/src/mesh/mesh-task-stats.ts +154 -0
  222. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  223. package/src/mesh/mesh-work-queue.ts +888 -141
  224. package/src/mesh/preview-freshness.ts +118 -0
  225. package/src/mesh/refine-config.ts +423 -0
  226. package/src/mesh/worktree-bootstrap-config.ts +331 -0
  227. package/src/providers/acp-provider-instance.ts +25 -9
  228. package/src/providers/approval-utils.d.ts +4 -0
  229. package/src/providers/approval-utils.ts +47 -5
  230. package/src/providers/chat-message-normalization.ts +45 -5
  231. package/src/providers/cli-provider-instance.ts +1038 -99
  232. package/src/providers/contracts.d.ts +55 -0
  233. package/src/providers/contracts.ts +150 -6
  234. package/src/providers/extension-provider-instance.ts +12 -7
  235. package/src/providers/external-sources.ts +218 -0
  236. package/src/providers/ide-provider-instance.ts +35 -12
  237. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  238. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  239. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  240. package/src/providers/native-history/dispatcher.ts +340 -0
  241. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  242. package/src/providers/native-history/index.ts +30 -0
  243. package/src/providers/provider-instance-manager.ts +30 -0
  244. package/src/providers/provider-instance.ts +10 -1
  245. package/src/providers/provider-loader.ts +670 -50
  246. package/src/providers/provider-schema.ts +87 -14
  247. package/src/providers/provider-trust.ts +114 -0
  248. package/src/providers/read-chat-contract.ts +76 -16
  249. package/src/providers/sdk/README.md +49 -0
  250. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  251. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  252. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  253. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  254. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  255. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  256. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  257. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  258. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  259. package/src/providers/sdk/v1/index.ts +152 -0
  260. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  261. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  262. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  263. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  264. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  265. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  266. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  267. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  268. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  269. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  270. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  271. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  272. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  273. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  274. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  275. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  276. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  277. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  278. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  279. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  280. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  281. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  282. package/src/providers/sdk/v1/validators/index.ts +19 -0
  283. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  284. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  285. package/src/providers/spec/adapter.ts +168 -0
  286. package/src/providers/spec/cli-adapter.ts +986 -0
  287. package/src/providers/spec/evaluator.ts +345 -0
  288. package/src/providers/spec/fsm-driver.ts +1015 -0
  289. package/src/providers/spec/fsm-evaluator.ts +255 -0
  290. package/src/providers/spec/fsm-loader.ts +120 -0
  291. package/src/providers/spec/fsm-types.ts +238 -0
  292. package/src/providers/spec/native-history-executor.ts +1136 -0
  293. package/src/providers/spec/pre-launch-trust.ts +104 -0
  294. package/src/providers/spec/route.ts +51 -0
  295. package/src/providers/spec/types.ts +226 -0
  296. package/src/providers/status-monitor.d.ts +7 -7
  297. package/src/providers/status-monitor.ts +37 -22
  298. package/src/providers/transcript-v2.ts +567 -0
  299. package/src/providers/types/interactive-prompt.ts +536 -0
  300. package/src/providers/version-archive.ts +64 -24
  301. package/src/providers/working-dir.ts +23 -0
  302. package/src/repo-mesh-types.ts +378 -14
  303. package/src/sessions/registry.ts +6 -0
  304. package/src/shared-types-extra.ts +2 -4
  305. package/src/shared-types.d.ts +8 -0
  306. package/src/shared-types.ts +64 -1
  307. package/src/status/builders.ts +26 -6
  308. package/src/status/normalize.ts +2 -0
  309. package/src/status/reporter.ts +19 -1
  310. package/src/status/snapshot.ts +95 -26
  311. package/src/system/host-memory.ts +29 -12
  312. package/src/system/load-better-sqlite3.ts +68 -0
  313. package/src/types.ts +5 -0
  314. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  315. package/dist/mesh/mesh-sync.d.ts +0 -53
  316. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  317. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  318. package/src/mesh/mesh-sync.ts +0 -111
@@ -0,0 +1,822 @@
1
+ // ---------------------------------------------------------------------------
2
+ // mesh-reconcile-loop — periodic queue → live coordinator reconciliation
3
+ // ---------------------------------------------------------------------------
4
+ // Single-model replacement for the old event-based "spontaneous forward" paths
5
+ // (remote P2P mesh_forward_event dispatch + live-CLI PTY fire-and-forget inject).
6
+ // Those pushed events at the moment a worker transitioned state, and silently
7
+ // dropped on the network (P2P) or when the coordinator was generating.
8
+ //
9
+ // The reliable backbone has always been the pending-events queue (SQLite +
10
+ // JSONL): every mesh coordinator event is persisted there before anything else
11
+ // (see injectMeshSystemMessage). What was missing was an *active* drainer that
12
+ // runs on a schedule rather than only when the coordinator (an LLM) happens to
13
+ // call a mesh tool.
14
+ //
15
+ // This loop is that drainer. On a fixed interval it:
16
+ // 1. Finds live CLI coordinator sessions on THIS daemon (meshCoordinatorFor
17
+ // stamp). For each mesh, drains the local queue scoped to this daemon and
18
+ // injects pending events into the coordinator. When a coordinator is idle it
19
+ // receives every queued event. When ONLY generating coordinators exist (the
20
+ // common case while the coordinator is blocked awaiting a worker result), the
21
+ // loop force-drains ONLY the force-inject events (completion / approval / stop /
22
+ // refine·bootstrap terminal) and force-writes them into the generating PTY —
23
+ // the same busy-bypass send-guard escape the live-CLI inject used to use.
24
+ // Non-force progress events stay queued for the next idle tick (injecting them
25
+ // mid-generation would be noise). This is what makes a coordinator parked in
26
+ // `generating` while awaiting a worker's completion actually receive it.
27
+ // 2. In cloud mode (dispatchMeshCommand present), pulls each remote worker
28
+ // node daemon's queue over P2P (get_pending_mesh_events) and re-injects via
29
+ // handleMeshForwardEvent — the same pull the MCP drainCoordinatorPendingEvents
30
+ // already does, now driven by the daemon timer instead of an LLM tool call.
31
+ //
32
+ // IMPORTANT — limits of this loop:
33
+ // - It only delivers to *live CLI coordinator instances* on this daemon. A
34
+ // pure stdio MCP coordinator (an LLM with no live CLI session to inject
35
+ // into) has no inject target here; that case stays pull-driven — the LLM
36
+ // drains the queue when it calls mesh_status / mesh_read_chat. We do NOT try
37
+ // to "wake" an LLM from the daemon; that is structurally impossible over a
38
+ // stdio request/response transport. See docs/refactoring/2026-06-15-mesh-event-to-queue-polling.md §4.7.
39
+ // - Queue persistence (queuePendingMeshCoordinatorEvent) and the SQLite
40
+ // drained=1 idempotency are the trust backbone and are untouched by this loop.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
44
+ import type { LocalMeshEntry } from '../repo-mesh-types.js';
45
+ import { loadConfig } from '../config/config.js';
46
+ import { listMeshes } from '../config/mesh-config.js';
47
+ import { LOG } from '../logging/logger.js';
48
+ import { drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
49
+ import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
50
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
51
+ import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS, triggerMeshQueue } from './mesh-events-coordinator.js';
52
+ import {
53
+ peekUnresolvedDelegateForwards,
54
+ ackUnresolvedDelegateForward,
55
+ expireStaleUnresolvedDelegateForwards,
56
+ } from './mesh-unresolved-forward-outbox.js';
57
+ import { readNonEmptyString } from './mesh-events-utils.js';
58
+ import { getActiveDirectDispatches, getQueue } from './mesh-work-queue.js';
59
+ import { readLedgerEntries } from './mesh-ledger.js';
60
+ import { pruneStaleDirectDispatches } from './mesh-active-work.js';
61
+ import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
62
+ import { extractFinalAssistantSummaryEvidence } from '../providers/chat-message-normalization.js';
63
+ import type { ChatMessage } from '../types.js';
64
+
65
+ // Default reconcile cadence. approval/completion notifications to a live CLI
66
+ // coordinator land within at most one interval. Overridable via env for tuning.
67
+ const DEFAULT_RECONCILE_INTERVAL_MS = 4_000;
68
+
69
+ // PHASE 5 (auto-prune) conservative age gate. A direct dispatch whose node/session is
70
+ // orphaned (no longer in the live mesh) is only auto-pruned once it is at least this old,
71
+ // measured from its dispatch time. This protects against a node/session that is only
72
+ // *transiently* invisible (a momentary probe failure, a daemon restart) being pruned the
73
+ // instant it disappears. The MANUAL prune (mesh_prune_stale_direct) has no age gate — an
74
+ // operator pruning explicitly wants the orphan gone now. Overridable via env for tuning.
75
+ const DEFAULT_AUTO_PRUNE_MIN_AGE_MS = 24 * 60 * 60_000; // 24h
76
+
77
+ function resolveAutoPruneMinAgeMs(): number {
78
+ const raw = readNonEmptyString(process.env.MESH_AUTO_PRUNE_MIN_AGE_MS);
79
+ if (raw) {
80
+ const parsed = Number.parseInt(raw, 10);
81
+ // Clamp to [1h, 30d] so a mis-set env can't make the gate pathologically aggressive
82
+ // (prune the moment something blinks) or effectively disable it forever.
83
+ if (Number.isFinite(parsed) && parsed >= 60 * 60_000 && parsed <= 30 * 24 * 60 * 60_000) return parsed;
84
+ }
85
+ return DEFAULT_AUTO_PRUNE_MIN_AGE_MS;
86
+ }
87
+
88
+ function resolveReconcileIntervalMs(): number {
89
+ const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
90
+ if (raw) {
91
+ const parsed = Number.parseInt(raw, 10);
92
+ if (Number.isFinite(parsed) && parsed >= 1_000 && parsed <= 60_000) return parsed;
93
+ }
94
+ return DEFAULT_RECONCILE_INTERVAL_MS;
95
+ }
96
+
97
+ interface LiveCoordinator {
98
+ meshId: string;
99
+ instance: ReturnType<DaemonComponents['instanceManager']['getInstance']>;
100
+ idle: boolean;
101
+ // True when the coordinator session is parked on a harness modal awaiting a
102
+ // human answer — claude-cli AskUserQuestion (waiting_choice) or a tool-consent
103
+ // prompt (waiting_approval). A force-inject into such a session would write raw
104
+ // keystrokes the modal key handler consumes, silently selecting a choice the
105
+ // user never made (data corruption). PHASE 2 excludes these from force-inject
106
+ // and leaves the event queued for a later (modal-resolved) tick.
107
+ modalParked: boolean;
108
+ }
109
+
110
+ // The set of coordinator-daemon ids THIS daemon answers to when draining the
111
+ // pending-events queue. A unicast completion event is stamped with the worker's
112
+ // meshCoordinatorDaemonId, which can be either:
113
+ // - the daemon's canonical status id (`standalone_<machineId>` / `daemon_<machineId>`),
114
+ // stamped by the MCP layer via ctx.localDaemonId (= getStatus().status.instanceId), or
115
+ // - the bare machineId, stamped by the local queue-assignment path (loadConfig().machineId).
116
+ // Draining with only one of these silently misses events stamped with the other —
117
+ // the exact reason a generating coordinator never self-received local completions.
118
+ // We accept BOTH so the drain matches regardless of which path stamped the event.
119
+ function resolveCoordinatorDaemonIds(components: DaemonComponents): string[] {
120
+ const ids = new Set<string>();
121
+ const statusInstanceId = readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId);
122
+ if (statusInstanceId) ids.add(statusInstanceId);
123
+ const machineId = readNonEmptyString(loadConfig().machineId);
124
+ if (machineId) ids.add(machineId);
125
+ return [...ids];
126
+ }
127
+
128
+ // Whether THIS daemon is the coordinator/host for a mesh — i.e. the daemon that
129
+ // owns coordinator ownership and must collect every worker node's completion
130
+ // events into its local queue. This is true regardless of whether a *live CLI*
131
+ // coordinator session currently exists: the coordinator is frequently a pure
132
+ // stdio MCP LLM (no live CLI session to inject into), and that LLM only sees the
133
+ // queue when it next calls a mesh tool. For it to see remote worker completions
134
+ // at all, the daemon must have already pulled them into the local queue on the
135
+ // timer — which is exactly what this predicate gates.
136
+ //
137
+ // Rule: this daemon hosts the mesh when meshHost.role is 'host' (the default for
138
+ // standalone-compat meshes with no host metadata) AND, when a hostDaemonId is
139
+ // pinned, it resolves to one of this daemon's ids. Member-only daemons return
140
+ // false — their own queue is pulled BY the host, not the other way around.
141
+ //
142
+ // `daemonIds` here is the EXPANDED self-identity set (runtime drain ids ∪ this
143
+ // daemon's mesh-config node id forms) — see resolveCoordinatorSelfIds. The
144
+ // pinned hostDaemonId is itself a config-form id and frequently does NOT equal a
145
+ // runtime id (bare machineId / status id), so gating on the runtime ids alone
146
+ // would wrongly classify the real host as a non-host and skip the remote pull
147
+ // entirely.
148
+ function daemonHostsMesh(mesh: LocalMeshEntry, daemonIds: string[]): boolean {
149
+ const host = mesh.meshHost;
150
+ // No metadata → default host (standalone compatibility, see createDefaultMeshHostMetadata).
151
+ if (!host) return true;
152
+ if (host.role && host.role !== 'host') return false;
153
+ const hostDaemonId = readNonEmptyString(host.hostDaemonId);
154
+ // Host role but no pinned hostDaemonId → treat as host (single-daemon / legacy).
155
+ if (!hostDaemonId) return true;
156
+ return daemonIds.includes(hostDaemonId);
157
+ }
158
+
159
+ // Resolve EVERY id-form this daemon answers to FOR A GIVEN MESH: the runtime drain
160
+ // ids (status id + bare machineId) unioned with this daemon's mesh-config identity
161
+ // forms — the self node's daemonId/machineId (the node whose daemonId/machineId
162
+ // matches a runtime id) and the pinned meshHost.hostDaemonId WHEN it is provably
163
+ // ours. This is the single source of truth for "is this id me?" across both the
164
+ // host gate and the remote pull filter; the worker's meshCoordinatorDaemonId stamp
165
+ // is guaranteed to be one of these forms (it comes from resolveCoordinatorDaemonId,
166
+ // which prefers the coordinator node's config-form daemonId over the runtime status id).
167
+ function resolveCoordinatorSelfIds(mesh: LocalMeshEntry, drainDaemonIds: string[]): string[] {
168
+ const ids = new Set<string>(drainDaemonIds);
169
+ // Expand with the config-form id(s) of the self node — the mesh node whose
170
+ // daemonId/machineId matches a runtime id. Its config-form daemonId is exactly
171
+ // what resolveCoordinatorNode()→resolveCoordinatorDaemonId() stamps onto a worker.
172
+ for (const node of mesh.nodes) {
173
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
174
+ const nodeMachineId = readNonEmptyString(node.machineId);
175
+ const isSelf = (nodeDaemonId && drainDaemonIds.includes(nodeDaemonId))
176
+ || (nodeMachineId && drainDaemonIds.includes(nodeMachineId));
177
+ if (!isSelf) continue;
178
+ if (nodeDaemonId) ids.add(nodeDaemonId);
179
+ if (nodeMachineId) ids.add(nodeMachineId);
180
+ }
181
+ // The pinned host id is included ONLY when it is provably one of THIS daemon's ids
182
+ // (it already matches a runtime id or a resolved self-node id). A hostDaemonId that
183
+ // names a DIFFERENT daemon must NOT be claimed — that would make a member-only
184
+ // daemon believe it is the host and pull queues it does not own. Having a node on
185
+ // this daemon does not make this daemon the host; daemonHostsMesh still honours a
186
+ // foreign hostDaemonId and rejects ownership.
187
+ const hostDaemonId = readNonEmptyString(mesh.meshHost?.hostDaemonId);
188
+ if (hostDaemonId && ids.has(hostDaemonId)) ids.add(hostDaemonId);
189
+ return [...ids];
190
+ }
191
+
192
+ // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
193
+ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
194
+ const out: LiveCoordinator[] = [];
195
+ for (const inst of components.instanceManager.getByCategory('cli')) {
196
+ const state = inst.getState();
197
+ const settings = state.settings && typeof state.settings === 'object'
198
+ ? state.settings as Record<string, unknown>
199
+ : {};
200
+ const meshId = readNonEmptyString(settings.meshCoordinatorFor);
201
+ if (!meshId) continue;
202
+ const status = readNonEmptyString(state.status).toLowerCase();
203
+ // getState() overlays the modal-park statuses: an active AskUserQuestion
204
+ // prompt surfaces as waiting_choice, a tool-consent prompt as waiting_approval.
205
+ // Lowercase literal compare — the SessionStatus enum is forked across modules
206
+ // and waiting_choice is absent from some of them (see cli-provider-instance).
207
+ const modalParked = status === 'waiting_choice' || status === 'waiting_approval';
208
+ out.push({ meshId, instance: inst, idle: status === 'idle', modalParked });
209
+ }
210
+ return out;
211
+ }
212
+
213
+ // Inject a drained pending event into a live coordinator session. Force-inject
214
+ // events carry force:true so they bypass the busy send-guard and land in the PTY
215
+ // even while the coordinator is generating (see shouldForceInjectMeshEvent).
216
+ function injectPendingIntoCoordinator(
217
+ coordinator: LiveCoordinator['instance'],
218
+ pending: PendingMeshCoordinatorEvent,
219
+ ): void {
220
+ if (!coordinator || !pending.coordinatorMessage) return;
221
+ const force = shouldForceInjectMeshEvent(pending.event);
222
+ coordinator.onEvent('send_message', {
223
+ input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
224
+ ...(force ? { force: true } : {}),
225
+ });
226
+ }
227
+
228
+ // One reconcile tick. Two independent phases:
229
+ //
230
+ // PHASE 1 — Remote queue pull (the fix for remote worktree completions never
231
+ // reaching an MCP/LLM coordinator). For EVERY mesh this daemon hosts/
232
+ // coordinates, pull each remote worker node's pending-events queue over P2P
233
+ // into THIS daemon's local queue. This runs *regardless of whether a live CLI
234
+ // coordinator exists* — the coordinator is usually a pure stdio MCP LLM with
235
+ // no live CLI session, and it can only observe a remote worker's completion
236
+ // once that event has been pulled into the local queue (which it then drains
237
+ // on its next mesh tool call). Previously this pull was gated behind a live
238
+ // CLI coordinator and so never ran for MCP/LLM coordinators — remote
239
+ // completions sat on the remote node's queue until the LLM happened to call
240
+ // mesh_read_chat, which triggered the MCP-side pull. The daemon now does it
241
+ // autonomously on the timer. Standalone (no dispatchMeshCommand) skips this
242
+ // phase entirely — there are no remote nodes to pull from.
243
+ //
244
+ // PHASE 2 — Live CLI inject. For each mesh that has a live CLI coordinator on
245
+ // THIS daemon, drain the local queue and inject pending events into the PTY.
246
+ // Unchanged from before.
247
+ export async function runMeshReconcileTick(components: DaemonComponents): Promise<void> {
248
+ const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
249
+ // The id-set used to scope the local queue drain (status id + machineId). See
250
+ // resolveCoordinatorDaemonIds — the status id is what the MCP layer stamps and
251
+ // is mandatory here for a generating CLI coordinator to self-receive completions.
252
+ const drainDaemonIds = resolveCoordinatorDaemonIds(components);
253
+ const dispatchMeshCommand = components.dispatchMeshCommand;
254
+ const store = (() => {
255
+ try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
256
+ })();
257
+
258
+ // ── PHASE 0: retry the worker-side unresolved-delegate forward outbox ──────
259
+ // Cloud-only (needs dispatchMeshCommand). A worker that is NOT a member of the
260
+ // coordinator's mesh cannot be reached by the coordinator's PHASE 1 pull (it is
261
+ // in no mesh.node), so its completion must be PUSHED to the coordinator. This
262
+ // drains the durable outbox enqueued by forwardUnresolvedDelegateEvent and retries
263
+ // any push that has not yet been acked. See mesh-unresolved-forward-outbox.ts.
264
+ if (dispatchMeshCommand) {
265
+ try {
266
+ await retryUnresolvedDelegateForwards(components);
267
+ } catch (e: any) {
268
+ LOG.warn('MeshReconcile', `Unresolved-delegate forward retry failed: ${e?.message || e}`);
269
+ }
270
+ }
271
+
272
+ // ── PHASE 1: pull remote node queues for every mesh this daemon hosts ──────
273
+ // Cloud-only (dispatchMeshCommand present). Runs whether or not a live CLI
274
+ // coordinator exists — this is what lets an MCP/LLM coordinator ever see a
275
+ // remote worker's completion.
276
+ if (dispatchMeshCommand) {
277
+ for (const mesh of listMeshes()) {
278
+ // Expand to every id-form this daemon answers to for this mesh (runtime
279
+ // drain ids ∪ config-form node/host ids) and use it for BOTH the host gate
280
+ // and the remote pull filter, so a worker stamp in any form is recovered.
281
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
282
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
283
+ try {
284
+ await pullRemoteNodeQueues(components, mesh, localDaemonId, selfIds);
285
+ } catch (e: any) {
286
+ LOG.warn('MeshReconcile', `Remote node pull failed for mesh ${mesh.id}: ${e?.message || e}`);
287
+ }
288
+ }
289
+ }
290
+
291
+ // ── PHASE 3: recover pending queue claims for newly-idle sessions ──────────
292
+ // The event-driven claim paths (agent:ready / agent:generating_completed in
293
+ // mesh-events-coordinator) re-claim the queue the moment a session goes idle,
294
+ // but that depends on a single event being emitted AND (for a remote node)
295
+ // successfully forwarded to this coordinator. If that event is missed/dropped,
296
+ // a pending task targeting a now-idle session would sit unclaimed forever —
297
+ // there was no periodic safety net. This phase is that net: for every mesh this
298
+ // daemon hosts that has at least one pending task, run one triggerMeshQueue so a
299
+ // session that became idle without a delivered ready-event still gets its work.
300
+ //
301
+ // O(1) guard: skip the (relatively expensive) full idle-session + remote-idle
302
+ // scan entirely when the queue has no pending tasks — a COUNT(*) over the
303
+ // indexed status column, so an idle mesh costs one cheap query per tick.
304
+ // claimNextQueueTask is atomic, so racing the event-driven path can only have
305
+ // one winner; double-claiming is impossible.
306
+ for (const mesh of listMeshes()) {
307
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
308
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
309
+ if (store) {
310
+ try {
311
+ if (store.pendingQueueTaskCount(mesh.id) === 0) continue;
312
+ } catch { /* fall through and let triggerMeshQueue decide */ }
313
+ }
314
+ try {
315
+ await triggerMeshQueue(components, mesh.id);
316
+ } catch (e: any) {
317
+ LOG.warn('MeshReconcile', `Pending-claim recovery trigger failed for mesh ${mesh.id}: ${e?.message || e}`);
318
+ }
319
+ }
320
+
321
+ // ── PHASE 4: synthesize lost completions for unterminated direct dispatches ─
322
+ // Symmetric to PHASE 3 (which recovers a *lost claim* for a newly-idle session)
323
+ // but for the opposite gap: a worker that ALREADY completed, went idle, and
324
+ // whose terminal completion event was never persisted (dropped before reaching
325
+ // the queue/outbox, or its forward was lost). PHASE 1/2/3 can only deliver an
326
+ // event that exists in a queue — they cannot recover a completion that was
327
+ // never recorded, so the coordinator keeps believing the worker is generating.
328
+ //
329
+ // reconcileDirectDispatchCompletionFromTranscript already synthesizes the
330
+ // missing terminal event from the worker's transcript, but until now it ran
331
+ // ONLY when an LLM coordinator polled mesh_status (mcp_mesh_status_transcript_
332
+ // reconciliation). This phase pulls that same correction onto the daemon timer
333
+ // so it no longer depends on the LLM polling. The reconcile is idempotent
334
+ // (hasTerminalLedgerAfterDispatch guards against re-synthesis), so attempting it
335
+ // every tick for the same dispatch is safe — once a terminal exists it no-ops.
336
+ for (const mesh of listMeshes()) {
337
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
338
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
339
+ try {
340
+ await reconcileUnterminatedDirectDispatches(components, mesh, selfIds, localDaemonId);
341
+ } catch (e: any) {
342
+ LOG.warn('MeshReconcile', `Completion reconcile failed for mesh ${mesh.id}: ${e?.message || e}`);
343
+ }
344
+ }
345
+
346
+ // ── PHASE 5: auto-prune orphaned direct dispatch records ───────────────────
347
+ // staleDirectWork (orphaned direct-dispatch rows whose node/session is no longer in the
348
+ // live mesh) otherwise accumulates indefinitely: a removed worktree node or a cleanly
349
+ // terminated session leaves its direct-dispatch row behind, stuck in a non-terminal status
350
+ // (e.g. generating) for days. This is NOT a false-idle bug — it is the separate problem of
351
+ // orphaned records that the only existing cleanup path (manual MCP mesh_prune_stale_direct)
352
+ // never reaches unless an operator runs it by hand.
353
+ //
354
+ // This phase runs the SAME prune core the manual tool calls (pruneStaleDirectDispatches),
355
+ // in execute mode, on the daemon timer. The only difference from the manual path is a
356
+ // conservative age gate (DEFAULT_AUTO_PRUNE_MIN_AGE_MS): a freshly-orphaned record is held
357
+ // back until it is provably stale, so a transient probe miss never auto-prunes live work.
358
+ // Every other safety rule is inherited unchanged from the core — active/pending/generating
359
+ // work and fresh unacknowledged dispatch failures are never pruned, ledger-only audit entries
360
+ // are preserved, and the prune itself is recorded with a direct_dispatch_pruned ledger entry.
361
+ // Idempotent: a pruned row is gone from getActiveDirectDispatches, so the next tick finds
362
+ // nothing to re-prune. Isolated in its own try/catch per mesh so it can never kill the tick.
363
+ {
364
+ const minAgeMs = resolveAutoPruneMinAgeMs();
365
+ for (const mesh of listMeshes()) {
366
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
367
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
368
+ try {
369
+ await autoPruneStaleDirectDispatches(components, mesh, selfIds, localDaemonId, minAgeMs);
370
+ } catch (e: any) {
371
+ LOG.warn('MeshReconcile', `Auto-prune stale direct failed for mesh ${mesh.id}: ${e?.message || e}`);
372
+ }
373
+ }
374
+ }
375
+
376
+ // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
377
+ const coordinators = findLiveCoordinators(components);
378
+ if (coordinators.length === 0) {
379
+ // No live CLI coordinator on this daemon — nothing to inject into.
380
+ // (MCP-only LLM coordinators drain the local queue via their own tool
381
+ // calls; PHASE 1 above has already populated it from remote nodes.)
382
+ return;
383
+ }
384
+
385
+ // Group coordinators by mesh; multiple coordinator instances for one mesh is
386
+ // unusual but supported (each gets the same drained events).
387
+ const byMesh = new Map<string, LiveCoordinator[]>();
388
+ for (const c of coordinators) {
389
+ const list = byMesh.get(c.meshId);
390
+ if (list) list.push(c);
391
+ else byMesh.set(c.meshId, [c]);
392
+ }
393
+
394
+ for (const [meshId, meshCoordinators] of byMesh) {
395
+ // Drain the local queue scoped to this coordinator daemon and inject.
396
+ // - If an idle coordinator exists, FULL-drain and deliver every event to it
397
+ // (it can receive non-force progress events without deadlocking).
398
+ // - If only GENERATING coordinators exist, force-drain ONLY the force-inject
399
+ // events (completion/approval/stop/refine·bootstrap terminal) and force-inject
400
+ // them so a coordinator parked in `generating` while awaiting that very event
401
+ // is not deadlocked. Non-force progress events stay queued for the next idle
402
+ // tick — injecting them would be noise mid-generation. Both drains mark the
403
+ // consumed rows drained=1 atomically, so the pull path can't re-deliver.
404
+ const idleCoordinators = meshCoordinators.filter(c => c.idle);
405
+ // A coordinator parked on a harness modal (waiting_choice / waiting_approval)
406
+ // is non-idle, so it would otherwise be treated as a force-inject target. It
407
+ // must NOT be: a force-inject writes raw keystrokes into the PTY, which the
408
+ // modal's key handler consumes and silently resolves to a choice the user
409
+ // never made. Force-inject is only safe into a coordinator parked in plain
410
+ // `generating` (the deadlock the force path exists to break). So generating
411
+ // targets are the non-idle, non-modal-parked coordinators.
412
+ const generatingCoordinators = meshCoordinators.filter(c => !c.idle && !c.modalParked);
413
+ const modalParkedCoordinators = meshCoordinators.filter(c => !c.idle && c.modalParked);
414
+ const targetCoordinators = idleCoordinators.length > 0 ? idleCoordinators : generatingCoordinators;
415
+ const forceOnly = idleCoordinators.length === 0;
416
+
417
+ // ── modal-blocked short-circuit (MUST precede the drain) ──────────────────
418
+ // When the ONLY coordinators for this mesh are modal-parked (no idle, no plain
419
+ // generating target), there is nowhere safe to deliver. We skip-and-requeue:
420
+ // by NOT draining we leave the events at drained=0 in the queue, so a later tick
421
+ // (once the modal is resolved and the coordinator returns to idle/generating)
422
+ // delivers them. This short-circuit MUST run BEFORE drainPendingMeshCoordinatorEvents
423
+ // — the drain marks rows drained=1 atomically, which would lose the events for a
424
+ // coordinator that is only transiently blocked. (Note: generating is still
425
+ // force-injected via generatingCoordinators — we never block the deadlock-break.)
426
+ if (targetCoordinators.length === 0) {
427
+ if (modalParkedCoordinators.length > 0) {
428
+ LOG.info('MeshReconcile', `Reconcile skip → modal-parked: holding pending event(s) for mesh ${meshId} (${modalParkedCoordinators.length} coordinator(s) awaiting a modal answer; events left queued)`);
429
+ }
430
+ continue;
431
+ }
432
+
433
+ // O(1) guard: skip the drain entirely when the queue is empty.
434
+ if (store) {
435
+ try {
436
+ if (store.pendingEventCount(meshId) === 0) continue;
437
+ } catch { /* fall through to drain */ }
438
+ }
439
+
440
+ let pendingEvents: PendingMeshCoordinatorEvent[] = [];
441
+ try {
442
+ pendingEvents = drainPendingMeshCoordinatorEvents(
443
+ meshId,
444
+ drainDaemonIds.length > 0 ? drainDaemonIds : localDaemonId,
445
+ forceOnly ? { onlyEvents: MESH_FORCE_INJECT_EVENTS } : undefined,
446
+ );
447
+ } catch (e: any) {
448
+ LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
449
+ continue;
450
+ }
451
+ if (pendingEvents.length === 0) continue;
452
+
453
+ const mode = forceOnly ? 'force-drain → generating' : 'inject → idle';
454
+ LOG.info('MeshReconcile', `Reconcile ${mode}: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
455
+ for (const pending of pendingEvents) {
456
+ for (const c of targetCoordinators) {
457
+ injectPendingIntoCoordinator(c.instance, pending);
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ // Cloud-only: retry the worker-side unresolved-delegate forward outbox. For each
464
+ // durably-queued entry, push it to its coordinator daemon over P2P (mesh_forward_event)
465
+ // and ack (mark drained) ONLY on a successful, non-rejected response. A failed or
466
+ // rejected push leaves the entry queued for the next tick — at-least-once delivery.
467
+ // Stale entries (coordinator unreachable past the max age) are expired first so the
468
+ // outbox can't grow without bound. The coordinator dedups duplicate deliveries on its
469
+ // own fingerprint, so a retry that races the original immediate push is harmless.
470
+ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Promise<void> {
471
+ const dispatchMeshCommand = components.dispatchMeshCommand;
472
+ if (!dispatchMeshCommand) return;
473
+
474
+ // Drop entries that have exhausted their retry budget (fail-loud inside).
475
+ expireStaleUnresolvedDelegateForwards();
476
+
477
+ const entries = peekUnresolvedDelegateForwards();
478
+ if (entries.length === 0) return;
479
+
480
+ for (const entry of entries) {
481
+ let result: any;
482
+ try {
483
+ result = await dispatchMeshCommand(entry.coordinatorDaemonId, 'mesh_forward_event', entry.payload);
484
+ } catch (e: any) {
485
+ // Coordinator unreachable — keep the entry queued and try again next tick.
486
+ LOG.warn('MeshReconcile', `Retry forward to coordinator ${entry.coordinatorDaemonId} failed: ${e?.message || e} — left queued`);
487
+ continue;
488
+ }
489
+ if (result && result.success === false) {
490
+ LOG.warn('MeshReconcile', `Retry forward to coordinator ${entry.coordinatorDaemonId} rejected (${readNonEmptyString(result.error) || 'no reason'}) — left queued`);
491
+ continue;
492
+ }
493
+ // Acked — mark the durable copy delivered.
494
+ ackUnresolvedDelegateForward(entry.id);
495
+ LOG.info('MeshReconcile', `Retried+delivered unresolved-delegate ${readNonEmptyString(entry.payload.event)} to coordinator ${entry.coordinatorDaemonId}`);
496
+ }
497
+ }
498
+
499
+ // Cloud-only: poll each remote worker node daemon for pending coordinator events
500
+ // and re-inject them locally via handleMeshForwardEvent (which re-queues +
501
+ // surfaces to the live coordinator on the next tick / immediately if idle).
502
+ //
503
+ // Scoping: the remote handler (get_pending_mesh_events) drains its queue filtered
504
+ // by coordinatorDaemonId — returning events targeted at that id OR unscoped, and
505
+ // leaving events targeted at a *different* coordinator. A remote worker stamps the
506
+ // coordinator id in one of SEVERAL forms (the canonical status id `standalone_`/
507
+ // `daemon_<machineId>` stamped by the MCP layer, the bare machineId stamped by the
508
+ // local queue path, OR — most commonly for remote launches — the coordinator mesh
509
+ // node's config-form `daemonId`, which resolveCoordinatorDaemonId prefers and which
510
+ // is NOT canonicalised). `candidateDaemonIds` is the already-expanded self-identity
511
+ // set (resolveCoordinatorSelfIds: runtime drain ids ∪ this daemon's mesh-config node/
512
+ // host id forms), so we pull ONCE PER candidate id and a completion stamped with any
513
+ // of them is recovered. The remote drain is atomic (drained=1), so issuing multiple
514
+ // pulls cannot double-deliver — the first pull that matches consumes the event; the
515
+ // rest see nothing. When no ids resolve we fall back to a single unscoped pull.
516
+ async function pullRemoteNodeQueues(
517
+ components: DaemonComponents,
518
+ mesh: LocalMeshEntry,
519
+ localDaemonId: string | undefined,
520
+ candidateDaemonIds: string[],
521
+ ): Promise<void> {
522
+ const dispatchMeshCommand = components.dispatchMeshCommand;
523
+ if (!dispatchMeshCommand) return;
524
+ const meshId = mesh.id;
525
+
526
+ // One args object per candidate coordinator-id form, or a single unscoped pull
527
+ // when none resolve.
528
+ const pulls: Array<Record<string, unknown>> = candidateDaemonIds.length > 0
529
+ ? candidateDaemonIds.map(id => ({ meshId, coordinatorDaemonId: id }))
530
+ : [{ meshId }];
531
+
532
+ for (const node of mesh.nodes) {
533
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
534
+ // Skip nodes without a daemon, and nodes on THIS daemon (their events are
535
+ // already in the local queue drained in PHASE 2). "This daemon" is matched
536
+ // against the full self-identity set (candidateDaemonIds), not just the bare
537
+ // localDaemonId — a self node can be registered under the config-form daemonId
538
+ // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
539
+ // from ourselves over P2P is both wasteful and a self-dispatch hazard.
540
+ if (!nodeDaemonId) continue;
541
+ if (localDaemonId && nodeDaemonId === localDaemonId) continue;
542
+ if (candidateDaemonIds.includes(nodeDaemonId)) continue;
543
+
544
+ for (const pendingEventArgs of pulls) {
545
+ let events: unknown;
546
+ try {
547
+ events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
548
+ } catch {
549
+ // Remote pull is best-effort; the node may be offline. Retry next tick.
550
+ break; // node unreachable — don't bother with the other id form this tick.
551
+ }
552
+ const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
553
+ for (const event of list) {
554
+ const payload = buildForwardPayloadFromPending(event);
555
+ if (!payload.event || !payload.meshId) continue;
556
+ try {
557
+ handleMeshForwardEvent(components, payload);
558
+ } catch { /* best-effort re-inject */ }
559
+ }
560
+ }
561
+ }
562
+ }
563
+
564
+ // Pull the read_chat payload out of whatever envelope the transport returned.
565
+ // A local commandHandler.handle() returns the CommandResult directly; a remote
566
+ // dispatchMeshCommand returns it possibly wrapped in { payload } / { result }.
567
+ function unwrapReadChatPayload(raw: unknown): Record<string, unknown> | null {
568
+ let cursor: unknown = raw;
569
+ for (let depth = 0; depth < 4 && cursor && typeof cursor === 'object'; depth++) {
570
+ const record = cursor as Record<string, unknown>;
571
+ if (Array.isArray(record.messages)) return record;
572
+ if (record.payload && typeof record.payload === 'object') { cursor = record.payload; continue; }
573
+ if (record.result && typeof record.result === 'object') { cursor = record.result; continue; }
574
+ if (record.data && typeof record.data === 'object') { cursor = record.data; continue; }
575
+ break;
576
+ }
577
+ return cursor && typeof cursor === 'object' ? cursor as Record<string, unknown> : null;
578
+ }
579
+
580
+ function readChatPayloadStatus(payload: Record<string, unknown> | null): string {
581
+ return readNonEmptyString(payload?.status).toLowerCase();
582
+ }
583
+
584
+ // PHASE 4 helper. For every active (non-terminal) direct dispatch this daemon
585
+ // hosts, confirm the worker session is idle via a read_chat and — if a final
586
+ // assistant summary is present but no terminal ledger exists for that dispatch —
587
+ // synthesize the missing completion through reconcileDirectDispatchCompletionFromTranscript.
588
+ //
589
+ // read_chat is resolved against the target node: a node on THIS daemon is read
590
+ // through the local commandHandler; a remote node is read over P2P via
591
+ // dispatchMeshCommand. Both yield the same { messages, status, providerSessionId }
592
+ // shape. We only synthesize when the session reports idle AND a final assistant
593
+ // message exists — the same evidence bar the MCP poll path uses — so an actively
594
+ // generating worker is never falsely completed. The reconcile itself is idempotent.
595
+ async function reconcileUnterminatedDirectDispatches(
596
+ components: DaemonComponents,
597
+ mesh: LocalMeshEntry,
598
+ selfIds: string[],
599
+ localDaemonId: string | undefined,
600
+ ): Promise<void> {
601
+ const dispatches = getActiveDirectDispatches(mesh.id);
602
+ if (dispatches.length === 0) return; // cheap exit — nothing dispatched, nothing to reconcile
603
+
604
+ const dispatchMeshCommand = components.dispatchMeshCommand;
605
+ const nodeById = new Map(mesh.nodes.map(n => [n.id, n] as const));
606
+
607
+ for (const dispatch of dispatches) {
608
+ const sessionId = readNonEmptyString(dispatch.sessionId);
609
+ const nodeId = readNonEmptyString(dispatch.nodeId);
610
+ const taskId = readNonEmptyString(dispatch.taskId);
611
+ if (!sessionId || !nodeId || !taskId) continue;
612
+
613
+ const node = nodeById.get(nodeId);
614
+ const nodeDaemonId = readNonEmptyString(node?.daemonId);
615
+ // A node is local when it has no daemonId, names this daemon, or actually
616
+ // has a live instance here. Anything else is reached over P2P.
617
+ const isLocalNode = !nodeDaemonId
618
+ || selfIds.includes(nodeDaemonId)
619
+ || (localDaemonId !== undefined && nodeDaemonId === localDaemonId)
620
+ || !!components.instanceManager.getInstance(sessionId);
621
+
622
+ const providerType = readNonEmptyString(dispatch.providerType);
623
+ const readArgs: Record<string, unknown> = {
624
+ sessionId,
625
+ targetSessionId: sessionId,
626
+ tailLimit: 10,
627
+ ...(node?.workspace ? { workspace: node.workspace } : {}),
628
+ ...(providerType ? { agentType: providerType, providerType } : {}),
629
+ };
630
+
631
+ let payload: Record<string, unknown> | null = null;
632
+ try {
633
+ if (isLocalNode) {
634
+ const result = await components.commandHandler.handle('read_chat', readArgs);
635
+ if (result && (result as { success?: boolean }).success === false) continue;
636
+ payload = unwrapReadChatPayload(result);
637
+ } else if (dispatchMeshCommand) {
638
+ const result = await dispatchMeshCommand(nodeDaemonId, 'read_chat', readArgs);
639
+ payload = unwrapReadChatPayload(result);
640
+ if (payload && (payload as { success?: boolean }).success === false) continue;
641
+ } else {
642
+ continue; // remote node but no P2P transport — can't read; retry next tick
643
+ }
644
+ } catch {
645
+ continue; // best-effort; session may be gone or node offline — retry next tick
646
+ }
647
+ if (!payload) continue;
648
+
649
+ // Only act on a session that has actually settled to idle. A generating /
650
+ // waiting_approval session is mid-turn — synthesizing a completion now would
651
+ // be wrong. (idle is the only status the MCP poll path reconciles too.)
652
+ if (readChatPayloadStatus(payload) !== 'idle') continue;
653
+
654
+ const messages = Array.isArray(payload.messages) ? payload.messages as ChatMessage[] : [];
655
+ const evidence = extractFinalAssistantSummaryEvidence(messages);
656
+ if (!evidence.finalSummary) continue; // no assistant result yet — nothing to attribute
657
+
658
+ const providerSessionId = readNonEmptyString(payload.providerSessionId);
659
+ const coordinatorDaemonId = selfIds.find(id => !!id);
660
+ try {
661
+ const result = reconcileDirectDispatchCompletionFromTranscript({
662
+ meshId: mesh.id,
663
+ nodeId,
664
+ sessionId,
665
+ providerType: providerType || undefined,
666
+ providerSessionId: providerSessionId || undefined,
667
+ taskId,
668
+ finalSummary: evidence.finalSummary,
669
+ ...(evidence.transcriptMessageAt ? { transcriptMessageAt: evidence.transcriptMessageAt } : {}),
670
+ ...(coordinatorDaemonId ? { targetCoordinatorDaemonId: coordinatorDaemonId } : {}),
671
+ source: 'daemon_reconcile_transcript_completion',
672
+ });
673
+ if (result.reconciled) {
674
+ LOG.info('MeshReconcile', `Synthesized missing completion (${result.kind}) for task ${taskId} on node ${nodeId} (mesh ${mesh.id})`);
675
+ }
676
+ } catch (e: any) {
677
+ LOG.warn('MeshReconcile', `Transcript completion reconcile threw for task ${taskId}: ${e?.message || e}`);
678
+ }
679
+ }
680
+ }
681
+
682
+ // PHASE 5 helper. Build the live-node view (mesh.nodes decorated with each node's live
683
+ // session list) and run the shared prune core in execute mode with the conservative age gate.
684
+ //
685
+ // Orphan detection needs the SAME live-session evidence the manual MCP prune uses: a node still
686
+ // in mesh.nodes whose session list no longer contains the dispatched sessionId is "session not
687
+ // present" (prunable); a node missing from mesh.nodes entirely is "node no longer in live mesh"
688
+ // (prunable). We obtain live sessions per node via get_status_metadata — local nodes through the
689
+ // local commandHandler, remote nodes over P2P (dispatchMeshCommand) — exactly the transports
690
+ // PHASE 4 already uses. A node we cannot probe (offline) keeps an empty session list; combined
691
+ // with the age gate that only matters once the orphan is genuinely old.
692
+ //
693
+ // O(1) fast exit: when there are no active direct dispatches at all there is nothing to prune,
694
+ // so we skip the (per-node) status probes entirely — an idle mesh costs one indexed query.
695
+ async function autoPruneStaleDirectDispatches(
696
+ components: DaemonComponents,
697
+ mesh: LocalMeshEntry,
698
+ selfIds: string[],
699
+ localDaemonId: string | undefined,
700
+ minAgeMs: number,
701
+ ): Promise<void> {
702
+ const directDispatches = getActiveDirectDispatches(mesh.id);
703
+ if (directDispatches.length === 0) return; // nothing dispatched → nothing to prune
704
+
705
+ const liveNodes = await collectLiveNodesWithSessions(components, mesh, selfIds, localDaemonId);
706
+
707
+ const result = pruneStaleDirectDispatches({
708
+ meshId: mesh.id,
709
+ queue: getQueue(mesh.id),
710
+ ledgerEntries: readLedgerEntries(mesh.id, { tail: 500 }),
711
+ directDispatches,
712
+ nodes: liveNodes,
713
+ execute: true,
714
+ minAgeMs,
715
+ source: 'daemon_reconcile_auto_prune',
716
+ });
717
+
718
+ // Log only when something was actually pruned — silence on the common no-op tick.
719
+ if (result.prunedCount > 0) {
720
+ LOG.info('MeshReconcile', `Auto-pruned ${result.prunedCount} orphaned direct dispatch record(s) for mesh ${mesh.id}`);
721
+ }
722
+ }
723
+
724
+ // Probe each node for its live session list (get_status_metadata) and return mesh.nodes
725
+ // decorated with a `sessions` array — the shape buildMeshActiveWork / sessionStatusFromNodes
726
+ // consume to decide whether a dispatched session is still present. Best-effort: an unreachable
727
+ // node yields an empty session list rather than throwing.
728
+ async function collectLiveNodesWithSessions(
729
+ components: DaemonComponents,
730
+ mesh: LocalMeshEntry,
731
+ selfIds: string[],
732
+ localDaemonId: string | undefined,
733
+ ): Promise<any[]> {
734
+ const dispatchMeshCommand = components.dispatchMeshCommand;
735
+ return Promise.all(mesh.nodes.map(async (node) => {
736
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
737
+ const isLocalNode = !nodeDaemonId
738
+ || selfIds.includes(nodeDaemonId)
739
+ || (localDaemonId !== undefined && nodeDaemonId === localDaemonId);
740
+ let statusResult: unknown;
741
+ try {
742
+ if (isLocalNode) {
743
+ statusResult = await components.commandHandler.handle('get_status_metadata', {});
744
+ } else if (dispatchMeshCommand) {
745
+ statusResult = await dispatchMeshCommand(nodeDaemonId, 'get_status_metadata', {});
746
+ } else {
747
+ return node; // remote node, no P2P transport — leave undecorated
748
+ }
749
+ } catch {
750
+ return node; // unreachable — leave undecorated (empty session list)
751
+ }
752
+ const sessions = extractStatusMetadataSessions(statusResult);
753
+ return sessions.length > 0 ? { ...node, sessions } : node;
754
+ }));
755
+ }
756
+
757
+ // Pull the live session list out of a get_status_metadata result, tolerating the same
758
+ // envelope shapes unwrapReadChatPayload handles (direct CommandResult or { payload }/{ result }).
759
+ function extractStatusMetadataSessions(raw: unknown): any[] {
760
+ let cursor: unknown = raw;
761
+ for (let depth = 0; depth < 4 && cursor && typeof cursor === 'object'; depth++) {
762
+ const record = cursor as Record<string, unknown>;
763
+ const status = record.status && typeof record.status === 'object' ? record.status as Record<string, unknown> : undefined;
764
+ if (status && Array.isArray(status.sessions)) return status.sessions;
765
+ if (Array.isArray(record.sessions)) return record.sessions;
766
+ if (record.payload && typeof record.payload === 'object') { cursor = record.payload; continue; }
767
+ if (record.result && typeof record.result === 'object') { cursor = record.result; continue; }
768
+ if (record.data && typeof record.data === 'object') { cursor = record.data; continue; }
769
+ break;
770
+ }
771
+ return [];
772
+ }
773
+
774
+ function extractPendingEvents(raw: unknown): any[] {
775
+ if (Array.isArray(raw)) return raw;
776
+ if (raw && typeof raw === 'object') {
777
+ const events = (raw as Record<string, unknown>).events;
778
+ if (Array.isArray(events)) return events;
779
+ }
780
+ return [];
781
+ }
782
+
783
+ // Flatten a queued PendingMeshCoordinatorEvent into the flat payload shape
784
+ // handleMeshForwardEvent expects (mirrors the MCP buildMeshForwardPayloadFromPendingEvent).
785
+ function buildForwardPayloadFromPending(event: any): Record<string, unknown> {
786
+ const metadata = event?.metadataEvent && typeof event.metadataEvent === 'object'
787
+ ? event.metadataEvent as Record<string, unknown>
788
+ : {};
789
+ return {
790
+ event: readNonEmptyString(event?.event),
791
+ meshId: readNonEmptyString(event?.meshId),
792
+ nodeId: readNonEmptyString(event?.nodeId) || readNonEmptyString(metadata.meshNodeId),
793
+ workspace: readNonEmptyString(event?.workspace) || readNonEmptyString(metadata.workspace),
794
+ ...metadata,
795
+ };
796
+ }
797
+
798
+ interface ReconcileLoopHandle {
799
+ stop(): void;
800
+ }
801
+
802
+ // Start the periodic reconcile loop. Returns a handle with stop() for shutdown.
803
+ export function setupMeshReconcileLoop(components: DaemonComponents): ReconcileLoopHandle {
804
+ const intervalMs = resolveReconcileIntervalMs();
805
+ let running = false;
806
+ const timer = setInterval(() => {
807
+ if (running) return; // never overlap ticks
808
+ running = true;
809
+ void runMeshReconcileTick(components)
810
+ .catch((e: any) => LOG.warn('MeshReconcile', `Reconcile tick error: ${e?.message || e}`))
811
+ .finally(() => { running = false; });
812
+ }, intervalMs);
813
+ // Don't keep the process alive solely for this timer.
814
+ if (typeof timer.unref === 'function') timer.unref();
815
+ LOG.info('MeshReconcile', `Mesh reconcile loop started (interval ${intervalMs}ms)`);
816
+ return {
817
+ stop() {
818
+ clearInterval(timer);
819
+ LOG.info('MeshReconcile', 'Mesh reconcile loop stopped');
820
+ },
821
+ };
822
+ }