@adhdev/daemon-core 0.9.82-rc.37 → 0.9.82-rc.370

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 (382) 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 +55 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +198 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +116 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +63 -0
  14. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  15. package/dist/cli-adapters/resolve-executable.d.ts +14 -0
  16. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  17. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  18. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  19. package/dist/commands/chat-commands.d.ts +27 -0
  20. package/dist/commands/cli-manager.d.ts +15 -1
  21. package/dist/commands/handler.d.ts +125 -0
  22. package/dist/commands/high-family/index.d.ts +3 -0
  23. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  24. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  25. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  26. package/dist/commands/high-family/types.d.ts +60 -0
  27. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  28. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  29. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  30. package/dist/commands/low-family/index.d.ts +3 -0
  31. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  32. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  33. package/dist/commands/low-family/notification.d.ts +2 -0
  34. package/dist/commands/low-family/refine-config.d.ts +2 -0
  35. package/dist/commands/low-family/session-host.d.ts +2 -0
  36. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  37. package/dist/commands/low-family/status-meta.d.ts +2 -0
  38. package/dist/commands/low-family/types.d.ts +33 -0
  39. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  40. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  41. package/dist/commands/med-family/ide.d.ts +10 -0
  42. package/dist/commands/med-family/index.d.ts +3 -0
  43. package/dist/commands/med-family/mesh-crud.d.ts +2 -0
  44. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  45. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  46. package/dist/commands/med-family/types.d.ts +116 -0
  47. package/dist/commands/mesh-coordinator.d.ts +85 -1
  48. package/dist/commands/router.d.ts +666 -2
  49. package/dist/commands/upgrade-helper.d.ts +41 -1
  50. package/dist/config/chat-history.d.ts +9 -0
  51. package/dist/config/config.d.ts +5 -0
  52. package/dist/config/mesh-config.d.ts +77 -1
  53. package/dist/daemon/dev-server.d.ts +0 -2
  54. package/dist/detection/ide-detector.d.ts +13 -0
  55. package/dist/detection/win32-ide-version.d.ts +37 -0
  56. package/dist/git/change-impact-config.d.ts +159 -0
  57. package/dist/git/git-commands.d.ts +11 -1
  58. package/dist/git/git-diff.d.ts +6 -0
  59. package/dist/git/git-executor.d.ts +11 -0
  60. package/dist/git/git-status.d.ts +23 -0
  61. package/dist/git/git-types.d.ts +2 -50
  62. package/dist/git/index.d.ts +2 -0
  63. package/dist/index.d.ts +55 -13
  64. package/dist/index.js +46516 -19739
  65. package/dist/index.js.map +1 -1
  66. package/dist/index.mjs +43516 -16870
  67. package/dist/index.mjs.map +1 -1
  68. package/dist/installer.d.ts +1 -4
  69. package/dist/ipc/local-ipc-server.d.ts +91 -0
  70. package/dist/launch.d.ts +1 -1
  71. package/dist/logging/async-batch-writer.d.ts +10 -0
  72. package/dist/logging/log-redactor.d.ts +24 -0
  73. package/dist/logging/log-tail-reader.d.ts +46 -0
  74. package/dist/logging/logger.d.ts +1 -1
  75. package/dist/mesh/contracts.d.ts +164 -0
  76. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  77. package/dist/mesh/coordinator-registry.d.ts +59 -0
  78. package/dist/mesh/mesh-active-work.d.ts +174 -0
  79. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  80. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  81. package/dist/mesh/mesh-events-coordinator.d.ts +91 -0
  82. package/dist/mesh/mesh-events-pending.d.ts +55 -0
  83. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  84. package/dist/mesh/mesh-events-utils.d.ts +61 -0
  85. package/dist/mesh/mesh-events.d.ts +6 -49
  86. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  87. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  88. package/dist/mesh/mesh-init.d.ts +86 -0
  89. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  90. package/dist/mesh/mesh-ledger.d.ts +77 -1
  91. package/dist/mesh/mesh-missions.d.ts +164 -0
  92. package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
  93. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  94. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  95. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  96. package/dist/mesh/mesh-routing.d.ts +70 -0
  97. package/dist/mesh/mesh-runtime-store.d.ts +429 -0
  98. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  99. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  100. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  101. package/dist/mesh/mesh-work-queue.d.ts +246 -5
  102. package/dist/mesh/preview-freshness.d.ts +18 -0
  103. package/dist/mesh/refine-config.d.ts +216 -0
  104. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  105. package/dist/providers/acp-provider-instance.d.ts +5 -0
  106. package/dist/providers/approval-utils.d.ts +20 -0
  107. package/dist/providers/chat-message-normalization.d.ts +12 -0
  108. package/dist/providers/cli-provider-instance.d.ts +108 -3
  109. package/dist/providers/contracts.d.ts +139 -6
  110. package/dist/providers/external-sources.d.ts +71 -0
  111. package/dist/providers/manual-attendance.d.ts +63 -0
  112. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  113. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  114. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  115. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  116. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  117. package/dist/providers/native-history/index.d.ts +13 -0
  118. package/dist/providers/provider-instance-manager.d.ts +14 -0
  119. package/dist/providers/provider-instance.d.ts +23 -1
  120. package/dist/providers/provider-loader.d.ts +26 -4
  121. package/dist/providers/provider-trust.d.ts +31 -0
  122. package/dist/providers/read-chat-contract.d.ts +29 -0
  123. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  124. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  125. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  126. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  127. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  128. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  129. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  130. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  131. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  132. package/dist/providers/sdk/v1/index.d.ts +30 -0
  133. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  134. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  135. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  136. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  137. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  138. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  139. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  140. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  141. package/dist/providers/spec/adapter.d.ts +91 -0
  142. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  143. package/dist/providers/spec/evaluator.d.ts +45 -0
  144. package/dist/providers/spec/fsm-driver.d.ts +395 -0
  145. package/dist/providers/spec/fsm-evaluator.d.ts +76 -0
  146. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  147. package/dist/providers/spec/fsm-types.d.ts +174 -0
  148. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  149. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  150. package/dist/providers/spec/route.d.ts +4 -0
  151. package/dist/providers/spec/types.d.ts +222 -0
  152. package/dist/providers/status-monitor.d.ts +7 -7
  153. package/dist/providers/transcript-v2.d.ts +176 -0
  154. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  155. package/dist/providers/working-dir.d.ts +17 -0
  156. package/dist/repo-mesh-types.d.ts +295 -13
  157. package/dist/sessions/registry.d.ts +6 -0
  158. package/dist/shared-types-extra.d.ts +2 -4
  159. package/dist/shared-types.d.ts +59 -2
  160. package/dist/status/normalize.d.ts +1 -1
  161. package/dist/status/normalize.js +1 -0
  162. package/dist/status/normalize.js.map +1 -1
  163. package/dist/status/normalize.mjs +1 -0
  164. package/dist/status/normalize.mjs.map +1 -1
  165. package/dist/status/reporter.d.ts +2 -0
  166. package/dist/status/snapshot.d.ts +26 -0
  167. package/dist/system/hash.d.ts +8 -0
  168. package/dist/system/load-better-sqlite3.d.ts +21 -0
  169. package/dist/types.d.ts +5 -0
  170. package/package.json +7 -3
  171. package/src/agent-stream/poller.ts +2 -3
  172. package/src/agent-stream/provider-adapter.ts +1 -1
  173. package/src/boot/daemon-lifecycle.ts +63 -12
  174. package/src/boot/process-hardening.ts +89 -0
  175. package/src/build-info.ts +73 -0
  176. package/src/chat/source-machine.ts +534 -0
  177. package/src/chat/source-resolver.ts +0 -0
  178. package/src/chat/subscription-updates.ts +20 -1
  179. package/src/cli-adapter-types.d.ts +1 -0
  180. package/src/cli-adapter-types.ts +56 -2
  181. package/src/cli-adapters/cli-script-runner.ts +421 -0
  182. package/src/cli-adapters/cli-state-engine.ts +1221 -0
  183. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  184. package/src/cli-adapters/provider-cli-adapter.ts +775 -1141
  185. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  186. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  187. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  188. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  189. package/src/cli-adapters/provider-cli-shared.ts +110 -11
  190. package/src/cli-adapters/pty-transport.ts +2 -1
  191. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  192. package/src/cli-adapters/resolve-executable.ts +90 -0
  193. package/src/cli-adapters/session-host-transport.ts +2 -1
  194. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  195. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  196. package/src/cli-adapters/terminal-screen.ts +16 -81
  197. package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +198 -0
  198. package/src/commands/chat-commands.ts +1917 -62
  199. package/src/commands/cli-manager.ts +502 -24
  200. package/src/commands/handler.ts +841 -2
  201. package/src/commands/high-family/index.ts +28 -0
  202. package/src/commands/high-family/mesh-coordinator-launch.ts +592 -0
  203. package/src/commands/high-family/mesh-events.ts +47 -0
  204. package/src/commands/high-family/mesh-status.ts +642 -0
  205. package/src/commands/high-family/types.ts +76 -0
  206. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  207. package/src/commands/low-family/daemon-lifecycle.ts +107 -0
  208. package/src/commands/low-family/diagnostics.ts +57 -0
  209. package/src/commands/low-family/index.ts +37 -0
  210. package/src/commands/low-family/mesh-ledger.ts +62 -0
  211. package/src/commands/low-family/mesh-node-logs.ts +81 -0
  212. package/src/commands/low-family/notification.ts +116 -0
  213. package/src/commands/low-family/refine-config.ts +106 -0
  214. package/src/commands/low-family/session-host.ts +274 -0
  215. package/src/commands/low-family/spec-providerdev.ts +217 -0
  216. package/src/commands/low-family/status-meta.ts +112 -0
  217. package/src/commands/low-family/types.ts +39 -0
  218. package/src/commands/med-family/cli-agent.ts +218 -0
  219. package/src/commands/med-family/fast-forward.ts +198 -0
  220. package/src/commands/med-family/ide.ts +163 -0
  221. package/src/commands/med-family/index.ts +35 -0
  222. package/src/commands/med-family/mesh-crud.ts +788 -0
  223. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  224. package/src/commands/med-family/mesh-queue.ts +131 -0
  225. package/src/commands/med-family/types.ts +120 -0
  226. package/src/commands/mesh-coordinator.ts +334 -124
  227. package/src/commands/router.ts +5121 -2369
  228. package/src/commands/stream-commands.ts +8 -0
  229. package/src/commands/upgrade-helper.ts +310 -45
  230. package/src/config/chat-history.ts +483 -24
  231. package/src/config/config.ts +12 -0
  232. package/src/config/mesh-config.ts +389 -6
  233. package/src/config/recent-activity.ts +8 -2
  234. package/src/daemon/dev-auto-implement.ts +3 -2
  235. package/src/daemon/dev-cli-debug.ts +10 -1
  236. package/src/daemon/dev-server.ts +0 -541
  237. package/src/detection/cli-detector.ts +28 -9
  238. package/src/detection/ide-detector.ts +55 -16
  239. package/src/detection/win32-ide-version.ts +106 -0
  240. package/src/git/change-impact-config.ts +354 -0
  241. package/src/git/git-commands.ts +55 -14
  242. package/src/git/git-diff.ts +81 -11
  243. package/src/git/git-executor.ts +12 -0
  244. package/src/git/git-status.ts +574 -43
  245. package/src/git/git-types.ts +14 -62
  246. package/src/git/git-worktree.ts +8 -1
  247. package/src/git/index.ts +16 -0
  248. package/src/index.ts +158 -12
  249. package/src/installer.d.ts +1 -1
  250. package/src/installer.ts +8 -6
  251. package/src/ipc/local-ipc-server.ts +278 -0
  252. package/src/launch.d.ts +1 -1
  253. package/src/launch.ts +37 -28
  254. package/src/logging/async-batch-writer.ts +55 -0
  255. package/src/logging/command-log.ts +7 -5
  256. package/src/logging/log-redactor.ts +100 -0
  257. package/src/logging/log-tail-reader.ts +220 -0
  258. package/src/logging/logger.ts +14 -7
  259. package/src/mesh/contracts.ts +338 -0
  260. package/src/mesh/coordinator-prompt.ts +234 -31
  261. package/src/mesh/coordinator-registry.ts +121 -0
  262. package/src/mesh/mesh-active-work.ts +645 -0
  263. package/src/mesh/mesh-delivery-policy.ts +315 -0
  264. package/src/mesh/mesh-event-trace.ts +67 -0
  265. package/src/mesh/mesh-events-coordinator.ts +2866 -0
  266. package/src/mesh/mesh-events-pending.ts +599 -0
  267. package/src/mesh/mesh-events-stale.ts +309 -0
  268. package/src/mesh/mesh-events-utils.ts +387 -0
  269. package/src/mesh/mesh-events.ts +30 -1035
  270. package/src/mesh/mesh-fast-forward.ts +839 -0
  271. package/src/mesh/mesh-host-ownership.ts +73 -0
  272. package/src/mesh/mesh-init.ts +260 -0
  273. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  274. package/src/mesh/mesh-ledger.ts +591 -102
  275. package/src/mesh/mesh-missions.ts +322 -0
  276. package/src/mesh/mesh-reconcile-loop.ts +1134 -0
  277. package/src/mesh/mesh-refine-batch.ts +197 -0
  278. package/src/mesh/mesh-refine-status.ts +231 -0
  279. package/src/mesh/mesh-review-inbox.ts +307 -0
  280. package/src/mesh/mesh-routing.ts +291 -0
  281. package/src/mesh/mesh-runtime-store.ts +1697 -0
  282. package/src/mesh/mesh-task-stats.ts +161 -0
  283. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  284. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  285. package/src/mesh/mesh-work-queue.ts +997 -141
  286. package/src/mesh/preview-freshness.ts +118 -0
  287. package/src/mesh/refine-config.ts +423 -0
  288. package/src/mesh/worktree-bootstrap-config.ts +331 -0
  289. package/src/providers/acp-provider-instance.ts +43 -10
  290. package/src/providers/approval-utils.d.ts +5 -0
  291. package/src/providers/approval-utils.ts +57 -5
  292. package/src/providers/chat-message-normalization.ts +45 -5
  293. package/src/providers/cli-provider-instance.ts +1310 -101
  294. package/src/providers/contracts.d.ts +55 -0
  295. package/src/providers/contracts.ts +150 -6
  296. package/src/providers/extension-provider-instance.ts +12 -7
  297. package/src/providers/external-sources.ts +218 -0
  298. package/src/providers/ide-provider-instance.ts +35 -12
  299. package/src/providers/manual-attendance.ts +85 -0
  300. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  301. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  302. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  303. package/src/providers/native-history/dispatcher.ts +340 -0
  304. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  305. package/src/providers/native-history/index.ts +30 -0
  306. package/src/providers/provider-instance-manager.ts +30 -0
  307. package/src/providers/provider-instance.ts +19 -1
  308. package/src/providers/provider-loader.ts +668 -50
  309. package/src/providers/provider-schema.ts +87 -14
  310. package/src/providers/provider-trust.ts +114 -0
  311. package/src/providers/read-chat-contract.ts +76 -16
  312. package/src/providers/sdk/README.md +49 -0
  313. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  314. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  315. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  316. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  317. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  318. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  319. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  320. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  321. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  322. package/src/providers/sdk/v1/index.ts +152 -0
  323. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  324. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  325. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  326. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  327. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  328. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  329. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  330. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  331. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  332. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  333. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  334. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  335. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  336. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  337. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  338. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  339. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  340. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  341. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  342. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  343. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  344. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  345. package/src/providers/sdk/v1/validators/index.ts +19 -0
  346. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  347. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  348. package/src/providers/spec/adapter.ts +235 -0
  349. package/src/providers/spec/cli-adapter.ts +1051 -0
  350. package/src/providers/spec/evaluator.ts +407 -0
  351. package/src/providers/spec/fsm-driver.ts +1305 -0
  352. package/src/providers/spec/fsm-evaluator.ts +272 -0
  353. package/src/providers/spec/fsm-loader.ts +120 -0
  354. package/src/providers/spec/fsm-types.ts +273 -0
  355. package/src/providers/spec/native-history-executor.ts +1136 -0
  356. package/src/providers/spec/pre-launch-trust.ts +104 -0
  357. package/src/providers/spec/route.ts +51 -0
  358. package/src/providers/spec/types.ts +262 -0
  359. package/src/providers/status-monitor.d.ts +7 -7
  360. package/src/providers/status-monitor.ts +37 -22
  361. package/src/providers/transcript-v2.ts +567 -0
  362. package/src/providers/types/interactive-prompt.ts +536 -0
  363. package/src/providers/version-archive.ts +64 -24
  364. package/src/providers/working-dir.ts +23 -0
  365. package/src/repo-mesh-types.ts +378 -14
  366. package/src/sessions/registry.ts +6 -0
  367. package/src/shared-types-extra.ts +2 -4
  368. package/src/shared-types.d.ts +8 -0
  369. package/src/shared-types.ts +64 -1
  370. package/src/status/builders.ts +26 -6
  371. package/src/status/normalize.ts +2 -0
  372. package/src/status/reporter.ts +19 -1
  373. package/src/status/snapshot.ts +95 -26
  374. package/src/system/hash.ts +23 -0
  375. package/src/system/host-memory.ts +29 -12
  376. package/src/system/load-better-sqlite3.ts +68 -0
  377. package/src/types.ts +5 -0
  378. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  379. package/dist/mesh/mesh-sync.d.ts +0 -53
  380. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  381. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  382. package/src/mesh/mesh-sync.ts +0 -111
