@adhdev/daemon-core 0.9.82-rc.23 → 0.9.82-rc.231

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 (270) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +1 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/chat/source-machine.d.ts +166 -0
  4. package/dist/chat/source-resolver.d.ts +104 -0
  5. package/dist/chat/subscription-updates.d.ts +1 -0
  6. package/dist/cli-adapter-types.d.ts +23 -2
  7. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  8. package/dist/cli-adapters/cli-state-engine.d.ts +178 -0
  9. package/dist/cli-adapters/provider-cli-adapter.d.ts +106 -63
  10. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  11. package/dist/cli-adapters/provider-cli-shared.d.ts +29 -0
  12. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  13. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +3 -3
  14. package/dist/cli-adapters/terminal-backends/types.d.ts +1 -2
  15. package/dist/cli-adapters/terminal-screen.d.ts +2 -11
  16. package/dist/commands/cli-manager.d.ts +2 -1
  17. package/dist/commands/handler.d.ts +110 -0
  18. package/dist/commands/mesh-coordinator.d.ts +85 -1
  19. package/dist/commands/router.d.ts +32 -1
  20. package/dist/config/chat-history.d.ts +9 -0
  21. package/dist/config/config.d.ts +5 -0
  22. package/dist/config/mesh-config.d.ts +72 -1
  23. package/dist/git/git-commands.d.ts +9 -1
  24. package/dist/git/git-diff.d.ts +6 -0
  25. package/dist/git/git-types.d.ts +2 -0
  26. package/dist/index.d.ts +47 -11
  27. package/dist/index.js +30490 -11310
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +30346 -11258
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/installer.d.ts +1 -4
  32. package/dist/ipc/local-ipc-server.d.ts +91 -0
  33. package/dist/launch.d.ts +1 -1
  34. package/dist/logging/async-batch-writer.d.ts +10 -0
  35. package/dist/mesh/contracts.d.ts +164 -0
  36. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  37. package/dist/mesh/coordinator-registry.d.ts +59 -0
  38. package/dist/mesh/mesh-active-work.d.ts +90 -0
  39. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  40. package/dist/mesh/mesh-events-coordinator.d.ts +151 -0
  41. package/dist/mesh/mesh-events-pending.d.ts +33 -0
  42. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  43. package/dist/mesh/mesh-events-utils.d.ts +14 -0
  44. package/dist/mesh/mesh-events.d.ts +5 -40
  45. package/dist/mesh/mesh-fast-forward.d.ts +41 -0
  46. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  47. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  48. package/dist/mesh/mesh-ledger.d.ts +77 -1
  49. package/dist/mesh/mesh-missions.d.ts +58 -0
  50. package/dist/mesh/mesh-refine-status.d.ts +26 -0
  51. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  52. package/dist/mesh/mesh-runtime-store.d.ts +324 -0
  53. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  54. package/dist/mesh/mesh-work-queue.d.ts +137 -6
  55. package/dist/mesh/preview-freshness.d.ts +18 -0
  56. package/dist/mesh/refine-config.d.ts +215 -0
  57. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  58. package/dist/providers/acp-provider-instance.d.ts +2 -0
  59. package/dist/providers/approval-utils.d.ts +13 -0
  60. package/dist/providers/cli-provider-instance.d.ts +40 -3
  61. package/dist/providers/contracts.d.ts +130 -6
  62. package/dist/providers/external-sources.d.ts +71 -0
  63. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  64. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  65. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  66. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  67. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  68. package/dist/providers/native-history/index.d.ts +13 -0
  69. package/dist/providers/provider-instance-manager.d.ts +13 -0
  70. package/dist/providers/provider-instance.d.ts +13 -1
  71. package/dist/providers/provider-loader.d.ts +26 -4
  72. package/dist/providers/provider-trust.d.ts +31 -0
  73. package/dist/providers/read-chat-contract.d.ts +29 -0
  74. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  75. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  76. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  77. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  78. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  79. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  80. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  81. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  82. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  83. package/dist/providers/sdk/v1/index.d.ts +30 -0
  84. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  85. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  86. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  87. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  88. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  89. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  90. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  91. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  92. package/dist/providers/spec/adapter.d.ts +52 -0
  93. package/dist/providers/spec/cli-adapter.d.ts +102 -0
  94. package/dist/providers/spec/evaluator.d.ts +23 -0
  95. package/dist/providers/spec/fsm-driver.d.ts +231 -0
  96. package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
  97. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  98. package/dist/providers/spec/fsm-types.d.ts +91 -0
  99. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  100. package/dist/providers/spec/route.d.ts +4 -0
  101. package/dist/providers/spec/types.d.ts +129 -0
  102. package/dist/providers/transcript-v2.d.ts +176 -0
  103. package/dist/providers/types/interactive-prompt.d.ts +48 -0
  104. package/dist/repo-mesh-types.d.ts +126 -1
  105. package/dist/sessions/registry.d.ts +6 -0
  106. package/dist/shared-types-extra.d.ts +2 -4
  107. package/dist/shared-types.d.ts +42 -1
  108. package/dist/status/normalize.d.ts +1 -1
  109. package/dist/status/normalize.js +1 -0
  110. package/dist/status/normalize.js.map +1 -1
  111. package/dist/status/normalize.mjs +1 -0
  112. package/dist/status/normalize.mjs.map +1 -1
  113. package/dist/status/reporter.d.ts +2 -0
  114. package/dist/status/snapshot.d.ts +1 -0
  115. package/dist/types.d.ts +5 -0
  116. package/package.json +6 -3
  117. package/src/agent-stream/poller.ts +2 -3
  118. package/src/boot/daemon-lifecycle.ts +22 -10
  119. package/src/boot/process-hardening.ts +89 -0
  120. package/src/chat/source-machine.ts +534 -0
  121. package/src/chat/source-resolver.ts +0 -0
  122. package/src/chat/subscription-updates.ts +20 -1
  123. package/src/cli-adapter-types.d.ts +1 -0
  124. package/src/cli-adapter-types.ts +22 -2
  125. package/src/cli-adapters/cli-script-runner.ts +421 -0
  126. package/src/cli-adapters/cli-state-engine.ts +1162 -0
  127. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  128. package/src/cli-adapters/provider-cli-adapter.ts +673 -1139
  129. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  130. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  131. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  132. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  133. package/src/cli-adapters/provider-cli-shared.ts +59 -11
  134. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  135. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +35 -29
  136. package/src/cli-adapters/terminal-backends/types.ts +1 -3
  137. package/src/cli-adapters/terminal-screen.ts +11 -81
  138. package/src/commands/chat-commands.ts +1806 -60
  139. package/src/commands/cli-manager.ts +286 -14
  140. package/src/commands/handler.ts +809 -2
  141. package/src/commands/mesh-coordinator.ts +332 -122
  142. package/src/commands/router.ts +4624 -538
  143. package/src/config/chat-history.ts +95 -24
  144. package/src/config/config.ts +12 -0
  145. package/src/config/mesh-config.ts +303 -3
  146. package/src/config/recent-activity.ts +8 -2
  147. package/src/daemon/dev-auto-implement.ts +3 -2
  148. package/src/daemon/dev-cli-debug.ts +10 -1
  149. package/src/detection/ide-detector.ts +26 -16
  150. package/src/git/git-commands.ts +42 -12
  151. package/src/git/git-diff.ts +53 -0
  152. package/src/git/git-status.ts +35 -6
  153. package/src/git/git-types.ts +2 -0
  154. package/src/git/git-worktree.ts +8 -1
  155. package/src/index.ts +127 -10
  156. package/src/installer.d.ts +1 -1
  157. package/src/installer.ts +8 -6
  158. package/src/ipc/local-ipc-server.ts +278 -0
  159. package/src/launch.d.ts +1 -1
  160. package/src/launch.ts +37 -28
  161. package/src/logging/async-batch-writer.ts +55 -0
  162. package/src/logging/logger.ts +2 -1
  163. package/src/mesh/contracts.ts +329 -0
  164. package/src/mesh/coordinator-prompt.ts +219 -31
  165. package/src/mesh/coordinator-registry.ts +121 -0
  166. package/src/mesh/mesh-active-work.ts +437 -0
  167. package/src/mesh/mesh-delivery-policy.ts +315 -0
  168. package/src/mesh/mesh-events-coordinator.ts +1484 -0
  169. package/src/mesh/mesh-events-pending.ts +371 -0
  170. package/src/mesh/mesh-events-stale.ts +283 -0
  171. package/src/mesh/mesh-events-utils.ts +175 -0
  172. package/src/mesh/mesh-events.ts +24 -908
  173. package/src/mesh/mesh-fast-forward.ts +438 -0
  174. package/src/mesh/mesh-host-ownership.ts +73 -0
  175. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  176. package/src/mesh/mesh-ledger.ts +587 -102
  177. package/src/mesh/mesh-missions.ts +151 -0
  178. package/src/mesh/mesh-refine-status.ts +144 -0
  179. package/src/mesh/mesh-review-inbox.ts +307 -0
  180. package/src/mesh/mesh-runtime-store.ts +1305 -0
  181. package/src/mesh/mesh-task-stats.ts +154 -0
  182. package/src/mesh/mesh-work-queue.ts +490 -166
  183. package/src/mesh/preview-freshness.ts +118 -0
  184. package/src/mesh/refine-config.ts +403 -0
  185. package/src/mesh/worktree-bootstrap-config.ts +326 -0
  186. package/src/providers/acp-provider-instance.ts +15 -1
  187. package/src/providers/approval-utils.d.ts +4 -0
  188. package/src/providers/approval-utils.ts +47 -5
  189. package/src/providers/chat-message-normalization.ts +4 -11
  190. package/src/providers/cli-provider-instance.ts +850 -71
  191. package/src/providers/contracts.d.ts +55 -0
  192. package/src/providers/contracts.ts +141 -6
  193. package/src/providers/external-sources.ts +218 -0
  194. package/src/providers/ide-provider-instance.ts +19 -5
  195. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  196. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  197. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  198. package/src/providers/native-history/dispatcher.ts +340 -0
  199. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  200. package/src/providers/native-history/index.ts +30 -0
  201. package/src/providers/provider-instance-manager.ts +30 -0
  202. package/src/providers/provider-instance.ts +10 -1
  203. package/src/providers/provider-loader.ts +584 -50
  204. package/src/providers/provider-schema.ts +87 -14
  205. package/src/providers/provider-trust.ts +114 -0
  206. package/src/providers/read-chat-contract.ts +76 -16
  207. package/src/providers/sdk/README.md +49 -0
  208. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  209. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  210. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  211. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  212. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  213. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  214. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  215. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  216. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  217. package/src/providers/sdk/v1/index.ts +152 -0
  218. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  219. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  220. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  221. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  222. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  223. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  224. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  225. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  226. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  227. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  228. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  229. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  230. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  231. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  232. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  233. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  234. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  235. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  236. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  237. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  238. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  239. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  240. package/src/providers/sdk/v1/validators/index.ts +19 -0
  241. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  242. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  243. package/src/providers/spec/adapter.ts +138 -0
  244. package/src/providers/spec/cli-adapter.ts +646 -0
  245. package/src/providers/spec/evaluator.ts +321 -0
  246. package/src/providers/spec/fsm-driver.ts +749 -0
  247. package/src/providers/spec/fsm-evaluator.ts +255 -0
  248. package/src/providers/spec/fsm-loader.ts +102 -0
  249. package/src/providers/spec/fsm-types.ts +184 -0
  250. package/src/providers/spec/native-history-executor.ts +943 -0
  251. package/src/providers/spec/route.ts +51 -0
  252. package/src/providers/spec/types.ts +173 -0
  253. package/src/providers/transcript-v2.ts +567 -0
  254. package/src/providers/types/interactive-prompt.ts +425 -0
  255. package/src/providers/version-archive.ts +38 -20
  256. package/src/repo-mesh-types.ts +138 -1
  257. package/src/sessions/registry.ts +6 -0
  258. package/src/shared-types-extra.ts +2 -4
  259. package/src/shared-types.ts +45 -1
  260. package/src/status/builders.ts +26 -6
  261. package/src/status/normalize.ts +2 -0
  262. package/src/status/reporter.ts +15 -0
  263. package/src/status/snapshot.ts +84 -25
  264. package/src/system/host-memory.ts +29 -12
  265. package/src/types.ts +5 -0
  266. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  267. package/dist/mesh/mesh-sync.d.ts +0 -53
  268. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  269. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  270. 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,66 @@ 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 ExternalNativeFinalReconciliation = {
77
+ fingerprint: string;
78
+ finalSummary: string;
79
+ evidence: CompletionFinalAssistantEvidence;
80
+ };
81
+
82
+ type ExternalTranscriptProbe = {
83
+ readAt: number;
84
+ msgCount: number;
85
+ lastRole: string | null;
86
+ lastKind: string | null;
87
+ contentLen: number;
88
+ sourcePath: string | null;
89
+ sourceMtimeMs: number | null;
90
+ mtimeAgeMs: number | null;
44
91
  };
