@adhdev/daemon-core 0.9.82-rc.31 → 0.9.82-rc.310

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