@adhdev/daemon-core 0.9.82-rc.28 → 0.9.82-rc.280

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