@adhdev/daemon-core 0.9.82-rc.35 → 0.9.82-rc.351

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 (318) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +8 -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 +46 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +198 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +116 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +53 -0
  14. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  15. package/dist/cli-adapters/resolve-executable.d.ts +14 -0
  16. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  17. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  18. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  19. package/dist/commands/cli-manager.d.ts +2 -1
  20. package/dist/commands/handler.d.ts +110 -0
  21. package/dist/commands/mesh-coordinator.d.ts +85 -1
  22. package/dist/commands/router.d.ts +324 -1
  23. package/dist/commands/upgrade-helper.d.ts +12 -0
  24. package/dist/config/chat-history.d.ts +9 -0
  25. package/dist/config/config.d.ts +5 -0
  26. package/dist/config/mesh-config.d.ts +77 -1
  27. package/dist/detection/ide-detector.d.ts +13 -0
  28. package/dist/detection/win32-ide-version.d.ts +37 -0
  29. package/dist/git/change-impact-config.d.ts +159 -0
  30. package/dist/git/git-commands.d.ts +11 -1
  31. package/dist/git/git-diff.d.ts +6 -0
  32. package/dist/git/git-executor.d.ts +11 -0
  33. package/dist/git/git-status.d.ts +23 -0
  34. package/dist/git/git-types.d.ts +2 -50
  35. package/dist/git/index.d.ts +2 -0
  36. package/dist/index.d.ts +53 -13
  37. package/dist/index.js +39817 -14303
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +41436 -16047
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/installer.d.ts +1 -4
  42. package/dist/ipc/local-ipc-server.d.ts +91 -0
  43. package/dist/launch.d.ts +1 -1
  44. package/dist/logging/async-batch-writer.d.ts +10 -0
  45. package/dist/logging/log-redactor.d.ts +24 -0
  46. package/dist/logging/log-tail-reader.d.ts +46 -0
  47. package/dist/mesh/contracts.d.ts +164 -0
  48. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  49. package/dist/mesh/coordinator-registry.d.ts +59 -0
  50. package/dist/mesh/mesh-active-work.d.ts +174 -0
  51. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  52. package/dist/mesh/mesh-events-coordinator.d.ts +169 -0
  53. package/dist/mesh/mesh-events-pending.d.ts +55 -0
  54. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  55. package/dist/mesh/mesh-events-utils.d.ts +62 -0
  56. package/dist/mesh/mesh-events.d.ts +6 -49
  57. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  58. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  59. package/dist/mesh/mesh-init.d.ts +86 -0
  60. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  61. package/dist/mesh/mesh-ledger.d.ts +77 -1
  62. package/dist/mesh/mesh-missions.d.ts +164 -0
  63. package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
  64. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  65. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  66. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  67. package/dist/mesh/mesh-routing.d.ts +70 -0
  68. package/dist/mesh/mesh-runtime-store.d.ts +420 -0
  69. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  70. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  71. package/dist/mesh/mesh-work-queue.d.ts +218 -5
  72. package/dist/mesh/preview-freshness.d.ts +18 -0
  73. package/dist/mesh/refine-config.d.ts +216 -0
  74. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  75. package/dist/providers/acp-provider-instance.d.ts +2 -0
  76. package/dist/providers/approval-utils.d.ts +13 -0
  77. package/dist/providers/chat-message-normalization.d.ts +12 -0
  78. package/dist/providers/cli-provider-instance.d.ts +77 -3
  79. package/dist/providers/contracts.d.ts +139 -6
  80. package/dist/providers/external-sources.d.ts +71 -0
  81. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  82. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  83. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  84. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  85. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  86. package/dist/providers/native-history/index.d.ts +13 -0
  87. package/dist/providers/provider-instance-manager.d.ts +14 -0
  88. package/dist/providers/provider-instance.d.ts +15 -1
  89. package/dist/providers/provider-loader.d.ts +26 -4
  90. package/dist/providers/provider-trust.d.ts +31 -0
  91. package/dist/providers/read-chat-contract.d.ts +29 -0
  92. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  93. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  94. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  95. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  96. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  97. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  98. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  99. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  100. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  101. package/dist/providers/sdk/v1/index.d.ts +30 -0
  102. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  103. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  104. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  105. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  106. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  107. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  108. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  109. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  110. package/dist/providers/spec/adapter.d.ts +69 -0
  111. package/dist/providers/spec/cli-adapter.d.ts +217 -0
  112. package/dist/providers/spec/evaluator.d.ts +23 -0
  113. package/dist/providers/spec/fsm-driver.d.ts +322 -0
  114. package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
  115. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  116. package/dist/providers/spec/fsm-types.d.ts +144 -0
  117. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  118. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  119. package/dist/providers/spec/route.d.ts +4 -0
  120. package/dist/providers/spec/types.d.ts +180 -0
  121. package/dist/providers/status-monitor.d.ts +7 -7
  122. package/dist/providers/transcript-v2.d.ts +176 -0
  123. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  124. package/dist/providers/working-dir.d.ts +17 -0
  125. package/dist/repo-mesh-types.d.ts +295 -13
  126. package/dist/sessions/registry.d.ts +6 -0
  127. package/dist/shared-types-extra.d.ts +2 -4
  128. package/dist/shared-types.d.ts +59 -2
  129. package/dist/status/normalize.d.ts +1 -1
  130. package/dist/status/normalize.js +1 -0
  131. package/dist/status/normalize.js.map +1 -1
  132. package/dist/status/normalize.mjs +1 -0
  133. package/dist/status/normalize.mjs.map +1 -1
  134. package/dist/status/reporter.d.ts +2 -0
  135. package/dist/status/snapshot.d.ts +26 -0
  136. package/dist/system/load-better-sqlite3.d.ts +21 -0
  137. package/dist/types.d.ts +5 -0
  138. package/package.json +7 -3
  139. package/src/agent-stream/poller.ts +2 -3
  140. package/src/agent-stream/provider-adapter.ts +1 -1
  141. package/src/boot/daemon-lifecycle.ts +55 -12
  142. package/src/boot/process-hardening.ts +89 -0
  143. package/src/build-info.ts +73 -0
  144. package/src/chat/source-machine.ts +534 -0
  145. package/src/chat/source-resolver.ts +0 -0
  146. package/src/chat/subscription-updates.ts +20 -1
  147. package/src/cli-adapter-types.d.ts +1 -0
  148. package/src/cli-adapter-types.ts +47 -2
  149. package/src/cli-adapters/cli-script-runner.ts +421 -0
  150. package/src/cli-adapters/cli-state-engine.ts +1215 -0
  151. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  152. package/src/cli-adapters/provider-cli-adapter.ts +774 -1141
  153. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  154. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  155. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  156. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  157. package/src/cli-adapters/provider-cli-shared.ts +100 -11
  158. package/src/cli-adapters/pty-transport.ts +2 -1
  159. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  160. package/src/cli-adapters/resolve-executable.ts +90 -0
  161. package/src/cli-adapters/session-host-transport.ts +2 -1
  162. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  163. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  164. package/src/cli-adapters/terminal-screen.ts +16 -81
  165. package/src/commands/chat-commands.ts +1823 -62
  166. package/src/commands/cli-manager.ts +304 -14
  167. package/src/commands/handler.ts +809 -2
  168. package/src/commands/mesh-coordinator.ts +332 -122
  169. package/src/commands/router.ts +7505 -1311
  170. package/src/commands/stream-commands.ts +8 -0
  171. package/src/commands/upgrade-helper.ts +81 -30
  172. package/src/config/chat-history.ts +483 -24
  173. package/src/config/config.ts +12 -0
  174. package/src/config/mesh-config.ts +388 -6
  175. package/src/config/recent-activity.ts +8 -2
  176. package/src/daemon/dev-auto-implement.ts +3 -2
  177. package/src/daemon/dev-cli-debug.ts +10 -1
  178. package/src/detection/cli-detector.ts +28 -9
  179. package/src/detection/ide-detector.ts +55 -16
  180. package/src/detection/win32-ide-version.ts +106 -0
  181. package/src/git/change-impact-config.ts +354 -0
  182. package/src/git/git-commands.ts +55 -14
  183. package/src/git/git-diff.ts +53 -0
  184. package/src/git/git-executor.ts +12 -0
  185. package/src/git/git-status.ts +574 -43
  186. package/src/git/git-types.ts +14 -62
  187. package/src/git/git-worktree.ts +8 -1
  188. package/src/git/index.ts +16 -0
  189. package/src/index.ts +151 -12
  190. package/src/installer.d.ts +1 -1
  191. package/src/installer.ts +8 -6
  192. package/src/ipc/local-ipc-server.ts +278 -0
  193. package/src/launch.d.ts +1 -1
  194. package/src/launch.ts +37 -28
  195. package/src/logging/async-batch-writer.ts +55 -0
  196. package/src/logging/log-redactor.ts +100 -0
  197. package/src/logging/log-tail-reader.ts +220 -0
  198. package/src/logging/logger.ts +2 -1
  199. package/src/mesh/contracts.ts +329 -0
  200. package/src/mesh/coordinator-prompt.ts +234 -31
  201. package/src/mesh/coordinator-registry.ts +121 -0
  202. package/src/mesh/mesh-active-work.ts +667 -0
  203. package/src/mesh/mesh-delivery-policy.ts +315 -0
  204. package/src/mesh/mesh-events-coordinator.ts +2303 -0
  205. package/src/mesh/mesh-events-pending.ts +567 -0
  206. package/src/mesh/mesh-events-stale.ts +307 -0
  207. package/src/mesh/mesh-events-utils.ts +381 -0
  208. package/src/mesh/mesh-events.ts +30 -1035
  209. package/src/mesh/mesh-fast-forward.ts +839 -0
  210. package/src/mesh/mesh-host-ownership.ts +73 -0
  211. package/src/mesh/mesh-init.ts +260 -0
  212. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  213. package/src/mesh/mesh-ledger.ts +590 -102
  214. package/src/mesh/mesh-missions.ts +322 -0
  215. package/src/mesh/mesh-reconcile-loop.ts +989 -0
  216. package/src/mesh/mesh-refine-batch.ts +197 -0
  217. package/src/mesh/mesh-refine-status.ts +231 -0
  218. package/src/mesh/mesh-review-inbox.ts +307 -0
  219. package/src/mesh/mesh-routing.ts +291 -0
  220. package/src/mesh/mesh-runtime-store.ts +1664 -0
  221. package/src/mesh/mesh-task-stats.ts +161 -0
  222. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  223. package/src/mesh/mesh-work-queue.ts +901 -141
  224. package/src/mesh/preview-freshness.ts +118 -0
  225. package/src/mesh/refine-config.ts +423 -0
  226. package/src/mesh/worktree-bootstrap-config.ts +331 -0
  227. package/src/providers/acp-provider-instance.ts +25 -9
  228. package/src/providers/approval-utils.d.ts +4 -0
  229. package/src/providers/approval-utils.ts +47 -5
  230. package/src/providers/chat-message-normalization.ts +45 -5
  231. package/src/providers/cli-provider-instance.ts +1041 -99
  232. package/src/providers/contracts.d.ts +55 -0
  233. package/src/providers/contracts.ts +150 -6
  234. package/src/providers/extension-provider-instance.ts +12 -7
  235. package/src/providers/external-sources.ts +218 -0
  236. package/src/providers/ide-provider-instance.ts +35 -12
  237. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  238. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  239. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  240. package/src/providers/native-history/dispatcher.ts +340 -0
  241. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  242. package/src/providers/native-history/index.ts +30 -0
  243. package/src/providers/provider-instance-manager.ts +30 -0
  244. package/src/providers/provider-instance.ts +10 -1
  245. package/src/providers/provider-loader.ts +670 -50
  246. package/src/providers/provider-schema.ts +87 -14
  247. package/src/providers/provider-trust.ts +114 -0
  248. package/src/providers/read-chat-contract.ts +76 -16
  249. package/src/providers/sdk/README.md +49 -0
  250. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  251. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  252. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  253. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  254. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  255. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  256. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  257. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  258. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  259. package/src/providers/sdk/v1/index.ts +152 -0
  260. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  261. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  262. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  263. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  264. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  265. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  266. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  267. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  268. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  269. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  270. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  271. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  272. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  273. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  274. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  275. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  276. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  277. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  278. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  279. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  280. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  281. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  282. package/src/providers/sdk/v1/validators/index.ts +19 -0
  283. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  284. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  285. package/src/providers/spec/adapter.ts +168 -0
  286. package/src/providers/spec/cli-adapter.ts +986 -0
  287. package/src/providers/spec/evaluator.ts +345 -0
  288. package/src/providers/spec/fsm-driver.ts +1065 -0
  289. package/src/providers/spec/fsm-evaluator.ts +255 -0
  290. package/src/providers/spec/fsm-loader.ts +120 -0
  291. package/src/providers/spec/fsm-types.ts +238 -0
  292. package/src/providers/spec/native-history-executor.ts +1136 -0
  293. package/src/providers/spec/pre-launch-trust.ts +104 -0
  294. package/src/providers/spec/route.ts +51 -0
  295. package/src/providers/spec/types.ts +226 -0
  296. package/src/providers/status-monitor.d.ts +7 -7
  297. package/src/providers/status-monitor.ts +37 -22
  298. package/src/providers/transcript-v2.ts +567 -0
  299. package/src/providers/types/interactive-prompt.ts +536 -0
  300. package/src/providers/version-archive.ts +64 -24
  301. package/src/providers/working-dir.ts +23 -0
  302. package/src/repo-mesh-types.ts +378 -14
  303. package/src/sessions/registry.ts +6 -0
  304. package/src/shared-types-extra.ts +2 -4
  305. package/src/shared-types.d.ts +8 -0
  306. package/src/shared-types.ts +64 -1
  307. package/src/status/builders.ts +26 -6
  308. package/src/status/normalize.ts +2 -0
  309. package/src/status/reporter.ts +19 -1
  310. package/src/status/snapshot.ts +95 -26
  311. package/src/system/host-memory.ts +29 -12
  312. package/src/system/load-better-sqlite3.ts +68 -0
  313. package/src/types.ts +5 -0
  314. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  315. package/dist/mesh/mesh-sync.d.ts +0 -53
  316. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  317. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  318. package/src/mesh/mesh-sync.ts +0 -111
