@adhdev/daemon-core 0.9.82-rc.42 → 0.9.82-rc.421

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 (425) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +9 -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 +68 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +200 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +126 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +5 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +74 -0
  14. package/dist/cli-adapters/pty-write-chunking.d.ts +34 -0
  15. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  16. package/dist/cli-adapters/resolve-executable.d.ts +69 -0
  17. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  18. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  19. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  20. package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
  21. package/dist/commands/chat-commands-read.d.ts +7 -0
  22. package/dist/commands/chat-commands-scope.d.ts +39 -0
  23. package/dist/commands/chat-commands-shared.d.ts +33 -0
  24. package/dist/commands/chat-commands-write.d.ts +14 -0
  25. package/dist/commands/chat-commands.d.ts +10 -23
  26. package/dist/commands/cli-manager.d.ts +15 -1
  27. package/dist/commands/handler.d.ts +125 -0
  28. package/dist/commands/high-family/index.d.ts +3 -0
  29. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  30. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  31. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  32. package/dist/commands/high-family/types.d.ts +60 -0
  33. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  34. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  35. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  36. package/dist/commands/low-family/index.d.ts +3 -0
  37. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  38. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  39. package/dist/commands/low-family/notification.d.ts +2 -0
  40. package/dist/commands/low-family/refine-config.d.ts +2 -0
  41. package/dist/commands/low-family/session-host.d.ts +2 -0
  42. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  43. package/dist/commands/low-family/status-meta.d.ts +2 -0
  44. package/dist/commands/low-family/types.d.ts +33 -0
  45. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  46. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  47. package/dist/commands/med-family/ide.d.ts +10 -0
  48. package/dist/commands/med-family/index.d.ts +3 -0
  49. package/dist/commands/med-family/mesh-crud.d.ts +29 -0
  50. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  51. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  52. package/dist/commands/med-family/mesh-restart.d.ts +2 -0
  53. package/dist/commands/med-family/types.d.ts +141 -0
  54. package/dist/commands/mesh-coordinator.d.ts +85 -1
  55. package/dist/commands/router.d.ts +276 -2
  56. package/dist/commands/upgrade-helper.d.ts +41 -1
  57. package/dist/config/chat-history.d.ts +9 -0
  58. package/dist/config/config.d.ts +5 -0
  59. package/dist/config/mesh-config.d.ts +100 -1
  60. package/dist/config/mesh-json-config.d.ts +199 -0
  61. package/dist/config/repo-settings.d.ts +77 -0
  62. package/dist/daemon/dev-server.d.ts +0 -2
  63. package/dist/detection/ide-detector.d.ts +13 -0
  64. package/dist/detection/win32-ide-version.d.ts +37 -0
  65. package/dist/git/change-impact-config.d.ts +159 -0
  66. package/dist/git/git-commands.d.ts +11 -1
  67. package/dist/git/git-diff.d.ts +6 -0
  68. package/dist/git/git-executor.d.ts +11 -0
  69. package/dist/git/git-status.d.ts +57 -0
  70. package/dist/git/git-types.d.ts +2 -50
  71. package/dist/git/git-worktree.d.ts +71 -1
  72. package/dist/git/index.d.ts +3 -1
  73. package/dist/index.d.ts +63 -14
  74. package/dist/index.js +50038 -20126
  75. package/dist/index.js.map +1 -1
  76. package/dist/index.mjs +49631 -19875
  77. package/dist/index.mjs.map +1 -1
  78. package/dist/installer.d.ts +1 -4
  79. package/dist/ipc/local-ipc-server.d.ts +91 -0
  80. package/dist/launch.d.ts +1 -1
  81. package/dist/logging/async-batch-writer.d.ts +10 -0
  82. package/dist/logging/log-redactor.d.ts +24 -0
  83. package/dist/logging/log-tail-reader.d.ts +81 -0
  84. package/dist/logging/logger.d.ts +1 -1
  85. package/dist/mesh/contracts.d.ts +164 -0
  86. package/dist/mesh/coordinator-prompt.d.ts +89 -0
  87. package/dist/mesh/coordinator-registry.d.ts +59 -0
  88. package/dist/mesh/mesh-active-work.d.ts +174 -0
  89. package/dist/mesh/mesh-clone-grace.d.ts +8 -0
  90. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  91. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  92. package/dist/mesh/mesh-event-classify.d.ts +5 -0
  93. package/dist/mesh/mesh-event-forwarding.d.ts +41 -0
  94. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  95. package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
  96. package/dist/mesh/mesh-events-pending.d.ts +69 -0
  97. package/dist/mesh/mesh-events-stale.d.ts +61 -0
  98. package/dist/mesh/mesh-events-utils.d.ts +75 -0
  99. package/dist/mesh/mesh-events.d.ts +6 -49
  100. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  101. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  102. package/dist/mesh/mesh-init.d.ts +86 -0
  103. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
  104. package/dist/mesh/mesh-ledger.d.ts +77 -1
  105. package/dist/mesh/mesh-magi-status.d.ts +63 -0
  106. package/dist/mesh/mesh-missions.d.ts +164 -0
  107. package/dist/mesh/mesh-node-identity.d.ts +289 -0
  108. package/dist/mesh/mesh-queue-assignment.d.ts +113 -0
  109. package/dist/mesh/mesh-reconcile-loop.d.ts +65 -0
  110. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  111. package/dist/mesh/mesh-refine-gates.d.ts +428 -0
  112. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  113. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  114. package/dist/mesh/mesh-routing.d.ts +70 -0
  115. package/dist/mesh/mesh-runtime-store.d.ts +447 -0
  116. package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
  117. package/dist/mesh/mesh-task-inflight.d.ts +46 -0
  118. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  119. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  120. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  121. package/dist/mesh/mesh-work-queue.d.ts +290 -5
  122. package/dist/mesh/preview-freshness.d.ts +18 -0
  123. package/dist/mesh/refine-config.d.ts +216 -0
  124. package/dist/mesh/worktree-bootstrap-config.d.ts +145 -0
  125. package/dist/providers/acp-provider-instance.d.ts +5 -0
  126. package/dist/providers/approval-utils.d.ts +20 -0
  127. package/dist/providers/chat-message-normalization.d.ts +31 -1
  128. package/dist/providers/cli-provider-instance.d.ts +208 -3
  129. package/dist/providers/contracts.d.ts +139 -6
  130. package/dist/providers/external-sources.d.ts +71 -0
  131. package/dist/providers/manual-attendance.d.ts +63 -0
  132. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  133. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  134. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  135. package/dist/providers/native-history/constants.d.ts +12 -0
  136. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  137. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  138. package/dist/providers/native-history/index.d.ts +13 -0
  139. package/dist/providers/provider-instance-manager.d.ts +14 -0
  140. package/dist/providers/provider-instance.d.ts +23 -1
  141. package/dist/providers/provider-loader.d.ts +26 -4
  142. package/dist/providers/provider-trust.d.ts +31 -0
  143. package/dist/providers/read-chat-contract.d.ts +29 -0
  144. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  145. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  146. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  147. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  148. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  149. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  150. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  151. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  152. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  153. package/dist/providers/sdk/v1/index.d.ts +30 -0
  154. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  155. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  156. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  157. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  158. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  159. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  160. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  161. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  162. package/dist/providers/spec/adapter.d.ts +91 -0
  163. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  164. package/dist/providers/spec/evaluator.d.ts +45 -0
  165. package/dist/providers/spec/fsm-driver.d.ts +422 -0
  166. package/dist/providers/spec/fsm-evaluator.d.ts +76 -0
  167. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  168. package/dist/providers/spec/fsm-types.d.ts +174 -0
  169. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  170. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  171. package/dist/providers/spec/route.d.ts +4 -0
  172. package/dist/providers/spec/types.d.ts +222 -0
  173. package/dist/providers/status-monitor.d.ts +7 -7
  174. package/dist/providers/transcript-v2.d.ts +176 -0
  175. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  176. package/dist/providers/working-dir.d.ts +17 -0
  177. package/dist/repo-mesh-types.d.ts +428 -13
  178. package/dist/runtime-defaults.d.ts +2 -0
  179. package/dist/sessions/registry.d.ts +6 -0
  180. package/dist/shared-types-extra.d.ts +2 -4
  181. package/dist/shared-types.d.ts +59 -2
  182. package/dist/status/normalize.d.ts +1 -1
  183. package/dist/status/normalize.js +1 -0
  184. package/dist/status/normalize.js.map +1 -1
  185. package/dist/status/normalize.mjs +1 -0
  186. package/dist/status/normalize.mjs.map +1 -1
  187. package/dist/status/reporter.d.ts +2 -0
  188. package/dist/status/snapshot.d.ts +26 -0
  189. package/dist/system/hash.d.ts +8 -0
  190. package/dist/system/load-better-sqlite3.d.ts +21 -0
  191. package/dist/types.d.ts +5 -0
  192. package/package.json +7 -3
  193. package/src/agent-stream/poller.ts +2 -3
  194. package/src/agent-stream/provider-adapter.ts +1 -1
  195. package/src/boot/daemon-lifecycle.ts +63 -12
  196. package/src/boot/process-hardening.ts +89 -0
  197. package/src/build-info.ts +73 -0
  198. package/src/chat/source-machine.ts +534 -0
  199. package/src/chat/source-resolver.ts +0 -0
  200. package/src/chat/subscription-updates.ts +20 -1
  201. package/src/cli-adapter-types.d.ts +3 -1
  202. package/src/cli-adapter-types.ts +68 -2
  203. package/src/cli-adapters/cli-script-runner.ts +421 -0
  204. package/src/cli-adapters/cli-state-engine.ts +1246 -0
  205. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  206. package/src/cli-adapters/provider-cli-adapter.ts +914 -1144
  207. package/src/cli-adapters/provider-cli-parse.d.ts +2 -0
  208. package/src/cli-adapters/provider-cli-parse.ts +19 -0
  209. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  210. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  211. package/src/cli-adapters/provider-cli-shared.ts +121 -11
  212. package/src/cli-adapters/pty-transport.ts +2 -1
  213. package/src/cli-adapters/pty-write-chunking.ts +106 -0
  214. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  215. package/src/cli-adapters/resolve-executable.ts +204 -0
  216. package/src/cli-adapters/session-host-transport.ts +2 -1
  217. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +63 -29
  218. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  219. package/src/cli-adapters/terminal-screen.ts +16 -81
  220. package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +198 -0
  221. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  222. package/src/commands/chat-commands-read.ts +2327 -0
  223. package/src/commands/chat-commands-scope.ts +54 -0
  224. package/src/commands/chat-commands-shared.ts +114 -0
  225. package/src/commands/chat-commands-write.ts +891 -0
  226. package/src/commands/chat-commands.ts +19 -1841
  227. package/src/commands/cli-manager.ts +527 -26
  228. package/src/commands/handler.ts +841 -2
  229. package/src/commands/high-family/index.ts +28 -0
  230. package/src/commands/high-family/mesh-coordinator-launch.ts +678 -0
  231. package/src/commands/high-family/mesh-events.ts +80 -0
  232. package/src/commands/high-family/mesh-status.ts +678 -0
  233. package/src/commands/high-family/types.ts +76 -0
  234. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  235. package/src/commands/low-family/daemon-lifecycle.ts +107 -0
  236. package/src/commands/low-family/diagnostics.ts +57 -0
  237. package/src/commands/low-family/index.ts +37 -0
  238. package/src/commands/low-family/mesh-ledger.ts +62 -0
  239. package/src/commands/low-family/mesh-node-logs.ts +87 -0
  240. package/src/commands/low-family/notification.ts +116 -0
  241. package/src/commands/low-family/refine-config.ts +106 -0
  242. package/src/commands/low-family/session-host.ts +274 -0
  243. package/src/commands/low-family/spec-providerdev.ts +217 -0
  244. package/src/commands/low-family/status-meta.ts +112 -0
  245. package/src/commands/low-family/types.ts +39 -0
  246. package/src/commands/med-family/cli-agent.ts +238 -0
  247. package/src/commands/med-family/fast-forward.ts +230 -0
  248. package/src/commands/med-family/ide.ts +163 -0
  249. package/src/commands/med-family/index.ts +37 -0
  250. package/src/commands/med-family/mesh-crud.ts +998 -0
  251. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  252. package/src/commands/med-family/mesh-queue.ts +167 -0
  253. package/src/commands/med-family/mesh-restart.ts +92 -0
  254. package/src/commands/med-family/types.ts +142 -0
  255. package/src/commands/mesh-coordinator.ts +334 -124
  256. package/src/commands/router.ts +2669 -3246
  257. package/src/commands/stream-commands.ts +8 -0
  258. package/src/commands/upgrade-helper.ts +310 -45
  259. package/src/config/chat-history.ts +483 -24
  260. package/src/config/config.ts +12 -0
  261. package/src/config/mesh-config.ts +469 -22
  262. package/src/config/mesh-json-config.ts +376 -0
  263. package/src/config/recent-activity.ts +8 -2
  264. package/src/config/repo-settings.ts +111 -0
  265. package/src/daemon/dev-auto-implement.ts +3 -2
  266. package/src/daemon/dev-cli-debug.ts +10 -1
  267. package/src/daemon/dev-server.ts +0 -541
  268. package/src/detection/cli-detector.ts +28 -9
  269. package/src/detection/ide-detector.ts +55 -16
  270. package/src/detection/win32-ide-version.ts +106 -0
  271. package/src/git/change-impact-config.ts +354 -0
  272. package/src/git/git-commands.ts +59 -15
  273. package/src/git/git-diff.ts +81 -11
  274. package/src/git/git-executor.ts +12 -0
  275. package/src/git/git-status.ts +767 -48
  276. package/src/git/git-types.ts +14 -62
  277. package/src/git/git-worktree.ts +261 -4
  278. package/src/git/index.ts +17 -0
  279. package/src/index.ts +188 -12
  280. package/src/installer.d.ts +1 -1
  281. package/src/installer.ts +8 -6
  282. package/src/ipc/local-ipc-server.ts +278 -0
  283. package/src/launch.d.ts +1 -1
  284. package/src/launch.ts +37 -28
  285. package/src/logging/async-batch-writer.ts +55 -0
  286. package/src/logging/command-log.ts +7 -5
  287. package/src/logging/log-redactor.ts +100 -0
  288. package/src/logging/log-tail-reader.ts +341 -0
  289. package/src/logging/logger.ts +14 -7
  290. package/src/mesh/contracts.ts +338 -0
  291. package/src/mesh/coordinator-prompt.ts +381 -33
  292. package/src/mesh/coordinator-registry.ts +121 -0
  293. package/src/mesh/mesh-active-work.ts +645 -0
  294. package/src/mesh/mesh-clone-grace.ts +68 -0
  295. package/src/mesh/mesh-coordinator-config.ts +97 -0
  296. package/src/mesh/mesh-delivery-policy.ts +315 -0
  297. package/src/mesh/mesh-event-classify.ts +51 -0
  298. package/src/mesh/mesh-event-forwarding.ts +1883 -0
  299. package/src/mesh/mesh-event-trace.ts +67 -0
  300. package/src/mesh/mesh-events-coordinator.ts +32 -0
  301. package/src/mesh/mesh-events-pending.ts +656 -0
  302. package/src/mesh/mesh-events-stale.ts +389 -0
  303. package/src/mesh/mesh-events-utils.ts +443 -0
  304. package/src/mesh/mesh-events.ts +33 -1035
  305. package/src/mesh/mesh-fast-forward.ts +843 -0
  306. package/src/mesh/mesh-host-ownership.ts +73 -0
  307. package/src/mesh/mesh-init.ts +260 -0
  308. package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
  309. package/src/mesh/mesh-ledger.ts +613 -102
  310. package/src/mesh/mesh-magi-status.ts +223 -0
  311. package/src/mesh/mesh-missions.ts +407 -0
  312. package/src/mesh/mesh-node-identity.ts +1887 -0
  313. package/src/mesh/mesh-queue-assignment.ts +1863 -0
  314. package/src/mesh/mesh-reconcile-loop.ts +1734 -0
  315. package/src/mesh/mesh-refine-batch.ts +205 -0
  316. package/src/mesh/mesh-refine-gates.ts +1673 -0
  317. package/src/mesh/mesh-refine-status.ts +231 -0
  318. package/src/mesh/mesh-review-inbox.ts +307 -0
  319. package/src/mesh/mesh-routing.ts +291 -0
  320. package/src/mesh/mesh-runtime-store.ts +1836 -0
  321. package/src/mesh/mesh-scheduling-runtime.ts +198 -0
  322. package/src/mesh/mesh-task-inflight.ts +70 -0
  323. package/src/mesh/mesh-task-stats.ts +161 -0
  324. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  325. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  326. package/src/mesh/mesh-work-queue.ts +1243 -141
  327. package/src/mesh/preview-freshness.ts +118 -0
  328. package/src/mesh/refine-config.ts +423 -0
  329. package/src/mesh/worktree-bootstrap-config.ts +450 -0
  330. package/src/providers/acp-provider-instance.ts +43 -10
  331. package/src/providers/approval-utils.d.ts +5 -0
  332. package/src/providers/approval-utils.ts +57 -5
  333. package/src/providers/chat-message-normalization.ts +92 -4
  334. package/src/providers/cli-provider-instance.ts +1898 -104
  335. package/src/providers/contracts.d.ts +55 -0
  336. package/src/providers/contracts.ts +150 -6
  337. package/src/providers/extension-provider-instance.ts +12 -7
  338. package/src/providers/external-sources.ts +218 -0
  339. package/src/providers/ide-provider-instance.ts +35 -12
  340. package/src/providers/manual-attendance.ts +85 -0
  341. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  342. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  343. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  344. package/src/providers/native-history/constants.ts +19 -0
  345. package/src/providers/native-history/dispatcher.ts +339 -0
  346. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  347. package/src/providers/native-history/index.ts +30 -0
  348. package/src/providers/provider-instance-manager.ts +30 -0
  349. package/src/providers/provider-instance.ts +19 -1
  350. package/src/providers/provider-loader.ts +668 -50
  351. package/src/providers/provider-schema.ts +87 -14
  352. package/src/providers/provider-trust.ts +114 -0
  353. package/src/providers/read-chat-contract.ts +76 -16
  354. package/src/providers/sdk/README.md +49 -0
  355. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  356. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  357. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  358. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  359. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  360. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  361. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  362. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  363. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  364. package/src/providers/sdk/v1/index.ts +152 -0
  365. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  366. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  367. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  368. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  369. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  370. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  371. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  372. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  373. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  374. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  375. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  376. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  377. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  378. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  379. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  380. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  381. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  382. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  383. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  384. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  385. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  386. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  387. package/src/providers/sdk/v1/validators/index.ts +19 -0
  388. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  389. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  390. package/src/providers/spec/adapter.ts +235 -0
  391. package/src/providers/spec/cli-adapter.ts +1056 -0
  392. package/src/providers/spec/evaluator.ts +407 -0
  393. package/src/providers/spec/fsm-driver.ts +1410 -0
  394. package/src/providers/spec/fsm-evaluator.ts +272 -0
  395. package/src/providers/spec/fsm-loader.ts +120 -0
  396. package/src/providers/spec/fsm-types.ts +273 -0
  397. package/src/providers/spec/native-history-executor.ts +1128 -0
  398. package/src/providers/spec/pre-launch-trust.ts +104 -0
  399. package/src/providers/spec/route.ts +51 -0
  400. package/src/providers/spec/types.ts +262 -0
  401. package/src/providers/status-monitor.d.ts +7 -7
  402. package/src/providers/status-monitor.ts +37 -22
  403. package/src/providers/transcript-v2.ts +567 -0
  404. package/src/providers/types/interactive-prompt.ts +536 -0
  405. package/src/providers/version-archive.ts +64 -24
  406. package/src/providers/working-dir.ts +23 -0
  407. package/src/repo-mesh-types.ts +639 -14
  408. package/src/runtime-defaults.ts +39 -0
  409. package/src/sessions/registry.ts +6 -0
  410. package/src/shared-types-extra.ts +2 -4
  411. package/src/shared-types.d.ts +8 -0
  412. package/src/shared-types.ts +64 -1
  413. package/src/status/builders.ts +26 -6
  414. package/src/status/normalize.ts +2 -0
  415. package/src/status/reporter.ts +19 -1
  416. package/src/status/snapshot.ts +95 -26
  417. package/src/system/hash.ts +23 -0
  418. package/src/system/host-memory.ts +29 -12
  419. package/src/system/load-better-sqlite3.ts +68 -0
  420. package/src/types.ts +5 -0
  421. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  422. package/dist/mesh/mesh-sync.d.ts +0 -53
  423. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  424. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  425. package/src/mesh/mesh-sync.ts +0 -111