@@ -13,19 +13,25 @@ import { createRequire } from 'node:module';
13
13
  import { normalizeInputEnvelope, type ProviderModule, flattenContent, type InputEnvelope, type InputPart } from './contracts.js';
14
14
  import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport } from './provider-input-support.js';
15
15
  import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, ProviderErrorReason, HotChatSessionState, SessionModalState } from './provider-instance.js';
16
+ import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, type InteractivePrompt } from './types/interactive-prompt.js';
16
17
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
18
+ import { shortHash } from '../system/hash.js';
17
19
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
20
+ import { createCliAdapter } from './spec/route.js';
18
21
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
19
22
  import { StatusMonitor } from './status-monitor.js';
20
23
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
21
24
  import { LOG } from '../logging/logger.js';
25
+ import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
22
26
  import type { ChatMessage } from '../types.js';
23
27
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
24
- import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.js';
28
+ import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, looksLikeActiveApprovalPromptText } from './approval-utils.js';
25
29
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
26
30
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
27
31
  import { normalizeProviderSessionId } from './provider-session-id.js';
28
32
  import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
33
+ import { workingDirBasename } from './working-dir.js';
34
+ import { ManualAttendanceTracker } from './manual-attendance.js';
29
35
 
30
36
  type PersistableCliHistoryMessage = {
31
37
  role: string;
@@ -35,17 +41,77 @@ type PersistableCliHistoryMessage = {
35
41
  receivedAt?: number;
36
42
  };
37
43
 
44
+ // Status snapshots only ever surface the newest messages: the cloud 'live'
45
+ // profile drops chat messages entirely (loaded lazily via read_chat on
46
+ // subscribe) and the 'full' profile caps activeChat.messages to the last 60
47
+ // (see status/normalize.ts). Unread/completion markers walk only the tail.
48
+ // So getState()'s saved-history hydration — which runs once per resume/manual
49
+ // CLI session on every status report — must read only a bounded tail, not the
50
+ // entire transcript. A full MAX_SAFE_INTEGER read here makes the initial
51
+ // status report O(transcript) × N(sessions), which is the real cold first-
52
+ // connection bottleneck on chat-heavy machines. The window comfortably exceeds
53
+ // the 60-message snapshot cap so dedup/collapse at the boundary stays stable.
54
+ const STATUS_HYDRATION_TAIL_LIMIT = 200;
55
+
38
56
  type CompletedDebouncePending = {
39
57
  chatTitle: string;
40
58
  duration: number;
41
59
  timestamp: number;
42
60
  firstObservedAt: number;
61
+ previousStatus: string;
43
62
  loggedBlockReason?: string;
63
+ loggedTranscriptProbe?: boolean;
64
+ transcriptProbeHistory?: ExternalTranscriptProbe[];
65
+ };
66
+
67
+ function isIdleStatus(value: unknown): boolean {
68
+ const status = typeof value === 'string' ? value.trim().toLowerCase() : '';
69
+ return !status || status === 'idle' || status === 'ready';
70
+ }
71
+
72
+
73
+ function getMessageTime(message: unknown): number {
74
+ if (!message || typeof message !== 'object') return 0;
75
+ const record = message as { receivedAt?: unknown; timestamp?: unknown };
76
+ const value = Number(record.receivedAt ?? record.timestamp ?? 0);
77
+ return Number.isFinite(value) ? value : 0;
78
+ }
79
+
80
+ type CompletedFinalizationBlock = {
81
+ reason: string;
82
+ terminal?: boolean;
83
+ allowTimeout?: boolean;
84
+ };
85
+
86
+ type CompletionFinalAssistantEvidence = {
87
+ present: boolean;
88
+ messages: unknown[];
89
+ source: 'parsed' | 'external-native' | 'unavailable';
90
+ };
91
+
92
+ type ExternalTranscriptProbe = {
93
+ readAt: number;
94
+ msgCount: number;
95
+ lastRole: string | null;
96
+ lastKind: string | null;
97
+ contentLen: number;
98
+ sourcePath: string | null;
99
+ sourceMtimeMs: number | null;
100
+ mtimeAgeMs: number | null;
44
101
  };
45
102
 
46
103
  const COMPLETED_FINALIZATION_RETRY_MS = 1000;
47
104
  const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
48
105
 
106
+ /** Events that signal a dispatched mesh task has reached a terminal state.
107
+ * Detach the mesh assignment after emitting one of these so the worker's
108
+ * next unrelated turn doesn't impersonate another completion. */
109
+ const TERMINAL_MESH_EVENTS = new Set([
110
+ 'agent:generating_completed',
111
+ 'agent:stopped',
112
+ 'agent:ready',
113
+ ]);
114
+
49
115
  const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
50
116
  'image/png': '.png',
51
117
  'image/jpeg': '.jpg',
@@ -120,7 +186,7 @@ function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
120
186
  }
121
187
 
122
188
  function isCliGeneratingLikeStatus(status: unknown): boolean {
123
- return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
189
+ return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
124
190
  }
125
191
 
