@adhdev/daemon-core 0.9.82-rc.46 → 0.9.82-rc.461

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 (440) 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 +25 -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 +64 -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 +48 -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 +152 -0
  54. package/dist/commands/mesh-coordinator.d.ts +85 -1
  55. package/dist/commands/router-refine.d.ts +137 -0
  56. package/dist/commands/router-worktree-cleanup.d.ts +133 -0
  57. package/dist/commands/router.d.ts +241 -12
  58. package/dist/commands/upgrade-helper.d.ts +41 -1
  59. package/dist/config/chat-history.d.ts +9 -0
  60. package/dist/config/config.d.ts +19 -0
  61. package/dist/config/mesh-config.d.ts +136 -1
  62. package/dist/config/mesh-json-config.d.ts +199 -0
  63. package/dist/config/registry-resolver.d.ts +54 -0
  64. package/dist/config/repo-settings.d.ts +77 -0
  65. package/dist/daemon/dev-server.d.ts +0 -2
  66. package/dist/detection/cli-detector.d.ts +17 -0
  67. package/dist/detection/ide-detector.d.ts +13 -0
  68. package/dist/detection/win32-ide-version.d.ts +37 -0
  69. package/dist/git/change-impact-config.d.ts +159 -0
  70. package/dist/git/git-commands.d.ts +26 -2
  71. package/dist/git/git-diff.d.ts +6 -0
  72. package/dist/git/git-executor.d.ts +11 -0
  73. package/dist/git/git-status.d.ts +57 -0
  74. package/dist/git/git-types.d.ts +2 -50
  75. package/dist/git/git-worktree.d.ts +71 -1
  76. package/dist/git/index.d.ts +3 -1
  77. package/dist/index.d.ts +67 -15
  78. package/dist/index.js +58583 -25541
  79. package/dist/index.js.map +1 -1
  80. package/dist/index.mjs +58207 -25332
  81. package/dist/index.mjs.map +1 -1
  82. package/dist/installer.d.ts +1 -4
  83. package/dist/ipc/local-ipc-server.d.ts +91 -0
  84. package/dist/launch.d.ts +1 -1
  85. package/dist/logging/async-batch-writer.d.ts +10 -0
  86. package/dist/logging/debug-config.d.ts +16 -0
  87. package/dist/logging/log-redactor.d.ts +24 -0
  88. package/dist/logging/log-tail-reader.d.ts +81 -0
  89. package/dist/logging/logger.d.ts +1 -1
  90. package/dist/mesh/contracts.d.ts +230 -0
  91. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  92. package/dist/mesh/coordinator-registry.d.ts +59 -0
  93. package/dist/mesh/mesh-active-work.d.ts +174 -0
  94. package/dist/mesh/mesh-clone-grace.d.ts +8 -0
  95. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  96. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  97. package/dist/mesh/mesh-event-classify.d.ts +7 -0
  98. package/dist/mesh/mesh-event-forwarding.d.ts +41 -0
  99. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  100. package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
  101. package/dist/mesh/mesh-events-pending.d.ts +155 -0
  102. package/dist/mesh/mesh-events-stale.d.ts +61 -0
  103. package/dist/mesh/mesh-events-utils.d.ts +75 -0
  104. package/dist/mesh/mesh-events.d.ts +6 -49
  105. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  106. package/dist/mesh/mesh-host-ownership.d.ts +29 -0
  107. package/dist/mesh/mesh-init.d.ts +128 -0
  108. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
  109. package/dist/mesh/mesh-ledger.d.ts +133 -1
  110. package/dist/mesh/mesh-magi-status.d.ts +63 -0
  111. package/dist/mesh/mesh-missions.d.ts +189 -0
  112. package/dist/mesh/mesh-node-identity.d.ts +310 -0
  113. package/dist/mesh/mesh-queue-assignment.d.ts +158 -0
  114. package/dist/mesh/mesh-reconcile-loop.d.ts +80 -0
  115. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  116. package/dist/mesh/mesh-refine-gates.d.ts +428 -0
  117. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  118. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  119. package/dist/mesh/mesh-routing.d.ts +70 -0
  120. package/dist/mesh/mesh-runtime-store.d.ts +493 -0
  121. package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
  122. package/dist/mesh/mesh-task-inflight.d.ts +46 -0
  123. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  124. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  125. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  126. package/dist/mesh/mesh-work-queue.d.ts +318 -5
  127. package/dist/mesh/preview-freshness.d.ts +28 -0
  128. package/dist/mesh/refine-config.d.ts +216 -0
  129. package/dist/mesh/worktree-bootstrap-config.d.ts +219 -0
  130. package/dist/providers/acp-provider-instance.d.ts +5 -0
  131. package/dist/providers/approval-utils.d.ts +45 -0
  132. package/dist/providers/chat-message-normalization.d.ts +58 -1
  133. package/dist/providers/cli-provider-instance.d.ts +290 -3
  134. package/dist/providers/contracts.d.ts +149 -6
  135. package/dist/providers/external-sources.d.ts +71 -0
  136. package/dist/providers/manual-attendance.d.ts +79 -0
  137. package/dist/providers/native-history/antigravity-claim-registry.d.ts +28 -0
  138. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +137 -0
  139. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  140. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  141. package/dist/providers/native-history/constants.d.ts +12 -0
  142. package/dist/providers/native-history/dispatcher.d.ts +31 -0
  143. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  144. package/dist/providers/native-history/index.d.ts +13 -0
  145. package/dist/providers/provider-instance-manager.d.ts +29 -0
  146. package/dist/providers/provider-instance.d.ts +30 -1
  147. package/dist/providers/provider-loader.d.ts +42 -5
  148. package/dist/providers/provider-trust.d.ts +31 -0
  149. package/dist/providers/read-chat-contract.d.ts +29 -0
  150. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  151. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  152. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  153. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  154. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  155. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  156. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  157. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  158. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  159. package/dist/providers/sdk/v1/index.d.ts +30 -0
  160. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  161. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  162. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  163. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  164. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  165. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  166. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  167. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  168. package/dist/providers/spec/adapter.d.ts +98 -0
  169. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  170. package/dist/providers/spec/evaluator.d.ts +45 -0
  171. package/dist/providers/spec/fsm-driver.d.ts +449 -0
  172. package/dist/providers/spec/fsm-evaluator.d.ts +93 -0
  173. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  174. package/dist/providers/spec/fsm-types.d.ts +202 -0
  175. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  176. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  177. package/dist/providers/spec/route.d.ts +4 -0
  178. package/dist/providers/spec/types.d.ts +222 -0
  179. package/dist/providers/status-monitor.d.ts +7 -7
  180. package/dist/providers/transcript-v2.d.ts +176 -0
  181. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  182. package/dist/providers/working-dir.d.ts +17 -0
  183. package/dist/repo-mesh-types.d.ts +598 -12
  184. package/dist/runtime-defaults.d.ts +2 -0
  185. package/dist/session-host/managed-host.d.ts +64 -0
  186. package/dist/session-host/runtime-surface.d.ts +10 -16
  187. package/dist/sessions/registry.d.ts +6 -0
  188. package/dist/shared-types-extra.d.ts +2 -4
  189. package/dist/shared-types.d.ts +86 -55
  190. package/dist/status/normalize.d.ts +1 -1
  191. package/dist/status/normalize.js +1 -0
  192. package/dist/status/normalize.js.map +1 -1
  193. package/dist/status/normalize.mjs +1 -0
  194. package/dist/status/normalize.mjs.map +1 -1
  195. package/dist/status/reporter.d.ts +2 -0
  196. package/dist/status/snapshot.d.ts +26 -0
  197. package/dist/system/hash.d.ts +8 -0
  198. package/dist/system/load-better-sqlite3.d.ts +21 -0
  199. package/dist/types.d.ts +5 -0
  200. package/package.json +11 -4
  201. package/src/agent-stream/poller.ts +2 -3
  202. package/src/agent-stream/provider-adapter.ts +1 -1
  203. package/src/boot/daemon-lifecycle.ts +80 -14
  204. package/src/boot/process-hardening.ts +89 -0
  205. package/src/build-info.ts +73 -0
  206. package/src/chat/source-machine.ts +534 -0
  207. package/src/chat/source-resolver.ts +0 -0
  208. package/src/chat/subscription-updates.ts +20 -1
  209. package/src/cli-adapter-types.d.ts +3 -1
  210. package/src/cli-adapter-types.ts +68 -2
  211. package/src/cli-adapters/cli-script-runner.ts +421 -0
  212. package/src/cli-adapters/cli-state-engine.ts +1246 -0
  213. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  214. package/src/cli-adapters/provider-cli-adapter.ts +914 -1144
  215. package/src/cli-adapters/provider-cli-parse.d.ts +2 -0
  216. package/src/cli-adapters/provider-cli-parse.ts +19 -0
  217. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  218. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  219. package/src/cli-adapters/provider-cli-shared.ts +121 -11
  220. package/src/cli-adapters/pty-transport.ts +2 -1
  221. package/src/cli-adapters/pty-write-chunking.ts +106 -0
  222. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  223. package/src/cli-adapters/resolve-executable.ts +204 -0
  224. package/src/cli-adapters/session-host-transport.ts +2 -1
  225. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +63 -29
  226. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  227. package/src/cli-adapters/terminal-screen.ts +16 -81
  228. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  229. package/src/commands/chat-commands-read.ts +2488 -0
  230. package/src/commands/chat-commands-scope.ts +54 -0
  231. package/src/commands/chat-commands-shared.ts +114 -0
  232. package/src/commands/chat-commands-write.ts +891 -0
  233. package/src/commands/chat-commands.ts +19 -1841
  234. package/src/commands/cli-manager.ts +596 -27
  235. package/src/commands/handler.ts +845 -2
  236. package/src/commands/high-family/index.ts +28 -0
  237. package/src/commands/high-family/mesh-coordinator-launch.ts +680 -0
  238. package/src/commands/high-family/mesh-events.ts +89 -0
  239. package/src/commands/high-family/mesh-status.ts +832 -0
  240. package/src/commands/high-family/types.ts +80 -0
  241. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  242. package/src/commands/low-family/daemon-lifecycle.ts +120 -0
  243. package/src/commands/low-family/diagnostics.ts +57 -0
  244. package/src/commands/low-family/index.ts +37 -0
  245. package/src/commands/low-family/mesh-ledger.ts +62 -0
  246. package/src/commands/low-family/mesh-node-logs.ts +87 -0
  247. package/src/commands/low-family/notification.ts +116 -0
  248. package/src/commands/low-family/refine-config.ts +106 -0
  249. package/src/commands/low-family/session-host.ts +274 -0
  250. package/src/commands/low-family/spec-providerdev.ts +217 -0
  251. package/src/commands/low-family/status-meta.ts +112 -0
  252. package/src/commands/low-family/types.ts +39 -0
  253. package/src/commands/med-family/cli-agent.ts +241 -0
  254. package/src/commands/med-family/fast-forward.ts +230 -0
  255. package/src/commands/med-family/ide.ts +163 -0
  256. package/src/commands/med-family/index.ts +37 -0
  257. package/src/commands/med-family/mesh-crud.ts +1287 -0
  258. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  259. package/src/commands/med-family/mesh-queue.ts +167 -0
  260. package/src/commands/med-family/mesh-restart.ts +92 -0
  261. package/src/commands/med-family/types.ts +153 -0
  262. package/src/commands/mesh-coordinator.ts +334 -124
  263. package/src/commands/router-refine.ts +1711 -0
  264. package/src/commands/router-worktree-cleanup.ts +870 -0
  265. package/src/commands/router.ts +1025 -4070
  266. package/src/commands/stream-commands.ts +8 -0
  267. package/src/commands/upgrade-helper.ts +310 -45
  268. package/src/config/chat-history.ts +493 -24
  269. package/src/config/config.ts +30 -0
  270. package/src/config/mesh-config.ts +632 -23
  271. package/src/config/mesh-json-config.ts +376 -0
  272. package/src/config/recent-activity.ts +8 -2
  273. package/src/config/registry-resolver.ts +100 -0
  274. package/src/config/repo-settings.ts +111 -0
  275. package/src/daemon/dev-auto-implement.ts +3 -2
  276. package/src/daemon/dev-cli-debug.ts +10 -1
  277. package/src/daemon/dev-server.ts +0 -541
  278. package/src/detection/cli-detector.ts +94 -9
  279. package/src/detection/ide-detector.ts +55 -16
  280. package/src/detection/win32-ide-version.ts +106 -0
  281. package/src/git/change-impact-config.ts +354 -0
  282. package/src/git/git-commands.ts +112 -16
  283. package/src/git/git-diff.ts +81 -11
  284. package/src/git/git-executor.ts +12 -0
  285. package/src/git/git-status.ts +767 -48
  286. package/src/git/git-types.ts +14 -62
  287. package/src/git/git-worktree.ts +261 -4
  288. package/src/git/index.ts +17 -0
  289. package/src/index.ts +200 -12
  290. package/src/installer.d.ts +1 -1
  291. package/src/installer.ts +8 -6
  292. package/src/ipc/local-ipc-server.ts +278 -0
  293. package/src/launch.d.ts +1 -1
  294. package/src/launch.ts +37 -28
  295. package/src/logging/async-batch-writer.ts +55 -0
  296. package/src/logging/command-log.ts +7 -5
  297. package/src/logging/debug-config.ts +25 -0
  298. package/src/logging/debug-trace.ts +7 -2
  299. package/src/logging/log-redactor.ts +100 -0
  300. package/src/logging/log-tail-reader.ts +341 -0
  301. package/src/logging/logger.ts +14 -7
  302. package/src/mesh/contracts.ts +469 -0
  303. package/src/mesh/coordinator-prompt.ts +554 -34
  304. package/src/mesh/coordinator-registry.ts +121 -0
  305. package/src/mesh/mesh-active-work.ts +656 -0
  306. package/src/mesh/mesh-clone-grace.ts +68 -0
  307. package/src/mesh/mesh-coordinator-config.ts +97 -0
  308. package/src/mesh/mesh-delivery-policy.ts +315 -0
  309. package/src/mesh/mesh-event-classify.ts +73 -0
  310. package/src/mesh/mesh-event-forwarding.ts +1950 -0
  311. package/src/mesh/mesh-event-trace.ts +67 -0
  312. package/src/mesh/mesh-events-coordinator.ts +32 -0
  313. package/src/mesh/mesh-events-pending.ts +1132 -0
  314. package/src/mesh/mesh-events-stale.ts +440 -0
  315. package/src/mesh/mesh-events-utils.ts +443 -0
  316. package/src/mesh/mesh-events.ts +35 -1035
  317. package/src/mesh/mesh-fast-forward.ts +856 -0
  318. package/src/mesh/mesh-host-ownership.ts +111 -0
  319. package/src/mesh/mesh-init.ts +350 -0
  320. package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
  321. package/src/mesh/mesh-ledger.ts +873 -102
  322. package/src/mesh/mesh-magi-status.ts +223 -0
  323. package/src/mesh/mesh-missions.ts +449 -0
  324. package/src/mesh/mesh-node-identity.ts +2065 -0
  325. package/src/mesh/mesh-queue-assignment.ts +2233 -0
  326. package/src/mesh/mesh-reconcile-loop.ts +2397 -0
  327. package/src/mesh/mesh-refine-batch.ts +205 -0
  328. package/src/mesh/mesh-refine-gates.ts +1686 -0
  329. package/src/mesh/mesh-refine-status.ts +231 -0
  330. package/src/mesh/mesh-review-inbox.ts +308 -0
  331. package/src/mesh/mesh-routing.ts +291 -0
  332. package/src/mesh/mesh-runtime-store.ts +2056 -0
  333. package/src/mesh/mesh-scheduling-runtime.ts +198 -0
  334. package/src/mesh/mesh-task-inflight.ts +70 -0
  335. package/src/mesh/mesh-task-stats.ts +161 -0
  336. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  337. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  338. package/src/mesh/mesh-work-queue.ts +1281 -141
  339. package/src/mesh/preview-freshness.ts +163 -0
  340. package/src/mesh/refine-config.ts +423 -0
  341. package/src/mesh/worktree-bootstrap-config.ts +599 -0
  342. package/src/providers/acp-provider-instance.ts +43 -10
  343. package/src/providers/approval-utils.d.ts +5 -0
  344. package/src/providers/approval-utils.ts +99 -5
  345. package/src/providers/chat-message-normalization.ts +126 -4
  346. package/src/providers/cli-provider-instance.ts +2456 -107
  347. package/src/providers/contracts.d.ts +55 -0
  348. package/src/providers/contracts.ts +160 -6
  349. package/src/providers/extension-provider-instance.ts +12 -7
  350. package/src/providers/external-sources.ts +218 -0
  351. package/src/providers/ide-provider-instance.ts +35 -12
  352. package/src/providers/manual-attendance.ts +105 -0
  353. package/src/providers/native-history/antigravity-claim-registry.ts +131 -0
  354. package/src/providers/native-history/antigravity-cli-transcript.ts +1193 -0
  355. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  356. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  357. package/src/providers/native-history/constants.ts +19 -0
  358. package/src/providers/native-history/dispatcher.ts +503 -0
  359. package/src/providers/native-history/hermes-cli-transcript.ts +260 -0
  360. package/src/providers/native-history/index.ts +30 -0
  361. package/src/providers/provider-instance-manager.ts +71 -0
  362. package/src/providers/provider-instance.ts +24 -1
  363. package/src/providers/provider-loader.ts +700 -55
  364. package/src/providers/provider-schema.ts +87 -14
  365. package/src/providers/provider-trust.ts +114 -0
  366. package/src/providers/read-chat-contract.ts +76 -16
  367. package/src/providers/sdk/README.md +49 -0
  368. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  369. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  370. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  371. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +261 -0
  372. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  373. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  374. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  375. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  376. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  377. package/src/providers/sdk/v1/index.ts +152 -0
  378. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  379. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  380. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  381. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +509 -0
  382. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  383. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  384. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  385. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  386. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  387. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  388. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  389. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  390. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  391. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  392. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  393. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  394. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  395. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  396. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  397. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  398. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  399. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  400. package/src/providers/sdk/v1/validators/index.ts +19 -0
  401. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  402. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  403. package/src/providers/spec/adapter.ts +246 -0
  404. package/src/providers/spec/cli-adapter.ts +1064 -0
  405. package/src/providers/spec/evaluator.ts +407 -0
  406. package/src/providers/spec/fsm-driver.ts +1528 -0
  407. package/src/providers/spec/fsm-evaluator.ts +290 -0
  408. package/src/providers/spec/fsm-loader.ts +128 -0
  409. package/src/providers/spec/fsm-types.ts +301 -0
  410. package/src/providers/spec/native-history-executor.ts +1174 -0
  411. package/src/providers/spec/pre-launch-trust.ts +104 -0
  412. package/src/providers/spec/route.ts +51 -0
  413. package/src/providers/spec/types.ts +262 -0
  414. package/src/providers/status-monitor.d.ts +7 -7
  415. package/src/providers/status-monitor.ts +37 -22
  416. package/src/providers/transcript-v2.ts +567 -0
  417. package/src/providers/types/interactive-prompt.ts +536 -0
  418. package/src/providers/version-archive.ts +64 -24
  419. package/src/providers/working-dir.ts +23 -0
  420. package/src/repo-mesh-types.ts +836 -13
  421. package/src/runtime-defaults.ts +39 -0
  422. package/src/session-host/managed-host.ts +218 -0
  423. package/src/session-host/runtime-surface.ts +20 -80
  424. package/src/sessions/registry.ts +6 -0
  425. package/src/shared-types-extra.ts +2 -4
  426. package/src/shared-types.d.ts +8 -0
  427. package/src/shared-types.ts +124 -55
  428. package/src/status/builders.ts +26 -6
  429. package/src/status/normalize.ts +2 -0
  430. package/src/status/reporter.ts +19 -1
  431. package/src/status/snapshot.ts +95 -26
  432. package/src/system/hash.ts +23 -0
  433. package/src/system/host-memory.ts +29 -12
  434. package/src/system/load-better-sqlite3.ts +68 -0
  435. package/src/types.ts +5 -0
  436. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  437. package/dist/mesh/mesh-sync.d.ts +0 -53
  438. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  439. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  440. package/src/mesh/mesh-sync.ts +0 -111
@@ -13,19 +13,32 @@ 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 { recordDebugTrace } from '../logging/debug-trace.js';
26
+ import { shouldCollectTraceCategory } from '../logging/debug-config.js';
27
+ import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
22
28
  import type { ChatMessage } from '../types.js';
23
29
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
24
- import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.js';
30
+ import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText } from './approval-utils.js';
25
31
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
26
32
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
27
33
  import { normalizeProviderSessionId } from './provider-session-id.js';
28
- import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
34
+ import {
35
+ antigravityOwnerToken,
36
+ claimAntigravityConversation,
37
+ releaseAntigravityOwner,
38
+ } from './native-history/antigravity-claim-registry.js';
39
+ import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter, readChatMessageTimestampMs } from './chat-message-normalization.js';
40
+ import { workingDirBasename } from './working-dir.js';
41
+ import { ManualAttendanceTracker } from './manual-attendance.js';
29
42
 