@@ -13,19 +13,25 @@ import { createRequire } from 'node:module';
13
13
  import { normalizeInputEnvelope, type ProviderModule, flattenContent, type InputEnvelope, type InputPart } from './contracts.js';
14
14
  import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport } from './provider-input-support.js';
15
15
  import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, ProviderErrorReason, HotChatSessionState, SessionModalState } from './provider-instance.js';
16
+ import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, type InteractivePrompt } from './types/interactive-prompt.js';
16
17
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
18
+ import { shortHash } from '../system/hash.js';
17
19
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
20
+ import { createCliAdapter } from './spec/route.js';
18
21
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
19
22
  import { StatusMonitor } from './status-monitor.js';
20
23
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
21
24
  import { LOG } from '../logging/logger.js';
25
+ import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
22
26
  import type { ChatMessage } from '../types.js';
23
27
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
24
- import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.js';
28
+ import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, looksLikeActiveApprovalPromptText } from './approval-utils.js';
25
29
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
26
30
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
27
31
  import { normalizeProviderSessionId } from './provider-session-id.js';
28
- import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
32
+ import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter } from './chat-message-normalization.js';
33
+ import { workingDirBasename } from './working-dir.js';
34
+ import { ManualAttendanceTracker } from './manual-attendance.js';
29
35
 
30
36
  type PersistableCliHistoryMessage = {
31
37
  role: string;
@@ -35,16 +41,136 @@ type PersistableCliHistoryMessage = {
35
41
  receivedAt?: number;
36
42
  };
37
43
 
44
+ // Status snapshots only ever surface the newest messages: the cloud 'live'
45
+ // profile drops chat messages entirely (loaded lazily via read_chat on
46
+ // subscribe) and the 'full' profile caps activeChat.messages to the last 60
47
+ // (see status/normalize.ts). Unread/completion markers walk only the tail.
48
+ // So getState()'s saved-history hydration — which runs once per resume/manual
49
+ // CLI session on every status report — must read only a bounded tail, not the
50
+ // entire transcript. A full MAX_SAFE_INTEGER read here makes the initial
51
+ // status report O(transcript) × N(sessions), which is the real cold first-
52
+ // connection bottleneck on chat-heavy machines. The window comfortably exceeds
53
+ // the 60-message snapshot cap so dedup/collapse at the boundary stays stable.
54
+ const STATUS_HYDRATION_TAIL_LIMIT = 200;
55
+
38
56
  type CompletedDebouncePending = {
39
57
  chatTitle: string;
40
58
  duration: number;
41
59
  timestamp: number;
42
60
  firstObservedAt: number;
61
+ previousStatus: string;
43
62
  loggedBlockReason?: string;
63
+ loggedTranscriptProbe?: boolean;
64
+ transcriptProbeHistory?: ExternalTranscriptProbe[];
65
+ // ARCH-REFACTOR R1: the taskId of the turn that produced this (debounced) completion,
66
+ // captured SYNCHRONOUSLY at the generating→idle transition. The actual completion
67
+ // event is emitted later by the debounce flush, by which point a follow-up task may
68
+ // already have started its own turn and overwritten engine.currentTurnTaskId — so the
69
+ // id must be snapshotted here, not re-read at flush time.
70
+ taskId?: string;
71
+ // NOTIF Defect-B: the wall-clock start of the turn that produced this (debounced)
72
+ // completion, snapshotted SYNCHRONOUSLY at the generating→idle transition (same
73
+ // reason as taskId — a follow-up turn moves engine.currentTurnStartedAt). The
74
+ // completion's finalSummary is turn-scoped to bubbles at/after this instant so a
75
+ // debounce that flushes before the producing turn's final assistant bubble lands
76
+ // in the native transcript never echoes the PRIOR task's last bubble.
77
+ turnStartedAt?: number;
78
+ };
79
+
80
+ function isIdleStatus(value: unknown): boolean {
81
+ const status = typeof value === 'string' ? value.trim().toLowerCase() : '';
82
+ return !status || status === 'idle' || status === 'ready';
83
+ }
84
+
85
+
86
+ function getMessageTime(message: unknown): number {
87
+ if (!message || typeof message !== 'object') return 0;
88
+ const record = message as { receivedAt?: unknown; timestamp?: unknown };
89
+ const value = Number(record.receivedAt ?? record.timestamp ?? 0);
90
+ return Number.isFinite(value) ? value : 0;
91
+ }
92
+
93
+ type CompletedFinalizationBlock = {
94
+ reason: string;
95
+ terminal?: boolean;
96
+ allowTimeout?: boolean;
97
+ // (SETTLE-VALLEY) When set, suppress the CANON-C decoupled-immediate emit for this
98
+ // missing_final_assistant block and HOLD (retry up to COMPLETED_FINALIZATION_MAX_WAIT_MS)
99
+ // until the native transcript's final assistant turn arrives (block clears → genuine emit)
100
+ // or the worker resumes (resume guard cancels). Set only for the inter-approval idle valley
101
+ // of a native-history mesh worker, where an immediate weak emit would freeze a truncated
102
+ // preamble summary (evidenceLevel=insufficient) into the append-only ledger before the
103
+ // worker's next approval turn resumes. Independent of valley length.
104
+ holdForTranscript?: boolean;
105
+ };
106
+
107
+ type CompletionFinalAssistantEvidence = {
108
+ present: boolean;
109
+ messages: unknown[];
110
+ source: 'parsed' | 'external-native' | 'unavailable';
111
+ };
112
+
113
+ type ExternalTranscriptProbe = {
114
+ readAt: number;
115
+ msgCount: number;
116
+ lastRole: string | null;
117
+ lastKind: string | null;
118
+ contentLen: number;
119
+ sourcePath: string | null;
120
+ sourceMtimeMs: number | null;
121
+ mtimeAgeMs: number | null;
44
122
  };
45
123
 
46
124
  const COMPLETED_FINALIZATION_RETRY_MS = 1000;
47
125
  const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
126
+ // (FALSEIDLE-BGCHILD-a) Minimum generating→idle settle window for native-history mesh worker
127
+ // sessions. Native-history providers (e.g. claude-cli) normally flush the completion with
128
+ // flushDelay=0 — the transcript is authoritative, so there is no reason to wait. But a worker
129
+ // turn that spawns a BACKGROUND child (e.g. `npm test &`, a backgrounded Bash tool) can paint
130
+ // a burst of child output, fall quiet, and have the screen parser read a PRIOR/intermediate
131
+ // standard assistant as if the turn were done — firing a false idle while the agent is in fact
132
+ // still generating (e.g. mid-commit). With flushDelay=0 there is no window for the resume guard
133
+ // in flushCompletedDebounceIfFinalized (latestVisibleStatus !== 'idle' → cancel) to observe the
134
+ // agent picking the turn back up. A short non-zero settle window restores that resume guard for
135
+ // mesh workers without delaying genuinely-finished turns beyond this bound. Scoped to mesh
136
+ // worker sessions so interactive native-history sessions keep the immediate flush.
137
+ // 4000ms (was 1500): live measurement showed the completion event can fire 1.6–3s
138
+ // BEFORE the worker's final-assistant turn lands in the transcript on a natural
139
+ // generating→idle completion (no approval modal), freezing a prior intermediate
140
+ // bubble as finalSummary (evidenceLevel=insufficient). The 68a3c324 waiting_approval
141
+ // hold only covers the approval-resolved valley; widening this settle window to 4000ms
142
+ // covers that race AND the ~3s waiting_approval valley within the settle bound.
143
+ const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
144
+ // TASKBUBBLE-DUP: window during which an identical user-input ack (same trimmed
145
+ // content on the same instance) is treated as a redelivery of one dispatch and
146
+ // suppressed from the chat transcript. Matches the coordinator-side
147
+ // DUPLICATE_DISPATCH_WINDOW_MS (mesh-tools) so the daemon's bubble-level guard
148
+ // covers the same retry horizon as the MCP-level dispatch dedup.
149
+ const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60_000;
150
+ // GENERATING-BOUNDARY (R4c): window — measured from the startup-grace COLLAPSE moment
151
+ // (startupGraceCollapseAt), not boot — inside which a "first turn that ran+completed
152
+ // without the FSM ever observing a 'generating' frame" is attributed to the startup-grace
153
+ // collapse and synthesized (reason 'startup_grace_idle_turn_collapse').
154
+ // The spec's startup-grace exit is elapsed_ms=8000, so the FSM spends ~8s in 'starting'
155
+ // before collapsing to idle; a turn can be dispatched a few seconds AFTER that collapse
156
+ // (the live R4b miss: collapse at boot+8s, dispatch at boot+12.4s — already past a 12s
157
+ // boot-anchored window before the turn even started). Anchoring on the collapse moment
158
+ // covers dispatch-delay; R4d additionally anchors on the turn-START moment
159
+ // (engine.currentTurnStartedAt) so a non-trivial turn-DURATION cannot push the completion
160
+ // past a now-anchored window (the live rc.405 Probe2 miss). The strong discriminator is
161
+ // generatingStartedAt===0 (generating was never observed) AND a started-but-finished turn
162
+ // — the window only keeps the synthesized reason honest and scopes the synthesis to the
163
+ // boot collapse, so a much-later unobservably-fast turn is not mislabelled a startup collapse.
164
+ const STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS = 12_000;
165
+
166
+ /** Events that signal a dispatched mesh task has reached a terminal state.
167
+ * Detach the mesh assignment after emitting one of these so the worker's
168
+ * next unrelated turn doesn't impersonate another completion. */
169
+ const TERMINAL_MESH_EVENTS = new Set([
170
+ 'agent:generating_completed',
171
+ 'agent:stopped',
172
+ 'agent:ready',
173
+ ]);
48
174
 
49
175
  const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
50
176
  'image/png': '.png',
@@ -120,7 +246,7 @@ function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
120
246
  }
121
247
 
122
248
  function isCliGeneratingLikeStatus(status: unknown): boolean {
123
- return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
249
+ return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
124
250
  }
125
251
 