45
92
 
46
93
  const COMPLETED_FINALIZATION_RETRY_MS = 1000;
47
94
  const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
48
95
 
96
+ /** Events that signal a dispatched mesh task has reached a terminal state.
97
+ * Detach the mesh assignment after emitting one of these so the worker's
98
+ * next unrelated turn doesn't impersonate another completion. */
99
+ const TERMINAL_MESH_EVENTS = new Set([
100
+ 'agent:generating_completed',
101
+ 'agent:stopped',
102
+ 'agent:ready',
103
+ ]);
104
+
49
105
  const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
50
106
  'image/png': '.png',
51
107
  'image/jpeg': '.jpg',
@@ -309,15 +365,18 @@ export class CliProviderInstance implements ProviderInstance {
309
365
  private monitor: StatusMonitor;
310
366
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
311
367
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
312
- private lastApprovalEventAt = 0;
368
+ private lastApprovalEventFingerprint = '';
313
369
  private autoApproveBusy = false;
314
370
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
371
+ private lastAutoApprovalSignature = '';
315
372
  private controlValues: Record<string, string | number | boolean> = {};
316
373
  private summaryMetadata: unknown = undefined;
317
374
  private appliedEffectKeys = new Set<string>();
318
375
  private historyWriter: ChatHistoryWriter;
319
376
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
320
377
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
378
+ private lastAcknowledgedUserInputAt = 0;
379
+ private externalBusyIdleFingerprint = '';
321
380
  private lastNativeSourceCanonicalCheckAt = 0;
322
381
  private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
323
382
  private cachedSqliteDb: {
@@ -330,6 +389,7 @@ export class CliProviderInstance implements ProviderInstance {
330
389
  private suppressIdleHistoryReplay = false;
331
390
  private errorMessage: string | undefined = undefined;
332
391
  private errorReason: ProviderErrorReason | undefined = undefined;
392
+ private activeInteractivePrompt: InteractivePrompt | null = null;
333
393
 
334
394
  private presentationMode: 'terminal' | 'chat';
335
395
  private providerSessionId?: string;
@@ -370,7 +430,10 @@ export class CliProviderInstance implements ProviderInstance {
370
430
  this.providerSessionId = options?.providerSessionId;
371
431
  this.launchMode = options?.launchMode || 'new';
372
432
  this.onProviderSessionResolved = options?.onProviderSessionResolved;
373
- this.adapter = new ProviderCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory);
433
+ this.adapter = createCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory) as ProviderCliAdapter;
434
+ if (this.providerSessionId) {
435
+ this.adapter.updateRuntimeMeta({ providerSessionId: this.providerSessionId });
436
+ }
374
437
  this.monitor = new StatusMonitor();
375
438
  this.historyWriter = new ChatHistoryWriter();
376
439
  }
@@ -412,7 +475,7 @@ export class CliProviderInstance implements ProviderInstance {
412
475
  await this.adapter.spawn();
413
476
  await this.enforceFreshSessionLaunchIfNeeded();
414
477
  this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
415
- if (this.providerSessionId) {
478
+ if (this.providerSessionId && this.shouldHydrateExistingProviderHistory()) {
416
479
  this.restorePersistedHistoryFromCurrentSession();
417
480
  }
418
481
  if (this.providerSessionId && this.launchMode === 'resume') {
@@ -486,14 +549,38 @@ export class CliProviderInstance implements ProviderInstance {
486
549
  }
487
550
 
488
551
  getState(): ProviderState {
552
+ // TODO(phase5-sandbox): JS override scripts (detectStatus, parseApproval,
553
+ // parseSession) are currently invoked by CliScriptRunner.invoke() via direct
554
+ // function calls — the scripts run in the daemon process with full Node.js
555
+ // access and no resource limits.
556
+ //
557
+ // When Phase 5 lands, CliScriptRunner should route these calls through a
558
+ // SandboxedScriptRunner (see providers/sdk/v1/sandbox/script-runner.ts) so
559
+ // that each call gets a fresh isolated-vm context with a 50 ms CPU limit and
560
+ // a 32 MB memory cap. The execution path to change is:
561
+ // CliScriptRunner.invoke() → SandboxedScriptRunner.run(scriptSource, context)
562
+ //
563
+ // This getState() call-site is NOT where the change goes — the wiring belongs
564
+ // in cli-script-runner.ts (CliScriptRunner.detectStatus / parseApproval /
565
+ // parseSession), with provider-loader.ts updated to store script source strings
566
+ // alongside the loaded function references for extended-legacy providers.
489
567
  const adapterStatus = this.adapter.getStatus();
568
+ if (Object.prototype.hasOwnProperty.call(adapterStatus, 'activeInteractivePrompt')) {
569
+ this.activeInteractivePrompt = adapterStatus.activeInteractivePrompt ?? null;
570
+ }
490
571
  let parsedStatus: any = null;
491
572
  let parseErrorMessage: string | undefined;
492
573
  if (typeof this.adapter.getScriptParsedStatus === 'function') {
493
574
  try {
494
575
  parsedStatus = this.adapter.getScriptParsedStatus() || null;
495
- this.errorMessage = undefined;
496
- this.errorReason = undefined;
576
+ const parsedErrorMessage = typeof parsedStatus?.errorMessage === 'string' && parsedStatus.errorMessage.trim()
577
+ ? parsedStatus.errorMessage.trim()
578
+ : undefined;
579
+ const parsedErrorReason = typeof parsedStatus?.errorReason === 'string' && parsedStatus.errorReason.trim()
580
+ ? parsedStatus.errorReason.trim() as ProviderErrorReason
581
+ : undefined;
582
+ this.errorMessage = parsedErrorMessage;
583
+ this.errorReason = parsedErrorReason;
497
584
  } catch (error: any) {
498
585
  parseErrorMessage = error?.message || String(error);
499
586
  this.errorMessage = parseErrorMessage;
@@ -503,22 +590,59 @@ export class CliProviderInstance implements ProviderInstance {
503
590
  this.errorMessage = undefined;
504
591
  this.errorReason = undefined;
505
592
  }
506
- const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
507
- const visibleStatus = parseErrorMessage
508
- ? 'error'
509
- : (autoApproveActive ? 'generating' : adapterStatus.status);
510
- const parsedProviderSessionId = normalizeProviderSessionId(
593
+ const adapterProviderSessionId = normalizeProviderSessionId(
511
594
  this.provider,
512
- typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
595
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
513
596
  );
514
- if (parsedProviderSessionId) {
515
- this.promoteProviderSessionId(parsedProviderSessionId);
597
+ const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
598
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
599
+ let visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
600
+ ? 'error'
601
+ : (autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status);
602
+ const externalNativeFinal = this.getExternalNativeFinalReconciliation(parsedStatus?.messages, adapterStatus);
603
+ if (externalNativeFinal && isCliGeneratingLikeStatus(visibleStatus)) {
604
+ visibleStatus = 'idle';
605
+ }
606
+ // Adapter raw status can lag behind parsed/native evidence: if the spec driver
607
+ // has not yet emitted a state_changed(idle) event but the parsed transcript
608
+ // already shows a final assistant turn, treat the session as idle so that
609
+ // getState() agrees with what detectStatusTransition already recorded via
610
+ // lastStatus. Without this guard, getState() returns 'generating' even after
611
+ // the instance's lastStatus has flipped to 'idle', causing the dashboard to
612
+ // show a perpetual generating spinner.
613
+ if (isCliGeneratingLikeStatus(visibleStatus) && this.lastStatus === 'idle') {
614
+ visibleStatus = 'idle';
516
615
  }
517
616
  const runtime = this.adapter.getRuntimeMetadata();
518
617
  this.maybeAppendRuntimeRecoveryMessage(runtime);
519
618
  let parsedMessages = Array.isArray(parsedStatus?.messages)
520
619
  ? parsedStatus.messages
521
620
  : [];
621
+ const parsedProviderSessionId = normalizeProviderSessionId(
622
+ this.provider,
623
+ typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
624
+ );
625
+ const suppressFreshLaunchStartupReplay = this.shouldSuppressFreshLaunchStartupReplay(
626
+ parsedMessages,
627
+ parsedStatus,
628
+ adapterStatus,
629
+ parsedProviderSessionId,
630
+ );
631
+ if (adapterProviderSessionId && !suppressFreshLaunchStartupReplay) {
632
+ this.promoteProviderSessionId(adapterProviderSessionId);
633
+ }
634
+ if (parsedProviderSessionId && !suppressFreshLaunchStartupReplay) {
635
+ this.promoteProviderSessionId(parsedProviderSessionId);
636
+ }
637
+ if (suppressFreshLaunchStartupReplay) {
638
+ parsedMessages = [];
639
+ }
640
+ // Adapter runtime metadata is transport-owned and is not guaranteed to
641
+ // identify this conversation. Spec adapters historically exposed the
642
+ // provider spec id (for example "codex-cli") as runtimeId, which made
643
+ // concurrent sessions share one activeChat identity until their native
644
+ // provider session ids were discovered.
645
+ const activeChatId = this.providerSessionId || this.instanceId;
522
646
  const historyMessageCount = Number.isFinite(parsedStatus?.historyMessageCount)
523
647
  ? Math.max(0, Number(parsedStatus.historyMessageCount))
524
648
  : null;
@@ -528,7 +652,18 @@ export class CliProviderInstance implements ProviderInstance {
528
652
  : [];
529
653
  }
530
654
  const mergedMessages = this.mergeConversationMessages(parsedMessages);
531
- const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
655
+ const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
656
+ ? this.syncCanonicalSavedHistoryIfNeeded()
657
+ : false;
658
+ const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
659
+ ? this.lastPersistedHistoryMessages.map((message) => ({
660
+ role: message.role,
661
+ content: message.content,
662
+ kind: message.kind,
663
+ senderName: message.senderName,
664
+ receivedAt: message.receivedAt,
665
+ }))
666
+ : mergedMessages;
532
667
 
533
668
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
534
669
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
@@ -572,33 +707,48 @@ export class CliProviderInstance implements ProviderInstance {
572
707
  }
573
708
  }
574
709
 
575
- this.applyProviderResponse(parsedStatus, { phase: 'immediate' });
710
+ this.applyProviderResponse(
711
+ suppressFreshLaunchStartupReplay && parsedStatus && typeof parsedStatus === 'object'
712
+ ? { ...parsedStatus, providerSessionId: undefined }
713
+ : parsedStatus,
714
+ { phase: 'immediate' },
715
+ );
576
716
  const surface = resolveProviderStateSurface({
577
717
  summaryMetadata: this.summaryMetadata as any,
578
718
  controlValues: this.controlValues,
579
719
  });
580
720
  const activeChatStatus = parseErrorMessage
581
721
  ? 'error'
582
- : autoApproveActive && parsedStatus?.status === 'waiting_approval'
722
+ : (autoApproveActive && parsedStatus?.status === 'waiting_approval') || autoApproveHoldIdle
583
723
  ? 'generating'
584
724
  : (adapterStatus.status !== 'idle'
585
725
  ? visibleStatus
586
726
  : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
587
727
 
728
+ // If an AskUserQuestion prompt is awaiting user input, overlay status as
729
+ // waiting_choice. This is distinct from waiting_approval (tool-use consent)
730
+ // — the engine's isWaitingForResponse state is unchanged, so completion
731
+ // tracking continues normally once the user responds.
732
+ const hasInteractivePrompt = !!this.activeInteractivePrompt;
733
+ const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
734
+ const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
735
+
588
736
  return {
589
737
  type: this.type,
590
738
  name: this.provider.name,
591
739
  category: 'cli',
592
- status: visibleStatus,
740
+ status: finalStatus,
593
741
  mode: this.presentationMode,
594
742
  activeChat: {
595
- id: `${this.type}_${this.workingDir}`,
743
+ id: activeChatId,
596
744
  title: parsedStatus?.title || dirName,
597
- status: activeChatStatus,
598
- messages: mergedMessages,
599
- activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
745
+ status: finalChatStatus,
746
+ messages: statusMessages,
747
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
748
+ activeInteractivePrompt: this.activeInteractivePrompt,
600
749
  inputContent: '',
601
750
  },
751
+ activeInteractivePrompt: this.activeInteractivePrompt,
602
752
  workspace: this.workingDir,
603
753
  instanceId: this.instanceId,
604
754
  providerSessionId: this.providerSessionId,
@@ -639,7 +789,8 @@ export class CliProviderInstance implements ProviderInstance {
639
789
  getHotChatSessionState(): HotChatSessionState {
640
790
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
641
791
  const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
642
- const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
792
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
793
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
643
794
  const runtime = this.adapter.getRuntimeMetadata();
644
795
  return {
645
796
  id: this.instanceId,
@@ -651,21 +802,41 @@ export class CliProviderInstance implements ProviderInstance {
651
802
  };
652
803
  }
653
804
 
654
- getSessionModalState(): SessionModalState {
655
- const adapterStatus = this.adapter.getStatus({ allowParse: false });
805
+ getSessionModalState(sessionId?: string): SessionModalState {
806
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
656
807
  const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
657
- const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
808
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
809
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
658
810
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
659
811
  return {
660
- id: this.instanceId,
812
+ // Honor the caller-supplied sessionId — InstanceMgr rejects the
813
+ // projection when projected.id !== requested sessionId, and
814
+ // this.instanceId is the manager's internal key, not the public
815
+ // sessionId the dashboard subscribes by.
816
+ id: sessionId ?? this.instanceId,
661
817
  status: visibleStatus,
662
818
  title: dirName,
663
- activeModal: autoApproveActive ? null : adapterStatus.activeModal,
819
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : adapterStatus.activeModal,
664
820
  };
665
821
  }
666
822
 
667
823
  updateSettings(newSettings: Record<string, any>): void {
668
- this.settings = { ...newSettings };
824
+ const runtimeMeshSettings: Record<string, any> = {};
825
+ for (const key of [
826
+ 'meshNodeFor',
827
+ 'meshNodeId',
828
+ 'meshActiveTaskId',
829
+ 'meshCoordinatorFor',
830
+ 'meshCoordinatorDaemonId',
831
+ 'meshCoordinatorNodeId',
832
+ 'spawnedSessionVisibility',
833
+ 'launchedByCoordinator',
834
+ ]) {
835
+ if (this.settings[key] !== undefined && newSettings[key] === undefined) {
836
+ runtimeMeshSettings[key] = this.settings[key];
837
+ }
838
+ }
839
+ this.settings = { ...newSettings, ...runtimeMeshSettings };
669
840
  this.adapter.updateRuntimeSettings?.(this.settings);
670
841
  this.monitor.updateConfig({
671
842
  approvalAlert: this.settings.approvalAlert !== false,
@@ -674,6 +845,40 @@ export class CliProviderInstance implements ProviderInstance {
674
845
  });
675
846
  }
676
847
 
848
+ /**
849
+ * Stamp a direct-dispatch mesh assignment on this instance.
850
+ * setupMeshEventForwarding reads settings.meshNodeFor + meshActiveTaskId to
851
+ * route generating_completed back to the originating coordinator. Without
852
+ * this stamp, mesh_send_task --direct targets a plain CLI session whose
853
+ * completion events silently drop because the forwarder has nothing to
854
+ * match against.
855
+ */
856
+ attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string }): void {
857
+ if (!assignment?.meshId) return;
858
+ this.settings = {
859
+ ...this.settings,
860
+ meshNodeFor: assignment.meshId,
861
+ ...(assignment.nodeId ? { meshNodeId: assignment.nodeId } : {}),
862
+ ...(assignment.taskId ? { meshActiveTaskId: assignment.taskId } : {}),
863
+ ...(assignment.coordinatorDaemonId ? { meshCoordinatorDaemonId: assignment.coordinatorDaemonId } : {}),
864
+ };
865
+ this.adapter.updateRuntimeSettings?.(this.settings);
866
+ }
867
+
868
+ /**
869
+ * Clear a previously-attached mesh assignment after the task reaches a
870
+ * terminal state. Leaving meshNodeFor pinned would route this session's
871
+ * subsequent unrelated turns (e.g. ad-hoc dashboard chats) to the
872
+ * coordinator as if they were task completions.
873
+ */
874
+ detachMeshAssignment(): void {
875
+ if (!this.settings.meshNodeFor && !this.settings.meshActiveTaskId && !this.settings.meshNodeId) return;
876
+ const { meshNodeFor, meshNodeId, meshActiveTaskId, ...rest } = this.settings;
877
+ void meshNodeFor; void meshNodeId; void meshActiveTaskId;
878
+ this.settings = rest;
879
+ this.adapter.updateRuntimeSettings?.(this.settings);
880
+ }
881
+
677
882
  onEvent(event: string, data?: any): void {
678
883
  if (event === 'send_message') {
679
884
  const input = normalizeInputEnvelope(data);
@@ -690,11 +895,67 @@ export class CliProviderInstance implements ProviderInstance {
690
895
  void this.adapter.resolveAction(data).catch((e: any) => {
691
896
  LOG.warn('CLI', `[${this.type}] resolve_action failed: ${e?.message || e}`);
692
897
  });
898
+ } else if (event === 'interactive_prompt' && data) {
899
+ const prompt = normalizeInteractivePrompt(data);
900
+ if (prompt) {
901
+ this.activeInteractivePrompt = prompt;
902
+ this.events.push({
903
+ event: 'interactive_prompt',
904
+ timestamp: Date.now(),
905
+ promptId: prompt.promptId,
906
+ });
907
+ }
908
+ } else if (event === 'interactive_prompt_response' && data) {
909
+ try {
910
+ const response = normalizeInteractivePromptResponse(data);
911
+ if (this.activeInteractivePrompt?.promptId === response.promptId) {
912
+ this.activeInteractivePrompt = null;
913
+ }
914
+ if (typeof this.adapter.setInteractivePromptResponse !== 'function') {
915
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response ignored: adapter does not support interactive prompts`);
916
+ return;
917
+ }
918
+ void this.adapter.setInteractivePromptResponse(response).catch((e: any) => {
919
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response failed: ${e?.message || e}`);
920
+ });
921
+ } catch (e: any) {
922
+ LOG.warn('CLI', `[${this.type}] invalid interactive_prompt_response: ${e?.message || e}`);
923
+ }
693
924
  } else if (event === 'provider_state_patch' && data && typeof data === 'object') {
694
925
  this.applyProviderResponse(data, { phase: 'immediate' });
695
926
  }
696
927
  }
697
928
 
929
+ recordAcknowledgedUserInput(input: InputEnvelope | string): void {
930
+ const content = typeof input === 'string'
931
+ ? input.trim()
932
+ : buildCliStructuredInputPrompt(input).trim();
933
+ if (!content) return;
934
+
935
+ const receivedAt = Date.now();
936
+ this.lastAcknowledgedUserInputAt = receivedAt;
937
+ this.externalBusyIdleFingerprint = '';
938
+ const dedupKey = `user_input_ack:${crypto
939
+ .createHash('sha256')
940
+ .update(`${this.instanceId}:${content}:${receivedAt}`)
941
+ .digest('hex')
942
+ .slice(0, 24)}`;
943
+ this.appendRuntimeMessage(buildChatMessage({
944
+ role: 'user',
945
+ senderName: 'User',
946
+ kind: 'standard',
947
+ content,
948
+ receivedAt,
949
+ timestamp: receivedAt,
950
+ source: 'runtime_input_ack',
951
+ meta: {
952
+ runtimeInputAck: true,
953
+ provider: this.type,
954
+ workspace: this.workingDir,
955
+ },
956
+ } as ChatMessage), dedupKey);
957
+ }
958
+
698
959
  dispose(): void {
699
960
  this.adapter.shutdown();
700
961
  this.monitor.reset();
@@ -706,6 +967,7 @@ export class CliProviderInstance implements ProviderInstance {
706
967
 
707
968
  private completedDebounceTimer: NodeJS.Timeout | null = null;
708
969
  private completedDebouncePending: CompletedDebouncePending | null = null;
970
+ private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
709
971
 
710
972
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
711
973
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -740,7 +1002,210 @@ export class CliProviderInstance implements ProviderInstance {
740
1002
  const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
741
1003
  const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
742
1004
  const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
743
- return role === 'assistant' && !!content;
1005
+ if (role !== 'assistant' || !content) return false;
1006
+ // Guard: if the last assistant message looks like an active approval/input prompt,
1007
+ // it is not a real completion — the session is still awaiting user input.
1008
+ if (looksLikeActiveApprovalPromptText(content)) return false;
1009
+ return true;
1010
+ }
1011
+
1012
+ private buildExternalTranscriptProbe(messages: unknown[], sourcePath?: string, sourceMtimeMs?: number): ExternalTranscriptProbe {
1013
+ const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1014
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1015
+ const readAt = Date.now();
1016
+ const mtimeMs = Number(sourceMtimeMs) || 0;
1017
+ return {
1018
+ readAt,
1019
+ msgCount: messages.length,
1020
+ lastRole: typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null,
1021
+ lastKind: typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null,
1022
+ contentLen: lastVisible ? flattenContent(lastVisible.content).trim().length : 0,
1023
+ sourcePath: typeof sourcePath === 'string' && sourcePath ? sourcePath : null,
1024
+ sourceMtimeMs: mtimeMs || null,
1025
+ mtimeAgeMs: mtimeMs ? Math.max(0, readAt - mtimeMs) : null,
1026
+ };
1027
+ }
1028
+
1029
+ private recordPendingTranscriptProbe(pending: CompletedDebouncePending): ExternalTranscriptProbe | null {
1030
+ const probe = this.lastExternalCompletionProbe;
1031
+ if (!probe) return null;
1032
+ const history = pending.transcriptProbeHistory || [];
1033
+ const last = history[history.length - 1];
1034
+ if (!last || last.readAt !== probe.readAt || last.msgCount !== probe.msgCount || last.lastRole !== probe.lastRole || last.contentLen !== probe.contentLen) {
1035
+ history.push(probe);
1036
+ pending.transcriptProbeHistory = history.slice(-5);
1037
+ }
1038
+ return probe;
1039
+ }
1040
+
1041
+ private readExternalCompletionMessages(): unknown[] | null {
1042
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1043
+ if (!adapterOwnsMessagesElsewhere) return null;
1044
+ if (!this.providerSessionId) return null;
1045
+ if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
1046
+
1047
+ if (this.lastExternalCompletionProbe?.sourcePath) {
1048
+ try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
1049
+ }
1050
+ const restoredHistory = readProviderChatHistory(this.type, {
1051
+ canonicalHistory: this.provider.nativeHistory,
1052
+ historySessionId: this.providerSessionId,
1053
+ workspace: this.workingDir,
1054
+ offset: 0,
1055
+ limit: Number.MAX_SAFE_INTEGER,
1056
+ historyBehavior: this.provider.historyBehavior,
1057
+ scripts: this.provider.scripts as any,
1058
+ sessionStartedAtMs: this.startedAt,
1059
+ forceRefresh: true,
1060
+ });
1061
+ if (restoredHistory.source !== 'provider-native') {
1062
+ this.lastExternalCompletionProbe = null;
1063
+ return null;
1064
+ }
1065
+ this.lastExternalCompletionProbe = this.buildExternalTranscriptProbe(
1066
+ restoredHistory.messages,
1067
+ restoredHistory.sourcePath,
1068
+ restoredHistory.sourceMtimeMs,
1069
+ );
1070
+ return restoredHistory.messages;
1071
+ }
1072
+
1073
+ private completionFinalAssistantEvidence(parsedMessages: unknown): CompletionFinalAssistantEvidence {
1074
+ if (this.completionHasFinalAssistantMessage(parsedMessages)) {
1075
+ return {
1076
+ present: true,
1077
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1078
+ source: 'parsed',
1079
+ };
1080
+ }
1081
+
1082
+ const externalMessages = this.readExternalCompletionMessages();
1083
+ if (externalMessages) {
1084
+ return {
1085
+ present: this.completionHasFinalAssistantMessage(externalMessages),
1086
+ messages: externalMessages,
1087
+ source: 'external-native',
1088
+ };
1089
+ }
1090
+
1091
+ return {
1092
+ present: false,
1093
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1094
+ source: 'unavailable',
1095
+ };
1096
+ }
1097
+
1098
+ private completionFinalSummary(parsedMessages: unknown): string | undefined {
1099
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
1100
+ return extractFinalSummaryFromMessages(evidence.messages as any);
1101
+ }
1102
+
1103
+ private externalNativeFinalFingerprint(evidence: CompletionFinalAssistantEvidence): string {
1104
+ const messages = Array.isArray(evidence.messages) ? evidence.messages : [];
1105
+ const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1106
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1107
+ const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
1108
+ const receivedAt = lastVisible ? getMessageTime(lastVisible) : 0;
1109
+ const probe = this.lastExternalCompletionProbe;
1110
+ return crypto
1111
+ .createHash('sha256')
1112
+ .update([
1113
+ this.type,
1114
+ this.providerSessionId || '',
1115
+ probe?.sourcePath || '',
1116
+ String(probe?.sourceMtimeMs || 0),
1117
+ String(receivedAt || 0),
1118
+ content.slice(-500),
1119
+ ].join('\0'))
1120
+ .digest('hex')
1121
+ .slice(0, 24);
1122
+ }
1123
+
1124
+ private getExternalNativeFinalReconciliation(parsedMessages: unknown, adapterStatus: any): ExternalNativeFinalReconciliation | null {
1125
+ const rawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
1126
+ if (!isCliGeneratingLikeStatus(rawStatus)) return null;
1127
+ if (hasNonEmptyCliModalButtons(adapterStatus?.activeModal ?? adapterStatus?.modal)) return null;
1128
+
1129
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
1130
+ if (evidence.source !== 'external-native' || !evidence.present) return null;
1131
+
1132
+ const messages = Array.isArray(evidence.messages) ? evidence.messages : [];
1133
+ const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1134
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1135
+ const lastMessageAt = lastVisible ? getMessageTime(lastVisible) : 0;
1136
+ const sourceMtimeMs = Number(this.lastExternalCompletionProbe?.sourceMtimeMs || 0);
1137
+ const minEvidenceAt = Math.max(
1138
+ this.startedAt > 0 ? this.startedAt - 5_000 : 0,
1139
+ this.generatingStartedAt > 0 ? this.generatingStartedAt - 5_000 : 0,
1140
+ this.lastAcknowledgedUserInputAt > 0 ? this.lastAcknowledgedUserInputAt - 1_000 : 0,
1141
+ );
1142
+ if (minEvidenceAt > 0 && lastMessageAt > 0 && lastMessageAt < minEvidenceAt && sourceMtimeMs < minEvidenceAt) {
1143
+ return null;
1144
+ }
1145
+
1146
+ const finalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
1147
+ if (!finalSummary) return null;
1148
+ const fingerprint = this.externalNativeFinalFingerprint(evidence);
1149
+ if (fingerprint === this.externalBusyIdleFingerprint) {
1150
+ return { fingerprint, finalSummary, evidence };
1151
+ }
1152
+ this.externalBusyIdleFingerprint = fingerprint;
1153
+ return { fingerprint, finalSummary, evidence };
1154
+ }
1155
+
1156
+ private buildCompletedFinalizationDiagnostic(args: {
1157
+ blockReason: string;
1158
+ latestStatus?: any;
1159
+ latestVisibleStatus: string;
1160
+ waitedMs: number;
1161
+ pending: CompletedDebouncePending;
1162
+ emittedAfterFinalizationTimeout: boolean;
1163
+ }): Record<string, unknown> {
1164
+ let parsed: any = null;
1165
+ let parseError: string | undefined;
1166
+ try {
1167
+ parsed = this.adapter.getScriptParsedStatus();
1168
+ } catch (error: any) {
1169
+ parseError = error?.message || String(error);
1170
+ }
1171
+
1172
+ const evidence = this.completionFinalAssistantEvidence(parsed?.messages);
1173
+ if (evidence.source === 'external-native') {
1174
+ this.recordPendingTranscriptProbe(args.pending);
1175
+ }
1176
+ const visibleMessages = (Array.isArray(evidence.messages) ? evidence.messages : [])
1177
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1178
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1179
+ const lastVisibleRole = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null;
1180
+ const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
1181
+ const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
1182
+
1183
+ return {
1184
+ providerType: this.type,
1185
+ sessionId: this.instanceId,
1186
+ providerSessionId: this.providerSessionId || null,
1187
+ workspace: this.workingDir,
1188
+ blockReason: args.blockReason,
1189
+ emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
1190
+ waitedMs: args.waitedMs,
1191
+ maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
1192
+ adapterStatus: typeof args.latestStatus?.status === 'string' ? args.latestStatus.status : null,
1193
+ latestVisibleStatus: args.latestVisibleStatus,
1194
+ parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
1195
+ parseError: parseError || undefined,
1196
+ finalAssistantPresent: evidence.present,
1197
+ finalAssistantEvidenceSource: evidence.source,
1198
+ visibleMessageCount: visibleMessages.length,
1199
+ lastVisibleRole,
1200
+ lastVisibleKind,
1201
+ lastVisibleContentLength,
1202
+ pendingStartedAt: this.generatingStartedAt || null,
1203
+ pendingFirstObservedAt: args.pending.firstObservedAt,
1204
+ pendingTimestamp: args.pending.timestamp,
1205
+ pendingDurationSec: args.pending.duration,
1206
+ previousBlockReason: args.pending.loggedBlockReason || null,
1207
+ transcriptProbeHistory: args.pending.transcriptProbeHistory || [],
1208
+ };
744
1209
  }
745
1210
 
746
1211
  private hasAdapterPendingResponse(): boolean {
@@ -765,32 +1230,99 @@ export class CliProviderInstance implements ProviderInstance {
765
1230
  if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
766
1231
  if (adapterRawStatus !== 'idle') return false;
767
1232
  if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
768
- return !this.hasAdapterPendingResponse();
1233
+ if (this.hasAdapterPendingResponse()) return false;
1234
+ // Do not suppress when the adapter's raw response buffer is still non-empty.
1235
+ // This catches the case where isWaitingForResponse has already flipped to false
1236
+ // (so getPartialResponse() returns '') but the provider's native parser still
1237
+ // reports generating because it's parsing buffered content. Suppressing the
1238
+ // finalization block here would emit a false completion event while the provider
1239
+ // session is still actively processing its response stream.
1240
+ const adapterAny = this.adapter as any;
1241
+ if (typeof adapterAny?.responseBuffer === 'string' && adapterAny.responseBuffer.trim()) return false;
1242
+ return true;
769
1243
  }
770
1244
 
771
- private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
- if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
1245
+ private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending, opts?: { externalNativeFinal?: ExternalNativeFinalReconciliation | null }): CompletedFinalizationBlock | null {
1246
+ if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
773
1247
 
774
1248
  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';
1249
+ const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1250
+ const externalNativeFinal = opts?.externalNativeFinal || null;
1251
+ if (!approvalResolvedIdle && !externalNativeFinal) {
1252
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
1253
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
1254
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
1255
+ }
777
1256
 
778
1257
  const partial = typeof this.adapter.getPartialResponse === 'function'
779
1258
  ? this.adapter.getPartialResponse()
780
1259
  : '';
781
- if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
1260
+ if (!externalNativeFinal && typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
782
1261
 
783
1262
  let parsed: any;
784
1263
  try {
785
1264
  parsed = this.adapter.getScriptParsedStatus();
786
1265
  } catch (error: any) {
787
- return `parse_error:${error?.message || String(error)}`;
1266
+ return { reason: `parse_error:${error?.message || String(error)}` };
788
1267
  }
789
1268
 
790
1269
  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';
1270
+ if (parsedStatus !== 'idle') {
1271
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
1272
+ if (externalNativeFinal && isCliGeneratingLikeStatus(parsedStatus)) return null;
1273
+ if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
1274
+ return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
1275
+ }
1276
+ if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
1277
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1278
+ const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
1279
+ const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1280
+ LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1281
+ if (!finalAssistantEvidence.present) {
1282
+ if (adapterOwnsMessagesElsewhere) {
1283
+ if (finalAssistantEvidence.source === 'external-native') {
1284
+ const probe = this.recordPendingTranscriptProbe(pending);
1285
+ if (probe && !pending.loggedTranscriptProbe) {
1286
+ 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`);
1287
+ pending.loggedTranscriptProbe = true;
1288
+ }
1289
+ LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
1290
+ if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
1291
+ return null;
1292
+ }
1293
+ if (this.type === 'antigravity-cli') {
1294
+ return null;
1295
+ }
1296
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1297
+ }
1298
+ if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
1299
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1300
+ }
1301
+ } else {
1302
+ LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
1303
+ return {
1304
+ reason: 'missing_final_assistant',
1305
+ terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
1306
+ allowTimeout: allowMissingAssistantTimeout,
1307
+ };
1308
+ }
1309
+ }
1310
+
1311
+ // Guard: if the screen still shows an approval/choice prompt as the last visible text,
1312
+ // the turn is not complete even if the parsed status says idle and there is an assistant
1313
+ // message. This catches the case where waiting_approval→idle transitions occur before
1314
+ // the modal has been resolved (e.g. the PTY rendered the prompt but no button press fired).
1315
+ try {
1316
+ const screenText = typeof (this.adapter as any).getScreenText === 'function'
1317
+ ? String((this.adapter as any).getScreenText() || '')
1318
+ : '';
1319
+ if (screenText) {
1320
+ const tailLines = screenText.split(/\r?\n/).slice(-16).join('\n');
1321
+ if (looksLikeActiveApprovalPromptText(tailLines)) {
1322
+ return { reason: 'screen_shows_approval_prompt', terminal: approvalResolvedIdle };
1323
+ }
1324
+ }
1325
+ } catch { /* defensive: screen text read is best-effort */ }
794
1326
 
795
1327
  return null;
796
1328
  }
@@ -809,7 +1341,11 @@ export class CliProviderInstance implements ProviderInstance {
809
1341
 
810
1342
  const latestStatus = this.adapter.getStatus({ allowParse: false });
811
1343
  const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
812
- const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
1344
+ const externalNativeFinal = this.getExternalNativeFinalReconciliation(undefined, latestStatus);
1345
+ const latestVisibleStatus = externalNativeFinal && isCliGeneratingLikeStatus(latestStatus.status)
1346
+ ? 'idle'
1347
+ : (latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status);
1348
+ LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} externalNativeFinal=${!!externalNativeFinal} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
813
1349
  if (latestVisibleStatus !== 'idle') {
814
1350
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
815
1351
  this.completedDebouncePending = null;
@@ -817,10 +1353,12 @@ export class CliProviderInstance implements ProviderInstance {
817
1353
  return;
818
1354
  }
819
1355
 
820
- const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
- if (blockReason) {
1356
+ const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending, { externalNativeFinal });
1357
+ if (block) {
1358
+ const blockReason = block.reason;
822
1359
  const waitedMs = Date.now() - pending.firstObservedAt;
823
- if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
1360
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1361
+ if ((block.terminal && !block.allowTimeout) || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
824
1362
  if (pending.loggedBlockReason !== blockReason) {
825
1363
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
826
1364
  pending.loggedBlockReason = blockReason;
@@ -828,10 +1366,29 @@ export class CliProviderInstance implements ProviderInstance {
828
1366
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
1367
  return;
830
1368
  }
831
- LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1369
+ const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
1370
+ blockReason,
1371
+ latestStatus,
1372
+ latestVisibleStatus,
1373
+ waitedMs,
1374
+ pending,
1375
+ emittedAfterFinalizationTimeout: true,
1376
+ });
1377
+ LOG.warn('CLI', `[${this.type}] emitting completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1378
+ this.pushEvent({
1379
+ event: 'agent:generating_completed',
1380
+ chatTitle: pending.chatTitle,
1381
+ duration: pending.duration,
1382
+ timestamp: pending.timestamp,
1383
+ finalSummary: blockReason.startsWith('parsed_status:')
1384
+ ? ''
1385
+ : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1386
+ completionDiagnostic,
1387
+ });
832
1388
  this.completedDebouncePending = null;
833
1389
  this.completedDebounceTimer = null;
834
1390
  this.generatingStartedAt = 0;
1391
+ this.lastApprovalEventFingerprint = '';
835
1392
  return;
836
1393
  }
837
1394
 
@@ -841,11 +1398,23 @@ export class CliProviderInstance implements ProviderInstance {
841
1398
  chatTitle: pending.chatTitle,
842
1399
  duration: pending.duration,
843
1400
  timestamp: pending.timestamp,
844
- finalSummary: extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages),
1401
+ finalSummary: externalNativeFinal?.finalSummary || this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1402
+ ...(externalNativeFinal ? {
1403
+ completionDiagnostic: {
1404
+ providerType: this.type,
1405
+ sessionId: this.instanceId,
1406
+ providerSessionId: this.providerSessionId || null,
1407
+ reconciliationReason: 'external_native_final_assistant_while_adapter_busy',
1408
+ finalAssistantPresent: true,
1409
+ finalAssistantEvidenceSource: externalNativeFinal.evidence.source,
1410
+ externalFinalFingerprint: externalNativeFinal.fingerprint,
1411
+ },
1412
+ } : {}),
845
1413
  });
846
1414
  this.completedDebouncePending = null;
847
1415
  this.completedDebounceTimer = null;
848
1416
  this.generatingStartedAt = 0;
1417
+ this.lastApprovalEventFingerprint = '';
849
1418
  }
850
1419
 
851
1420
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
@@ -853,15 +1422,46 @@ export class CliProviderInstance implements ProviderInstance {
853
1422
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
854
1423
  // times while the PTY absorbs the approval key. Without this flag, repeated
855
1424
  // snapshots would write stray keys into the input once the modal dismisses.
856
- if (autoApproveActive && !this.autoApproveBusy) {
1425
+ // However, Claude Code can present a second approval immediately after the
1426
+ // first. Resolve a changed modal signature even while the previous write is
1427
+ // still inside the short busy window.
1428
+ if (!autoApproveActive) {
1429
+ this.lastAutoApprovalSignature = '';
1430
+ return autoApproveActive;
1431
+ }
1432
+ const modal = adapterStatus.activeModal;
1433
+ // (fix) Do not auto-approve when no concrete modal/buttons are present.
1434
+ // Claude TUI flaps between paints; without this guard adapterStatus
1435
+ // could report status=waiting_approval with activeModal=null (or with
1436
+ // an empty buttons array briefly) and we'd still call
1437
+ // resolveModal(-1) — which used to type "1" into the prompt
1438
+ // repeatedly. Skip until a real modal is captured.
1439
+ const buttons = Array.isArray(modal?.buttons)
1440
+ ? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
1441
+ : [];
1442
+ if (!modal || buttons.length === 0) {
1443
+ return autoApproveActive;
1444
+ }
1445
+ const { index: buttonIndex, label: buttonLabel } = pickAutoApprovalButton(buttons);
1446
+ if (buttonIndex < 0) {
1447
+ // No concrete button matched — don't pick a random index, just
1448
+ // surface the modal so the user can decide.
1449
+ return autoApproveActive;
1450
+ }
1451
+ const signature = [
1452
+ typeof modal?.message === 'string' ? modal.message.trim() : '',
1453
+ buttons.join('|'),
1454
+ buttonIndex,
1455
+ ].join('::');
1456
+ if (!this.autoApproveBusy || signature !== this.lastAutoApprovalSignature) {
857
1457
  this.autoApproveBusy = true;
1458
+ this.lastAutoApprovalSignature = signature;
858
1459
  if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
859
1460
  this.autoApproveBusyTimer = setTimeout(() => {
860
1461
  this.autoApproveBusy = false;
861
1462
  this.autoApproveBusyTimer = null;
862
- }, 2000);
863
- const modal = adapterStatus.activeModal;
864
- const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(modal?.buttons, this.provider);
1463
+ this.lastAutoApprovalSignature = '';
1464
+ }, 5000);
865
1465
  this.recordAutoApproval(modal?.message, buttonLabel, now);
866
1466
  setTimeout(() => {
867
1467
  this.adapter.resolveModal(buttonIndex);
@@ -876,10 +1476,25 @@ export class CliProviderInstance implements ProviderInstance {
876
1476
  // during long-running CLI sessions. Keep this path on adapter-owned light
877
1477
  // state only; rich provider parsing is reserved for getState/read_chat.
878
1478
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
1479
+ const adapterProviderSessionId = normalizeProviderSessionId(
1480
+ this.provider,
1481
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
1482
+ );
1483
+ if (adapterProviderSessionId) {
1484
+ this.promoteProviderSessionId(adapterProviderSessionId);
1485
+ }
879
1486
  const parsedStatus = null;
880
1487
  const rawStatus = adapterStatus.status;
881
1488
  const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
882
- const newStatus = autoApproveActive ? 'generating' : rawStatus;
1489
+ const externalNativeFinal = this.getExternalNativeFinalReconciliation(undefined, adapterStatus);
1490
+ // During the autoApproveBusy window (2s after firing approval key), the PTY
1491
+ // can briefly report 'idle' before the next generating phase starts. Treat that
1492
+ // transient idle as 'generating' to suppress a spurious agent:generating_completed
1493
+ // push notification. externalNativeFinal still wins to allow hard-stop overrides.
1494
+ const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
1495
+ const newStatus = externalNativeFinal && isCliGeneratingLikeStatus(rawStatus)
1496
+ ? 'idle'
1497
+ : (autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus);
883
1498
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
884
1499
  const chatTitle = `${this.provider.name} · ${dirName}`;
885
1500
  const partial = this.adapter.getPartialResponse();
@@ -891,10 +1506,18 @@ export class CliProviderInstance implements ProviderInstance {
891
1506
  if (newStatus !== this.lastStatus) {
892
1507
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
893
1508
  if (this.lastStatus === 'idle' && newStatus === 'generating') {
1509
+ // If a completion event is already pending and the turn has ended
1510
+ // (generatingStartedAt===0), the PTY is painting its prompt area
1511
+ // after completing. Ignore this blip — do not cancel the pending
1512
+ // completion and do not advance lastStatus to generating.
1513
+ if (this.completedDebouncePending && this.generatingStartedAt === 0) {
1514
+ LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
1515
+ return;
1516
+ }
894
1517
  this.suppressIdleHistoryReplay = false;
895
1518
  // Cancel any pending completed event (multi-step: idle→generating resume)
896
1519
  if (this.completedDebouncePending) {
897
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
1520
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
898
1521
  if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
899
1522
  this.completedDebouncePending = null;
900
1523
  }
@@ -926,10 +1549,15 @@ export class CliProviderInstance implements ProviderInstance {
926
1549
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
927
1550
  const modal = adapterStatus.activeModal;
928
1551
  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;
1552
+ const approvalFingerprint = JSON.stringify({
1553
+ message: typeof modal?.message === 'string' ? modal.message.trim() : '',
1554
+ buttons: Array.isArray(modal?.buttons) ? modal.buttons.map((button: unknown) => String(button).trim()) : [],
1555
+ });
1556
+ // PTY redraws repeat the same modal content; fingerprint dedup prevents duplicate events.
1557
+ // Do NOT also gate on lastStatus: consecutive approvals can arrive waiting_approval→waiting_approval
1558
+ // (e.g. antigravity-cli resolves one prompt and immediately shows the next) and would be silently dropped.
1559
+ if (approvalFingerprint !== this.lastApprovalEventFingerprint) {
1560
+ this.lastApprovalEventFingerprint = approvalFingerprint;
933
1561
  this.appendRuntimeSystemMessage(
934
1562
  this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
935
1563
  `approval_request:${now}`,
@@ -943,20 +1571,102 @@ export class CliProviderInstance implements ProviderInstance {
943
1571
  }
944
1572
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
945
1573
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
946
- // If debounce still pending (generating lasted < 1s), cancel both events
1574
+ // Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
1575
+ // was entered from 'starting' state (startup PTY noise), not from a real idle→generating
1576
+ // task dispatch. The idle→generating handler is the only code path that sets
1577
+ // generatingStartedAt and generatingDebouncePending, so both being absent means no
1578
+ // task was ever dispatched. Suppress the spurious completion event and fall through
1579
+ // to a simple lastStatus update.
1580
+ if (!this.generatingStartedAt && !this.generatingDebouncePending) {
1581
+ LOG.debug('CLI', `[${this.type}] suppressed startup-phase generating→idle blip (generatingStartedAt=0, no debounce pending)`);
1582
+ } else
1583
+ // If debounce still pending (generating lasted < 1s), cancel both UI events.
1584
+ // Still emit agent:generating_completed so mesh orchestration can record
1585
+ // task_completed for direct dispatches that complete faster than the debounce.
947
1586
  if (this.generatingDebouncePending) {
948
- LOG.info('CLI', `[${this.type}] suppressed short generating (${now - this.generatingStartedAt}ms)`);
1587
+ const shortDurationMs = this.generatingStartedAt ? now - this.generatingStartedAt : 0;
1588
+ LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms)`);
949
1589
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
1590
+ // Emit completion for mesh task association even though the UI generating
1591
+ // started/completed pair is suppressed (too short for visible UI update).
1592
+ let shortFinalSummary: string | undefined;
1593
+ let shortEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
1594
+ try {
1595
+ const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
1596
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
1597
+ shortEvidenceSource = evidence.source;
1598
+ shortFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
1599
+ } catch { /* best-effort */ }
1600
+ // If a real response is confirmed, retroactively emit started so the chat
1601
+ // bubble appears even though the debounce suppressed the original event.
1602
+ if (shortFinalSummary) {
1603
+ this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now - shortDurationMs });
1604
+ }
950
1605
  this.generatingDebouncePending = null;
951
1606
  this.generatingStartedAt = 0;
1607
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || shortEvidenceSource === 'external-native') && !shortFinalSummary;
1608
+ if (missingEvidence) {
1609
+ LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
1610
+ }
1611
+ // When evidence is missing and there is no active mesh task context, suppress
1612
+ // the completion event. Providers with requiresFinalAssistantBeforeIdle or
1613
+ // external-native history must confirm a final assistant message before the
1614
+ // coordinator records task_completed. Only emit here if a mesh task is active
1615
+ // so the coordinator can apply its own timeout/retry logic.
1616
+ const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1617
+ if (missingEvidence && !hasMeshContext) {
1618
+ LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
1619
+ // completedDebouncePending intentionally left null — the session is now idle
1620
+ // with no confirmed turn, matching the startup-blip suppression semantics.
1621
+ } else {
1622
+ this.pushEvent({
1623
+ event: 'agent:generating_completed',
1624
+ chatTitle,
1625
+ duration: 0,
1626
+ timestamp: now,
1627
+ finalSummary: shortFinalSummary,
1628
+ completionDiagnostic: {
1629
+ reason: 'short_generating_suppressed',
1630
+ shortDurationMs,
1631
+ finalAssistantEvidenceSource: shortEvidenceSource,
1632
+ ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
1633
+ },
1634
+ });
1635
+ }
952
1636
  } else {
953
1637
  // Debounce completed, then require the rich transcript path that read_chat
954
1638
  // 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);
1639
+ this.completedDebouncePending = {
1640
+ chatTitle,
1641
+ duration,
1642
+ timestamp: now,
1643
+ firstObservedAt: now,
1644
+ previousStatus: this.lastStatus,
1645
+ };
1646
+ const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
1647
+ const flushDelay = ownsExternalHistory ? 0 : 3000;
1648
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
1649
+ this.scheduleCompletedDebounceFlush(flushDelay);
957
1650
  }
958
1651
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
959
1652
  this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
1653
+ } else if (newStatus === 'error') {
1654
+ if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
1655
+ this.generatingDebouncePending = null;
1656
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
1657
+ this.completedDebouncePending = null;
1658
+ this.errorMessage = adapterStatus.errorMessage || this.errorMessage;
1659
+ this.errorReason = (adapterStatus.errorReason as ProviderErrorReason) || this.errorReason;
1660
+ this.pushEvent({
1661
+ event: 'agent:stopped',
1662
+ chatTitle,
1663
+ timestamp: now,
1664
+ finalSummary: adapterStatus.errorMessage || adapterStatus.errorReason || 'Provider reported an error',
1665
+ completionDiagnostic: {
1666
+ reason: adapterStatus.errorReason || 'provider_error',
1667
+ errorMessage: adapterStatus.errorMessage || undefined,
1668
+ },
1669
+ });
960
1670
  } else if (newStatus === 'stopped') {
961
1671
  // Cancel any pending debounce
962
1672
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -977,7 +1687,34 @@ export class CliProviderInstance implements ProviderInstance {
977
1687
  // Monitor check (cooldown based notification, IDE/CLI common)
978
1688
  const agentKey = `${this.type}:cli`;
979
1689
  const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint);
1690
+ const monitorParsedStatus: any = parsedStatus;
980
1691
  for (const me of monitorEvents) {
1692
+ if (
1693
+ me.type === 'monitor:long_generating'
1694
+ && this.completionHasFinalAssistantMessage(monitorParsedStatus?.messages)
1695
+ && !this.hasAdapterPendingResponse()
1696
+ && !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
1697
+ ) {
1698
+ this.pushEvent({
1699
+ event: 'agent:generating_completed',
1700
+ chatTitle,
1701
+ duration: this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : undefined,
1702
+ timestamp: me.timestamp,
1703
+ finalSummary: extractFinalSummaryFromMessages(monitorParsedStatus?.messages),
1704
+ completionDiagnostic: {
1705
+ providerType: this.type,
1706
+ sessionId: this.instanceId,
1707
+ providerSessionId: this.providerSessionId || null,
1708
+ reconciliationReason: 'long_generating_monitor_final_summary',
1709
+ finalAssistantPresent: true,
1710
+ },
1711
+ });
1712
+ this.generatingStartedAt = 0;
1713
+ // Cancel any pending debounce flush — monitor already fired completion.
1714
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
1715
+ this.completedDebouncePending = null;
1716
+ continue;
1717
+ }
981
1718
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
982
1719
  }
983
1720
  }
@@ -1003,9 +1740,18 @@ export class CliProviderInstance implements ProviderInstance {
1003
1740
  };
1004
1741
  if (this.context?.emitProviderEvent) {
1005
1742
  this.context.emitProviderEvent(enrichedEvent);
1006
- return;
1743
+ } else {
1744
+ this.events.push(enrichedEvent);
1745
+ }
1746
+ // Auto-detach a direct-dispatch mesh assignment once the dispatched
1747
+ // task reaches a terminal state. Leaving meshNodeFor pinned would
1748
+ // route this session's next unrelated turn (a dashboard chat) into
1749
+ // the coordinator as if it were the completion of another task.
1750
+ // We schedule after the emit so the originating coordinator still
1751
+ // observes the completion event with its routing marker intact.
1752
+ if (TERMINAL_MESH_EVENTS.has(event.event) && this.settings.meshActiveTaskId) {
1753
+ try { this.detachMeshAssignment(); } catch { /* best-effort */ }
1007
1754
  }
1008
- this.events.push(enrichedEvent);
1009
1755
  }
1010
1756
 
1011
1757
  private flushEvents(): ProviderEvent[] {
@@ -1258,12 +2004,28 @@ export class CliProviderInstance implements ProviderInstance {
1258
2004
  index,
1259
2005
  source: 'parsed',
1260
2006
  }));
2007
+ const getRole = (message: ChatMessage): string => typeof message.role === 'string'
2008
+ ? message.role.trim().toLowerCase()
2009
+ : '';
1261
2010
  const runtimeEntries: MergeEntry[] = this.runtimeMessages.map((entry, index) => ({
1262
2011
  message: entry.message,
1263
2012
  index: parsedMessages.length + index,
1264
- source: 'runtime',
2013
+ source: 'runtime' as const,
1265
2014
  runtimeKey: entry.key,
1266
- }));
2015
+ })).filter((entry) => {
2016
+ const meta = entry.message.meta && typeof entry.message.meta === 'object' && !Array.isArray(entry.message.meta)
2017
+ ? entry.message.meta as Record<string, unknown>
2018
+ : {};
2019
+ if (meta.runtimeInputAck !== true) return true;
2020
+ const runtimeText = flattenContent(entry.message.content).replace(/\s+/g, ' ').trim();
2021
+ if (!runtimeText) return false;
2022
+ return !parsedEntries.some((parsedEntry) => {
2023
+ const parsedRole = getRole(parsedEntry.message);
2024
+ if (parsedRole !== 'user' && parsedRole !== 'human') return false;
2025
+ const parsedText = flattenContent(parsedEntry.message.content).replace(/\s+/g, ' ').trim();
2026
+ return parsedText === runtimeText;
2027
+ });
2028
+ });
1267
2029
  const getTime = (message: ChatMessage): number => {
1268
2030
  const value = typeof message.receivedAt === 'number'
1269
2031
  ? message.receivedAt
@@ -1273,9 +2035,6 @@ export class CliProviderInstance implements ProviderInstance {
1273
2035
  return Number.isFinite(value) && value > 0 ? value : 0;
1274
2036
  };
1275
2037
 
1276
- const getRole = (message: ChatMessage): string => typeof message.role === 'string'
1277
- ? message.role.trim().toLowerCase()
1278
- : '';
1279
2038
  const isRuntimeOverlay = (entry: MergeEntry): boolean => {
1280
2039
  if (entry.source !== 'runtime') return false;
1281
2040
  const key = typeof entry.runtimeKey === 'string' ? entry.runtimeKey.trim().toLowerCase() : '';
@@ -1341,7 +2100,9 @@ export class CliProviderInstance implements ProviderInstance {
1341
2100
  this.providerSessionId = nextSessionId;
1342
2101
  this.historyWriter.promoteHistorySession(this.type, previousHistorySessionId, nextSessionId);
1343
2102
  this.historyWriter.writeSessionStart(this.type, nextSessionId, this.workingDir, this.instanceId);
1344
- this.restorePersistedHistoryFromCurrentSession();
2103
+ if (this.shouldHydrateExistingProviderHistory()) {
2104
+ this.restorePersistedHistoryFromCurrentSession();
2105
+ }
1345
2106
  this.adapter.updateRuntimeMeta({ providerSessionId: nextSessionId });
1346
2107
  this.onProviderSessionResolved?.({
1347
2108
  instanceId: this.instanceId,
@@ -1354,9 +2115,27 @@ export class CliProviderInstance implements ProviderInstance {
1354
2115
  LOG.info('CLI', `[${this.type}] discovered provider session id: ${nextSessionId}`);
1355
2116
  }
1356
2117
 
2118
+ private shouldHydrateExistingProviderHistory(): boolean {
2119
+ return this.launchMode === 'resume' || this.launchMode === 'manual';
2120
+ }
2121
+
2122
+ private shouldSuppressFreshLaunchStartupReplay(parsedMessages: unknown[], parsedStatus: any, adapterStatus: any, parsedProviderSessionId = ''): boolean {
2123
+ if (this.launchMode !== 'new') return false;
2124
+ if (this.providerSessionId) return false;
2125
+ if (!Array.isArray(parsedMessages) || parsedMessages.length === 0) return false;
2126
+ if (!isIdleStatus(adapterStatus?.status) || !isIdleStatus(parsedStatus?.status)) return false;
2127
+ if (parsedProviderSessionId) return true;
2128
+
2129
+ const newestMessageAt = parsedMessages.reduce<number>((newest, message) => Math.max(newest, getMessageTime(message)), 0);
2130
+
2131
+ // Untimestamped idle parser output during a fresh launch is usually the
2132
+ // provider's last workspace transcript before a new turn exists.
2133
+ return newestMessageAt === 0;
2134
+ }
2135
+
1357
2136
  private syncCanonicalSavedHistoryIfNeeded(): boolean {
1358
2137
  if (!this.providerSessionId) return false;
1359
- const canonicalHistory = this.provider.canonicalHistory;
2138
+ const canonicalHistory = this.provider.nativeHistory;
1360
2139
  if (!canonicalHistory) return false;
1361
2140
 
1362
2141
  if (isNativeSourceCanonicalHistory(canonicalHistory)) {
@@ -1418,9 +2197,9 @@ export class CliProviderInstance implements ProviderInstance {
1418
2197
  private restorePersistedHistoryFromCurrentSession(): void {
1419
2198
  if (!this.providerSessionId) return;
1420
2199
  this.syncCanonicalSavedHistoryIfNeeded();
1421
- const restoredHistory = isNativeSourceCanonicalHistory(this.provider.canonicalHistory)
2200
+ const restoredHistory = isNativeSourceCanonicalHistory(this.provider.nativeHistory)
1422
2201
  ? readProviderChatHistory(this.type, {
1423
- canonicalHistory: this.provider.canonicalHistory,
2202
+ canonicalHistory: this.provider.nativeHistory,
1424
2203
  historySessionId: this.providerSessionId,
1425
2204
  workspace: this.workingDir,
1426
2205
  offset: 0,