30
43
  type PersistableCliHistoryMessage = {
31
44
  role: string;
@@ -35,16 +48,146 @@ type PersistableCliHistoryMessage = {
35
48
  receivedAt?: number;
36
49
  };
37
50
 
51
+ // Status snapshots only ever surface the newest messages: the cloud 'live'
52
+ // profile drops chat messages entirely (loaded lazily via read_chat on
53
+ // subscribe) and the 'full' profile caps activeChat.messages to the last 60
54
+ // (see status/normalize.ts). Unread/completion markers walk only the tail.
55
+ // So getState()'s saved-history hydration — which runs once per resume/manual
56
+ // CLI session on every status report — must read only a bounded tail, not the
57
+ // entire transcript. A full MAX_SAFE_INTEGER read here makes the initial
58
+ // status report O(transcript) × N(sessions), which is the real cold first-
59
+ // connection bottleneck on chat-heavy machines. The window comfortably exceeds
60
+ // the 60-message snapshot cap so dedup/collapse at the boundary stays stable.
61
+ const STATUS_HYDRATION_TAIL_LIMIT = 200;
62
+
38
63
  type CompletedDebouncePending = {
39
64
  chatTitle: string;
40
65
  duration: number;
41
66
  timestamp: number;
42
67
  firstObservedAt: number;
68
+ previousStatus: string;
43
69
  loggedBlockReason?: string;
70
+ loggedTranscriptProbe?: boolean;
71
+ transcriptProbeHistory?: ExternalTranscriptProbe[];
72
+ // ARCH-REFACTOR R1: the taskId of the turn that produced this (debounced) completion,
73
+ // captured SYNCHRONOUSLY at the generating→idle transition. The actual completion
74
+ // event is emitted later by the debounce flush, by which point a follow-up task may
75
+ // already have started its own turn and overwritten engine.currentTurnTaskId — so the
76
+ // id must be snapshotted here, not re-read at flush time.
77
+ taskId?: string;
78
+ // NOTIF Defect-B: the wall-clock start of the turn that produced this (debounced)
79
+ // completion, snapshotted SYNCHRONOUSLY at the generating→idle transition (same
80
+ // reason as taskId — a follow-up turn moves engine.currentTurnStartedAt). The
81
+ // completion's finalSummary is turn-scoped to bubbles at/after this instant so a
82
+ // debounce that flushes before the producing turn's final assistant bubble lands
83
+ // in the native transcript never echoes the PRIOR task's last bubble.
84
+ turnStartedAt?: number;
85
+ // FALSE-IDLE continuity: the busyEpoch value at the instant this pending was armed.
86
+ // The flush guard requires this.busyEpoch to still equal this — proving no busy
87
+ // phase (generating/waiting_approval) opened since arming. A momentary busy→idle
88
+ // blip in an inter-approval valley bumps busyEpoch, so a completion armed before
89
+ // the blip is cancelled at flush instead of emitting a stale mid-turn summary.
90
+ busyEpochAtArm?: number;
91
+ // FALSE-IDLE continuity: the adapter's raw PTY lastOutputAt at arm time. New PTY
92
+ // output after arming means the session was not continuously idle through the
93
+ // settle window (the agent kept printing), so the completion is cancelled.
94
+ lastOutputAtArm?: number;
95
+ };
96
+
97
+ function isIdleStatus(value: unknown): boolean {
98
+ const status = typeof value === 'string' ? value.trim().toLowerCase() : '';
99
+ return !status || status === 'idle' || status === 'ready';
100
+ }
101
+
102
+
103
+ function getMessageTime(message: unknown): number {
104
+ if (!message || typeof message !== 'object') return 0;
105
+ const record = message as { receivedAt?: unknown; timestamp?: unknown };
106
+ const value = Number(record.receivedAt ?? record.timestamp ?? 0);
107
+ return Number.isFinite(value) ? value : 0;
108
+ }
109
+
110
+ type CompletedFinalizationBlock = {
111
+ reason: string;
112
+ terminal?: boolean;
113
+ allowTimeout?: boolean;
114
+ // (SETTLE-VALLEY) When set, suppress the CANON-C decoupled-immediate emit for this
115
+ // missing_final_assistant block and HOLD (retry up to COMPLETED_FINALIZATION_MAX_WAIT_MS)
116
+ // until the native transcript's final assistant turn arrives (block clears → genuine emit)
117
+ // or the worker resumes (resume guard cancels). Set only for the inter-approval idle valley
118
+ // of a native-history mesh worker, where an immediate weak emit would freeze a truncated
119
+ // preamble summary (evidenceLevel=insufficient) into the append-only ledger before the
120
+ // worker's next approval turn resumes. Independent of valley length.
121
+ holdForTranscript?: boolean;
122
+ };
123
+
124
+ type CompletionFinalAssistantEvidence = {
125
+ present: boolean;
126
+ messages: unknown[];
127
+ source: 'parsed' | 'external-native' | 'unavailable';
128
+ };
129
+
130
+ type ExternalTranscriptProbe = {
131
+ readAt: number;
132
+ msgCount: number;
133
+ lastRole: string | null;
134
+ lastKind: string | null;
135
+ contentLen: number;
136
+ sourcePath: string | null;
137
+ sourceMtimeMs: number | null;
138
+ mtimeAgeMs: number | null;
44
139
  };
45
140
 
46
141
  const COMPLETED_FINALIZATION_RETRY_MS = 1000;
47
142
  const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
143
+ // (FALSEIDLE-BGCHILD-a) Minimum generating→idle settle window for native-history mesh worker
144
+ // sessions. Native-history providers (e.g. claude-cli) normally flush the completion with
145
+ // flushDelay=0 — the transcript is authoritative, so there is no reason to wait. But a worker
146
+ // turn that spawns a BACKGROUND child (e.g. `npm test &`, a backgrounded Bash tool) can paint
147
+ // a burst of child output, fall quiet, and have the screen parser read a PRIOR/intermediate
148
+ // standard assistant as if the turn were done — firing a false idle while the agent is in fact
149
+ // still generating (e.g. mid-commit). With flushDelay=0 there is no window for the resume guard
150
+ // in flushCompletedDebounceIfFinalized (latestVisibleStatus !== 'idle' → cancel) to observe the
151
+ // agent picking the turn back up. A short non-zero settle window restores that resume guard for
152
+ // mesh workers without delaying genuinely-finished turns beyond this bound. Scoped to mesh
153
+ // worker sessions so interactive native-history sessions keep the immediate flush.
154
+ // 4000ms (was 1500): live measurement showed the completion event can fire 1.6–3s
155
+ // BEFORE the worker's final-assistant turn lands in the transcript on a natural
156
+ // generating→idle completion (no approval modal), freezing a prior intermediate
157
+ // bubble as finalSummary (evidenceLevel=insufficient). The 68a3c324 waiting_approval
158
+ // hold only covers the approval-resolved valley; widening this settle window to 4000ms
159
+ // covers that race AND the ~3s waiting_approval valley within the settle bound.
160
+ const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
161
+ // TASKBUBBLE-DUP: window during which an identical user-input ack (same trimmed
162
+ // content on the same instance) is treated as a redelivery of one dispatch and
163
+ // suppressed from the chat transcript. Matches the coordinator-side
164
+ // DUPLICATE_DISPATCH_WINDOW_MS (mesh-tools) so the daemon's bubble-level guard
165
+ // covers the same retry horizon as the MCP-level dispatch dedup.
166
+ const USER_INPUT_ACK_DEDUP_WINDOW_MS = 60_000;
167
+ // GENERATING-BOUNDARY (R4c): window — measured from the startup-grace COLLAPSE moment
168
+ // (startupGraceCollapseAt), not boot — inside which a "first turn that ran+completed
169
+ // without the FSM ever observing a 'generating' frame" is attributed to the startup-grace
170
+ // collapse and synthesized (reason 'startup_grace_idle_turn_collapse').
171
+ // The spec's startup-grace exit is elapsed_ms=8000, so the FSM spends ~8s in 'starting'
172
+ // before collapsing to idle; a turn can be dispatched a few seconds AFTER that collapse
173
+ // (the live R4b miss: collapse at boot+8s, dispatch at boot+12.4s — already past a 12s
174
+ // boot-anchored window before the turn even started). Anchoring on the collapse moment
175
+ // covers dispatch-delay; R4d additionally anchors on the turn-START moment
176
+ // (engine.currentTurnStartedAt) so a non-trivial turn-DURATION cannot push the completion
177
+ // past a now-anchored window (the live rc.405 Probe2 miss). The strong discriminator is
178
+ // generatingStartedAt===0 (generating was never observed) AND a started-but-finished turn
179
+ // — the window only keeps the synthesized reason honest and scopes the synthesis to the
180
+ // boot collapse, so a much-later unobservably-fast turn is not mislabelled a startup collapse.
181
+ const STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS = 12_000;
182
+
183
+ /** Events that signal a dispatched mesh task has reached a terminal state.
184
+ * Detach the mesh assignment after emitting one of these so the worker's
185
+ * next unrelated turn doesn't impersonate another completion. */
186
+ const TERMINAL_MESH_EVENTS = new Set([
187
+ 'agent:generating_completed',
188
+ 'agent:stopped',
189
+ 'agent:ready',
190
+ ]);
48
191
 
49
192
  const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
50
193
  'image/png': '.png',
@@ -120,7 +263,29 @@ function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
120
263
  }
121
264
 
122
265
  function isCliGeneratingLikeStatus(status: unknown): boolean {
123
- return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
266
+ return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
267
+ }
268
+
269
+ /**
270
+ * NOTIF Defect-2a: the REPORTED short-generating duration, anchored on the IMMUTABLE turn
271
+ * start. generatingStartedAt is reset to 0 on every mid-turn waiting_approval/idle blip and
272
+ * re-armed on the next →generating, so a long turn that blips would otherwise measure only the
273
+ * final 1.5-2.5s sliver. engine.currentTurnStartedAt (set once at onTurnStarted, surviving
274
+ * mid-turn blips until the next turn starts) is preferred; generatingStartedAt is the fallback
275
+ * for turns that never recorded an engine turn start. Returns 0 when neither anchor is set.
276
+ * Pure / unit-testable.
277
+ */
278
+ export function computeTurnAnchoredDurationMs(
279
+ engineTurnStartedAt: number | undefined,
280
+ generatingStartedAt: number,
281
+ now: number,
282
+ ): { durationMs: number; anchor: 'turn-start' | 'generatingStartedAt' | 'none' } {
283
+ const engineStart = typeof engineTurnStartedAt === 'number' && Number.isFinite(engineTurnStartedAt)
284
+ ? engineTurnStartedAt
285
+ : 0;
286
+ if (engineStart > 0) return { durationMs: now - engineStart, anchor: 'turn-start' };
287
+ if (generatingStartedAt > 0) return { durationMs: now - generatingStartedAt, anchor: 'generatingStartedAt' };
288
+ return { durationMs: 0, anchor: 'none' };
124
289
  }
125
290
 
