@adhdev/daemon-core 0.9.82-rc.38 → 0.9.82-rc.381

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