126
252
  export function buildCliStructuredInputPrompt(
@@ -300,24 +426,131 @@ export class CliProviderInstance implements ProviderInstance {
300
426
  readonly type: string;
301
427
  readonly category = 'cli' as const;
302
428
 
429
+ /**
430
+ * Quiet period an approval modal's signature must be stable before
431
+ * auto-approve sends the approve key. Guards against firing on a prompt
432
+ * that is still streaming into the PTY (the "resolves too fast" symptom):
433
+ * while the modal text/buttons are still changing, every frame yields a
434
+ * new signature and the settle clock restarts. Once the prompt finishes
435
+ * rendering the signature holds and the key is sent after this window.
436
+ * Bounded + small so genuine approvals stay timely. The FSM is already
437
+ * authoritative over the `waiting_approval` state; this only delays the
438
+ * keystroke until the modal *content* has settled.
439
+ */
440
+ private static readonly AUTO_APPROVE_SETTLE_MS = 600;
441
+
442
+ /**
443
+ * Busy-side hysteresis for the settle gate. A momentary `generating` flip
444
+ * while the SAME approval modal's button block is still on screen (its
445
+ * question line scrolled out of the captured frame, only the buttons + a
446
+ * residual `esc to interrupt` spinner remain) briefly reports
447
+ * status!=waiting_approval. Without hysteresis that flip wipes the settle
448
+ * clock, and the modal→generating→modal flap restarts the 600ms window
449
+ * every time so auto-approve never fires. We keep the in-progress settle
450
+ * gate warm across an inactive blip up to this bound; only once the modal
451
+ * has genuinely stayed gone this long (a real resolution → idle) is the
452
+ * gate cleared. Bounded so a genuinely new, later approval still re-settles
453
+ * from scratch rather than firing on a stale timestamp.
454
+ */
455
+ private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS = 1500;
456
+
457
+ /**
458
+ * STATUS-MISMATCH: upper bound on how long the auto-approve→`generating` SURFACE
459
+ * mask may hide a worker's `waiting_approval` (status + activeModal) before we give
460
+ * up and surface the real prompt. The mask exists because auto-approve is expected
461
+ * to resolve the modal momentarily; but if it STALLS without ever calling
462
+ * resolveModal — the modal signature never settles for AUTO_APPROVE_SETTLE_MS (a
463
+ * perpetually-flapping/streaming prompt), no concrete modal is ever captured, or the
464
+ * modal is a picker/non-affirmative we never auto-pick — the mask would persist
465
+ * forever and read_chat / mesh_status / the dashboard would NEVER see the pending
466
+ * approval (the coordinator cannot mesh_approve what it cannot see). Once an episode
467
+ * exceeds this bound we stop masking. Generously larger than
468
+ * AUTO_APPROVE_SETTLE_MS (600) + AUTO_APPROVE_GATE_HYSTERESIS_MS (1500) so a
469
+ * legitimately slow-settling / blip-flapping prompt is never unmasked early; a
470
+ * genuine never-resolving stall surfaces within this window. The settle gate keeps
471
+ * running underneath, so a prompt that finally stabilises still auto-approves, and
472
+ * mesh_approve (raw FSM, unmasked) works throughout.
473
+ */
474
+ private static readonly AUTO_APPROVE_MASK_STALL_MS = 4500;
475
+
303
476
  private adapter: ProviderCliAdapter;
304
477
  private context: InstanceContext | null = null;
305
478
  private events: ProviderEvent[] = [];
306
479
  private lastStatus: string = 'starting';
480
+ // Idempotency guard for the queue-claim agent:ready event. agent:ready is the
481
+ // sole signal the mesh coordinator's tryAssignQueueTask waits on to hand a
482
+ // queued task to this worker. It is emitted in two places: the boot-time
483
+ // starting→idle one-shot, and the readySeen re-arm below. This flag makes the
484
+ // event fire AT MOST ONCE per session so a worker is never claimed twice and a
485
+ // queued task is never double-dispatched/double-injected. Whichever path fires
486
+ // first sets it; the other becomes a no-op.
487
+ private agentReadyEmitted = false;
307
488
  private generatingStartedAt: number = 0;
489
+ // GENERATING-BOUNDARY (R4b): the per-turn taskId for which a startup-grace
490
+ // started+completed pair was already synthesized. Both fast-collapse callers
491
+ // (starting→idle transition AND the idle-stayed no-status-change poll) route
492
+ // through maybeSynthesizeStartupGraceCollapse; the idle-stayed caller re-polls
493
+ // steadily while the session sits idle, so this guard makes the synthesis fire
494
+ // AT MOST ONCE per collapsed turn.
495
+ private fastCollapseSynthesizedTaskId: string | null = null;
496
+ // GENERATING-BOUNDARY (R4c): the wall-clock moment the FSM's starting→idle
497
+ // startup-grace collapse was observed (set ONCE, on the first starting→idle
498
+ // transition this boot). The idle-stayed collapse window is anchored on THIS,
499
+ // not on instance/boot time (this.startedAt): the FSM spends the full 8s
500
+ // startup-grace sitting in 'starting' before collapsing, and a turn can be
501
+ // dispatched a few seconds AFTER the collapse — measuring the window from boot
502
+ // would have it already closed by the time that turn lands+completes (the live
503
+ // R4b miss: collapse at boot+8s, dispatch at boot+12.4s > the 12s boot window).
504
+ // Anchoring on the collapse moment makes the window cover dispatch-delay+turn.
505
+ private startupGraceCollapseAt: number | null = null;
308
506
  private settings: Record<string, any> = {};
309
507
  private monitor: StatusMonitor;
310
508
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
311
509
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
312
- private lastApprovalEventAt = 0;
510
+ private lastApprovalEventFingerprint = '';
313
511
  private autoApproveBusy = false;
314
512
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
513
+ private lastAutoApprovalSignature = '';
514
+ // Settle gate: the approval modal's signature + the wall-clock when this
515
+ // exact signature was first observed. Auto-approve only fires once the
516
+ // SAME signature has been stable for AUTO_APPROVE_SETTLE_MS, so a prompt
517
+ // still streaming into the PTY (its buttons/message changing frame to
518
+ // frame) keeps resetting the timer and is never approved half-rendered.
519
+ private pendingAutoApprovalSignature = '';
520
+ private pendingAutoApprovalSince = 0;
521
+ private autoApproveSettleTimer: NodeJS.Timeout | null = null;
522
+ // Wall-clock when auto-approve first observed status!=waiting_approval while
523
+ // a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS so a
524
+ // brief generating flip does not immediately wipe the settle clock.
525
+ private autoApproveInactiveSince = 0;
526
+ // STATUS-MISMATCH: wall-clock when the CURRENT auto-approve episode (waiting_approval
527
+ // + shouldAutoApprove) first began wanting to mask. Unlike pendingAutoApprovalSince it
528
+ // is NOT reset when the modal signature changes (a still-streaming/flapping prompt) and
529
+ // survives the same hysteresis blips the settle gate does, so it measures the TRUE age
530
+ // of an unresolved auto-approve. Once it exceeds AUTO_APPROVE_MASK_STALL_MS the surface
531
+ // mask is dropped so the real waiting_approval surfaces. Cleared when the episode ends
532
+ // (modal genuinely gone, manual attendance takes over, or auto-approve fires).
533
+ private autoApproveMaskSince = 0;
534
+ // Provider-common manual-attendance signal: while a human is actively driving
535
+ // this session from the dashboard, auto-approve holds so they can take manual
536
+ // control. Background mesh workers are never attended → delegated auto-approve
537
+ // is unaffected.
538
+ private readonly manualAttendance = new ManualAttendanceTracker();
315
539
  private controlValues: Record<string, string | number | boolean> = {};
316
540
  private summaryMetadata: unknown = undefined;
317
541
  private appliedEffectKeys = new Set<string>();
318
542
  private historyWriter: ChatHistoryWriter;
319
543
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
320
544
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
545
+ private lastAcknowledgedUserInputAt = 0;
546
+ // TASKBUBBLE-DUP: per-content last-ack timestamps so the same dispatched
547
+ // prompt acked twice in quick succession (the worker buffers the first
548
+ // send during bootstrap/busy, then a redelivery — dispatch-confirm-timeout
549
+ // requeue or a reconcile re-dispatch — fires a SECOND send_chat before the
550
+ // outbound queue drains) collapses to ONE user bubble. Keyed on the trimmed
551
+ // content; an entry older than USER_INPUT_ACK_DEDUP_WINDOW_MS is treated as
552
+ // a fresh, intentional resend and is NOT suppressed.
553
+ private recentUserInputAcks = new Map<string, number>();
321
554
  private lastNativeSourceCanonicalCheckAt = 0;
322
555
  private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
323
556
  private cachedSqliteDb: {
@@ -330,6 +563,7 @@ export class CliProviderInstance implements ProviderInstance {
330
563
  private suppressIdleHistoryReplay = false;
331
564
  private errorMessage: string | undefined = undefined;
332
565
  private errorReason: ProviderErrorReason | undefined = undefined;
566
+ private activeInteractivePrompt: InteractivePrompt | null = null;
333
567
 
334
568
  private presentationMode: 'terminal' | 'chat';
335
569
  private providerSessionId?: string;
@@ -370,7 +604,10 @@ export class CliProviderInstance implements ProviderInstance {
370
604
  this.providerSessionId = options?.providerSessionId;
371
605
  this.launchMode = options?.launchMode || 'new';
372
606
  this.onProviderSessionResolved = options?.onProviderSessionResolved;
373
- this.adapter = new ProviderCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory);
607
+ this.adapter = createCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory) as ProviderCliAdapter;
608
+ if (this.providerSessionId) {
609
+ this.adapter.updateRuntimeMeta({ providerSessionId: this.providerSessionId });
610
+ }
374
611
  this.monitor = new StatusMonitor();
375
612
  this.historyWriter = new ChatHistoryWriter();
376
613
  }
@@ -389,8 +626,8 @@ export class CliProviderInstance implements ProviderInstance {
389
626
  this.adapter.updateRuntimeSettings?.(this.settings);
390
627
  this.monitor.updateConfig({
391
628
  approvalAlert: this.settings.approvalAlert !== false,
392
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
393
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
629
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
630
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
394
631
  });
395
632
 
396
633
  // Server connection
@@ -412,7 +649,7 @@ export class CliProviderInstance implements ProviderInstance {
412
649
  await this.adapter.spawn();
413
650
  await this.enforceFreshSessionLaunchIfNeeded();
414
651
  this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
415
- if (this.providerSessionId) {
652
+ if (this.providerSessionId && this.shouldHydrateExistingProviderHistory()) {
416
653
  this.restorePersistedHistoryFromCurrentSession();
417
654
  }
418
655
  if (this.providerSessionId && this.launchMode === 'resume') {
@@ -486,14 +723,38 @@ export class CliProviderInstance implements ProviderInstance {
486
723
  }
487
724
 
488
725
  getState(): ProviderState {
726
+ // TODO(phase5-sandbox): JS override scripts (detectStatus, parseApproval,
727
+ // parseSession) are currently invoked by CliScriptRunner.invoke() via direct
728
+ // function calls — the scripts run in the daemon process with full Node.js
729
+ // access and no resource limits.
730
+ //
731
+ // When Phase 5 lands, CliScriptRunner should route these calls through a
732
+ // SandboxedScriptRunner (see providers/sdk/v1/sandbox/script-runner.ts) so
733
+ // that each call gets a fresh isolated-vm context with a 50 ms CPU limit and
734
+ // a 32 MB memory cap. The execution path to change is:
735
+ // CliScriptRunner.invoke() → SandboxedScriptRunner.run(scriptSource, context)
736
+ //
737
+ // This getState() call-site is NOT where the change goes — the wiring belongs
738
+ // in cli-script-runner.ts (CliScriptRunner.detectStatus / parseApproval /
739
+ // parseSession), with provider-loader.ts updated to store script source strings
740
+ // alongside the loaded function references for extended-legacy providers.
489
741
  const adapterStatus = this.adapter.getStatus();
742
+ if (Object.prototype.hasOwnProperty.call(adapterStatus, 'activeInteractivePrompt')) {
743
+ this.activeInteractivePrompt = adapterStatus.activeInteractivePrompt ?? null;
744
+ }
490
745
  let parsedStatus: any = null;
491
746
  let parseErrorMessage: string | undefined;
492
747
  if (typeof this.adapter.getScriptParsedStatus === 'function') {
493
748
  try {
494
749
  parsedStatus = this.adapter.getScriptParsedStatus() || null;
495
- this.errorMessage = undefined;
496
- this.errorReason = undefined;
750
+ const parsedErrorMessage = typeof parsedStatus?.errorMessage === 'string' && parsedStatus.errorMessage.trim()
751
+ ? parsedStatus.errorMessage.trim()
752
+ : undefined;
753
+ const parsedErrorReason = typeof parsedStatus?.errorReason === 'string' && parsedStatus.errorReason.trim()
754
+ ? parsedStatus.errorReason.trim() as ProviderErrorReason
755
+ : undefined;
756
+ this.errorMessage = parsedErrorMessage;
757
+ this.errorReason = parsedErrorReason;
497
758
  } catch (error: any) {
498
759
  parseErrorMessage = error?.message || String(error);
499
760
  this.errorMessage = parseErrorMessage;
@@ -503,22 +764,59 @@ export class CliProviderInstance implements ProviderInstance {
503
764
  this.errorMessage = undefined;
504
765
  this.errorReason = undefined;
505
766
  }
506
- const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
507
- const visibleStatus = parseErrorMessage
508
- ? 'error'
509
- : (autoApproveActive ? 'generating' : adapterStatus.status);
510
- const parsedProviderSessionId = normalizeProviderSessionId(
767
+ const adapterProviderSessionId = normalizeProviderSessionId(
511
768
  this.provider,
512
- typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
769
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
513
770
  );
514
- if (parsedProviderSessionId) {
515
- this.promoteProviderSessionId(parsedProviderSessionId);
771
+ const nowMs = Date.now();
772
+ // STATUS-MISMATCH: maybeAutoApproveStatus still runs for its side effects (settle gate,
773
+ // resolveModal fire), but the SURFACE mask is dropped once the episode has stalled past
774
+ // AUTO_APPROVE_MASK_STALL_MS — otherwise a never-settling auto-approve hides the worker's
775
+ // waiting_approval + modal from read_chat/mesh_status/dashboard forever.
776
+ const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, nowMs)
777
+ && !this.autoApproveMaskStalled(nowMs);
778
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
779
+ let visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
780
+ ? 'error'
781
+ : (autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status);
782
+ // getState() must agree with the status the FSM-driven detectStatusTransition()
783
+ // already committed to lastStatus. The adapter's own status is authoritative; we do
784
+ // not second-guess it with native-transcript shape. Only reconcile a generating-like
785
+ // read down to idle when our own lastStatus has already flipped idle (avoids a
786
+ // perpetual dashboard spinner during the brief window before the next getStatus()).
787
+ if (isCliGeneratingLikeStatus(visibleStatus) && this.lastStatus === 'idle') {
788
+ visibleStatus = 'idle';
516
789
  }
517
790
  const runtime = this.adapter.getRuntimeMetadata();
518
791
  this.maybeAppendRuntimeRecoveryMessage(runtime);
519
792
  let parsedMessages = Array.isArray(parsedStatus?.messages)
520
793
  ? parsedStatus.messages
521
794
  : [];
795
+ const parsedProviderSessionId = normalizeProviderSessionId(
796
+ this.provider,
797
+ typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
798
+ );
799
+ const suppressFreshLaunchStartupReplay = this.shouldSuppressFreshLaunchStartupReplay(
800
+ parsedMessages,
801
+ parsedStatus,
802
+ adapterStatus,
803
+ parsedProviderSessionId,
804
+ );
805
+ if (adapterProviderSessionId && !suppressFreshLaunchStartupReplay) {
806
+ this.promoteProviderSessionId(adapterProviderSessionId);
807
+ }
808
+ if (parsedProviderSessionId && !suppressFreshLaunchStartupReplay) {
809
+ this.promoteProviderSessionId(parsedProviderSessionId);
810
+ }
811
+ if (suppressFreshLaunchStartupReplay) {
812
+ parsedMessages = [];
813
+ }
814
+ // Adapter runtime metadata is transport-owned and is not guaranteed to
815
+ // identify this conversation. Spec adapters historically exposed the
816
+ // provider spec id (for example "codex-cli") as runtimeId, which made
817
+ // concurrent sessions share one activeChat identity until their native
818
+ // provider session ids were discovered.
819
+ const activeChatId = this.providerSessionId || this.instanceId;
522
820
  const historyMessageCount = Number.isFinite(parsedStatus?.historyMessageCount)
523
821
  ? Math.max(0, Number(parsedStatus.historyMessageCount))
524
822
  : null;
@@ -528,9 +826,20 @@ export class CliProviderInstance implements ProviderInstance {
528
826
  : [];
529
827
  }
530
828
  const mergedMessages = this.mergeConversationMessages(parsedMessages);
531
- const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
829
+ const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
830
+ ? this.syncCanonicalSavedHistoryIfNeeded()
831
+ : false;
832
+ const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
833
+ ? this.lastPersistedHistoryMessages.map((message) => ({
834
+ role: message.role,
835
+ content: message.content,
836
+ kind: message.kind,
837
+ senderName: message.senderName,
838
+ receivedAt: message.receivedAt,
839
+ }))
840
+ : mergedMessages;
532
841
 
533
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
842
+ const dirName = workingDirBasename(this.workingDir);
534
843
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
535
844
  ? parsedStatus.status.trim()
536
845
  : undefined;
@@ -542,7 +851,7 @@ export class CliProviderInstance implements ProviderInstance {
542
851
  && adapterStatus.status === 'idle'
543
852
  && parsedStatus?.status === 'idle';
544
853
  let messagesToSave = parsedMessages;
545
- if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
854
+ if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'no_progress' || parsedChatStatus === 'long_generating')) {
546
855
  const lastIdx = messagesToSave.length - 1;
547
856
  if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
548
857
  messagesToSave = messagesToSave.slice(0, lastIdx);
@@ -572,33 +881,48 @@ export class CliProviderInstance implements ProviderInstance {
572
881
  }
573
882
  }
574
883
 
575
- this.applyProviderResponse(parsedStatus, { phase: 'immediate' });
884
+ this.applyProviderResponse(
885
+ suppressFreshLaunchStartupReplay && parsedStatus && typeof parsedStatus === 'object'
886
+ ? { ...parsedStatus, providerSessionId: undefined }
887
+ : parsedStatus,
888
+ { phase: 'immediate' },
889
+ );
576
890
  const surface = resolveProviderStateSurface({
577
891
  summaryMetadata: this.summaryMetadata as any,
578
892
  controlValues: this.controlValues,
579
893
  });
580
894
  const activeChatStatus = parseErrorMessage
581
895
  ? 'error'
582
- : autoApproveActive && parsedStatus?.status === 'waiting_approval'
896
+ : (autoApproveActive && parsedStatus?.status === 'waiting_approval') || autoApproveHoldIdle
583
897
  ? 'generating'
584
898
  : (adapterStatus.status !== 'idle'
585
899
  ? visibleStatus
586
900
  : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
587
901
 
902
+ // If an AskUserQuestion prompt is awaiting user input, overlay status as
903
+ // waiting_choice. This is distinct from waiting_approval (tool-use consent)
904
+ // — the engine's isWaitingForResponse state is unchanged, so completion
905
+ // tracking continues normally once the user responds.
906
+ const hasInteractivePrompt = !!this.activeInteractivePrompt;
907
+ const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
908
+ const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
909
+
588
910
  return {
589
911
  type: this.type,
590
912
  name: this.provider.name,
591
913
  category: 'cli',
592
- status: visibleStatus,
914
+ status: finalStatus,
593
915
  mode: this.presentationMode,
594
916
  activeChat: {
595
- id: `${this.type}_${this.workingDir}`,
917
+ id: activeChatId,
596
918
  title: parsedStatus?.title || dirName,
597
- status: activeChatStatus,
598
- messages: mergedMessages,
599
- activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
919
+ status: finalChatStatus,
920
+ messages: statusMessages,
921
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
922
+ activeInteractivePrompt: this.activeInteractivePrompt,
600
923
  inputContent: '',
601
924
  },
925
+ activeInteractivePrompt: this.activeInteractivePrompt,
602
926
  workspace: this.workingDir,
603
927
  instanceId: this.instanceId,
604
928
  providerSessionId: this.providerSessionId,
@@ -638,8 +962,12 @@ export class CliProviderInstance implements ProviderInstance {
638
962
 
639
963
  getHotChatSessionState(): HotChatSessionState {
640
964
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
641
- const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
642
- const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
965
+ const nowMs = Date.now();
966
+ // STATUS-MISMATCH: drop the mask once the auto-approve episode has stalled (see getState).
967
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status, nowMs)
968
+ && !this.autoApproveMaskStalled(nowMs);
969
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
970
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
643
971
  const runtime = this.adapter.getRuntimeMetadata();
644
972
  return {
645
973
  id: this.instanceId,
@@ -651,36 +979,231 @@ export class CliProviderInstance implements ProviderInstance {
651
979
  };
652
980
  }
653
981
 
654
- getSessionModalState(): SessionModalState {
655
- const adapterStatus = this.adapter.getStatus({ allowParse: false });
656
- const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
657
- const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
658
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
982
+ getSessionModalState(sessionId?: string): SessionModalState {
983
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
984
+ const nowMs = Date.now();
985
+ // STATUS-MISMATCH: drop the mask once the auto-approve episode has stalled (see getState).
986
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status, nowMs)
987
+ && !this.autoApproveMaskStalled(nowMs);
988
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
989
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
990
+ const dirName = workingDirBasename(this.workingDir);
659
991
  return {
660
- id: this.instanceId,
992
+ // Honor the caller-supplied sessionId — InstanceMgr rejects the
993
+ // projection when projected.id !== requested sessionId, and
994
+ // this.instanceId is the manager's internal key, not the public
995
+ // sessionId the dashboard subscribes by.
996
+ id: sessionId ?? this.instanceId,
661
997
  status: visibleStatus,
662
998
  title: dirName,
663
- activeModal: autoApproveActive ? null : adapterStatus.activeModal,
999
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : adapterStatus.activeModal,
664
1000
  };
665
1001
  }
666
1002
 
667
1003
  updateSettings(newSettings: Record<string, any>): void {
668
- this.settings = { ...newSettings };
1004
+ // Merge semantics: a key omitted from newSettings preserves its existing
1005
+ // value, a key present in newSettings (even as false) overrides it.
1006
+ //
1007
+ // This is required because updateSettings has two callers with opposite
1008
+ // intent:
1009
+ // 1. Full re-injection — the dashboard toggle path (handleSetProviderSetting
1010
+ // → getSettings → updateInstanceSettings) sends the COMPLETE settings
1011
+ // object, so an explicit autoApprove:false must win.
1012
+ // 2. Partial stamp — the mesh relay-safety stamp (router.ts agent_command,
1013
+ // buildMeshWorkerRelayStamp) sends ONLY {meshNodeFor, meshNodeId,
1014
+ // meshCoordinatorDaemonId, launchedByCoordinator} on every coordinator
1015
+ // re-dispatch. It carries no autoApprove, so a full replacement would
1016
+ // wipe the launch-time autoApprove:true the worker was started with,
1017
+ // silently dropping every later approval to a manual gate until the
1018
+ // machine-page toggle re-injected the full settings.
1019
+ //
1020
+ // A plain merge satisfies both: undefined keys fall through to the existing
1021
+ // value (preserving launch-stamp settings like autoApprove + the mesh routing
1022
+ // keys), explicit keys override. This subsumes the previous mesh-key preserve
1023
+ // list, which only protected the routing keys and not autoApprove.
1024
+ this.settings = { ...this.settings, ...newSettings };
669
1025
  this.adapter.updateRuntimeSettings?.(this.settings);
670
1026
  this.monitor.updateConfig({
671
1027
  approvalAlert: this.settings.approvalAlert !== false,
672
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
673
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
1028
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
1029
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
674
1030
  });
675
1031
  }
676
1032
 
1033
+ /**
1034
+ * Stamp a direct-dispatch mesh assignment on this instance.
1035
+ * setupMeshEventForwarding reads settings.meshNodeFor + meshActiveTaskId to
1036
+ * route generating_completed back to the originating coordinator. Without
1037
+ * this stamp, mesh_send_task --direct targets a plain CLI session whose
1038
+ * completion events silently drop because the forwarder has nothing to
1039
+ * match against.
1040
+ */
1041
+ attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void {
1042
+ if (!assignment?.meshId) return;
1043
+ this.settings = {
1044
+ ...this.settings,
1045
+ meshNodeFor: assignment.meshId,
1046
+ // WTCLAIM (A): track the bound node id under BOTH the active marker
1047
+ // (meshNodeId, cleared on detach) and a sticky marker (meshLastNodeId,
1048
+ // preserved across detach). The sticky marker lets a detached but still
1049
+ // coordinator-owned session be re-picked ONLY for the SAME node it served
1050
+ // — never auto-adopted for a sibling node (e.g. a cloned worktree) that
1051
+ // shares this daemon. See isMeshOwnedDelegateSession's post-detach gate.
1052
+ ...(assignment.nodeId ? { meshNodeId: assignment.nodeId, meshLastNodeId: assignment.nodeId } : {}),
1053
+ ...(assignment.taskId ? { meshActiveTaskId: assignment.taskId } : {}),
1054
+ ...(assignment.coordinatorDaemonId ? { meshCoordinatorDaemonId: assignment.coordinatorDaemonId } : {}),
1055
+ // Session-level routing anchor: the originating coordinator session, so this
1056
+ // worker's completion events route back to the exact session that dispatched it.
1057
+ ...(assignment.coordinatorSessionId ? { meshCoordinatorSessionId: assignment.coordinatorSessionId } : {}),
1058
+ };
1059
+ this.adapter.updateRuntimeSettings?.(this.settings);
1060
+ }
1061
+
1062
+ /**
1063
+ * Clear a previously-attached mesh assignment after the task reaches a
1064
+ * terminal state. Leaving meshNodeFor pinned would route this session's
1065
+ * subsequent unrelated turns (e.g. ad-hoc dashboard chats) to the
1066
+ * coordinator as if they were task completions.
1067
+ *
1068
+ * MESHID-DROP-ON-DETACH (Fix C): a coordinator-LAUNCHED worker session
1069
+ * (launchedByCoordinator) holds its mesh membership (meshNodeFor / meshNodeId /
1070
+ * meshCoordinatorDaemonId) at the SESSION level — set once at launch
1071
+ * (mesh_launch_session / queue auto-launch), independent of any single task.
1072
+ * The original detach wiped meshNodeFor + meshNodeId together with the
1073
+ * task-level meshActiveTaskId, so the FIRST task completion stripped the
1074
+ * membership and EVERY subsequent completion forwarded with meshId absent —
1075
+ * resolveWorkerDelegateRouting fell to mesh_unresolved and the coordinator
1076
+ * rejected the forward "meshId required". For a launched member we therefore
1077
+ * clear ONLY the task-level marker (meshActiveTaskId) and preserve the
1078
+ * session-level membership so its next task's completion still resolves.
1079
+ * A task-less ad-hoc turn on a preserved-membership session is NOT misrouted:
1080
+ * its completion carries no taskId and the session holds no active assignment,
1081
+ * so the forwarder's WARMUPGAP guard skips the dispatch-row flip (it only
1082
+ * injects a benign task-less notification). A NON-launched session (a plain CLI
1083
+ * session adopted by mesh_send_task --direct, launchedByCoordinator falsy)
1084
+ * keeps the original full clear so an ad-hoc session is never left pinned.
1085
+ */
1086
+ detachMeshAssignment(): void {
1087
+ if (!this.settings.meshNodeFor && !this.settings.meshActiveTaskId && !this.settings.meshNodeId) return;
1088
+ // Session-level member: keep membership, drop only the task-level marker.
1089
+ if (this.settings.launchedByCoordinator === true) {
1090
+ if (!this.settings.meshActiveTaskId) return;
1091
+ const { meshActiveTaskId, ...rest } = this.settings;
1092
+ void meshActiveTaskId;
1093
+ this.settings = rest;
1094
+ this.adapter.updateRuntimeSettings?.(this.settings);
1095
+ return;
1096
+ }
1097
+ const { meshNodeFor, meshNodeId, meshActiveTaskId, ...rest } = this.settings;
1098
+ void meshNodeFor; void meshActiveTaskId;
1099
+ // WTCLAIM (A): clear the active binding but PRESERVE the last bound node id
1100
+ // (meshLastNodeId) so a later sessionless dispatch can re-adopt this idle
1101
+ // session ONLY for the node it last served. Carry the id being cleared, or
1102
+ // keep an already-present sticky marker if meshNodeId was absent.
1103
+ const lastNodeId = (typeof meshNodeId === 'string' && meshNodeId.trim())
1104
+ ? meshNodeId.trim()
1105
+ : (typeof rest.meshLastNodeId === 'string' && rest.meshLastNodeId.trim() ? rest.meshLastNodeId.trim() : undefined);
1106
+ this.settings = lastNodeId ? { ...rest, meshLastNodeId: lastNodeId } : rest;
1107
+ this.adapter.updateRuntimeSettings?.(this.settings);
1108
+ }
1109
+
1110
+ /**
1111
+ * The resolved modal-park status of this session, or null when it is not
1112
+ * parked on a modal awaiting a human answer. Mirrors the overlay logic in
1113
+ * getState(): an active AskUserQuestion interactive prompt resolves to
1114
+ * waiting_choice; otherwise the adapter's waiting_approval (tool consent)
1115
+ * counts — UNLESS auto-approve will dismiss it, in which case the session is
1116
+ * effectively generating and is NOT modal-parked. This is the single signal
1117
+ * the mesh force-inject guard consults, and the same status string the
1118
+ * reconcile loop reads off get_status_metadata. Lowercase literals only —
1119
+ * the SessionStatus enum is forked across modules and waiting_choice is
1120
+ * absent from some of them.
1121
+ */
1122
+ resolveModalParkStatus(): 'waiting_choice' | 'waiting_approval' | null {
1123
+ if (this.activeInteractivePrompt) return 'waiting_choice';
1124
+ let adapterStatus: { status?: string };
1125
+ try {
1126
+ adapterStatus = this.adapter.getStatus({ allowParse: false });
1127
+ } catch {
1128
+ return null;
1129
+ }
1130
+ // A session whose auto-approve is held by manual attendance IS parked on a
1131
+ // modal awaiting the human — autoApproveEffectivelyActive folds that in, so
1132
+ // the mesh force-inject guard correctly treats it as modal-parked. STATUS-MISMATCH:
1133
+ // a STALLED auto-approve (never resolving) is likewise effectively parked — treat it
1134
+ // as modal-parked so its events are held/surfaced rather than masked behind generating.
1135
+ if (adapterStatus.status === 'waiting_approval'
1136
+ && (!this.autoApproveEffectivelyActive(adapterStatus.status) || this.autoApproveMaskStalled())) {
1137
+ // NOTIF-HELD-DRAIN (Fix 1): an autonomous mesh session (coordinator or worker)
1138
+ // that is actively progressing a turn — a tool call is in flight
1139
+ // (hasAdapterPendingResponse) and NO human is attending it by hand — surfaces a
1140
+ // routine tool-consent `waiting_approval` on EVERY tool call when auto-approve is
1141
+ // off. That transient consent is part of the turn the harness/operator drives to
1142
+ // completion, NOT a session genuinely wedged awaiting a human's modal answer.
1143
+ // Classifying it modal-parked makes findLiveCoordinators hold the mesh's pending
1144
+ // completion events under `modal_parked` across a busy coordinator's whole work
1145
+ // batch, which is the multi-minute notification stall. Treat such a transient
1146
+ // consent as NOT modal-parked so it is held as ordinary "generating" (released on
1147
+ // the next idle) instead. A manually-attended session, a stalled auto-approve, or a
1148
+ // non-progressing session (no turn in flight) still parks — those are the genuine
1149
+ // human-await cases the guard must keep holding.
1150
+ if (this.isTransientToolConsent()) {
1151
+ return null;
1152
+ }
1153
+ return 'waiting_approval';
1154
+ }
1155
+ return null;
1156
+ }
1157
+
1158
+ /**
1159
+ * NOTIF-HELD-DRAIN: true when this `waiting_approval` is a routine, transient tool-consent
1160
+ * of an autonomously-progressing mesh session rather than a genuine human-await modal —
1161
+ * i.e. it is a mesh coordinator/worker session, a turn is actively in flight
1162
+ * (hasAdapterPendingResponse), and no human is attending it by hand. Such a consent is
1163
+ * driven to resolution by the harness/operator as part of the in-flight turn, so holding
1164
+ * the mesh's completion events behind it (modal_parked) is the false-positive that stalls
1165
+ * delivery. Narrow by design: manual attendance or a non-progressing session falls through
1166
+ * to the genuine-modal classification.
1167
+ */
1168
+ private isTransientToolConsent(now = Date.now()): boolean {
1169
+ const isAutonomousMeshSession = this.isMeshWorkerSession() || !!this.settings.meshCoordinatorFor;
1170
+ return isAutonomousMeshSession
1171
+ && this.hasAdapterPendingResponse()
1172
+ && !this.manualAttendance.isAttended(now);
1173
+ }
1174
+
1175
+ /** True when this session is parked on a modal awaiting a human answer. */
1176
+ isModalParked(): boolean {
1177
+ return this.resolveModalParkStatus() !== null;
1178
+ }
1179
+
677
1180
  onEvent(event: string, data?: any): void {
678
1181
  if (event === 'send_message') {
679
1182
  const input = normalizeInputEnvelope(data);
680
1183
  assertProviderSupportsDeclaredInput(this.provider, input);
681
1184
  const promptText = buildCliStructuredInputPrompt(input);
682
1185
  if (promptText) {
683
- void this.adapter.sendMessage(promptText).catch((e: any) => {
1186
+ // force:true bypasses the busy/generating send guard so terminal mesh
1187
+ // events (completion/failure/bootstrap) land in a coordinator session that
1188
+ // is itself parked in `generating` while awaiting that very event.
1189
+ // Without it the message is queued and only flushed on the coordinator's
1190
+ // own idle transition — which never happens until it receives the message.
1191
+ const force = data?.force === true;
1192
+ // Modal guard: a force-inject still writes raw keystrokes into the PTY,
1193
+ // bypassing the busy send-guard. If the coordinator is parked on a
1194
+ // harness modal (claude-cli AskUserQuestion → waiting_choice, or a
1195
+ // tool-consent waiting_approval), those keystrokes are consumed by the
1196
+ // modal's key handler and silently select a choice the user never made
1197
+ // (data corruption). Hold the force-inject in that narrow window —
1198
+ // the event stays queued and the reconcile loop redelivers it on the
1199
+ // next tick once the modal is resolved. We ONLY hold for the two modal
1200
+ // states; generating is still force-injected (that is the deadlock the
1201
+ // force path exists to break — see mesh-events-coordinator).
1202
+ if (force && this.isModalParked()) {
1203
+ LOG.info('CLI', `[${this.type}] force send_message held — coordinator parked on modal (${this.resolveModalParkStatus()})`);
1204
+ return;
1205
+ }
1206
+ void this.adapter.sendMessage(promptText, force ? { force: true } : {}).catch((e: any) => {
684
1207
  LOG.warn('CLI', `[${this.type}] send_message failed: ${e?.message || e}`);
685
1208
  });
686
1209
  }
@@ -690,14 +1213,101 @@ export class CliProviderInstance implements ProviderInstance {
690
1213
  void this.adapter.resolveAction(data).catch((e: any) => {
691
1214
  LOG.warn('CLI', `[${this.type}] resolve_action failed: ${e?.message || e}`);
692
1215
  });
1216
+ } else if (event === 'interactive_prompt' && data) {
1217
+ const prompt = normalizeInteractivePrompt(data);
1218
+ if (prompt) {
1219
+ this.activeInteractivePrompt = prompt;
1220
+ this.events.push({
1221
+ event: 'interactive_prompt',
1222
+ timestamp: Date.now(),
1223
+ promptId: prompt.promptId,
1224
+ });
1225
+ }
1226
+ } else if (event === 'interactive_prompt_response' && data) {
1227
+ try {
1228
+ const response = normalizeInteractivePromptResponse(data);
1229
+ if (this.activeInteractivePrompt?.promptId === response.promptId) {
1230
+ this.activeInteractivePrompt = null;
1231
+ }
1232
+ if (typeof this.adapter.setInteractivePromptResponse !== 'function') {
1233
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response ignored: adapter does not support interactive prompts`);
1234
+ return;
1235
+ }
1236
+ void this.adapter.setInteractivePromptResponse(response).catch((e: any) => {
1237
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response failed: ${e?.message || e}`);
1238
+ });
1239
+ } catch (e: any) {
1240
+ LOG.warn('CLI', `[${this.type}] invalid interactive_prompt_response: ${e?.message || e}`);
1241
+ }
693
1242
  } else if (event === 'provider_state_patch' && data && typeof data === 'object') {
694
1243
  this.applyProviderResponse(data, { phase: 'immediate' });
695
1244
  }
696
1245
  }
697
1246
 
1247
+ recordAcknowledgedUserInput(input: InputEnvelope | string): void {
1248
+ const content = typeof input === 'string'
1249
+ ? input.trim()
1250
+ : buildCliStructuredInputPrompt(input).trim();
1251
+ if (!content) return;
1252
+
1253
+ const receivedAt = Date.now();
1254
+
1255
+ // TASKBUBBLE-DUP: collapse a redelivered dispatch to one bubble. A single
1256
+ // mesh_send_task can reach this instance as TWO send_chat calls when the
1257
+ // first injection is buffered during bootstrap/busy and a retry (dispatch-
1258
+ // confirm-timeout requeue, or a reconcile re-dispatch) fires before the
1259
+ // outbound queue drains. The previous dedupKey hashed receivedAt, so the
1260
+ // two acks produced different keys and BOTH bubbled. Suppress an identical
1261
+ // content ack seen within USER_INPUT_ACK_DEDUP_WINDOW_MS; a later resend of
1262
+ // the same text (beyond the window) is a genuine new turn and still shows.
1263
+ const ackContentKey = shortHash(`${this.instanceId}:${content}`, 24);
1264
+ const lastAckAt = this.recentUserInputAcks.get(ackContentKey);
1265
+ if (lastAckAt !== undefined && receivedAt - lastAckAt <= USER_INPUT_ACK_DEDUP_WINDOW_MS) {
1266
+ // Refresh the timestamp so a steady stream of redeliveries keeps
1267
+ // collapsing, and prune stale entries to bound the map size.
1268
+ this.recentUserInputAcks.set(ackContentKey, receivedAt);
1269
+ this.pruneRecentUserInputAcks(receivedAt);
1270
+ return;
1271
+ }
1272
+ this.recentUserInputAcks.set(ackContentKey, receivedAt);
1273
+ this.pruneRecentUserInputAcks(receivedAt);
1274
+
1275
+ this.lastAcknowledgedUserInputAt = receivedAt;
1276
+ // The runtimeMessages dedupKey stays per-call unique (includes receivedAt)
1277
+ // so a genuine resend of the same text after the window appends a fresh
1278
+ // bubble; redelivery within the window is already suppressed above.
1279
+ const dedupKey = `user_input_ack:${shortHash(`${this.instanceId}:${content}:${receivedAt}`, 24)}`;
1280
+ this.appendRuntimeMessage(buildChatMessage({
1281
+ role: 'user',
1282
+ senderName: 'User',
1283
+ kind: 'standard',
1284
+ content,
1285
+ receivedAt,
1286
+ timestamp: receivedAt,
1287
+ source: 'runtime_input_ack',
1288
+ meta: {
1289
+ runtimeInputAck: true,
1290
+ provider: this.type,
1291
+ workspace: this.workingDir,
1292
+ },
1293
+ } as ChatMessage), dedupKey);
1294
+ }
1295
+
1296
+ /** Drop user-input ack entries older than the dedup window so the map can't grow unbounded. */
1297
+ private pruneRecentUserInputAcks(now: number): void {
1298
+ if (this.recentUserInputAcks.size <= 1) return;
1299
+ for (const [key, at] of this.recentUserInputAcks) {
1300
+ if (now - at > USER_INPUT_ACK_DEDUP_WINDOW_MS) this.recentUserInputAcks.delete(key);
1301
+ }
1302
+ }
1303
+
698
1304
  dispose(): void {
699
1305
  this.adapter.shutdown();
700
1306
  this.monitor.reset();
1307
+ // Cancel any armed auto-approve timers so a pending settle re-check
1308
+ // can't fire resolveModal/detectStatusTransition against a dead adapter.
1309
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1310
+ if (this.autoApproveBusyTimer) { clearTimeout(this.autoApproveBusyTimer); this.autoApproveBusyTimer = null; }
701
1311
  this.appliedEffectKeys.clear();
702
1312
  try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
703
1313
  this.cachedSqliteDb = null;
@@ -706,6 +1316,7 @@ export class CliProviderInstance implements ProviderInstance {
706
1316
 
707
1317
  private completedDebounceTimer: NodeJS.Timeout | null = null;
708
1318
  private completedDebouncePending: CompletedDebouncePending | null = null;
1319
+ private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
709
1320
 
710
1321
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
711
1322
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -740,7 +1351,191 @@ export class CliProviderInstance implements ProviderInstance {
740
1351
  const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
741
1352
  const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
742
1353
  const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
743
- return role === 'assistant' && !!content;
1354
+ if (role !== 'assistant' || !content) return false;
1355
+ // Guard: if the last assistant message looks like an active approval/input prompt,
1356
+ // it is not a real completion — the session is still awaiting user input.
1357
+ if (looksLikeActiveApprovalPromptText(content)) return false;
1358
+ return true;
1359
+ }
1360
+
1361
+ private buildExternalTranscriptProbe(messages: unknown[], sourcePath?: string, sourceMtimeMs?: number): ExternalTranscriptProbe {
1362
+ const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1363
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1364
+ const readAt = Date.now();
1365
+ const mtimeMs = Number(sourceMtimeMs) || 0;
1366
+ return {
1367
+ readAt,
1368
+ msgCount: messages.length,
1369
+ lastRole: typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null,
1370
+ lastKind: typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null,
1371
+ contentLen: lastVisible ? flattenContent(lastVisible.content).trim().length : 0,
1372
+ sourcePath: typeof sourcePath === 'string' && sourcePath ? sourcePath : null,
1373
+ sourceMtimeMs: mtimeMs || null,
1374
+ mtimeAgeMs: mtimeMs ? Math.max(0, readAt - mtimeMs) : null,
1375
+ };
1376
+ }
1377
+
1378
+ private recordPendingTranscriptProbe(pending: CompletedDebouncePending): ExternalTranscriptProbe | null {
1379
+ const probe = this.lastExternalCompletionProbe;
1380
+ if (!probe) return null;
1381
+ const history = pending.transcriptProbeHistory || [];
1382
+ const last = history[history.length - 1];
1383
+ if (!last || last.readAt !== probe.readAt || last.msgCount !== probe.msgCount || last.lastRole !== probe.lastRole || last.contentLen !== probe.contentLen) {
1384
+ history.push(probe);
1385
+ pending.transcriptProbeHistory = history.slice(-5);
1386
+ }
1387
+ return probe;
1388
+ }
1389
+
1390
+ private readExternalCompletionMessages(): unknown[] | null {
1391
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1392
+ if (!adapterOwnsMessagesElsewhere) return null;
1393
+ if (!this.providerSessionId) return null;
1394
+ if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
1395
+
1396
+ if (this.lastExternalCompletionProbe?.sourcePath) {
1397
+ try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
1398
+ }
1399
+ const restoredHistory = readProviderChatHistory(this.type, {
1400
+ canonicalHistory: this.provider.nativeHistory,
1401
+ historySessionId: this.providerSessionId,
1402
+ workspace: this.workingDir,
1403
+ offset: 0,
1404
+ limit: Number.MAX_SAFE_INTEGER,
1405
+ historyBehavior: this.provider.historyBehavior,
1406
+ scripts: this.provider.scripts as any,
1407
+ sessionStartedAtMs: this.startedAt,
1408
+ forceRefresh: true,
1409
+ });
1410
+ if (restoredHistory.source !== 'provider-native') {
1411
+ this.lastExternalCompletionProbe = null;
1412
+ return null;
1413
+ }
1414
+ this.lastExternalCompletionProbe = this.buildExternalTranscriptProbe(
1415
+ restoredHistory.messages,
1416
+ restoredHistory.sourcePath,
1417
+ restoredHistory.sourceMtimeMs,
1418
+ );
1419
+ return restoredHistory.messages;
1420
+ }
1421
+
1422
+ private completionFinalAssistantEvidence(parsedMessages: unknown): CompletionFinalAssistantEvidence {
1423
+ if (this.completionHasFinalAssistantMessage(parsedMessages)) {
1424
+ return {
1425
+ present: true,
1426
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1427
+ source: 'parsed',
1428
+ };
1429
+ }
1430
+
1431
+ const externalMessages = this.readExternalCompletionMessages();
1432
+ if (externalMessages) {
1433
+ return {
1434
+ present: this.completionHasFinalAssistantMessage(externalMessages),
1435
+ messages: externalMessages,
1436
+ source: 'external-native',
1437
+ };
1438
+ }
1439
+
1440
+ return {
1441
+ present: false,
1442
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1443
+ source: 'unavailable',
1444
+ };
1445
+ }
1446
+
1447
+ private completionFinalSummary(parsedMessages: unknown, turnStartedAt?: number): string | undefined {
1448
+ // For native-source providers (claude-cli: chatMessagesOwnedExternally), the PTY
1449
+ // screen parse is NOT the source of truth for the final summary — the terminal
1450
+ // wraps/scrolls/clips text, so a screen-parsed assistant message is often a partial
1451
+ // prefix (e.g. 76 chars of a 112-char turn). The append-only native transcript holds
1452
+ // the complete turn. Prefer it whenever it yields a longer/complete summary; fall back
1453
+ // to the parsed screen only when the transcript is unavailable. This is the real cause
1454
+ // of the truncated finalSummary — independent of cloud vs standalone (it surfaces on
1455
+ // any short, fast-completing task where screen parse wins the race).
1456
+ //
1457
+ // NOTIF Defect-B: `turnStartedAt` (the producing turn's start, snapshotted on the
1458
+ // completedDebouncePending record) turn-scopes the NATIVE transcript read. The native
1459
+ // transcript holds the WHOLE session filtered only by session start, so a debounce that
1460
+ // flushes before the producing turn's final assistant bubble has landed would otherwise
1461
+ // return the PRIOR task's last bubble (event taskId=B but summary=A). Filtering to bubbles
1462
+ // at/after turnStartedAt yields '' in that race instead of the stale tail; the weak/empty
1463
+ // summary is later upgraded by the mesh reconcile loop once the real bubble is written.
1464
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1465
+ const parsedSummary = extractFinalSummaryFromMessages(
1466
+ (this.completionHasFinalAssistantMessage(parsedMessages)
1467
+ ? (Array.isArray(parsedMessages) ? parsedMessages : [])
1468
+ : []) as any,
1469
+ );
1470
+ if (adapterOwnsMessagesElsewhere) {
1471
+ const externalMessages = this.readExternalCompletionMessages();
1472
+ // Turn-scope the external transcript: never return a bubble produced before this
1473
+ // turn started. With no boundary known (turnStartedAt falsy) behaviour is unchanged.
1474
+ const externalSummary = externalMessages
1475
+ ? extractFinalSummaryFromMessagesAfter(externalMessages as any, turnStartedAt)
1476
+ : '';
1477
+ // The transcript is authoritative for native-source providers. Use it unless it is
1478
+ // empty (not yet written, or no in-turn bubble) — only then fall back to the screen
1479
+ // parse, which reflects the LIVE screen (this turn's output), not the stale tail.
1480
+ if (externalSummary) return externalSummary;
1481
+ return parsedSummary || undefined;
1482
+ }
1483
+ return parsedSummary || undefined;
1484
+ }
1485
+
1486
+ private buildCompletedFinalizationDiagnostic(args: {
1487
+ blockReason: string;
1488
+ latestStatus?: any;
1489
+ latestVisibleStatus: string;
1490
+ waitedMs: number;
1491
+ pending: CompletedDebouncePending;
1492
+ emittedAfterFinalizationTimeout: boolean;
1493
+ }): Record<string, unknown> {
1494
+ let parsed: any = null;
1495
+ let parseError: string | undefined;
1496
+ try {
1497
+ parsed = this.adapter.getScriptParsedStatus();
1498
+ } catch (error: any) {
1499
+ parseError = error?.message || String(error);
1500
+ }
1501
+
1502
+ const evidence = this.completionFinalAssistantEvidence(parsed?.messages);
1503
+ if (evidence.source === 'external-native') {
1504
+ this.recordPendingTranscriptProbe(args.pending);
1505
+ }
1506
+ const visibleMessages = (Array.isArray(evidence.messages) ? evidence.messages : [])
1507
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1508
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1509
+ const lastVisibleRole = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null;
1510
+ const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
1511
+ const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
1512
+
1513
+ return {
1514
+ providerType: this.type,
1515
+ sessionId: this.instanceId,
1516
+ providerSessionId: this.providerSessionId || null,
1517
+ workspace: this.workingDir,
1518
+ blockReason: args.blockReason,
1519
+ emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
1520
+ waitedMs: args.waitedMs,
1521
+ maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
1522
+ adapterStatus: typeof args.latestStatus?.status === 'string' ? args.latestStatus.status : null,
1523
+ latestVisibleStatus: args.latestVisibleStatus,
1524
+ parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
1525
+ parseError: parseError || undefined,
1526
+ finalAssistantPresent: evidence.present,
1527
+ finalAssistantEvidenceSource: evidence.source,
1528
+ visibleMessageCount: visibleMessages.length,
1529
+ lastVisibleRole,
1530
+ lastVisibleKind,
1531
+ lastVisibleContentLength,
1532
+ pendingStartedAt: this.generatingStartedAt || null,
1533
+ pendingFirstObservedAt: args.pending.firstObservedAt,
1534
+ pendingTimestamp: args.pending.timestamp,
1535
+ pendingDurationSec: args.pending.duration,
1536
+ previousBlockReason: args.pending.loggedBlockReason || null,
1537
+ transcriptProbeHistory: args.pending.transcriptProbeHistory || [],
1538
+ };
744
1539
  }
745
1540
 
746
1541
  private hasAdapterPendingResponse(): boolean {
@@ -765,41 +1560,206 @@ export class CliProviderInstance implements ProviderInstance {
765
1560
  if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
766
1561
  if (adapterRawStatus !== 'idle') return false;
767
1562
  if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
768
- return !this.hasAdapterPendingResponse();
1563
+ if (this.hasAdapterPendingResponse()) return false;
1564
+ // Do not suppress when the adapter's raw response buffer is still non-empty.
1565
+ // This catches the case where isWaitingForResponse has already flipped to false
1566
+ // (so getPartialResponse() returns '') but the provider's native parser still
1567
+ // reports generating because it's parsing buffered content. Suppressing the
1568
+ // finalization block here would emit a false completion event while the provider
1569
+ // session is still actively processing its response stream.
1570
+ const adapterAny = this.adapter as any;
1571
+ if (typeof adapterAny?.responseBuffer === 'string' && adapterAny.responseBuffer.trim()) return false;
1572
+ return true;
769
1573
  }
770
1574
 
771
- private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
- if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
1575
+ private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1576
+ if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
773
1577
 
774
1578
  const adapterAny = this.adapter as any;
775
- if (adapterAny?.isWaitingForResponse === true) return 'adapter_waiting_for_response';
776
- if (adapterAny?.currentTurnScope) return 'adapter_turn_scope_active';
1579
+ const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1580
+ if (!approvalResolvedIdle) {
1581
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
1582
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
1583
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
1584
+ }
777
1585
 
778
1586
  const partial = typeof this.adapter.getPartialResponse === 'function'
779
1587
  ? this.adapter.getPartialResponse()
780
1588
  : '';
781
- if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
1589
+ if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
782
1590
 
783
1591
  let parsed: any;
784
1592
  try {
785
1593
  parsed = this.adapter.getScriptParsedStatus();
786
1594
  } catch (error: any) {
787
- return `parse_error:${error?.message || String(error)}`;
1595
+ return { reason: `parse_error:${error?.message || String(error)}` };
788
1596
  }
789
1597
 
790
1598
  const parsedStatus = typeof parsed?.status === 'string' ? parsed.status : 'unknown';
791
- if (parsedStatus !== 'idle') return `parsed_status:${parsedStatus}`;
792
- if (parsed?.activeModal || parsed?.modal) return 'parsed_modal_active';
793
- if (!this.completionHasFinalAssistantMessage(parsed?.messages)) return 'missing_final_assistant';
1599
+ if (parsedStatus !== 'idle') {
1600
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
1601
+ if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
1602
+ return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
1603
+ }
1604
+ if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
1605
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1606
+ const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
1607
+ const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1608
+ LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1609
+ if (!finalAssistantEvidence.present) {
1610
+ if (adapterOwnsMessagesElsewhere) {
1611
+ if (finalAssistantEvidence.source === 'external-native') {
1612
+ const probe = this.recordPendingTranscriptProbe(pending);
1613
+ if (probe && !pending.loggedTranscriptProbe) {
1614
+ LOG.info('CLI', `[${this.type}] external transcript probe: msgCount=${probe.msgCount} lastRole=${probe.lastRole || 'none'} lastKind=${probe.lastKind || 'none'} contentLen=${probe.contentLen} sourceMtime=${probe.sourceMtimeMs ?? 'unknown'} mtimeAge=${probe.mtimeAgeMs ?? 'unknown'}ms`);
1615
+ pending.loggedTranscriptProbe = true;
1616
+ }
1617
+ LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
1618
+ if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
1619
+ return null;
1620
+ }
1621
+ if (this.type === 'antigravity-cli') {
1622
+ return null;
1623
+ }
1624
+ // (SETTLE-VALLEY) The inter-approval idle valley: a native-history mesh worker
1625
+ // that resolved an approval and fell briefly idle (waiting_approval→idle) BEFORE
1626
+ // the next approval turn resumes. The live valley (~3s) is mostly covered by the
1627
+ // 4000ms NATIVE_HISTORY_MESH_IDLE_SETTLE_MS settle window, but a longer valley can
1628
+ // still let the flush run while the transcript's final assistant turn is not yet
1629
+ // written (source still the screen parse → finalAssistantPresent=false,
1630
+ // workerResult.source='default'). CANON-C would emit immediately here, freezing a
1631
+ // truncated preamble summary as evidenceLevel=insufficient. Instead HOLD: this
1632
+ // waiting_approval hold complements the settle window. Retry until the transcript finalizes
1633
+ // (block clears → genuine emit) or the worker resumes (resume guard cancels),
1634
+ // bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS. Scoped to the approval-resolved
1635
+ // idle so a genuinely-finished background-child turn keeps the CANON-C immediate
1636
+ // emit (its transcript trails by a write, not by a whole resume).
1637
+ if (allowMissingAssistantTimeout && pending.previousStatus === 'waiting_approval') {
1638
+ return { reason: 'missing_final_assistant', terminal: false, holdForTranscript: true };
1639
+ }
1640
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1641
+ }
1642
+ if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
1643
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1644
+ }
1645
+ } else {
1646
+ LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
1647
+ return {
1648
+ reason: 'missing_final_assistant',
1649
+ terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
1650
+ allowTimeout: allowMissingAssistantTimeout,
1651
+ };
1652
+ }
1653
+ }
1654
+
1655
+ // (FALSEIDLE-a) Structural approval-resolution gate. Runs BEFORE the brittle
1656
+ // screen-text heuristic below so it also catches modals whose text does not match
1657
+ // looksLikeActiveApprovalPromptText (e.g. claude-cli's cd / "untrusted hooks" prompt).
1658
+ const approvalResolutionBlock = this.approvalResolutionFinalizationBlock(pending);
1659
+ if (approvalResolutionBlock) return approvalResolutionBlock;
1660
+
1661
+ // Guard: if the screen still shows an approval/choice prompt as the last visible text,
1662
+ // the turn is not complete even if the parsed status says idle and there is an assistant
1663
+ // message. This catches the case where waiting_approval→idle transitions occur before
1664
+ // the modal has been resolved (e.g. the PTY rendered the prompt but no button press fired).
1665
+ try {
1666
+ const screenText = typeof (this.adapter as any).getScreenText === 'function'
1667
+ ? String((this.adapter as any).getScreenText() || '')
1668
+ : '';
1669
+ if (screenText) {
1670
+ const tailLines = screenText.split(/\r?\n/).slice(-16).join('\n');
1671
+ if (looksLikeActiveApprovalPromptText(tailLines)) {
1672
+ return { reason: 'screen_shows_approval_prompt', terminal: approvalResolvedIdle };
1673
+ }
1674
+ }
1675
+ } catch { /* defensive: screen text read is best-effort */ }
794
1676
 
795
1677
  return null;
796
1678
  }
797
1679
 
1680
+ // (FALSEIDLE-a) Positive, structural proof that the latest approval entry was resolved
1681
+ // through ADHDev. resolveModal() — driven by auto-approve, dashboard/mesh_approve, and
1682
+ // dev-cli-debug alike — advances the engine's lastResolvedEntrySeq to the current
1683
+ // approvalEntrySeq. So `lastResolvedEntrySeq >= approvalEntrySeq` (with a real entry,
1684
+ // approvalEntrySeq > 0) means the modal we last saw was actually answered. Absence of this
1685
+ // evidence after a waiting_approval→idle transition means the idle is suspect: the spec's
1686
+ // text-based approval→idle rule false-tripped while the modal is still unresolved.
1687
+ // Fails OPEN (returns true) when the seq fields are unavailable, so the gate can never wedge
1688
+ // a session on a provider/adapter that does not surface the counters.
1689
+ private hasApprovalResolutionEvidence(): boolean {
1690
+ try {
1691
+ const status = this.adapter.getStatus({ allowParse: false }) as any;
1692
+ const entrySeq = typeof status?.approvalEntrySeq === 'number' ? status.approvalEntrySeq : 0;
1693
+ if (entrySeq <= 0) return true;
1694
+ const resolvedSeq = typeof status?.lastResolvedEntrySeq === 'number' ? status.lastResolvedEntrySeq : undefined;
1695
+ if (resolvedSeq === undefined) return true;
1696
+ return resolvedSeq >= entrySeq;
1697
+ } catch {
1698
+ return true;
1699
+ }
1700
+ }
1701
+
1702
+ // (FALSEIDLE-a) Hold a completion that is the anomalous DIRECT waiting_approval→idle
1703
+ // transition with no positive resolution evidence. A genuinely resolved approval routes
1704
+ // through resolveModal → setStatus('generating'), so its completion's previousStatus is
1705
+ // 'generating' (not 'waiting_approval') and this gate never fires for it. Scoped to
1706
+ // delegated mesh/coordinator sessions — whose only modal-resolution path is auto-approve /
1707
+ // mesh_approve (both advance lastResolvedEntrySeq) — so an interactive local session, where
1708
+ // a human may answer the PTY prompt directly and leave no resolveModal record, is untouched.
1709
+ // Non-terminal: the hold is bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS (30s), giving a
1710
+ // settling auto-approve time to fire and advance the seq, and guaranteeing no permanent wedge
1711
+ // if resolution ever happens via a path that does not record evidence.
1712
+ private approvalResolutionFinalizationBlock(pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1713
+ if (pending.previousStatus !== 'waiting_approval') return null;
1714
+ const meshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1715
+ if (!meshContext) return null;
1716
+ if (this.hasApprovalResolutionEvidence()) return null;
1717
+ return { reason: 'approval_resolution_unconfirmed', terminal: false };
1718
+ }
1719
+
798
1720
  private scheduleCompletedDebounceFlush(delayMs: number): void {
799
1721
  if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
800
1722
  this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
801
1723
  }
802
1724
 
1725
+ // EVTTRACE (observation-only): is this a mesh worker session whose completion
1726
+ // events must route to a coordinator? Used purely to gate trace logging so a
1727
+ // non-mesh CLI session's completions don't add EvtTrace noise. No decision logic.
1728
+ private isMeshWorkerSession(): boolean {
1729
+ return !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId
1730
+ || this.settings.meshNodeId || this.settings.launchedByCoordinator);
1731
+ }
1732
+
1733
+ /**
1734
+ * ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
1735
+ * Prefers the per-turn binding (engine.currentTurnTaskId, set when the turn was
1736
+ * submitted and surviving until the next turn starts) over the last-write-wins
1737
+ * session scalar (settings.meshActiveTaskId). The scalar is retained only as a
1738
+ * backward-compat alias for the "current/last assignment" and is the source of the
1739
+ * NOTIF-MISDELIVER / TASK-MSG-MISROUTE race: a second task attaching before this
1740
+ * turn completes overwrites it. Returns undefined for a non-task ad-hoc turn.
1741
+ */
1742
+ private completingTurnTaskId(): string | undefined {
1743
+ const turnTaskId = this.adapter?.currentTurnTaskId;
1744
+ if (typeof turnTaskId === 'string' && turnTaskId.trim()) return turnTaskId;
1745
+ const scalar = this.settings.meshActiveTaskId;
1746
+ return typeof scalar === 'string' && scalar.trim() ? scalar : undefined;
1747
+ }
1748
+
1749
+ // EVTTRACE correlation context for this session's completion lifecycle. taskId is
1750
+ // the primary grep anchor; instanceId is the session fallback.
1751
+ private meshTraceCtx(event = 'agent:generating_completed'): Record<string, unknown> {
1752
+ return {
1753
+ // ARCH-REFACTOR R1: trace the per-turn taskId (falling back to the scalar) so
1754
+ // EvtTrace anchors on the same id the completion event actually carries.
1755
+ taskId: this.completingTurnTaskId(),
1756
+ sessionId: this.instanceId,
1757
+ nodeId: this.settings.meshNodeId,
1758
+ meshId: this.settings.meshNodeFor,
1759
+ event,
1760
+ };
1761
+ }
1762
+
803
1763
  private flushCompletedDebounceIfFinalized(): void {
804
1764
  const pending = this.completedDebouncePending;
805
1765
  if (!pending) {
@@ -809,7 +1769,8 @@ export class CliProviderInstance implements ProviderInstance {
809
1769
 
810
1770
  const latestStatus = this.adapter.getStatus({ allowParse: false });
811
1771
  const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
812
- const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
1772
+ const latestVisibleStatus = latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status;
1773
+ LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
813
1774
  if (latestVisibleStatus !== 'idle') {
814
1775
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
815
1776
  this.completedDebouncePending = null;
@@ -817,84 +1778,514 @@ export class CliProviderInstance implements ProviderInstance {
817
1778
  return;
818
1779
  }
819
1780
 
820
- const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
- if (blockReason) {
1781
+ const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
1782
+ if (block) {
1783
+ const blockReason = block.reason;
822
1784
  const waitedMs = Date.now() - pending.firstObservedAt;
823
- if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
1785
+ // CANON-C (completion-gate decouple): a block carrying `allowTimeout` is the
1786
+ // transcript-evidence gate — the worker FSM has ALREADY reached idle and the only
1787
+ // thing missing is the append-only transcript's final assistant turn (a native-source
1788
+ // race: claude-cli owns its history externally and the file write trails the idle
1789
+ // transition). `allowTimeout` is set ONLY on the missing_final_assistant block, and
1790
+ // ONLY for mesh worker sessions (meshNodeFor / meshActiveTaskId / launchedByCoordinator).
1791
+ // The coordinator's sole path to learn this session is idle is agent:generating_completed,
1792
+ // so holding it up to COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) leaves the coordinator
1793
+ // false-generating while the worker is done. Decouple the idle NOTIFICATION from the
1794
+ // transcript evidence: emit the completion immediately, marked weak
1795
+ // (completionDiagnostic.blockReason=missing_final_assistant, finalAssistantPresent=false).
1796
+ // The finalSummary is enriched on a SEPARATE path — the mesh reconcile loop reads the
1797
+ // transcript once written and re-emits a GENUINE completion (CANON-B weak→genuine
1798
+ // upgrade; buildPendingEventFingerprint keeps weak and genuine distinct so the enriched
1799
+ // one still surfaces, and isFalseIdleCompletion keeps the direct dispatch active until
1800
+ // then). All OTHER blocks (genuinely-busy adapter/partial/parsed states, transient
1801
+ // parse_error) keep the existing terminal-hold / 30s-retry behavior unchanged.
1802
+ //
1803
+ // (SETTLE-VALLEY) Exception: a `holdForTranscript` block is the inter-approval idle
1804
+ // valley of a native-history mesh worker (waiting_approval→idle that will resume into
1805
+ // the next approval). It deliberately does NOT carry allowTimeout, so it falls into the
1806
+ // hold-and-retry path below (terminal:false) rather than the CANON-C immediate emit —
1807
+ // the retry loop re-runs the resume guard each cycle, so when the worker resumes the
1808
+ // pending completion is cancelled, and when the transcript's final assistant arrives the
1809
+ // block clears for a GENUINE emit. This blocks the truncated weak (insufficient) summary
1810
+ // from ever being emitted during the valley, without depending on the valley's length.
1811
+ const isTranscriptEvidenceGate = block.allowTimeout === true;
1812
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} allowTimeout=${isTranscriptEvidenceGate} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1813
+ if (!isTranscriptEvidenceGate && (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS)) {
824
1814
  if (pending.loggedBlockReason !== blockReason) {
825
1815
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
1816
+ // EVTTRACE: completion held by the finalization gate (CANON-C). Observation
1817
+ // only — does not change the hold decision above.
1818
+ if (this.isMeshWorkerSession()) {
1819
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
1820
+ }
826
1821
  pending.loggedBlockReason = blockReason;
827
1822
  }
828
1823
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
1824
  return;
830
1825
  }
831
- LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1826
+ const emittedAfterFinalizationTimeout = waitedMs >= COMPLETED_FINALIZATION_MAX_WAIT_MS;
1827
+ const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
1828
+ blockReason,
1829
+ latestStatus,
1830
+ latestVisibleStatus,
1831
+ waitedMs,
1832
+ pending,
1833
+ emittedAfterFinalizationTimeout,
1834
+ });
1835
+ // Surface the CANON-C immediate-emit path distinctly so a delegated worker's idle
1836
+ // notification (transcript still pending) is not mistaken for a 30s-timeout fallback.
1837
+ (completionDiagnostic as Record<string, unknown>).decoupledImmediateEmit = isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout;
1838
+ LOG.warn('CLI', `[${this.type}] emitting completed event (${isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'CANON-C decoupled-immediate, transcript pending' : `after ${waitedMs}ms`}) without finalized assistant turn (${blockReason})`);
1839
+ // EVTTRACE: completion fired (forced past the finalization timeout / CANON-C decoupled-immediate).
1840
+ if (this.isMeshWorkerSession()) {
1841
+ traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
1842
+ }
1843
+ this.pushEvent({
1844
+ event: 'agent:generating_completed',
1845
+ chatTitle: pending.chatTitle,
1846
+ duration: pending.duration,
1847
+ timestamp: pending.timestamp,
1848
+ // ARCH-REFACTOR R1: attribute to the turn captured at idle-transition.
1849
+ ...(pending.taskId ? { taskId: pending.taskId } : {}),
1850
+ // When finalization is forced past the timeout on a `parsed_status:` block
1851
+ // (the parser never confirmed a final assistant turn) we previously rode an
1852
+ // empty `finalSummary` unconditionally. That empty value propagates to the
1853
+ // mesh coordinator's mirror preview (meshSessionLastMessagePreview), leaving a
1854
+ // delegated session's inbox preview blank — or, for a LOCAL worktree session,
1855
+ // stuck on the dispatched user task. If the parser DID surface assistant text,
1856
+ // prefer it; only fall back to '' when no assistant summary can be derived.
1857
+ finalSummary: blockReason.startsWith('parsed_status:')
1858
+ ? (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) ?? '')
1859
+ : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
1860
+ completionDiagnostic,
1861
+ });
832
1862
  this.completedDebouncePending = null;
833
1863
  this.completedDebounceTimer = null;
834
1864
  this.generatingStartedAt = 0;
1865
+ this.lastApprovalEventFingerprint = '';
835
1866
  return;
836
1867
  }
837
1868
 
838
1869
  LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
1870
+ // EVTTRACE: completion fired (transcript finalized cleanly).
1871
+ if (this.isMeshWorkerSession()) {
1872
+ traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
1873
+ }
839
1874
  this.pushEvent({
840
1875
  event: 'agent:generating_completed',
841
1876
  chatTitle: pending.chatTitle,
842
1877
  duration: pending.duration,
843
1878
  timestamp: pending.timestamp,
844
- finalSummary: extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages),
1879
+ // ARCH-REFACTOR R1: attribute to the turn captured at idle-transition.
1880
+ ...(pending.taskId ? { taskId: pending.taskId } : {}),
1881
+ finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
845
1882
  });
846
1883
  this.completedDebouncePending = null;
847
1884
  this.completedDebounceTimer = null;
848
1885
  this.generatingStartedAt = 0;
1886
+ this.lastApprovalEventFingerprint = '';
849
1887
  }
850
1888
 
851
1889
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
1890
+ // Manual-attendance suppression (provider-common): when a human is
1891
+ // actively driving this session from the dashboard, hold auto-approve so
1892
+ // the modal stays visible and they can pick a button / use the controlbar
1893
+ // themselves. Return false (NOT auto-approving) so getState keeps the
1894
+ // modal surfaced. Clear any in-progress settle gate — a genuine fire
1895
+ // after the window lapses must re-settle from scratch — and arm a
1896
+ // re-check for the lapse moment, because the PTY may have gone silent and
1897
+ // would otherwise never re-drive this decision. Background mesh workers
1898
+ // are never attended, so their delegated auto-approve is untouched.
1899
+ if (adapterStatus?.status === 'waiting_approval'
1900
+ && this.shouldAutoApprove()
1901
+ && this.manualAttendance.isAttended(now)) {
1902
+ this.lastAutoApprovalSignature = '';
1903
+ this.pendingAutoApprovalSignature = '';
1904
+ this.pendingAutoApprovalSince = 0;
1905
+ this.autoApproveInactiveSince = 0;
1906
+ // Manual attendance takes over — the modal stays surfaced (maybeAutoApproveStatus
1907
+ // returns false), so end the mask episode.
1908
+ this.autoApproveMaskSince = 0;
1909
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1910
+ this.autoApproveSettleTimer = setTimeout(() => {
1911
+ this.autoApproveSettleTimer = null;
1912
+ this.recheckAutoApproveSettled();
1913
+ }, this.manualAttendance.remainingMs(now) + 20);
1914
+ return false;
1915
+ }
852
1916
  const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
853
1917
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
854
1918
  // times while the PTY absorbs the approval key. Without this flag, repeated
855
1919
  // snapshots would write stray keys into the input once the modal dismisses.
856
- if (autoApproveActive && !this.autoApproveBusy) {
857
- this.autoApproveBusy = true;
858
- if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
859
- this.autoApproveBusyTimer = setTimeout(() => {
860
- this.autoApproveBusy = false;
861
- this.autoApproveBusyTimer = null;
862
- }, 2000);
863
- const modal = adapterStatus.activeModal;
864
- const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(modal?.buttons, this.provider);
865
- this.recordAutoApproval(modal?.message, buttonLabel, now);
866
- setTimeout(() => {
867
- this.adapter.resolveModal(buttonIndex);
868
- }, 0);
1920
+ // However, Claude Code can present a second approval immediately after the
1921
+ // first. Resolve a changed modal signature even while the previous write is
1922
+ // still inside the short busy window.
1923
+ if (!autoApproveActive) {
1924
+ this.lastAutoApprovalSignature = '';
1925
+ // Hysteresis: if a settle gate is mid-progress, a momentary
1926
+ // status!=waiting_approval blip (a generating flip while the same
1927
+ // modal's button block is still on screen) must NOT wipe the settle
1928
+ // clock otherwise the modal→generating→modal flap restarts the
1929
+ // 600ms window every time and auto-approve never fires. Keep the
1930
+ // gate warm for AUTO_APPROVE_GATE_HYSTERESIS_MS; re-arm a timer so
1931
+ // that if the modal does NOT come back the gate is cleared on the
1932
+ // re-check (a genuine resolution → idle frees the gate normally).
1933
+ if (this.pendingAutoApprovalSince) {
1934
+ if (!this.autoApproveInactiveSince) this.autoApproveInactiveSince = now;
1935
+ const goneForMs = now - this.autoApproveInactiveSince;
1936
+ if (goneForMs < CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS) {
1937
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1938
+ this.autoApproveSettleTimer = setTimeout(() => {
1939
+ this.autoApproveSettleTimer = null;
1940
+ this.recheckAutoApproveSettled();
1941
+ }, CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS - goneForMs + 20);
1942
+ return autoApproveActive;
1943
+ }
1944
+ }
1945
+ // Clear the settle gate so the next approval starts its own quiet
1946
+ // window from scratch (a stale timestamp would let it fire instantly).
1947
+ this.pendingAutoApprovalSignature = '';
1948
+ this.pendingAutoApprovalSince = 0;
1949
+ this.autoApproveInactiveSince = 0;
1950
+ // Modal has genuinely been gone past the hysteresis window → the episode ended;
1951
+ // end the mask episode too (a later approval starts a fresh stall clock).
1952
+ this.autoApproveMaskSince = 0;
1953
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1954
+ return autoApproveActive;
1955
+ }
1956
+ // Active approval observed — reset the inactivity tracker so a later
1957
+ // blip starts its hysteresis window fresh.
1958
+ this.autoApproveInactiveSince = 0;
1959
+ // STATUS-MISMATCH: start (or keep) the mask-stall clock for this episode. Set ONLY
1960
+ // when zero so it survives modal-signature changes and hysteresis blips — it measures
1961
+ // the true age of the unresolved auto-approve, not the per-signature settle window.
1962
+ if (!this.autoApproveMaskSince) this.autoApproveMaskSince = now;
1963
+ const modal = adapterStatus.activeModal;
1964
+ // (fix) Do not auto-approve when no concrete modal/buttons are present.
1965
+ // Claude TUI flaps between paints; without this guard adapterStatus
1966
+ // could report status=waiting_approval with activeModal=null (or with
1967
+ // an empty buttons array briefly) and we'd still call
1968
+ // resolveModal(-1) — which used to type "1" into the prompt
1969
+ // repeatedly. Skip until a real modal is captured.
1970
+ const buttons = Array.isArray(modal?.buttons)
1971
+ ? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
1972
+ : [];
1973
+ if (!modal || buttons.length === 0) {
1974
+ return autoApproveActive;
1975
+ }
1976
+ // Picker/confirm exclusion (provider-common). A /model or /mode picker is
1977
+ // surfaced with status=waiting_approval so the dashboard shows it, but it
1978
+ // has no "correct" answer to auto-pick — blindly selecting the first
1979
+ // option silently switches the model (the "always Opus, before I even
1980
+ // choose" bug). Two independent gates, BOTH must pass to fire:
1981
+ //
1982
+ // (1) modal_kind — the spec/FSM tells us this is an 'approval' modal,
1983
+ // not a 'picker'/'confirm'. A modal whose kind is unknown (legacy
1984
+ // adapter, or a spec that predates modal_kind) reads as 'approval'
1985
+ // so genuine approvals keep auto-approving; only an explicit
1986
+ // 'picker'/'confirm' is excluded here.
1987
+ // (2) structural anchor — a real approval offers an affirmative AND a
1988
+ // decline option (pickApprovalButton finds a positive that isn't a
1989
+ // decline, and hasNegativeApprovalOption confirms a No/Cancel/Deny
1990
+ // is present). A model picker ("1. Default 2. Opus 3. Sonnet")
1991
+ // has no decline, so even an un-migrated picker is caught here.
1992
+ //
1993
+ // Mirrors the SDK v1 detect-status approval heuristic (detect-status.ts).
1994
+ const modalKind = typeof modal?.kind === 'string' ? modal.kind : 'approval';
1995
+ if (modalKind !== 'approval') {
1996
+ // Picker/confirm — leave it for the user; keep the modal surfaced.
1997
+ return autoApproveActive;
869
1998
  }
1999
+ const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
2000
+ if (buttonIndex < 0 || !hasNegativeApprovalOption(buttons)) {
2001
+ // No affirmative matched, or no decline option present (→ not a real
2002
+ // consent prompt, e.g. a picker that slipped past the kind gate).
2003
+ // Surface the modal so the user decides; never pick blindly.
2004
+ return autoApproveActive;
2005
+ }
2006
+ // Modal *identity* signature — the question/button set only, NO volatile
2007
+ // counters. This is what the settle gate tracks: the FSM bumps
2008
+ // approvalEntrySeq on every fresh waiting_approval entry, and a
2009
+ // modal→generating→modal flap (the question line scrolled out of the
2010
+ // captured frame while the button block stays) re-enters and bumps it
2011
+ // again. Folding that seq into the settle signature made the 600ms
2012
+ // settle clock restart on every flap, so the modal never stayed stable
2013
+ // long enough to fire — the gate was never satisfied. Identity excludes
2014
+ // the seq so button/seq flap of the SAME modal keeps one settle clock.
2015
+ const modalSignature = [
2016
+ typeof modal?.message === 'string' ? modal.message.trim() : '',
2017
+ buttons.join('|'),
2018
+ buttonIndex,
2019
+ ].join('::');
2020
+ // Busy-window re-entry guard still needs the seq: two DISTINCT
2021
+ // back-to-back approvals can carry identical message/buttons (common
2022
+ // with claude-cli). Without the seq their busy signatures collide and
2023
+ // the 5s busy-window guard below would swallow the second auto-approve,
2024
+ // leaving it stuck. The seq is bumped by the FSM on every fresh
2025
+ // waiting_approval entry, so a new approval always yields a new busy
2026
+ // signature and fires through.
2027
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
2028
+ ? adapterStatus.approvalEntrySeq
2029
+ : 0;
2030
+ const busySignature = `${approvalEntrySeq}::${modalSignature}`;
2031
+ // Already fired for this exact modal entry and still inside the busy
2032
+ // window — nothing to do (re-entry guard for repeated snapshots of one
2033
+ // modal).
2034
+ if (this.autoApproveBusy && busySignature === this.lastAutoApprovalSignature) {
2035
+ return autoApproveActive;
2036
+ }
2037
+
2038
+ // Settle gate: only fire once this modal identity has been stable for
2039
+ // AUTO_APPROVE_SETTLE_MS. A still-streaming prompt mutates its
2040
+ // message/buttons each frame → new identity → clock restarts, so we
2041
+ // never approve a half-rendered prompt (the "resolves too fast" bug).
2042
+ if (modalSignature !== this.pendingAutoApprovalSignature) {
2043
+ this.pendingAutoApprovalSignature = modalSignature;
2044
+ this.pendingAutoApprovalSince = now;
2045
+ }
2046
+ const settledForMs = now - this.pendingAutoApprovalSince;
2047
+ if (settledForMs < CliProviderInstance.AUTO_APPROVE_SETTLE_MS) {
2048
+ // Not yet settled. Arm a timer to re-check after the remaining quiet
2049
+ // window — the PTY may go silent once the prompt finishes painting,
2050
+ // so there is no guaranteed status-change frame to re-drive us.
2051
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
2052
+ this.autoApproveSettleTimer = setTimeout(() => {
2053
+ this.autoApproveSettleTimer = null;
2054
+ this.recheckAutoApproveSettled();
2055
+ }, CliProviderInstance.AUTO_APPROVE_SETTLE_MS - settledForMs + 20);
2056
+ return autoApproveActive;
2057
+ }
2058
+
2059
+ // Settled — fire the approve key.
2060
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
2061
+ this.autoApproveBusy = true;
2062
+ this.lastAutoApprovalSignature = busySignature;
2063
+ this.pendingAutoApprovalSignature = '';
2064
+ this.pendingAutoApprovalSince = 0;
2065
+ this.autoApproveInactiveSince = 0;
2066
+ // Fired (resolveModal in flight) — the episode resolved; end the mask-stall clock.
2067
+ this.autoApproveMaskSince = 0;
2068
+ if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
2069
+ this.autoApproveBusyTimer = setTimeout(() => {
2070
+ this.autoApproveBusy = false;
2071
+ this.autoApproveBusyTimer = null;
2072
+ this.lastAutoApprovalSignature = '';
2073
+ }, 5000);
2074
+ this.recordAutoApproval(modal?.message, buttonLabel, now);
2075
+ setTimeout(() => {
2076
+ this.adapter.resolveModal(buttonIndex);
2077
+ }, 0);
870
2078
  return autoApproveActive;
871
2079
  }
872
2080
 
2081
+ /**
2082
+ * Re-drive the auto-approve check after the settle quiet window elapses.
2083
+ * The PTY may have gone silent once the approval prompt finished painting,
2084
+ * so no status-change frame is guaranteed to re-enter maybeAutoApproveStatus
2085
+ * — this timer-driven re-check picks up the now-settled modal and fires.
2086
+ * Deliberately lighter than detectStatusTransition(): it only re-evaluates
2087
+ * the approval decision; the next real PTY frame refreshes visible status.
2088
+ */
2089
+ private recheckAutoApproveSettled(): void {
2090
+ try {
2091
+ // APPROVAL Defect-C (auto-approve gap): re-probe with a LIVE parse, not the cached
2092
+ // engine snapshot. This timer is the ONLY re-drive when the PTY goes silent after the
2093
+ // approval prompt finishes painting (its whole reason to exist) — but with
2094
+ // allowParse:false it read only engine.activeModal, which the engine's per-frame settle
2095
+ // pass can leave null/stale when the modal arrived between writes. The re-check then saw
2096
+ // no modal and never fired, so the delegated worker's transient/quiet approval was missed
2097
+ // and the coordinator had to step in with a manual mesh_approve. allowParse:true makes
2098
+ // getStatus re-run runDetectStatus/runParseApproval on the current screen buffer (the same
2099
+ // live re-probe the coordinator's resolveAction path uses), recovering the modal so
2100
+ // auto-approve fires on its own. The half-rendered-frame guard (buttons.length===0) and
2101
+ // the settle gate in maybeAutoApproveStatus still protect against firing on a partial modal.
2102
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
2103
+ this.maybeAutoApproveStatus(adapterStatus, Date.now());
2104
+ } catch { /* adapter gone / transient — next frame retries */ }
2105
+ }
2106
+
2107
+ /**
2108
+ * Emit the queue-claim agent:ready event at most once per session. Both the
2109
+ * boot-time starting→idle one-shot and the fsmReadySeen re-arm call this; the
2110
+ * agentReadyEmitted guard ensures the second caller is a no-op so a worker is
2111
+ * never claimed twice and a queued task is never double-dispatched.
2112
+ */
2113
+ private emitAgentReadyOnce(chatTitle: string, now: number): void {
2114
+ if (this.agentReadyEmitted) return;
2115
+ this.agentReadyEmitted = true;
2116
+ this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
2117
+ }
2118
+
2119
+ /**
2120
+ * GENERATING-BOUNDARY synthetic emit for a first turn that started AND finished
2121
+ * inside the startup-grace window without the FSM ever observing a 'generating'
2122
+ * frame (an unobservably-fast turn). Synthesizes the agent:generating_started +
2123
+ * agent:generating_completed pair so the mesh coordinator learns the worker went
2124
+ * idle. Returns true iff it synthesized.
2125
+ *
2126
+ * Two callers share this defense line:
2127
+ * - 'startup_grace_fast_collapse' — the starting→idle transition (the FSM whose
2128
+ * spec lacks/hadn't-synced the starting→busy edge collapses starting→idle
2129
+ * directly, with no intervening 'generating' frame).
2130
+ * - 'startup_grace_idle_turn_collapse' — the idle→idle no-status-change poll
2131
+ * (the launch settle already drained starting→idle BEFORE the first turn, so
2132
+ * the turn runs+completes while status stays 'idle' the whole time and
2133
+ * detectStatusTransition never re-enters its change block — the live rc.403
2134
+ * Probe1 miss).
2135
+ *
2136
+ * False-positive safety (must NOT fire on a benign boot): currentTurnTaskId is
2137
+ * set ONLY by onTurnStarted (a real turn STARTED this boot) and persists past
2138
+ * completion, so it excludes a true idle boot (null) and a queued-pending first
2139
+ * turn that only runs after grace (onTurnStarted not yet called → null).
2140
+ * !hasAdapterPendingResponse() excludes a turn still mid-flight. generating
2141
+ * never armed (generatingStartedAt===0 && !generatingDebouncePending) means the
2142
+ * whole idle→busy→idle never happened for this turn. The once-per-turn guard
2143
+ * (fastCollapseSynthesizedTaskId) keeps the re-polled idle-stayed caller from
2144
+ * re-emitting every poll.
2145
+ */
2146
+ private maybeSynthesizeStartupGraceCollapse(
2147
+ chatTitle: string,
2148
+ now: number,
2149
+ reason: 'startup_grace_fast_collapse' | 'startup_grace_idle_turn_collapse',
2150
+ ): boolean {
2151
+ const startedTurnTaskId = typeof (this.adapter as any)?.currentTurnTaskId === 'string'
2152
+ && (this.adapter as any).currentTurnTaskId.trim()
2153
+ ? (this.adapter as any).currentTurnTaskId as string
2154
+ : undefined;
2155
+ const fastCollapsed = !!startedTurnTaskId
2156
+ && !this.hasAdapterPendingResponse()
2157
+ && !this.generatingStartedAt
2158
+ && !this.generatingDebouncePending;
2159
+ if (!fastCollapsed) return false;
2160
+ // Once-per-turn: the idle-stayed caller re-polls steadily while the session
2161
+ // sits idle; without this guard it would re-emit the pair every poll.
2162
+ if (this.fastCollapseSynthesizedTaskId === startedTurnTaskId) return false;
2163
+
2164
+ let fcFinalSummary: string | undefined;
2165
+ let fcEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
2166
+ try {
2167
+ const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
2168
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
2169
+ fcEvidenceSource = evidence.source;
2170
+ fcFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
2171
+ } catch { /* best-effort */ }
2172
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || fcEvidenceSource === 'external-native') && !fcFinalSummary;
2173
+ // Mirror the short-generating idle path's suppression: a provider that
2174
+ // requires a final assistant (or external-native history) with NO confirmed
2175
+ // summary and NO mesh context emits nothing — the session is idle with no
2176
+ // confirmed turn, matching startup-blip semantics. With mesh context we still
2177
+ // emit so the coordinator can apply its own timeout/retry logic. Left UNMARKED
2178
+ // so a later poll can retry once the transcript's final assistant lands.
2179
+ const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
2180
+ if (missingEvidence && !hasMeshContext) {
2181
+ LOG.info('CLI', `[${this.type}] ${reason} suppressed: missing final assistant evidence, no mesh context (source=${fcEvidenceSource})`);
2182
+ return false;
2183
+ }
2184
+ // Mark BEFORE emitting so a re-entrant poll cannot double-fire.
2185
+ this.fastCollapseSynthesizedTaskId = startedTurnTaskId;
2186
+ LOG.info('CLI', `[${this.type}] ${reason}: synthesizing started+completed (taskId=${startedTurnTaskId} source=${fcEvidenceSource} hadFinalSummary=${!!fcFinalSummary})`);
2187
+ // Retroactive started so the started→completed pair (and the chat bubble) is
2188
+ // well-formed; pushEvent stamps the per-turn taskId for CANON-B ack.
2189
+ this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now });
2190
+ if (this.isMeshWorkerSession()) {
2191
+ traceMeshEventStage('fired', this.meshTraceCtx(), `${reason} (source=${fcEvidenceSource})`);
2192
+ }
2193
+ this.pushEvent({
2194
+ event: 'agent:generating_completed',
2195
+ chatTitle,
2196
+ duration: 0,
2197
+ timestamp: now,
2198
+ finalSummary: fcFinalSummary,
2199
+ completionDiagnostic: {
2200
+ reason,
2201
+ finalAssistantEvidenceSource: fcEvidenceSource,
2202
+ ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
2203
+ },
2204
+ });
2205
+ return true;
2206
+ }
2207
+
873
2208
  private detectStatusTransition(): void {
874
2209
  const now = Date.now();
875
2210
  // Status-change handling is a hot path: PTY output can fire it many times
876
2211
  // during long-running CLI sessions. Keep this path on adapter-owned light
877
2212
  // state only; rich provider parsing is reserved for getState/read_chat.
878
2213
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
2214
+ const adapterProviderSessionId = normalizeProviderSessionId(
2215
+ this.provider,
2216
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
2217
+ );
2218
+ if (adapterProviderSessionId) {
2219
+ this.promoteProviderSessionId(adapterProviderSessionId);
2220
+ }
879
2221
  const parsedStatus = null;
880
2222
  const rawStatus = adapterStatus.status;
881
2223
  const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
882
- const newStatus = autoApproveActive ? 'generating' : rawStatus;
883
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
2224
+ // During the autoApproveBusy window (2s after firing approval key), the PTY
2225
+ // can briefly report 'idle' before the next generating phase starts. Treat that
2226
+ // transient idle as 'generating' to suppress a spurious agent:generating_completed
2227
+ // push notification. The adapter's status is otherwise authoritative — native
2228
+ // transcript shape does NOT override the FSM's busy/idle decision.
2229
+ const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
2230
+ const newStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus;
2231
+ const dirName = workingDirBasename(this.workingDir);
884
2232
  const chatTitle = `${this.provider.name} · ${dirName}`;
885
2233
  const partial = this.adapter.getPartialResponse();
2234
+ // Liveness fingerprint for the no-progress watchdog. The parsed
2235
+ // assistant buffer (`partial`) alone goes static while a tool/build runs
2236
+ // — the assistant emits no tokens even though the PTY is actively
2237
+ // printing tool output — which made the watchdog false-fire a "stuck"
2238
+ // alert mid-turn. Fold in the adapter's raw-activity timestamps so any
2239
+ // visible terminal progress (lastScreenChangeAt) or raw PTY byte
2240
+ // (lastOutputAt) keeps the fingerprint moving. The watchdog then only
2241
+ // survives a genuine stall where nothing at all is happening.
886
2242
  const progressFingerprint = newStatus === 'generating'
887
- ? `${partial || ''}`.slice(-2000)
2243
+ ? `${`${partial || ''}`.slice(-2000)}::scr=${adapterStatus.lastScreenChangeAt ?? 0}::out=${adapterStatus.lastOutputAt ?? 0}`
888
2244
  : undefined;
889
2245
 
890
2246
  const previousStatus = this.lastStatus;
891
2247
  if (newStatus !== this.lastStatus) {
892
2248
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
893
- if (this.lastStatus === 'idle' && newStatus === 'generating') {
2249
+ // GENERATING-MISSING (win32 fresh-worktree first-turn): a freshly-launched session
2250
+ // is in 'starting' until its startup-grace settles to idle. When the FIRST inject
2251
+ // lands inside that grace window, the adapter can report status DIRECTLY
2252
+ // starting → generating without an intervening 'idle' frame for
2253
+ // detectStatusTransition() to observe. Previously only the idle→generating arm
2254
+ // armed the bookkeeping, so a starting→generating frame fell straight through to the
2255
+ // bare `this.lastStatus = newStatus` update: generatingStartedAt stayed 0 and no
2256
+ // generating_started was queued. The fast turn's generating→idle completion was then
2257
+ // suppressed by the startup-blip guard below (generatingStartedAt===0 &&
2258
+ // !generatingDebouncePending) — so NO generating_started AND NO generating_completed
2259
+ // ever fired and the mesh coordinator never learned the worker went idle.
2260
+ //
2261
+ // We extend the idle→generating arm to also fire on starting→generating, BUT ONLY
2262
+ // when a real turn is in flight. The adapter script can also report 'generating' from
2263
+ // pure startup PTY noise (no task dispatched) — antigravity/codex/hermes-cli all
2264
+ // exercise that benign starting→generating→idle blip, which must NOT emit a
2265
+ // completion (see "startup-phase spurious completion suppression" tests).
2266
+ // hasAdapterPendingResponse() is the discriminator: a genuine inject sets the
2267
+ // adapter's isWaitingForResponse / currentTurnScope (or leaves a partial response),
2268
+ // whereas startup repaint noise leaves all of them empty. So an armed
2269
+ // starting→generating means "the worker actually started its first turn", and a
2270
+ // bare one stays a suppressed blip via the existing fall-through.
2271
+ const startingToGeneratingWithActiveTurn = this.lastStatus === 'starting'
2272
+ && newStatus === 'generating'
2273
+ && this.hasAdapterPendingResponse();
2274
+ if (((this.lastStatus === 'idle' && newStatus === 'generating') || startingToGeneratingWithActiveTurn)) {
2275
+ // If a completion event is already pending and the turn has ended
2276
+ // (generatingStartedAt===0), the PTY is painting its prompt area
2277
+ // after completing. Ignore this blip — do not cancel the pending
2278
+ // completion and do not advance lastStatus to generating. (On a true
2279
+ // starting→generating the session is fresh: completedDebouncePending is
2280
+ // null, so this blip guard is a no-op and we arm normally below.)
2281
+ if (this.completedDebouncePending && this.generatingStartedAt === 0) {
2282
+ LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
2283
+ return;
2284
+ }
894
2285
  this.suppressIdleHistoryReplay = false;
895
2286
  // Cancel any pending completed event (multi-step: idle→generating resume)
896
2287
  if (this.completedDebouncePending) {
897
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
2288
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
898
2289
  if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
899
2290
  this.completedDebouncePending = null;
900
2291
  }
@@ -926,10 +2317,29 @@ export class CliProviderInstance implements ProviderInstance {
926
2317
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
927
2318
  const modal = adapterStatus.activeModal;
928
2319
  LOG.info('CLI', `[${this.type}] approval modal: "${modal?.message?.slice(0, 80) ?? 'none'}"`);
929
- // Only push event if not already in waiting_approval (prevent flood from rapid cycles)
930
- const approvalCooldown = 5000;
931
- if (this.lastStatus !== 'waiting_approval' && (!this.lastApprovalEventAt || now - this.lastApprovalEventAt > approvalCooldown)) {
932
- this.lastApprovalEventAt = now;
2320
+ // Include the FSM's approval entry seq, mirroring the auto-approve
2321
+ // path (maybeAutoApproveStatus) and resolveModal's sameEntryReResolve
2322
+ // guard. Two distinct back-to-back approvals can carry identical
2323
+ // message/buttons (very common with claude-cli's "Allow Bash
2324
+ // command?"). Without the seq their fingerprints collide and the dedup
2325
+ // below silently drops the second waiting_approval event — it is never
2326
+ // emitted, so it cannot even land in the pending inbox for a later
2327
+ // read_chat reconcile to recover. The seq is bumped by the FSM on every
2328
+ // fresh waiting_approval entry, so a new approval always yields a new
2329
+ // fingerprint and emits.
2330
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
2331
+ ? adapterStatus.approvalEntrySeq
2332
+ : 0;
2333
+ const approvalFingerprint = JSON.stringify({
2334
+ message: typeof modal?.message === 'string' ? modal.message.trim() : '',
2335
+ buttons: Array.isArray(modal?.buttons) ? modal.buttons.map((button: unknown) => String(button).trim()) : [],
2336
+ seq: approvalEntrySeq,
2337
+ });
2338
+ // PTY redraws repeat the same modal content; fingerprint dedup prevents duplicate events.
2339
+ // Do NOT also gate on lastStatus: consecutive approvals can arrive waiting_approval→waiting_approval
2340
+ // (e.g. antigravity-cli resolves one prompt and immediately shows the next) and would be silently dropped.
2341
+ if (approvalFingerprint !== this.lastApprovalEventFingerprint) {
2342
+ this.lastApprovalEventFingerprint = approvalFingerprint;
933
2343
  this.appendRuntimeSystemMessage(
934
2344
  this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
935
2345
  `approval_request:${now}`,
@@ -941,22 +2351,170 @@ export class CliProviderInstance implements ProviderInstance {
941
2351
  modalButtons: modal?.buttons,
942
2352
  });
943
2353
  }
2354
+ } else if (newStatus === 'generating' && this.lastStatus === 'waiting_approval') {
2355
+ // Approval resolved and the agent resumed work. Defense-in-depth:
2356
+ // clear the approval emit fingerprint here too (not only on
2357
+ // completion at scheduleCompletedDebounceFlush). A subsequent
2358
+ // waiting_approval with the same modal content as the one just
2359
+ // resolved would otherwise collide with the stale fingerprint and be
2360
+ // dropped. The seq in the fingerprint already separates entries; this
2361
+ // reset is a belt-and-suspenders guard for the re-entry case.
2362
+ this.lastApprovalEventFingerprint = '';
944
2363
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
945
2364
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
946
- // If debounce still pending (generating lasted < 1s), cancel both events
2365
+ // Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
2366
+ // was entered from 'starting' state (startup PTY noise), not from a real idle→generating
2367
+ // task dispatch. The idle→generating handler is the only code path that sets
2368
+ // generatingStartedAt and generatingDebouncePending, so both being absent means no
2369
+ // task was ever dispatched. Suppress the spurious completion event and fall through
2370
+ // to a simple lastStatus update.
2371
+ if (!this.generatingStartedAt && !this.generatingDebouncePending) {
2372
+ LOG.debug('CLI', `[${this.type}] suppressed startup-phase generating→idle blip (generatingStartedAt=0, no debounce pending)`);
2373
+ } else
2374
+ // If debounce still pending (generating lasted < 1s), cancel both UI events.
2375
+ // Still emit agent:generating_completed so mesh orchestration can record
2376
+ // task_completed for direct dispatches that complete faster than the debounce.
947
2377
  if (this.generatingDebouncePending) {
948
- LOG.info('CLI', `[${this.type}] suppressed short generating (${now - this.generatingStartedAt}ms)`);
2378
+ const shortDurationMs = this.generatingStartedAt ? now - this.generatingStartedAt : 0;
2379
+ LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms)`);
949
2380
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
2381
+ // Emit completion for mesh task association even though the UI generating
2382
+ // started/completed pair is suppressed (too short for visible UI update).
2383
+ let shortFinalSummary: string | undefined;
2384
+ let shortEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
2385
+ try {
2386
+ const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
2387
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
2388
+ shortEvidenceSource = evidence.source;
2389
+ shortFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
2390
+ } catch { /* best-effort */ }
2391
+ // If a real response is confirmed, retroactively emit started so the chat
2392
+ // bubble appears even though the debounce suppressed the original event.
2393
+ if (shortFinalSummary) {
2394
+ this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now - shortDurationMs });
2395
+ }
950
2396
  this.generatingDebouncePending = null;
951
2397
  this.generatingStartedAt = 0;
2398
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || shortEvidenceSource === 'external-native') && !shortFinalSummary;
2399
+ if (missingEvidence) {
2400
+ LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
2401
+ }
2402
+ // When evidence is missing and there is no active mesh task context, suppress
2403
+ // the completion event. Providers with requiresFinalAssistantBeforeIdle or
2404
+ // external-native history must confirm a final assistant message before the
2405
+ // coordinator records task_completed. Only emit here if a mesh task is active
2406
+ // so the coordinator can apply its own timeout/retry logic.
2407
+ const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
2408
+ if (missingEvidence && !hasMeshContext) {
2409
+ LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
2410
+ // completedDebouncePending intentionally left null — the session is now idle
2411
+ // with no confirmed turn, matching the startup-blip suppression semantics.
2412
+ // (No EvtTrace: not a mesh session, so nothing routes to a coordinator.)
2413
+ } else {
2414
+ // EVTTRACE: completion fired (short-generating idle path).
2415
+ if (this.isMeshWorkerSession()) {
2416
+ traceMeshEventStage('fired', this.meshTraceCtx(), `short-generating idle (source=${shortEvidenceSource})`);
2417
+ }
2418
+ this.pushEvent({
2419
+ event: 'agent:generating_completed',
2420
+ chatTitle,
2421
+ duration: 0,
2422
+ timestamp: now,
2423
+ finalSummary: shortFinalSummary,
2424
+ completionDiagnostic: {
2425
+ reason: 'short_generating_suppressed',
2426
+ shortDurationMs,
2427
+ finalAssistantEvidenceSource: shortEvidenceSource,
2428
+ ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
2429
+ },
2430
+ });
2431
+ }
952
2432
  } else {
953
2433
  // Debounce completed, then require the rich transcript path that read_chat
954
2434
  // uses to show an idle turn whose last user-facing message is assistant.
955
- this.completedDebouncePending = { chatTitle, duration, timestamp: now, firstObservedAt: now };
956
- this.scheduleCompletedDebounceFlush(3000);
2435
+ this.completedDebouncePending = {
2436
+ chatTitle,
2437
+ duration,
2438
+ timestamp: now,
2439
+ firstObservedAt: now,
2440
+ previousStatus: this.lastStatus,
2441
+ // ARCH-REFACTOR R1: snapshot the completing turn's taskId NOW (sync),
2442
+ // before any follow-up task's flush can start a new turn and move
2443
+ // engine.currentTurnTaskId.
2444
+ ...(this.completingTurnTaskId() ? { taskId: this.completingTurnTaskId() } : {}),
2445
+ // NOTIF Defect-B: snapshot the producing turn's START instant NOW, for the
2446
+ // same reason as taskId — a follow-up turn moves engine.currentTurnStartedAt.
2447
+ // Prefer the engine's per-turn start (set at onTurnStarted, earliest reliable
2448
+ // anchor) and fall back to generatingStartedAt (when generating was observed).
2449
+ ...((() => {
2450
+ const engineTurnStart = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
2451
+ && Number.isFinite((this.adapter as any).currentTurnStartedAt)
2452
+ ? (this.adapter as any).currentTurnStartedAt as number
2453
+ : 0;
2454
+ const turnStartedAt = engineTurnStart || this.generatingStartedAt || 0;
2455
+ return turnStartedAt ? { turnStartedAt } : {};
2456
+ })()),
2457
+ };
2458
+ const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
2459
+ // (FALSEIDLE-BGCHILD-a) Native-history providers flush immediately (the
2460
+ // transcript is authoritative). For mesh worker sessions, give the
2461
+ // generating→idle transition a short settle window so a background-child
2462
+ // false idle (quiet after a backgrounded test/command while the parent turn
2463
+ // continues) gets caught by the resume guard in flushCompletedDebounceIfFinalized
2464
+ // instead of firing an early completion the coordinator can never correct.
2465
+ const meshWorkerSession = this.isMeshWorkerSession();
2466
+ const flushDelay = ownsExternalHistory
2467
+ ? (meshWorkerSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
2468
+ : 3000;
2469
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} meshWorker=${meshWorkerSession} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
2470
+ this.scheduleCompletedDebounceFlush(flushDelay);
957
2471
  }
958
2472
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
959
- this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
2473
+ this.emitAgentReadyOnce(chatTitle, now);
2474
+ // GENERATING-BOUNDARY (R4c): stamp the collapse moment ONCE so the
2475
+ // idle-stayed window below is anchored on the startup-grace collapse,
2476
+ // not on boot. Set only the first time so a later starting re-entry
2477
+ // cannot slide the window forward.
2478
+ if (this.startupGraceCollapseAt === null) this.startupGraceCollapseAt = now;
2479
+ // GENERATING-BOUNDARY fast-collapse (R4, win32 startup-grace first turn):
2480
+ // a turn dispatched into the startup-grace window can START and FINISH while
2481
+ // the FSM is still in 'starting'. On a daemon whose claude-cli spec has NOT yet
2482
+ // synced the starting→busy edge (the primary cure lives in the spec's
2483
+ // idle→busy.from), the FSM never reaches 'busy'/generating, so
2484
+ // detectStatusTransition observes starting→idle DIRECTLY with no intervening
2485
+ // 'generating' frame. The idle→generating arm — the only path that sets
2486
+ // generatingStartedAt and arms the completion — never fired, so the completing
2487
+ // turn's agent:generating_completed is never emitted and the mesh coordinator
2488
+ // never learns the worker went idle. Synthesize the started+completed pair here.
2489
+ //
2490
+ // Discriminator (false-positive safe — must NOT fire on a benign boot):
2491
+ // adapter.currentTurnTaskId is set ONLY by onTurnStarted (a real turn STARTED
2492
+ // this boot) and persists past completion, so it cleanly separates the three
2493
+ // non-firing cases — a true idle boot (no turn → null), a queued-pending
2494
+ // first turn that only runs AFTER startup-grace drains the composer (onTurnStarted
2495
+ // not yet called → null; it completes normally later via idle→busy→idle), and a
2496
+ // turn STILL running at the 8s mark (hasAdapterPendingResponse() still true →
2497
+ // excluded so we don't fire a premature mid-turn completion; idle→busy self-
2498
+ // corrects once the FSM reaches idle). We fire only when a turn started AND has
2499
+ // already finished: started-this-boot && !still-in-flight.
2500
+ this.maybeSynthesizeStartupGraceCollapse(chatTitle, now, 'startup_grace_fast_collapse');
2501
+ } else if (newStatus === 'error') {
2502
+ if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
2503
+ this.generatingDebouncePending = null;
2504
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
2505
+ this.completedDebouncePending = null;
2506
+ this.errorMessage = adapterStatus.errorMessage || this.errorMessage;
2507
+ this.errorReason = (adapterStatus.errorReason as ProviderErrorReason) || this.errorReason;
2508
+ this.pushEvent({
2509
+ event: 'agent:stopped',
2510
+ chatTitle,
2511
+ timestamp: now,
2512
+ finalSummary: adapterStatus.errorMessage || adapterStatus.errorReason || 'Provider reported an error',
2513
+ completionDiagnostic: {
2514
+ reason: adapterStatus.errorReason || 'provider_error',
2515
+ errorMessage: adapterStatus.errorMessage || undefined,
2516
+ },
2517
+ });
960
2518
  } else if (newStatus === 'stopped') {
961
2519
  // Cancel any pending debounce
962
2520
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -968,6 +2526,79 @@ export class CliProviderInstance implements ProviderInstance {
968
2526
  this.lastStatus = newStatus;
969
2527
  }
970
2528
 
2529
+ // GENERATING-BOUNDARY idle-stayed collapse (R4b): the starting→idle
2530
+ // fast-collapse arm above only fires when the FIRST turn itself drives the
2531
+ // starting→idle transition. When the launch settle already drained
2532
+ // starting→idle BEFORE the first turn arrives, the session is already 'idle'
2533
+ // and a turn that runs+completes inside the startup-grace window — too fast
2534
+ // for any poll to observe a 'generating' frame — produces NO status change
2535
+ // at all (idle→idle). detectStatusTransition's change block above is skipped
2536
+ // entirely, so neither the idle→generating arm nor the starting→idle
2537
+ // fast-collapse arm ever runs, and the mesh coordinator never learns the
2538
+ // worker went idle (the live rc.403 Probe1 miss). Catch it here: an
2539
+ // already-idle poll (no status change), still inside the startup-grace
2540
+ // window, where a turn started this boot and has already finished without
2541
+ // ever arming generating. The same false-positive-safe discriminators apply
2542
+ // (currentTurnTaskId set && !pending && generating never armed), plus a
2543
+ // once-per-turn guard since this branch is re-polled while the session sits
2544
+ // idle. Normal turns that DO reach 'busy' set generatingStartedAt and are
2545
+ // excluded; a queued-pending first turn that only runs after grace falls
2546
+ // outside the window and completes normally via idle→busy→idle.
2547
+ //
2548
+ // R4d (the live rc.405 Probe2 miss): R4c anchored the window on the collapse
2549
+ // moment but still measured its END against `now` (the poll/completion time). The
2550
+ // helper only fires once the turn has FINISHED (!hasAdapterPendingResponse()), so
2551
+ // the first eligible poll happens at completion. When the first turn is dispatched
2552
+ // a few seconds after the collapse AND runs for a non-trivial duration, that
2553
+ // completion lands PAST the 12s now-anchored window even though the turn was a
2554
+ // genuine startup-grace first turn (live: collapse→dispatch +5.2s, turn ~11s →
2555
+ // completion at collapse+16.2s > 12s). Anchor the window on when the first turn
2556
+ // STARTED (engine.currentTurnStartedAt, set by onTurnStarted) instead: a turn that
2557
+ // STARTED within the collapse window is a startup-grace first turn no matter how
2558
+ // long it then ran. The now-anchored check is retained as a union so a fast turn
2559
+ // (dispatched+completed quickly within 12s of collapse) keeps firing too; both
2560
+ // close for a much-later turn, preserving the "don't mislabel a late fast turn"
2561
+ // honesty the window exists for.
2562
+ const firstTurnStartedAt = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
2563
+ ? (this.adapter as any).currentTurnStartedAt as number
2564
+ : 0;
2565
+ const collapsedAt = this.startupGraceCollapseAt;
2566
+ const turnStartedWithinCollapseWindow = collapsedAt !== null
2567
+ && firstTurnStartedAt > 0
2568
+ && firstTurnStartedAt >= collapsedAt
2569
+ && (firstTurnStartedAt - collapsedAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS;
2570
+ const nowWithinCollapseWindow = collapsedAt !== null
2571
+ && (now - collapsedAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS;
2572
+ if (
2573
+ newStatus === 'idle'
2574
+ && previousStatus === 'idle'
2575
+ && (turnStartedWithinCollapseWindow || nowWithinCollapseWindow)
2576
+ ) {
2577
+ this.maybeSynthesizeStartupGraceCollapse(chatTitle, now, 'startup_grace_idle_turn_collapse');
2578
+ }
2579
+
2580
+ // Re-arm the queue-claim agent:ready on the FSM's first GENUINE ready.
2581
+ //
2582
+ // The boot-time starting→idle one-shot above is the historical claim
2583
+ // trigger, but it is consumed too early for FSM-spec providers whose
2584
+ // INITIAL state already reports status 'idle' (e.g. antigravity-cli): the
2585
+ // adapter reports idle before maybeMarkReady has fired, so lastStatus
2586
+ // advances starting→idle while the prompt is not yet drawn and the worker
2587
+ // cannot yet claim. Subsequent state-driven idle frames are idle→idle (no
2588
+ // status change), so the one-shot never re-fires and the worker strands its
2589
+ // queued task — the coordinator then relaunch-loops every ~90s.
2590
+ //
2591
+ // The adapter surfaces fsmReadySeen=true exactly when the FSM reaches its
2592
+ // first non-initial idle (the prompt is genuinely up). On that signal we
2593
+ // emit agent:ready once more. emitAgentReadyOnce is idempotent (guarded by
2594
+ // agentReadyEmitted), so providers whose boot one-shot already landed on a
2595
+ // real ready (claude-cli / codex-cli / hermes-cli, which use a startup
2596
+ // grace and whose initial state is not idle) treat this as a no-op — no
2597
+ // double claim, no double task injection.
2598
+ if (newStatus === 'idle' && adapterStatus.fsmReadySeen === true && !this.agentReadyEmitted) {
2599
+ this.emitAgentReadyOnce(chatTitle, now);
2600
+ }
2601
+
971
2602
  this.applyProviderResponse(parsedStatus, {
972
2603
  phase: (newStatus === 'idle' && (previousStatus === 'generating' || previousStatus === 'waiting_approval'))
973
2604
  ? 'turn_completed'
@@ -976,8 +2607,43 @@ export class CliProviderInstance implements ProviderInstance {
976
2607
 
977
2608
  // Monitor check (cooldown based notification, IDE/CLI common)
978
2609
  const agentKey = `${this.type}:cli`;
979
- const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint);
2610
+ // Approval pending is detected from the raw adapter status, not `newStatus`:
2611
+ // auto-approve synthesizes `waiting_approval` → 'generating', which would
2612
+ // otherwise let the no-progress watchdog accumulate the approval wait.
2613
+ const approvalPending = rawStatus === 'waiting_approval';
2614
+ const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint, approvalPending);
2615
+ const monitorParsedStatus: any = parsedStatus;
980
2616
  for (const me of monitorEvents) {
2617
+ if (
2618
+ me.type === 'monitor:no_progress'
2619
+ && this.completionHasFinalAssistantMessage(monitorParsedStatus?.messages)
2620
+ && !this.hasAdapterPendingResponse()
2621
+ && !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
2622
+ ) {
2623
+ // EVTTRACE: completion fired (no-progress monitor reconciled to completion).
2624
+ if (this.isMeshWorkerSession()) {
2625
+ traceMeshEventStage('fired', this.meshTraceCtx(), 'no_progress_monitor_final_summary');
2626
+ }
2627
+ this.pushEvent({
2628
+ event: 'agent:generating_completed',
2629
+ chatTitle,
2630
+ duration: this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : undefined,
2631
+ timestamp: me.timestamp,
2632
+ finalSummary: extractFinalSummaryFromMessages(monitorParsedStatus?.messages),
2633
+ completionDiagnostic: {
2634
+ providerType: this.type,
2635
+ sessionId: this.instanceId,
2636
+ providerSessionId: this.providerSessionId || null,
2637
+ reconciliationReason: 'no_progress_monitor_final_summary',
2638
+ finalAssistantPresent: true,
2639
+ },
2640
+ });
2641
+ this.generatingStartedAt = 0;
2642
+ // Cancel any pending debounce flush — monitor already fired completion.
2643
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
2644
+ this.completedDebouncePending = null;
2645
+ continue;
2646
+ }
981
2647
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
982
2648
  }
983
2649
  }
@@ -997,15 +2663,61 @@ export class CliProviderInstance implements ProviderInstance {
997
2663
  workspaceName: typeof event.workspaceName === 'string' && event.workspaceName.trim()
998
2664
  ? event.workspaceName
999
2665
  : this.workingDir,
2666
+ // Carry the workspace under BOTH `workspace` and `workspaceName` so the
2667
+ // downstream mesh forward/merge path — which reads `workspace` — can
2668
+ // propagate it to the coordinator snapshot. Without `workspace` the live
2669
+ // event path delivers an empty workspace and the dashboard falls back to
2670
+ // the generic "Terminal (Mesh Node)" title.
2671
+ workspace: typeof event.workspace === 'string' && event.workspace.trim()
2672
+ ? event.workspace
2673
+ : this.workingDir,
1000
2674
  providerSessionId: typeof event.providerSessionId === 'string' && event.providerSessionId.trim()
1001
2675
  ? event.providerSessionId
1002
2676
  : this.providerSessionId,
1003
2677
  };
2678
+ // TASKIDLESS: stamp the mesh task primary key on lifecycle events emitted by
2679
+ // a mesh worker session. The consumer (updateDirectDispatchStatus) was switched
2680
+ // to key on task_id (CANON-B), but the producer never carried it — so every
2681
+ // forwarded metadataEvent.taskId arrived undefined and the coordinator fell back
2682
+ // to a session_id match, which can flip a sibling dispatch row. Surface it here so
2683
+ // updateDirectDispatchStatus hits the exact PK row and the session_id fallback is
2684
+ // never exercised. Non-mesh sessions get no taskId (regression guard) —
2685
+ // isMeshWorkerSession() gates the injection.
2686
+ //
2687
+ // ARCH-REFACTOR R1 (per-turn identity): resolution order is
2688
+ // (1) an explicit taskId already on the event — the debounce-flush completion
2689
+ // path stamps the taskId captured at the generating→idle transition (the
2690
+ // turn that actually produced this completion);
2691
+ // (2) the per-turn binding (engine.currentTurnTaskId) for synchronously-emitted
2692
+ // events whose turn is still the current one;
2693
+ // (3) the legacy session scalar (settings.meshActiveTaskId) as a last-resort
2694
+ // backward-compat alias.
2695
+ // The scalar is last because it is last-write-wins: a second task attaching while
2696
+ // this turn was still running overwrites it, which is the exact NOTIF-MISDELIVER /
2697
+ // TASK-MSG-MISROUTE race this refactor removes.
2698
+ if (this.isMeshWorkerSession()) {
2699
+ const existingTaskId = typeof enrichedEvent.taskId === 'string' && enrichedEvent.taskId.trim()
2700
+ ? enrichedEvent.taskId
2701
+ : undefined;
2702
+ if (!existingTaskId) {
2703
+ const resolved = this.completingTurnTaskId();
2704
+ if (resolved) enrichedEvent.taskId = resolved;
2705
+ }
2706
+ }
1004
2707
  if (this.context?.emitProviderEvent) {
1005
2708
  this.context.emitProviderEvent(enrichedEvent);
1006
- return;
2709
+ } else {
2710
+ this.events.push(enrichedEvent);
2711
+ }
2712
+ // Auto-detach a direct-dispatch mesh assignment once the dispatched
2713
+ // task reaches a terminal state. Leaving meshNodeFor pinned would
2714
+ // route this session's next unrelated turn (a dashboard chat) into
2715
+ // the coordinator as if it were the completion of another task.
2716
+ // We schedule after the emit so the originating coordinator still
2717
+ // observes the completion event with its routing marker intact.
2718
+ if (TERMINAL_MESH_EVENTS.has(event.event) && this.settings.meshActiveTaskId) {
2719
+ try { this.detachMeshAssignment(); } catch { /* best-effort */ }
1007
2720
  }
1008
- this.events.push(enrichedEvent);
1009
2721
  }
1010
2722
 
1011
2723
  private flushEvents(): ProviderEvent[] {
@@ -1151,6 +2863,37 @@ export class CliProviderInstance implements ProviderInstance {
1151
2863
  return false;
1152
2864
  }
1153
2865
 
2866
+ /** @see ProviderInstance.noteManualInteraction */
2867
+ noteManualInteraction(now = Date.now()): void {
2868
+ this.manualAttendance.note(now);
2869
+ }
2870
+
2871
+ /**
2872
+ * Whether auto-approve should be treated as active *right now* for display
2873
+ * and firing decisions: the configured intent AND the user is not currently
2874
+ * attending this session by hand. When a human is attending, auto-approve is
2875
+ * held so the modal stays visible and they can drive it via the controlbar.
2876
+ * Provider-agnostic — the attendance signal is the command set, never any
2877
+ * CLI-specific modal text.
2878
+ */
2879
+ private autoApproveEffectivelyActive(status: string | undefined, now = Date.now()): boolean {
2880
+ return status === 'waiting_approval'
2881
+ && this.shouldAutoApprove()
2882
+ && !this.manualAttendance.isAttended(now);
2883
+ }
2884
+
2885
+ // STATUS-MISMATCH: true once the current auto-approve episode has been masking
2886
+ // waiting_approval behind `generating` for longer than AUTO_APPROVE_MASK_STALL_MS without
2887
+ // resolving (the settle gate never fired). When stalled, the surface mask must be dropped
2888
+ // so read_chat / mesh_status / the dashboard see the real waiting_approval + modal (and a
2889
+ // coordinator can mesh_approve it). autoApproveMaskSince is maintained by
2890
+ // maybeAutoApproveStatus (driven by getState + the recheck timer during a waiting episode);
2891
+ // this read is side-effect-free so getStatusMetadata can consult it too.
2892
+ private autoApproveMaskStalled(now = Date.now()): boolean {
2893
+ return this.autoApproveMaskSince > 0
2894
+ && now - this.autoApproveMaskSince > CliProviderInstance.AUTO_APPROVE_MASK_STALL_MS;
2895
+ }
2896
+
1154
2897
  private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
1155
2898
  this.appendRuntimeSystemMessage(
1156
2899
  formatAutoApprovalMessage(modalMessage, buttonLabel),
@@ -1238,7 +2981,7 @@ export class CliProviderInstance implements ProviderInstance {
1238
2981
  receivedAt: normalizedMessage.receivedAt || normalizedMessage.timestamp,
1239
2982
  historyDedupKey: dedupKey,
1240
2983
  }],
1241
- this.adapter.getScriptParsedStatus?.()?.title || this.workingDir.split('/').filter(Boolean).pop() || 'session',
2984
+ this.adapter.getScriptParsedStatus?.()?.title || workingDirBasename(this.workingDir),
1242
2985
  this.instanceId,
1243
2986
  this.providerSessionId,
1244
2987
  );
@@ -1258,12 +3001,28 @@ export class CliProviderInstance implements ProviderInstance {
1258
3001
  index,
1259
3002
  source: 'parsed',
1260
3003
  }));
3004
+ const getRole = (message: ChatMessage): string => typeof message.role === 'string'
3005
+ ? message.role.trim().toLowerCase()
3006
+ : '';
1261
3007
  const runtimeEntries: MergeEntry[] = this.runtimeMessages.map((entry, index) => ({
1262
3008
  message: entry.message,
1263
3009
  index: parsedMessages.length + index,
1264
- source: 'runtime',
3010
+ source: 'runtime' as const,
1265
3011
  runtimeKey: entry.key,
1266
- }));
3012
+ })).filter((entry) => {
3013
+ const meta = entry.message.meta && typeof entry.message.meta === 'object' && !Array.isArray(entry.message.meta)
3014
+ ? entry.message.meta as Record<string, unknown>
3015
+ : {};
3016
+ if (meta.runtimeInputAck !== true) return true;
3017
+ const runtimeText = flattenContent(entry.message.content).replace(/\s+/g, ' ').trim();
3018
+ if (!runtimeText) return false;
3019
+ return !parsedEntries.some((parsedEntry) => {
3020
+ const parsedRole = getRole(parsedEntry.message);
3021
+ if (parsedRole !== 'user' && parsedRole !== 'human') return false;
3022
+ const parsedText = flattenContent(parsedEntry.message.content).replace(/\s+/g, ' ').trim();
3023
+ return parsedText === runtimeText;
3024
+ });
3025
+ });
1267
3026
  const getTime = (message: ChatMessage): number => {
1268
3027
  const value = typeof message.receivedAt === 'number'
1269
3028
  ? message.receivedAt
@@ -1273,9 +3032,6 @@ export class CliProviderInstance implements ProviderInstance {
1273
3032
  return Number.isFinite(value) && value > 0 ? value : 0;
1274
3033
  };
1275
3034
 
1276
- const getRole = (message: ChatMessage): string => typeof message.role === 'string'
1277
- ? message.role.trim().toLowerCase()
1278
- : '';
1279
3035
  const isRuntimeOverlay = (entry: MergeEntry): boolean => {
1280
3036
  if (entry.source !== 'runtime') return false;
1281
3037
  const key = typeof entry.runtimeKey === 'string' ? entry.runtimeKey.trim().toLowerCase() : '';
@@ -1341,7 +3097,9 @@ export class CliProviderInstance implements ProviderInstance {
1341
3097
  this.providerSessionId = nextSessionId;
1342
3098
  this.historyWriter.promoteHistorySession(this.type, previousHistorySessionId, nextSessionId);
1343
3099
  this.historyWriter.writeSessionStart(this.type, nextSessionId, this.workingDir, this.instanceId);
1344
- this.restorePersistedHistoryFromCurrentSession();
3100
+ if (this.shouldHydrateExistingProviderHistory()) {
3101
+ this.restorePersistedHistoryFromCurrentSession();
3102
+ }
1345
3103
  this.adapter.updateRuntimeMeta({ providerSessionId: nextSessionId });
1346
3104
  this.onProviderSessionResolved?.({
1347
3105
  instanceId: this.instanceId,
@@ -1354,13 +3112,39 @@ export class CliProviderInstance implements ProviderInstance {
1354
3112
  LOG.info('CLI', `[${this.type}] discovered provider session id: ${nextSessionId}`);
1355
3113
  }
1356
3114
 
1357
- private syncCanonicalSavedHistoryIfNeeded(): boolean {
3115
+ private shouldHydrateExistingProviderHistory(): boolean {
3116
+ return this.launchMode === 'resume' || this.launchMode === 'manual';
3117
+ }
3118
+
3119
+ private shouldSuppressFreshLaunchStartupReplay(parsedMessages: unknown[], parsedStatus: any, adapterStatus: any, parsedProviderSessionId = ''): boolean {
3120
+ if (this.launchMode !== 'new') return false;
3121
+ if (this.providerSessionId) return false;
3122
+ if (!Array.isArray(parsedMessages) || parsedMessages.length === 0) return false;
3123
+ if (!isIdleStatus(adapterStatus?.status) || !isIdleStatus(parsedStatus?.status)) return false;
3124
+ if (parsedProviderSessionId) return true;
3125
+
3126
+ const newestMessageAt = parsedMessages.reduce<number>((newest, message) => Math.max(newest, getMessageTime(message)), 0);
3127
+
3128
+ // Untimestamped idle parser output during a fresh launch is usually the
3129
+ // provider's last workspace transcript before a new turn exists.
3130
+ return newestMessageAt === 0;
3131
+ }
3132
+
3133
+ private syncCanonicalSavedHistoryIfNeeded(options: { full?: boolean } = {}): boolean {
1358
3134
  if (!this.providerSessionId) return false;
1359
- const canonicalHistory = this.provider.canonicalHistory;
3135
+ const canonicalHistory = this.provider.nativeHistory;
1360
3136
  if (!canonicalHistory) return false;
1361
3137
 
3138
+ // Per-status-report hydration reads only a bounded tail (snapshot needs at
3139
+ // most the newest 60). The once-per-resume restore path passes full:true
3140
+ // because seedSessionHistory needs the COMPLETE transcript to seed dedup
3141
+ // state. The read-cache key encodes the window so the bounded and full
3142
+ // reads don't share/clobber each other's 2s cache entry.
3143
+ const limit = options.full ? Number.MAX_SAFE_INTEGER : STATUS_HYDRATION_TAIL_LIMIT;
3144
+ const windowTag = options.full ? 'full' : `tail:${STATUS_HYDRATION_TAIL_LIMIT}`;
3145
+
1362
3146
  if (isNativeSourceCanonicalHistory(canonicalHistory)) {
1363
- const cacheKey = [this.type, this.providerSessionId, this.workingDir].join('\0');
3147
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir, windowTag].join('\0');
1364
3148
  const now = Date.now();
1365
3149
  if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1366
3150
  return true;
@@ -1373,7 +3157,7 @@ export class CliProviderInstance implements ProviderInstance {
1373
3157
  historySessionId: this.providerSessionId,
1374
3158
  workspace: this.workingDir,
1375
3159
  offset: 0,
1376
- limit: Number.MAX_SAFE_INTEGER,
3160
+ limit,
1377
3161
  historyBehavior: this.provider.historyBehavior,
1378
3162
  scripts: this.provider.scripts as any,
1379
3163
  });
@@ -1390,7 +3174,7 @@ export class CliProviderInstance implements ProviderInstance {
1390
3174
  }
1391
3175
 
1392
3176
  try {
1393
- const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror'].join('\0');
3177
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror', windowTag].join('\0');
1394
3178
  const now = Date.now();
1395
3179
  if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1396
3180
  return true;
@@ -1401,7 +3185,14 @@ export class CliProviderInstance implements ProviderInstance {
1401
3185
  if (!materializeProviderNativeHistory(this.type, canonicalHistory, this.providerSessionId, this.workingDir, this.provider.scripts as any)) {
1402
3186
  return false;
1403
3187
  }
1404
- const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
3188
+ // Bounded by default: the per-status-report path only needs the newest
3189
+ // STATUS_HYDRATION_TAIL_LIMIT messages because the snapshot caps
3190
+ // activeChat.messages to the last 60 (status/normalize.ts) and loads
3191
+ // the rest lazily via read_chat on subscribe. The once-per-resume
3192
+ // restore path passes full:true so seedSessionHistory still sees the
3193
+ // COMPLETE transcript for prefix-dedup seeding. readChatHistory serves
3194
+ // a bounded limit as an O(tail) read.
3195
+ const restoredHistory = readChatHistory(this.type, 0, limit, this.providerSessionId, 0, this.provider.historyBehavior);
1405
3196
  this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
1406
3197
  role: message.role,
1407
3198
  content: message.content,
@@ -1417,10 +3208,13 @@ export class CliProviderInstance implements ProviderInstance {
1417
3208
 
1418
3209
  private restorePersistedHistoryFromCurrentSession(): void {
1419
3210
  if (!this.providerSessionId) return;
1420
- this.syncCanonicalSavedHistoryIfNeeded();
1421
- const restoredHistory = isNativeSourceCanonicalHistory(this.provider.canonicalHistory)
3211
+ // Restore is the once-per-resume seeding path: it needs the COMPLETE
3212
+ // transcript so seedSessionHistory can prime dedup state. Pass full so the
3213
+ // hydration read is unbounded here (and only here).
3214
+ this.syncCanonicalSavedHistoryIfNeeded({ full: true });
3215
+ const restoredHistory = isNativeSourceCanonicalHistory(this.provider.nativeHistory)
1422
3216
  ? readProviderChatHistory(this.type, {
1423
- canonicalHistory: this.provider.canonicalHistory,
3217
+ canonicalHistory: this.provider.nativeHistory,
1424
3218
  historySessionId: this.providerSessionId,
1425
3219
  workspace: this.workingDir,
1426
3220
  offset: 0,