126
291
  export function buildCliStructuredInputPrompt(
@@ -300,24 +465,147 @@ export class CliProviderInstance implements ProviderInstance {
300
465
  readonly type: string;
301
466
  readonly category = 'cli' as const;
302
467
 
468
+ /**
469
+ * Quiet period an approval modal's signature must be stable before
470
+ * auto-approve sends the approve key. Guards against firing on a prompt
471
+ * that is still streaming into the PTY (the "resolves too fast" symptom):
472
+ * while the modal text/buttons are still changing, every frame yields a
473
+ * new signature and the settle clock restarts. Once the prompt finishes
474
+ * rendering the signature holds and the key is sent after this window.
475
+ * Bounded + small so genuine approvals stay timely. The FSM is already
476
+ * authoritative over the `waiting_approval` state; this only delays the
477
+ * keystroke until the modal *content* has settled.
478
+ */
479
+ private static readonly AUTO_APPROVE_SETTLE_MS = 600;
480
+
481
+ /**
482
+ * Busy-side hysteresis for the settle gate. A momentary `generating` flip
483
+ * while the SAME approval modal's button block is still on screen (its
484
+ * question line scrolled out of the captured frame, only the buttons + a
485
+ * residual `esc to interrupt` spinner remain) briefly reports
486
+ * status!=waiting_approval. Without hysteresis that flip wipes the settle
487
+ * clock, and the modal→generating→modal flap restarts the 600ms window
488
+ * every time so auto-approve never fires. We keep the in-progress settle
489
+ * gate warm across an inactive blip up to this bound; only once the modal
490
+ * has genuinely stayed gone this long (a real resolution → idle) is the
491
+ * gate cleared. Bounded so a genuinely new, later approval still re-settles
492
+ * from scratch rather than firing on a stale timestamp.
493
+ */
494
+ private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS = 1500;
495
+
496
+ /**
497
+ * STATUS-MISMATCH: upper bound on how long the auto-approve→`generating` SURFACE
498
+ * mask may hide a worker's `waiting_approval` (status + activeModal) before we give
499
+ * up and surface the real prompt. The mask exists because auto-approve is expected
500
+ * to resolve the modal momentarily; but if it STALLS without ever calling
501
+ * resolveModal — the modal signature never settles for AUTO_APPROVE_SETTLE_MS (a
502
+ * perpetually-flapping/streaming prompt), no concrete modal is ever captured, or the
503
+ * modal is a picker/non-affirmative we never auto-pick — the mask would persist
504
+ * forever and read_chat / mesh_status / the dashboard would NEVER see the pending
505
+ * approval (the coordinator cannot mesh_approve what it cannot see). Once an episode
506
+ * exceeds this bound we stop masking. Generously larger than
507
+ * AUTO_APPROVE_SETTLE_MS (600) + AUTO_APPROVE_GATE_HYSTERESIS_MS (1500) so a
508
+ * legitimately slow-settling / blip-flapping prompt is never unmasked early; a
509
+ * genuine never-resolving stall surfaces within this window. The settle gate keeps
510
+ * running underneath, so a prompt that finally stabilises still auto-approves, and
511
+ * mesh_approve (raw FSM, unmasked) works throughout.
512
+ */
513
+ private static readonly AUTO_APPROVE_MASK_STALL_MS = 4500;
514
+
303
515
  private adapter: ProviderCliAdapter;
304
516
  private context: InstanceContext | null = null;
305
517
  private events: ProviderEvent[] = [];
306
518
  private lastStatus: string = 'starting';
519
+ // Idempotency guard for the queue-claim agent:ready event. agent:ready is the
520
+ // sole signal the mesh coordinator's tryAssignQueueTask waits on to hand a
521
+ // queued task to this worker. It is emitted in two places: the boot-time
522
+ // starting→idle one-shot, and the readySeen re-arm below. This flag makes the
523
+ // event fire AT MOST ONCE per session so a worker is never claimed twice and a
524
+ // queued task is never double-dispatched/double-injected. Whichever path fires
525
+ // first sets it; the other becomes a no-op.
526
+ private agentReadyEmitted = false;
307
527
  private generatingStartedAt: number = 0;
528
+ // FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
529
+ // phase (→generating or →waiting_approval). The completedDebouncePending snapshots
530
+ // this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
531
+ // — proving the session did not re-enter a busy phase (a momentary busy→idle blip
532
+ // in an inter-approval valley) between arming the debounce and flushing it. A
533
+ // single point-sample of status at flush time cannot see a generating phase that
534
+ // opened AND closed within the settle window; the epoch can. See
535
+ // flushCompletedDebounceIfFinalized.
536
+ private busyEpoch: number = 0;
537
+ // GENERATING-BOUNDARY (R4b): the per-turn taskId for which a startup-grace
538
+ // started+completed pair was already synthesized. Both fast-collapse callers
539
+ // (starting→idle transition AND the idle-stayed no-status-change poll) route
540
+ // through maybeSynthesizeStartupGraceCollapse; the idle-stayed caller re-polls
541
+ // steadily while the session sits idle, so this guard makes the synthesis fire
542
+ // AT MOST ONCE per collapsed turn.
543
+ private fastCollapseSynthesizedTaskId: string | null = null;
544
+ // GENERATING-BOUNDARY (R4c): the wall-clock moment the FSM's starting→idle
545
+ // startup-grace collapse was observed (set ONCE, on the first starting→idle
546
+ // transition this boot). The idle-stayed collapse window is anchored on THIS,
547
+ // not on instance/boot time (this.startedAt): the FSM spends the full 8s
548
+ // startup-grace sitting in 'starting' before collapsing, and a turn can be
549
+ // dispatched a few seconds AFTER the collapse — measuring the window from boot
550
+ // would have it already closed by the time that turn lands+completes (the live
551
+ // R4b miss: collapse at boot+8s, dispatch at boot+12.4s > the 12s boot window).
552
+ // Anchoring on the collapse moment makes the window cover dispatch-delay+turn.
553
+ private startupGraceCollapseAt: number | null = null;
308
554
  private settings: Record<string, any> = {};
309
555
  private monitor: StatusMonitor;
310
556
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
311
557
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
312
- private lastApprovalEventAt = 0;
558
+ private lastApprovalEventFingerprint = '';
313
559
  private autoApproveBusy = false;
314
560
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
561
+ private lastAutoApprovalSignature = '';
562
+ // Settle gate: the approval modal's signature + the wall-clock when this
563
+ // exact signature was first observed. Auto-approve only fires once the
564
+ // SAME signature has been stable for AUTO_APPROVE_SETTLE_MS, so a prompt
565
+ // still streaming into the PTY (its buttons/message changing frame to
566
+ // frame) keeps resetting the timer and is never approved half-rendered.
567
+ private pendingAutoApprovalSignature = '';
568
+ private pendingAutoApprovalSince = 0;
569
+ private autoApproveSettleTimer: NodeJS.Timeout | null = null;
570
+ // Wall-clock when auto-approve first observed status!=waiting_approval while
571
+ // a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS so a
572
+ // brief generating flip does not immediately wipe the settle clock.
573
+ private autoApproveInactiveSince = 0;
574
+ // STATUS-MISMATCH: wall-clock when the CURRENT auto-approve episode (waiting_approval
575
+ // + shouldAutoApprove) first began wanting to mask. Unlike pendingAutoApprovalSince it
576
+ // is NOT reset when the modal signature changes (a still-streaming/flapping prompt) and
577
+ // survives the same hysteresis blips the settle gate does, so it measures the TRUE age
578
+ // of an unresolved auto-approve. Once it exceeds AUTO_APPROVE_MASK_STALL_MS the surface
579
+ // mask is dropped so the real waiting_approval surfaces. Cleared when the episode ends
580
+ // (modal genuinely gone, manual attendance takes over, or auto-approve fires).
581
+ private autoApproveMaskSince = 0;
582
+ // NOTIF-APPROVAL-MASKED (Q1b): the autoApproveMaskSince episode value for which a
583
+ // stalled-approval coordinator nudge has already been emitted, so the nudge fires
584
+ // exactly once per stalled auto-approve episode (0 = none emitted). Reusing the
585
+ // per-episode mask-clock value as the key makes it provider-agnostic (no reliance on
586
+ // approvalEntrySeq) and self-resetting: each new episode gets a fresh
587
+ // autoApproveMaskSince timestamp, and the episode-end reset zeroes it.
588
+ private stalledApprovalNudgeEpisode = 0;
589
+ // Provider-common manual-attendance signal: while a human is actively driving
590
+ // this session from the dashboard, auto-approve holds so they can take manual
591
+ // control. Background mesh workers are never attended → delegated auto-approve
592
+ // is unaffected.
593
+ private readonly manualAttendance = new ManualAttendanceTracker();
315
594
  private controlValues: Record<string, string | number | boolean> = {};
316
595
  private summaryMetadata: unknown = undefined;
317
596
  private appliedEffectKeys = new Set<string>();
318
597
  private historyWriter: ChatHistoryWriter;
319
598
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
320
599
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
600
+ private lastAcknowledgedUserInputAt = 0;
601
+ // TASKBUBBLE-DUP: per-content last-ack timestamps so the same dispatched
602
+ // prompt acked twice in quick succession (the worker buffers the first
603
+ // send during bootstrap/busy, then a redelivery — dispatch-confirm-timeout
604
+ // requeue or a reconcile re-dispatch — fires a SECOND send_chat before the
605
+ // outbound queue drains) collapses to ONE user bubble. Keyed on the trimmed
606
+ // content; an entry older than USER_INPUT_ACK_DEDUP_WINDOW_MS is treated as
607
+ // a fresh, intentional resend and is NOT suppressed.
608
+ private recentUserInputAcks = new Map<string, number>();
321
609
  private lastNativeSourceCanonicalCheckAt = 0;
322
610
  private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
323
611
  private cachedSqliteDb: {
@@ -330,6 +618,7 @@ export class CliProviderInstance implements ProviderInstance {
330
618
  private suppressIdleHistoryReplay = false;
331
619
  private errorMessage: string | undefined = undefined;
332
620
  private errorReason: ProviderErrorReason | undefined = undefined;
621
+ private activeInteractivePrompt: InteractivePrompt | null = null;
333
622
 
334
623
  private presentationMode: 'terminal' | 'chat';
335
624
  private providerSessionId?: string;
@@ -370,7 +659,10 @@ export class CliProviderInstance implements ProviderInstance {
370
659
  this.providerSessionId = options?.providerSessionId;
371
660
  this.launchMode = options?.launchMode || 'new';
372
661
  this.onProviderSessionResolved = options?.onProviderSessionResolved;
373
- this.adapter = new ProviderCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory);
662
+ this.adapter = createCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory) as ProviderCliAdapter;
663
+ if (this.providerSessionId) {
664
+ this.adapter.updateRuntimeMeta({ providerSessionId: this.providerSessionId });
665
+ }
374
666
  this.monitor = new StatusMonitor();
375
667
  this.historyWriter = new ChatHistoryWriter();
376
668
  }
@@ -389,8 +681,8 @@ export class CliProviderInstance implements ProviderInstance {
389
681
  this.adapter.updateRuntimeSettings?.(this.settings);
390
682
  this.monitor.updateConfig({
391
683
  approvalAlert: this.settings.approvalAlert !== false,
392
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
393
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
684
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
685
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
394
686
  });
395
687
 
396
688
  // Server connection
@@ -412,7 +704,7 @@ export class CliProviderInstance implements ProviderInstance {
412
704
  await this.adapter.spawn();
413
705
  await this.enforceFreshSessionLaunchIfNeeded();
414
706
  this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
415
- if (this.providerSessionId) {
707
+ if (this.providerSessionId && this.shouldHydrateExistingProviderHistory()) {
416
708
  this.restorePersistedHistoryFromCurrentSession();
417
709
  }
418
710
  if (this.providerSessionId && this.launchMode === 'resume') {
@@ -486,14 +778,38 @@ export class CliProviderInstance implements ProviderInstance {
486
778
  }
487
779
 
488
780
  getState(): ProviderState {
781
+ // TODO(phase5-sandbox): JS override scripts (detectStatus, parseApproval,
782
+ // parseSession) are currently invoked by CliScriptRunner.invoke() via direct
783
+ // function calls — the scripts run in the daemon process with full Node.js
784
+ // access and no resource limits.
785
+ //
786
+ // When Phase 5 lands, CliScriptRunner should route these calls through a
787
+ // SandboxedScriptRunner (see providers/sdk/v1/sandbox/script-runner.ts) so
788
+ // that each call gets a fresh isolated-vm context with a 50 ms CPU limit and
789
+ // a 32 MB memory cap. The execution path to change is:
790
+ // CliScriptRunner.invoke() → SandboxedScriptRunner.run(scriptSource, context)
791
+ //
792
+ // This getState() call-site is NOT where the change goes — the wiring belongs
793
+ // in cli-script-runner.ts (CliScriptRunner.detectStatus / parseApproval /
794
+ // parseSession), with provider-loader.ts updated to store script source strings
795
+ // alongside the loaded function references for extended-legacy providers.
489
796
  const adapterStatus = this.adapter.getStatus();
797
+ if (Object.prototype.hasOwnProperty.call(adapterStatus, 'activeInteractivePrompt')) {
798
+ this.activeInteractivePrompt = adapterStatus.activeInteractivePrompt ?? null;
799
+ }
490
800
  let parsedStatus: any = null;
491
801
  let parseErrorMessage: string | undefined;
492
802
  if (typeof this.adapter.getScriptParsedStatus === 'function') {
493
803
  try {
494
804
  parsedStatus = this.adapter.getScriptParsedStatus() || null;
495
- this.errorMessage = undefined;
496
- this.errorReason = undefined;
805
+ const parsedErrorMessage = typeof parsedStatus?.errorMessage === 'string' && parsedStatus.errorMessage.trim()
806
+ ? parsedStatus.errorMessage.trim()
807
+ : undefined;
808
+ const parsedErrorReason = typeof parsedStatus?.errorReason === 'string' && parsedStatus.errorReason.trim()
809
+ ? parsedStatus.errorReason.trim() as ProviderErrorReason
810
+ : undefined;
811
+ this.errorMessage = parsedErrorMessage;
812
+ this.errorReason = parsedErrorReason;
497
813
  } catch (error: any) {
498
814
  parseErrorMessage = error?.message || String(error);
499
815
  this.errorMessage = parseErrorMessage;
@@ -503,22 +819,59 @@ export class CliProviderInstance implements ProviderInstance {
503
819
  this.errorMessage = undefined;
504
820
  this.errorReason = undefined;
505
821
  }
506
- const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
507
- const visibleStatus = parseErrorMessage
508
- ? 'error'
509
- : (autoApproveActive ? 'generating' : adapterStatus.status);
510
- const parsedProviderSessionId = normalizeProviderSessionId(
822
+ const adapterProviderSessionId = normalizeProviderSessionId(
511
823
  this.provider,
512
- typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
824
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
513
825
  );
514
- if (parsedProviderSessionId) {
515
- this.promoteProviderSessionId(parsedProviderSessionId);
826
+ const nowMs = Date.now();
827
+ // STATUS-MISMATCH: maybeAutoApproveStatus still runs for its side effects (settle gate,
828
+ // resolveModal fire), but the SURFACE mask is dropped once the episode has stalled past
829
+ // AUTO_APPROVE_MASK_STALL_MS — otherwise a never-settling auto-approve hides the worker's
830
+ // waiting_approval + modal from read_chat/mesh_status/dashboard forever.
831
+ const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, nowMs)
832
+ && !this.autoApproveMaskStalled(nowMs);
833
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
834
+ let visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
835
+ ? 'error'
836
+ : (autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status);
837
+ // getState() must agree with the status the FSM-driven detectStatusTransition()
838
+ // already committed to lastStatus. The adapter's own status is authoritative; we do
839
+ // not second-guess it with native-transcript shape. Only reconcile a generating-like
840
+ // read down to idle when our own lastStatus has already flipped idle (avoids a
841
+ // perpetual dashboard spinner during the brief window before the next getStatus()).
842
+ if (isCliGeneratingLikeStatus(visibleStatus) && this.lastStatus === 'idle') {
843
+ visibleStatus = 'idle';
516
844
  }
517
845
  const runtime = this.adapter.getRuntimeMetadata();
518
846
  this.maybeAppendRuntimeRecoveryMessage(runtime);
519
847
  let parsedMessages = Array.isArray(parsedStatus?.messages)
520
848
  ? parsedStatus.messages
521
849
  : [];
850
+ const parsedProviderSessionId = normalizeProviderSessionId(
851
+ this.provider,
852
+ typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
853
+ );
854
+ const suppressFreshLaunchStartupReplay = this.shouldSuppressFreshLaunchStartupReplay(
855
+ parsedMessages,
856
+ parsedStatus,
857
+ adapterStatus,
858
+ parsedProviderSessionId,
859
+ );
860
+ if (adapterProviderSessionId && !suppressFreshLaunchStartupReplay) {
861
+ this.promoteProviderSessionId(adapterProviderSessionId);
862
+ }
863
+ if (parsedProviderSessionId && !suppressFreshLaunchStartupReplay) {
864
+ this.promoteProviderSessionId(parsedProviderSessionId);
865
+ }
866
+ if (suppressFreshLaunchStartupReplay) {
867
+ parsedMessages = [];
868
+ }
869
+ // Adapter runtime metadata is transport-owned and is not guaranteed to
870
+ // identify this conversation. Spec adapters historically exposed the
871
+ // provider spec id (for example "codex-cli") as runtimeId, which made
872
+ // concurrent sessions share one activeChat identity until their native
873
+ // provider session ids were discovered.
874
+ const activeChatId = this.providerSessionId || this.instanceId;
522
875
  const historyMessageCount = Number.isFinite(parsedStatus?.historyMessageCount)
523
876
  ? Math.max(0, Number(parsedStatus.historyMessageCount))
524
877
  : null;
@@ -528,9 +881,20 @@ export class CliProviderInstance implements ProviderInstance {
528
881
  : [];
529
882
  }
530
883
  const mergedMessages = this.mergeConversationMessages(parsedMessages);
531
- const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
884
+ const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
885
+ ? this.syncCanonicalSavedHistoryIfNeeded()
886
+ : false;
887
+ const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
888
+ ? this.lastPersistedHistoryMessages.map((message) => ({
889
+ role: message.role,
890
+ content: message.content,
891
+ kind: message.kind,
892
+ senderName: message.senderName,
893
+ receivedAt: message.receivedAt,
894
+ }))
895
+ : mergedMessages;
532
896
 
533
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
897
+ const dirName = workingDirBasename(this.workingDir);
534
898
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
535
899
  ? parsedStatus.status.trim()
536
900
  : undefined;
@@ -542,7 +906,7 @@ export class CliProviderInstance implements ProviderInstance {
542
906
  && adapterStatus.status === 'idle'
543
907
  && parsedStatus?.status === 'idle';
544
908
  let messagesToSave = parsedMessages;
545
- if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
909
+ if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'no_progress' || parsedChatStatus === 'long_generating')) {
546
910
  const lastIdx = messagesToSave.length - 1;
547
911
  if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
548
912
  messagesToSave = messagesToSave.slice(0, lastIdx);
@@ -572,33 +936,48 @@ export class CliProviderInstance implements ProviderInstance {
572
936
  }
573
937
  }
574
938
 
575
- this.applyProviderResponse(parsedStatus, { phase: 'immediate' });
939
+ this.applyProviderResponse(
940
+ suppressFreshLaunchStartupReplay && parsedStatus && typeof parsedStatus === 'object'
941
+ ? { ...parsedStatus, providerSessionId: undefined }
942
+ : parsedStatus,
943
+ { phase: 'immediate' },
944
+ );
576
945
  const surface = resolveProviderStateSurface({
577
946
  summaryMetadata: this.summaryMetadata as any,
578
947
  controlValues: this.controlValues,
579
948
  });
580
949
  const activeChatStatus = parseErrorMessage
581
950
  ? 'error'
582
- : autoApproveActive && parsedStatus?.status === 'waiting_approval'
951
+ : (autoApproveActive && parsedStatus?.status === 'waiting_approval') || autoApproveHoldIdle
583
952
  ? 'generating'
584
953
  : (adapterStatus.status !== 'idle'
585
954
  ? visibleStatus
586
955
  : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
587
956
 
957
+ // If an AskUserQuestion prompt is awaiting user input, overlay status as
958
+ // waiting_choice. This is distinct from waiting_approval (tool-use consent)
959
+ // — the engine's isWaitingForResponse state is unchanged, so completion
960
+ // tracking continues normally once the user responds.
961
+ const hasInteractivePrompt = !!this.activeInteractivePrompt;
962
+ const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
963
+ const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
964
+
588
965
  return {
589
966
  type: this.type,
590
967
  name: this.provider.name,
591
968
  category: 'cli',
592
- status: visibleStatus,
969
+ status: finalStatus,
593
970
  mode: this.presentationMode,
594
971
  activeChat: {
595
- id: `${this.type}_${this.workingDir}`,
972
+ id: activeChatId,
596
973
  title: parsedStatus?.title || dirName,
597
- status: activeChatStatus,
598
- messages: mergedMessages,
599
- activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
974
+ status: finalChatStatus,
975
+ messages: statusMessages,
976
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
977
+ activeInteractivePrompt: this.activeInteractivePrompt,
600
978
  inputContent: '',
601
979
  },
980
+ activeInteractivePrompt: this.activeInteractivePrompt,
602
981
  workspace: this.workingDir,
603
982
  instanceId: this.instanceId,
604
983
  providerSessionId: this.providerSessionId,
@@ -638,8 +1017,12 @@ export class CliProviderInstance implements ProviderInstance {
638
1017
 
639
1018
  getHotChatSessionState(): HotChatSessionState {
640
1019
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
641
- const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
642
- const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
1020
+ const nowMs = Date.now();
1021
+ // STATUS-MISMATCH: drop the mask once the auto-approve episode has stalled (see getState).
1022
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status, nowMs)
1023
+ && !this.autoApproveMaskStalled(nowMs);
1024
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
1025
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
643
1026
  const runtime = this.adapter.getRuntimeMetadata();
644
1027
  return {
645
1028
  id: this.instanceId,
@@ -651,36 +1034,274 @@ export class CliProviderInstance implements ProviderInstance {
651
1034
  };
652
1035
  }
653
1036
 
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';
1037
+ getSessionModalState(sessionId?: string): SessionModalState {
1038
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
1039
+ const nowMs = Date.now();
1040
+ // STATUS-MISMATCH: drop the mask once the auto-approve episode has stalled (see getState).
1041
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status, nowMs)
1042
+ && !this.autoApproveMaskStalled(nowMs);
1043
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
1044
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
1045
+ const dirName = workingDirBasename(this.workingDir);
659
1046
  return {
660
- id: this.instanceId,
1047
+ // Honor the caller-supplied sessionId — InstanceMgr rejects the
1048
+ // projection when projected.id !== requested sessionId, and
1049
+ // this.instanceId is the manager's internal key, not the public
1050
+ // sessionId the dashboard subscribes by.
1051
+ id: sessionId ?? this.instanceId,
661
1052
  status: visibleStatus,
662
1053
  title: dirName,
663
- activeModal: autoApproveActive ? null : adapterStatus.activeModal,
1054
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : adapterStatus.activeModal,
664
1055
  };
665
1056
  }
666
1057
 
667
1058
  updateSettings(newSettings: Record<string, any>): void {
668
- this.settings = { ...newSettings };
1059
+ // Merge semantics: a key omitted from newSettings preserves its existing
1060
+ // value, a key present in newSettings (even as false) overrides it.
1061
+ //
1062
+ // This is required because updateSettings has two callers with opposite
1063
+ // intent:
1064
+ // 1. Full re-injection — the dashboard toggle path (handleSetProviderSetting
1065
+ // → getSettings → updateInstanceSettings) sends the COMPLETE settings
1066
+ // object, so an explicit autoApprove:false must win.
1067
+ // 2. Partial stamp — the mesh relay-safety stamp (router.ts agent_command,
1068
+ // buildMeshWorkerRelayStamp) sends ONLY {meshNodeFor, meshNodeId,
1069
+ // meshCoordinatorDaemonId, launchedByCoordinator} on every coordinator
1070
+ // re-dispatch. It carries no autoApprove, so a full replacement would
1071
+ // wipe the launch-time autoApprove:true the worker was started with,
1072
+ // silently dropping every later approval to a manual gate until the
1073
+ // machine-page toggle re-injected the full settings.
1074
+ //
1075
+ // A plain merge satisfies both: undefined keys fall through to the existing
1076
+ // value (preserving launch-stamp settings like autoApprove + the mesh routing
1077
+ // keys), explicit keys override. This subsumes the previous mesh-key preserve
1078
+ // list, which only protected the routing keys and not autoApprove.
1079
+ this.settings = { ...this.settings, ...newSettings };
669
1080
  this.adapter.updateRuntimeSettings?.(this.settings);
670
1081
  this.monitor.updateConfig({
671
1082
  approvalAlert: this.settings.approvalAlert !== false,
672
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
673
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
1083
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
1084
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
674
1085
  });
675
1086
  }
676
1087
 
1088
+ /**
1089
+ * Stamp a direct-dispatch mesh assignment on this instance.
1090
+ * setupMeshEventForwarding reads settings.meshNodeFor + meshActiveTaskId to
1091
+ * route generating_completed back to the originating coordinator. Without
1092
+ * this stamp, mesh_send_task --direct targets a plain CLI session whose
1093
+ * completion events silently drop because the forwarder has nothing to
1094
+ * match against.
1095
+ */
1096
+ attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void {
1097
+ if (!assignment?.meshId) return;
1098
+ this.settings = {
1099
+ ...this.settings,
1100
+ meshNodeFor: assignment.meshId,
1101
+ // WTCLAIM (A): track the bound node id under BOTH the active marker
1102
+ // (meshNodeId, cleared on detach) and a sticky marker (meshLastNodeId,
1103
+ // preserved across detach). The sticky marker lets a detached but still
1104
+ // coordinator-owned session be re-picked ONLY for the SAME node it served
1105
+ // — never auto-adopted for a sibling node (e.g. a cloned worktree) that
1106
+ // shares this daemon. See isMeshOwnedDelegateSession's post-detach gate.
1107
+ ...(assignment.nodeId ? { meshNodeId: assignment.nodeId, meshLastNodeId: assignment.nodeId } : {}),
1108
+ ...(assignment.taskId ? { meshActiveTaskId: assignment.taskId } : {}),
1109
+ ...(assignment.coordinatorDaemonId ? { meshCoordinatorDaemonId: assignment.coordinatorDaemonId } : {}),
1110
+ // Session-level routing anchor: the originating coordinator session, so this
1111
+ // worker's completion events route back to the exact session that dispatched it.
1112
+ ...(assignment.coordinatorSessionId ? { meshCoordinatorSessionId: assignment.coordinatorSessionId } : {}),
1113
+ };
1114
+ this.adapter.updateRuntimeSettings?.(this.settings);
1115
+ }
1116
+
1117
+ /**
1118
+ * Clear a previously-attached mesh assignment after the task reaches a
1119
+ * terminal state. Leaving meshNodeFor pinned would route this session's
1120
+ * subsequent unrelated turns (e.g. ad-hoc dashboard chats) to the
1121
+ * coordinator as if they were task completions.
1122
+ *
1123
+ * MESHID-DROP-ON-DETACH (Fix C): a coordinator-LAUNCHED worker session
1124
+ * (launchedByCoordinator) holds its mesh membership (meshNodeFor / meshNodeId /
1125
+ * meshCoordinatorDaemonId) at the SESSION level — set once at launch
1126
+ * (mesh_launch_session / queue auto-launch), independent of any single task.
1127
+ * The original detach wiped meshNodeFor + meshNodeId together with the
1128
+ * task-level meshActiveTaskId, so the FIRST task completion stripped the
1129
+ * membership and EVERY subsequent completion forwarded with meshId absent —
1130
+ * resolveWorkerDelegateRouting fell to mesh_unresolved and the coordinator
1131
+ * rejected the forward "meshId required". For a launched member we therefore
1132
+ * clear ONLY the task-level marker (meshActiveTaskId) and preserve the
1133
+ * session-level membership so its next task's completion still resolves.
1134
+ * A task-less ad-hoc turn on a preserved-membership session is NOT misrouted:
1135
+ * its completion carries no taskId and the session holds no active assignment,
1136
+ * so the forwarder's WARMUPGAP guard skips the dispatch-row flip (it only
1137
+ * injects a benign task-less notification). A NON-launched session (a plain CLI
1138
+ * session adopted by mesh_send_task --direct, launchedByCoordinator falsy)
1139
+ * keeps the original full clear so an ad-hoc session is never left pinned.
1140
+ */
1141
+ detachMeshAssignment(): void {
1142
+ if (!this.settings.meshNodeFor && !this.settings.meshActiveTaskId && !this.settings.meshNodeId) return;
1143
+ // Session-level member: keep membership, drop only the task-level marker.
1144
+ if (this.settings.launchedByCoordinator === true) {
1145
+ if (!this.settings.meshActiveTaskId) return;
1146
+ const { meshActiveTaskId, ...rest } = this.settings;
1147
+ void meshActiveTaskId;
1148
+ this.settings = rest;
1149
+ this.adapter.updateRuntimeSettings?.(this.settings);
1150
+ return;
1151
+ }
1152
+ const { meshNodeFor, meshNodeId, meshActiveTaskId, ...rest } = this.settings;
1153
+ void meshNodeFor; void meshActiveTaskId;
1154
+ // WTCLAIM (A): clear the active binding but PRESERVE the last bound node id
1155
+ // (meshLastNodeId) so a later sessionless dispatch can re-adopt this idle
1156
+ // session ONLY for the node it last served. Carry the id being cleared, or
1157
+ // keep an already-present sticky marker if meshNodeId was absent.
1158
+ const lastNodeId = (typeof meshNodeId === 'string' && meshNodeId.trim())
1159
+ ? meshNodeId.trim()
1160
+ : (typeof rest.meshLastNodeId === 'string' && rest.meshLastNodeId.trim() ? rest.meshLastNodeId.trim() : undefined);
1161
+ this.settings = lastNodeId ? { ...rest, meshLastNodeId: lastNodeId } : rest;
1162
+ this.adapter.updateRuntimeSettings?.(this.settings);
1163
+ }
1164
+
1165
+ /**
1166
+ * The resolved modal-park status of this session, or null when it is not
1167
+ * parked on a modal awaiting a human answer. Mirrors the overlay logic in
1168
+ * getState(): an active AskUserQuestion interactive prompt resolves to
1169
+ * waiting_choice; otherwise the adapter's waiting_approval (tool consent)
1170
+ * counts — UNLESS auto-approve will dismiss it, in which case the session is
1171
+ * effectively generating and is NOT modal-parked. This is the single signal
1172
+ * the mesh force-inject guard consults, and the same status string the
1173
+ * reconcile loop reads off get_status_metadata. Lowercase literals only —
1174
+ * the SessionStatus enum is forked across modules and waiting_choice is
1175
+ * absent from some of them.
1176
+ */
1177
+ resolveModalParkStatus(): 'waiting_choice' | 'waiting_approval' | null {
1178
+ if (this.activeInteractivePrompt) return 'waiting_choice';
1179
+ let adapterStatus: { status?: string };
1180
+ try {
1181
+ adapterStatus = this.adapter.getStatus({ allowParse: false });
1182
+ } catch {
1183
+ return null;
1184
+ }
1185
+ // A session whose auto-approve is held by manual attendance IS parked on a
1186
+ // modal awaiting the human — autoApproveEffectivelyActive folds that in, so
1187
+ // the mesh force-inject guard correctly treats it as modal-parked. STATUS-MISMATCH:
1188
+ // a STALLED auto-approve (never resolving) is likewise effectively parked — treat it
1189
+ // as modal-parked so its events are held/surfaced rather than masked behind generating.
1190
+ if (adapterStatus.status === 'waiting_approval'
1191
+ && (!this.autoApproveEffectivelyActive(adapterStatus.status) || this.autoApproveMaskStalled())) {
1192
+ // NOTIF-HELD-DRAIN (Fix 1): an autonomous mesh session (coordinator or worker)
1193
+ // that is actively progressing a turn — a tool call is in flight
1194
+ // (hasAdapterPendingResponse) and NO human is attending it by hand — surfaces a
1195
+ // routine tool-consent `waiting_approval` on EVERY tool call when auto-approve is
1196
+ // off. That transient consent is part of the turn the harness/operator drives to
1197
+ // completion, NOT a session genuinely wedged awaiting a human's modal answer.
1198
+ // Classifying it modal-parked makes findLiveCoordinators hold the mesh's pending
1199
+ // completion events under `modal_parked` across a busy coordinator's whole work
1200
+ // batch, which is the multi-minute notification stall. Treat such a transient
1201
+ // consent as NOT modal-parked so it is held as ordinary "generating" (released on
1202
+ // the next idle) instead. A manually-attended session, a stalled auto-approve, or a
1203
+ // non-progressing session (no turn in flight) still parks — those are the genuine
1204
+ // human-await cases the guard must keep holding.
1205
+ if (this.isTransientToolConsent()) {
1206
+ return null;
1207
+ }
1208
+ return 'waiting_approval';
1209
+ }
1210
+ return null;
1211
+ }
1212
+
1213
+ /**
1214
+ * NOTIF-HELD-DRAIN: true when this `waiting_approval` is a routine, transient tool-consent
1215
+ * of an autonomously-progressing mesh session rather than a genuine human-await modal —
1216
+ * i.e. it is a mesh coordinator/worker session, a turn is actively in flight
1217
+ * (hasAdapterPendingResponse), and no human is attending it by hand. Such a consent is
1218
+ * driven to resolution by the harness/operator as part of the in-flight turn, so holding
1219
+ * the mesh's completion events behind it (modal_parked) is the false-positive that stalls
1220
+ * delivery. Narrow by design: manual attendance or a non-progressing session falls through
1221
+ * to the genuine-modal classification.
1222
+ */
1223
+ private isTransientToolConsent(now = Date.now()): boolean {
1224
+ return this.isAutonomousMeshSession()
1225
+ && this.hasAdapterPendingResponse()
1226
+ && !this.manualAttendance.isAttended(now);
1227
+ }
1228
+
1229
+ /** True when this session is parked on a modal awaiting a human answer. */
1230
+ isModalParked(): boolean {
1231
+ return this.resolveModalParkStatus() !== null;
1232
+ }
1233
+
1234
+ /**
1235
+ * PTY-OVERTRUST-DRAIN (Defect B). The deliverability/drain status the mesh
1236
+ * reconcile loop must consult — the RAW adapter turn-state, with the
1237
+ * auto-approve "hold-idle" visual mask STRIPPED.
1238
+ *
1239
+ * getState().status overlays `autoApproveHoldIdle`/`autoApproveActive` to paint a
1240
+ * genuinely-idle adapter as `generating` (a UI-flicker suppression while an
1241
+ * auto-approve key-press settles — see getState() ~:800). That mask is correct
1242
+ * for the dashboard, but the reconcile loop trusts it as "the coordinator is
1243
+ * busy" and therefore HOLDS a worker's completion under
1244
+ * `generating_no_idle_coordinator` even though the coordinator's PTY is at a real
1245
+ * turn end and would accept the inject as a turn — the completion is stranded.
1246
+ *
1247
+ * This accessor reports the drain truth instead:
1248
+ * - 'modal_parked' — a GENUINE human-await modal (AskUserQuestion / a non-
1249
+ * transient tool-consent). Still excluded from drain (a force-inject here
1250
+ * writes raw keystrokes the modal eats → data corruption). Mirrors
1251
+ * isModalParked(), evaluated first so a parked session never reads idle.
1252
+ * - 'idle' — the RAW adapter is at a turn end (adapter.getStatus(allowParse:false)
1253
+ * === 'idle') and the session is not modal-parked. Drain-eligible REGARDLESS
1254
+ * of the auto-approve mask. This is the case the mask used to hide.
1255
+ * - 'generating' — the raw adapter is genuinely mid-turn. Held (a raw PTY write
1256
+ * into a generating claude-cli is not consumed as a turn → data loss). The
1257
+ * intentional removal of force-inject-into-generating is preserved.
1258
+ * - 'other' — any other raw status (error / starting / waiting_choice handled by
1259
+ * modal-park above). Not a drain target.
1260
+ *
1261
+ * Uses allowParse:false (engine.activeModal only, side-effect-free) so it never
1262
+ * mutates the very auto-approve mask state the diagnostics read.
1263
+ */
1264
+ getDrainStatus(): 'idle' | 'generating' | 'modal_parked' | 'other' {
1265
+ if (this.isModalParked()) return 'modal_parked';
1266
+ let rawStatus: string;
1267
+ try {
1268
+ const raw = this.adapter.getStatus({ allowParse: false })?.status;
1269
+ rawStatus = typeof raw === 'string' ? raw.trim() : '';
1270
+ } catch {
1271
+ return 'other';
1272
+ }
1273
+ if (rawStatus === 'idle') return 'idle';
1274
+ if (isCliGeneratingLikeStatus(rawStatus)) return 'generating';
1275
+ return 'other';
1276
+ }
1277
+
677
1278
  onEvent(event: string, data?: any): void {
678
1279
  if (event === 'send_message') {
679
1280
  const input = normalizeInputEnvelope(data);
680
1281
  assertProviderSupportsDeclaredInput(this.provider, input);
681
1282
  const promptText = buildCliStructuredInputPrompt(input);
682
1283
  if (promptText) {
683
- void this.adapter.sendMessage(promptText).catch((e: any) => {
1284
+ // force:true bypasses the busy/generating send guard so terminal mesh
1285
+ // events (completion/failure/bootstrap) land in a coordinator session that
1286
+ // is itself parked in `generating` while awaiting that very event.
1287
+ // Without it the message is queued and only flushed on the coordinator's
1288
+ // own idle transition — which never happens until it receives the message.
1289
+ const force = data?.force === true;
1290
+ // Modal guard: a force-inject still writes raw keystrokes into the PTY,
1291
+ // bypassing the busy send-guard. If the coordinator is parked on a
1292
+ // harness modal (claude-cli AskUserQuestion → waiting_choice, or a
1293
+ // tool-consent waiting_approval), those keystrokes are consumed by the
1294
+ // modal's key handler and silently select a choice the user never made
1295
+ // (data corruption). Hold the force-inject in that narrow window —
1296
+ // the event stays queued and the reconcile loop redelivers it on the
1297
+ // next tick once the modal is resolved. We ONLY hold for the two modal
1298
+ // states; generating is still force-injected (that is the deadlock the
1299
+ // force path exists to break — see mesh-events-coordinator).
1300
+ if (force && this.isModalParked()) {
1301
+ LOG.info('CLI', `[${this.type}] force send_message held — coordinator parked on modal (${this.resolveModalParkStatus()})`);
1302
+ return;
1303
+ }
1304
+ void this.adapter.sendMessage(promptText, force ? { force: true } : {}).catch((e: any) => {
684
1305
  LOG.warn('CLI', `[${this.type}] send_message failed: ${e?.message || e}`);
685
1306
  });
686
1307
  }
@@ -690,14 +1311,118 @@ export class CliProviderInstance implements ProviderInstance {
690
1311
  void this.adapter.resolveAction(data).catch((e: any) => {
691
1312
  LOG.warn('CLI', `[${this.type}] resolve_action failed: ${e?.message || e}`);
692
1313
  });
1314
+ } else if (event === 'interactive_prompt' && data) {
1315
+ const prompt = normalizeInteractivePrompt(data);
1316
+ if (prompt) {
1317
+ this.activeInteractivePrompt = prompt;
1318
+ this.events.push({
1319
+ event: 'interactive_prompt',
1320
+ timestamp: Date.now(),
1321
+ promptId: prompt.promptId,
1322
+ });
1323
+ }
1324
+ } else if (event === 'interactive_prompt_response' && data) {
1325
+ try {
1326
+ const response = normalizeInteractivePromptResponse(data);
1327
+ if (this.activeInteractivePrompt?.promptId === response.promptId) {
1328
+ this.activeInteractivePrompt = null;
1329
+ }
1330
+ if (typeof this.adapter.setInteractivePromptResponse !== 'function') {
1331
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response ignored: adapter does not support interactive prompts`);
1332
+ return;
1333
+ }
1334
+ void this.adapter.setInteractivePromptResponse(response).catch((e: any) => {
1335
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response failed: ${e?.message || e}`);
1336
+ });
1337
+ } catch (e: any) {
1338
+ LOG.warn('CLI', `[${this.type}] invalid interactive_prompt_response: ${e?.message || e}`);
1339
+ }
693
1340
  } else if (event === 'provider_state_patch' && data && typeof data === 'object') {
694
1341
  this.applyProviderResponse(data, { phase: 'immediate' });
695
1342
  }
696
1343
  }
697
1344
 
1345
+ recordAcknowledgedUserInput(input: InputEnvelope | string): void {
1346
+ const content = typeof input === 'string'
1347
+ ? input.trim()
1348
+ : buildCliStructuredInputPrompt(input).trim();
1349
+ if (!content) return;
1350
+
1351
+ const receivedAt = Date.now();
1352
+
1353
+ // TASKBUBBLE-DUP: collapse a redelivered dispatch to one bubble. A single
1354
+ // mesh_send_task can reach this instance as TWO send_chat calls when the
1355
+ // first injection is buffered during bootstrap/busy and a retry (dispatch-
1356
+ // confirm-timeout requeue, or a reconcile re-dispatch) fires before the
1357
+ // outbound queue drains. The previous dedupKey hashed receivedAt, so the
1358
+ // two acks produced different keys and BOTH bubbled. Suppress an identical
1359
+ // content ack seen within USER_INPUT_ACK_DEDUP_WINDOW_MS; a later resend of
1360
+ // the same text (beyond the window) is a genuine new turn and still shows.
1361
+ const ackContentKey = shortHash(`${this.instanceId}:${content}`, 24);
1362
+ const lastAckAt = this.recentUserInputAcks.get(ackContentKey);
1363
+ if (lastAckAt !== undefined && receivedAt - lastAckAt <= USER_INPUT_ACK_DEDUP_WINDOW_MS) {
1364
+ // Refresh the timestamp so a steady stream of redeliveries keeps
1365
+ // collapsing, and prune stale entries to bound the map size.
1366
+ this.recentUserInputAcks.set(ackContentKey, receivedAt);
1367
+ this.pruneRecentUserInputAcks(receivedAt);
1368
+ return;
1369
+ }
1370
+ this.recentUserInputAcks.set(ackContentKey, receivedAt);
1371
+ this.pruneRecentUserInputAcks(receivedAt);
1372
+
1373
+ this.lastAcknowledgedUserInputAt = receivedAt;
1374
+ // The runtimeMessages dedupKey stays per-call unique (includes receivedAt)
1375
+ // so a genuine resend of the same text after the window appends a fresh
1376
+ // bubble; redelivery within the window is already suppressed above.
1377
+ const dedupKey = `user_input_ack:${shortHash(`${this.instanceId}:${content}:${receivedAt}`, 24)}`;
1378
+ this.appendRuntimeMessage(buildChatMessage({
1379
+ role: 'user',
1380
+ senderName: 'User',
1381
+ kind: 'standard',
1382
+ content,
1383
+ receivedAt,
1384
+ timestamp: receivedAt,
1385
+ source: 'runtime_input_ack',
1386
+ meta: {
1387
+ runtimeInputAck: true,
1388
+ provider: this.type,
1389
+ workspace: this.workingDir,
1390
+ },
1391
+ } as ChatMessage), dedupKey);
1392
+ }
1393
+
1394
+ /** Drop user-input ack entries older than the dedup window so the map can't grow unbounded. */
1395
+ private pruneRecentUserInputAcks(now: number): void {
1396
+ if (this.recentUserInputAcks.size <= 1) return;
1397
+ for (const [key, at] of this.recentUserInputAcks) {
1398
+ if (now - at > USER_INPUT_ACK_DEDUP_WINDOW_MS) this.recentUserInputAcks.delete(key);
1399
+ }
1400
+ }
1401
+
1402
+ /**
1403
+ * Owner token for this session in the antigravity conversation-claim
1404
+ * registry. Derived identically to the dispatcher's read-side token
1405
+ * (workspace + spawn time) so the claims the dispatcher records under this
1406
+ * session are the ones dispose() releases.
1407
+ */
1408
+ private antigravityClaimOwner(): string {
1409
+ return antigravityOwnerToken(this.workingDir, this.startedAt);
1410
+ }
1411
+
698
1412
  dispose(): void {
1413
+ // Release this session's antigravity conversation claims so the store
1414
+ // becomes available again (e.g. a later resume) and the registry doesn't
1415
+ // leak entries for dead sessions.
1416
+ if (this.type === 'antigravity-cli') {
1417
+ const owner = this.antigravityClaimOwner();
1418
+ if (owner) releaseAntigravityOwner(owner);
1419
+ }
699
1420
  this.adapter.shutdown();
700
1421
  this.monitor.reset();
1422
+ // Cancel any armed auto-approve timers so a pending settle re-check
1423
+ // can't fire resolveModal/detectStatusTransition against a dead adapter.
1424
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1425
+ if (this.autoApproveBusyTimer) { clearTimeout(this.autoApproveBusyTimer); this.autoApproveBusyTimer = null; }
701
1426
  this.appliedEffectKeys.clear();
702
1427
  try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
703
1428
  this.cachedSqliteDb = null;
@@ -706,6 +1431,7 @@ export class CliProviderInstance implements ProviderInstance {
706
1431
 
707
1432
  private completedDebounceTimer: NodeJS.Timeout | null = null;
708
1433
  private completedDebouncePending: CompletedDebouncePending | null = null;
1434
+ private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
709
1435
 
710
1436
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
711
1437
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -734,13 +1460,233 @@ export class CliProviderInstance implements ProviderInstance {
734
1460
  this.applyProviderResponse(parsed.payload, { phase: 'immediate' });
735
1461
  }
736
1462
 
737
- private completionHasFinalAssistantMessage(messages: unknown): boolean {
1463
+ private completionHasFinalAssistantMessage(messages: unknown, turnStartedAt?: number): boolean {
738
1464
  const visibleMessages = (Array.isArray(messages) ? messages : [])
739
1465
  .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
740
1466
  const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
741
1467
  const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
742
1468
  const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
743
- return role === 'assistant' && !!content;
1469
+ if (role !== 'assistant' || !content) return false;
1470
+ // Guard: if the last assistant message looks like an active approval/input prompt,
1471
+ // it is not a real completion — the session is still awaiting user input.
1472
+ if (looksLikeActiveApprovalPromptText(content)) return false;
1473
+ // FALSE-IDLE turn-boundary evidence (Defect 1b): when a producing-turn start is
1474
+ // known, the final assistant bubble must POST-DATE it. A STALE mid-turn assistant
1475
+ // (predating this turn's start — e.g. the last bubble of a prior sub-turn observed
1476
+ // during an inter-approval valley) must NOT satisfy the finalization gate, or a
1477
+ // false-idle blip emits a completion carrying that stale summary. A bubble with no
1478
+ // parseable timestamp cannot be proven stale, so it is kept (fails open — behaviour
1479
+ // identical to before for providers/paths that carry no timestamps).
1480
+ if (typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0) {
1481
+ // readChatMessageTimestampMs mirrors the summary turn-scoping reader
1482
+ // (extractFinalSummaryFromMessagesAfter) — same seconds-vs-ms heuristic and
1483
+ // field precedence — so the present-check and the summary-scope agree on which
1484
+ // bubbles predate the turn.
1485
+ const ts = readChatMessageTimestampMs(lastVisible);
1486
+ if (typeof ts === 'number' && ts < turnStartedAt) return false;
1487
+ }
1488
+ return true;
1489
+ }
1490
+
1491
+ private buildExternalTranscriptProbe(messages: unknown[], sourcePath?: string, sourceMtimeMs?: number): ExternalTranscriptProbe {
1492
+ const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1493
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1494
+ const readAt = Date.now();
1495
+ const mtimeMs = Number(sourceMtimeMs) || 0;
1496
+ return {
1497
+ readAt,
1498
+ msgCount: messages.length,
1499
+ lastRole: typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null,
1500
+ lastKind: typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null,
1501
+ contentLen: lastVisible ? flattenContent(lastVisible.content).trim().length : 0,
1502
+ sourcePath: typeof sourcePath === 'string' && sourcePath ? sourcePath : null,
1503
+ sourceMtimeMs: mtimeMs || null,
1504
+ mtimeAgeMs: mtimeMs ? Math.max(0, readAt - mtimeMs) : null,
1505
+ };
1506
+ }
1507
+
1508
+ private recordPendingTranscriptProbe(pending: CompletedDebouncePending): ExternalTranscriptProbe | null {
1509
+ const probe = this.lastExternalCompletionProbe;
1510
+ if (!probe) return null;
1511
+ const history = pending.transcriptProbeHistory || [];
1512
+ const last = history[history.length - 1];
1513
+ if (!last || last.readAt !== probe.readAt || last.msgCount !== probe.msgCount || last.lastRole !== probe.lastRole || last.contentLen !== probe.contentLen) {
1514
+ history.push(probe);
1515
+ pending.transcriptProbeHistory = history.slice(-5);
1516
+ }
1517
+ return probe;
1518
+ }
1519
+
1520
+ private readExternalCompletionMessages(): unknown[] | null {
1521
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1522
+ if (!adapterOwnsMessagesElsewhere) return null;
1523
+ if (!this.providerSessionId) return null;
1524
+ if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
1525
+
1526
+ if (this.lastExternalCompletionProbe?.sourcePath) {
1527
+ try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
1528
+ }
1529
+ const restoredHistory = readProviderChatHistory(this.type, {
1530
+ canonicalHistory: this.provider.nativeHistory,
1531
+ historySessionId: this.providerSessionId,
1532
+ workspace: this.workingDir,
1533
+ offset: 0,
1534
+ limit: Number.MAX_SAFE_INTEGER,
1535
+ historyBehavior: this.provider.historyBehavior,
1536
+ scripts: this.provider.scripts as any,
1537
+ sessionStartedAtMs: this.startedAt,
1538
+ forceRefresh: true,
1539
+ });
1540
+ if (restoredHistory.source !== 'provider-native') {
1541
+ this.lastExternalCompletionProbe = null;
1542
+ return null;
1543
+ }
1544
+ this.lastExternalCompletionProbe = this.buildExternalTranscriptProbe(
1545
+ restoredHistory.messages,
1546
+ restoredHistory.sourcePath,
1547
+ restoredHistory.sourceMtimeMs,
1548
+ );
1549
+ return restoredHistory.messages;
1550
+ }
1551
+
1552
+ private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1553
+ // (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
1554
+ // pure message-content check ("does the last visible bubble read as a finalized assistant
1555
+ // reply, post-dating the turn start?"). That LOWER bound alone treated the FIRST assistant
1556
+ // bubble of a turn that is STILL running — a tool call in flight between two assistant
1557
+ // bubbles — as proof the turn ended (RCA cases a & b). Require in ADDITION that the turn is
1558
+ // genuinely OVER: hasAdapterPendingResponse() folds the three upper-bound discriminators
1559
+ // into one — currentTurnScope closed, no in-flight tool (isProcessing false), and no partial
1560
+ // response buffer. So a mid-turn point-sample (short-gen / fast-collapse inline paths that
1561
+ // do NOT route through getCompletedFinalizationBlock) yields present=false and is held/settled
1562
+ // rather than fired. A genuinely-finished turn (adapter idle, no pending) is unaffected — the
1563
+ // gate stays open and the completion fires exactly as before. This mirrors the established
1564
+ // `completionHasFinalAssistantMessage(...) && !hasAdapterPendingResponse()` pairing already
1565
+ // used by the no-progress monitor reconcile path.
1566
+ const turnClosed = !this.hasAdapterPendingResponse();
1567
+ if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
1568
+ return {
1569
+ present: turnClosed,
1570
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1571
+ source: 'parsed',
1572
+ };
1573
+ }
1574
+
1575
+ const externalMessages = this.readExternalCompletionMessages();
1576
+ if (externalMessages) {
1577
+ return {
1578
+ present: turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1579
+ messages: externalMessages,
1580
+ source: 'external-native',
1581
+ };
1582
+ }
1583
+
1584
+ return {
1585
+ present: false,
1586
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1587
+ source: 'unavailable',
1588
+ };
1589
+ }
1590
+
1591
+ private completionFinalSummary(parsedMessages: unknown, turnStartedAt?: number): string | undefined {
1592
+ // For native-source providers (claude-cli: chatMessagesOwnedExternally), the PTY
1593
+ // screen parse is NOT the source of truth for the final summary — the terminal
1594
+ // wraps/scrolls/clips text, so a screen-parsed assistant message is often a partial
1595
+ // prefix (e.g. 76 chars of a 112-char turn). The append-only native transcript holds
1596
+ // the complete turn. Prefer it whenever it yields a longer/complete summary; fall back
1597
+ // to the parsed screen only when the transcript is unavailable. This is the real cause
1598
+ // of the truncated finalSummary — independent of cloud vs standalone (it surfaces on
1599
+ // any short, fast-completing task where screen parse wins the race).
1600
+ //
1601
+ // NOTIF Defect-B: `turnStartedAt` (the producing turn's start, snapshotted on the
1602
+ // completedDebouncePending record) turn-scopes the NATIVE transcript read. The native
1603
+ // transcript holds the WHOLE session filtered only by session start, so a debounce that
1604
+ // flushes before the producing turn's final assistant bubble has landed would otherwise
1605
+ // return the PRIOR task's last bubble (event taskId=B but summary=A). Filtering to bubbles
1606
+ // at/after turnStartedAt yields '' in that race instead of the stale tail; the weak/empty
1607
+ // summary is later upgraded by the mesh reconcile loop once the real bubble is written.
1608
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1609
+ // FALSE-IDLE Defect 1b: turn-scope the PARSED screen fallback too. Without this a stale
1610
+ // mid-turn assistant (predating turnStartedAt) that the turn-boundary gate already
1611
+ // rejected as evidence could still leak into the finalSummary via this parsed fallback
1612
+ // when the external transcript's turn-scoped read is empty — freezing the very stale text
1613
+ // the gate rejected. extractFinalSummaryFromMessagesAfter drops bubbles before the turn
1614
+ // start; with no boundary known (turnStartedAt falsy) it is identical to the unscoped read.
1615
+ const parsedSummary = extractFinalSummaryFromMessagesAfter(
1616
+ (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)
1617
+ ? (Array.isArray(parsedMessages) ? parsedMessages : [])
1618
+ : []) as any,
1619
+ turnStartedAt,
1620
+ );
1621
+ if (adapterOwnsMessagesElsewhere) {
1622
+ const externalMessages = this.readExternalCompletionMessages();
1623
+ // Turn-scope the external transcript: never return a bubble produced before this
1624
+ // turn started. With no boundary known (turnStartedAt falsy) behaviour is unchanged.
1625
+ const externalSummary = externalMessages
1626
+ ? extractFinalSummaryFromMessagesAfter(externalMessages as any, turnStartedAt)
1627
+ : '';
1628
+ // The transcript is authoritative for native-source providers. Use it unless it is
1629
+ // empty (not yet written, or no in-turn bubble) — only then fall back to the screen
1630
+ // parse, which reflects the LIVE screen (this turn's output), not the stale tail.
1631
+ if (externalSummary) return externalSummary;
1632
+ return parsedSummary || undefined;
1633
+ }
1634
+ return parsedSummary || undefined;
1635
+ }
1636
+
1637
+ private buildCompletedFinalizationDiagnostic(args: {
1638
+ blockReason: string;
1639
+ latestStatus?: any;
1640
+ latestVisibleStatus: string;
1641
+ waitedMs: number;
1642
+ pending: CompletedDebouncePending;
1643
+ emittedAfterFinalizationTimeout: boolean;
1644
+ }): Record<string, unknown> {
1645
+ let parsed: any = null;
1646
+ let parseError: string | undefined;
1647
+ try {
1648
+ parsed = this.adapter.getScriptParsedStatus();
1649
+ } catch (error: any) {
1650
+ parseError = error?.message || String(error);
1651
+ }
1652
+
1653
+ const evidence = this.completionFinalAssistantEvidence(parsed?.messages);
1654
+ if (evidence.source === 'external-native') {
1655
+ this.recordPendingTranscriptProbe(args.pending);
1656
+ }
1657
+ const visibleMessages = (Array.isArray(evidence.messages) ? evidence.messages : [])
1658
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1659
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1660
+ const lastVisibleRole = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null;
1661
+ const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
1662
+ const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
1663
+
1664
+ return {
1665
+ providerType: this.type,
1666
+ sessionId: this.instanceId,
1667
+ providerSessionId: this.providerSessionId || null,
1668
+ workspace: this.workingDir,
1669
+ blockReason: args.blockReason,
1670
+ emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
1671
+ waitedMs: args.waitedMs,
1672
+ maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
1673
+ adapterStatus: typeof args.latestStatus?.status === 'string' ? args.latestStatus.status : null,
1674
+ latestVisibleStatus: args.latestVisibleStatus,
1675
+ parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
1676
+ parseError: parseError || undefined,
1677
+ finalAssistantPresent: evidence.present,
1678
+ finalAssistantEvidenceSource: evidence.source,
1679
+ visibleMessageCount: visibleMessages.length,
1680
+ lastVisibleRole,
1681
+ lastVisibleKind,
1682
+ lastVisibleContentLength,
1683
+ pendingStartedAt: this.generatingStartedAt || null,
1684
+ pendingFirstObservedAt: args.pending.firstObservedAt,
1685
+ pendingTimestamp: args.pending.timestamp,
1686
+ pendingDurationSec: args.pending.duration,
1687
+ previousBlockReason: args.pending.loggedBlockReason || null,
1688
+ transcriptProbeHistory: args.pending.transcriptProbeHistory || [],
1689
+ };
744
1690
  }
745
1691
 
746
1692
  private hasAdapterPendingResponse(): boolean {
@@ -765,41 +1711,268 @@ export class CliProviderInstance implements ProviderInstance {
765
1711
  if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
766
1712
  if (adapterRawStatus !== 'idle') return false;
767
1713
  if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
768
- return !this.hasAdapterPendingResponse();
1714
+ if (this.hasAdapterPendingResponse()) return false;
1715
+ // Do not suppress when the adapter's raw response buffer is still non-empty.
1716
+ // This catches the case where isWaitingForResponse has already flipped to false
1717
+ // (so getPartialResponse() returns '') but the provider's native parser still
1718
+ // reports generating because it's parsing buffered content. Suppressing the
1719
+ // finalization block here would emit a false completion event while the provider
1720
+ // session is still actively processing its response stream.
1721
+ const adapterAny = this.adapter as any;
1722
+ if (typeof adapterAny?.responseBuffer === 'string' && adapterAny.responseBuffer.trim()) return false;
1723
+ return true;
769
1724
  }
770
1725
 
771
- private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
- if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
1726
+ private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1727
+ if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
773
1728
 
774
1729
  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';
1730
+ const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1731
+ // (FALSEIDLE-a FixA) The adapter pending-response checks run UNCONDITIONALLY.
1732
+ // Previously they were SKIPPED when approvalResolvedIdle, on the assumption that
1733
+ // a waiting_approval→idle transition proved the approval's turn was over. But
1734
+ // auto-approve RESOLVES the modal and the agent RESUMES the same turn — currentTurnScope
1735
+ // / isWaitingForResponse stay set, or a tool runs — so skipping the guard let the FIRST
1736
+ // assistant bubble of the still-running turn be mistaken for the last and fired an early
1737
+ // completion the coordinator could never correct (RCA case a). Keep the guard live for the
1738
+ // approval path too: when the resumed turn genuinely ends these clear and the completion
1739
+ // fires. Approval-resolved holds are NON-terminal (bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS)
1740
+ // so a provider that never closes its turn-scope still force-fires a weak completion rather
1741
+ // than wedging; the non-approval path keeps its terminal hold (a genuinely-busy adapter must
1742
+ // never force a completion out).
1743
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: !approvalResolvedIdle };
1744
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: !approvalResolvedIdle };
1745
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: !approvalResolvedIdle };
777
1746
 
778
1747
  const partial = typeof this.adapter.getPartialResponse === 'function'
779
1748
  ? this.adapter.getPartialResponse()
780
1749
  : '';
781
- if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
1750
+ if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
782
1751
 
783
1752
  let parsed: any;
784
1753
  try {
785
1754
  parsed = this.adapter.getScriptParsedStatus();
786
1755
  } catch (error: any) {
787
- return `parse_error:${error?.message || String(error)}`;
1756
+ return { reason: `parse_error:${error?.message || String(error)}` };
788
1757
  }
789
1758
 
790
1759
  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';
1760
+ if (parsedStatus !== 'idle') {
1761
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
1762
+ if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
1763
+ return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
1764
+ }
1765
+ if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
1766
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1767
+ // FALSE-IDLE turn-boundary evidence (Defect 1b): turn-scope the present-check so a
1768
+ // STALE mid-turn assistant (predating pending.turnStartedAt) cannot satisfy the
1769
+ // finalization gate. Only the confirming final-assistant bubble that POST-DATES this
1770
+ // turn's start counts as evidence the turn genuinely ended.
1771
+ const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages, pending.turnStartedAt);
1772
+ const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1773
+ LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1774
+ if (!finalAssistantEvidence.present) {
1775
+ if (adapterOwnsMessagesElsewhere) {
1776
+ if (finalAssistantEvidence.source === 'external-native') {
1777
+ const probe = this.recordPendingTranscriptProbe(pending);
1778
+ if (probe && !pending.loggedTranscriptProbe) {
1779
+ 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`);
1780
+ pending.loggedTranscriptProbe = true;
1781
+ }
1782
+ LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
1783
+ if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
1784
+ return null;
1785
+ }
1786
+ if (this.type === 'antigravity-cli') {
1787
+ return null;
1788
+ }
1789
+ // (SETTLE-VALLEY) The inter-approval idle valley: a native-history mesh worker
1790
+ // that resolved an approval and fell briefly idle (waiting_approval→idle) BEFORE
1791
+ // the next approval turn resumes. The live valley (~3s) is mostly covered by the
1792
+ // 4000ms NATIVE_HISTORY_MESH_IDLE_SETTLE_MS settle window, but a longer valley can
1793
+ // still let the flush run while the transcript's final assistant turn is not yet
1794
+ // written (source still the screen parse → finalAssistantPresent=false,
1795
+ // workerResult.source='default'). CANON-C would emit immediately here, freezing a
1796
+ // truncated preamble summary as evidenceLevel=insufficient. Instead HOLD: this
1797
+ // waiting_approval hold complements the settle window. Retry until the transcript finalizes
1798
+ // (block clears → genuine emit) or the worker resumes (resume guard cancels),
1799
+ // bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS. Scoped to the approval-resolved
1800
+ // idle so a genuinely-finished background-child turn keeps the CANON-C immediate
1801
+ // emit (its transcript trails by a write, not by a whole resume).
1802
+ if (allowMissingAssistantTimeout && pending.previousStatus === 'waiting_approval') {
1803
+ return { reason: 'missing_final_assistant', terminal: false, holdForTranscript: true };
1804
+ }
1805
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1806
+ }
1807
+ if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
1808
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1809
+ }
1810
+ } else {
1811
+ LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
1812
+ return {
1813
+ reason: 'missing_final_assistant',
1814
+ terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
1815
+ allowTimeout: allowMissingAssistantTimeout,
1816
+ };
1817
+ }
1818
+ }
1819
+
1820
+ // (FALSEIDLE-a) Structural approval-resolution gate. Runs BEFORE the brittle
1821
+ // screen-text heuristic below so it also catches modals whose text does not match
1822
+ // looksLikeActiveApprovalPromptText (e.g. claude-cli's cd / "untrusted hooks" prompt).
1823
+ const approvalResolutionBlock = this.approvalResolutionFinalizationBlock(pending);
1824
+ if (approvalResolutionBlock) return approvalResolutionBlock;
1825
+
1826
+ // Guard: if the screen still shows an approval/choice prompt as the last visible text,
1827
+ // the turn is not complete even if the parsed status says idle and there is an assistant
1828
+ // message. This catches the case where waiting_approval→idle transitions occur before
1829
+ // the modal has been resolved (e.g. the PTY rendered the prompt but no button press fired).
1830
+ try {
1831
+ const screenText = typeof (this.adapter as any).getScreenText === 'function'
1832
+ ? String((this.adapter as any).getScreenText() || '')
1833
+ : '';
1834
+ if (screenText) {
1835
+ const tailLines = screenText.split(/\r?\n/).slice(-16).join('\n');
1836
+ if (looksLikeActiveApprovalPromptText(tailLines)) {
1837
+ return { reason: 'screen_shows_approval_prompt', terminal: approvalResolvedIdle };
1838
+ }
1839
+ }
1840
+ } catch { /* defensive: screen text read is best-effort */ }
794
1841
 
795
1842
  return null;
796
1843
  }
797
1844
 
1845
+ // (FALSEIDLE-a) Positive, structural proof that the latest approval entry was resolved
1846
+ // through ADHDev. resolveModal() — driven by auto-approve, dashboard/mesh_approve, and
1847
+ // dev-cli-debug alike — advances the engine's lastResolvedEntrySeq to the current
1848
+ // approvalEntrySeq. So `lastResolvedEntrySeq >= approvalEntrySeq` (with a real entry,
1849
+ // approvalEntrySeq > 0) means the modal we last saw was actually answered. Absence of this
1850
+ // evidence after a waiting_approval→idle transition means the idle is suspect: the spec's
1851
+ // text-based approval→idle rule false-tripped while the modal is still unresolved.
1852
+ // Fails OPEN (returns true) when the seq fields are unavailable, so the gate can never wedge
1853
+ // a session on a provider/adapter that does not surface the counters.
1854
+ private hasApprovalResolutionEvidence(): boolean {
1855
+ try {
1856
+ const status = this.adapter.getStatus({ allowParse: false }) as any;
1857
+ const entrySeq = typeof status?.approvalEntrySeq === 'number' ? status.approvalEntrySeq : 0;
1858
+ if (entrySeq <= 0) return true;
1859
+ const resolvedSeq = typeof status?.lastResolvedEntrySeq === 'number' ? status.lastResolvedEntrySeq : undefined;
1860
+ if (resolvedSeq === undefined) return true;
1861
+ return resolvedSeq >= entrySeq;
1862
+ } catch {
1863
+ return true;
1864
+ }
1865
+ }
1866
+
1867
+ // (FALSEIDLE-a) Hold a completion that is the anomalous DIRECT waiting_approval→idle
1868
+ // transition with no positive resolution evidence. A genuinely resolved approval routes
1869
+ // through resolveModal → setStatus('generating'), so its completion's previousStatus is
1870
+ // 'generating' (not 'waiting_approval') and this gate never fires for it. Scoped to
1871
+ // delegated mesh/coordinator sessions — whose only modal-resolution path is auto-approve /
1872
+ // mesh_approve (both advance lastResolvedEntrySeq) — so an interactive local session, where
1873
+ // a human may answer the PTY prompt directly and leave no resolveModal record, is untouched.
1874
+ // Non-terminal: the hold is bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS (30s), giving a
1875
+ // settling auto-approve time to fire and advance the seq, and guaranteeing no permanent wedge
1876
+ // if resolution ever happens via a path that does not record evidence.
1877
+ private approvalResolutionFinalizationBlock(pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1878
+ if (pending.previousStatus !== 'waiting_approval') return null;
1879
+ const meshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1880
+ if (!meshContext) return null;
1881
+ if (this.hasApprovalResolutionEvidence()) return null;
1882
+ return { reason: 'approval_resolution_unconfirmed', terminal: false };
1883
+ }
1884
+
798
1885
  private scheduleCompletedDebounceFlush(delayMs: number): void {
799
1886
  if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
800
1887
  this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
801
1888
  }
802
1889
 
1890
+ // EVTTRACE (observation-only): is this a mesh worker session whose completion
1891
+ // events must route to a coordinator? Used purely to gate trace logging so a
1892
+ // non-mesh CLI session's completions don't add EvtTrace noise. No decision logic.
1893
+ private isMeshWorkerSession(): boolean {
1894
+ return !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId
1895
+ || this.settings.meshNodeId || this.settings.launchedByCoordinator);
1896
+ }
1897
+
1898
+ // FALSE-IDLE (self-coordinator settle): an autonomously-progressing mesh session
1899
+ // is either a delegated worker (isMeshWorkerSession) OR the coordinator's OWN
1900
+ // claude-cli session (meshCoordinatorFor). Both run auto-approved tool turns whose
1901
+ // inter-approval valley (busy→idle blip→generating re-entry ~0.5s later) must be
1902
+ // absorbed by the completedDebounce settle window, not flushed on the first idle
1903
+ // sample. The worker branch already gets NATIVE_HISTORY_MESH_IDLE_SETTLE_MS; the
1904
+ // self-coordinator session (worker markers absent, meshCoordinatorFor present) was
1905
+ // taking flushDelay=0 — no settle window — so its busyEpoch/lastOutputAt continuity
1906
+ // guard had no window to observe the valley and fired mid-turn "next-step" previews
1907
+ // as a finalSummary. Mirrors the isAutonomousMeshSession notion in isTransientToolConsent.
1908
+ private isAutonomousMeshSession(): boolean {
1909
+ return this.isMeshWorkerSession() || !!this.settings.meshCoordinatorFor;
1910
+ }
1911
+
1912
+ /**
1913
+ * ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
1914
+ * Prefers the per-turn binding (engine.currentTurnTaskId, set when the turn was
1915
+ * submitted and surviving until the next turn starts) over the last-write-wins
1916
+ * session scalar (settings.meshActiveTaskId). The scalar is retained only as a
1917
+ * backward-compat alias for the "current/last assignment" and is the source of the
1918
+ * NOTIF-MISDELIVER / TASK-MSG-MISROUTE race: a second task attaching before this
1919
+ * turn completes overwrites it. Returns undefined for a non-task ad-hoc turn.
1920
+ */
1921
+ private completingTurnTaskId(): string | undefined {
1922
+ const turnTaskId = this.adapter?.currentTurnTaskId;
1923
+ if (typeof turnTaskId === 'string' && turnTaskId.trim()) return turnTaskId;
1924
+ const scalar = this.settings.meshActiveTaskId;
1925
+ return typeof scalar === 'string' && scalar.trim() ? scalar : undefined;
1926
+ }
1927
+
1928
+ // EVTTRACE correlation context for this session's completion lifecycle. taskId is
1929
+ // the primary grep anchor; instanceId is the session fallback.
1930
+ private meshTraceCtx(event = 'agent:generating_completed'): Record<string, unknown> {
1931
+ return {
1932
+ // ARCH-REFACTOR R1: trace the per-turn taskId (falling back to the scalar) so
1933
+ // EvtTrace anchors on the same id the completion event actually carries.
1934
+ taskId: this.completingTurnTaskId(),
1935
+ sessionId: this.instanceId,
1936
+ nodeId: this.settings.meshNodeId,
1937
+ meshId: this.settings.meshNodeFor,
1938
+ event,
1939
+ };
1940
+ }
1941
+
1942
+ // COMPLETION-EARLYNOTIFY instrumentation. A session-keyed FSM-transition +
1943
+ // completion-gate snapshot recorded into the shared debug-trace ring buffer
1944
+ // (secret-safe, length/role/pattern-name only — never screen or bubble text).
1945
+ // Retrieved via getRecentDebugTrace (chat_debug_bundle). Both categories are a
1946
+ // no-op unless collectDebugTrace is on AND the category is selected, so the
1947
+ // hot-path guards below (completionTraceOn / fsmTraceOn) keep production cost
1948
+ // at a single boolean check.
1949
+ private completionTraceOn(): boolean {
1950
+ return shouldCollectTraceCategory('completion-gate');
1951
+ }
1952
+ private fsmTraceOn(): boolean {
1953
+ return shouldCollectTraceCategory('fsm-transition');
1954
+ }
1955
+ private recordCompletionGateTrace(stage: string, payload: Record<string, unknown>): void {
1956
+ recordDebugTrace({
1957
+ category: 'completion-gate',
1958
+ stage,
1959
+ level: 'debug',
1960
+ sessionId: this.instanceId,
1961
+ providerType: this.type,
1962
+ payload,
1963
+ });
1964
+ }
1965
+ private recordFsmTransitionTrace(payload: Record<string, unknown>): void {
1966
+ recordDebugTrace({
1967
+ category: 'fsm-transition',
1968
+ stage: 'transition',
1969
+ level: 'debug',
1970
+ sessionId: this.instanceId,
1971
+ providerType: this.type,
1972
+ payload,
1973
+ });
1974
+ }
1975
+
803
1976
  private flushCompletedDebounceIfFinalized(): void {
804
1977
  const pending = this.completedDebouncePending;
805
1978
  if (!pending) {
@@ -809,97 +1982,664 @@ export class CliProviderInstance implements ProviderInstance {
809
1982
 
810
1983
  const latestStatus = this.adapter.getStatus({ allowParse: false });
811
1984
  const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
812
- const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
1985
+ const latestVisibleStatus = latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status;
1986
+ 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
1987
  if (latestVisibleStatus !== 'idle') {
814
1988
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
1989
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
1990
+ blockReason: 'resumed_status',
1991
+ latestVisibleStatus,
1992
+ previousStatus: pending.previousStatus,
1993
+ busyEpochAtArm: pending.busyEpochAtArm,
1994
+ busyEpoch: this.busyEpoch,
1995
+ });
1996
+ this.completedDebouncePending = null;
1997
+ this.completedDebounceTimer = null;
1998
+ return;
1999
+ }
2000
+
2001
+ // FALSE-IDLE continuity guard (Defect 1a): the point-sample above only proves the
2002
+ // session is idle at THIS instant. A momentary busy→idle blip inside an inter-approval
2003
+ // valley (auto-approved tool turns) opens AND closes a generating phase entirely within
2004
+ // the settle window — so the single sample reads 'idle' even though the turn is still in
2005
+ // flight (it re-enters generating ~0.5s later). Require instead that the session stayed
2006
+ // CONTINUOUSLY idle since the debounce was armed: (1) no entry into a busy phase
2007
+ // (busyEpoch unchanged), and (2) no new raw PTY output (lastOutputAt did not advance).
2008
+ // Either signal ⇒ the idle was not continuous ⇒ cancel; the still-live turn re-arms its
2009
+ // own completion when it genuinely finishes. This only ever cancels (never emits more),
2010
+ // so shared behaviour for claude/codex/antigravity is strictly stricter, never looser.
2011
+ if (typeof pending.busyEpochAtArm === 'number' && this.busyEpoch !== pending.busyEpochAtArm) {
2012
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (busy re-entry during settle: epoch ${pending.busyEpochAtArm}→${this.busyEpoch})`);
2013
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
2014
+ blockReason: 'busy_reentry',
2015
+ latestVisibleStatus,
2016
+ previousStatus: pending.previousStatus,
2017
+ busyEpochAtArm: pending.busyEpochAtArm,
2018
+ busyEpoch: this.busyEpoch,
2019
+ busyEpochDelta: this.busyEpoch - pending.busyEpochAtArm,
2020
+ });
2021
+ this.completedDebouncePending = null;
2022
+ this.completedDebounceTimer = null;
2023
+ return;
2024
+ }
2025
+ const latestOutputAt = typeof (latestStatus as any)?.lastOutputAt === 'number' ? (latestStatus as any).lastOutputAt as number : undefined;
2026
+ if (typeof pending.lastOutputAtArm === 'number'
2027
+ && typeof latestOutputAt === 'number'
2028
+ && latestOutputAt > pending.lastOutputAtArm) {
2029
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (new PTY output during settle: ${pending.lastOutputAtArm}→${latestOutputAt})`);
2030
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('cancel', {
2031
+ blockReason: 'new_pty_output',
2032
+ latestVisibleStatus,
2033
+ previousStatus: pending.previousStatus,
2034
+ lastOutputAtArm: pending.lastOutputAtArm,
2035
+ lastOutputAt: latestOutputAt,
2036
+ lastOutputAtDelta: latestOutputAt - pending.lastOutputAtArm,
2037
+ });
815
2038
  this.completedDebouncePending = null;
816
2039
  this.completedDebounceTimer = null;
817
2040
  return;
818
2041
  }
819
2042
 
820
- const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
- if (blockReason) {
2043
+ const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
2044
+ if (block) {
2045
+ const blockReason = block.reason;
822
2046
  const waitedMs = Date.now() - pending.firstObservedAt;
823
- if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
2047
+ // CANON-C (completion-gate decouple): a block carrying `allowTimeout` is the
2048
+ // transcript-evidence gate — the worker FSM has ALREADY reached idle and the only
2049
+ // thing missing is the append-only transcript's final assistant turn (a native-source
2050
+ // race: claude-cli owns its history externally and the file write trails the idle
2051
+ // transition). `allowTimeout` is set ONLY on the missing_final_assistant block, and
2052
+ // ONLY for mesh worker sessions (meshNodeFor / meshActiveTaskId / launchedByCoordinator).
2053
+ // The coordinator's sole path to learn this session is idle is agent:generating_completed,
2054
+ // so holding it up to COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) leaves the coordinator
2055
+ // false-generating while the worker is done. Decouple the idle NOTIFICATION from the
2056
+ // transcript evidence: emit the completion immediately, marked weak
2057
+ // (completionDiagnostic.blockReason=missing_final_assistant, finalAssistantPresent=false).
2058
+ // The finalSummary is enriched on a SEPARATE path — the mesh reconcile loop reads the
2059
+ // transcript once written and re-emits a GENUINE completion (CANON-B weak→genuine
2060
+ // upgrade; buildPendingEventFingerprint keeps weak and genuine distinct so the enriched
2061
+ // one still surfaces, and isFalseIdleCompletion keeps the direct dispatch active until
2062
+ // then). All OTHER blocks (genuinely-busy adapter/partial/parsed states, transient
2063
+ // parse_error) keep the existing terminal-hold / 30s-retry behavior unchanged.
2064
+ //
2065
+ // (SETTLE-VALLEY) Exception: a `holdForTranscript` block is the inter-approval idle
2066
+ // valley of a native-history mesh worker (waiting_approval→idle that will resume into
2067
+ // the next approval). It deliberately does NOT carry allowTimeout, so it falls into the
2068
+ // hold-and-retry path below (terminal:false) rather than the CANON-C immediate emit —
2069
+ // the retry loop re-runs the resume guard each cycle, so when the worker resumes the
2070
+ // pending completion is cancelled, and when the transcript's final assistant arrives the
2071
+ // block clears for a GENUINE emit. This blocks the truncated weak (insufficient) summary
2072
+ // from ever being emitted during the valley, without depending on the valley's length.
2073
+ const isTranscriptEvidenceGate = block.allowTimeout === true;
2074
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} allowTimeout=${isTranscriptEvidenceGate} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
2075
+ if (!isTranscriptEvidenceGate && (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS)) {
824
2076
  if (pending.loggedBlockReason !== blockReason) {
825
2077
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
2078
+ // EVTTRACE: completion held by the finalization gate (CANON-C). Observation
2079
+ // only — does not change the hold decision above.
2080
+ if (this.isMeshWorkerSession()) {
2081
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
2082
+ }
2083
+ // COMPLETION-EARLYNOTIFY: a hold is the CORRECT outcome when the turn is not
2084
+ // yet proven done (the FixA/FixB gates route here); trace it so an early-notify
2085
+ // investigation can see the gate holding rather than firing.
2086
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
2087
+ blockReason,
2088
+ latestVisibleStatus,
2089
+ terminal: block.terminal === true,
2090
+ holdForTranscript: block.holdForTranscript === true,
2091
+ approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
2092
+ waitedMs,
2093
+ });
826
2094
  pending.loggedBlockReason = blockReason;
827
2095
  }
828
2096
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
2097
  return;
830
2098
  }
831
- LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
2099
+ const emittedAfterFinalizationTimeout = waitedMs >= COMPLETED_FINALIZATION_MAX_WAIT_MS;
2100
+ const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
2101
+ blockReason,
2102
+ latestStatus,
2103
+ latestVisibleStatus,
2104
+ waitedMs,
2105
+ pending,
2106
+ emittedAfterFinalizationTimeout,
2107
+ });
2108
+ // Surface the CANON-C immediate-emit path distinctly so a delegated worker's idle
2109
+ // notification (transcript still pending) is not mistaken for a 30s-timeout fallback.
2110
+ (completionDiagnostic as Record<string, unknown>).decoupledImmediateEmit = isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout;
2111
+ LOG.warn('CLI', `[${this.type}] emitting completed event (${isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'CANON-C decoupled-immediate, transcript pending' : `after ${waitedMs}ms`}) without finalized assistant turn (${blockReason})`);
2112
+ // EVTTRACE: completion fired (forced past the finalization timeout / CANON-C decoupled-immediate).
2113
+ if (this.isMeshWorkerSession()) {
2114
+ traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
2115
+ }
2116
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
2117
+ path: isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'canon_c_decoupled' : 'forced_timeout',
2118
+ blockReason,
2119
+ latestVisibleStatus,
2120
+ approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
2121
+ finalAssistantPresent: (completionDiagnostic as any).finalAssistantPresent === true,
2122
+ evidenceSource: (completionDiagnostic as any).finalAssistantEvidenceSource ?? null,
2123
+ lastVisibleRole: (completionDiagnostic as any).lastVisibleRole ?? null,
2124
+ lastVisibleContentLen: (completionDiagnostic as any).lastVisibleContentLength ?? null,
2125
+ emittedAfterFinalizationTimeout,
2126
+ waitedMs,
2127
+ busyEpoch: this.busyEpoch,
2128
+ });
2129
+ this.pushEvent({
2130
+ event: 'agent:generating_completed',
2131
+ chatTitle: pending.chatTitle,
2132
+ duration: pending.duration,
2133
+ timestamp: pending.timestamp,
2134
+ // ARCH-REFACTOR R1: attribute to the turn captured at idle-transition.
2135
+ ...(pending.taskId ? { taskId: pending.taskId } : {}),
2136
+ // When finalization is forced past the timeout on a `parsed_status:` block
2137
+ // (the parser never confirmed a final assistant turn) we previously rode an
2138
+ // empty `finalSummary` unconditionally. That empty value propagates to the
2139
+ // mesh coordinator's mirror preview (meshSessionLastMessagePreview), leaving a
2140
+ // delegated session's inbox preview blank — or, for a LOCAL worktree session,
2141
+ // stuck on the dispatched user task. If the parser DID surface assistant text,
2142
+ // prefer it; only fall back to '' when no assistant summary can be derived.
2143
+ finalSummary: blockReason.startsWith('parsed_status:')
2144
+ ? (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) ?? '')
2145
+ : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
2146
+ completionDiagnostic,
2147
+ });
832
2148
  this.completedDebouncePending = null;
833
2149
  this.completedDebounceTimer = null;
834
2150
  this.generatingStartedAt = 0;
2151
+ this.lastApprovalEventFingerprint = '';
835
2152
  return;
836
2153
  }
837
2154
 
838
2155
  LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
2156
+ // EVTTRACE: completion fired (transcript finalized cleanly).
2157
+ if (this.isMeshWorkerSession()) {
2158
+ traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
2159
+ }
2160
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('fire', {
2161
+ path: 'clean',
2162
+ latestVisibleStatus,
2163
+ approvalResolvedIdle: pending.previousStatus === 'waiting_approval',
2164
+ finalAssistantPresent: true,
2165
+ duration: pending.duration,
2166
+ busyEpoch: this.busyEpoch,
2167
+ });
839
2168
  this.pushEvent({
840
2169
  event: 'agent:generating_completed',
841
2170
  chatTitle: pending.chatTitle,
842
2171
  duration: pending.duration,
843
2172
  timestamp: pending.timestamp,
844
- finalSummary: extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages),
2173
+ // ARCH-REFACTOR R1: attribute to the turn captured at idle-transition.
2174
+ ...(pending.taskId ? { taskId: pending.taskId } : {}),
2175
+ finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
845
2176
  });
846
2177
  this.completedDebouncePending = null;
847
2178
  this.completedDebounceTimer = null;
848
2179
  this.generatingStartedAt = 0;
2180
+ this.lastApprovalEventFingerprint = '';
849
2181
  }
850
2182
 
851
2183
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
2184
+ // Manual-attendance suppression (provider-common): when a human is
2185
+ // actively driving this session from the dashboard, hold auto-approve so
2186
+ // the modal stays visible and they can pick a button / use the controlbar
2187
+ // themselves. Return false (NOT auto-approving) so getState keeps the
2188
+ // modal surfaced. Clear any in-progress settle gate — a genuine fire
2189
+ // after the window lapses must re-settle from scratch — and arm a
2190
+ // re-check for the lapse moment, because the PTY may have gone silent and
2191
+ // would otherwise never re-drive this decision. Background mesh workers
2192
+ // are never attended, so their delegated auto-approve is untouched.
2193
+ if (adapterStatus?.status === 'waiting_approval'
2194
+ && this.shouldAutoApprove()
2195
+ && this.manualAttendance.isAttended(now)) {
2196
+ this.lastAutoApprovalSignature = '';
2197
+ this.pendingAutoApprovalSignature = '';
2198
+ this.pendingAutoApprovalSince = 0;
2199
+ this.autoApproveInactiveSince = 0;
2200
+ // Manual attendance takes over — the modal stays surfaced (maybeAutoApproveStatus
2201
+ // returns false), so end the mask episode.
2202
+ this.autoApproveMaskSince = 0;
2203
+ this.stalledApprovalNudgeEpisode = 0;
2204
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
2205
+ this.autoApproveSettleTimer = setTimeout(() => {
2206
+ this.autoApproveSettleTimer = null;
2207
+ this.recheckAutoApproveSettled();
2208
+ }, this.manualAttendance.remainingMs(now) + 20);
2209
+ return false;
2210
+ }
852
2211
  const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
853
2212
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
854
2213
  // times while the PTY absorbs the approval key. Without this flag, repeated
855
2214
  // 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);
2215
+ // However, Claude Code can present a second approval immediately after the
2216
+ // first. Resolve a changed modal signature even while the previous write is
2217
+ // still inside the short busy window.
2218
+ if (!autoApproveActive) {
2219
+ this.lastAutoApprovalSignature = '';
2220
+ // Hysteresis: if a settle gate is mid-progress, a momentary
2221
+ // status!=waiting_approval blip (a generating flip while the same
2222
+ // modal's button block is still on screen) must NOT wipe the settle
2223
+ // clock otherwise the modal→generating→modal flap restarts the
2224
+ // 600ms window every time and auto-approve never fires. Keep the
2225
+ // gate warm for AUTO_APPROVE_GATE_HYSTERESIS_MS; re-arm a timer so
2226
+ // that if the modal does NOT come back the gate is cleared on the
2227
+ // re-check (a genuine resolution → idle frees the gate normally).
2228
+ if (this.pendingAutoApprovalSince) {
2229
+ if (!this.autoApproveInactiveSince) this.autoApproveInactiveSince = now;
2230
+ const goneForMs = now - this.autoApproveInactiveSince;
2231
+ if (goneForMs < CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS) {
2232
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
2233
+ this.autoApproveSettleTimer = setTimeout(() => {
2234
+ this.autoApproveSettleTimer = null;
2235
+ this.recheckAutoApproveSettled();
2236
+ }, CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS - goneForMs + 20);
2237
+ return autoApproveActive;
2238
+ }
2239
+ }
2240
+ // Clear the settle gate so the next approval starts its own quiet
2241
+ // window from scratch (a stale timestamp would let it fire instantly).
2242
+ this.pendingAutoApprovalSignature = '';
2243
+ this.pendingAutoApprovalSince = 0;
2244
+ this.autoApproveInactiveSince = 0;
2245
+ // Modal has genuinely been gone past the hysteresis window → the episode ended;
2246
+ // end the mask episode too (a later approval starts a fresh stall clock).
2247
+ this.autoApproveMaskSince = 0;
2248
+ this.stalledApprovalNudgeEpisode = 0;
2249
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
2250
+ return autoApproveActive;
2251
+ }
2252
+ // Active approval observed — reset the inactivity tracker so a later
2253
+ // blip starts its hysteresis window fresh.
2254
+ this.autoApproveInactiveSince = 0;
2255
+ // STATUS-MISMATCH: start (or keep) the mask-stall clock for this episode. Set ONLY
2256
+ // when zero so it survives modal-signature changes and hysteresis blips — it measures
2257
+ // the true age of the unresolved auto-approve, not the per-signature settle window.
2258
+ if (!this.autoApproveMaskSince) this.autoApproveMaskSince = now;
2259
+ // NOTIF-APPROVAL-MASKED (Q1b): once this episode has stalled past the mask threshold,
2260
+ // surface the raw waiting_approval to the mesh coordinator (decoupled from the dashboard
2261
+ // mask). Placed on the active-approval path here — the single choke point that owns the
2262
+ // mask-stall clock and is re-driven throughout a silent stall (getState heartbeat,
2263
+ // detectStatusTransition, recheckAutoApproveSettled). No-op until the stall threshold trips.
2264
+ this.maybeEmitStalledApprovalNudge(adapterStatus, now);
2265
+ const modal = adapterStatus.activeModal;
2266
+ // (fix) Do not auto-approve when no concrete modal/buttons are present.
2267
+ // Claude TUI flaps between paints; without this guard adapterStatus
2268
+ // could report status=waiting_approval with activeModal=null (or with
2269
+ // an empty buttons array briefly) and we'd still call
2270
+ // resolveModal(-1) — which used to type "1" into the prompt
2271
+ // repeatedly. Skip until a real modal is captured.
2272
+ const buttons = Array.isArray(modal?.buttons)
2273
+ ? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
2274
+ : [];
2275
+ if (!modal || buttons.length === 0) {
2276
+ return autoApproveActive;
2277
+ }
2278
+ // Picker/confirm exclusion (provider-common). A /model or /mode picker is
2279
+ // surfaced with status=waiting_approval so the dashboard shows it, but it
2280
+ // has no "correct" answer to auto-pick — blindly selecting the first
2281
+ // option silently switches the model (the "always Opus, before I even
2282
+ // choose" bug). Two independent gates, BOTH must pass to fire:
2283
+ //
2284
+ // (1) modal_kind — the spec/FSM tells us this is an 'approval' modal,
2285
+ // not a 'picker'/'confirm'. A modal whose kind is unknown (legacy
2286
+ // adapter, or a spec that predates modal_kind) reads as 'approval'
2287
+ // so genuine approvals keep auto-approving; only an explicit
2288
+ // 'picker'/'confirm' is excluded here.
2289
+ // (2) structural anchor — a real approval offers an affirmative AND a
2290
+ // decline option (pickApprovalButton finds a positive that isn't a
2291
+ // decline, and hasNegativeApprovalOption confirms a No/Cancel/Deny
2292
+ // is present). A model picker ("1. Default 2. Opus 3. Sonnet")
2293
+ // has no decline, so even an un-migrated picker is caught here.
2294
+ //
2295
+ // Mirrors the SDK v1 detect-status approval heuristic (detect-status.ts).
2296
+ const modalKind = typeof modal?.kind === 'string' ? modal.kind : 'approval';
2297
+ if (modalKind !== 'approval') {
2298
+ // Picker/confirm — leave it for the user; keep the modal surfaced.
2299
+ return autoApproveActive;
2300
+ }
2301
+ const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
2302
+ // Structural decline anchor. A real approval offers BOTH an affirmative
2303
+ // and a decline — but on a TALL Write/Edit diff the trailing "3. No"
2304
+ // scrolls off the captured frame, leaving only "1. Yes" + "2. Yes, allow
2305
+ // … this session" so hasNegativeApprovalOption reads false (#137). A
2306
+ // scoped grant-affirmative ("Yes, allow … during this session" / "…don't
2307
+ // ask again") ONLY appears in a genuine consent modal (never a picker),
2308
+ // so it stands in for the off-frame decline as a reliable second anchor.
2309
+ // Conservative by construction: a picker without a grant-scope option
2310
+ // still bails here, and the fire below still picks the plain allow-once
2311
+ // "Yes" via pickApprovalButton, not the broader grant.
2312
+ const hasReliableConsentAnchor = hasNegativeApprovalOption(buttons)
2313
+ || hasReliableApprovalAffirmative(buttons);
2314
+ if (buttonIndex < 0 || !hasReliableConsentAnchor) {
2315
+ // No affirmative matched, or no decline / reliable grant option present
2316
+ // (→ not a real consent prompt, e.g. a picker that slipped past the
2317
+ // kind gate). Surface the modal so the user decides; never pick blindly.
2318
+ return autoApproveActive;
869
2319
  }
2320
+ // Modal *identity* signature — the question/button set only, NO volatile
2321
+ // counters. This is what the settle gate tracks: the FSM bumps
2322
+ // approvalEntrySeq on every fresh waiting_approval entry, and a
2323
+ // modal→generating→modal flap (the question line scrolled out of the
2324
+ // captured frame while the button block stays) re-enters and bumps it
2325
+ // again. Folding that seq into the settle signature made the 600ms
2326
+ // settle clock restart on every flap, so the modal never stayed stable
2327
+ // long enough to fire — the gate was never satisfied. Identity excludes
2328
+ // the seq so button/seq flap of the SAME modal keeps one settle clock.
2329
+ const modalSignature = [
2330
+ typeof modal?.message === 'string' ? modal.message.trim() : '',
2331
+ buttons.join('|'),
2332
+ buttonIndex,
2333
+ ].join('::');
2334
+ // Busy-window re-entry guard still needs the seq: two DISTINCT
2335
+ // back-to-back approvals can carry identical message/buttons (common
2336
+ // with claude-cli). Without the seq their busy signatures collide and
2337
+ // the 5s busy-window guard below would swallow the second auto-approve,
2338
+ // leaving it stuck. The seq is bumped by the FSM on every fresh
2339
+ // waiting_approval entry, so a new approval always yields a new busy
2340
+ // signature and fires through.
2341
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
2342
+ ? adapterStatus.approvalEntrySeq
2343
+ : 0;
2344
+ const busySignature = `${approvalEntrySeq}::${modalSignature}`;
2345
+ // Already fired for this exact modal entry and still inside the busy
2346
+ // window — nothing to do (re-entry guard for repeated snapshots of one
2347
+ // modal).
2348
+ if (this.autoApproveBusy && busySignature === this.lastAutoApprovalSignature) {
2349
+ return autoApproveActive;
2350
+ }
2351
+
2352
+ // Settle gate: only fire once this modal identity has been stable for
2353
+ // AUTO_APPROVE_SETTLE_MS. A still-streaming prompt mutates its
2354
+ // message/buttons each frame → new identity → clock restarts, so we
2355
+ // never approve a half-rendered prompt (the "resolves too fast" bug).
2356
+ if (modalSignature !== this.pendingAutoApprovalSignature) {
2357
+ this.pendingAutoApprovalSignature = modalSignature;
2358
+ this.pendingAutoApprovalSince = now;
2359
+ }
2360
+ const settledForMs = now - this.pendingAutoApprovalSince;
2361
+ if (settledForMs < CliProviderInstance.AUTO_APPROVE_SETTLE_MS) {
2362
+ // Not yet settled. Arm a timer to re-check after the remaining quiet
2363
+ // window — the PTY may go silent once the prompt finishes painting,
2364
+ // so there is no guaranteed status-change frame to re-drive us.
2365
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
2366
+ this.autoApproveSettleTimer = setTimeout(() => {
2367
+ this.autoApproveSettleTimer = null;
2368
+ this.recheckAutoApproveSettled();
2369
+ }, CliProviderInstance.AUTO_APPROVE_SETTLE_MS - settledForMs + 20);
2370
+ return autoApproveActive;
2371
+ }
2372
+
2373
+ // Settled — fire the approve key.
2374
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
2375
+ this.autoApproveBusy = true;
2376
+ this.lastAutoApprovalSignature = busySignature;
2377
+ this.pendingAutoApprovalSignature = '';
2378
+ this.pendingAutoApprovalSince = 0;
2379
+ this.autoApproveInactiveSince = 0;
2380
+ // Fired (resolveModal in flight) — the episode resolved; end the mask-stall clock.
2381
+ this.autoApproveMaskSince = 0;
2382
+ this.stalledApprovalNudgeEpisode = 0;
2383
+ if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
2384
+ this.autoApproveBusyTimer = setTimeout(() => {
2385
+ this.autoApproveBusy = false;
2386
+ this.autoApproveBusyTimer = null;
2387
+ this.lastAutoApprovalSignature = '';
2388
+ }, 5000);
2389
+ this.recordAutoApproval(modal?.message, buttonLabel, now);
2390
+ setTimeout(() => {
2391
+ this.adapter.resolveModal(buttonIndex);
2392
+ }, 0);
870
2393
  return autoApproveActive;
871
2394
  }
872
2395
 
2396
+ /**
2397
+ * Re-drive the auto-approve check after the settle quiet window elapses.
2398
+ * The PTY may have gone silent once the approval prompt finished painting,
2399
+ * so no status-change frame is guaranteed to re-enter maybeAutoApproveStatus
2400
+ * — this timer-driven re-check picks up the now-settled modal and fires.
2401
+ * Deliberately lighter than detectStatusTransition(): it only re-evaluates
2402
+ * the approval decision; the next real PTY frame refreshes visible status.
2403
+ */
2404
+ private recheckAutoApproveSettled(): void {
2405
+ try {
2406
+ // APPROVAL Defect-C (auto-approve gap): re-probe with a LIVE parse, not the cached
2407
+ // engine snapshot. This timer is the ONLY re-drive when the PTY goes silent after the
2408
+ // approval prompt finishes painting (its whole reason to exist) — but with
2409
+ // allowParse:false it read only engine.activeModal, which the engine's per-frame settle
2410
+ // pass can leave null/stale when the modal arrived between writes. The re-check then saw
2411
+ // no modal and never fired, so the delegated worker's transient/quiet approval was missed
2412
+ // and the coordinator had to step in with a manual mesh_approve. allowParse:true makes
2413
+ // getStatus re-run runDetectStatus/runParseApproval on the current screen buffer (the same
2414
+ // live re-probe the coordinator's resolveAction path uses), recovering the modal so
2415
+ // auto-approve fires on its own. The half-rendered-frame guard (buttons.length===0) and
2416
+ // the settle gate in maybeAutoApproveStatus still protect against firing on a partial modal.
2417
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
2418
+ this.maybeAutoApproveStatus(adapterStatus, Date.now());
2419
+ } catch { /* adapter gone / transient — next frame retries */ }
2420
+ }
2421
+
2422
+ /**
2423
+ * Emit the queue-claim agent:ready event at most once per session. Both the
2424
+ * boot-time starting→idle one-shot and the fsmReadySeen re-arm call this; the
2425
+ * agentReadyEmitted guard ensures the second caller is a no-op so a worker is
2426
+ * never claimed twice and a queued task is never double-dispatched.
2427
+ */
2428
+ private emitAgentReadyOnce(chatTitle: string, now: number): void {
2429
+ if (this.agentReadyEmitted) return;
2430
+ this.agentReadyEmitted = true;
2431
+ this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
2432
+ }
2433
+
2434
+ /**
2435
+ * GENERATING-BOUNDARY synthetic emit for a first turn that started AND finished
2436
+ * inside the startup-grace window without the FSM ever observing a 'generating'
2437
+ * frame (an unobservably-fast turn). Synthesizes the agent:generating_started +
2438
+ * agent:generating_completed pair so the mesh coordinator learns the worker went
2439
+ * idle. Returns true iff it synthesized.
2440
+ *
2441
+ * Two callers share this defense line:
2442
+ * - 'startup_grace_fast_collapse' — the starting→idle transition (the FSM whose
2443
+ * spec lacks/hadn't-synced the starting→busy edge collapses starting→idle
2444
+ * directly, with no intervening 'generating' frame).
2445
+ * - 'startup_grace_idle_turn_collapse' — the idle→idle no-status-change poll
2446
+ * (the launch settle already drained starting→idle BEFORE the first turn, so
2447
+ * the turn runs+completes while status stays 'idle' the whole time and
2448
+ * detectStatusTransition never re-enters its change block — the live rc.403
2449
+ * Probe1 miss).
2450
+ *
2451
+ * False-positive safety (must NOT fire on a benign boot): currentTurnTaskId is
2452
+ * set ONLY by onTurnStarted (a real turn STARTED this boot) and persists past
2453
+ * completion, so it excludes a true idle boot (null) and a queued-pending first
2454
+ * turn that only runs after grace (onTurnStarted not yet called → null).
2455
+ * !hasAdapterPendingResponse() excludes a turn still mid-flight. generating
2456
+ * never armed (generatingStartedAt===0 && !generatingDebouncePending) means the
2457
+ * whole idle→busy→idle never happened for this turn. The once-per-turn guard
2458
+ * (fastCollapseSynthesizedTaskId) keeps the re-polled idle-stayed caller from
2459
+ * re-emitting every poll.
2460
+ */
2461
+ private maybeSynthesizeStartupGraceCollapse(
2462
+ chatTitle: string,
2463
+ now: number,
2464
+ reason: 'startup_grace_fast_collapse' | 'startup_grace_idle_turn_collapse',
2465
+ ): boolean {
2466
+ const startedTurnTaskId = typeof (this.adapter as any)?.currentTurnTaskId === 'string'
2467
+ && (this.adapter as any).currentTurnTaskId.trim()
2468
+ ? (this.adapter as any).currentTurnTaskId as string
2469
+ : undefined;
2470
+ const fastCollapsed = !!startedTurnTaskId
2471
+ && !this.hasAdapterPendingResponse()
2472
+ && !this.generatingStartedAt
2473
+ && !this.generatingDebouncePending;
2474
+ if (!fastCollapsed) return false;
2475
+ // Once-per-turn: the idle-stayed caller re-polls steadily while the session
2476
+ // sits idle; without this guard it would re-emit the pair every poll.
2477
+ if (this.fastCollapseSynthesizedTaskId === startedTurnTaskId) return false;
2478
+
2479
+ let fcFinalSummary: string | undefined;
2480
+ let fcEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
2481
+ try {
2482
+ const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
2483
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
2484
+ fcEvidenceSource = evidence.source;
2485
+ fcFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
2486
+ } catch { /* best-effort */ }
2487
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || fcEvidenceSource === 'external-native') && !fcFinalSummary;
2488
+ // Mirror the short-generating idle path's suppression: a provider that
2489
+ // requires a final assistant (or external-native history) with NO confirmed
2490
+ // summary and NO mesh context emits nothing — the session is idle with no
2491
+ // confirmed turn, matching startup-blip semantics. With mesh context we still
2492
+ // emit so the coordinator can apply its own timeout/retry logic. Left UNMARKED
2493
+ // so a later poll can retry once the transcript's final assistant lands.
2494
+ const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
2495
+ if (missingEvidence && !hasMeshContext) {
2496
+ LOG.info('CLI', `[${this.type}] ${reason} suppressed: missing final assistant evidence, no mesh context (source=${fcEvidenceSource})`);
2497
+ return false;
2498
+ }
2499
+ // Mark BEFORE emitting so a re-entrant poll cannot double-fire.
2500
+ this.fastCollapseSynthesizedTaskId = startedTurnTaskId;
2501
+ LOG.info('CLI', `[${this.type}] ${reason}: synthesizing started+completed (taskId=${startedTurnTaskId} source=${fcEvidenceSource} hadFinalSummary=${!!fcFinalSummary})`);
2502
+ // Retroactive started so the started→completed pair (and the chat bubble) is
2503
+ // well-formed; pushEvent stamps the per-turn taskId for CANON-B ack.
2504
+ this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now });
2505
+ if (this.isMeshWorkerSession()) {
2506
+ traceMeshEventStage('fired', this.meshTraceCtx(), `${reason} (source=${fcEvidenceSource})`);
2507
+ }
2508
+ // EARLYNOTIFY-GATEBYPASS (c): a startup-grace fast-collapse never OBSERVED the turn's
2509
+ // generating phase — its evidence is a plain transcript tail, never a self-attributing
2510
+ // final_summary_json — so this synth is TENTATIVE by default. Mark it WEAK
2511
+ // (evidenceLevel:'weak') so buildPendingEventFingerprint keys it `…::weak` and any later
2512
+ // genuine agent:generating_completed for the same task can still surface (CANON-B). The
2513
+ // stronger missing_final_assistant marker is preserved when there is also no summary.
2514
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('synth-fire', {
2515
+ path: 'startup_grace_fast_collapse',
2516
+ reason,
2517
+ evidenceSource: fcEvidenceSource,
2518
+ hadFinalSummary: !!fcFinalSummary,
2519
+ missingEvidence,
2520
+ evidenceLevel: 'weak',
2521
+ });
2522
+ this.pushEvent({
2523
+ event: 'agent:generating_completed',
2524
+ chatTitle,
2525
+ duration: 0,
2526
+ timestamp: now,
2527
+ finalSummary: fcFinalSummary,
2528
+ evidenceLevel: 'weak',
2529
+ completionDiagnostic: {
2530
+ reason,
2531
+ finalAssistantEvidenceSource: fcEvidenceSource,
2532
+ ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
2533
+ },
2534
+ });
2535
+ return true;
2536
+ }
2537
+
873
2538
  private detectStatusTransition(): void {
874
2539
  const now = Date.now();
875
2540
  // Status-change handling is a hot path: PTY output can fire it many times
876
2541
  // during long-running CLI sessions. Keep this path on adapter-owned light
877
2542
  // state only; rich provider parsing is reserved for getState/read_chat.
878
2543
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
2544
+ const adapterProviderSessionId = normalizeProviderSessionId(
2545
+ this.provider,
2546
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
2547
+ );
2548
+ if (adapterProviderSessionId) {
2549
+ this.promoteProviderSessionId(adapterProviderSessionId);
2550
+ }
879
2551
  const parsedStatus = null;
880
2552
  const rawStatus = adapterStatus.status;
881
2553
  const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
882
- const newStatus = autoApproveActive ? 'generating' : rawStatus;
883
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
2554
+ // During the autoApproveBusy window (2s after firing approval key), the PTY
2555
+ // can briefly report 'idle' before the next generating phase starts. Treat that
2556
+ // transient idle as 'generating' to suppress a spurious agent:generating_completed
2557
+ // push notification. The adapter's status is otherwise authoritative — native
2558
+ // transcript shape does NOT override the FSM's busy/idle decision.
2559
+ const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
2560
+ const newStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus;
2561
+ const dirName = workingDirBasename(this.workingDir);
884
2562
  const chatTitle = `${this.provider.name} · ${dirName}`;
885
2563
  const partial = this.adapter.getPartialResponse();
2564
+ // Liveness fingerprint for the no-progress watchdog. The parsed
2565
+ // assistant buffer (`partial`) alone goes static while a tool/build runs
2566
+ // — the assistant emits no tokens even though the PTY is actively
2567
+ // printing tool output — which made the watchdog false-fire a "stuck"
2568
+ // alert mid-turn. Fold in the adapter's raw-activity timestamps so any
2569
+ // visible terminal progress (lastScreenChangeAt) or raw PTY byte
2570
+ // (lastOutputAt) keeps the fingerprint moving. The watchdog then only
2571
+ // survives a genuine stall where nothing at all is happening.
886
2572
  const progressFingerprint = newStatus === 'generating'
887
- ? `${partial || ''}`.slice(-2000)
2573
+ ? `${`${partial || ''}`.slice(-2000)}::scr=${adapterStatus.lastScreenChangeAt ?? 0}::out=${adapterStatus.lastOutputAt ?? 0}`
888
2574
  : undefined;
889
2575
 
890
2576
  const previousStatus = this.lastStatus;
891
2577
  if (newStatus !== this.lastStatus) {
892
2578
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
893
- if (this.lastStatus === 'idle' && newStatus === 'generating') {
2579
+ // COMPLETION-EARLYNOTIFY: snapshot every FSM status transition (the arm/fire/cancel
2580
+ // decisions downstream all hang off these edges). Guarded so production pays only a
2581
+ // boolean check; payload carries the visibility/auto-approve flags and the continuity
2582
+ // clocks (busyEpoch / lastOutputAt / lastScreenChangeAt) that the completion gate reads.
2583
+ if (this.fsmTraceOn()) this.recordFsmTransitionTrace({
2584
+ from: this.lastStatus,
2585
+ to: newStatus,
2586
+ rawStatus,
2587
+ autoApproveActive,
2588
+ autoApproveHoldIdle,
2589
+ autoApproveBusy: this.autoApproveBusy,
2590
+ hasPending: this.hasAdapterPendingResponse(),
2591
+ busyEpoch: this.busyEpoch,
2592
+ lastOutputAt: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
2593
+ lastScreenChangeAt: typeof adapterStatus?.lastScreenChangeAt === 'number' ? adapterStatus.lastScreenChangeAt : null,
2594
+ });
2595
+ // GENERATING-MISSING (win32 fresh-worktree first-turn): a freshly-launched session
2596
+ // is in 'starting' until its startup-grace settles to idle. When the FIRST inject
2597
+ // lands inside that grace window, the adapter can report status DIRECTLY
2598
+ // starting → generating without an intervening 'idle' frame for
2599
+ // detectStatusTransition() to observe. Previously only the idle→generating arm
2600
+ // armed the bookkeeping, so a starting→generating frame fell straight through to the
2601
+ // bare `this.lastStatus = newStatus` update: generatingStartedAt stayed 0 and no
2602
+ // generating_started was queued. The fast turn's generating→idle completion was then
2603
+ // suppressed by the startup-blip guard below (generatingStartedAt===0 &&
2604
+ // !generatingDebouncePending) — so NO generating_started AND NO generating_completed
2605
+ // ever fired and the mesh coordinator never learned the worker went idle.
2606
+ //
2607
+ // We extend the idle→generating arm to also fire on starting→generating, BUT ONLY
2608
+ // when a real turn is in flight. The adapter script can also report 'generating' from
2609
+ // pure startup PTY noise (no task dispatched) — antigravity/codex/hermes-cli all
2610
+ // exercise that benign starting→generating→idle blip, which must NOT emit a
2611
+ // completion (see "startup-phase spurious completion suppression" tests).
2612
+ // hasAdapterPendingResponse() is the discriminator: a genuine inject sets the
2613
+ // adapter's isWaitingForResponse / currentTurnScope (or leaves a partial response),
2614
+ // whereas startup repaint noise leaves all of them empty. So an armed
2615
+ // starting→generating means "the worker actually started its first turn", and a
2616
+ // bare one stays a suppressed blip via the existing fall-through.
2617
+ const startingToGeneratingWithActiveTurn = this.lastStatus === 'starting'
2618
+ && newStatus === 'generating'
2619
+ && this.hasAdapterPendingResponse();
2620
+ if (((this.lastStatus === 'idle' && newStatus === 'generating') || startingToGeneratingWithActiveTurn)) {
2621
+ // If a completion event is already pending and the turn has ended
2622
+ // (generatingStartedAt===0), the PTY is painting its prompt area
2623
+ // after completing. Ignore this blip — do not cancel the pending
2624
+ // completion and do not advance lastStatus to generating. (On a true
2625
+ // starting→generating the session is fresh: completedDebouncePending is
2626
+ // null, so this blip guard is a no-op and we arm normally below.)
2627
+ if (this.completedDebouncePending && this.generatingStartedAt === 0) {
2628
+ LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
2629
+ return;
2630
+ }
894
2631
  this.suppressIdleHistoryReplay = false;
895
2632
  // Cancel any pending completed event (multi-step: idle→generating resume)
896
2633
  if (this.completedDebouncePending) {
897
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
2634
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
898
2635
  if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
899
2636
  this.completedDebouncePending = null;
900
2637
  }
901
2638
 
902
2639
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
2640
+ // FALSE-IDLE continuity: entering a busy phase invalidates any
2641
+ // completedDebouncePending armed earlier in this settle window.
2642
+ this.busyEpoch++;
903
2643
  // Defer the generating_started event — if idle comes back within 3s,
904
2644
  // the whole started→completed pair was a false positive from PTY noise
905
2645
  if (this.generatingDebounceTimer) clearTimeout(this.generatingDebounceTimer);
@@ -924,12 +2664,36 @@ export class CliProviderInstance implements ProviderInstance {
924
2664
  this.completedDebouncePending = null;
925
2665
 
926
2666
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
2667
+ // FALSE-IDLE continuity: waiting_approval is a busy phase (the agent
2668
+ // resumes into it), so bump the epoch too — the completedDebouncePending
2669
+ // cancel above covers the currently-armed pending, and the epoch covers
2670
+ // a pending that re-arms and flushes across this same valley.
2671
+ this.busyEpoch++;
927
2672
  const modal = adapterStatus.activeModal;
928
2673
  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;
2674
+ // Include the FSM's approval entry seq, mirroring the auto-approve
2675
+ // path (maybeAutoApproveStatus) and resolveModal's sameEntryReResolve
2676
+ // guard. Two distinct back-to-back approvals can carry identical
2677
+ // message/buttons (very common with claude-cli's "Allow Bash
2678
+ // command?"). Without the seq their fingerprints collide and the dedup
2679
+ // below silently drops the second waiting_approval event — it is never
2680
+ // emitted, so it cannot even land in the pending inbox for a later
2681
+ // read_chat reconcile to recover. The seq is bumped by the FSM on every
2682
+ // fresh waiting_approval entry, so a new approval always yields a new
2683
+ // fingerprint and emits.
2684
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
2685
+ ? adapterStatus.approvalEntrySeq
2686
+ : 0;
2687
+ const approvalFingerprint = JSON.stringify({
2688
+ message: typeof modal?.message === 'string' ? modal.message.trim() : '',
2689
+ buttons: Array.isArray(modal?.buttons) ? modal.buttons.map((button: unknown) => String(button).trim()) : [],
2690
+ seq: approvalEntrySeq,
2691
+ });
2692
+ // PTY redraws repeat the same modal content; fingerprint dedup prevents duplicate events.
2693
+ // Do NOT also gate on lastStatus: consecutive approvals can arrive waiting_approval→waiting_approval
2694
+ // (e.g. antigravity-cli resolves one prompt and immediately shows the next) and would be silently dropped.
2695
+ if (approvalFingerprint !== this.lastApprovalEventFingerprint) {
2696
+ this.lastApprovalEventFingerprint = approvalFingerprint;
933
2697
  this.appendRuntimeSystemMessage(
934
2698
  this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
935
2699
  `approval_request:${now}`,
@@ -941,22 +2705,287 @@ export class CliProviderInstance implements ProviderInstance {
941
2705
  modalButtons: modal?.buttons,
942
2706
  });
943
2707
  }
2708
+ } else if (newStatus === 'generating' && this.lastStatus === 'waiting_approval') {
2709
+ // Approval resolved and the agent resumed work. Defense-in-depth:
2710
+ // clear the approval emit fingerprint here too (not only on
2711
+ // completion at scheduleCompletedDebounceFlush). A subsequent
2712
+ // waiting_approval with the same modal content as the one just
2713
+ // resolved would otherwise collide with the stale fingerprint and be
2714
+ // dropped. The seq in the fingerprint already separates entries; this
2715
+ // reset is a belt-and-suspenders guard for the re-entry case.
2716
+ this.lastApprovalEventFingerprint = '';
944
2717
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
945
2718
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
946
- // If debounce still pending (generating lasted < 1s), cancel both events
2719
+ // Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
2720
+ // was entered from 'starting' state (startup PTY noise), not from a real idle→generating
2721
+ // task dispatch. The idle→generating handler is the only code path that sets
2722
+ // generatingStartedAt and generatingDebouncePending, so both being absent means no
2723
+ // task was ever dispatched. Suppress the spurious completion event and fall through
2724
+ // to a simple lastStatus update.
2725
+ if (!this.generatingStartedAt && !this.generatingDebouncePending) {
2726
+ LOG.debug('CLI', `[${this.type}] suppressed startup-phase generating→idle blip (generatingStartedAt=0, no debounce pending)`);
2727
+ } else
2728
+ // If debounce still pending (generating lasted < 1s), cancel both UI events.
2729
+ // Still emit agent:generating_completed so mesh orchestration can record
2730
+ // task_completed for direct dispatches that complete faster than the debounce.
947
2731
  if (this.generatingDebouncePending) {
948
- LOG.info('CLI', `[${this.type}] suppressed short generating (${now - this.generatingStartedAt}ms)`);
2732
+ // NOTIF Defect-2a: shortDurationMs is the REPORTED turn duration, so it must be
2733
+ // measured from the IMMUTABLE turn start — not generatingStartedAt, which is
2734
+ // reset to 0 on every mid-turn waiting_approval/idle blip (see :1864/1885/2397/
2735
+ // 2503/2521) and re-armed on the next →generating, so a long turn that blipped
2736
+ // would measure only the final 1.5-2.5s sliver. engine.currentTurnStartedAt is
2737
+ // stamped once at onTurnStarted and persists past mid-turn blips until the next
2738
+ // turn starts, so it captures the true turn length. generatingStartedAt remains
2739
+ // the fallback (and the debounce itself stays a pure UI-suppression signal,
2740
+ // decoupled from the reported duration).
2741
+ const { durationMs: shortDurationMs, anchor: durationAnchor } = computeTurnAnchoredDurationMs(
2742
+ (this.adapter as any)?.currentTurnStartedAt,
2743
+ this.generatingStartedAt,
2744
+ now,
2745
+ );
2746
+ LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms, anchor=${durationAnchor})`);
949
2747
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
2748
+ // Emit completion for mesh task association even though the UI generating
2749
+ // started/completed pair is suppressed (too short for visible UI update).
2750
+ let shortFinalSummary: string | undefined;
2751
+ let shortEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
2752
+ try {
2753
+ const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
2754
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
2755
+ shortEvidenceSource = evidence.source;
2756
+ shortFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
2757
+ } catch { /* best-effort */ }
2758
+ // If a real response is confirmed, retroactively emit started so the chat
2759
+ // bubble appears even though the debounce suppressed the original event.
2760
+ if (shortFinalSummary) {
2761
+ this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now - shortDurationMs });
2762
+ }
2763
+ // FALSE-IDLE short-gen settle: snapshot the producing turn's start + taskId NOW,
2764
+ // before `generatingStartedAt` is reset below — the settle-arm path (mesh sessions,
2765
+ // see the mesh branch further down) needs the same turn anchor the normal
2766
+ // completedDebounce branch captures, and generatingStartedAt is the fallback for it.
2767
+ const shortEngineTurnStart = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
2768
+ && Number.isFinite((this.adapter as any).currentTurnStartedAt)
2769
+ ? (this.adapter as any).currentTurnStartedAt as number
2770
+ : 0;
2771
+ const shortTurnStartedAt = shortEngineTurnStart || this.generatingStartedAt || 0;
2772
+ const shortTaskId = this.completingTurnTaskId();
950
2773
  this.generatingDebouncePending = null;
951
2774
  this.generatingStartedAt = 0;
2775
+ // FALSE-IDLE short-gen: a short-generating completion with NO transcript
2776
+ // backing at all (shortEvidenceSource === 'unavailable': both the screen parse
2777
+ // AND the external-native transcript failed to yield a final assistant) is just
2778
+ // as unproven as an 'external-native' source that returned no final assistant.
2779
+ // Fold 'unavailable' into the missing-evidence predicate so a zero-evidence dip
2780
+ // (the mid-turn point-sample that triggered this whole false-idle bug) is treated
2781
+ // as weak/held, not fired as a genuine completion. A real shortFinalSummary being
2782
+ // present still clears the gate (the !shortFinalSummary guard is unchanged).
2783
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true
2784
+ || shortEvidenceSource === 'external-native'
2785
+ || shortEvidenceSource === 'unavailable') && !shortFinalSummary;
2786
+ if (missingEvidence) {
2787
+ LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
2788
+ }
2789
+ if (this.isAutonomousMeshSession()) {
2790
+ // FALSE-IDLE short-gen settle (the core fix): for an autonomously-progressing
2791
+ // mesh session (delegated worker OR self-coordinator), the short-generating
2792
+ // branch was a POINT-SAMPLE — a single idle read from getStatus({allowParse:false})
2793
+ // that fires the completion INLINE with zero continuity backing. When the worker
2794
+ // is merely mid-turn (a sub-3s dip between two tool calls), this synchronously
2795
+ // emitted a false agent:generating_completed the coordinator can never correct.
2796
+ //
2797
+ // Route it through the SAME settle + continuity machinery as the normal
2798
+ // completedDebounce branch: arm completedDebouncePending capturing busyEpochAtArm
2799
+ // and lastOutputAtArm, then scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS)
2800
+ // so flushCompletedDebounceIfFinalized() re-verifies CONTINUOUS idle before emitting.
2801
+ // A busy re-entry (busyEpoch bump) or new PTY output within the settle window —
2802
+ // exactly what happens when the worker resumes its next tool call — CANCELS the
2803
+ // false completion. Genuine completions (real final assistant) still emit at the
2804
+ // end of the (short, 4s) settle window; missingEvidence flows through the
2805
+ // finalization block (missing_final_assistant → CANON-C weak/held) rather than
2806
+ // being frozen as a genuine inline fire.
2807
+ this.completedDebouncePending = {
2808
+ chatTitle,
2809
+ duration: Math.round(shortDurationMs / 1000),
2810
+ timestamp: now,
2811
+ firstObservedAt: now,
2812
+ // Short-gen enters from generating→idle (or waiting_approval→idle); the
2813
+ // completedDebounce finalization gate treats previousStatus for its
2814
+ // approval-resolution / inter-approval-valley handling. lastStatus is the
2815
+ // status we transitioned FROM here.
2816
+ previousStatus: this.lastStatus,
2817
+ ...(shortTaskId ? { taskId: shortTaskId } : {}),
2818
+ ...(shortTurnStartedAt ? { turnStartedAt: shortTurnStartedAt } : {}),
2819
+ // FALSE-IDLE continuity: same arm-time snapshots as the normal branch so the
2820
+ // flush guard can prove continuous idle across the settle window.
2821
+ busyEpochAtArm: this.busyEpoch,
2822
+ ...(typeof adapterStatus?.lastOutputAt === 'number' && Number.isFinite(adapterStatus.lastOutputAt)
2823
+ ? { lastOutputAtArm: adapterStatus.lastOutputAt as number }
2824
+ : {}),
2825
+ };
2826
+ LOG.info('CLI', `[${this.type}] short-generating routed through settle window (${shortDurationMs}ms, source=${shortEvidenceSource}, missingEvidence=${missingEvidence}) — arming completedDebouncePending instead of inline fire`);
2827
+ // EVTTRACE: now traces the settle ARM (not an inline fire) for mesh sessions,
2828
+ // so logs show the short-gen path deferring to continuity re-check.
2829
+ if (this.isMeshWorkerSession()) {
2830
+ traceMeshEventStage('arm', this.meshTraceCtx(), `short-generating settle-arm (source=${shortEvidenceSource}, missingEvidence=${missingEvidence})`);
2831
+ }
2832
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
2833
+ branch: 'short_generating',
2834
+ previousStatus: this.lastStatus,
2835
+ turnStartedAt: shortTurnStartedAt || null,
2836
+ busyEpochAtArm: this.busyEpoch,
2837
+ lastOutputAtArm: typeof adapterStatus?.lastOutputAt === 'number' ? adapterStatus.lastOutputAt : null,
2838
+ flushDelay: NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
2839
+ evidenceSource: shortEvidenceSource,
2840
+ missingEvidence,
2841
+ hasFinalSummary: !!shortFinalSummary,
2842
+ });
2843
+ this.scheduleCompletedDebounceFlush(NATIVE_HISTORY_MESH_IDLE_SETTLE_MS);
2844
+ } else if (missingEvidence) {
2845
+ // NON-MESH, missing evidence: suppress the completion event entirely (the
2846
+ // original !hasMeshContext suppression). A genuinely non-mesh session has no
2847
+ // coordinator to notify, and firing a completion with no confirmed final
2848
+ // assistant would just surface an empty/unconfirmed turn. Leave
2849
+ // completedDebouncePending null — the session is now idle with no confirmed
2850
+ // turn, matching the startup-blip suppression semantics. (No EvtTrace: not a
2851
+ // mesh session, so nothing routes to a coordinator.)
2852
+ LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, non-mesh session (source=${shortEvidenceSource})`);
2853
+ } else {
2854
+ // NON-MESH interactive fast-path with a CONFIRMED summary: keep the existing
2855
+ // inline fire. The dashboard UX reason for the short path (a fast turn should
2856
+ // surface its completion promptly without a 4s settle) still holds, and a
2857
+ // non-mesh session has no coordinator to be falsely notified — the false-idle
2858
+ // bug being fixed is specifically the mesh worker/coordinator misfire above.
2859
+ this.pushEvent({
2860
+ event: 'agent:generating_completed',
2861
+ chatTitle,
2862
+ duration: 0,
2863
+ timestamp: now,
2864
+ finalSummary: shortFinalSummary,
2865
+ completionDiagnostic: {
2866
+ reason: 'short_generating_suppressed',
2867
+ shortDurationMs,
2868
+ finalAssistantEvidenceSource: shortEvidenceSource,
2869
+ },
2870
+ });
2871
+ }
952
2872
  } else {
953
2873
  // Debounce completed, then require the rich transcript path that read_chat
954
2874
  // 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);
2875
+ this.completedDebouncePending = {
2876
+ chatTitle,
2877
+ duration,
2878
+ timestamp: now,
2879
+ firstObservedAt: now,
2880
+ previousStatus: this.lastStatus,
2881
+ // ARCH-REFACTOR R1: snapshot the completing turn's taskId NOW (sync),
2882
+ // before any follow-up task's flush can start a new turn and move
2883
+ // engine.currentTurnTaskId.
2884
+ ...(this.completingTurnTaskId() ? { taskId: this.completingTurnTaskId() } : {}),
2885
+ // NOTIF Defect-B: snapshot the producing turn's START instant NOW, for the
2886
+ // same reason as taskId — a follow-up turn moves engine.currentTurnStartedAt.
2887
+ // Prefer the engine's per-turn start (set at onTurnStarted, earliest reliable
2888
+ // anchor) and fall back to generatingStartedAt (when generating was observed).
2889
+ ...((() => {
2890
+ const engineTurnStart = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
2891
+ && Number.isFinite((this.adapter as any).currentTurnStartedAt)
2892
+ ? (this.adapter as any).currentTurnStartedAt as number
2893
+ : 0;
2894
+ const turnStartedAt = engineTurnStart || this.generatingStartedAt || 0;
2895
+ return turnStartedAt ? { turnStartedAt } : {};
2896
+ })()),
2897
+ // FALSE-IDLE continuity: snapshot the busy epoch + raw PTY output
2898
+ // clock at arm time so the flush guard can prove the session stayed
2899
+ // continuously idle (no busy re-entry, no new PTY output) through the
2900
+ // settle window rather than merely reading 'idle' once at flush.
2901
+ busyEpochAtArm: this.busyEpoch,
2902
+ ...(typeof adapterStatus?.lastOutputAt === 'number' && Number.isFinite(adapterStatus.lastOutputAt)
2903
+ ? { lastOutputAtArm: adapterStatus.lastOutputAt as number }
2904
+ : {}),
2905
+ };
2906
+ const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
2907
+ // (FALSEIDLE-BGCHILD-a) Native-history providers flush immediately (the
2908
+ // transcript is authoritative). For autonomously-progressing mesh sessions,
2909
+ // give the generating→idle transition a short settle window so a background-child
2910
+ // false idle (quiet after a backgrounded test/command while the parent turn
2911
+ // continues) or an inter-approval auto-approve valley gets caught by the resume
2912
+ // guard in flushCompletedDebounceIfFinalized instead of firing an early completion
2913
+ // the coordinator can never correct.
2914
+ //
2915
+ // (FALSE-IDLE self-coordinator settle) The settle window now covers BOTH mesh
2916
+ // worker sessions AND the coordinator's own claude-cli session (meshCoordinatorFor):
2917
+ // isAutonomousMeshSession(). Previously only isMeshWorkerSession() qualified, so a
2918
+ // self-coordinating daemon (worker + coordinator on the same daemon) ran the
2919
+ // coordinator's own turn at flushDelay=0 — no settle window at all — and the
2920
+ // busyEpoch/lastOutputAt continuity guard, being a flush-time point-check, had no
2921
+ // window in which to observe the ~0.5s auto-approve valley. Its mid-turn
2922
+ // "next-step" sentence was flushed as finalSummary. A genuinely non-mesh session
2923
+ // (neither worker nor self-coordinator) still flushes immediately (delay=0), so
2924
+ // no non-mesh behaviour changes; this only ADDS a settle window (strictly
2925
+ // stricter — the guard can only ever CANCEL a pending flush, never emit more).
2926
+ const meshSettleSession = this.isAutonomousMeshSession();
2927
+ const flushDelay = ownsExternalHistory
2928
+ ? (meshSettleSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
2929
+ : 3000;
2930
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} meshSettle=${meshSettleSession} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
2931
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('arm', {
2932
+ branch: 'normal',
2933
+ previousStatus: this.completedDebouncePending.previousStatus,
2934
+ turnStartedAt: this.completedDebouncePending.turnStartedAt ?? null,
2935
+ busyEpochAtArm: this.completedDebouncePending.busyEpochAtArm ?? null,
2936
+ lastOutputAtArm: this.completedDebouncePending.lastOutputAtArm ?? null,
2937
+ flushDelay,
2938
+ ownsExternalHistory,
2939
+ meshSettle: meshSettleSession,
2940
+ });
2941
+ this.scheduleCompletedDebounceFlush(flushDelay);
957
2942
  }
958
2943
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
959
- this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
2944
+ this.emitAgentReadyOnce(chatTitle, now);
2945
+ // GENERATING-BOUNDARY (R4c): stamp the collapse moment ONCE so the
2946
+ // idle-stayed window below is anchored on the startup-grace collapse,
2947
+ // not on boot. Set only the first time so a later starting re-entry
2948
+ // cannot slide the window forward.
2949
+ if (this.startupGraceCollapseAt === null) this.startupGraceCollapseAt = now;
2950
+ // GENERATING-BOUNDARY fast-collapse (R4, win32 startup-grace first turn):
2951
+ // a turn dispatched into the startup-grace window can START and FINISH while
2952
+ // the FSM is still in 'starting'. On a daemon whose claude-cli spec has NOT yet
2953
+ // synced the starting→busy edge (the primary cure lives in the spec's
2954
+ // idle→busy.from), the FSM never reaches 'busy'/generating, so
2955
+ // detectStatusTransition observes starting→idle DIRECTLY with no intervening
2956
+ // 'generating' frame. The idle→generating arm — the only path that sets
2957
+ // generatingStartedAt and arms the completion — never fired, so the completing
2958
+ // turn's agent:generating_completed is never emitted and the mesh coordinator
2959
+ // never learns the worker went idle. Synthesize the started+completed pair here.
2960
+ //
2961
+ // Discriminator (false-positive safe — must NOT fire on a benign boot):
2962
+ // adapter.currentTurnTaskId is set ONLY by onTurnStarted (a real turn STARTED
2963
+ // this boot) and persists past completion, so it cleanly separates the three
2964
+ // non-firing cases — a true idle boot (no turn → null), a queued-pending
2965
+ // first turn that only runs AFTER startup-grace drains the composer (onTurnStarted
2966
+ // not yet called → null; it completes normally later via idle→busy→idle), and a
2967
+ // turn STILL running at the 8s mark (hasAdapterPendingResponse() still true →
2968
+ // excluded so we don't fire a premature mid-turn completion; idle→busy self-
2969
+ // corrects once the FSM reaches idle). We fire only when a turn started AND has
2970
+ // already finished: started-this-boot && !still-in-flight.
2971
+ this.maybeSynthesizeStartupGraceCollapse(chatTitle, now, 'startup_grace_fast_collapse');
2972
+ } else if (newStatus === 'error') {
2973
+ if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
2974
+ this.generatingDebouncePending = null;
2975
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
2976
+ this.completedDebouncePending = null;
2977
+ this.errorMessage = adapterStatus.errorMessage || this.errorMessage;
2978
+ this.errorReason = (adapterStatus.errorReason as ProviderErrorReason) || this.errorReason;
2979
+ this.pushEvent({
2980
+ event: 'agent:stopped',
2981
+ chatTitle,
2982
+ timestamp: now,
2983
+ finalSummary: adapterStatus.errorMessage || adapterStatus.errorReason || 'Provider reported an error',
2984
+ completionDiagnostic: {
2985
+ reason: adapterStatus.errorReason || 'provider_error',
2986
+ errorMessage: adapterStatus.errorMessage || undefined,
2987
+ },
2988
+ });
960
2989
  } else if (newStatus === 'stopped') {
961
2990
  // Cancel any pending debounce
962
2991
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -968,6 +2997,79 @@ export class CliProviderInstance implements ProviderInstance {
968
2997
  this.lastStatus = newStatus;
969
2998
  }
970
2999
 
3000
+ // GENERATING-BOUNDARY idle-stayed collapse (R4b): the starting→idle
3001
+ // fast-collapse arm above only fires when the FIRST turn itself drives the
3002
+ // starting→idle transition. When the launch settle already drained
3003
+ // starting→idle BEFORE the first turn arrives, the session is already 'idle'
3004
+ // and a turn that runs+completes inside the startup-grace window — too fast
3005
+ // for any poll to observe a 'generating' frame — produces NO status change
3006
+ // at all (idle→idle). detectStatusTransition's change block above is skipped
3007
+ // entirely, so neither the idle→generating arm nor the starting→idle
3008
+ // fast-collapse arm ever runs, and the mesh coordinator never learns the
3009
+ // worker went idle (the live rc.403 Probe1 miss). Catch it here: an
3010
+ // already-idle poll (no status change), still inside the startup-grace
3011
+ // window, where a turn started this boot and has already finished without
3012
+ // ever arming generating. The same false-positive-safe discriminators apply
3013
+ // (currentTurnTaskId set && !pending && generating never armed), plus a
3014
+ // once-per-turn guard since this branch is re-polled while the session sits
3015
+ // idle. Normal turns that DO reach 'busy' set generatingStartedAt and are
3016
+ // excluded; a queued-pending first turn that only runs after grace falls
3017
+ // outside the window and completes normally via idle→busy→idle.
3018
+ //
3019
+ // R4d (the live rc.405 Probe2 miss): R4c anchored the window on the collapse
3020
+ // moment but still measured its END against `now` (the poll/completion time). The
3021
+ // helper only fires once the turn has FINISHED (!hasAdapterPendingResponse()), so
3022
+ // the first eligible poll happens at completion. When the first turn is dispatched
3023
+ // a few seconds after the collapse AND runs for a non-trivial duration, that
3024
+ // completion lands PAST the 12s now-anchored window even though the turn was a
3025
+ // genuine startup-grace first turn (live: collapse→dispatch +5.2s, turn ~11s →
3026
+ // completion at collapse+16.2s > 12s). Anchor the window on when the first turn
3027
+ // STARTED (engine.currentTurnStartedAt, set by onTurnStarted) instead: a turn that
3028
+ // STARTED within the collapse window is a startup-grace first turn no matter how
3029
+ // long it then ran. The now-anchored check is retained as a union so a fast turn
3030
+ // (dispatched+completed quickly within 12s of collapse) keeps firing too; both
3031
+ // close for a much-later turn, preserving the "don't mislabel a late fast turn"
3032
+ // honesty the window exists for.
3033
+ const firstTurnStartedAt = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
3034
+ ? (this.adapter as any).currentTurnStartedAt as number
3035
+ : 0;
3036
+ const collapsedAt = this.startupGraceCollapseAt;
3037
+ const turnStartedWithinCollapseWindow = collapsedAt !== null
3038
+ && firstTurnStartedAt > 0
3039
+ && firstTurnStartedAt >= collapsedAt
3040
+ && (firstTurnStartedAt - collapsedAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS;
3041
+ const nowWithinCollapseWindow = collapsedAt !== null
3042
+ && (now - collapsedAt) < STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS;
3043
+ if (
3044
+ newStatus === 'idle'
3045
+ && previousStatus === 'idle'
3046
+ && (turnStartedWithinCollapseWindow || nowWithinCollapseWindow)
3047
+ ) {
3048
+ this.maybeSynthesizeStartupGraceCollapse(chatTitle, now, 'startup_grace_idle_turn_collapse');
3049
+ }
3050
+
3051
+ // Re-arm the queue-claim agent:ready on the FSM's first GENUINE ready.
3052
+ //
3053
+ // The boot-time starting→idle one-shot above is the historical claim
3054
+ // trigger, but it is consumed too early for FSM-spec providers whose
3055
+ // INITIAL state already reports status 'idle' (e.g. antigravity-cli): the
3056
+ // adapter reports idle before maybeMarkReady has fired, so lastStatus
3057
+ // advances starting→idle while the prompt is not yet drawn and the worker
3058
+ // cannot yet claim. Subsequent state-driven idle frames are idle→idle (no
3059
+ // status change), so the one-shot never re-fires and the worker strands its
3060
+ // queued task — the coordinator then relaunch-loops every ~90s.
3061
+ //
3062
+ // The adapter surfaces fsmReadySeen=true exactly when the FSM reaches its
3063
+ // first non-initial idle (the prompt is genuinely up). On that signal we
3064
+ // emit agent:ready once more. emitAgentReadyOnce is idempotent (guarded by
3065
+ // agentReadyEmitted), so providers whose boot one-shot already landed on a
3066
+ // real ready (claude-cli / codex-cli / hermes-cli, which use a startup
3067
+ // grace and whose initial state is not idle) treat this as a no-op — no
3068
+ // double claim, no double task injection.
3069
+ if (newStatus === 'idle' && adapterStatus.fsmReadySeen === true && !this.agentReadyEmitted) {
3070
+ this.emitAgentReadyOnce(chatTitle, now);
3071
+ }
3072
+
971
3073
  this.applyProviderResponse(parsedStatus, {
972
3074
  phase: (newStatus === 'idle' && (previousStatus === 'generating' || previousStatus === 'waiting_approval'))
973
3075
  ? 'turn_completed'
@@ -976,8 +3078,43 @@ export class CliProviderInstance implements ProviderInstance {
976
3078
 
977
3079
  // Monitor check (cooldown based notification, IDE/CLI common)
978
3080
  const agentKey = `${this.type}:cli`;
979
- const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint);
3081
+ // Approval pending is detected from the raw adapter status, not `newStatus`:
3082
+ // auto-approve synthesizes `waiting_approval` → 'generating', which would
3083
+ // otherwise let the no-progress watchdog accumulate the approval wait.
3084
+ const approvalPending = rawStatus === 'waiting_approval';
3085
+ const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint, approvalPending);
3086
+ const monitorParsedStatus: any = parsedStatus;
980
3087
  for (const me of monitorEvents) {
3088
+ if (
3089
+ me.type === 'monitor:no_progress'
3090
+ && this.completionHasFinalAssistantMessage(monitorParsedStatus?.messages)
3091
+ && !this.hasAdapterPendingResponse()
3092
+ && !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
3093
+ ) {
3094
+ // EVTTRACE: completion fired (no-progress monitor reconciled to completion).
3095
+ if (this.isMeshWorkerSession()) {
3096
+ traceMeshEventStage('fired', this.meshTraceCtx(), 'no_progress_monitor_final_summary');
3097
+ }
3098
+ this.pushEvent({
3099
+ event: 'agent:generating_completed',
3100
+ chatTitle,
3101
+ duration: this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : undefined,
3102
+ timestamp: me.timestamp,
3103
+ finalSummary: extractFinalSummaryFromMessages(monitorParsedStatus?.messages),
3104
+ completionDiagnostic: {
3105
+ providerType: this.type,
3106
+ sessionId: this.instanceId,
3107
+ providerSessionId: this.providerSessionId || null,
3108
+ reconciliationReason: 'no_progress_monitor_final_summary',
3109
+ finalAssistantPresent: true,
3110
+ },
3111
+ });
3112
+ this.generatingStartedAt = 0;
3113
+ // Cancel any pending debounce flush — monitor already fired completion.
3114
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
3115
+ this.completedDebouncePending = null;
3116
+ continue;
3117
+ }
981
3118
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
982
3119
  }
983
3120
  }
@@ -997,15 +3134,61 @@ export class CliProviderInstance implements ProviderInstance {
997
3134
  workspaceName: typeof event.workspaceName === 'string' && event.workspaceName.trim()
998
3135
  ? event.workspaceName
999
3136
  : this.workingDir,
3137
+ // Carry the workspace under BOTH `workspace` and `workspaceName` so the
3138
+ // downstream mesh forward/merge path — which reads `workspace` — can
3139
+ // propagate it to the coordinator snapshot. Without `workspace` the live
3140
+ // event path delivers an empty workspace and the dashboard falls back to
3141
+ // the generic "Terminal (Mesh Node)" title.
3142
+ workspace: typeof event.workspace === 'string' && event.workspace.trim()
3143
+ ? event.workspace
3144
+ : this.workingDir,
1000
3145
  providerSessionId: typeof event.providerSessionId === 'string' && event.providerSessionId.trim()
1001
3146
  ? event.providerSessionId
1002
3147
  : this.providerSessionId,
1003
3148
  };
3149
+ // TASKIDLESS: stamp the mesh task primary key on lifecycle events emitted by
3150
+ // a mesh worker session. The consumer (updateDirectDispatchStatus) was switched
3151
+ // to key on task_id (CANON-B), but the producer never carried it — so every
3152
+ // forwarded metadataEvent.taskId arrived undefined and the coordinator fell back
3153
+ // to a session_id match, which can flip a sibling dispatch row. Surface it here so
3154
+ // updateDirectDispatchStatus hits the exact PK row and the session_id fallback is
3155
+ // never exercised. Non-mesh sessions get no taskId (regression guard) —
3156
+ // isMeshWorkerSession() gates the injection.
3157
+ //
3158
+ // ARCH-REFACTOR R1 (per-turn identity): resolution order is
3159
+ // (1) an explicit taskId already on the event — the debounce-flush completion
3160
+ // path stamps the taskId captured at the generating→idle transition (the
3161
+ // turn that actually produced this completion);
3162
+ // (2) the per-turn binding (engine.currentTurnTaskId) for synchronously-emitted
3163
+ // events whose turn is still the current one;
3164
+ // (3) the legacy session scalar (settings.meshActiveTaskId) as a last-resort
3165
+ // backward-compat alias.
3166
+ // The scalar is last because it is last-write-wins: a second task attaching while
3167
+ // this turn was still running overwrites it, which is the exact NOTIF-MISDELIVER /
3168
+ // TASK-MSG-MISROUTE race this refactor removes.
3169
+ if (this.isMeshWorkerSession()) {
3170
+ const existingTaskId = typeof enrichedEvent.taskId === 'string' && enrichedEvent.taskId.trim()
3171
+ ? enrichedEvent.taskId
3172
+ : undefined;
3173
+ if (!existingTaskId) {
3174
+ const resolved = this.completingTurnTaskId();
3175
+ if (resolved) enrichedEvent.taskId = resolved;
3176
+ }
3177
+ }
1004
3178
  if (this.context?.emitProviderEvent) {
1005
3179
  this.context.emitProviderEvent(enrichedEvent);
1006
- return;
3180
+ } else {
3181
+ this.events.push(enrichedEvent);
3182
+ }
3183
+ // Auto-detach a direct-dispatch mesh assignment once the dispatched
3184
+ // task reaches a terminal state. Leaving meshNodeFor pinned would
3185
+ // route this session's next unrelated turn (a dashboard chat) into
3186
+ // the coordinator as if it were the completion of another task.
3187
+ // We schedule after the emit so the originating coordinator still
3188
+ // observes the completion event with its routing marker intact.
3189
+ if (TERMINAL_MESH_EVENTS.has(event.event) && this.settings.meshActiveTaskId) {
3190
+ try { this.detachMeshAssignment(); } catch { /* best-effort */ }
1007
3191
  }
1008
- this.events.push(enrichedEvent);
1009
3192
  }
1010
3193
 
1011
3194
  private flushEvents(): ProviderEvent[] {
@@ -1022,7 +3205,13 @@ export class CliProviderInstance implements ProviderInstance {
1022
3205
  typeof data.providerSessionId === 'string' ? data.providerSessionId : '',
1023
3206
  );
1024
3207
  if (patchedProviderSessionId) {
1025
- this.promoteProviderSessionId(patchedProviderSessionId);
3208
+ // A provider-response id is authoritative when it carries an
3209
+ // explicit `new_session` marker (the CLI genuinely started a new
3210
+ // conversation). Without that marker it's just an observed id and
3211
+ // must not hijack an existing binding (see promoteProviderSessionId).
3212
+ this.promoteProviderSessionId(patchedProviderSessionId, {
3213
+ authoritative: data.sessionEvent === 'new_session',
3214
+ });
1026
3215
  }
1027
3216
 
1028
3217
  if (data.sessionEvent === 'new_session') {
@@ -1151,6 +3340,90 @@ export class CliProviderInstance implements ProviderInstance {
1151
3340
  return false;
1152
3341
  }
1153
3342
 
3343
+ /** @see ProviderInstance.noteManualInteraction */
3344
+ noteManualInteraction(now = Date.now(), opts?: { passive?: boolean }): void {
3345
+ // P1b (#137 secondary): a DELEGATED worker session must not treat a
3346
+ // passive dashboard view (foreground tab selection / panel open) as
3347
+ // manual attendance. A coordinator merely peeking at a worker's panel
3348
+ // would otherwise suppress that worker's delegated auto-approve for the
3349
+ // whole 60s window. Only explicit input/intervention (controlbar,
3350
+ // resolve_action, pty_input) attends a worker. Non-worker (foreground)
3351
+ // sessions keep noting on passive views so a user foregrounding their own
3352
+ // session still holds auto-approve to act on the modal themselves.
3353
+ if (opts?.passive && this.isMeshWorkerSession()) return;
3354
+ this.manualAttendance.note(now);
3355
+ }
3356
+
3357
+ /**
3358
+ * Whether auto-approve should be treated as active *right now* for display
3359
+ * and firing decisions: the configured intent AND the user is not currently
3360
+ * attending this session by hand. When a human is attending, auto-approve is
3361
+ * held so the modal stays visible and they can drive it via the controlbar.
3362
+ * Provider-agnostic — the attendance signal is the command set, never any
3363
+ * CLI-specific modal text.
3364
+ */
3365
+ private autoApproveEffectivelyActive(status: string | undefined, now = Date.now()): boolean {
3366
+ return status === 'waiting_approval'
3367
+ && this.shouldAutoApprove()
3368
+ && !this.manualAttendance.isAttended(now);
3369
+ }
3370
+
3371
+ // STATUS-MISMATCH: true once the current auto-approve episode has been masking
3372
+ // waiting_approval behind `generating` for longer than AUTO_APPROVE_MASK_STALL_MS without
3373
+ // resolving (the settle gate never fired). When stalled, the surface mask must be dropped
3374
+ // so read_chat / mesh_status / the dashboard see the real waiting_approval + modal (and a
3375
+ // coordinator can mesh_approve it). autoApproveMaskSince is maintained by
3376
+ // maybeAutoApproveStatus (driven by getState + the recheck timer during a waiting episode);
3377
+ // this read is side-effect-free so getStatusMetadata can consult it too.
3378
+ private autoApproveMaskStalled(now = Date.now()): boolean {
3379
+ return this.autoApproveMaskSince > 0
3380
+ && now - this.autoApproveMaskSince > CliProviderInstance.AUTO_APPROVE_MASK_STALL_MS;
3381
+ }
3382
+
3383
+ /**
3384
+ * NOTIF-APPROVAL-MASKED (Q1b): surface a delegated worker's STALLED auto-approve modal
3385
+ * to the mesh COORDINATOR, decoupled from the dashboard visible-status mask.
3386
+ *
3387
+ * When auto-approve is configured but the episode never settles (modal parse miss / the
3388
+ * settle gate never satisfied), getState()/detectStatusTransition() fold the raw
3389
+ * `waiting_approval` into `generating` to suppress dashboard flicker — so
3390
+ * detectStatusTransition()'s `waiting_approval` arm never runs and NO agent:waiting_approval
3391
+ * event is emitted. The coordinator's real-time approval-nudge delivery then has no input and
3392
+ * the worker's stuck modal is never surfaced (the live ~25s stall). The dashboard mask is
3393
+ * intentional and stays; this emits the coordinator nudge exactly ONCE, gated on the SAME
3394
+ * raw-waiting_approval + mask-stalled signal resolveModalParkStatus() distinguishes, the
3395
+ * instant the mask-stall threshold trips (the same moment getState un-folds the mask).
3396
+ *
3397
+ * Only delegated worker sessions qualify: a foreground session has no coordinator to notify,
3398
+ * and its own dashboard mask already reveals the modal on stall. A normally-resolving
3399
+ * auto-approve never reaches AUTO_APPROVE_MASK_STALL_MS, so it emits nothing here; and if a
3400
+ * masked approval clears just as this fires, rc.455's isApprovalNudgeResolved stale-drop
3401
+ * discards the nudge coordinator-side without noise. Dedup is per-episode (keyed on the
3402
+ * mask-clock value) so a modal that flaps between parsed/unparsed states is announced once.
3403
+ */
3404
+ private maybeEmitStalledApprovalNudge(adapterStatus: any, now: number): void {
3405
+ if (!this.isMeshWorkerSession()) return;
3406
+ if (adapterStatus?.status !== 'waiting_approval') return;
3407
+ if (!this.autoApproveMaskStalled(now)) return;
3408
+ // Exactly once per stalled episode (autoApproveMaskSince uniquely identifies it).
3409
+ if (this.stalledApprovalNudgeEpisode === this.autoApproveMaskSince) return;
3410
+ this.stalledApprovalNudgeEpisode = this.autoApproveMaskSince;
3411
+ const modal = adapterStatus.activeModal;
3412
+ const dirName = workingDirBasename(this.workingDir);
3413
+ const chatTitle = `${this.provider.name} · ${dirName}`;
3414
+ this.appendRuntimeSystemMessage(
3415
+ this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
3416
+ `approval_request:${now}`,
3417
+ now,
3418
+ );
3419
+ this.pushEvent({
3420
+ event: 'agent:waiting_approval', chatTitle, timestamp: now,
3421
+ modalMessage: modal?.message,
3422
+ modalButtons: modal?.buttons,
3423
+ });
3424
+ LOG.info('CLI', `[${this.type}] stalled auto-approve nudge → coordinator (masked ${Math.round((now - this.autoApproveMaskSince) / 1000)}s)`);
3425
+ }
3426
+
1154
3427
  private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
1155
3428
  this.appendRuntimeSystemMessage(
1156
3429
  formatAutoApprovalMessage(modalMessage, buttonLabel),
@@ -1238,7 +3511,7 @@ export class CliProviderInstance implements ProviderInstance {
1238
3511
  receivedAt: normalizedMessage.receivedAt || normalizedMessage.timestamp,
1239
3512
  historyDedupKey: dedupKey,
1240
3513
  }],
1241
- this.adapter.getScriptParsedStatus?.()?.title || this.workingDir.split('/').filter(Boolean).pop() || 'session',
3514
+ this.adapter.getScriptParsedStatus?.()?.title || workingDirBasename(this.workingDir),
1242
3515
  this.instanceId,
1243
3516
  this.providerSessionId,
1244
3517
  );
@@ -1258,12 +3531,28 @@ export class CliProviderInstance implements ProviderInstance {
1258
3531
  index,
1259
3532
  source: 'parsed',
1260
3533
  }));
3534
+ const getRole = (message: ChatMessage): string => typeof message.role === 'string'
3535
+ ? message.role.trim().toLowerCase()
3536
+ : '';
1261
3537
  const runtimeEntries: MergeEntry[] = this.runtimeMessages.map((entry, index) => ({
1262
3538
  message: entry.message,
1263
3539
  index: parsedMessages.length + index,
1264
- source: 'runtime',
3540
+ source: 'runtime' as const,
1265
3541
  runtimeKey: entry.key,
1266
- }));
3542
+ })).filter((entry) => {
3543
+ const meta = entry.message.meta && typeof entry.message.meta === 'object' && !Array.isArray(entry.message.meta)
3544
+ ? entry.message.meta as Record<string, unknown>
3545
+ : {};
3546
+ if (meta.runtimeInputAck !== true) return true;
3547
+ const runtimeText = flattenContent(entry.message.content).replace(/\s+/g, ' ').trim();
3548
+ if (!runtimeText) return false;
3549
+ return !parsedEntries.some((parsedEntry) => {
3550
+ const parsedRole = getRole(parsedEntry.message);
3551
+ if (parsedRole !== 'user' && parsedRole !== 'human') return false;
3552
+ const parsedText = flattenContent(parsedEntry.message.content).replace(/\s+/g, ' ').trim();
3553
+ return parsedText === runtimeText;
3554
+ });
3555
+ });
1267
3556
  const getTime = (message: ChatMessage): number => {
1268
3557
  const value = typeof message.receivedAt === 'number'
1269
3558
  ? message.receivedAt
@@ -1273,9 +3562,6 @@ export class CliProviderInstance implements ProviderInstance {
1273
3562
  return Number.isFinite(value) && value > 0 ? value : 0;
1274
3563
  };
1275
3564
 
1276
- const getRole = (message: ChatMessage): string => typeof message.role === 'string'
1277
- ? message.role.trim().toLowerCase()
1278
- : '';
1279
3565
  const isRuntimeOverlay = (entry: MergeEntry): boolean => {
1280
3566
  if (entry.source !== 'runtime') return false;
1281
3567
  const key = typeof entry.runtimeKey === 'string' ? entry.runtimeKey.trim().toLowerCase() : '';
@@ -1332,16 +3618,43 @@ export class CliProviderInstance implements ProviderInstance {
1332
3618
  return lines.join('\n');
1333
3619
  }
1334
3620
 
1335
- private promoteProviderSessionId(sessionId: string): void {
3621
+ private promoteProviderSessionId(sessionId: string, opts: { authoritative?: boolean } = {}): void {
1336
3622
  const nextSessionId = String(sessionId || '').trim();
1337
3623
  if (!nextSessionId || nextSessionId === this.providerSessionId) return;
1338
3624
 
3625
+ // Sticky binding: once this instance is bound to a provider session,
3626
+ // an *observed* id (one discovered from a status parse or the native
3627
+ // history reader) must NOT hijack the live binding. hermes ≥0.14
3628
+ // spawns a fresh `sessions` row per internal sub-session, so a
3629
+ // newest-wins native read surfaces a different id mid-turn on every
3630
+ // poll; accepting it would re-bind the instance, re-hydrate unbounded
3631
+ // history (daemon saturation) and reset completion detection so the
3632
+ // turn never finalizes. Only an *authoritative* change — the first
3633
+ // bind (no id yet) or an explicit provider `new_session`/resume — may
3634
+ // replace an existing binding. Legitimate resume/new-session paths
3635
+ // pass authoritative:true and are unaffected.
3636
+ if (this.providerSessionId && !opts.authoritative) {
3637
+ LOG.debug('CLI', `[${this.type}] ignoring non-authoritative session id ${nextSessionId} (bound to ${this.providerSessionId})`);
3638
+ return;
3639
+ }
3640
+
1339
3641
  const previousHistorySessionId = this.providerSessionId || this.instanceId;
1340
3642
  const previousProviderSessionId = this.providerSessionId;
1341
3643
  this.providerSessionId = nextSessionId;
3644
+ // Conversation-binding lock (antigravity): the moment this session is
3645
+ // authoritatively bound to a conversation uuid, claim it so a concurrent
3646
+ // sibling session's newest-on-disk discovery can never resolve to the
3647
+ // same .db (RCA: two antigravity sessions ~94ms apart shared one store
3648
+ // and cross-routed completions). Released on dispose().
3649
+ if (this.type === 'antigravity-cli') {
3650
+ const owner = this.antigravityClaimOwner();
3651
+ if (owner) claimAntigravityConversation(nextSessionId, owner);
3652
+ }
1342
3653
  this.historyWriter.promoteHistorySession(this.type, previousHistorySessionId, nextSessionId);
1343
3654
  this.historyWriter.writeSessionStart(this.type, nextSessionId, this.workingDir, this.instanceId);
1344
- this.restorePersistedHistoryFromCurrentSession();
3655
+ if (this.shouldHydrateExistingProviderHistory()) {
3656
+ this.restorePersistedHistoryFromCurrentSession();
3657
+ }
1345
3658
  this.adapter.updateRuntimeMeta({ providerSessionId: nextSessionId });
1346
3659
  this.onProviderSessionResolved?.({
1347
3660
  instanceId: this.instanceId,
@@ -1354,13 +3667,39 @@ export class CliProviderInstance implements ProviderInstance {
1354
3667
  LOG.info('CLI', `[${this.type}] discovered provider session id: ${nextSessionId}`);
1355
3668
  }
1356
3669
 
1357
- private syncCanonicalSavedHistoryIfNeeded(): boolean {
3670
+ private shouldHydrateExistingProviderHistory(): boolean {
3671
+ return this.launchMode === 'resume' || this.launchMode === 'manual';
3672
+ }
3673
+
3674
+ private shouldSuppressFreshLaunchStartupReplay(parsedMessages: unknown[], parsedStatus: any, adapterStatus: any, parsedProviderSessionId = ''): boolean {
3675
+ if (this.launchMode !== 'new') return false;
3676
+ if (this.providerSessionId) return false;
3677
+ if (!Array.isArray(parsedMessages) || parsedMessages.length === 0) return false;
3678
+ if (!isIdleStatus(adapterStatus?.status) || !isIdleStatus(parsedStatus?.status)) return false;
3679
+ if (parsedProviderSessionId) return true;
3680
+
3681
+ const newestMessageAt = parsedMessages.reduce<number>((newest, message) => Math.max(newest, getMessageTime(message)), 0);
3682
+
3683
+ // Untimestamped idle parser output during a fresh launch is usually the
3684
+ // provider's last workspace transcript before a new turn exists.
3685
+ return newestMessageAt === 0;
3686
+ }
3687
+
3688
+ private syncCanonicalSavedHistoryIfNeeded(options: { full?: boolean } = {}): boolean {
1358
3689
  if (!this.providerSessionId) return false;
1359
- const canonicalHistory = this.provider.canonicalHistory;
3690
+ const canonicalHistory = this.provider.nativeHistory;
1360
3691
  if (!canonicalHistory) return false;
1361
3692
 
3693
+ // Per-status-report hydration reads only a bounded tail (snapshot needs at
3694
+ // most the newest 60). The once-per-resume restore path passes full:true
3695
+ // because seedSessionHistory needs the COMPLETE transcript to seed dedup
3696
+ // state. The read-cache key encodes the window so the bounded and full
3697
+ // reads don't share/clobber each other's 2s cache entry.
3698
+ const limit = options.full ? Number.MAX_SAFE_INTEGER : STATUS_HYDRATION_TAIL_LIMIT;
3699
+ const windowTag = options.full ? 'full' : `tail:${STATUS_HYDRATION_TAIL_LIMIT}`;
3700
+
1362
3701
  if (isNativeSourceCanonicalHistory(canonicalHistory)) {
1363
- const cacheKey = [this.type, this.providerSessionId, this.workingDir].join('\0');
3702
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir, windowTag].join('\0');
1364
3703
  const now = Date.now();
1365
3704
  if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1366
3705
  return true;
@@ -1373,7 +3712,7 @@ export class CliProviderInstance implements ProviderInstance {
1373
3712
  historySessionId: this.providerSessionId,
1374
3713
  workspace: this.workingDir,
1375
3714
  offset: 0,
1376
- limit: Number.MAX_SAFE_INTEGER,
3715
+ limit,
1377
3716
  historyBehavior: this.provider.historyBehavior,
1378
3717
  scripts: this.provider.scripts as any,
1379
3718
  });
@@ -1390,7 +3729,7 @@ export class CliProviderInstance implements ProviderInstance {
1390
3729
  }
1391
3730
 
1392
3731
  try {
1393
- const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror'].join('\0');
3732
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror', windowTag].join('\0');
1394
3733
  const now = Date.now();
1395
3734
  if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1396
3735
  return true;
@@ -1401,7 +3740,14 @@ export class CliProviderInstance implements ProviderInstance {
1401
3740
  if (!materializeProviderNativeHistory(this.type, canonicalHistory, this.providerSessionId, this.workingDir, this.provider.scripts as any)) {
1402
3741
  return false;
1403
3742
  }
1404
- const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
3743
+ // Bounded by default: the per-status-report path only needs the newest
3744
+ // STATUS_HYDRATION_TAIL_LIMIT messages because the snapshot caps
3745
+ // activeChat.messages to the last 60 (status/normalize.ts) and loads
3746
+ // the rest lazily via read_chat on subscribe. The once-per-resume
3747
+ // restore path passes full:true so seedSessionHistory still sees the
3748
+ // COMPLETE transcript for prefix-dedup seeding. readChatHistory serves
3749
+ // a bounded limit as an O(tail) read.
3750
+ const restoredHistory = readChatHistory(this.type, 0, limit, this.providerSessionId, 0, this.provider.historyBehavior);
1405
3751
  this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
1406
3752
  role: message.role,
1407
3753
  content: message.content,
@@ -1417,10 +3763,13 @@ export class CliProviderInstance implements ProviderInstance {
1417
3763
 
1418
3764
  private restorePersistedHistoryFromCurrentSession(): void {
1419
3765
  if (!this.providerSessionId) return;
1420
- this.syncCanonicalSavedHistoryIfNeeded();
1421
- const restoredHistory = isNativeSourceCanonicalHistory(this.provider.canonicalHistory)
3766
+ // Restore is the once-per-resume seeding path: it needs the COMPLETE
3767
+ // transcript so seedSessionHistory can prime dedup state. Pass full so the
3768
+ // hydration read is unbounded here (and only here).
3769
+ this.syncCanonicalSavedHistoryIfNeeded({ full: true });
3770
+ const restoredHistory = isNativeSourceCanonicalHistory(this.provider.nativeHistory)
1422
3771
  ? readProviderChatHistory(this.type, {
1423
- canonicalHistory: this.provider.canonicalHistory,
3772
+ canonicalHistory: this.provider.nativeHistory,
1424
3773
  historySessionId: this.providerSessionId,
1425
3774
  workspace: this.workingDir,
1426
3775
  offset: 0,