126
192
  export function buildCliStructuredInputPrompt(
@@ -300,6 +366,34 @@ export class CliProviderInstance implements ProviderInstance {
300
366
  readonly type: string;
301
367
  readonly category = 'cli' as const;
302
368
 
369
+ /**
370
+ * Quiet period an approval modal's signature must be stable before
371
+ * auto-approve sends the approve key. Guards against firing on a prompt
372
+ * that is still streaming into the PTY (the "resolves too fast" symptom):
373
+ * while the modal text/buttons are still changing, every frame yields a
374
+ * new signature and the settle clock restarts. Once the prompt finishes
375
+ * rendering the signature holds and the key is sent after this window.
376
+ * Bounded + small so genuine approvals stay timely. The FSM is already
377
+ * authoritative over the `waiting_approval` state; this only delays the
378
+ * keystroke until the modal *content* has settled.
379
+ */
380
+ private static readonly AUTO_APPROVE_SETTLE_MS = 600;
381
+
382
+ /**
383
+ * Busy-side hysteresis for the settle gate. A momentary `generating` flip
384
+ * while the SAME approval modal's button block is still on screen (its
385
+ * question line scrolled out of the captured frame, only the buttons + a
386
+ * residual `esc to interrupt` spinner remain) briefly reports
387
+ * status!=waiting_approval. Without hysteresis that flip wipes the settle
388
+ * clock, and the modal→generating→modal flap restarts the 600ms window
389
+ * every time so auto-approve never fires. We keep the in-progress settle
390
+ * gate warm across an inactive blip up to this bound; only once the modal
391
+ * has genuinely stayed gone this long (a real resolution → idle) is the
392
+ * gate cleared. Bounded so a genuinely new, later approval still re-settles
393
+ * from scratch rather than firing on a stale timestamp.
394
+ */
395
+ private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS = 1500;
396
+
303
397
  private adapter: ProviderCliAdapter;
304
398
  private context: InstanceContext | null = null;
305
399
  private events: ProviderEvent[] = [];
@@ -309,15 +403,34 @@ export class CliProviderInstance implements ProviderInstance {
309
403
  private monitor: StatusMonitor;
310
404
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
311
405
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
312
- private lastApprovalEventAt = 0;
406
+ private lastApprovalEventFingerprint = '';
313
407
  private autoApproveBusy = false;
314
408
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
409
+ private lastAutoApprovalSignature = '';
410
+ // Settle gate: the approval modal's signature + the wall-clock when this
411
+ // exact signature was first observed. Auto-approve only fires once the
412
+ // SAME signature has been stable for AUTO_APPROVE_SETTLE_MS, so a prompt
413
+ // still streaming into the PTY (its buttons/message changing frame to
414
+ // frame) keeps resetting the timer and is never approved half-rendered.
415
+ private pendingAutoApprovalSignature = '';
416
+ private pendingAutoApprovalSince = 0;
417
+ private autoApproveSettleTimer: NodeJS.Timeout | null = null;
418
+ // Wall-clock when auto-approve first observed status!=waiting_approval while
419
+ // a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS so a
420
+ // brief generating flip does not immediately wipe the settle clock.
421
+ private autoApproveInactiveSince = 0;
422
+ // Provider-common manual-attendance signal: while a human is actively driving
423
+ // this session from the dashboard, auto-approve holds so they can take manual
424
+ // control. Background mesh workers are never attended → delegated auto-approve
425
+ // is unaffected.
426
+ private readonly manualAttendance = new ManualAttendanceTracker();
315
427
  private controlValues: Record<string, string | number | boolean> = {};
316
428
  private summaryMetadata: unknown = undefined;
317
429
  private appliedEffectKeys = new Set<string>();
318
430
  private historyWriter: ChatHistoryWriter;
319
431
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
320
432
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
433
+ private lastAcknowledgedUserInputAt = 0;
321
434
  private lastNativeSourceCanonicalCheckAt = 0;
322
435
  private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
323
436
  private cachedSqliteDb: {
@@ -330,6 +443,7 @@ export class CliProviderInstance implements ProviderInstance {
330
443
  private suppressIdleHistoryReplay = false;
331
444
  private errorMessage: string | undefined = undefined;
332
445
  private errorReason: ProviderErrorReason | undefined = undefined;
446
+ private activeInteractivePrompt: InteractivePrompt | null = null;
333
447
 
334
448
  private presentationMode: 'terminal' | 'chat';
335
449
  private providerSessionId?: string;
@@ -370,7 +484,10 @@ export class CliProviderInstance implements ProviderInstance {
370
484
  this.providerSessionId = options?.providerSessionId;
371
485
  this.launchMode = options?.launchMode || 'new';
372
486
  this.onProviderSessionResolved = options?.onProviderSessionResolved;
373
- this.adapter = new ProviderCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory);
487
+ this.adapter = createCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory) as ProviderCliAdapter;
488
+ if (this.providerSessionId) {
489
+ this.adapter.updateRuntimeMeta({ providerSessionId: this.providerSessionId });
490
+ }
374
491
  this.monitor = new StatusMonitor();
375
492
  this.historyWriter = new ChatHistoryWriter();
376
493
  }
@@ -389,8 +506,8 @@ export class CliProviderInstance implements ProviderInstance {
389
506
  this.adapter.updateRuntimeSettings?.(this.settings);
390
507
  this.monitor.updateConfig({
391
508
  approvalAlert: this.settings.approvalAlert !== false,
392
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
393
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
509
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
510
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
394
511
  });
395
512
 
396
513
  // Server connection
@@ -412,7 +529,7 @@ export class CliProviderInstance implements ProviderInstance {
412
529
  await this.adapter.spawn();
413
530
  await this.enforceFreshSessionLaunchIfNeeded();
414
531
  this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
415
- if (this.providerSessionId) {
532
+ if (this.providerSessionId && this.shouldHydrateExistingProviderHistory()) {
416
533
  this.restorePersistedHistoryFromCurrentSession();
417
534
  }
418
535
  if (this.providerSessionId && this.launchMode === 'resume') {
@@ -486,14 +603,38 @@ export class CliProviderInstance implements ProviderInstance {
486
603
  }
487
604
 
488
605
  getState(): ProviderState {
606
+ // TODO(phase5-sandbox): JS override scripts (detectStatus, parseApproval,
607
+ // parseSession) are currently invoked by CliScriptRunner.invoke() via direct
608
+ // function calls — the scripts run in the daemon process with full Node.js
609
+ // access and no resource limits.
610
+ //
611
+ // When Phase 5 lands, CliScriptRunner should route these calls through a
612
+ // SandboxedScriptRunner (see providers/sdk/v1/sandbox/script-runner.ts) so
613
+ // that each call gets a fresh isolated-vm context with a 50 ms CPU limit and
614
+ // a 32 MB memory cap. The execution path to change is:
615
+ // CliScriptRunner.invoke() → SandboxedScriptRunner.run(scriptSource, context)
616
+ //
617
+ // This getState() call-site is NOT where the change goes — the wiring belongs
618
+ // in cli-script-runner.ts (CliScriptRunner.detectStatus / parseApproval /
619
+ // parseSession), with provider-loader.ts updated to store script source strings
620
+ // alongside the loaded function references for extended-legacy providers.
489
621
  const adapterStatus = this.adapter.getStatus();
622
+ if (Object.prototype.hasOwnProperty.call(adapterStatus, 'activeInteractivePrompt')) {
623
+ this.activeInteractivePrompt = adapterStatus.activeInteractivePrompt ?? null;
624
+ }
490
625
  let parsedStatus: any = null;
491
626
  let parseErrorMessage: string | undefined;
492
627
  if (typeof this.adapter.getScriptParsedStatus === 'function') {
493
628
  try {
494
629
  parsedStatus = this.adapter.getScriptParsedStatus() || null;
495
- this.errorMessage = undefined;
496
- this.errorReason = undefined;
630
+ const parsedErrorMessage = typeof parsedStatus?.errorMessage === 'string' && parsedStatus.errorMessage.trim()
631
+ ? parsedStatus.errorMessage.trim()
632
+ : undefined;
633
+ const parsedErrorReason = typeof parsedStatus?.errorReason === 'string' && parsedStatus.errorReason.trim()
634
+ ? parsedStatus.errorReason.trim() as ProviderErrorReason
635
+ : undefined;
636
+ this.errorMessage = parsedErrorMessage;
637
+ this.errorReason = parsedErrorReason;
497
638
  } catch (error: any) {
498
639
  parseErrorMessage = error?.message || String(error);
499
640
  this.errorMessage = parseErrorMessage;
@@ -503,22 +644,53 @@ export class CliProviderInstance implements ProviderInstance {
503
644
  this.errorMessage = undefined;
504
645
  this.errorReason = undefined;
505
646
  }
506
- const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
507
- const visibleStatus = parseErrorMessage
508
- ? 'error'
509
- : (autoApproveActive ? 'generating' : adapterStatus.status);
510
- const parsedProviderSessionId = normalizeProviderSessionId(
647
+ const adapterProviderSessionId = normalizeProviderSessionId(
511
648
  this.provider,
512
- typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
649
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
513
650
  );
514
- if (parsedProviderSessionId) {
515
- this.promoteProviderSessionId(parsedProviderSessionId);
651
+ const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
652
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
653
+ let visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
654
+ ? 'error'
655
+ : (autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status);
656
+ // getState() must agree with the status the FSM-driven detectStatusTransition()
657
+ // already committed to lastStatus. The adapter's own status is authoritative; we do
658
+ // not second-guess it with native-transcript shape. Only reconcile a generating-like
659
+ // read down to idle when our own lastStatus has already flipped idle (avoids a
660
+ // perpetual dashboard spinner during the brief window before the next getStatus()).
661
+ if (isCliGeneratingLikeStatus(visibleStatus) && this.lastStatus === 'idle') {
662
+ visibleStatus = 'idle';
516
663
  }
517
664
  const runtime = this.adapter.getRuntimeMetadata();
518
665
  this.maybeAppendRuntimeRecoveryMessage(runtime);
519
666
  let parsedMessages = Array.isArray(parsedStatus?.messages)
520
667
  ? parsedStatus.messages
521
668
  : [];
669
+ const parsedProviderSessionId = normalizeProviderSessionId(
670
+ this.provider,
671
+ typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
672
+ );
673
+ const suppressFreshLaunchStartupReplay = this.shouldSuppressFreshLaunchStartupReplay(
674
+ parsedMessages,
675
+ parsedStatus,
676
+ adapterStatus,
677
+ parsedProviderSessionId,
678
+ );
679
+ if (adapterProviderSessionId && !suppressFreshLaunchStartupReplay) {
680
+ this.promoteProviderSessionId(adapterProviderSessionId);
681
+ }
682
+ if (parsedProviderSessionId && !suppressFreshLaunchStartupReplay) {
683
+ this.promoteProviderSessionId(parsedProviderSessionId);
684
+ }
685
+ if (suppressFreshLaunchStartupReplay) {
686
+ parsedMessages = [];
687
+ }
688
+ // Adapter runtime metadata is transport-owned and is not guaranteed to
689
+ // identify this conversation. Spec adapters historically exposed the
690
+ // provider spec id (for example "codex-cli") as runtimeId, which made
691
+ // concurrent sessions share one activeChat identity until their native
692
+ // provider session ids were discovered.
693
+ const activeChatId = this.providerSessionId || this.instanceId;
522
694
  const historyMessageCount = Number.isFinite(parsedStatus?.historyMessageCount)
523
695
  ? Math.max(0, Number(parsedStatus.historyMessageCount))
524
696
  : null;
@@ -528,9 +700,20 @@ export class CliProviderInstance implements ProviderInstance {
528
700
  : [];
529
701
  }
530
702
  const mergedMessages = this.mergeConversationMessages(parsedMessages);
531
- const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
703
+ const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
704
+ ? this.syncCanonicalSavedHistoryIfNeeded()
705
+ : false;
706
+ const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
707
+ ? this.lastPersistedHistoryMessages.map((message) => ({
708
+ role: message.role,
709
+ content: message.content,
710
+ kind: message.kind,
711
+ senderName: message.senderName,
712
+ receivedAt: message.receivedAt,
713
+ }))
714
+ : mergedMessages;
532
715
 
533
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
716
+ const dirName = workingDirBasename(this.workingDir);
534
717
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
535
718
  ? parsedStatus.status.trim()
536
719
  : undefined;
@@ -542,7 +725,7 @@ export class CliProviderInstance implements ProviderInstance {
542
725
  && adapterStatus.status === 'idle'
543
726
  && parsedStatus?.status === 'idle';
544
727
  let messagesToSave = parsedMessages;
545
- if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
728
+ if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'no_progress' || parsedChatStatus === 'long_generating')) {
546
729
  const lastIdx = messagesToSave.length - 1;
547
730
  if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
548
731
  messagesToSave = messagesToSave.slice(0, lastIdx);
@@ -572,33 +755,48 @@ export class CliProviderInstance implements ProviderInstance {
572
755
  }
573
756
  }
574
757
 
575
- this.applyProviderResponse(parsedStatus, { phase: 'immediate' });
758
+ this.applyProviderResponse(
759
+ suppressFreshLaunchStartupReplay && parsedStatus && typeof parsedStatus === 'object'
760
+ ? { ...parsedStatus, providerSessionId: undefined }
761
+ : parsedStatus,
762
+ { phase: 'immediate' },
763
+ );
576
764
  const surface = resolveProviderStateSurface({
577
765
  summaryMetadata: this.summaryMetadata as any,
578
766
  controlValues: this.controlValues,
579
767
  });
580
768
  const activeChatStatus = parseErrorMessage
581
769
  ? 'error'
582
- : autoApproveActive && parsedStatus?.status === 'waiting_approval'
770
+ : (autoApproveActive && parsedStatus?.status === 'waiting_approval') || autoApproveHoldIdle
583
771
  ? 'generating'
584
772
  : (adapterStatus.status !== 'idle'
585
773
  ? visibleStatus
586
774
  : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
587
775
 
776
+ // If an AskUserQuestion prompt is awaiting user input, overlay status as
777
+ // waiting_choice. This is distinct from waiting_approval (tool-use consent)
778
+ // — the engine's isWaitingForResponse state is unchanged, so completion
779
+ // tracking continues normally once the user responds.
780
+ const hasInteractivePrompt = !!this.activeInteractivePrompt;
781
+ const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
782
+ const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
783
+
588
784
  return {
589
785
  type: this.type,
590
786
  name: this.provider.name,
591
787
  category: 'cli',
592
- status: visibleStatus,
788
+ status: finalStatus,
593
789
  mode: this.presentationMode,
594
790
  activeChat: {
595
- id: `${this.type}_${this.workingDir}`,
791
+ id: activeChatId,
596
792
  title: parsedStatus?.title || dirName,
597
- status: activeChatStatus,
598
- messages: mergedMessages,
599
- activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
793
+ status: finalChatStatus,
794
+ messages: statusMessages,
795
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
796
+ activeInteractivePrompt: this.activeInteractivePrompt,
600
797
  inputContent: '',
601
798
  },
799
+ activeInteractivePrompt: this.activeInteractivePrompt,
602
800
  workspace: this.workingDir,
603
801
  instanceId: this.instanceId,
604
802
  providerSessionId: this.providerSessionId,
@@ -638,8 +836,9 @@ export class CliProviderInstance implements ProviderInstance {
638
836
 
639
837
  getHotChatSessionState(): HotChatSessionState {
640
838
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
641
- const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
642
- const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
839
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status);
840
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
841
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
643
842
  const runtime = this.adapter.getRuntimeMetadata();
644
843
  return {
645
844
  id: this.instanceId,
@@ -651,36 +850,165 @@ export class CliProviderInstance implements ProviderInstance {
651
850
  };
652
851
  }
653
852
 
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';
853
+ getSessionModalState(sessionId?: string): SessionModalState {
854
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
855
+ const autoApproveActive = this.autoApproveEffectivelyActive(adapterStatus.status);
856
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
857
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
858
+ const dirName = workingDirBasename(this.workingDir);
659
859
  return {
660
- id: this.instanceId,
860
+ // Honor the caller-supplied sessionId — InstanceMgr rejects the
861
+ // projection when projected.id !== requested sessionId, and
862
+ // this.instanceId is the manager's internal key, not the public
863
+ // sessionId the dashboard subscribes by.
864
+ id: sessionId ?? this.instanceId,
661
865
  status: visibleStatus,
662
866
  title: dirName,
663
- activeModal: autoApproveActive ? null : adapterStatus.activeModal,
867
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : adapterStatus.activeModal,
664
868
  };
665
869
  }
666
870
 
667
871
  updateSettings(newSettings: Record<string, any>): void {
668
- this.settings = { ...newSettings };
872
+ // Merge semantics: a key omitted from newSettings preserves its existing
873
+ // value, a key present in newSettings (even as false) overrides it.
874
+ //
875
+ // This is required because updateSettings has two callers with opposite
876
+ // intent:
877
+ // 1. Full re-injection — the dashboard toggle path (handleSetProviderSetting
878
+ // → getSettings → updateInstanceSettings) sends the COMPLETE settings
879
+ // object, so an explicit autoApprove:false must win.
880
+ // 2. Partial stamp — the mesh relay-safety stamp (router.ts agent_command,
881
+ // buildMeshWorkerRelayStamp) sends ONLY {meshNodeFor, meshNodeId,
882
+ // meshCoordinatorDaemonId, launchedByCoordinator} on every coordinator
883
+ // re-dispatch. It carries no autoApprove, so a full replacement would
884
+ // wipe the launch-time autoApprove:true the worker was started with,
885
+ // silently dropping every later approval to a manual gate until the
886
+ // machine-page toggle re-injected the full settings.
887
+ //
888
+ // A plain merge satisfies both: undefined keys fall through to the existing
889
+ // value (preserving launch-stamp settings like autoApprove + the mesh routing
890
+ // keys), explicit keys override. This subsumes the previous mesh-key preserve
891
+ // list, which only protected the routing keys and not autoApprove.
892
+ this.settings = { ...this.settings, ...newSettings };
669
893
  this.adapter.updateRuntimeSettings?.(this.settings);
670
894
  this.monitor.updateConfig({
671
895
  approvalAlert: this.settings.approvalAlert !== false,
672
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
673
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
896
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
897
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
674
898
  });
675
899
  }
676
900
 
901
+ /**
902
+ * Stamp a direct-dispatch mesh assignment on this instance.
903
+ * setupMeshEventForwarding reads settings.meshNodeFor + meshActiveTaskId to
904
+ * route generating_completed back to the originating coordinator. Without
905
+ * this stamp, mesh_send_task --direct targets a plain CLI session whose
906
+ * completion events silently drop because the forwarder has nothing to
907
+ * match against.
908
+ */
909
+ attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void {
910
+ if (!assignment?.meshId) return;
911
+ this.settings = {
912
+ ...this.settings,
913
+ meshNodeFor: assignment.meshId,
914
+ // WTCLAIM (A): track the bound node id under BOTH the active marker
915
+ // (meshNodeId, cleared on detach) and a sticky marker (meshLastNodeId,
916
+ // preserved across detach). The sticky marker lets a detached but still
917
+ // coordinator-owned session be re-picked ONLY for the SAME node it served
918
+ // — never auto-adopted for a sibling node (e.g. a cloned worktree) that
919
+ // shares this daemon. See isMeshOwnedDelegateSession's post-detach gate.
920
+ ...(assignment.nodeId ? { meshNodeId: assignment.nodeId, meshLastNodeId: assignment.nodeId } : {}),
921
+ ...(assignment.taskId ? { meshActiveTaskId: assignment.taskId } : {}),
922
+ ...(assignment.coordinatorDaemonId ? { meshCoordinatorDaemonId: assignment.coordinatorDaemonId } : {}),
923
+ // Session-level routing anchor: the originating coordinator session, so this
924
+ // worker's completion events route back to the exact session that dispatched it.
925
+ ...(assignment.coordinatorSessionId ? { meshCoordinatorSessionId: assignment.coordinatorSessionId } : {}),
926
+ };
927
+ this.adapter.updateRuntimeSettings?.(this.settings);
928
+ }
929
+
930
+ /**
931
+ * Clear a previously-attached mesh assignment after the task reaches a
932
+ * terminal state. Leaving meshNodeFor pinned would route this session's
933
+ * subsequent unrelated turns (e.g. ad-hoc dashboard chats) to the
934
+ * coordinator as if they were task completions.
935
+ */
936
+ detachMeshAssignment(): void {
937
+ if (!this.settings.meshNodeFor && !this.settings.meshActiveTaskId && !this.settings.meshNodeId) return;
938
+ const { meshNodeFor, meshNodeId, meshActiveTaskId, ...rest } = this.settings;
939
+ void meshNodeFor; void meshActiveTaskId;
940
+ // WTCLAIM (A): clear the active binding but PRESERVE the last bound node id
941
+ // (meshLastNodeId) so a later sessionless dispatch can re-adopt this idle
942
+ // session ONLY for the node it last served. Carry the id being cleared, or
943
+ // keep an already-present sticky marker if meshNodeId was absent.
944
+ const lastNodeId = (typeof meshNodeId === 'string' && meshNodeId.trim())
945
+ ? meshNodeId.trim()
946
+ : (typeof rest.meshLastNodeId === 'string' && rest.meshLastNodeId.trim() ? rest.meshLastNodeId.trim() : undefined);
947
+ this.settings = lastNodeId ? { ...rest, meshLastNodeId: lastNodeId } : rest;
948
+ this.adapter.updateRuntimeSettings?.(this.settings);
949
+ }
950
+
951
+ /**
952
+ * The resolved modal-park status of this session, or null when it is not
953
+ * parked on a modal awaiting a human answer. Mirrors the overlay logic in
954
+ * getState(): an active AskUserQuestion interactive prompt resolves to
955
+ * waiting_choice; otherwise the adapter's waiting_approval (tool consent)
956
+ * counts — UNLESS auto-approve will dismiss it, in which case the session is
957
+ * effectively generating and is NOT modal-parked. This is the single signal
958
+ * the mesh force-inject guard consults, and the same status string the
959
+ * reconcile loop reads off get_status_metadata. Lowercase literals only —
960
+ * the SessionStatus enum is forked across modules and waiting_choice is
961
+ * absent from some of them.
962
+ */
963
+ resolveModalParkStatus(): 'waiting_choice' | 'waiting_approval' | null {
964
+ if (this.activeInteractivePrompt) return 'waiting_choice';
965
+ let adapterStatus: { status?: string };
966
+ try {
967
+ adapterStatus = this.adapter.getStatus({ allowParse: false });
968
+ } catch {
969
+ return null;
970
+ }
971
+ // A session whose auto-approve is held by manual attendance IS parked on a
972
+ // modal awaiting the human — autoApproveEffectivelyActive folds that in, so
973
+ // the mesh force-inject guard correctly treats it as modal-parked.
974
+ if (adapterStatus.status === 'waiting_approval' && !this.autoApproveEffectivelyActive(adapterStatus.status)) {
975
+ return 'waiting_approval';
976
+ }
977
+ return null;
978
+ }
979
+
980
+ /** True when this session is parked on a modal awaiting a human answer. */
981
+ isModalParked(): boolean {
982
+ return this.resolveModalParkStatus() !== null;
983
+ }
984
+
677
985
  onEvent(event: string, data?: any): void {
678
986
  if (event === 'send_message') {
679
987
  const input = normalizeInputEnvelope(data);
680
988
  assertProviderSupportsDeclaredInput(this.provider, input);
681
989
  const promptText = buildCliStructuredInputPrompt(input);
682
990
  if (promptText) {
683
- void this.adapter.sendMessage(promptText).catch((e: any) => {
991
+ // force:true bypasses the busy/generating send guard so terminal mesh
992
+ // events (completion/failure/bootstrap) land in a coordinator session that
993
+ // is itself parked in `generating` while awaiting that very event.
994
+ // Without it the message is queued and only flushed on the coordinator's
995
+ // own idle transition — which never happens until it receives the message.
996
+ const force = data?.force === true;
997
+ // Modal guard: a force-inject still writes raw keystrokes into the PTY,
998
+ // bypassing the busy send-guard. If the coordinator is parked on a
999
+ // harness modal (claude-cli AskUserQuestion → waiting_choice, or a
1000
+ // tool-consent waiting_approval), those keystrokes are consumed by the
1001
+ // modal's key handler and silently select a choice the user never made
1002
+ // (data corruption). Hold the force-inject in that narrow window —
1003
+ // the event stays queued and the reconcile loop redelivers it on the
1004
+ // next tick once the modal is resolved. We ONLY hold for the two modal
1005
+ // states; generating is still force-injected (that is the deadlock the
1006
+ // force path exists to break — see mesh-events-coordinator).
1007
+ if (force && this.isModalParked()) {
1008
+ LOG.info('CLI', `[${this.type}] force send_message held — coordinator parked on modal (${this.resolveModalParkStatus()})`);
1009
+ return;
1010
+ }
1011
+ void this.adapter.sendMessage(promptText, force ? { force: true } : {}).catch((e: any) => {
684
1012
  LOG.warn('CLI', `[${this.type}] send_message failed: ${e?.message || e}`);
685
1013
  });
686
1014
  }
@@ -690,14 +1018,69 @@ export class CliProviderInstance implements ProviderInstance {
690
1018
  void this.adapter.resolveAction(data).catch((e: any) => {
691
1019
  LOG.warn('CLI', `[${this.type}] resolve_action failed: ${e?.message || e}`);
692
1020
  });
1021
+ } else if (event === 'interactive_prompt' && data) {
1022
+ const prompt = normalizeInteractivePrompt(data);
1023
+ if (prompt) {
1024
+ this.activeInteractivePrompt = prompt;
1025
+ this.events.push({
1026
+ event: 'interactive_prompt',
1027
+ timestamp: Date.now(),
1028
+ promptId: prompt.promptId,
1029
+ });
1030
+ }
1031
+ } else if (event === 'interactive_prompt_response' && data) {
1032
+ try {
1033
+ const response = normalizeInteractivePromptResponse(data);
1034
+ if (this.activeInteractivePrompt?.promptId === response.promptId) {
1035
+ this.activeInteractivePrompt = null;
1036
+ }
1037
+ if (typeof this.adapter.setInteractivePromptResponse !== 'function') {
1038
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response ignored: adapter does not support interactive prompts`);
1039
+ return;
1040
+ }
1041
+ void this.adapter.setInteractivePromptResponse(response).catch((e: any) => {
1042
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response failed: ${e?.message || e}`);
1043
+ });
1044
+ } catch (e: any) {
1045
+ LOG.warn('CLI', `[${this.type}] invalid interactive_prompt_response: ${e?.message || e}`);
1046
+ }
693
1047
  } else if (event === 'provider_state_patch' && data && typeof data === 'object') {
694
1048
  this.applyProviderResponse(data, { phase: 'immediate' });
695
1049
  }
696
1050
  }
697
1051
 
1052
+ recordAcknowledgedUserInput(input: InputEnvelope | string): void {
1053
+ const content = typeof input === 'string'
1054
+ ? input.trim()
1055
+ : buildCliStructuredInputPrompt(input).trim();
1056
+ if (!content) return;
1057
+
1058
+ const receivedAt = Date.now();
1059
+ this.lastAcknowledgedUserInputAt = receivedAt;
1060
+ const dedupKey = `user_input_ack:${shortHash(`${this.instanceId}:${content}:${receivedAt}`, 24)}`;
1061
+ this.appendRuntimeMessage(buildChatMessage({
1062
+ role: 'user',
1063
+ senderName: 'User',
1064
+ kind: 'standard',
1065
+ content,
1066
+ receivedAt,
1067
+ timestamp: receivedAt,
1068
+ source: 'runtime_input_ack',
1069
+ meta: {
1070
+ runtimeInputAck: true,
1071
+ provider: this.type,
1072
+ workspace: this.workingDir,
1073
+ },
1074
+ } as ChatMessage), dedupKey);
1075
+ }
1076
+
698
1077
  dispose(): void {
699
1078
  this.adapter.shutdown();
700
1079
  this.monitor.reset();
1080
+ // Cancel any armed auto-approve timers so a pending settle re-check
1081
+ // can't fire resolveModal/detectStatusTransition against a dead adapter.
1082
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1083
+ if (this.autoApproveBusyTimer) { clearTimeout(this.autoApproveBusyTimer); this.autoApproveBusyTimer = null; }
701
1084
  this.appliedEffectKeys.clear();
702
1085
  try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
703
1086
  this.cachedSqliteDb = null;
@@ -706,6 +1089,7 @@ export class CliProviderInstance implements ProviderInstance {
706
1089
 
707
1090
  private completedDebounceTimer: NodeJS.Timeout | null = null;
708
1091
  private completedDebouncePending: CompletedDebouncePending | null = null;
1092
+ private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
709
1093
 
710
1094
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
711
1095
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -740,7 +1124,180 @@ export class CliProviderInstance implements ProviderInstance {
740
1124
  const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
741
1125
  const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
742
1126
  const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
743
- return role === 'assistant' && !!content;
1127
+ if (role !== 'assistant' || !content) return false;
1128
+ // Guard: if the last assistant message looks like an active approval/input prompt,
1129
+ // it is not a real completion — the session is still awaiting user input.
1130
+ if (looksLikeActiveApprovalPromptText(content)) return false;
1131
+ return true;
1132
+ }
1133
+
1134
+ private buildExternalTranscriptProbe(messages: unknown[], sourcePath?: string, sourceMtimeMs?: number): ExternalTranscriptProbe {
1135
+ const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1136
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1137
+ const readAt = Date.now();
1138
+ const mtimeMs = Number(sourceMtimeMs) || 0;
1139
+ return {
1140
+ readAt,
1141
+ msgCount: messages.length,
1142
+ lastRole: typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null,
1143
+ lastKind: typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null,
1144
+ contentLen: lastVisible ? flattenContent(lastVisible.content).trim().length : 0,
1145
+ sourcePath: typeof sourcePath === 'string' && sourcePath ? sourcePath : null,
1146
+ sourceMtimeMs: mtimeMs || null,
1147
+ mtimeAgeMs: mtimeMs ? Math.max(0, readAt - mtimeMs) : null,
1148
+ };
1149
+ }
1150
+
1151
+ private recordPendingTranscriptProbe(pending: CompletedDebouncePending): ExternalTranscriptProbe | null {
1152
+ const probe = this.lastExternalCompletionProbe;
1153
+ if (!probe) return null;
1154
+ const history = pending.transcriptProbeHistory || [];
1155
+ const last = history[history.length - 1];
1156
+ if (!last || last.readAt !== probe.readAt || last.msgCount !== probe.msgCount || last.lastRole !== probe.lastRole || last.contentLen !== probe.contentLen) {
1157
+ history.push(probe);
1158
+ pending.transcriptProbeHistory = history.slice(-5);
1159
+ }
1160
+ return probe;
1161
+ }
1162
+
1163
+ private readExternalCompletionMessages(): unknown[] | null {
1164
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1165
+ if (!adapterOwnsMessagesElsewhere) return null;
1166
+ if (!this.providerSessionId) return null;
1167
+ if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
1168
+
1169
+ if (this.lastExternalCompletionProbe?.sourcePath) {
1170
+ try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
1171
+ }
1172
+ const restoredHistory = readProviderChatHistory(this.type, {
1173
+ canonicalHistory: this.provider.nativeHistory,
1174
+ historySessionId: this.providerSessionId,
1175
+ workspace: this.workingDir,
1176
+ offset: 0,
1177
+ limit: Number.MAX_SAFE_INTEGER,
1178
+ historyBehavior: this.provider.historyBehavior,
1179
+ scripts: this.provider.scripts as any,
1180
+ sessionStartedAtMs: this.startedAt,
1181
+ forceRefresh: true,
1182
+ });
1183
+ if (restoredHistory.source !== 'provider-native') {
1184
+ this.lastExternalCompletionProbe = null;
1185
+ return null;
1186
+ }
1187
+ this.lastExternalCompletionProbe = this.buildExternalTranscriptProbe(
1188
+ restoredHistory.messages,
1189
+ restoredHistory.sourcePath,
1190
+ restoredHistory.sourceMtimeMs,
1191
+ );
1192
+ return restoredHistory.messages;
1193
+ }
1194
+
1195
+ private completionFinalAssistantEvidence(parsedMessages: unknown): CompletionFinalAssistantEvidence {
1196
+ if (this.completionHasFinalAssistantMessage(parsedMessages)) {
1197
+ return {
1198
+ present: true,
1199
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1200
+ source: 'parsed',
1201
+ };
1202
+ }
1203
+
1204
+ const externalMessages = this.readExternalCompletionMessages();
1205
+ if (externalMessages) {
1206
+ return {
1207
+ present: this.completionHasFinalAssistantMessage(externalMessages),
1208
+ messages: externalMessages,
1209
+ source: 'external-native',
1210
+ };
1211
+ }
1212
+
1213
+ return {
1214
+ present: false,
1215
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1216
+ source: 'unavailable',
1217
+ };
1218
+ }
1219
+
1220
+ private completionFinalSummary(parsedMessages: unknown): string | undefined {
1221
+ // For native-source providers (claude-cli: chatMessagesOwnedExternally), the PTY
1222
+ // screen parse is NOT the source of truth for the final summary — the terminal
1223
+ // wraps/scrolls/clips text, so a screen-parsed assistant message is often a partial
1224
+ // prefix (e.g. 76 chars of a 112-char turn). The append-only native transcript holds
1225
+ // the complete turn. Prefer it whenever it yields a longer/complete summary; fall back
1226
+ // to the parsed screen only when the transcript is unavailable. This is the real cause
1227
+ // of the truncated finalSummary — independent of cloud vs standalone (it surfaces on
1228
+ // any short, fast-completing task where screen parse wins the race).
1229
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1230
+ const parsedSummary = extractFinalSummaryFromMessages(
1231
+ (this.completionHasFinalAssistantMessage(parsedMessages)
1232
+ ? (Array.isArray(parsedMessages) ? parsedMessages : [])
1233
+ : []) as any,
1234
+ );
1235
+ if (adapterOwnsMessagesElsewhere) {
1236
+ const externalMessages = this.readExternalCompletionMessages();
1237
+ const externalSummary = externalMessages
1238
+ ? extractFinalSummaryFromMessages(externalMessages as any)
1239
+ : '';
1240
+ // The transcript is authoritative for native-source providers. Use it unless it is
1241
+ // empty (not yet written) — only then fall back to whatever the screen parsed.
1242
+ if (externalSummary) return externalSummary;
1243
+ return parsedSummary || undefined;
1244
+ }
1245
+ return parsedSummary || undefined;
1246
+ }
1247
+
1248
+ private buildCompletedFinalizationDiagnostic(args: {
1249
+ blockReason: string;
1250
+ latestStatus?: any;
1251
+ latestVisibleStatus: string;
1252
+ waitedMs: number;
1253
+ pending: CompletedDebouncePending;
1254
+ emittedAfterFinalizationTimeout: boolean;
1255
+ }): Record<string, unknown> {
1256
+ let parsed: any = null;
1257
+ let parseError: string | undefined;
1258
+ try {
1259
+ parsed = this.adapter.getScriptParsedStatus();
1260
+ } catch (error: any) {
1261
+ parseError = error?.message || String(error);
1262
+ }
1263
+
1264
+ const evidence = this.completionFinalAssistantEvidence(parsed?.messages);
1265
+ if (evidence.source === 'external-native') {
1266
+ this.recordPendingTranscriptProbe(args.pending);
1267
+ }
1268
+ const visibleMessages = (Array.isArray(evidence.messages) ? evidence.messages : [])
1269
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1270
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1271
+ const lastVisibleRole = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null;
1272
+ const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
1273
+ const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
1274
+
1275
+ return {
1276
+ providerType: this.type,
1277
+ sessionId: this.instanceId,
1278
+ providerSessionId: this.providerSessionId || null,
1279
+ workspace: this.workingDir,
1280
+ blockReason: args.blockReason,
1281
+ emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
1282
+ waitedMs: args.waitedMs,
1283
+ maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
1284
+ adapterStatus: typeof args.latestStatus?.status === 'string' ? args.latestStatus.status : null,
1285
+ latestVisibleStatus: args.latestVisibleStatus,
1286
+ parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
1287
+ parseError: parseError || undefined,
1288
+ finalAssistantPresent: evidence.present,
1289
+ finalAssistantEvidenceSource: evidence.source,
1290
+ visibleMessageCount: visibleMessages.length,
1291
+ lastVisibleRole,
1292
+ lastVisibleKind,
1293
+ lastVisibleContentLength,
1294
+ pendingStartedAt: this.generatingStartedAt || null,
1295
+ pendingFirstObservedAt: args.pending.firstObservedAt,
1296
+ pendingTimestamp: args.pending.timestamp,
1297
+ pendingDurationSec: args.pending.duration,
1298
+ previousBlockReason: args.pending.loggedBlockReason || null,
1299
+ transcriptProbeHistory: args.pending.transcriptProbeHistory || [],
1300
+ };
744
1301
  }
745
1302
 
746
1303
  private hasAdapterPendingResponse(): boolean {
@@ -765,41 +1322,172 @@ export class CliProviderInstance implements ProviderInstance {
765
1322
  if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
766
1323
  if (adapterRawStatus !== 'idle') return false;
767
1324
  if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
768
- return !this.hasAdapterPendingResponse();
1325
+ if (this.hasAdapterPendingResponse()) return false;
1326
+ // Do not suppress when the adapter's raw response buffer is still non-empty.
1327
+ // This catches the case where isWaitingForResponse has already flipped to false
1328
+ // (so getPartialResponse() returns '') but the provider's native parser still
1329
+ // reports generating because it's parsing buffered content. Suppressing the
1330
+ // finalization block here would emit a false completion event while the provider
1331
+ // session is still actively processing its response stream.
1332
+ const adapterAny = this.adapter as any;
1333
+ if (typeof adapterAny?.responseBuffer === 'string' && adapterAny.responseBuffer.trim()) return false;
1334
+ return true;
769
1335
  }
770
1336
 
771
- private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
- if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
1337
+ private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1338
+ if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
773
1339
 
774
1340
  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';
1341
+ const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1342
+ if (!approvalResolvedIdle) {
1343
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
1344
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
1345
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
1346
+ }
777
1347
 
778
1348
  const partial = typeof this.adapter.getPartialResponse === 'function'
779
1349
  ? this.adapter.getPartialResponse()
780
1350
  : '';
781
- if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
1351
+ if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
782
1352
 
783
1353
  let parsed: any;
784
1354
  try {
785
1355
  parsed = this.adapter.getScriptParsedStatus();
786
1356
  } catch (error: any) {
787
- return `parse_error:${error?.message || String(error)}`;
1357
+ return { reason: `parse_error:${error?.message || String(error)}` };
788
1358
  }
789
1359
 
790
1360
  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';
1361
+ if (parsedStatus !== 'idle') {
1362
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
1363
+ if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
1364
+ return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
1365
+ }
1366
+ if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
1367
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1368
+ const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
1369
+ const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1370
+ LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1371
+ if (!finalAssistantEvidence.present) {
1372
+ if (adapterOwnsMessagesElsewhere) {
1373
+ if (finalAssistantEvidence.source === 'external-native') {
1374
+ const probe = this.recordPendingTranscriptProbe(pending);
1375
+ if (probe && !pending.loggedTranscriptProbe) {
1376
+ 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`);
1377
+ pending.loggedTranscriptProbe = true;
1378
+ }
1379
+ LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
1380
+ if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
1381
+ return null;
1382
+ }
1383
+ if (this.type === 'antigravity-cli') {
1384
+ return null;
1385
+ }
1386
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1387
+ }
1388
+ if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
1389
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1390
+ }
1391
+ } else {
1392
+ LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
1393
+ return {
1394
+ reason: 'missing_final_assistant',
1395
+ terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
1396
+ allowTimeout: allowMissingAssistantTimeout,
1397
+ };
1398
+ }
1399
+ }
1400
+
1401
+ // (FALSEIDLE-a) Structural approval-resolution gate. Runs BEFORE the brittle
1402
+ // screen-text heuristic below so it also catches modals whose text does not match
1403
+ // looksLikeActiveApprovalPromptText (e.g. claude-cli's cd / "untrusted hooks" prompt).
1404
+ const approvalResolutionBlock = this.approvalResolutionFinalizationBlock(pending);
1405
+ if (approvalResolutionBlock) return approvalResolutionBlock;
1406
+
1407
+ // Guard: if the screen still shows an approval/choice prompt as the last visible text,
1408
+ // the turn is not complete even if the parsed status says idle and there is an assistant
1409
+ // message. This catches the case where waiting_approval→idle transitions occur before
1410
+ // the modal has been resolved (e.g. the PTY rendered the prompt but no button press fired).
1411
+ try {
1412
+ const screenText = typeof (this.adapter as any).getScreenText === 'function'
1413
+ ? String((this.adapter as any).getScreenText() || '')
1414
+ : '';
1415
+ if (screenText) {
1416
+ const tailLines = screenText.split(/\r?\n/).slice(-16).join('\n');
1417
+ if (looksLikeActiveApprovalPromptText(tailLines)) {
1418
+ return { reason: 'screen_shows_approval_prompt', terminal: approvalResolvedIdle };
1419
+ }
1420
+ }
1421
+ } catch { /* defensive: screen text read is best-effort */ }
794
1422
 
795
1423
  return null;
796
1424
  }
797
1425
 
1426
+ // (FALSEIDLE-a) Positive, structural proof that the latest approval entry was resolved
1427
+ // through ADHDev. resolveModal() — driven by auto-approve, dashboard/mesh_approve, and
1428
+ // dev-cli-debug alike — advances the engine's lastResolvedEntrySeq to the current
1429
+ // approvalEntrySeq. So `lastResolvedEntrySeq >= approvalEntrySeq` (with a real entry,
1430
+ // approvalEntrySeq > 0) means the modal we last saw was actually answered. Absence of this
1431
+ // evidence after a waiting_approval→idle transition means the idle is suspect: the spec's
1432
+ // text-based approval→idle rule false-tripped while the modal is still unresolved.
1433
+ // Fails OPEN (returns true) when the seq fields are unavailable, so the gate can never wedge
1434
+ // a session on a provider/adapter that does not surface the counters.
1435
+ private hasApprovalResolutionEvidence(): boolean {
1436
+ try {
1437
+ const status = this.adapter.getStatus({ allowParse: false }) as any;
1438
+ const entrySeq = typeof status?.approvalEntrySeq === 'number' ? status.approvalEntrySeq : 0;
1439
+ if (entrySeq <= 0) return true;
1440
+ const resolvedSeq = typeof status?.lastResolvedEntrySeq === 'number' ? status.lastResolvedEntrySeq : undefined;
1441
+ if (resolvedSeq === undefined) return true;
1442
+ return resolvedSeq >= entrySeq;
1443
+ } catch {
1444
+ return true;
1445
+ }
1446
+ }
1447
+
1448
+ // (FALSEIDLE-a) Hold a completion that is the anomalous DIRECT waiting_approval→idle
1449
+ // transition with no positive resolution evidence. A genuinely resolved approval routes
1450
+ // through resolveModal → setStatus('generating'), so its completion's previousStatus is
1451
+ // 'generating' (not 'waiting_approval') and this gate never fires for it. Scoped to
1452
+ // delegated mesh/coordinator sessions — whose only modal-resolution path is auto-approve /
1453
+ // mesh_approve (both advance lastResolvedEntrySeq) — so an interactive local session, where
1454
+ // a human may answer the PTY prompt directly and leave no resolveModal record, is untouched.
1455
+ // Non-terminal: the hold is bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS (30s), giving a
1456
+ // settling auto-approve time to fire and advance the seq, and guaranteeing no permanent wedge
1457
+ // if resolution ever happens via a path that does not record evidence.
1458
+ private approvalResolutionFinalizationBlock(pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1459
+ if (pending.previousStatus !== 'waiting_approval') return null;
1460
+ const meshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1461
+ if (!meshContext) return null;
1462
+ if (this.hasApprovalResolutionEvidence()) return null;
1463
+ return { reason: 'approval_resolution_unconfirmed', terminal: false };
1464
+ }
1465
+
798
1466
  private scheduleCompletedDebounceFlush(delayMs: number): void {
799
1467
  if (this.completedDebounceTimer) clearTimeout(this.completedDebounceTimer);
800
1468
  this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
801
1469
  }
802
1470
 
1471
+ // EVTTRACE (observation-only): is this a mesh worker session whose completion
1472
+ // events must route to a coordinator? Used purely to gate trace logging so a
1473
+ // non-mesh CLI session's completions don't add EvtTrace noise. No decision logic.
1474
+ private isMeshWorkerSession(): boolean {
1475
+ return !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId
1476
+ || this.settings.meshNodeId || this.settings.launchedByCoordinator);
1477
+ }
1478
+
1479
+ // EVTTRACE correlation context for this session's completion lifecycle. taskId is
1480
+ // the primary grep anchor; instanceId is the session fallback.
1481
+ private meshTraceCtx(event = 'agent:generating_completed'): Record<string, unknown> {
1482
+ return {
1483
+ taskId: this.settings.meshActiveTaskId,
1484
+ sessionId: this.instanceId,
1485
+ nodeId: this.settings.meshNodeId,
1486
+ meshId: this.settings.meshNodeFor,
1487
+ event,
1488
+ };
1489
+ }
1490
+
803
1491
  private flushCompletedDebounceIfFinalized(): void {
804
1492
  const pending = this.completedDebouncePending;
805
1493
  if (!pending) {
@@ -809,7 +1497,8 @@ export class CliProviderInstance implements ProviderInstance {
809
1497
 
810
1498
  const latestStatus = this.adapter.getStatus({ allowParse: false });
811
1499
  const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
812
- const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
1500
+ const latestVisibleStatus = latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status;
1501
+ 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
1502
  if (latestVisibleStatus !== 'idle') {
814
1503
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
815
1504
  this.completedDebouncePending = null;
@@ -817,84 +1506,350 @@ export class CliProviderInstance implements ProviderInstance {
817
1506
  return;
818
1507
  }
819
1508
 
820
- const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
- if (blockReason) {
1509
+ const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
1510
+ if (block) {
1511
+ const blockReason = block.reason;
822
1512
  const waitedMs = Date.now() - pending.firstObservedAt;
823
- if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
1513
+ // CANON-C (completion-gate decouple): a block carrying `allowTimeout` is the
1514
+ // transcript-evidence gate — the worker FSM has ALREADY reached idle and the only
1515
+ // thing missing is the append-only transcript's final assistant turn (a native-source
1516
+ // race: claude-cli owns its history externally and the file write trails the idle
1517
+ // transition). `allowTimeout` is set ONLY on the missing_final_assistant block, and
1518
+ // ONLY for mesh worker sessions (meshNodeFor / meshActiveTaskId / launchedByCoordinator).
1519
+ // The coordinator's sole path to learn this session is idle is agent:generating_completed,
1520
+ // so holding it up to COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) leaves the coordinator
1521
+ // false-generating while the worker is done. Decouple the idle NOTIFICATION from the
1522
+ // transcript evidence: emit the completion immediately, marked weak
1523
+ // (completionDiagnostic.blockReason=missing_final_assistant, finalAssistantPresent=false).
1524
+ // The finalSummary is enriched on a SEPARATE path — the mesh reconcile loop reads the
1525
+ // transcript once written and re-emits a GENUINE completion (CANON-B weak→genuine
1526
+ // upgrade; buildPendingEventFingerprint keeps weak and genuine distinct so the enriched
1527
+ // one still surfaces, and isFalseIdleCompletion keeps the direct dispatch active until
1528
+ // then). All OTHER blocks (genuinely-busy adapter/partial/parsed states, transient
1529
+ // parse_error) keep the existing terminal-hold / 30s-retry behavior unchanged.
1530
+ const isTranscriptEvidenceGate = block.allowTimeout === true;
1531
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} allowTimeout=${isTranscriptEvidenceGate} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1532
+ if (!isTranscriptEvidenceGate && (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS)) {
824
1533
  if (pending.loggedBlockReason !== blockReason) {
825
1534
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
1535
+ // EVTTRACE: completion held by the finalization gate (CANON-C). Observation
1536
+ // only — does not change the hold decision above.
1537
+ if (this.isMeshWorkerSession()) {
1538
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
1539
+ }
826
1540
  pending.loggedBlockReason = blockReason;
827
1541
  }
828
1542
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
1543
  return;
830
1544
  }
831
- LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1545
+ const emittedAfterFinalizationTimeout = waitedMs >= COMPLETED_FINALIZATION_MAX_WAIT_MS;
1546
+ const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
1547
+ blockReason,
1548
+ latestStatus,
1549
+ latestVisibleStatus,
1550
+ waitedMs,
1551
+ pending,
1552
+ emittedAfterFinalizationTimeout,
1553
+ });
1554
+ // Surface the CANON-C immediate-emit path distinctly so a delegated worker's idle
1555
+ // notification (transcript still pending) is not mistaken for a 30s-timeout fallback.
1556
+ (completionDiagnostic as Record<string, unknown>).decoupledImmediateEmit = isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout;
1557
+ LOG.warn('CLI', `[${this.type}] emitting completed event (${isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'CANON-C decoupled-immediate, transcript pending' : `after ${waitedMs}ms`}) without finalized assistant turn (${blockReason})`);
1558
+ // EVTTRACE: completion fired (forced past the finalization timeout / CANON-C decoupled-immediate).
1559
+ if (this.isMeshWorkerSession()) {
1560
+ traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
1561
+ }
1562
+ this.pushEvent({
1563
+ event: 'agent:generating_completed',
1564
+ chatTitle: pending.chatTitle,
1565
+ duration: pending.duration,
1566
+ timestamp: pending.timestamp,
1567
+ // When finalization is forced past the timeout on a `parsed_status:` block
1568
+ // (the parser never confirmed a final assistant turn) we previously rode an
1569
+ // empty `finalSummary` unconditionally. That empty value propagates to the
1570
+ // mesh coordinator's mirror preview (meshSessionLastMessagePreview), leaving a
1571
+ // delegated session's inbox preview blank — or, for a LOCAL worktree session,
1572
+ // stuck on the dispatched user task. If the parser DID surface assistant text,
1573
+ // prefer it; only fall back to '' when no assistant summary can be derived.
1574
+ finalSummary: blockReason.startsWith('parsed_status:')
1575
+ ? (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages) ?? '')
1576
+ : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1577
+ completionDiagnostic,
1578
+ });
832
1579
  this.completedDebouncePending = null;
833
1580
  this.completedDebounceTimer = null;
834
1581
  this.generatingStartedAt = 0;
1582
+ this.lastApprovalEventFingerprint = '';
835
1583
  return;
836
1584
  }
837
1585
 
838
1586
  LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
1587
+ // EVTTRACE: completion fired (transcript finalized cleanly).
1588
+ if (this.isMeshWorkerSession()) {
1589
+ traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
1590
+ }
839
1591
  this.pushEvent({
840
1592
  event: 'agent:generating_completed',
841
1593
  chatTitle: pending.chatTitle,
842
1594
  duration: pending.duration,
843
1595
  timestamp: pending.timestamp,
844
- finalSummary: extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages),
1596
+ finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
845
1597
  });
846
1598
  this.completedDebouncePending = null;
847
1599
  this.completedDebounceTimer = null;
848
1600
  this.generatingStartedAt = 0;
1601
+ this.lastApprovalEventFingerprint = '';
849
1602
  }
850
1603
 
851
1604
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
1605
+ // Manual-attendance suppression (provider-common): when a human is
1606
+ // actively driving this session from the dashboard, hold auto-approve so
1607
+ // the modal stays visible and they can pick a button / use the controlbar
1608
+ // themselves. Return false (NOT auto-approving) so getState keeps the
1609
+ // modal surfaced. Clear any in-progress settle gate — a genuine fire
1610
+ // after the window lapses must re-settle from scratch — and arm a
1611
+ // re-check for the lapse moment, because the PTY may have gone silent and
1612
+ // would otherwise never re-drive this decision. Background mesh workers
1613
+ // are never attended, so their delegated auto-approve is untouched.
1614
+ if (adapterStatus?.status === 'waiting_approval'
1615
+ && this.shouldAutoApprove()
1616
+ && this.manualAttendance.isAttended(now)) {
1617
+ this.lastAutoApprovalSignature = '';
1618
+ this.pendingAutoApprovalSignature = '';
1619
+ this.pendingAutoApprovalSince = 0;
1620
+ this.autoApproveInactiveSince = 0;
1621
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1622
+ this.autoApproveSettleTimer = setTimeout(() => {
1623
+ this.autoApproveSettleTimer = null;
1624
+ this.recheckAutoApproveSettled();
1625
+ }, this.manualAttendance.remainingMs(now) + 20);
1626
+ return false;
1627
+ }
852
1628
  const autoApproveActive = adapterStatus?.status === 'waiting_approval' && this.shouldAutoApprove();
853
1629
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
854
1630
  // times while the PTY absorbs the approval key. Without this flag, repeated
855
1631
  // 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);
1632
+ // However, Claude Code can present a second approval immediately after the
1633
+ // first. Resolve a changed modal signature even while the previous write is
1634
+ // still inside the short busy window.
1635
+ if (!autoApproveActive) {
1636
+ this.lastAutoApprovalSignature = '';
1637
+ // Hysteresis: if a settle gate is mid-progress, a momentary
1638
+ // status!=waiting_approval blip (a generating flip while the same
1639
+ // modal's button block is still on screen) must NOT wipe the settle
1640
+ // clock otherwise the modal→generating→modal flap restarts the
1641
+ // 600ms window every time and auto-approve never fires. Keep the
1642
+ // gate warm for AUTO_APPROVE_GATE_HYSTERESIS_MS; re-arm a timer so
1643
+ // that if the modal does NOT come back the gate is cleared on the
1644
+ // re-check (a genuine resolution → idle frees the gate normally).
1645
+ if (this.pendingAutoApprovalSince) {
1646
+ if (!this.autoApproveInactiveSince) this.autoApproveInactiveSince = now;
1647
+ const goneForMs = now - this.autoApproveInactiveSince;
1648
+ if (goneForMs < CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS) {
1649
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1650
+ this.autoApproveSettleTimer = setTimeout(() => {
1651
+ this.autoApproveSettleTimer = null;
1652
+ this.recheckAutoApproveSettled();
1653
+ }, CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS - goneForMs + 20);
1654
+ return autoApproveActive;
1655
+ }
1656
+ }
1657
+ // Clear the settle gate so the next approval starts its own quiet
1658
+ // window from scratch (a stale timestamp would let it fire instantly).
1659
+ this.pendingAutoApprovalSignature = '';
1660
+ this.pendingAutoApprovalSince = 0;
1661
+ this.autoApproveInactiveSince = 0;
1662
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1663
+ return autoApproveActive;
1664
+ }
1665
+ // Active approval observed — reset the inactivity tracker so a later
1666
+ // blip starts its hysteresis window fresh.
1667
+ this.autoApproveInactiveSince = 0;
1668
+ const modal = adapterStatus.activeModal;
1669
+ // (fix) Do not auto-approve when no concrete modal/buttons are present.
1670
+ // Claude TUI flaps between paints; without this guard adapterStatus
1671
+ // could report status=waiting_approval with activeModal=null (or with
1672
+ // an empty buttons array briefly) and we'd still call
1673
+ // resolveModal(-1) — which used to type "1" into the prompt
1674
+ // repeatedly. Skip until a real modal is captured.
1675
+ const buttons = Array.isArray(modal?.buttons)
1676
+ ? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
1677
+ : [];
1678
+ if (!modal || buttons.length === 0) {
1679
+ return autoApproveActive;
1680
+ }
1681
+ // Picker/confirm exclusion (provider-common). A /model or /mode picker is
1682
+ // surfaced with status=waiting_approval so the dashboard shows it, but it
1683
+ // has no "correct" answer to auto-pick — blindly selecting the first
1684
+ // option silently switches the model (the "always Opus, before I even
1685
+ // choose" bug). Two independent gates, BOTH must pass to fire:
1686
+ //
1687
+ // (1) modal_kind — the spec/FSM tells us this is an 'approval' modal,
1688
+ // not a 'picker'/'confirm'. A modal whose kind is unknown (legacy
1689
+ // adapter, or a spec that predates modal_kind) reads as 'approval'
1690
+ // so genuine approvals keep auto-approving; only an explicit
1691
+ // 'picker'/'confirm' is excluded here.
1692
+ // (2) structural anchor — a real approval offers an affirmative AND a
1693
+ // decline option (pickApprovalButton finds a positive that isn't a
1694
+ // decline, and hasNegativeApprovalOption confirms a No/Cancel/Deny
1695
+ // is present). A model picker ("1. Default 2. Opus 3. Sonnet")
1696
+ // has no decline, so even an un-migrated picker is caught here.
1697
+ //
1698
+ // Mirrors the SDK v1 detect-status approval heuristic (detect-status.ts).
1699
+ const modalKind = typeof modal?.kind === 'string' ? modal.kind : 'approval';
1700
+ if (modalKind !== 'approval') {
1701
+ // Picker/confirm — leave it for the user; keep the modal surfaced.
1702
+ return autoApproveActive;
1703
+ }
1704
+ const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
1705
+ if (buttonIndex < 0 || !hasNegativeApprovalOption(buttons)) {
1706
+ // No affirmative matched, or no decline option present (→ not a real
1707
+ // consent prompt, e.g. a picker that slipped past the kind gate).
1708
+ // Surface the modal so the user decides; never pick blindly.
1709
+ return autoApproveActive;
1710
+ }
1711
+ // Modal *identity* signature — the question/button set only, NO volatile
1712
+ // counters. This is what the settle gate tracks: the FSM bumps
1713
+ // approvalEntrySeq on every fresh waiting_approval entry, and a
1714
+ // modal→generating→modal flap (the question line scrolled out of the
1715
+ // captured frame while the button block stays) re-enters and bumps it
1716
+ // again. Folding that seq into the settle signature made the 600ms
1717
+ // settle clock restart on every flap, so the modal never stayed stable
1718
+ // long enough to fire — the gate was never satisfied. Identity excludes
1719
+ // the seq so button/seq flap of the SAME modal keeps one settle clock.
1720
+ const modalSignature = [
1721
+ typeof modal?.message === 'string' ? modal.message.trim() : '',
1722
+ buttons.join('|'),
1723
+ buttonIndex,
1724
+ ].join('::');
1725
+ // Busy-window re-entry guard still needs the seq: two DISTINCT
1726
+ // back-to-back approvals can carry identical message/buttons (common
1727
+ // with claude-cli). Without the seq their busy signatures collide and
1728
+ // the 5s busy-window guard below would swallow the second auto-approve,
1729
+ // leaving it stuck. The seq is bumped by the FSM on every fresh
1730
+ // waiting_approval entry, so a new approval always yields a new busy
1731
+ // signature and fires through.
1732
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
1733
+ ? adapterStatus.approvalEntrySeq
1734
+ : 0;
1735
+ const busySignature = `${approvalEntrySeq}::${modalSignature}`;
1736
+ // Already fired for this exact modal entry and still inside the busy
1737
+ // window — nothing to do (re-entry guard for repeated snapshots of one
1738
+ // modal).
1739
+ if (this.autoApproveBusy && busySignature === this.lastAutoApprovalSignature) {
1740
+ return autoApproveActive;
1741
+ }
1742
+
1743
+ // Settle gate: only fire once this modal identity has been stable for
1744
+ // AUTO_APPROVE_SETTLE_MS. A still-streaming prompt mutates its
1745
+ // message/buttons each frame → new identity → clock restarts, so we
1746
+ // never approve a half-rendered prompt (the "resolves too fast" bug).
1747
+ if (modalSignature !== this.pendingAutoApprovalSignature) {
1748
+ this.pendingAutoApprovalSignature = modalSignature;
1749
+ this.pendingAutoApprovalSince = now;
869
1750
  }
1751
+ const settledForMs = now - this.pendingAutoApprovalSince;
1752
+ if (settledForMs < CliProviderInstance.AUTO_APPROVE_SETTLE_MS) {
1753
+ // Not yet settled. Arm a timer to re-check after the remaining quiet
1754
+ // window — the PTY may go silent once the prompt finishes painting,
1755
+ // so there is no guaranteed status-change frame to re-drive us.
1756
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1757
+ this.autoApproveSettleTimer = setTimeout(() => {
1758
+ this.autoApproveSettleTimer = null;
1759
+ this.recheckAutoApproveSettled();
1760
+ }, CliProviderInstance.AUTO_APPROVE_SETTLE_MS - settledForMs + 20);
1761
+ return autoApproveActive;
1762
+ }
1763
+
1764
+ // Settled — fire the approve key.
1765
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1766
+ this.autoApproveBusy = true;
1767
+ this.lastAutoApprovalSignature = busySignature;
1768
+ this.pendingAutoApprovalSignature = '';
1769
+ this.pendingAutoApprovalSince = 0;
1770
+ this.autoApproveInactiveSince = 0;
1771
+ if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
1772
+ this.autoApproveBusyTimer = setTimeout(() => {
1773
+ this.autoApproveBusy = false;
1774
+ this.autoApproveBusyTimer = null;
1775
+ this.lastAutoApprovalSignature = '';
1776
+ }, 5000);
1777
+ this.recordAutoApproval(modal?.message, buttonLabel, now);
1778
+ setTimeout(() => {
1779
+ this.adapter.resolveModal(buttonIndex);
1780
+ }, 0);
870
1781
  return autoApproveActive;
871
1782
  }
872
1783
 
1784
+ /**
1785
+ * Re-drive the auto-approve check after the settle quiet window elapses.
1786
+ * The PTY may have gone silent once the approval prompt finished painting,
1787
+ * so no status-change frame is guaranteed to re-enter maybeAutoApproveStatus
1788
+ * — this timer-driven re-check picks up the now-settled modal and fires.
1789
+ * Deliberately lighter than detectStatusTransition(): it only re-evaluates
1790
+ * the approval decision; the next real PTY frame refreshes visible status.
1791
+ */
1792
+ private recheckAutoApproveSettled(): void {
1793
+ try {
1794
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
1795
+ this.maybeAutoApproveStatus(adapterStatus, Date.now());
1796
+ } catch { /* adapter gone / transient — next frame retries */ }
1797
+ }
1798
+
873
1799
  private detectStatusTransition(): void {
874
1800
  const now = Date.now();
875
1801
  // Status-change handling is a hot path: PTY output can fire it many times
876
1802
  // during long-running CLI sessions. Keep this path on adapter-owned light
877
1803
  // state only; rich provider parsing is reserved for getState/read_chat.
878
1804
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
1805
+ const adapterProviderSessionId = normalizeProviderSessionId(
1806
+ this.provider,
1807
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
1808
+ );
1809
+ if (adapterProviderSessionId) {
1810
+ this.promoteProviderSessionId(adapterProviderSessionId);
1811
+ }
879
1812
  const parsedStatus = null;
880
1813
  const rawStatus = adapterStatus.status;
881
1814
  const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
882
- const newStatus = autoApproveActive ? 'generating' : rawStatus;
883
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
1815
+ // During the autoApproveBusy window (2s after firing approval key), the PTY
1816
+ // can briefly report 'idle' before the next generating phase starts. Treat that
1817
+ // transient idle as 'generating' to suppress a spurious agent:generating_completed
1818
+ // push notification. The adapter's status is otherwise authoritative — native
1819
+ // transcript shape does NOT override the FSM's busy/idle decision.
1820
+ const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
1821
+ const newStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus;
1822
+ const dirName = workingDirBasename(this.workingDir);
884
1823
  const chatTitle = `${this.provider.name} · ${dirName}`;
885
1824
  const partial = this.adapter.getPartialResponse();
1825
+ // Liveness fingerprint for the no-progress watchdog. The parsed
1826
+ // assistant buffer (`partial`) alone goes static while a tool/build runs
1827
+ // — the assistant emits no tokens even though the PTY is actively
1828
+ // printing tool output — which made the watchdog false-fire a "stuck"
1829
+ // alert mid-turn. Fold in the adapter's raw-activity timestamps so any
1830
+ // visible terminal progress (lastScreenChangeAt) or raw PTY byte
1831
+ // (lastOutputAt) keeps the fingerprint moving. The watchdog then only
1832
+ // survives a genuine stall where nothing at all is happening.
886
1833
  const progressFingerprint = newStatus === 'generating'
887
- ? `${partial || ''}`.slice(-2000)
1834
+ ? `${`${partial || ''}`.slice(-2000)}::scr=${adapterStatus.lastScreenChangeAt ?? 0}::out=${adapterStatus.lastOutputAt ?? 0}`
888
1835
  : undefined;
889
1836
 
890
1837
  const previousStatus = this.lastStatus;
891
1838
  if (newStatus !== this.lastStatus) {
892
1839
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
893
1840
  if (this.lastStatus === 'idle' && newStatus === 'generating') {
1841
+ // If a completion event is already pending and the turn has ended
1842
+ // (generatingStartedAt===0), the PTY is painting its prompt area
1843
+ // after completing. Ignore this blip — do not cancel the pending
1844
+ // completion and do not advance lastStatus to generating.
1845
+ if (this.completedDebouncePending && this.generatingStartedAt === 0) {
1846
+ LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
1847
+ return;
1848
+ }
894
1849
  this.suppressIdleHistoryReplay = false;
895
1850
  // Cancel any pending completed event (multi-step: idle→generating resume)
896
1851
  if (this.completedDebouncePending) {
897
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
1852
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
898
1853
  if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
899
1854
  this.completedDebouncePending = null;
900
1855
  }
@@ -926,10 +1881,29 @@ export class CliProviderInstance implements ProviderInstance {
926
1881
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
927
1882
  const modal = adapterStatus.activeModal;
928
1883
  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;
1884
+ // Include the FSM's approval entry seq, mirroring the auto-approve
1885
+ // path (maybeAutoApproveStatus) and resolveModal's sameEntryReResolve
1886
+ // guard. Two distinct back-to-back approvals can carry identical
1887
+ // message/buttons (very common with claude-cli's "Allow Bash
1888
+ // command?"). Without the seq their fingerprints collide and the dedup
1889
+ // below silently drops the second waiting_approval event — it is never
1890
+ // emitted, so it cannot even land in the pending inbox for a later
1891
+ // read_chat reconcile to recover. The seq is bumped by the FSM on every
1892
+ // fresh waiting_approval entry, so a new approval always yields a new
1893
+ // fingerprint and emits.
1894
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
1895
+ ? adapterStatus.approvalEntrySeq
1896
+ : 0;
1897
+ const approvalFingerprint = JSON.stringify({
1898
+ message: typeof modal?.message === 'string' ? modal.message.trim() : '',
1899
+ buttons: Array.isArray(modal?.buttons) ? modal.buttons.map((button: unknown) => String(button).trim()) : [],
1900
+ seq: approvalEntrySeq,
1901
+ });
1902
+ // PTY redraws repeat the same modal content; fingerprint dedup prevents duplicate events.
1903
+ // Do NOT also gate on lastStatus: consecutive approvals can arrive waiting_approval→waiting_approval
1904
+ // (e.g. antigravity-cli resolves one prompt and immediately shows the next) and would be silently dropped.
1905
+ if (approvalFingerprint !== this.lastApprovalEventFingerprint) {
1906
+ this.lastApprovalEventFingerprint = approvalFingerprint;
933
1907
  this.appendRuntimeSystemMessage(
934
1908
  this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
935
1909
  `approval_request:${now}`,
@@ -941,22 +1915,118 @@ export class CliProviderInstance implements ProviderInstance {
941
1915
  modalButtons: modal?.buttons,
942
1916
  });
943
1917
  }
1918
+ } else if (newStatus === 'generating' && this.lastStatus === 'waiting_approval') {
1919
+ // Approval resolved and the agent resumed work. Defense-in-depth:
1920
+ // clear the approval emit fingerprint here too (not only on
1921
+ // completion at scheduleCompletedDebounceFlush). A subsequent
1922
+ // waiting_approval with the same modal content as the one just
1923
+ // resolved would otherwise collide with the stale fingerprint and be
1924
+ // dropped. The seq in the fingerprint already separates entries; this
1925
+ // reset is a belt-and-suspenders guard for the re-entry case.
1926
+ this.lastApprovalEventFingerprint = '';
944
1927
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
945
1928
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
946
- // If debounce still pending (generating lasted < 1s), cancel both events
1929
+ // Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
1930
+ // was entered from 'starting' state (startup PTY noise), not from a real idle→generating
1931
+ // task dispatch. The idle→generating handler is the only code path that sets
1932
+ // generatingStartedAt and generatingDebouncePending, so both being absent means no
1933
+ // task was ever dispatched. Suppress the spurious completion event and fall through
1934
+ // to a simple lastStatus update.
1935
+ if (!this.generatingStartedAt && !this.generatingDebouncePending) {
1936
+ LOG.debug('CLI', `[${this.type}] suppressed startup-phase generating→idle blip (generatingStartedAt=0, no debounce pending)`);
1937
+ } else
1938
+ // If debounce still pending (generating lasted < 1s), cancel both UI events.
1939
+ // Still emit agent:generating_completed so mesh orchestration can record
1940
+ // task_completed for direct dispatches that complete faster than the debounce.
947
1941
  if (this.generatingDebouncePending) {
948
- LOG.info('CLI', `[${this.type}] suppressed short generating (${now - this.generatingStartedAt}ms)`);
1942
+ const shortDurationMs = this.generatingStartedAt ? now - this.generatingStartedAt : 0;
1943
+ LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms)`);
949
1944
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
1945
+ // Emit completion for mesh task association even though the UI generating
1946
+ // started/completed pair is suppressed (too short for visible UI update).
1947
+ let shortFinalSummary: string | undefined;
1948
+ let shortEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
1949
+ try {
1950
+ const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
1951
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
1952
+ shortEvidenceSource = evidence.source;
1953
+ shortFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
1954
+ } catch { /* best-effort */ }
1955
+ // If a real response is confirmed, retroactively emit started so the chat
1956
+ // bubble appears even though the debounce suppressed the original event.
1957
+ if (shortFinalSummary) {
1958
+ this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now - shortDurationMs });
1959
+ }
950
1960
  this.generatingDebouncePending = null;
951
1961
  this.generatingStartedAt = 0;
1962
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || shortEvidenceSource === 'external-native') && !shortFinalSummary;
1963
+ if (missingEvidence) {
1964
+ LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
1965
+ }
1966
+ // When evidence is missing and there is no active mesh task context, suppress
1967
+ // the completion event. Providers with requiresFinalAssistantBeforeIdle or
1968
+ // external-native history must confirm a final assistant message before the
1969
+ // coordinator records task_completed. Only emit here if a mesh task is active
1970
+ // so the coordinator can apply its own timeout/retry logic.
1971
+ const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1972
+ if (missingEvidence && !hasMeshContext) {
1973
+ LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
1974
+ // completedDebouncePending intentionally left null — the session is now idle
1975
+ // with no confirmed turn, matching the startup-blip suppression semantics.
1976
+ // (No EvtTrace: not a mesh session, so nothing routes to a coordinator.)
1977
+ } else {
1978
+ // EVTTRACE: completion fired (short-generating idle path).
1979
+ if (this.isMeshWorkerSession()) {
1980
+ traceMeshEventStage('fired', this.meshTraceCtx(), `short-generating idle (source=${shortEvidenceSource})`);
1981
+ }
1982
+ this.pushEvent({
1983
+ event: 'agent:generating_completed',
1984
+ chatTitle,
1985
+ duration: 0,
1986
+ timestamp: now,
1987
+ finalSummary: shortFinalSummary,
1988
+ completionDiagnostic: {
1989
+ reason: 'short_generating_suppressed',
1990
+ shortDurationMs,
1991
+ finalAssistantEvidenceSource: shortEvidenceSource,
1992
+ ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
1993
+ },
1994
+ });
1995
+ }
952
1996
  } else {
953
1997
  // Debounce completed, then require the rich transcript path that read_chat
954
1998
  // 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);
1999
+ this.completedDebouncePending = {
2000
+ chatTitle,
2001
+ duration,
2002
+ timestamp: now,
2003
+ firstObservedAt: now,
2004
+ previousStatus: this.lastStatus,
2005
+ };
2006
+ const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
2007
+ const flushDelay = ownsExternalHistory ? 0 : 3000;
2008
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
2009
+ this.scheduleCompletedDebounceFlush(flushDelay);
957
2010
  }
958
2011
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
959
2012
  this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
2013
+ } else if (newStatus === 'error') {
2014
+ if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
2015
+ this.generatingDebouncePending = null;
2016
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
2017
+ this.completedDebouncePending = null;
2018
+ this.errorMessage = adapterStatus.errorMessage || this.errorMessage;
2019
+ this.errorReason = (adapterStatus.errorReason as ProviderErrorReason) || this.errorReason;
2020
+ this.pushEvent({
2021
+ event: 'agent:stopped',
2022
+ chatTitle,
2023
+ timestamp: now,
2024
+ finalSummary: adapterStatus.errorMessage || adapterStatus.errorReason || 'Provider reported an error',
2025
+ completionDiagnostic: {
2026
+ reason: adapterStatus.errorReason || 'provider_error',
2027
+ errorMessage: adapterStatus.errorMessage || undefined,
2028
+ },
2029
+ });
960
2030
  } else if (newStatus === 'stopped') {
961
2031
  // Cancel any pending debounce
962
2032
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -976,8 +2046,43 @@ export class CliProviderInstance implements ProviderInstance {
976
2046
 
977
2047
  // Monitor check (cooldown based notification, IDE/CLI common)
978
2048
  const agentKey = `${this.type}:cli`;
979
- const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint);
2049
+ // Approval pending is detected from the raw adapter status, not `newStatus`:
2050
+ // auto-approve synthesizes `waiting_approval` → 'generating', which would
2051
+ // otherwise let the no-progress watchdog accumulate the approval wait.
2052
+ const approvalPending = rawStatus === 'waiting_approval';
2053
+ const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint, approvalPending);
2054
+ const monitorParsedStatus: any = parsedStatus;
980
2055
  for (const me of monitorEvents) {
2056
+ if (
2057
+ me.type === 'monitor:no_progress'
2058
+ && this.completionHasFinalAssistantMessage(monitorParsedStatus?.messages)
2059
+ && !this.hasAdapterPendingResponse()
2060
+ && !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
2061
+ ) {
2062
+ // EVTTRACE: completion fired (no-progress monitor reconciled to completion).
2063
+ if (this.isMeshWorkerSession()) {
2064
+ traceMeshEventStage('fired', this.meshTraceCtx(), 'no_progress_monitor_final_summary');
2065
+ }
2066
+ this.pushEvent({
2067
+ event: 'agent:generating_completed',
2068
+ chatTitle,
2069
+ duration: this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : undefined,
2070
+ timestamp: me.timestamp,
2071
+ finalSummary: extractFinalSummaryFromMessages(monitorParsedStatus?.messages),
2072
+ completionDiagnostic: {
2073
+ providerType: this.type,
2074
+ sessionId: this.instanceId,
2075
+ providerSessionId: this.providerSessionId || null,
2076
+ reconciliationReason: 'no_progress_monitor_final_summary',
2077
+ finalAssistantPresent: true,
2078
+ },
2079
+ });
2080
+ this.generatingStartedAt = 0;
2081
+ // Cancel any pending debounce flush — monitor already fired completion.
2082
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
2083
+ this.completedDebouncePending = null;
2084
+ continue;
2085
+ }
981
2086
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
982
2087
  }
983
2088
  }
@@ -997,15 +2102,49 @@ export class CliProviderInstance implements ProviderInstance {
997
2102
  workspaceName: typeof event.workspaceName === 'string' && event.workspaceName.trim()
998
2103
  ? event.workspaceName
999
2104
  : this.workingDir,
2105
+ // Carry the workspace under BOTH `workspace` and `workspaceName` so the
2106
+ // downstream mesh forward/merge path — which reads `workspace` — can
2107
+ // propagate it to the coordinator snapshot. Without `workspace` the live
2108
+ // event path delivers an empty workspace and the dashboard falls back to
2109
+ // the generic "Terminal (Mesh Node)" title.
2110
+ workspace: typeof event.workspace === 'string' && event.workspace.trim()
2111
+ ? event.workspace
2112
+ : this.workingDir,
1000
2113
  providerSessionId: typeof event.providerSessionId === 'string' && event.providerSessionId.trim()
1001
2114
  ? event.providerSessionId
1002
2115
  : this.providerSessionId,
1003
2116
  };
2117
+ // TASKIDLESS: stamp the mesh task primary key on lifecycle events emitted by
2118
+ // a mesh worker session. The consumer (updateDirectDispatchStatus) was switched
2119
+ // to key on task_id (CANON-B), but the producer never carried it — so every
2120
+ // forwarded metadataEvent.taskId arrived undefined and the coordinator fell back
2121
+ // to a session_id match, which can flip a sibling dispatch row. The session
2122
+ // already knows its own taskId via attachMeshAssignment (settings.meshActiveTaskId);
2123
+ // surface it here so updateDirectDispatchStatus hits the exact PK row and the
2124
+ // session_id fallback is never exercised. Non-mesh sessions get no taskId
2125
+ // (regression guard) — isMeshWorkerSession() gates the injection.
2126
+ if (this.isMeshWorkerSession() && this.settings.meshActiveTaskId) {
2127
+ const existingTaskId = typeof enrichedEvent.taskId === 'string' && enrichedEvent.taskId.trim()
2128
+ ? enrichedEvent.taskId
2129
+ : undefined;
2130
+ if (!existingTaskId) {
2131
+ enrichedEvent.taskId = this.settings.meshActiveTaskId;
2132
+ }
2133
+ }
1004
2134
  if (this.context?.emitProviderEvent) {
1005
2135
  this.context.emitProviderEvent(enrichedEvent);
1006
- return;
2136
+ } else {
2137
+ this.events.push(enrichedEvent);
2138
+ }
2139
+ // Auto-detach a direct-dispatch mesh assignment once the dispatched
2140
+ // task reaches a terminal state. Leaving meshNodeFor pinned would
2141
+ // route this session's next unrelated turn (a dashboard chat) into
2142
+ // the coordinator as if it were the completion of another task.
2143
+ // We schedule after the emit so the originating coordinator still
2144
+ // observes the completion event with its routing marker intact.
2145
+ if (TERMINAL_MESH_EVENTS.has(event.event) && this.settings.meshActiveTaskId) {
2146
+ try { this.detachMeshAssignment(); } catch { /* best-effort */ }
1007
2147
  }
1008
- this.events.push(enrichedEvent);
1009
2148
  }
1010
2149
 
1011
2150
  private flushEvents(): ProviderEvent[] {
@@ -1151,6 +2290,25 @@ export class CliProviderInstance implements ProviderInstance {
1151
2290
  return false;
1152
2291
  }
1153
2292
 
2293
+ /** @see ProviderInstance.noteManualInteraction */
2294
+ noteManualInteraction(now = Date.now()): void {
2295
+ this.manualAttendance.note(now);
2296
+ }
2297
+
2298
+ /**
2299
+ * Whether auto-approve should be treated as active *right now* for display
2300
+ * and firing decisions: the configured intent AND the user is not currently
2301
+ * attending this session by hand. When a human is attending, auto-approve is
2302
+ * held so the modal stays visible and they can drive it via the controlbar.
2303
+ * Provider-agnostic — the attendance signal is the command set, never any
2304
+ * CLI-specific modal text.
2305
+ */
2306
+ private autoApproveEffectivelyActive(status: string | undefined, now = Date.now()): boolean {
2307
+ return status === 'waiting_approval'
2308
+ && this.shouldAutoApprove()
2309
+ && !this.manualAttendance.isAttended(now);
2310
+ }
2311
+
1154
2312
  private recordAutoApproval(modalMessage?: string, buttonLabel?: string, now = Date.now()): void {
1155
2313
  this.appendRuntimeSystemMessage(
1156
2314
  formatAutoApprovalMessage(modalMessage, buttonLabel),
@@ -1238,7 +2396,7 @@ export class CliProviderInstance implements ProviderInstance {
1238
2396
  receivedAt: normalizedMessage.receivedAt || normalizedMessage.timestamp,
1239
2397
  historyDedupKey: dedupKey,
1240
2398
  }],
1241
- this.adapter.getScriptParsedStatus?.()?.title || this.workingDir.split('/').filter(Boolean).pop() || 'session',
2399
+ this.adapter.getScriptParsedStatus?.()?.title || workingDirBasename(this.workingDir),
1242
2400
  this.instanceId,
1243
2401
  this.providerSessionId,
1244
2402
  );
@@ -1258,12 +2416,28 @@ export class CliProviderInstance implements ProviderInstance {
1258
2416
  index,
1259
2417
  source: 'parsed',
1260
2418
  }));
2419
+ const getRole = (message: ChatMessage): string => typeof message.role === 'string'
2420
+ ? message.role.trim().toLowerCase()
2421
+ : '';
1261
2422
  const runtimeEntries: MergeEntry[] = this.runtimeMessages.map((entry, index) => ({
1262
2423
  message: entry.message,
1263
2424
  index: parsedMessages.length + index,
1264
- source: 'runtime',
2425
+ source: 'runtime' as const,
1265
2426
  runtimeKey: entry.key,
1266
- }));
2427
+ })).filter((entry) => {
2428
+ const meta = entry.message.meta && typeof entry.message.meta === 'object' && !Array.isArray(entry.message.meta)
2429
+ ? entry.message.meta as Record<string, unknown>
2430
+ : {};
2431
+ if (meta.runtimeInputAck !== true) return true;
2432
+ const runtimeText = flattenContent(entry.message.content).replace(/\s+/g, ' ').trim();
2433
+ if (!runtimeText) return false;
2434
+ return !parsedEntries.some((parsedEntry) => {
2435
+ const parsedRole = getRole(parsedEntry.message);
2436
+ if (parsedRole !== 'user' && parsedRole !== 'human') return false;
2437
+ const parsedText = flattenContent(parsedEntry.message.content).replace(/\s+/g, ' ').trim();
2438
+ return parsedText === runtimeText;
2439
+ });
2440
+ });
1267
2441
  const getTime = (message: ChatMessage): number => {
1268
2442
  const value = typeof message.receivedAt === 'number'
1269
2443
  ? message.receivedAt
@@ -1273,9 +2447,6 @@ export class CliProviderInstance implements ProviderInstance {
1273
2447
  return Number.isFinite(value) && value > 0 ? value : 0;
1274
2448
  };
1275
2449
 
1276
- const getRole = (message: ChatMessage): string => typeof message.role === 'string'
1277
- ? message.role.trim().toLowerCase()
1278
- : '';
1279
2450
  const isRuntimeOverlay = (entry: MergeEntry): boolean => {
1280
2451
  if (entry.source !== 'runtime') return false;
1281
2452
  const key = typeof entry.runtimeKey === 'string' ? entry.runtimeKey.trim().toLowerCase() : '';
@@ -1341,7 +2512,9 @@ export class CliProviderInstance implements ProviderInstance {
1341
2512
  this.providerSessionId = nextSessionId;
1342
2513
  this.historyWriter.promoteHistorySession(this.type, previousHistorySessionId, nextSessionId);
1343
2514
  this.historyWriter.writeSessionStart(this.type, nextSessionId, this.workingDir, this.instanceId);
1344
- this.restorePersistedHistoryFromCurrentSession();
2515
+ if (this.shouldHydrateExistingProviderHistory()) {
2516
+ this.restorePersistedHistoryFromCurrentSession();
2517
+ }
1345
2518
  this.adapter.updateRuntimeMeta({ providerSessionId: nextSessionId });
1346
2519
  this.onProviderSessionResolved?.({
1347
2520
  instanceId: this.instanceId,
@@ -1354,13 +2527,39 @@ export class CliProviderInstance implements ProviderInstance {
1354
2527
  LOG.info('CLI', `[${this.type}] discovered provider session id: ${nextSessionId}`);
1355
2528
  }
1356
2529
 
1357
- private syncCanonicalSavedHistoryIfNeeded(): boolean {
2530
+ private shouldHydrateExistingProviderHistory(): boolean {
2531
+ return this.launchMode === 'resume' || this.launchMode === 'manual';
2532
+ }
2533
+
2534
+ private shouldSuppressFreshLaunchStartupReplay(parsedMessages: unknown[], parsedStatus: any, adapterStatus: any, parsedProviderSessionId = ''): boolean {
2535
+ if (this.launchMode !== 'new') return false;
2536
+ if (this.providerSessionId) return false;
2537
+ if (!Array.isArray(parsedMessages) || parsedMessages.length === 0) return false;
2538
+ if (!isIdleStatus(adapterStatus?.status) || !isIdleStatus(parsedStatus?.status)) return false;
2539
+ if (parsedProviderSessionId) return true;
2540
+
2541
+ const newestMessageAt = parsedMessages.reduce<number>((newest, message) => Math.max(newest, getMessageTime(message)), 0);
2542
+
2543
+ // Untimestamped idle parser output during a fresh launch is usually the
2544
+ // provider's last workspace transcript before a new turn exists.
2545
+ return newestMessageAt === 0;
2546
+ }
2547
+
2548
+ private syncCanonicalSavedHistoryIfNeeded(options: { full?: boolean } = {}): boolean {
1358
2549
  if (!this.providerSessionId) return false;
1359
- const canonicalHistory = this.provider.canonicalHistory;
2550
+ const canonicalHistory = this.provider.nativeHistory;
1360
2551
  if (!canonicalHistory) return false;
1361
2552
 
2553
+ // Per-status-report hydration reads only a bounded tail (snapshot needs at
2554
+ // most the newest 60). The once-per-resume restore path passes full:true
2555
+ // because seedSessionHistory needs the COMPLETE transcript to seed dedup
2556
+ // state. The read-cache key encodes the window so the bounded and full
2557
+ // reads don't share/clobber each other's 2s cache entry.
2558
+ const limit = options.full ? Number.MAX_SAFE_INTEGER : STATUS_HYDRATION_TAIL_LIMIT;
2559
+ const windowTag = options.full ? 'full' : `tail:${STATUS_HYDRATION_TAIL_LIMIT}`;
2560
+
1362
2561
  if (isNativeSourceCanonicalHistory(canonicalHistory)) {
1363
- const cacheKey = [this.type, this.providerSessionId, this.workingDir].join('\0');
2562
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir, windowTag].join('\0');
1364
2563
  const now = Date.now();
1365
2564
  if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1366
2565
  return true;
@@ -1373,7 +2572,7 @@ export class CliProviderInstance implements ProviderInstance {
1373
2572
  historySessionId: this.providerSessionId,
1374
2573
  workspace: this.workingDir,
1375
2574
  offset: 0,
1376
- limit: Number.MAX_SAFE_INTEGER,
2575
+ limit,
1377
2576
  historyBehavior: this.provider.historyBehavior,
1378
2577
  scripts: this.provider.scripts as any,
1379
2578
  });
@@ -1390,7 +2589,7 @@ export class CliProviderInstance implements ProviderInstance {
1390
2589
  }
1391
2590
 
1392
2591
  try {
1393
- const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror'].join('\0');
2592
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror', windowTag].join('\0');
1394
2593
  const now = Date.now();
1395
2594
  if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1396
2595
  return true;
@@ -1401,7 +2600,14 @@ export class CliProviderInstance implements ProviderInstance {
1401
2600
  if (!materializeProviderNativeHistory(this.type, canonicalHistory, this.providerSessionId, this.workingDir, this.provider.scripts as any)) {
1402
2601
  return false;
1403
2602
  }
1404
- const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
2603
+ // Bounded by default: the per-status-report path only needs the newest
2604
+ // STATUS_HYDRATION_TAIL_LIMIT messages because the snapshot caps
2605
+ // activeChat.messages to the last 60 (status/normalize.ts) and loads
2606
+ // the rest lazily via read_chat on subscribe. The once-per-resume
2607
+ // restore path passes full:true so seedSessionHistory still sees the
2608
+ // COMPLETE transcript for prefix-dedup seeding. readChatHistory serves
2609
+ // a bounded limit as an O(tail) read.
2610
+ const restoredHistory = readChatHistory(this.type, 0, limit, this.providerSessionId, 0, this.provider.historyBehavior);
1405
2611
  this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
1406
2612
  role: message.role,
1407
2613
  content: message.content,
@@ -1417,10 +2623,13 @@ export class CliProviderInstance implements ProviderInstance {
1417
2623
 
1418
2624
  private restorePersistedHistoryFromCurrentSession(): void {
1419
2625
  if (!this.providerSessionId) return;
1420
- this.syncCanonicalSavedHistoryIfNeeded();
1421
- const restoredHistory = isNativeSourceCanonicalHistory(this.provider.canonicalHistory)
2626
+ // Restore is the once-per-resume seeding path: it needs the COMPLETE
2627
+ // transcript so seedSessionHistory can prime dedup state. Pass full so the
2628
+ // hydration read is unbounded here (and only here).
2629
+ this.syncCanonicalSavedHistoryIfNeeded({ full: true });
2630
+ const restoredHistory = isNativeSourceCanonicalHistory(this.provider.nativeHistory)
1422
2631
  ? readProviderChatHistory(this.type, {
1423
- canonicalHistory: this.provider.canonicalHistory,
2632
+ canonicalHistory: this.provider.nativeHistory,
1424
2633
  historySessionId: this.providerSessionId,
1425
2634
  workspace: this.workingDir,
1426
2635
  offset: 0,