@@ -13,19 +13,22 @@ 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';
17
18
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
19
+ import { createCliAdapter } from './spec/route.js';
18
20
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
19
21
  import { StatusMonitor } from './status-monitor.js';
20
22
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
21
23
  import { LOG } from '../logging/logger.js';
22
24
  import type { ChatMessage } from '../types.js';
23
25
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
24
- import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.js';
26
+ import { formatAutoApprovalMessage, pickApprovalButton, pickAutoApprovalButton, looksLikeActiveApprovalPromptText } from './approval-utils.js';
25
27
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
26
28
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
27
29
  import { normalizeProviderSessionId } from './provider-session-id.js';
28
30
  import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
31
+ import { workingDirBasename } from './working-dir.js';
29
32
 
30
33
  type PersistableCliHistoryMessage = {
31
34
  role: string;
@@ -35,17 +38,77 @@ type PersistableCliHistoryMessage = {
35
38
  receivedAt?: number;
36
39
  };
37
40
 
41
+ // Status snapshots only ever surface the newest messages: the cloud 'live'
42
+ // profile drops chat messages entirely (loaded lazily via read_chat on
43
+ // subscribe) and the 'full' profile caps activeChat.messages to the last 60
44
+ // (see status/normalize.ts). Unread/completion markers walk only the tail.
45
+ // So getState()'s saved-history hydration — which runs once per resume/manual
46
+ // CLI session on every status report — must read only a bounded tail, not the
47
+ // entire transcript. A full MAX_SAFE_INTEGER read here makes the initial
48
+ // status report O(transcript) × N(sessions), which is the real cold first-
49
+ // connection bottleneck on chat-heavy machines. The window comfortably exceeds
50
+ // the 60-message snapshot cap so dedup/collapse at the boundary stays stable.
51
+ const STATUS_HYDRATION_TAIL_LIMIT = 200;
52
+
38
53
  type CompletedDebouncePending = {
39
54
  chatTitle: string;
40
55
  duration: number;
41
56
  timestamp: number;
42
57
  firstObservedAt: number;
58
+ previousStatus: string;
43
59
  loggedBlockReason?: string;
60
+ loggedTranscriptProbe?: boolean;
61
+ transcriptProbeHistory?: ExternalTranscriptProbe[];
62
+ };
63
+
64
+ function isIdleStatus(value: unknown): boolean {
65
+ const status = typeof value === 'string' ? value.trim().toLowerCase() : '';
66
+ return !status || status === 'idle' || status === 'ready';
67
+ }
68
+
69
+
70
+ function getMessageTime(message: unknown): number {
71
+ if (!message || typeof message !== 'object') return 0;
72
+ const record = message as { receivedAt?: unknown; timestamp?: unknown };
73
+ const value = Number(record.receivedAt ?? record.timestamp ?? 0);
74
+ return Number.isFinite(value) ? value : 0;
75
+ }
76
+
77
+ type CompletedFinalizationBlock = {
78
+ reason: string;
79
+ terminal?: boolean;
80
+ allowTimeout?: boolean;
81
+ };
82
+
83
+ type CompletionFinalAssistantEvidence = {
84
+ present: boolean;
85
+ messages: unknown[];
86
+ source: 'parsed' | 'external-native' | 'unavailable';
87
+ };
88
+
89
+ type ExternalTranscriptProbe = {
90
+ readAt: number;
91
+ msgCount: number;
92
+ lastRole: string | null;
93
+ lastKind: string | null;
94
+ contentLen: number;
95
+ sourcePath: string | null;
96
+ sourceMtimeMs: number | null;
97
+ mtimeAgeMs: number | null;
44
98
  };
45
99
 
46
100
  const COMPLETED_FINALIZATION_RETRY_MS = 1000;
47
101
  const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
48
102
 
103
+ /** Events that signal a dispatched mesh task has reached a terminal state.
104
+ * Detach the mesh assignment after emitting one of these so the worker's
105
+ * next unrelated turn doesn't impersonate another completion. */
106
+ const TERMINAL_MESH_EVENTS = new Set([
107
+ 'agent:generating_completed',
108
+ 'agent:stopped',
109
+ 'agent:ready',
110
+ ]);
111
+
49
112
  const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
50
113
  'image/png': '.png',
51
114
  'image/jpeg': '.jpg',
@@ -120,7 +183,7 @@ function hasNonEmptyCliModalButtons(activeModal: unknown): boolean {
120
183
  }
121
184
 
122
185
  function isCliGeneratingLikeStatus(status: unknown): boolean {
123
- return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
186
+ return status === 'generating' || status === 'streaming' || status === 'no_progress' || status === 'long_generating' || status === 'starting';
124
187
  }
125
188
 
126
189
  export function buildCliStructuredInputPrompt(
@@ -300,6 +363,19 @@ export class CliProviderInstance implements ProviderInstance {
300
363
  readonly type: string;
301
364
  readonly category = 'cli' as const;
302
365
 
366
+ /**
367
+ * Quiet period an approval modal's signature must be stable before
368
+ * auto-approve sends the approve key. Guards against firing on a prompt
369
+ * that is still streaming into the PTY (the "resolves too fast" symptom):
370
+ * while the modal text/buttons are still changing, every frame yields a
371
+ * new signature and the settle clock restarts. Once the prompt finishes
372
+ * rendering the signature holds and the key is sent after this window.
373
+ * Bounded + small so genuine approvals stay timely. The FSM is already
374
+ * authoritative over the `waiting_approval` state; this only delays the
375
+ * keystroke until the modal *content* has settled.
376
+ */
377
+ private static readonly AUTO_APPROVE_SETTLE_MS = 600;
378
+
303
379
  private adapter: ProviderCliAdapter;
304
380
  private context: InstanceContext | null = null;
305
381
  private events: ProviderEvent[] = [];
@@ -309,15 +385,25 @@ export class CliProviderInstance implements ProviderInstance {
309
385
  private monitor: StatusMonitor;
310
386
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
311
387
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
312
- private lastApprovalEventAt = 0;
388
+ private lastApprovalEventFingerprint = '';
313
389
  private autoApproveBusy = false;
314
390
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
391
+ private lastAutoApprovalSignature = '';
392
+ // Settle gate: the approval modal's signature + the wall-clock when this
393
+ // exact signature was first observed. Auto-approve only fires once the
394
+ // SAME signature has been stable for AUTO_APPROVE_SETTLE_MS, so a prompt
395
+ // still streaming into the PTY (its buttons/message changing frame to
396
+ // frame) keeps resetting the timer and is never approved half-rendered.
397
+ private pendingAutoApprovalSignature = '';
398
+ private pendingAutoApprovalSince = 0;
399
+ private autoApproveSettleTimer: NodeJS.Timeout | null = null;
315
400
  private controlValues: Record<string, string | number | boolean> = {};
316
401
  private summaryMetadata: unknown = undefined;
317
402
  private appliedEffectKeys = new Set<string>();
318
403
  private historyWriter: ChatHistoryWriter;
319
404
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
320
405
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
406
+ private lastAcknowledgedUserInputAt = 0;
321
407
  private lastNativeSourceCanonicalCheckAt = 0;
322
408
  private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
323
409
  private cachedSqliteDb: {
@@ -330,6 +416,7 @@ export class CliProviderInstance implements ProviderInstance {
330
416
  private suppressIdleHistoryReplay = false;
331
417
  private errorMessage: string | undefined = undefined;
332
418
  private errorReason: ProviderErrorReason | undefined = undefined;
419
+ private activeInteractivePrompt: InteractivePrompt | null = null;
333
420
 
334
421
  private presentationMode: 'terminal' | 'chat';
335
422
  private providerSessionId?: string;
@@ -370,7 +457,10 @@ export class CliProviderInstance implements ProviderInstance {
370
457
  this.providerSessionId = options?.providerSessionId;
371
458
  this.launchMode = options?.launchMode || 'new';
372
459
  this.onProviderSessionResolved = options?.onProviderSessionResolved;
373
- this.adapter = new ProviderCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory);
460
+ this.adapter = createCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory) as ProviderCliAdapter;
461
+ if (this.providerSessionId) {
462
+ this.adapter.updateRuntimeMeta({ providerSessionId: this.providerSessionId });
463
+ }
374
464
  this.monitor = new StatusMonitor();
375
465
  this.historyWriter = new ChatHistoryWriter();
376
466
  }
@@ -389,8 +479,8 @@ export class CliProviderInstance implements ProviderInstance {
389
479
  this.adapter.updateRuntimeSettings?.(this.settings);
390
480
  this.monitor.updateConfig({
391
481
  approvalAlert: this.settings.approvalAlert !== false,
392
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
393
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
482
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
483
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
394
484
  });
395
485
 
396
486
  // Server connection
@@ -412,7 +502,7 @@ export class CliProviderInstance implements ProviderInstance {
412
502
  await this.adapter.spawn();
413
503
  await this.enforceFreshSessionLaunchIfNeeded();
414
504
  this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
415
- if (this.providerSessionId) {
505
+ if (this.providerSessionId && this.shouldHydrateExistingProviderHistory()) {
416
506
  this.restorePersistedHistoryFromCurrentSession();
417
507
  }
418
508
  if (this.providerSessionId && this.launchMode === 'resume') {
@@ -486,14 +576,38 @@ export class CliProviderInstance implements ProviderInstance {
486
576
  }
487
577
 
488
578
  getState(): ProviderState {
579
+ // TODO(phase5-sandbox): JS override scripts (detectStatus, parseApproval,
580
+ // parseSession) are currently invoked by CliScriptRunner.invoke() via direct
581
+ // function calls — the scripts run in the daemon process with full Node.js
582
+ // access and no resource limits.
583
+ //
584
+ // When Phase 5 lands, CliScriptRunner should route these calls through a
585
+ // SandboxedScriptRunner (see providers/sdk/v1/sandbox/script-runner.ts) so
586
+ // that each call gets a fresh isolated-vm context with a 50 ms CPU limit and
587
+ // a 32 MB memory cap. The execution path to change is:
588
+ // CliScriptRunner.invoke() → SandboxedScriptRunner.run(scriptSource, context)
589
+ //
590
+ // This getState() call-site is NOT where the change goes — the wiring belongs
591
+ // in cli-script-runner.ts (CliScriptRunner.detectStatus / parseApproval /
592
+ // parseSession), with provider-loader.ts updated to store script source strings
593
+ // alongside the loaded function references for extended-legacy providers.
489
594
  const adapterStatus = this.adapter.getStatus();
595
+ if (Object.prototype.hasOwnProperty.call(adapterStatus, 'activeInteractivePrompt')) {
596
+ this.activeInteractivePrompt = adapterStatus.activeInteractivePrompt ?? null;
597
+ }
490
598
  let parsedStatus: any = null;
491
599
  let parseErrorMessage: string | undefined;
492
600
  if (typeof this.adapter.getScriptParsedStatus === 'function') {
493
601
  try {
494
602
  parsedStatus = this.adapter.getScriptParsedStatus() || null;
495
- this.errorMessage = undefined;
496
- this.errorReason = undefined;
603
+ const parsedErrorMessage = typeof parsedStatus?.errorMessage === 'string' && parsedStatus.errorMessage.trim()
604
+ ? parsedStatus.errorMessage.trim()
605
+ : undefined;
606
+ const parsedErrorReason = typeof parsedStatus?.errorReason === 'string' && parsedStatus.errorReason.trim()
607
+ ? parsedStatus.errorReason.trim() as ProviderErrorReason
608
+ : undefined;
609
+ this.errorMessage = parsedErrorMessage;
610
+ this.errorReason = parsedErrorReason;
497
611
  } catch (error: any) {
498
612
  parseErrorMessage = error?.message || String(error);
499
613
  this.errorMessage = parseErrorMessage;
@@ -503,22 +617,53 @@ export class CliProviderInstance implements ProviderInstance {
503
617
  this.errorMessage = undefined;
504
618
  this.errorReason = undefined;
505
619
  }
506
- const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
507
- const visibleStatus = parseErrorMessage
508
- ? 'error'
509
- : (autoApproveActive ? 'generating' : adapterStatus.status);
510
- const parsedProviderSessionId = normalizeProviderSessionId(
620
+ const adapterProviderSessionId = normalizeProviderSessionId(
511
621
  this.provider,
512
- typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
622
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
513
623
  );
514
- if (parsedProviderSessionId) {
515
- this.promoteProviderSessionId(parsedProviderSessionId);
624
+ const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
625
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
626
+ let visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
627
+ ? 'error'
628
+ : (autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status);
629
+ // getState() must agree with the status the FSM-driven detectStatusTransition()
630
+ // already committed to lastStatus. The adapter's own status is authoritative; we do
631
+ // not second-guess it with native-transcript shape. Only reconcile a generating-like
632
+ // read down to idle when our own lastStatus has already flipped idle (avoids a
633
+ // perpetual dashboard spinner during the brief window before the next getStatus()).
634
+ if (isCliGeneratingLikeStatus(visibleStatus) && this.lastStatus === 'idle') {
635
+ visibleStatus = 'idle';
516
636
  }
517
637
  const runtime = this.adapter.getRuntimeMetadata();
518
638
  this.maybeAppendRuntimeRecoveryMessage(runtime);
519
639
  let parsedMessages = Array.isArray(parsedStatus?.messages)
520
640
  ? parsedStatus.messages
521
641
  : [];
642
+ const parsedProviderSessionId = normalizeProviderSessionId(
643
+ this.provider,
644
+ typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
645
+ );
646
+ const suppressFreshLaunchStartupReplay = this.shouldSuppressFreshLaunchStartupReplay(
647
+ parsedMessages,
648
+ parsedStatus,
649
+ adapterStatus,
650
+ parsedProviderSessionId,
651
+ );
652
+ if (adapterProviderSessionId && !suppressFreshLaunchStartupReplay) {
653
+ this.promoteProviderSessionId(adapterProviderSessionId);
654
+ }
655
+ if (parsedProviderSessionId && !suppressFreshLaunchStartupReplay) {
656
+ this.promoteProviderSessionId(parsedProviderSessionId);
657
+ }
658
+ if (suppressFreshLaunchStartupReplay) {
659
+ parsedMessages = [];
660
+ }
661
+ // Adapter runtime metadata is transport-owned and is not guaranteed to
662
+ // identify this conversation. Spec adapters historically exposed the
663
+ // provider spec id (for example "codex-cli") as runtimeId, which made
664
+ // concurrent sessions share one activeChat identity until their native
665
+ // provider session ids were discovered.
666
+ const activeChatId = this.providerSessionId || this.instanceId;
522
667
  const historyMessageCount = Number.isFinite(parsedStatus?.historyMessageCount)
523
668
  ? Math.max(0, Number(parsedStatus.historyMessageCount))
524
669
  : null;
@@ -528,9 +673,20 @@ export class CliProviderInstance implements ProviderInstance {
528
673
  : [];
529
674
  }
530
675
  const mergedMessages = this.mergeConversationMessages(parsedMessages);
531
- const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
676
+ const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
677
+ ? this.syncCanonicalSavedHistoryIfNeeded()
678
+ : false;
679
+ const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
680
+ ? this.lastPersistedHistoryMessages.map((message) => ({
681
+ role: message.role,
682
+ content: message.content,
683
+ kind: message.kind,
684
+ senderName: message.senderName,
685
+ receivedAt: message.receivedAt,
686
+ }))
687
+ : mergedMessages;
532
688
 
533
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
689
+ const dirName = workingDirBasename(this.workingDir);
534
690
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
535
691
  ? parsedStatus.status.trim()
536
692
  : undefined;
@@ -542,7 +698,7 @@ export class CliProviderInstance implements ProviderInstance {
542
698
  && adapterStatus.status === 'idle'
543
699
  && parsedStatus?.status === 'idle';
544
700
  let messagesToSave = parsedMessages;
545
- if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'long_generating')) {
701
+ if (!suppressStaleParsedBusyStatus && (parsedChatStatus === 'generating' || parsedChatStatus === 'no_progress' || parsedChatStatus === 'long_generating')) {
546
702
  const lastIdx = messagesToSave.length - 1;
547
703
  if (lastIdx >= 0 && messagesToSave[lastIdx]?.role === 'assistant') {
548
704
  messagesToSave = messagesToSave.slice(0, lastIdx);
@@ -572,33 +728,48 @@ export class CliProviderInstance implements ProviderInstance {
572
728
  }
573
729
  }
574
730
 
575
- this.applyProviderResponse(parsedStatus, { phase: 'immediate' });
731
+ this.applyProviderResponse(
732
+ suppressFreshLaunchStartupReplay && parsedStatus && typeof parsedStatus === 'object'
733
+ ? { ...parsedStatus, providerSessionId: undefined }
734
+ : parsedStatus,
735
+ { phase: 'immediate' },
736
+ );
576
737
  const surface = resolveProviderStateSurface({
577
738
  summaryMetadata: this.summaryMetadata as any,
578
739
  controlValues: this.controlValues,
579
740
  });
580
741
  const activeChatStatus = parseErrorMessage
581
742
  ? 'error'
582
- : autoApproveActive && parsedStatus?.status === 'waiting_approval'
743
+ : (autoApproveActive && parsedStatus?.status === 'waiting_approval') || autoApproveHoldIdle
583
744
  ? 'generating'
584
745
  : (adapterStatus.status !== 'idle'
585
746
  ? visibleStatus
586
747
  : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
587
748
 
749
+ // If an AskUserQuestion prompt is awaiting user input, overlay status as
750
+ // waiting_choice. This is distinct from waiting_approval (tool-use consent)
751
+ // — the engine's isWaitingForResponse state is unchanged, so completion
752
+ // tracking continues normally once the user responds.
753
+ const hasInteractivePrompt = !!this.activeInteractivePrompt;
754
+ const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
755
+ const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
756
+
588
757
  return {
589
758
  type: this.type,
590
759
  name: this.provider.name,
591
760
  category: 'cli',
592
- status: visibleStatus,
761
+ status: finalStatus,
593
762
  mode: this.presentationMode,
594
763
  activeChat: {
595
- id: `${this.type}_${this.workingDir}`,
764
+ id: activeChatId,
596
765
  title: parsedStatus?.title || dirName,
597
- status: activeChatStatus,
598
- messages: mergedMessages,
599
- activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
766
+ status: finalChatStatus,
767
+ messages: statusMessages,
768
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
769
+ activeInteractivePrompt: this.activeInteractivePrompt,
600
770
  inputContent: '',
601
771
  },
772
+ activeInteractivePrompt: this.activeInteractivePrompt,
602
773
  workspace: this.workingDir,
603
774
  instanceId: this.instanceId,
604
775
  providerSessionId: this.providerSessionId,
@@ -639,7 +810,8 @@ export class CliProviderInstance implements ProviderInstance {
639
810
  getHotChatSessionState(): HotChatSessionState {
640
811
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
641
812
  const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
642
- const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
813
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
814
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
643
815
  const runtime = this.adapter.getRuntimeMetadata();
644
816
  return {
645
817
  id: this.instanceId,
@@ -651,36 +823,149 @@ export class CliProviderInstance implements ProviderInstance {
651
823
  };
652
824
  }
653
825
 
654
- getSessionModalState(): SessionModalState {
655
- const adapterStatus = this.adapter.getStatus({ allowParse: false });
826
+ getSessionModalState(sessionId?: string): SessionModalState {
827
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
656
828
  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';
829
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
830
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
831
+ const dirName = workingDirBasename(this.workingDir);
659
832
  return {
660
- id: this.instanceId,
833
+ // Honor the caller-supplied sessionId — InstanceMgr rejects the
834
+ // projection when projected.id !== requested sessionId, and
835
+ // this.instanceId is the manager's internal key, not the public
836
+ // sessionId the dashboard subscribes by.
837
+ id: sessionId ?? this.instanceId,
661
838
  status: visibleStatus,
662
839
  title: dirName,
663
- activeModal: autoApproveActive ? null : adapterStatus.activeModal,
840
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : adapterStatus.activeModal,
664
841
  };
665
842
  }
666
843
 
667
844
  updateSettings(newSettings: Record<string, any>): void {
668
- this.settings = { ...newSettings };
845
+ // Merge semantics: a key omitted from newSettings preserves its existing
846
+ // value, a key present in newSettings (even as false) overrides it.
847
+ //
848
+ // This is required because updateSettings has two callers with opposite
849
+ // intent:
850
+ // 1. Full re-injection — the dashboard toggle path (handleSetProviderSetting
851
+ // → getSettings → updateInstanceSettings) sends the COMPLETE settings
852
+ // object, so an explicit autoApprove:false must win.
853
+ // 2. Partial stamp — the mesh relay-safety stamp (router.ts agent_command,
854
+ // buildMeshWorkerRelayStamp) sends ONLY {meshNodeFor, meshNodeId,
855
+ // meshCoordinatorDaemonId, launchedByCoordinator} on every coordinator
856
+ // re-dispatch. It carries no autoApprove, so a full replacement would
857
+ // wipe the launch-time autoApprove:true the worker was started with,
858
+ // silently dropping every later approval to a manual gate until the
859
+ // machine-page toggle re-injected the full settings.
860
+ //
861
+ // A plain merge satisfies both: undefined keys fall through to the existing
862
+ // value (preserving launch-stamp settings like autoApprove + the mesh routing
863
+ // keys), explicit keys override. This subsumes the previous mesh-key preserve
864
+ // list, which only protected the routing keys and not autoApprove.
865
+ this.settings = { ...this.settings, ...newSettings };
669
866
  this.adapter.updateRuntimeSettings?.(this.settings);
670
867
  this.monitor.updateConfig({
671
868
  approvalAlert: this.settings.approvalAlert !== false,
672
- longGeneratingAlert: this.settings.longGeneratingAlert !== false,
673
- longGeneratingThresholdSec: this.settings.longGeneratingThresholdSec || 180,
869
+ noProgressAlert: (this.settings.noProgressAlert ?? this.settings.longGeneratingAlert) !== false,
870
+ noProgressThresholdSec: this.settings.noProgressThresholdSec ?? this.settings.longGeneratingThresholdSec ?? 180,
674
871
  });
675
872
  }
676
873
 
874
+ /**
875
+ * Stamp a direct-dispatch mesh assignment on this instance.
876
+ * setupMeshEventForwarding reads settings.meshNodeFor + meshActiveTaskId to
877
+ * route generating_completed back to the originating coordinator. Without
878
+ * this stamp, mesh_send_task --direct targets a plain CLI session whose
879
+ * completion events silently drop because the forwarder has nothing to
880
+ * match against.
881
+ */
882
+ attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string; coordinatorSessionId?: string }): void {
883
+ if (!assignment?.meshId) return;
884
+ this.settings = {
885
+ ...this.settings,
886
+ meshNodeFor: assignment.meshId,
887
+ ...(assignment.nodeId ? { meshNodeId: assignment.nodeId } : {}),
888
+ ...(assignment.taskId ? { meshActiveTaskId: assignment.taskId } : {}),
889
+ ...(assignment.coordinatorDaemonId ? { meshCoordinatorDaemonId: assignment.coordinatorDaemonId } : {}),
890
+ // Session-level routing anchor: the originating coordinator session, so this
891
+ // worker's completion events route back to the exact session that dispatched it.
892
+ ...(assignment.coordinatorSessionId ? { meshCoordinatorSessionId: assignment.coordinatorSessionId } : {}),
893
+ };
894
+ this.adapter.updateRuntimeSettings?.(this.settings);
895
+ }
896
+
897
+ /**
898
+ * Clear a previously-attached mesh assignment after the task reaches a
899
+ * terminal state. Leaving meshNodeFor pinned would route this session's
900
+ * subsequent unrelated turns (e.g. ad-hoc dashboard chats) to the
901
+ * coordinator as if they were task completions.
902
+ */
903
+ detachMeshAssignment(): void {
904
+ if (!this.settings.meshNodeFor && !this.settings.meshActiveTaskId && !this.settings.meshNodeId) return;
905
+ const { meshNodeFor, meshNodeId, meshActiveTaskId, ...rest } = this.settings;
906
+ void meshNodeFor; void meshNodeId; void meshActiveTaskId;
907
+ this.settings = rest;
908
+ this.adapter.updateRuntimeSettings?.(this.settings);
909
+ }
910
+
911
+ /**
912
+ * The resolved modal-park status of this session, or null when it is not
913
+ * parked on a modal awaiting a human answer. Mirrors the overlay logic in
914
+ * getState(): an active AskUserQuestion interactive prompt resolves to
915
+ * waiting_choice; otherwise the adapter's waiting_approval (tool consent)
916
+ * counts — UNLESS auto-approve will dismiss it, in which case the session is
917
+ * effectively generating and is NOT modal-parked. This is the single signal
918
+ * the mesh force-inject guard consults, and the same status string the
919
+ * reconcile loop reads off get_status_metadata. Lowercase literals only —
920
+ * the SessionStatus enum is forked across modules and waiting_choice is
921
+ * absent from some of them.
922
+ */
923
+ resolveModalParkStatus(): 'waiting_choice' | 'waiting_approval' | null {
924
+ if (this.activeInteractivePrompt) return 'waiting_choice';
925
+ let adapterStatus: { status?: string };
926
+ try {
927
+ adapterStatus = this.adapter.getStatus({ allowParse: false });
928
+ } catch {
929
+ return null;
930
+ }
931
+ if (adapterStatus.status === 'waiting_approval' && !this.shouldAutoApprove()) {
932
+ return 'waiting_approval';
933
+ }
934
+ return null;
935
+ }
936
+
937
+ /** True when this session is parked on a modal awaiting a human answer. */
938
+ isModalParked(): boolean {
939
+ return this.resolveModalParkStatus() !== null;
940
+ }
941
+
677
942
  onEvent(event: string, data?: any): void {
678
943
  if (event === 'send_message') {
679
944
  const input = normalizeInputEnvelope(data);
680
945
  assertProviderSupportsDeclaredInput(this.provider, input);
681
946
  const promptText = buildCliStructuredInputPrompt(input);
682
947
  if (promptText) {
683
- void this.adapter.sendMessage(promptText).catch((e: any) => {
948
+ // force:true bypasses the busy/generating send guard so terminal mesh
949
+ // events (completion/failure/bootstrap) land in a coordinator session that
950
+ // is itself parked in `generating` while awaiting that very event.
951
+ // Without it the message is queued and only flushed on the coordinator's
952
+ // own idle transition — which never happens until it receives the message.
953
+ const force = data?.force === true;
954
+ // Modal guard: a force-inject still writes raw keystrokes into the PTY,
955
+ // bypassing the busy send-guard. If the coordinator is parked on a
956
+ // harness modal (claude-cli AskUserQuestion → waiting_choice, or a
957
+ // tool-consent waiting_approval), those keystrokes are consumed by the
958
+ // modal's key handler and silently select a choice the user never made
959
+ // (data corruption). Hold the force-inject in that narrow window —
960
+ // the event stays queued and the reconcile loop redelivers it on the
961
+ // next tick once the modal is resolved. We ONLY hold for the two modal
962
+ // states; generating is still force-injected (that is the deadlock the
963
+ // force path exists to break — see mesh-events-coordinator).
964
+ if (force && this.isModalParked()) {
965
+ LOG.info('CLI', `[${this.type}] force send_message held — coordinator parked on modal (${this.resolveModalParkStatus()})`);
966
+ return;
967
+ }
968
+ void this.adapter.sendMessage(promptText, force ? { force: true } : {}).catch((e: any) => {
684
969
  LOG.warn('CLI', `[${this.type}] send_message failed: ${e?.message || e}`);
685
970
  });
686
971
  }
@@ -690,14 +975,73 @@ export class CliProviderInstance implements ProviderInstance {
690
975
  void this.adapter.resolveAction(data).catch((e: any) => {
691
976
  LOG.warn('CLI', `[${this.type}] resolve_action failed: ${e?.message || e}`);
692
977
  });
978
+ } else if (event === 'interactive_prompt' && data) {
979
+ const prompt = normalizeInteractivePrompt(data);
980
+ if (prompt) {
981
+ this.activeInteractivePrompt = prompt;
982
+ this.events.push({
983
+ event: 'interactive_prompt',
984
+ timestamp: Date.now(),
985
+ promptId: prompt.promptId,
986
+ });
987
+ }
988
+ } else if (event === 'interactive_prompt_response' && data) {
989
+ try {
990
+ const response = normalizeInteractivePromptResponse(data);
991
+ if (this.activeInteractivePrompt?.promptId === response.promptId) {
992
+ this.activeInteractivePrompt = null;
993
+ }
994
+ if (typeof this.adapter.setInteractivePromptResponse !== 'function') {
995
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response ignored: adapter does not support interactive prompts`);
996
+ return;
997
+ }
998
+ void this.adapter.setInteractivePromptResponse(response).catch((e: any) => {
999
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response failed: ${e?.message || e}`);
1000
+ });
1001
+ } catch (e: any) {
1002
+ LOG.warn('CLI', `[${this.type}] invalid interactive_prompt_response: ${e?.message || e}`);
1003
+ }
693
1004
  } else if (event === 'provider_state_patch' && data && typeof data === 'object') {
694
1005
  this.applyProviderResponse(data, { phase: 'immediate' });
695
1006
  }
696
1007
  }
697
1008
 
1009
+ recordAcknowledgedUserInput(input: InputEnvelope | string): void {
1010
+ const content = typeof input === 'string'
1011
+ ? input.trim()
1012
+ : buildCliStructuredInputPrompt(input).trim();
1013
+ if (!content) return;
1014
+
1015
+ const receivedAt = Date.now();
1016
+ this.lastAcknowledgedUserInputAt = receivedAt;
1017
+ const dedupKey = `user_input_ack:${crypto
1018
+ .createHash('sha256')
1019
+ .update(`${this.instanceId}:${content}:${receivedAt}`)
1020
+ .digest('hex')
1021
+ .slice(0, 24)}`;
1022
+ this.appendRuntimeMessage(buildChatMessage({
1023
+ role: 'user',
1024
+ senderName: 'User',
1025
+ kind: 'standard',
1026
+ content,
1027
+ receivedAt,
1028
+ timestamp: receivedAt,
1029
+ source: 'runtime_input_ack',
1030
+ meta: {
1031
+ runtimeInputAck: true,
1032
+ provider: this.type,
1033
+ workspace: this.workingDir,
1034
+ },
1035
+ } as ChatMessage), dedupKey);
1036
+ }
1037
+
698
1038
  dispose(): void {
699
1039
  this.adapter.shutdown();
700
1040
  this.monitor.reset();
1041
+ // Cancel any armed auto-approve timers so a pending settle re-check
1042
+ // can't fire resolveModal/detectStatusTransition against a dead adapter.
1043
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1044
+ if (this.autoApproveBusyTimer) { clearTimeout(this.autoApproveBusyTimer); this.autoApproveBusyTimer = null; }
701
1045
  this.appliedEffectKeys.clear();
702
1046
  try { this.cachedSqliteDb?.close(); } catch { /* noop */ }
703
1047
  this.cachedSqliteDb = null;
@@ -706,6 +1050,7 @@ export class CliProviderInstance implements ProviderInstance {
706
1050
 
707
1051
  private completedDebounceTimer: NodeJS.Timeout | null = null;
708
1052
  private completedDebouncePending: CompletedDebouncePending | null = null;
1053
+ private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
709
1054
 
710
1055
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
711
1056
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -740,7 +1085,180 @@ export class CliProviderInstance implements ProviderInstance {
740
1085
  const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
741
1086
  const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
742
1087
  const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
743
- return role === 'assistant' && !!content;
1088
+ if (role !== 'assistant' || !content) return false;
1089
+ // Guard: if the last assistant message looks like an active approval/input prompt,
1090
+ // it is not a real completion — the session is still awaiting user input.
1091
+ if (looksLikeActiveApprovalPromptText(content)) return false;
1092
+ return true;
1093
+ }
1094
+
1095
+ private buildExternalTranscriptProbe(messages: unknown[], sourcePath?: string, sourceMtimeMs?: number): ExternalTranscriptProbe {
1096
+ const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1097
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1098
+ const readAt = Date.now();
1099
+ const mtimeMs = Number(sourceMtimeMs) || 0;
1100
+ return {
1101
+ readAt,
1102
+ msgCount: messages.length,
1103
+ lastRole: typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null,
1104
+ lastKind: typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null,
1105
+ contentLen: lastVisible ? flattenContent(lastVisible.content).trim().length : 0,
1106
+ sourcePath: typeof sourcePath === 'string' && sourcePath ? sourcePath : null,
1107
+ sourceMtimeMs: mtimeMs || null,
1108
+ mtimeAgeMs: mtimeMs ? Math.max(0, readAt - mtimeMs) : null,
1109
+ };
1110
+ }
1111
+
1112
+ private recordPendingTranscriptProbe(pending: CompletedDebouncePending): ExternalTranscriptProbe | null {
1113
+ const probe = this.lastExternalCompletionProbe;
1114
+ if (!probe) return null;
1115
+ const history = pending.transcriptProbeHistory || [];
1116
+ const last = history[history.length - 1];
1117
+ if (!last || last.readAt !== probe.readAt || last.msgCount !== probe.msgCount || last.lastRole !== probe.lastRole || last.contentLen !== probe.contentLen) {
1118
+ history.push(probe);
1119
+ pending.transcriptProbeHistory = history.slice(-5);
1120
+ }
1121
+ return probe;
1122
+ }
1123
+
1124
+ private readExternalCompletionMessages(): unknown[] | null {
1125
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1126
+ if (!adapterOwnsMessagesElsewhere) return null;
1127
+ if (!this.providerSessionId) return null;
1128
+ if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
1129
+
1130
+ if (this.lastExternalCompletionProbe?.sourcePath) {
1131
+ try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
1132
+ }
1133
+ const restoredHistory = readProviderChatHistory(this.type, {
1134
+ canonicalHistory: this.provider.nativeHistory,
1135
+ historySessionId: this.providerSessionId,
1136
+ workspace: this.workingDir,
1137
+ offset: 0,
1138
+ limit: Number.MAX_SAFE_INTEGER,
1139
+ historyBehavior: this.provider.historyBehavior,
1140
+ scripts: this.provider.scripts as any,
1141
+ sessionStartedAtMs: this.startedAt,
1142
+ forceRefresh: true,
1143
+ });
1144
+ if (restoredHistory.source !== 'provider-native') {
1145
+ this.lastExternalCompletionProbe = null;
1146
+ return null;
1147
+ }
1148
+ this.lastExternalCompletionProbe = this.buildExternalTranscriptProbe(
1149
+ restoredHistory.messages,
1150
+ restoredHistory.sourcePath,
1151
+ restoredHistory.sourceMtimeMs,
1152
+ );
1153
+ return restoredHistory.messages;
1154
+ }
1155
+
1156
+ private completionFinalAssistantEvidence(parsedMessages: unknown): CompletionFinalAssistantEvidence {
1157
+ if (this.completionHasFinalAssistantMessage(parsedMessages)) {
1158
+ return {
1159
+ present: true,
1160
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1161
+ source: 'parsed',
1162
+ };
1163
+ }
1164
+
1165
+ const externalMessages = this.readExternalCompletionMessages();
1166
+ if (externalMessages) {
1167
+ return {
1168
+ present: this.completionHasFinalAssistantMessage(externalMessages),
1169
+ messages: externalMessages,
1170
+ source: 'external-native',
1171
+ };
1172
+ }
1173
+
1174
+ return {
1175
+ present: false,
1176
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1177
+ source: 'unavailable',
1178
+ };
1179
+ }
1180
+
1181
+ private completionFinalSummary(parsedMessages: unknown): string | undefined {
1182
+ // For native-source providers (claude-cli: chatMessagesOwnedExternally), the PTY
1183
+ // screen parse is NOT the source of truth for the final summary — the terminal
1184
+ // wraps/scrolls/clips text, so a screen-parsed assistant message is often a partial
1185
+ // prefix (e.g. 76 chars of a 112-char turn). The append-only native transcript holds
1186
+ // the complete turn. Prefer it whenever it yields a longer/complete summary; fall back
1187
+ // to the parsed screen only when the transcript is unavailable. This is the real cause
1188
+ // of the truncated finalSummary — independent of cloud vs standalone (it surfaces on
1189
+ // any short, fast-completing task where screen parse wins the race).
1190
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1191
+ const parsedSummary = extractFinalSummaryFromMessages(
1192
+ (this.completionHasFinalAssistantMessage(parsedMessages)
1193
+ ? (Array.isArray(parsedMessages) ? parsedMessages : [])
1194
+ : []) as any,
1195
+ );
1196
+ if (adapterOwnsMessagesElsewhere) {
1197
+ const externalMessages = this.readExternalCompletionMessages();
1198
+ const externalSummary = externalMessages
1199
+ ? extractFinalSummaryFromMessages(externalMessages as any)
1200
+ : '';
1201
+ // The transcript is authoritative for native-source providers. Use it unless it is
1202
+ // empty (not yet written) — only then fall back to whatever the screen parsed.
1203
+ if (externalSummary) return externalSummary;
1204
+ return parsedSummary || undefined;
1205
+ }
1206
+ return parsedSummary || undefined;
1207
+ }
1208
+
1209
+ private buildCompletedFinalizationDiagnostic(args: {
1210
+ blockReason: string;
1211
+ latestStatus?: any;
1212
+ latestVisibleStatus: string;
1213
+ waitedMs: number;
1214
+ pending: CompletedDebouncePending;
1215
+ emittedAfterFinalizationTimeout: boolean;
1216
+ }): Record<string, unknown> {
1217
+ let parsed: any = null;
1218
+ let parseError: string | undefined;
1219
+ try {
1220
+ parsed = this.adapter.getScriptParsedStatus();
1221
+ } catch (error: any) {
1222
+ parseError = error?.message || String(error);
1223
+ }
1224
+
1225
+ const evidence = this.completionFinalAssistantEvidence(parsed?.messages);
1226
+ if (evidence.source === 'external-native') {
1227
+ this.recordPendingTranscriptProbe(args.pending);
1228
+ }
1229
+ const visibleMessages = (Array.isArray(evidence.messages) ? evidence.messages : [])
1230
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1231
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1232
+ const lastVisibleRole = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null;
1233
+ const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
1234
+ const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
1235
+
1236
+ return {
1237
+ providerType: this.type,
1238
+ sessionId: this.instanceId,
1239
+ providerSessionId: this.providerSessionId || null,
1240
+ workspace: this.workingDir,
1241
+ blockReason: args.blockReason,
1242
+ emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
1243
+ waitedMs: args.waitedMs,
1244
+ maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
1245
+ adapterStatus: typeof args.latestStatus?.status === 'string' ? args.latestStatus.status : null,
1246
+ latestVisibleStatus: args.latestVisibleStatus,
1247
+ parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
1248
+ parseError: parseError || undefined,
1249
+ finalAssistantPresent: evidence.present,
1250
+ finalAssistantEvidenceSource: evidence.source,
1251
+ visibleMessageCount: visibleMessages.length,
1252
+ lastVisibleRole,
1253
+ lastVisibleKind,
1254
+ lastVisibleContentLength,
1255
+ pendingStartedAt: this.generatingStartedAt || null,
1256
+ pendingFirstObservedAt: args.pending.firstObservedAt,
1257
+ pendingTimestamp: args.pending.timestamp,
1258
+ pendingDurationSec: args.pending.duration,
1259
+ previousBlockReason: args.pending.loggedBlockReason || null,
1260
+ transcriptProbeHistory: args.pending.transcriptProbeHistory || [],
1261
+ };
744
1262
  }
745
1263
 
746
1264
  private hasAdapterPendingResponse(): boolean {
@@ -765,32 +1283,97 @@ export class CliProviderInstance implements ProviderInstance {
765
1283
  if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
766
1284
  if (adapterRawStatus !== 'idle') return false;
767
1285
  if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
768
- return !this.hasAdapterPendingResponse();
1286
+ if (this.hasAdapterPendingResponse()) return false;
1287
+ // Do not suppress when the adapter's raw response buffer is still non-empty.
1288
+ // This catches the case where isWaitingForResponse has already flipped to false
1289
+ // (so getPartialResponse() returns '') but the provider's native parser still
1290
+ // reports generating because it's parsing buffered content. Suppressing the
1291
+ // finalization block here would emit a false completion event while the provider
1292
+ // session is still actively processing its response stream.
1293
+ const adapterAny = this.adapter as any;
1294
+ if (typeof adapterAny?.responseBuffer === 'string' && adapterAny.responseBuffer.trim()) return false;
1295
+ return true;
769
1296
  }
770
1297
 
771
- private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
- if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
1298
+ private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1299
+ if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
773
1300
 
774
1301
  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';
1302
+ const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1303
+ if (!approvalResolvedIdle) {
1304
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
1305
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
1306
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
1307
+ }
777
1308
 
778
1309
  const partial = typeof this.adapter.getPartialResponse === 'function'
779
1310
  ? this.adapter.getPartialResponse()
780
1311
  : '';
781
- if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
1312
+ if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
782
1313
 
783
1314
  let parsed: any;
784
1315
  try {
785
1316
  parsed = this.adapter.getScriptParsedStatus();
786
1317
  } catch (error: any) {
787
- return `parse_error:${error?.message || String(error)}`;
1318
+ return { reason: `parse_error:${error?.message || String(error)}` };
788
1319
  }
789
1320
 
790
1321
  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';
1322
+ if (parsedStatus !== 'idle') {
1323
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
1324
+ if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
1325
+ return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
1326
+ }
1327
+ if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
1328
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1329
+ const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
1330
+ const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1331
+ LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1332
+ if (!finalAssistantEvidence.present) {
1333
+ if (adapterOwnsMessagesElsewhere) {
1334
+ if (finalAssistantEvidence.source === 'external-native') {
1335
+ const probe = this.recordPendingTranscriptProbe(pending);
1336
+ if (probe && !pending.loggedTranscriptProbe) {
1337
+ 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`);
1338
+ pending.loggedTranscriptProbe = true;
1339
+ }
1340
+ LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
1341
+ if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
1342
+ return null;
1343
+ }
1344
+ if (this.type === 'antigravity-cli') {
1345
+ return null;
1346
+ }
1347
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1348
+ }
1349
+ if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
1350
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1351
+ }
1352
+ } else {
1353
+ LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
1354
+ return {
1355
+ reason: 'missing_final_assistant',
1356
+ terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
1357
+ allowTimeout: allowMissingAssistantTimeout,
1358
+ };
1359
+ }
1360
+ }
1361
+
1362
+ // Guard: if the screen still shows an approval/choice prompt as the last visible text,
1363
+ // the turn is not complete even if the parsed status says idle and there is an assistant
1364
+ // message. This catches the case where waiting_approval→idle transitions occur before
1365
+ // the modal has been resolved (e.g. the PTY rendered the prompt but no button press fired).
1366
+ try {
1367
+ const screenText = typeof (this.adapter as any).getScreenText === 'function'
1368
+ ? String((this.adapter as any).getScreenText() || '')
1369
+ : '';
1370
+ if (screenText) {
1371
+ const tailLines = screenText.split(/\r?\n/).slice(-16).join('\n');
1372
+ if (looksLikeActiveApprovalPromptText(tailLines)) {
1373
+ return { reason: 'screen_shows_approval_prompt', terminal: approvalResolvedIdle };
1374
+ }
1375
+ }
1376
+ } catch { /* defensive: screen text read is best-effort */ }
794
1377
 
795
1378
  return null;
796
1379
  }
@@ -809,7 +1392,8 @@ export class CliProviderInstance implements ProviderInstance {
809
1392
 
810
1393
  const latestStatus = this.adapter.getStatus({ allowParse: false });
811
1394
  const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
812
- const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
1395
+ const latestVisibleStatus = latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status;
1396
+ 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
1397
  if (latestVisibleStatus !== 'idle') {
814
1398
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
815
1399
  this.completedDebouncePending = null;
@@ -817,10 +1401,12 @@ export class CliProviderInstance implements ProviderInstance {
817
1401
  return;
818
1402
  }
819
1403
 
820
- const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
- if (blockReason) {
1404
+ const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
1405
+ if (block) {
1406
+ const blockReason = block.reason;
822
1407
  const waitedMs = Date.now() - pending.firstObservedAt;
823
- if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
1408
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1409
+ if ((block.terminal && !block.allowTimeout) || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
824
1410
  if (pending.loggedBlockReason !== blockReason) {
825
1411
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
826
1412
  pending.loggedBlockReason = blockReason;
@@ -828,10 +1414,36 @@ export class CliProviderInstance implements ProviderInstance {
828
1414
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
1415
  return;
830
1416
  }
831
- LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1417
+ const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
1418
+ blockReason,
1419
+ latestStatus,
1420
+ latestVisibleStatus,
1421
+ waitedMs,
1422
+ pending,
1423
+ emittedAfterFinalizationTimeout: true,
1424
+ });
1425
+ LOG.warn('CLI', `[${this.type}] emitting completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1426
+ this.pushEvent({
1427
+ event: 'agent:generating_completed',
1428
+ chatTitle: pending.chatTitle,
1429
+ duration: pending.duration,
1430
+ timestamp: pending.timestamp,
1431
+ // When finalization is forced past the timeout on a `parsed_status:` block
1432
+ // (the parser never confirmed a final assistant turn) we previously rode an
1433
+ // empty `finalSummary` unconditionally. That empty value propagates to the
1434
+ // mesh coordinator's mirror preview (meshSessionLastMessagePreview), leaving a
1435
+ // delegated session's inbox preview blank — or, for a LOCAL worktree session,
1436
+ // stuck on the dispatched user task. If the parser DID surface assistant text,
1437
+ // prefer it; only fall back to '' when no assistant summary can be derived.
1438
+ finalSummary: blockReason.startsWith('parsed_status:')
1439
+ ? (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages) ?? '')
1440
+ : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1441
+ completionDiagnostic,
1442
+ });
832
1443
  this.completedDebouncePending = null;
833
1444
  this.completedDebounceTimer = null;
834
1445
  this.generatingStartedAt = 0;
1446
+ this.lastApprovalEventFingerprint = '';
835
1447
  return;
836
1448
  }
837
1449
 
@@ -841,11 +1453,12 @@ export class CliProviderInstance implements ProviderInstance {
841
1453
  chatTitle: pending.chatTitle,
842
1454
  duration: pending.duration,
843
1455
  timestamp: pending.timestamp,
844
- finalSummary: extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages),
1456
+ finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
845
1457
  });
846
1458
  this.completedDebouncePending = null;
847
1459
  this.completedDebounceTimer = null;
848
1460
  this.generatingStartedAt = 0;
1461
+ this.lastApprovalEventFingerprint = '';
849
1462
  }
850
1463
 
851
1464
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
@@ -853,48 +1466,168 @@ export class CliProviderInstance implements ProviderInstance {
853
1466
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
854
1467
  // times while the PTY absorbs the approval key. Without this flag, repeated
855
1468
  // 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);
1469
+ // However, Claude Code can present a second approval immediately after the
1470
+ // first. Resolve a changed modal signature even while the previous write is
1471
+ // still inside the short busy window.
1472
+ if (!autoApproveActive) {
1473
+ this.lastAutoApprovalSignature = '';
1474
+ // Clear the settle gate so the next approval starts its own quiet
1475
+ // window from scratch (a stale timestamp would let it fire instantly).
1476
+ this.pendingAutoApprovalSignature = '';
1477
+ this.pendingAutoApprovalSince = 0;
1478
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1479
+ return autoApproveActive;
1480
+ }
1481
+ const modal = adapterStatus.activeModal;
1482
+ // (fix) Do not auto-approve when no concrete modal/buttons are present.
1483
+ // Claude TUI flaps between paints; without this guard adapterStatus
1484
+ // could report status=waiting_approval with activeModal=null (or with
1485
+ // an empty buttons array briefly) and we'd still call
1486
+ // resolveModal(-1) — which used to type "1" into the prompt
1487
+ // repeatedly. Skip until a real modal is captured.
1488
+ const buttons = Array.isArray(modal?.buttons)
1489
+ ? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
1490
+ : [];
1491
+ if (!modal || buttons.length === 0) {
1492
+ return autoApproveActive;
1493
+ }
1494
+ const { index: buttonIndex, label: buttonLabel } = pickAutoApprovalButton(buttons);
1495
+ if (buttonIndex < 0) {
1496
+ // No concrete button matched — don't pick a random index, just
1497
+ // surface the modal so the user can decide.
1498
+ return autoApproveActive;
1499
+ }
1500
+ // Include the FSM's approval entry seq: two distinct back-to-back
1501
+ // approvals can carry identical message/buttons (common with
1502
+ // claude-cli). Without the seq their signatures collide and the 5s
1503
+ // busy-window re-entry guard below swallows the second auto-approve,
1504
+ // leaving it stuck. The seq is bumped by the FSM on every fresh
1505
+ // waiting_approval entry, so a new approval always yields a new
1506
+ // signature and fires through.
1507
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
1508
+ ? adapterStatus.approvalEntrySeq
1509
+ : 0;
1510
+ const signature = [
1511
+ approvalEntrySeq,
1512
+ typeof modal?.message === 'string' ? modal.message.trim() : '',
1513
+ buttons.join('|'),
1514
+ buttonIndex,
1515
+ ].join('::');
1516
+ // Already fired for this exact modal and still inside the busy window —
1517
+ // nothing to do (re-entry guard for repeated snapshots of one modal).
1518
+ if (this.autoApproveBusy && signature === this.lastAutoApprovalSignature) {
1519
+ return autoApproveActive;
1520
+ }
1521
+
1522
+ // Settle gate: only fire once this exact signature has been stable for
1523
+ // AUTO_APPROVE_SETTLE_MS. A still-streaming prompt mutates its
1524
+ // message/buttons each frame → new signature → clock restarts, so we
1525
+ // never approve a half-rendered prompt (the "resolves too fast" bug).
1526
+ if (signature !== this.pendingAutoApprovalSignature) {
1527
+ this.pendingAutoApprovalSignature = signature;
1528
+ this.pendingAutoApprovalSince = now;
1529
+ }
1530
+ const settledForMs = now - this.pendingAutoApprovalSince;
1531
+ if (settledForMs < CliProviderInstance.AUTO_APPROVE_SETTLE_MS) {
1532
+ // Not yet settled. Arm a timer to re-check after the remaining quiet
1533
+ // window — the PTY may go silent once the prompt finishes painting,
1534
+ // so there is no guaranteed status-change frame to re-drive us.
1535
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1536
+ this.autoApproveSettleTimer = setTimeout(() => {
1537
+ this.autoApproveSettleTimer = null;
1538
+ this.recheckAutoApproveSettled();
1539
+ }, CliProviderInstance.AUTO_APPROVE_SETTLE_MS - settledForMs + 20);
1540
+ return autoApproveActive;
869
1541
  }
1542
+
1543
+ // Settled — fire the approve key.
1544
+ if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1545
+ this.autoApproveBusy = true;
1546
+ this.lastAutoApprovalSignature = signature;
1547
+ this.pendingAutoApprovalSignature = '';
1548
+ this.pendingAutoApprovalSince = 0;
1549
+ if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
1550
+ this.autoApproveBusyTimer = setTimeout(() => {
1551
+ this.autoApproveBusy = false;
1552
+ this.autoApproveBusyTimer = null;
1553
+ this.lastAutoApprovalSignature = '';
1554
+ }, 5000);
1555
+ this.recordAutoApproval(modal?.message, buttonLabel, now);
1556
+ setTimeout(() => {
1557
+ this.adapter.resolveModal(buttonIndex);
1558
+ }, 0);
870
1559
  return autoApproveActive;
871
1560
  }
872
1561
 
1562
+ /**
1563
+ * Re-drive the auto-approve check after the settle quiet window elapses.
1564
+ * The PTY may have gone silent once the approval prompt finished painting,
1565
+ * so no status-change frame is guaranteed to re-enter maybeAutoApproveStatus
1566
+ * — this timer-driven re-check picks up the now-settled modal and fires.
1567
+ * Deliberately lighter than detectStatusTransition(): it only re-evaluates
1568
+ * the approval decision; the next real PTY frame refreshes visible status.
1569
+ */
1570
+ private recheckAutoApproveSettled(): void {
1571
+ try {
1572
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
1573
+ this.maybeAutoApproveStatus(adapterStatus, Date.now());
1574
+ } catch { /* adapter gone / transient — next frame retries */ }
1575
+ }
1576
+
873
1577
  private detectStatusTransition(): void {
874
1578
  const now = Date.now();
875
1579
  // Status-change handling is a hot path: PTY output can fire it many times
876
1580
  // during long-running CLI sessions. Keep this path on adapter-owned light
877
1581
  // state only; rich provider parsing is reserved for getState/read_chat.
878
1582
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
1583
+ const adapterProviderSessionId = normalizeProviderSessionId(
1584
+ this.provider,
1585
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
1586
+ );
1587
+ if (adapterProviderSessionId) {
1588
+ this.promoteProviderSessionId(adapterProviderSessionId);
1589
+ }
879
1590
  const parsedStatus = null;
880
1591
  const rawStatus = adapterStatus.status;
881
1592
  const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
882
- const newStatus = autoApproveActive ? 'generating' : rawStatus;
883
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
1593
+ // During the autoApproveBusy window (2s after firing approval key), the PTY
1594
+ // can briefly report 'idle' before the next generating phase starts. Treat that
1595
+ // transient idle as 'generating' to suppress a spurious agent:generating_completed
1596
+ // push notification. The adapter's status is otherwise authoritative — native
1597
+ // transcript shape does NOT override the FSM's busy/idle decision.
1598
+ const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
1599
+ const newStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus;
1600
+ const dirName = workingDirBasename(this.workingDir);
884
1601
  const chatTitle = `${this.provider.name} · ${dirName}`;
885
1602
  const partial = this.adapter.getPartialResponse();
1603
+ // Liveness fingerprint for the no-progress watchdog. The parsed
1604
+ // assistant buffer (`partial`) alone goes static while a tool/build runs
1605
+ // — the assistant emits no tokens even though the PTY is actively
1606
+ // printing tool output — which made the watchdog false-fire a "stuck"
1607
+ // alert mid-turn. Fold in the adapter's raw-activity timestamps so any
1608
+ // visible terminal progress (lastScreenChangeAt) or raw PTY byte
1609
+ // (lastOutputAt) keeps the fingerprint moving. The watchdog then only
1610
+ // survives a genuine stall where nothing at all is happening.
886
1611
  const progressFingerprint = newStatus === 'generating'
887
- ? `${partial || ''}`.slice(-2000)
1612
+ ? `${`${partial || ''}`.slice(-2000)}::scr=${adapterStatus.lastScreenChangeAt ?? 0}::out=${adapterStatus.lastOutputAt ?? 0}`
888
1613
  : undefined;
889
1614
 
890
1615
  const previousStatus = this.lastStatus;
891
1616
  if (newStatus !== this.lastStatus) {
892
1617
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
893
1618
  if (this.lastStatus === 'idle' && newStatus === 'generating') {
1619
+ // If a completion event is already pending and the turn has ended
1620
+ // (generatingStartedAt===0), the PTY is painting its prompt area
1621
+ // after completing. Ignore this blip — do not cancel the pending
1622
+ // completion and do not advance lastStatus to generating.
1623
+ if (this.completedDebouncePending && this.generatingStartedAt === 0) {
1624
+ LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
1625
+ return;
1626
+ }
894
1627
  this.suppressIdleHistoryReplay = false;
895
1628
  // Cancel any pending completed event (multi-step: idle→generating resume)
896
1629
  if (this.completedDebouncePending) {
897
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
1630
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
898
1631
  if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
899
1632
  this.completedDebouncePending = null;
900
1633
  }
@@ -926,10 +1659,29 @@ export class CliProviderInstance implements ProviderInstance {
926
1659
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
927
1660
  const modal = adapterStatus.activeModal;
928
1661
  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;
1662
+ // Include the FSM's approval entry seq, mirroring the auto-approve
1663
+ // path (maybeAutoApproveStatus) and resolveModal's sameEntryReResolve
1664
+ // guard. Two distinct back-to-back approvals can carry identical
1665
+ // message/buttons (very common with claude-cli's "Allow Bash
1666
+ // command?"). Without the seq their fingerprints collide and the dedup
1667
+ // below silently drops the second waiting_approval event — it is never
1668
+ // emitted, so it cannot even land in the pending inbox for a later
1669
+ // read_chat reconcile to recover. The seq is bumped by the FSM on every
1670
+ // fresh waiting_approval entry, so a new approval always yields a new
1671
+ // fingerprint and emits.
1672
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
1673
+ ? adapterStatus.approvalEntrySeq
1674
+ : 0;
1675
+ const approvalFingerprint = JSON.stringify({
1676
+ message: typeof modal?.message === 'string' ? modal.message.trim() : '',
1677
+ buttons: Array.isArray(modal?.buttons) ? modal.buttons.map((button: unknown) => String(button).trim()) : [],
1678
+ seq: approvalEntrySeq,
1679
+ });
1680
+ // PTY redraws repeat the same modal content; fingerprint dedup prevents duplicate events.
1681
+ // Do NOT also gate on lastStatus: consecutive approvals can arrive waiting_approval→waiting_approval
1682
+ // (e.g. antigravity-cli resolves one prompt and immediately shows the next) and would be silently dropped.
1683
+ if (approvalFingerprint !== this.lastApprovalEventFingerprint) {
1684
+ this.lastApprovalEventFingerprint = approvalFingerprint;
933
1685
  this.appendRuntimeSystemMessage(
934
1686
  this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
935
1687
  `approval_request:${now}`,
@@ -941,22 +1693,113 @@ export class CliProviderInstance implements ProviderInstance {
941
1693
  modalButtons: modal?.buttons,
942
1694
  });
943
1695
  }
1696
+ } else if (newStatus === 'generating' && this.lastStatus === 'waiting_approval') {
1697
+ // Approval resolved and the agent resumed work. Defense-in-depth:
1698
+ // clear the approval emit fingerprint here too (not only on
1699
+ // completion at scheduleCompletedDebounceFlush). A subsequent
1700
+ // waiting_approval with the same modal content as the one just
1701
+ // resolved would otherwise collide with the stale fingerprint and be
1702
+ // dropped. The seq in the fingerprint already separates entries; this
1703
+ // reset is a belt-and-suspenders guard for the re-entry case.
1704
+ this.lastApprovalEventFingerprint = '';
944
1705
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
945
1706
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
946
- // If debounce still pending (generating lasted < 1s), cancel both events
1707
+ // Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
1708
+ // was entered from 'starting' state (startup PTY noise), not from a real idle→generating
1709
+ // task dispatch. The idle→generating handler is the only code path that sets
1710
+ // generatingStartedAt and generatingDebouncePending, so both being absent means no
1711
+ // task was ever dispatched. Suppress the spurious completion event and fall through
1712
+ // to a simple lastStatus update.
1713
+ if (!this.generatingStartedAt && !this.generatingDebouncePending) {
1714
+ LOG.debug('CLI', `[${this.type}] suppressed startup-phase generating→idle blip (generatingStartedAt=0, no debounce pending)`);
1715
+ } else
1716
+ // If debounce still pending (generating lasted < 1s), cancel both UI events.
1717
+ // Still emit agent:generating_completed so mesh orchestration can record
1718
+ // task_completed for direct dispatches that complete faster than the debounce.
947
1719
  if (this.generatingDebouncePending) {
948
- LOG.info('CLI', `[${this.type}] suppressed short generating (${now - this.generatingStartedAt}ms)`);
1720
+ const shortDurationMs = this.generatingStartedAt ? now - this.generatingStartedAt : 0;
1721
+ LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms)`);
949
1722
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
1723
+ // Emit completion for mesh task association even though the UI generating
1724
+ // started/completed pair is suppressed (too short for visible UI update).
1725
+ let shortFinalSummary: string | undefined;
1726
+ let shortEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
1727
+ try {
1728
+ const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
1729
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
1730
+ shortEvidenceSource = evidence.source;
1731
+ shortFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
1732
+ } catch { /* best-effort */ }
1733
+ // If a real response is confirmed, retroactively emit started so the chat
1734
+ // bubble appears even though the debounce suppressed the original event.
1735
+ if (shortFinalSummary) {
1736
+ this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now - shortDurationMs });
1737
+ }
950
1738
  this.generatingDebouncePending = null;
951
1739
  this.generatingStartedAt = 0;
1740
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || shortEvidenceSource === 'external-native') && !shortFinalSummary;
1741
+ if (missingEvidence) {
1742
+ LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
1743
+ }
1744
+ // When evidence is missing and there is no active mesh task context, suppress
1745
+ // the completion event. Providers with requiresFinalAssistantBeforeIdle or
1746
+ // external-native history must confirm a final assistant message before the
1747
+ // coordinator records task_completed. Only emit here if a mesh task is active
1748
+ // so the coordinator can apply its own timeout/retry logic.
1749
+ const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1750
+ if (missingEvidence && !hasMeshContext) {
1751
+ LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
1752
+ // completedDebouncePending intentionally left null — the session is now idle
1753
+ // with no confirmed turn, matching the startup-blip suppression semantics.
1754
+ } else {
1755
+ this.pushEvent({
1756
+ event: 'agent:generating_completed',
1757
+ chatTitle,
1758
+ duration: 0,
1759
+ timestamp: now,
1760
+ finalSummary: shortFinalSummary,
1761
+ completionDiagnostic: {
1762
+ reason: 'short_generating_suppressed',
1763
+ shortDurationMs,
1764
+ finalAssistantEvidenceSource: shortEvidenceSource,
1765
+ ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
1766
+ },
1767
+ });
1768
+ }
952
1769
  } else {
953
1770
  // Debounce completed, then require the rich transcript path that read_chat
954
1771
  // 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);
1772
+ this.completedDebouncePending = {
1773
+ chatTitle,
1774
+ duration,
1775
+ timestamp: now,
1776
+ firstObservedAt: now,
1777
+ previousStatus: this.lastStatus,
1778
+ };
1779
+ const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
1780
+ const flushDelay = ownsExternalHistory ? 0 : 3000;
1781
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
1782
+ this.scheduleCompletedDebounceFlush(flushDelay);
957
1783
  }
958
1784
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
959
1785
  this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
1786
+ } else if (newStatus === 'error') {
1787
+ if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
1788
+ this.generatingDebouncePending = null;
1789
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
1790
+ this.completedDebouncePending = null;
1791
+ this.errorMessage = adapterStatus.errorMessage || this.errorMessage;
1792
+ this.errorReason = (adapterStatus.errorReason as ProviderErrorReason) || this.errorReason;
1793
+ this.pushEvent({
1794
+ event: 'agent:stopped',
1795
+ chatTitle,
1796
+ timestamp: now,
1797
+ finalSummary: adapterStatus.errorMessage || adapterStatus.errorReason || 'Provider reported an error',
1798
+ completionDiagnostic: {
1799
+ reason: adapterStatus.errorReason || 'provider_error',
1800
+ errorMessage: adapterStatus.errorMessage || undefined,
1801
+ },
1802
+ });
960
1803
  } else if (newStatus === 'stopped') {
961
1804
  // Cancel any pending debounce
962
1805
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -976,8 +1819,39 @@ export class CliProviderInstance implements ProviderInstance {
976
1819
 
977
1820
  // Monitor check (cooldown based notification, IDE/CLI common)
978
1821
  const agentKey = `${this.type}:cli`;
979
- const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint);
1822
+ // Approval pending is detected from the raw adapter status, not `newStatus`:
1823
+ // auto-approve synthesizes `waiting_approval` → 'generating', which would
1824
+ // otherwise let the no-progress watchdog accumulate the approval wait.
1825
+ const approvalPending = rawStatus === 'waiting_approval';
1826
+ const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint, approvalPending);
1827
+ const monitorParsedStatus: any = parsedStatus;
980
1828
  for (const me of monitorEvents) {
1829
+ if (
1830
+ me.type === 'monitor:no_progress'
1831
+ && this.completionHasFinalAssistantMessage(monitorParsedStatus?.messages)
1832
+ && !this.hasAdapterPendingResponse()
1833
+ && !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
1834
+ ) {
1835
+ this.pushEvent({
1836
+ event: 'agent:generating_completed',
1837
+ chatTitle,
1838
+ duration: this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : undefined,
1839
+ timestamp: me.timestamp,
1840
+ finalSummary: extractFinalSummaryFromMessages(monitorParsedStatus?.messages),
1841
+ completionDiagnostic: {
1842
+ providerType: this.type,
1843
+ sessionId: this.instanceId,
1844
+ providerSessionId: this.providerSessionId || null,
1845
+ reconciliationReason: 'no_progress_monitor_final_summary',
1846
+ finalAssistantPresent: true,
1847
+ },
1848
+ });
1849
+ this.generatingStartedAt = 0;
1850
+ // Cancel any pending debounce flush — monitor already fired completion.
1851
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
1852
+ this.completedDebouncePending = null;
1853
+ continue;
1854
+ }
981
1855
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
982
1856
  }
983
1857
  }
@@ -997,15 +1871,32 @@ export class CliProviderInstance implements ProviderInstance {
997
1871
  workspaceName: typeof event.workspaceName === 'string' && event.workspaceName.trim()
998
1872
  ? event.workspaceName
999
1873
  : this.workingDir,
1874
+ // Carry the workspace under BOTH `workspace` and `workspaceName` so the
1875
+ // downstream mesh forward/merge path — which reads `workspace` — can
1876
+ // propagate it to the coordinator snapshot. Without `workspace` the live
1877
+ // event path delivers an empty workspace and the dashboard falls back to
1878
+ // the generic "Terminal (Mesh Node)" title.
1879
+ workspace: typeof event.workspace === 'string' && event.workspace.trim()
1880
+ ? event.workspace
1881
+ : this.workingDir,
1000
1882
  providerSessionId: typeof event.providerSessionId === 'string' && event.providerSessionId.trim()
1001
1883
  ? event.providerSessionId
1002
1884
  : this.providerSessionId,
1003
1885
  };
1004
1886
  if (this.context?.emitProviderEvent) {
1005
1887
  this.context.emitProviderEvent(enrichedEvent);
1006
- return;
1888
+ } else {
1889
+ this.events.push(enrichedEvent);
1890
+ }
1891
+ // Auto-detach a direct-dispatch mesh assignment once the dispatched
1892
+ // task reaches a terminal state. Leaving meshNodeFor pinned would
1893
+ // route this session's next unrelated turn (a dashboard chat) into
1894
+ // the coordinator as if it were the completion of another task.
1895
+ // We schedule after the emit so the originating coordinator still
1896
+ // observes the completion event with its routing marker intact.
1897
+ if (TERMINAL_MESH_EVENTS.has(event.event) && this.settings.meshActiveTaskId) {
1898
+ try { this.detachMeshAssignment(); } catch { /* best-effort */ }
1007
1899
  }
1008
- this.events.push(enrichedEvent);
1009
1900
  }
1010
1901
 
1011
1902
  private flushEvents(): ProviderEvent[] {
@@ -1238,7 +2129,7 @@ export class CliProviderInstance implements ProviderInstance {
1238
2129
  receivedAt: normalizedMessage.receivedAt || normalizedMessage.timestamp,
1239
2130
  historyDedupKey: dedupKey,
1240
2131
  }],
1241
- this.adapter.getScriptParsedStatus?.()?.title || this.workingDir.split('/').filter(Boolean).pop() || 'session',
2132
+ this.adapter.getScriptParsedStatus?.()?.title || workingDirBasename(this.workingDir),
1242
2133
  this.instanceId,
1243
2134
  this.providerSessionId,
1244
2135
  );
@@ -1258,12 +2149,28 @@ export class CliProviderInstance implements ProviderInstance {
1258
2149
  index,
1259
2150
  source: 'parsed',
1260
2151
  }));
2152
+ const getRole = (message: ChatMessage): string => typeof message.role === 'string'
2153
+ ? message.role.trim().toLowerCase()
2154
+ : '';
1261
2155
  const runtimeEntries: MergeEntry[] = this.runtimeMessages.map((entry, index) => ({
1262
2156
  message: entry.message,
1263
2157
  index: parsedMessages.length + index,
1264
- source: 'runtime',
2158
+ source: 'runtime' as const,
1265
2159
  runtimeKey: entry.key,
1266
- }));
2160
+ })).filter((entry) => {
2161
+ const meta = entry.message.meta && typeof entry.message.meta === 'object' && !Array.isArray(entry.message.meta)
2162
+ ? entry.message.meta as Record<string, unknown>
2163
+ : {};
2164
+ if (meta.runtimeInputAck !== true) return true;
2165
+ const runtimeText = flattenContent(entry.message.content).replace(/\s+/g, ' ').trim();
2166
+ if (!runtimeText) return false;
2167
+ return !parsedEntries.some((parsedEntry) => {
2168
+ const parsedRole = getRole(parsedEntry.message);
2169
+ if (parsedRole !== 'user' && parsedRole !== 'human') return false;
2170
+ const parsedText = flattenContent(parsedEntry.message.content).replace(/\s+/g, ' ').trim();
2171
+ return parsedText === runtimeText;
2172
+ });
2173
+ });
1267
2174
  const getTime = (message: ChatMessage): number => {
1268
2175
  const value = typeof message.receivedAt === 'number'
1269
2176
  ? message.receivedAt
@@ -1273,9 +2180,6 @@ export class CliProviderInstance implements ProviderInstance {
1273
2180
  return Number.isFinite(value) && value > 0 ? value : 0;
1274
2181
  };
1275
2182
 
1276
- const getRole = (message: ChatMessage): string => typeof message.role === 'string'
1277
- ? message.role.trim().toLowerCase()
1278
- : '';
1279
2183
  const isRuntimeOverlay = (entry: MergeEntry): boolean => {
1280
2184
  if (entry.source !== 'runtime') return false;
1281
2185
  const key = typeof entry.runtimeKey === 'string' ? entry.runtimeKey.trim().toLowerCase() : '';
@@ -1341,7 +2245,9 @@ export class CliProviderInstance implements ProviderInstance {
1341
2245
  this.providerSessionId = nextSessionId;
1342
2246
  this.historyWriter.promoteHistorySession(this.type, previousHistorySessionId, nextSessionId);
1343
2247
  this.historyWriter.writeSessionStart(this.type, nextSessionId, this.workingDir, this.instanceId);
1344
- this.restorePersistedHistoryFromCurrentSession();
2248
+ if (this.shouldHydrateExistingProviderHistory()) {
2249
+ this.restorePersistedHistoryFromCurrentSession();
2250
+ }
1345
2251
  this.adapter.updateRuntimeMeta({ providerSessionId: nextSessionId });
1346
2252
  this.onProviderSessionResolved?.({
1347
2253
  instanceId: this.instanceId,
@@ -1354,13 +2260,39 @@ export class CliProviderInstance implements ProviderInstance {
1354
2260
  LOG.info('CLI', `[${this.type}] discovered provider session id: ${nextSessionId}`);
1355
2261
  }
1356
2262
 
1357
- private syncCanonicalSavedHistoryIfNeeded(): boolean {
2263
+ private shouldHydrateExistingProviderHistory(): boolean {
2264
+ return this.launchMode === 'resume' || this.launchMode === 'manual';
2265
+ }
2266
+
2267
+ private shouldSuppressFreshLaunchStartupReplay(parsedMessages: unknown[], parsedStatus: any, adapterStatus: any, parsedProviderSessionId = ''): boolean {
2268
+ if (this.launchMode !== 'new') return false;
2269
+ if (this.providerSessionId) return false;
2270
+ if (!Array.isArray(parsedMessages) || parsedMessages.length === 0) return false;
2271
+ if (!isIdleStatus(adapterStatus?.status) || !isIdleStatus(parsedStatus?.status)) return false;
2272
+ if (parsedProviderSessionId) return true;
2273
+
2274
+ const newestMessageAt = parsedMessages.reduce<number>((newest, message) => Math.max(newest, getMessageTime(message)), 0);
2275
+
2276
+ // Untimestamped idle parser output during a fresh launch is usually the
2277
+ // provider's last workspace transcript before a new turn exists.
2278
+ return newestMessageAt === 0;
2279
+ }
2280
+
2281
+ private syncCanonicalSavedHistoryIfNeeded(options: { full?: boolean } = {}): boolean {
1358
2282
  if (!this.providerSessionId) return false;
1359
- const canonicalHistory = this.provider.canonicalHistory;
2283
+ const canonicalHistory = this.provider.nativeHistory;
1360
2284
  if (!canonicalHistory) return false;
1361
2285
 
2286
+ // Per-status-report hydration reads only a bounded tail (snapshot needs at
2287
+ // most the newest 60). The once-per-resume restore path passes full:true
2288
+ // because seedSessionHistory needs the COMPLETE transcript to seed dedup
2289
+ // state. The read-cache key encodes the window so the bounded and full
2290
+ // reads don't share/clobber each other's 2s cache entry.
2291
+ const limit = options.full ? Number.MAX_SAFE_INTEGER : STATUS_HYDRATION_TAIL_LIMIT;
2292
+ const windowTag = options.full ? 'full' : `tail:${STATUS_HYDRATION_TAIL_LIMIT}`;
2293
+
1362
2294
  if (isNativeSourceCanonicalHistory(canonicalHistory)) {
1363
- const cacheKey = [this.type, this.providerSessionId, this.workingDir].join('\0');
2295
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir, windowTag].join('\0');
1364
2296
  const now = Date.now();
1365
2297
  if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1366
2298
  return true;
@@ -1373,7 +2305,7 @@ export class CliProviderInstance implements ProviderInstance {
1373
2305
  historySessionId: this.providerSessionId,
1374
2306
  workspace: this.workingDir,
1375
2307
  offset: 0,
1376
- limit: Number.MAX_SAFE_INTEGER,
2308
+ limit,
1377
2309
  historyBehavior: this.provider.historyBehavior,
1378
2310
  scripts: this.provider.scripts as any,
1379
2311
  });
@@ -1390,7 +2322,7 @@ export class CliProviderInstance implements ProviderInstance {
1390
2322
  }
1391
2323
 
1392
2324
  try {
1393
- const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror'].join('\0');
2325
+ const cacheKey = [this.type, this.providerSessionId, this.workingDir, canonicalHistory.mode || 'materialized-mirror', windowTag].join('\0');
1394
2326
  const now = Date.now();
1395
2327
  if (cacheKey === this.lastNativeSourceCanonicalCacheKey && now - this.lastNativeSourceCanonicalCheckAt < 2_000) {
1396
2328
  return true;
@@ -1401,7 +2333,14 @@ export class CliProviderInstance implements ProviderInstance {
1401
2333
  if (!materializeProviderNativeHistory(this.type, canonicalHistory, this.providerSessionId, this.workingDir, this.provider.scripts as any)) {
1402
2334
  return false;
1403
2335
  }
1404
- const restoredHistory = readChatHistory(this.type, 0, Number.MAX_SAFE_INTEGER, this.providerSessionId, 0, this.provider.historyBehavior);
2336
+ // Bounded by default: the per-status-report path only needs the newest
2337
+ // STATUS_HYDRATION_TAIL_LIMIT messages because the snapshot caps
2338
+ // activeChat.messages to the last 60 (status/normalize.ts) and loads
2339
+ // the rest lazily via read_chat on subscribe. The once-per-resume
2340
+ // restore path passes full:true so seedSessionHistory still sees the
2341
+ // COMPLETE transcript for prefix-dedup seeding. readChatHistory serves
2342
+ // a bounded limit as an O(tail) read.
2343
+ const restoredHistory = readChatHistory(this.type, 0, limit, this.providerSessionId, 0, this.provider.historyBehavior);
1405
2344
  this.lastPersistedHistoryMessages = restoredHistory.messages.map((message) => ({
1406
2345
  role: message.role,
1407
2346
  content: message.content,
@@ -1417,10 +2356,13 @@ export class CliProviderInstance implements ProviderInstance {
1417
2356
 
1418
2357
  private restorePersistedHistoryFromCurrentSession(): void {
1419
2358
  if (!this.providerSessionId) return;
1420
- this.syncCanonicalSavedHistoryIfNeeded();
1421
- const restoredHistory = isNativeSourceCanonicalHistory(this.provider.canonicalHistory)
2359
+ // Restore is the once-per-resume seeding path: it needs the COMPLETE
2360
+ // transcript so seedSessionHistory can prime dedup state. Pass full so the
2361
+ // hydration read is unbounded here (and only here).
2362
+ this.syncCanonicalSavedHistoryIfNeeded({ full: true });
2363
+ const restoredHistory = isNativeSourceCanonicalHistory(this.provider.nativeHistory)
1422
2364
  ? readProviderChatHistory(this.type, {
1423
- canonicalHistory: this.provider.canonicalHistory,
2365
+ canonicalHistory: this.provider.nativeHistory,
1424
2366
  historySessionId: this.providerSessionId,
1425
2367
  workspace: this.workingDir,
1426
2368
  offset: 0,