@adhdev/daemon-core 0.9.82-rc.50 → 0.9.82-rc.501

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