@adhdev/daemon-core 0.9.82-rc.21 → 0.9.82-rc.211

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