@adhdev/daemon-core 0.9.82-rc.26 → 0.9.82-rc.261

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 (274) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +4 -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 +198 -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 +38 -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 +44 -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 +31114 -11255
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +31615 -11852
  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 +152 -0
  41. package/dist/mesh/mesh-events-pending.d.ts +40 -0
  42. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  43. package/dist/mesh/mesh-events-utils.d.ts +16 -0
  44. package/dist/mesh/mesh-events.d.ts +5 -49
  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-batch.d.ts +68 -0
  51. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  52. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  53. package/dist/mesh/mesh-routing.d.ts +70 -0
  54. package/dist/mesh/mesh-runtime-store.d.ts +327 -0
  55. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  56. package/dist/mesh/mesh-work-queue.d.ts +160 -5
  57. package/dist/mesh/preview-freshness.d.ts +18 -0
  58. package/dist/mesh/refine-config.d.ts +215 -0
  59. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  60. package/dist/providers/acp-provider-instance.d.ts +2 -0
  61. package/dist/providers/approval-utils.d.ts +13 -0
  62. package/dist/providers/cli-provider-instance.d.ts +37 -3
  63. package/dist/providers/contracts.d.ts +130 -6
  64. package/dist/providers/external-sources.d.ts +71 -0
  65. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  66. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  67. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  68. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  69. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  70. package/dist/providers/native-history/index.d.ts +13 -0
  71. package/dist/providers/provider-instance-manager.d.ts +13 -0
  72. package/dist/providers/provider-instance.d.ts +13 -1
  73. package/dist/providers/provider-loader.d.ts +26 -4
  74. package/dist/providers/provider-trust.d.ts +31 -0
  75. package/dist/providers/read-chat-contract.d.ts +29 -0
  76. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  77. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  78. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  79. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  80. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  81. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  82. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  83. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  84. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  85. package/dist/providers/sdk/v1/index.d.ts +30 -0
  86. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  87. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  88. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  89. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  90. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  91. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  92. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  93. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  94. package/dist/providers/spec/adapter.d.ts +52 -0
  95. package/dist/providers/spec/cli-adapter.d.ts +140 -0
  96. package/dist/providers/spec/evaluator.d.ts +23 -0
  97. package/dist/providers/spec/fsm-driver.d.ts +273 -0
  98. package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
  99. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  100. package/dist/providers/spec/fsm-types.d.ts +91 -0
  101. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  102. package/dist/providers/spec/route.d.ts +4 -0
  103. package/dist/providers/spec/types.d.ts +129 -0
  104. package/dist/providers/transcript-v2.d.ts +176 -0
  105. package/dist/providers/types/interactive-prompt.d.ts +48 -0
  106. package/dist/repo-mesh-types.d.ts +126 -1
  107. package/dist/sessions/registry.d.ts +6 -0
  108. package/dist/shared-types-extra.d.ts +2 -4
  109. package/dist/shared-types.d.ts +42 -1
  110. package/dist/status/normalize.d.ts +1 -1
  111. package/dist/status/normalize.js +1 -0
  112. package/dist/status/normalize.js.map +1 -1
  113. package/dist/status/normalize.mjs +1 -0
  114. package/dist/status/normalize.mjs.map +1 -1
  115. package/dist/status/reporter.d.ts +2 -0
  116. package/dist/status/snapshot.d.ts +1 -0
  117. package/dist/types.d.ts +5 -0
  118. package/package.json +6 -3
  119. package/src/agent-stream/poller.ts +2 -3
  120. package/src/boot/daemon-lifecycle.ts +30 -10
  121. package/src/boot/process-hardening.ts +89 -0
  122. package/src/chat/source-machine.ts +534 -0
  123. package/src/chat/source-resolver.ts +0 -0
  124. package/src/chat/subscription-updates.ts +20 -1
  125. package/src/cli-adapter-types.d.ts +1 -0
  126. package/src/cli-adapter-types.ts +22 -2
  127. package/src/cli-adapters/cli-script-runner.ts +421 -0
  128. package/src/cli-adapters/cli-state-engine.ts +1215 -0
  129. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  130. package/src/cli-adapters/provider-cli-adapter.ts +674 -1139
  131. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  132. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  133. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  134. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  135. package/src/cli-adapters/provider-cli-shared.ts +68 -11
  136. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  137. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +35 -29
  138. package/src/cli-adapters/terminal-backends/types.ts +1 -3
  139. package/src/cli-adapters/terminal-screen.ts +11 -81
  140. package/src/commands/chat-commands.ts +1806 -60
  141. package/src/commands/cli-manager.ts +286 -14
  142. package/src/commands/handler.ts +809 -2
  143. package/src/commands/mesh-coordinator.ts +332 -122
  144. package/src/commands/router.ts +4890 -489
  145. package/src/config/chat-history.ts +95 -24
  146. package/src/config/config.ts +12 -0
  147. package/src/config/mesh-config.ts +303 -3
  148. package/src/config/recent-activity.ts +8 -2
  149. package/src/daemon/dev-auto-implement.ts +3 -2
  150. package/src/daemon/dev-cli-debug.ts +10 -1
  151. package/src/detection/ide-detector.ts +26 -16
  152. package/src/git/git-commands.ts +42 -12
  153. package/src/git/git-diff.ts +53 -0
  154. package/src/git/git-status.ts +35 -6
  155. package/src/git/git-types.ts +2 -0
  156. package/src/git/git-worktree.ts +8 -1
  157. package/src/index.ts +127 -10
  158. package/src/installer.d.ts +1 -1
  159. package/src/installer.ts +8 -6
  160. package/src/ipc/local-ipc-server.ts +278 -0
  161. package/src/launch.d.ts +1 -1
  162. package/src/launch.ts +37 -28
  163. package/src/logging/async-batch-writer.ts +55 -0
  164. package/src/logging/logger.ts +2 -1
  165. package/src/mesh/contracts.ts +329 -0
  166. package/src/mesh/coordinator-prompt.ts +219 -31
  167. package/src/mesh/coordinator-registry.ts +121 -0
  168. package/src/mesh/mesh-active-work.ts +437 -0
  169. package/src/mesh/mesh-delivery-policy.ts +315 -0
  170. package/src/mesh/mesh-events-coordinator.ts +1625 -0
  171. package/src/mesh/mesh-events-pending.ts +439 -0
  172. package/src/mesh/mesh-events-stale.ts +283 -0
  173. package/src/mesh/mesh-events-utils.ts +195 -0
  174. package/src/mesh/mesh-events.ts +26 -1035
  175. package/src/mesh/mesh-fast-forward.ts +438 -0
  176. package/src/mesh/mesh-host-ownership.ts +73 -0
  177. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  178. package/src/mesh/mesh-ledger.ts +588 -102
  179. package/src/mesh/mesh-missions.ts +151 -0
  180. package/src/mesh/mesh-refine-batch.ts +197 -0
  181. package/src/mesh/mesh-refine-status.ts +231 -0
  182. package/src/mesh/mesh-review-inbox.ts +307 -0
  183. package/src/mesh/mesh-routing.ts +272 -0
  184. package/src/mesh/mesh-runtime-store.ts +1342 -0
  185. package/src/mesh/mesh-task-stats.ts +154 -0
  186. package/src/mesh/mesh-work-queue.ts +573 -145
  187. package/src/mesh/preview-freshness.ts +118 -0
  188. package/src/mesh/refine-config.ts +403 -0
  189. package/src/mesh/worktree-bootstrap-config.ts +326 -0
  190. package/src/providers/acp-provider-instance.ts +15 -1
  191. package/src/providers/approval-utils.d.ts +4 -0
  192. package/src/providers/approval-utils.ts +47 -5
  193. package/src/providers/chat-message-normalization.ts +4 -11
  194. package/src/providers/cli-provider-instance.ts +806 -72
  195. package/src/providers/contracts.d.ts +55 -0
  196. package/src/providers/contracts.ts +141 -6
  197. package/src/providers/external-sources.ts +218 -0
  198. package/src/providers/ide-provider-instance.ts +19 -5
  199. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  200. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  201. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  202. package/src/providers/native-history/dispatcher.ts +340 -0
  203. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  204. package/src/providers/native-history/index.ts +30 -0
  205. package/src/providers/provider-instance-manager.ts +30 -0
  206. package/src/providers/provider-instance.ts +10 -1
  207. package/src/providers/provider-loader.ts +584 -50
  208. package/src/providers/provider-schema.ts +87 -14
  209. package/src/providers/provider-trust.ts +114 -0
  210. package/src/providers/read-chat-contract.ts +76 -16
  211. package/src/providers/sdk/README.md +49 -0
  212. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  213. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  214. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  215. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  216. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  217. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  218. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  219. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  220. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  221. package/src/providers/sdk/v1/index.ts +152 -0
  222. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  223. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  224. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  225. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  226. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  227. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  228. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  229. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  230. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  231. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  232. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  233. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  234. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  235. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  236. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  237. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  238. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  239. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  240. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  241. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  242. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  243. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  244. package/src/providers/sdk/v1/validators/index.ts +19 -0
  245. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  246. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  247. package/src/providers/spec/adapter.ts +145 -0
  248. package/src/providers/spec/cli-adapter.ts +730 -0
  249. package/src/providers/spec/evaluator.ts +321 -0
  250. package/src/providers/spec/fsm-driver.ts +811 -0
  251. package/src/providers/spec/fsm-evaluator.ts +255 -0
  252. package/src/providers/spec/fsm-loader.ts +102 -0
  253. package/src/providers/spec/fsm-types.ts +184 -0
  254. package/src/providers/spec/native-history-executor.ts +943 -0
  255. package/src/providers/spec/route.ts +51 -0
  256. package/src/providers/spec/types.ts +173 -0
  257. package/src/providers/transcript-v2.ts +567 -0
  258. package/src/providers/types/interactive-prompt.ts +425 -0
  259. package/src/providers/version-archive.ts +38 -20
  260. package/src/repo-mesh-types.ts +138 -1
  261. package/src/sessions/registry.ts +6 -0
  262. package/src/shared-types-extra.ts +2 -4
  263. package/src/shared-types.ts +45 -1
  264. package/src/status/builders.ts +26 -6
  265. package/src/status/normalize.ts +2 -0
  266. package/src/status/reporter.ts +15 -0
  267. package/src/status/snapshot.ts +84 -25
  268. package/src/system/host-memory.ts +29 -12
  269. package/src/types.ts +5 -0
  270. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  271. package/dist/mesh/mesh-sync.d.ts +0 -53
  272. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  273. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  274. package/src/mesh/mesh-sync.ts +0 -111
@@ -13,15 +13,17 @@ import { createRequire } from 'node:module';
13
13
  import { normalizeInputEnvelope, type ProviderModule, flattenContent, type InputEnvelope, type InputPart } from './contracts.js';
14
14
  import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport } from './provider-input-support.js';
15
15
  import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext, ProviderErrorReason, HotChatSessionState, SessionModalState } from './provider-instance.js';
16
+ import { normalizeInteractivePrompt, normalizeInteractivePromptResponse, type InteractivePrompt } from './types/interactive-prompt.js';
16
17
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
17
18
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
19
+ import { createCliAdapter } from './spec/route.js';
18
20
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
19
21
  import { StatusMonitor } from './status-monitor.js';
20
22
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
21
23
  import { LOG } from '../logging/logger.js';
22
24
  import type { ChatMessage } from '../types.js';
23
25
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
24
- import { formatAutoApprovalMessage, pickApprovalButton } from './approval-utils.js';
26
+ import { formatAutoApprovalMessage, pickApprovalButton, pickAutoApprovalButton, looksLikeActiveApprovalPromptText } from './approval-utils.js';
25
27
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
26
28
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
27
29
  import { normalizeProviderSessionId } from './provider-session-id.js';
@@ -40,12 +42,60 @@ type CompletedDebouncePending = {
40
42
  duration: number;
41
43
  timestamp: number;
42
44
  firstObservedAt: number;
45
+ previousStatus: string;
43
46
  loggedBlockReason?: string;
47
+ loggedTranscriptProbe?: boolean;
48
+ transcriptProbeHistory?: ExternalTranscriptProbe[];
49
+ };
50
+
51
+ function isIdleStatus(value: unknown): boolean {
52
+ const status = typeof value === 'string' ? value.trim().toLowerCase() : '';
53
+ return !status || status === 'idle' || status === 'ready';
54
+ }
55
+
56
+
57
+ function getMessageTime(message: unknown): number {
58
+ if (!message || typeof message !== 'object') return 0;
59
+ const record = message as { receivedAt?: unknown; timestamp?: unknown };
60
+ const value = Number(record.receivedAt ?? record.timestamp ?? 0);
61
+ return Number.isFinite(value) ? value : 0;
62
+ }
63
+
64
+ type CompletedFinalizationBlock = {
65
+ reason: string;
66
+ terminal?: boolean;
67
+ allowTimeout?: boolean;
68
+ };
69
+
70
+ type CompletionFinalAssistantEvidence = {
71
+ present: boolean;
72
+ messages: unknown[];
73
+ source: 'parsed' | 'external-native' | 'unavailable';
74
+ };
75
+
76
+ type ExternalTranscriptProbe = {
77
+ readAt: number;
78
+ msgCount: number;
79
+ lastRole: string | null;
80
+ lastKind: string | null;
81
+ contentLen: number;
82
+ sourcePath: string | null;
83
+ sourceMtimeMs: number | null;
84
+ mtimeAgeMs: number | null;
44
85
  };
45
86
 
46
87
  const COMPLETED_FINALIZATION_RETRY_MS = 1000;
47
88
  const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
48
89
 
90
+ /** Events that signal a dispatched mesh task has reached a terminal state.
91
+ * Detach the mesh assignment after emitting one of these so the worker's
92
+ * next unrelated turn doesn't impersonate another completion. */
93
+ const TERMINAL_MESH_EVENTS = new Set([
94
+ 'agent:generating_completed',
95
+ 'agent:stopped',
96
+ 'agent:ready',
97
+ ]);
98
+
49
99
  const IMAGE_MIME_EXTENSIONS: Record<string, string> = {
50
100
  'image/png': '.png',
51
101
  'image/jpeg': '.jpg',
@@ -309,15 +359,17 @@ export class CliProviderInstance implements ProviderInstance {
309
359
  private monitor: StatusMonitor;
310
360
  private generatingDebounceTimer: NodeJS.Timeout | null = null;
311
361
  private generatingDebouncePending: { chatTitle: string; timestamp: number } | null = null;
312
- private lastApprovalEventAt = 0;
362
+ private lastApprovalEventFingerprint = '';
313
363
  private autoApproveBusy = false;
314
364
  private autoApproveBusyTimer: NodeJS.Timeout | null = null;
365
+ private lastAutoApprovalSignature = '';
315
366
  private controlValues: Record<string, string | number | boolean> = {};
316
367
  private summaryMetadata: unknown = undefined;
317
368
  private appliedEffectKeys = new Set<string>();
318
369
  private historyWriter: ChatHistoryWriter;
319
370
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
320
371
  private lastPersistedHistoryMessages: PersistableCliHistoryMessage[] = [];
372
+ private lastAcknowledgedUserInputAt = 0;
321
373
  private lastNativeSourceCanonicalCheckAt = 0;
322
374
  private lastNativeSourceCanonicalCacheKey: string | undefined = undefined;
323
375
  private cachedSqliteDb: {
@@ -330,6 +382,7 @@ export class CliProviderInstance implements ProviderInstance {
330
382
  private suppressIdleHistoryReplay = false;
331
383
  private errorMessage: string | undefined = undefined;
332
384
  private errorReason: ProviderErrorReason | undefined = undefined;
385
+ private activeInteractivePrompt: InteractivePrompt | null = null;
333
386
 
334
387
  private presentationMode: 'terminal' | 'chat';
335
388
  private providerSessionId?: string;
@@ -370,7 +423,10 @@ export class CliProviderInstance implements ProviderInstance {
370
423
  this.providerSessionId = options?.providerSessionId;
371
424
  this.launchMode = options?.launchMode || 'new';
372
425
  this.onProviderSessionResolved = options?.onProviderSessionResolved;
373
- this.adapter = new ProviderCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory);
426
+ this.adapter = createCliAdapter(provider as CliProviderModule, workingDir, cliArgs, options?.extraEnv || {}, transportFactory) as ProviderCliAdapter;
427
+ if (this.providerSessionId) {
428
+ this.adapter.updateRuntimeMeta({ providerSessionId: this.providerSessionId });
429
+ }
374
430
  this.monitor = new StatusMonitor();
375
431
  this.historyWriter = new ChatHistoryWriter();
376
432
  }
@@ -412,7 +468,7 @@ export class CliProviderInstance implements ProviderInstance {
412
468
  await this.adapter.spawn();
413
469
  await this.enforceFreshSessionLaunchIfNeeded();
414
470
  this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
415
- if (this.providerSessionId) {
471
+ if (this.providerSessionId && this.shouldHydrateExistingProviderHistory()) {
416
472
  this.restorePersistedHistoryFromCurrentSession();
417
473
  }
418
474
  if (this.providerSessionId && this.launchMode === 'resume') {
@@ -486,14 +542,38 @@ export class CliProviderInstance implements ProviderInstance {
486
542
  }
487
543
 
488
544
  getState(): ProviderState {
545
+ // TODO(phase5-sandbox): JS override scripts (detectStatus, parseApproval,
546
+ // parseSession) are currently invoked by CliScriptRunner.invoke() via direct
547
+ // function calls — the scripts run in the daemon process with full Node.js
548
+ // access and no resource limits.
549
+ //
550
+ // When Phase 5 lands, CliScriptRunner should route these calls through a
551
+ // SandboxedScriptRunner (see providers/sdk/v1/sandbox/script-runner.ts) so
552
+ // that each call gets a fresh isolated-vm context with a 50 ms CPU limit and
553
+ // a 32 MB memory cap. The execution path to change is:
554
+ // CliScriptRunner.invoke() → SandboxedScriptRunner.run(scriptSource, context)
555
+ //
556
+ // This getState() call-site is NOT where the change goes — the wiring belongs
557
+ // in cli-script-runner.ts (CliScriptRunner.detectStatus / parseApproval /
558
+ // parseSession), with provider-loader.ts updated to store script source strings
559
+ // alongside the loaded function references for extended-legacy providers.
489
560
  const adapterStatus = this.adapter.getStatus();
561
+ if (Object.prototype.hasOwnProperty.call(adapterStatus, 'activeInteractivePrompt')) {
562
+ this.activeInteractivePrompt = adapterStatus.activeInteractivePrompt ?? null;
563
+ }
490
564
  let parsedStatus: any = null;
491
565
  let parseErrorMessage: string | undefined;
492
566
  if (typeof this.adapter.getScriptParsedStatus === 'function') {
493
567
  try {
494
568
  parsedStatus = this.adapter.getScriptParsedStatus() || null;
495
- this.errorMessage = undefined;
496
- this.errorReason = undefined;
569
+ const parsedErrorMessage = typeof parsedStatus?.errorMessage === 'string' && parsedStatus.errorMessage.trim()
570
+ ? parsedStatus.errorMessage.trim()
571
+ : undefined;
572
+ const parsedErrorReason = typeof parsedStatus?.errorReason === 'string' && parsedStatus.errorReason.trim()
573
+ ? parsedStatus.errorReason.trim() as ProviderErrorReason
574
+ : undefined;
575
+ this.errorMessage = parsedErrorMessage;
576
+ this.errorReason = parsedErrorReason;
497
577
  } catch (error: any) {
498
578
  parseErrorMessage = error?.message || String(error);
499
579
  this.errorMessage = parseErrorMessage;
@@ -503,22 +583,53 @@ export class CliProviderInstance implements ProviderInstance {
503
583
  this.errorMessage = undefined;
504
584
  this.errorReason = undefined;
505
585
  }
506
- const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
507
- const visibleStatus = parseErrorMessage
508
- ? 'error'
509
- : (autoApproveActive ? 'generating' : adapterStatus.status);
510
- const parsedProviderSessionId = normalizeProviderSessionId(
586
+ const adapterProviderSessionId = normalizeProviderSessionId(
511
587
  this.provider,
512
- typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
588
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
513
589
  );
514
- if (parsedProviderSessionId) {
515
- this.promoteProviderSessionId(parsedProviderSessionId);
590
+ const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, Date.now());
591
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
592
+ let visibleStatus = parseErrorMessage || parsedStatus?.status === 'error'
593
+ ? 'error'
594
+ : (autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status);
595
+ // getState() must agree with the status the FSM-driven detectStatusTransition()
596
+ // already committed to lastStatus. The adapter's own status is authoritative; we do
597
+ // not second-guess it with native-transcript shape. Only reconcile a generating-like
598
+ // read down to idle when our own lastStatus has already flipped idle (avoids a
599
+ // perpetual dashboard spinner during the brief window before the next getStatus()).
600
+ if (isCliGeneratingLikeStatus(visibleStatus) && this.lastStatus === 'idle') {
601
+ visibleStatus = 'idle';
516
602
  }
517
603
  const runtime = this.adapter.getRuntimeMetadata();
518
604
  this.maybeAppendRuntimeRecoveryMessage(runtime);
519
605
  let parsedMessages = Array.isArray(parsedStatus?.messages)
520
606
  ? parsedStatus.messages
521
607
  : [];
608
+ const parsedProviderSessionId = normalizeProviderSessionId(
609
+ this.provider,
610
+ typeof parsedStatus?.providerSessionId === 'string' ? parsedStatus.providerSessionId : '',
611
+ );
612
+ const suppressFreshLaunchStartupReplay = this.shouldSuppressFreshLaunchStartupReplay(
613
+ parsedMessages,
614
+ parsedStatus,
615
+ adapterStatus,
616
+ parsedProviderSessionId,
617
+ );
618
+ if (adapterProviderSessionId && !suppressFreshLaunchStartupReplay) {
619
+ this.promoteProviderSessionId(adapterProviderSessionId);
620
+ }
621
+ if (parsedProviderSessionId && !suppressFreshLaunchStartupReplay) {
622
+ this.promoteProviderSessionId(parsedProviderSessionId);
623
+ }
624
+ if (suppressFreshLaunchStartupReplay) {
625
+ parsedMessages = [];
626
+ }
627
+ // Adapter runtime metadata is transport-owned and is not guaranteed to
628
+ // identify this conversation. Spec adapters historically exposed the
629
+ // provider spec id (for example "codex-cli") as runtimeId, which made
630
+ // concurrent sessions share one activeChat identity until their native
631
+ // provider session ids were discovered.
632
+ const activeChatId = this.providerSessionId || this.instanceId;
522
633
  const historyMessageCount = Number.isFinite(parsedStatus?.historyMessageCount)
523
634
  ? Math.max(0, Number(parsedStatus.historyMessageCount))
524
635
  : null;
@@ -528,7 +639,18 @@ export class CliProviderInstance implements ProviderInstance {
528
639
  : [];
529
640
  }
530
641
  const mergedMessages = this.mergeConversationMessages(parsedMessages);
531
- const canonicalBackedHistory = this.syncCanonicalSavedHistoryIfNeeded();
642
+ const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
643
+ ? this.syncCanonicalSavedHistoryIfNeeded()
644
+ : false;
645
+ const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
646
+ ? this.lastPersistedHistoryMessages.map((message) => ({
647
+ role: message.role,
648
+ content: message.content,
649
+ kind: message.kind,
650
+ senderName: message.senderName,
651
+ receivedAt: message.receivedAt,
652
+ }))
653
+ : mergedMessages;
532
654
 
533
655
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
534
656
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
@@ -572,33 +694,48 @@ export class CliProviderInstance implements ProviderInstance {
572
694
  }
573
695
  }
574
696
 
575
- this.applyProviderResponse(parsedStatus, { phase: 'immediate' });
697
+ this.applyProviderResponse(
698
+ suppressFreshLaunchStartupReplay && parsedStatus && typeof parsedStatus === 'object'
699
+ ? { ...parsedStatus, providerSessionId: undefined }
700
+ : parsedStatus,
701
+ { phase: 'immediate' },
702
+ );
576
703
  const surface = resolveProviderStateSurface({
577
704
  summaryMetadata: this.summaryMetadata as any,
578
705
  controlValues: this.controlValues,
579
706
  });
580
707
  const activeChatStatus = parseErrorMessage
581
708
  ? 'error'
582
- : autoApproveActive && parsedStatus?.status === 'waiting_approval'
709
+ : (autoApproveActive && parsedStatus?.status === 'waiting_approval') || autoApproveHoldIdle
583
710
  ? 'generating'
584
711
  : (adapterStatus.status !== 'idle'
585
712
  ? visibleStatus
586
713
  : (suppressStaleParsedBusyStatus ? visibleStatus : (parsedChatStatus || visibleStatus)));
587
714
 
715
+ // If an AskUserQuestion prompt is awaiting user input, overlay status as
716
+ // waiting_choice. This is distinct from waiting_approval (tool-use consent)
717
+ // — the engine's isWaitingForResponse state is unchanged, so completion
718
+ // tracking continues normally once the user responds.
719
+ const hasInteractivePrompt = !!this.activeInteractivePrompt;
720
+ const finalStatus = hasInteractivePrompt ? 'waiting_choice' : visibleStatus;
721
+ const finalChatStatus = hasInteractivePrompt ? 'waiting_choice' : activeChatStatus;
722
+
588
723
  return {
589
724
  type: this.type,
590
725
  name: this.provider.name,
591
726
  category: 'cli',
592
- status: visibleStatus,
727
+ status: finalStatus,
593
728
  mode: this.presentationMode,
594
729
  activeChat: {
595
- id: `${this.type}_${this.workingDir}`,
730
+ id: activeChatId,
596
731
  title: parsedStatus?.title || dirName,
597
- status: activeChatStatus,
598
- messages: mergedMessages,
599
- activeModal: autoApproveActive ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
732
+ status: finalChatStatus,
733
+ messages: statusMessages,
734
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : (parsedStatus?.activeModal ?? adapterStatus.activeModal),
735
+ activeInteractivePrompt: this.activeInteractivePrompt,
600
736
  inputContent: '',
601
737
  },
738
+ activeInteractivePrompt: this.activeInteractivePrompt,
602
739
  workspace: this.workingDir,
603
740
  instanceId: this.instanceId,
604
741
  providerSessionId: this.providerSessionId,
@@ -639,7 +776,8 @@ export class CliProviderInstance implements ProviderInstance {
639
776
  getHotChatSessionState(): HotChatSessionState {
640
777
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
641
778
  const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
642
- const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
779
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
780
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
643
781
  const runtime = this.adapter.getRuntimeMetadata();
644
782
  return {
645
783
  id: this.instanceId,
@@ -651,21 +789,41 @@ export class CliProviderInstance implements ProviderInstance {
651
789
  };
652
790
  }
653
791
 
654
- getSessionModalState(): SessionModalState {
655
- const adapterStatus = this.adapter.getStatus({ allowParse: false });
792
+ getSessionModalState(sessionId?: string): SessionModalState {
793
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
656
794
  const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
657
- const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
795
+ const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
796
+ const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
658
797
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
659
798
  return {
660
- id: this.instanceId,
799
+ // Honor the caller-supplied sessionId — InstanceMgr rejects the
800
+ // projection when projected.id !== requested sessionId, and
801
+ // this.instanceId is the manager's internal key, not the public
802
+ // sessionId the dashboard subscribes by.
803
+ id: sessionId ?? this.instanceId,
661
804
  status: visibleStatus,
662
805
  title: dirName,
663
- activeModal: autoApproveActive ? null : adapterStatus.activeModal,
806
+ activeModal: (autoApproveActive || autoApproveHoldIdle) ? null : adapterStatus.activeModal,
664
807
  };
665
808
  }
666
809
 
667
810
  updateSettings(newSettings: Record<string, any>): void {
668
- this.settings = { ...newSettings };
811
+ const runtimeMeshSettings: Record<string, any> = {};
812
+ for (const key of [
813
+ 'meshNodeFor',
814
+ 'meshNodeId',
815
+ 'meshActiveTaskId',
816
+ 'meshCoordinatorFor',
817
+ 'meshCoordinatorDaemonId',
818
+ 'meshCoordinatorNodeId',
819
+ 'spawnedSessionVisibility',
820
+ 'launchedByCoordinator',
821
+ ]) {
822
+ if (this.settings[key] !== undefined && newSettings[key] === undefined) {
823
+ runtimeMeshSettings[key] = this.settings[key];
824
+ }
825
+ }
826
+ this.settings = { ...newSettings, ...runtimeMeshSettings };
669
827
  this.adapter.updateRuntimeSettings?.(this.settings);
670
828
  this.monitor.updateConfig({
671
829
  approvalAlert: this.settings.approvalAlert !== false,
@@ -674,13 +832,53 @@ export class CliProviderInstance implements ProviderInstance {
674
832
  });
675
833
  }
676
834
 
835
+ /**
836
+ * Stamp a direct-dispatch mesh assignment on this instance.
837
+ * setupMeshEventForwarding reads settings.meshNodeFor + meshActiveTaskId to
838
+ * route generating_completed back to the originating coordinator. Without
839
+ * this stamp, mesh_send_task --direct targets a plain CLI session whose
840
+ * completion events silently drop because the forwarder has nothing to
841
+ * match against.
842
+ */
843
+ attachMeshAssignment(assignment: { meshId: string; nodeId?: string; taskId?: string; coordinatorDaemonId?: string }): void {
844
+ if (!assignment?.meshId) return;
845
+ this.settings = {
846
+ ...this.settings,
847
+ meshNodeFor: assignment.meshId,
848
+ ...(assignment.nodeId ? { meshNodeId: assignment.nodeId } : {}),
849
+ ...(assignment.taskId ? { meshActiveTaskId: assignment.taskId } : {}),
850
+ ...(assignment.coordinatorDaemonId ? { meshCoordinatorDaemonId: assignment.coordinatorDaemonId } : {}),
851
+ };
852
+ this.adapter.updateRuntimeSettings?.(this.settings);
853
+ }
854
+
855
+ /**
856
+ * Clear a previously-attached mesh assignment after the task reaches a
857
+ * terminal state. Leaving meshNodeFor pinned would route this session's
858
+ * subsequent unrelated turns (e.g. ad-hoc dashboard chats) to the
859
+ * coordinator as if they were task completions.
860
+ */
861
+ detachMeshAssignment(): void {
862
+ if (!this.settings.meshNodeFor && !this.settings.meshActiveTaskId && !this.settings.meshNodeId) return;
863
+ const { meshNodeFor, meshNodeId, meshActiveTaskId, ...rest } = this.settings;
864
+ void meshNodeFor; void meshNodeId; void meshActiveTaskId;
865
+ this.settings = rest;
866
+ this.adapter.updateRuntimeSettings?.(this.settings);
867
+ }
868
+
677
869
  onEvent(event: string, data?: any): void {
678
870
  if (event === 'send_message') {
679
871
  const input = normalizeInputEnvelope(data);
680
872
  assertProviderSupportsDeclaredInput(this.provider, input);
681
873
  const promptText = buildCliStructuredInputPrompt(input);
682
874
  if (promptText) {
683
- void this.adapter.sendMessage(promptText).catch((e: any) => {
875
+ // force:true bypasses the busy/generating send guard so terminal mesh
876
+ // events (completion/failure/bootstrap) land in a coordinator session that
877
+ // is itself parked in `generating` while awaiting that very event.
878
+ // Without it the message is queued and only flushed on the coordinator's
879
+ // own idle transition — which never happens until it receives the message.
880
+ const force = data?.force === true;
881
+ void this.adapter.sendMessage(promptText, force ? { force: true } : {}).catch((e: any) => {
684
882
  LOG.warn('CLI', `[${this.type}] send_message failed: ${e?.message || e}`);
685
883
  });
686
884
  }
@@ -690,11 +888,66 @@ export class CliProviderInstance implements ProviderInstance {
690
888
  void this.adapter.resolveAction(data).catch((e: any) => {
691
889
  LOG.warn('CLI', `[${this.type}] resolve_action failed: ${e?.message || e}`);
692
890
  });
891
+ } else if (event === 'interactive_prompt' && data) {
892
+ const prompt = normalizeInteractivePrompt(data);
893
+ if (prompt) {
894
+ this.activeInteractivePrompt = prompt;
895
+ this.events.push({
896
+ event: 'interactive_prompt',
897
+ timestamp: Date.now(),
898
+ promptId: prompt.promptId,
899
+ });
900
+ }
901
+ } else if (event === 'interactive_prompt_response' && data) {
902
+ try {
903
+ const response = normalizeInteractivePromptResponse(data);
904
+ if (this.activeInteractivePrompt?.promptId === response.promptId) {
905
+ this.activeInteractivePrompt = null;
906
+ }
907
+ if (typeof this.adapter.setInteractivePromptResponse !== 'function') {
908
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response ignored: adapter does not support interactive prompts`);
909
+ return;
910
+ }
911
+ void this.adapter.setInteractivePromptResponse(response).catch((e: any) => {
912
+ LOG.warn('CLI', `[${this.type}] interactive_prompt_response failed: ${e?.message || e}`);
913
+ });
914
+ } catch (e: any) {
915
+ LOG.warn('CLI', `[${this.type}] invalid interactive_prompt_response: ${e?.message || e}`);
916
+ }
693
917
  } else if (event === 'provider_state_patch' && data && typeof data === 'object') {
694
918
  this.applyProviderResponse(data, { phase: 'immediate' });
695
919
  }
696
920
  }
697
921
 
922
+ recordAcknowledgedUserInput(input: InputEnvelope | string): void {
923
+ const content = typeof input === 'string'
924
+ ? input.trim()
925
+ : buildCliStructuredInputPrompt(input).trim();
926
+ if (!content) return;
927
+
928
+ const receivedAt = Date.now();
929
+ this.lastAcknowledgedUserInputAt = receivedAt;
930
+ const dedupKey = `user_input_ack:${crypto
931
+ .createHash('sha256')
932
+ .update(`${this.instanceId}:${content}:${receivedAt}`)
933
+ .digest('hex')
934
+ .slice(0, 24)}`;
935
+ this.appendRuntimeMessage(buildChatMessage({
936
+ role: 'user',
937
+ senderName: 'User',
938
+ kind: 'standard',
939
+ content,
940
+ receivedAt,
941
+ timestamp: receivedAt,
942
+ source: 'runtime_input_ack',
943
+ meta: {
944
+ runtimeInputAck: true,
945
+ provider: this.type,
946
+ workspace: this.workingDir,
947
+ },
948
+ } as ChatMessage), dedupKey);
949
+ }
950
+
698
951
  dispose(): void {
699
952
  this.adapter.shutdown();
700
953
  this.monitor.reset();
@@ -706,6 +959,7 @@ export class CliProviderInstance implements ProviderInstance {
706
959
 
707
960
  private completedDebounceTimer: NodeJS.Timeout | null = null;
708
961
  private completedDebouncePending: CompletedDebouncePending | null = null;
962
+ private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
709
963
 
710
964
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
711
965
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -740,7 +994,180 @@ export class CliProviderInstance implements ProviderInstance {
740
994
  const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
741
995
  const role = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : '';
742
996
  const content = lastVisible ? flattenContent(lastVisible.content).trim() : '';
743
- return role === 'assistant' && !!content;
997
+ if (role !== 'assistant' || !content) return false;
998
+ // Guard: if the last assistant message looks like an active approval/input prompt,
999
+ // it is not a real completion — the session is still awaiting user input.
1000
+ if (looksLikeActiveApprovalPromptText(content)) return false;
1001
+ return true;
1002
+ }
1003
+
1004
+ private buildExternalTranscriptProbe(messages: unknown[], sourcePath?: string, sourceMtimeMs?: number): ExternalTranscriptProbe {
1005
+ const visibleMessages = messages.filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1006
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1007
+ const readAt = Date.now();
1008
+ const mtimeMs = Number(sourceMtimeMs) || 0;
1009
+ return {
1010
+ readAt,
1011
+ msgCount: messages.length,
1012
+ lastRole: typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null,
1013
+ lastKind: typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null,
1014
+ contentLen: lastVisible ? flattenContent(lastVisible.content).trim().length : 0,
1015
+ sourcePath: typeof sourcePath === 'string' && sourcePath ? sourcePath : null,
1016
+ sourceMtimeMs: mtimeMs || null,
1017
+ mtimeAgeMs: mtimeMs ? Math.max(0, readAt - mtimeMs) : null,
1018
+ };
1019
+ }
1020
+
1021
+ private recordPendingTranscriptProbe(pending: CompletedDebouncePending): ExternalTranscriptProbe | null {
1022
+ const probe = this.lastExternalCompletionProbe;
1023
+ if (!probe) return null;
1024
+ const history = pending.transcriptProbeHistory || [];
1025
+ const last = history[history.length - 1];
1026
+ if (!last || last.readAt !== probe.readAt || last.msgCount !== probe.msgCount || last.lastRole !== probe.lastRole || last.contentLen !== probe.contentLen) {
1027
+ history.push(probe);
1028
+ pending.transcriptProbeHistory = history.slice(-5);
1029
+ }
1030
+ return probe;
1031
+ }
1032
+
1033
+ private readExternalCompletionMessages(): unknown[] | null {
1034
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1035
+ if (!adapterOwnsMessagesElsewhere) return null;
1036
+ if (!this.providerSessionId) return null;
1037
+ if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
1038
+
1039
+ if (this.lastExternalCompletionProbe?.sourcePath) {
1040
+ try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
1041
+ }
1042
+ const restoredHistory = readProviderChatHistory(this.type, {
1043
+ canonicalHistory: this.provider.nativeHistory,
1044
+ historySessionId: this.providerSessionId,
1045
+ workspace: this.workingDir,
1046
+ offset: 0,
1047
+ limit: Number.MAX_SAFE_INTEGER,
1048
+ historyBehavior: this.provider.historyBehavior,
1049
+ scripts: this.provider.scripts as any,
1050
+ sessionStartedAtMs: this.startedAt,
1051
+ forceRefresh: true,
1052
+ });
1053
+ if (restoredHistory.source !== 'provider-native') {
1054
+ this.lastExternalCompletionProbe = null;
1055
+ return null;
1056
+ }
1057
+ this.lastExternalCompletionProbe = this.buildExternalTranscriptProbe(
1058
+ restoredHistory.messages,
1059
+ restoredHistory.sourcePath,
1060
+ restoredHistory.sourceMtimeMs,
1061
+ );
1062
+ return restoredHistory.messages;
1063
+ }
1064
+
1065
+ private completionFinalAssistantEvidence(parsedMessages: unknown): CompletionFinalAssistantEvidence {
1066
+ if (this.completionHasFinalAssistantMessage(parsedMessages)) {
1067
+ return {
1068
+ present: true,
1069
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1070
+ source: 'parsed',
1071
+ };
1072
+ }
1073
+
1074
+ const externalMessages = this.readExternalCompletionMessages();
1075
+ if (externalMessages) {
1076
+ return {
1077
+ present: this.completionHasFinalAssistantMessage(externalMessages),
1078
+ messages: externalMessages,
1079
+ source: 'external-native',
1080
+ };
1081
+ }
1082
+
1083
+ return {
1084
+ present: false,
1085
+ messages: Array.isArray(parsedMessages) ? parsedMessages : [],
1086
+ source: 'unavailable',
1087
+ };
1088
+ }
1089
+
1090
+ private completionFinalSummary(parsedMessages: unknown): string | undefined {
1091
+ // For native-source providers (claude-cli: chatMessagesOwnedExternally), the PTY
1092
+ // screen parse is NOT the source of truth for the final summary — the terminal
1093
+ // wraps/scrolls/clips text, so a screen-parsed assistant message is often a partial
1094
+ // prefix (e.g. 76 chars of a 112-char turn). The append-only native transcript holds
1095
+ // the complete turn. Prefer it whenever it yields a longer/complete summary; fall back
1096
+ // to the parsed screen only when the transcript is unavailable. This is the real cause
1097
+ // of the truncated finalSummary — independent of cloud vs standalone (it surfaces on
1098
+ // any short, fast-completing task where screen parse wins the race).
1099
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1100
+ const parsedSummary = extractFinalSummaryFromMessages(
1101
+ (this.completionHasFinalAssistantMessage(parsedMessages)
1102
+ ? (Array.isArray(parsedMessages) ? parsedMessages : [])
1103
+ : []) as any,
1104
+ );
1105
+ if (adapterOwnsMessagesElsewhere) {
1106
+ const externalMessages = this.readExternalCompletionMessages();
1107
+ const externalSummary = externalMessages
1108
+ ? extractFinalSummaryFromMessages(externalMessages as any)
1109
+ : '';
1110
+ // The transcript is authoritative for native-source providers. Use it unless it is
1111
+ // empty (not yet written) — only then fall back to whatever the screen parsed.
1112
+ if (externalSummary) return externalSummary;
1113
+ return parsedSummary || undefined;
1114
+ }
1115
+ return parsedSummary || undefined;
1116
+ }
1117
+
1118
+ private buildCompletedFinalizationDiagnostic(args: {
1119
+ blockReason: string;
1120
+ latestStatus?: any;
1121
+ latestVisibleStatus: string;
1122
+ waitedMs: number;
1123
+ pending: CompletedDebouncePending;
1124
+ emittedAfterFinalizationTimeout: boolean;
1125
+ }): Record<string, unknown> {
1126
+ let parsed: any = null;
1127
+ let parseError: string | undefined;
1128
+ try {
1129
+ parsed = this.adapter.getScriptParsedStatus();
1130
+ } catch (error: any) {
1131
+ parseError = error?.message || String(error);
1132
+ }
1133
+
1134
+ const evidence = this.completionFinalAssistantEvidence(parsed?.messages);
1135
+ if (evidence.source === 'external-native') {
1136
+ this.recordPendingTranscriptProbe(args.pending);
1137
+ }
1138
+ const visibleMessages = (Array.isArray(evidence.messages) ? evidence.messages : [])
1139
+ .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1140
+ const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
1141
+ const lastVisibleRole = typeof lastVisible?.role === 'string' ? lastVisible.role.trim().toLowerCase() : null;
1142
+ const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
1143
+ const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
1144
+
1145
+ return {
1146
+ providerType: this.type,
1147
+ sessionId: this.instanceId,
1148
+ providerSessionId: this.providerSessionId || null,
1149
+ workspace: this.workingDir,
1150
+ blockReason: args.blockReason,
1151
+ emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
1152
+ waitedMs: args.waitedMs,
1153
+ maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
1154
+ adapterStatus: typeof args.latestStatus?.status === 'string' ? args.latestStatus.status : null,
1155
+ latestVisibleStatus: args.latestVisibleStatus,
1156
+ parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
1157
+ parseError: parseError || undefined,
1158
+ finalAssistantPresent: evidence.present,
1159
+ finalAssistantEvidenceSource: evidence.source,
1160
+ visibleMessageCount: visibleMessages.length,
1161
+ lastVisibleRole,
1162
+ lastVisibleKind,
1163
+ lastVisibleContentLength,
1164
+ pendingStartedAt: this.generatingStartedAt || null,
1165
+ pendingFirstObservedAt: args.pending.firstObservedAt,
1166
+ pendingTimestamp: args.pending.timestamp,
1167
+ pendingDurationSec: args.pending.duration,
1168
+ previousBlockReason: args.pending.loggedBlockReason || null,
1169
+ transcriptProbeHistory: args.pending.transcriptProbeHistory || [],
1170
+ };
744
1171
  }
745
1172
 
746
1173
  private hasAdapterPendingResponse(): boolean {
@@ -765,32 +1192,97 @@ export class CliProviderInstance implements ProviderInstance {
765
1192
  if (!isCliGeneratingLikeStatus(parsedRawStatus)) return false;
766
1193
  if (adapterRawStatus !== 'idle') return false;
767
1194
  if (hasNonEmptyCliModalButtons(parsedStatus?.activeModal ?? parsedStatus?.modal)) return false;
768
- return !this.hasAdapterPendingResponse();
1195
+ if (this.hasAdapterPendingResponse()) return false;
1196
+ // Do not suppress when the adapter's raw response buffer is still non-empty.
1197
+ // This catches the case where isWaitingForResponse has already flipped to false
1198
+ // (so getPartialResponse() returns '') but the provider's native parser still
1199
+ // reports generating because it's parsing buffered content. Suppressing the
1200
+ // finalization block here would emit a false completion event while the provider
1201
+ // session is still actively processing its response stream.
1202
+ const adapterAny = this.adapter as any;
1203
+ if (typeof adapterAny?.responseBuffer === 'string' && adapterAny.responseBuffer.trim()) return false;
1204
+ return true;
769
1205
  }
770
1206
 
771
- private getCompletedFinalizationBlockReason(latestVisibleStatus: string): string | null {
772
- if (latestVisibleStatus !== 'idle') return `status:${latestVisibleStatus}`;
1207
+ private getCompletedFinalizationBlock(latestVisibleStatus: string, pending: CompletedDebouncePending): CompletedFinalizationBlock | null {
1208
+ if (latestVisibleStatus !== 'idle') return { reason: `status:${latestVisibleStatus}`, terminal: true };
773
1209
 
774
1210
  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';
1211
+ const approvalResolvedIdle = pending.previousStatus === 'waiting_approval';
1212
+ if (!approvalResolvedIdle) {
1213
+ if (adapterAny?.isWaitingForResponse === true) return { reason: 'adapter_waiting_for_response', terminal: true };
1214
+ if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: true };
1215
+ if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: true };
1216
+ }
777
1217
 
778
1218
  const partial = typeof this.adapter.getPartialResponse === 'function'
779
1219
  ? this.adapter.getPartialResponse()
780
1220
  : '';
781
- if (typeof partial === 'string' && partial.trim()) return 'partial_response_pending';
1221
+ if (typeof partial === 'string' && partial.trim()) return { reason: 'partial_response_pending', terminal: true };
782
1222
 
783
1223
  let parsed: any;
784
1224
  try {
785
1225
  parsed = this.adapter.getScriptParsedStatus();
786
1226
  } catch (error: any) {
787
- return `parse_error:${error?.message || String(error)}`;
1227
+ return { reason: `parse_error:${error?.message || String(error)}` };
788
1228
  }
789
1229
 
790
1230
  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';
1231
+ if (parsedStatus !== 'idle') {
1232
+ const adapterStatus = this.adapter.getStatus({ allowParse: false });
1233
+ if (this.shouldSuppressStaleParsedBusyStatus(parsed, adapterStatus)) return null;
1234
+ return { reason: `parsed_status:${parsedStatus}`, terminal: isCliGeneratingLikeStatus(parsedStatus) };
1235
+ }
1236
+ if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
1237
+ const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1238
+ const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
1239
+ const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1240
+ LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1241
+ if (!finalAssistantEvidence.present) {
1242
+ if (adapterOwnsMessagesElsewhere) {
1243
+ if (finalAssistantEvidence.source === 'external-native') {
1244
+ const probe = this.recordPendingTranscriptProbe(pending);
1245
+ if (probe && !pending.loggedTranscriptProbe) {
1246
+ 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`);
1247
+ pending.loggedTranscriptProbe = true;
1248
+ }
1249
+ LOG.debug('CLI', `[${this.type}] external-native probe result: lastRole=${probe?.lastRole} contentLen=${probe?.contentLen}`);
1250
+ if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
1251
+ return null;
1252
+ }
1253
+ if (this.type === 'antigravity-cli') {
1254
+ return null;
1255
+ }
1256
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1257
+ }
1258
+ if ((this.provider as any).requiresFinalAssistantBeforeIdle === true) {
1259
+ return { reason: 'missing_final_assistant', terminal: true, allowTimeout: allowMissingAssistantTimeout };
1260
+ }
1261
+ } else {
1262
+ LOG.debug('CLI', `[${this.type}] missing_final_assistant (not ownsExternal) requiresFinalAssistant=${!!(this.provider as any).requiresFinalAssistantBeforeIdle}`);
1263
+ return {
1264
+ reason: 'missing_final_assistant',
1265
+ terminal: (this.provider as any).requiresFinalAssistantBeforeIdle === true,
1266
+ allowTimeout: allowMissingAssistantTimeout,
1267
+ };
1268
+ }
1269
+ }
1270
+
1271
+ // Guard: if the screen still shows an approval/choice prompt as the last visible text,
1272
+ // the turn is not complete even if the parsed status says idle and there is an assistant
1273
+ // message. This catches the case where waiting_approval→idle transitions occur before
1274
+ // the modal has been resolved (e.g. the PTY rendered the prompt but no button press fired).
1275
+ try {
1276
+ const screenText = typeof (this.adapter as any).getScreenText === 'function'
1277
+ ? String((this.adapter as any).getScreenText() || '')
1278
+ : '';
1279
+ if (screenText) {
1280
+ const tailLines = screenText.split(/\r?\n/).slice(-16).join('\n');
1281
+ if (looksLikeActiveApprovalPromptText(tailLines)) {
1282
+ return { reason: 'screen_shows_approval_prompt', terminal: approvalResolvedIdle };
1283
+ }
1284
+ }
1285
+ } catch { /* defensive: screen text read is best-effort */ }
794
1286
 
795
1287
  return null;
796
1288
  }
@@ -809,7 +1301,8 @@ export class CliProviderInstance implements ProviderInstance {
809
1301
 
810
1302
  const latestStatus = this.adapter.getStatus({ allowParse: false });
811
1303
  const latestAutoApproveActive = latestStatus.status === 'waiting_approval' && this.shouldAutoApprove();
812
- const latestVisibleStatus = latestAutoApproveActive ? 'generating' : latestStatus.status;
1304
+ const latestVisibleStatus = latestAutoApproveActive || this.autoApproveBusy ? 'generating' : latestStatus.status;
1305
+ LOG.debug('CLI', `[${this.type}] flush attempt: adapterStatus=${latestStatus.status} latestVisible=${latestVisibleStatus} generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse} hasPartial=${!!this.adapter.getPartialResponse?.()}`);
813
1306
  if (latestVisibleStatus !== 'idle') {
814
1307
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed ${latestVisibleStatus})`);
815
1308
  this.completedDebouncePending = null;
@@ -817,10 +1310,12 @@ export class CliProviderInstance implements ProviderInstance {
817
1310
  return;
818
1311
  }
819
1312
 
820
- const blockReason = this.getCompletedFinalizationBlockReason(latestVisibleStatus);
821
- if (blockReason) {
1313
+ const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
1314
+ if (block) {
1315
+ const blockReason = block.reason;
822
1316
  const waitedMs = Date.now() - pending.firstObservedAt;
823
- if (waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
1317
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1318
+ if ((block.terminal && !block.allowTimeout) || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
824
1319
  if (pending.loggedBlockReason !== blockReason) {
825
1320
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
826
1321
  pending.loggedBlockReason = blockReason;
@@ -828,10 +1323,29 @@ export class CliProviderInstance implements ProviderInstance {
828
1323
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
829
1324
  return;
830
1325
  }
831
- LOG.warn('CLI', `[${this.type}] suppressed completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1326
+ const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
1327
+ blockReason,
1328
+ latestStatus,
1329
+ latestVisibleStatus,
1330
+ waitedMs,
1331
+ pending,
1332
+ emittedAfterFinalizationTimeout: true,
1333
+ });
1334
+ LOG.warn('CLI', `[${this.type}] emitting completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1335
+ this.pushEvent({
1336
+ event: 'agent:generating_completed',
1337
+ chatTitle: pending.chatTitle,
1338
+ duration: pending.duration,
1339
+ timestamp: pending.timestamp,
1340
+ finalSummary: blockReason.startsWith('parsed_status:')
1341
+ ? ''
1342
+ : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1343
+ completionDiagnostic,
1344
+ });
832
1345
  this.completedDebouncePending = null;
833
1346
  this.completedDebounceTimer = null;
834
1347
  this.generatingStartedAt = 0;
1348
+ this.lastApprovalEventFingerprint = '';
835
1349
  return;
836
1350
  }
837
1351
 
@@ -841,11 +1355,12 @@ export class CliProviderInstance implements ProviderInstance {
841
1355
  chatTitle: pending.chatTitle,
842
1356
  duration: pending.duration,
843
1357
  timestamp: pending.timestamp,
844
- finalSummary: extractFinalSummaryFromMessages(this.adapter?.getScriptParsedStatus()?.messages),
1358
+ finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
845
1359
  });
846
1360
  this.completedDebouncePending = null;
847
1361
  this.completedDebounceTimer = null;
848
1362
  this.generatingStartedAt = 0;
1363
+ this.lastApprovalEventFingerprint = '';
849
1364
  }
850
1365
 
851
1366
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
@@ -853,15 +1368,57 @@ export class CliProviderInstance implements ProviderInstance {
853
1368
  // Guard re-entry: onStatusChange/getState can observe the same modal multiple
854
1369
  // times while the PTY absorbs the approval key. Without this flag, repeated
855
1370
  // snapshots would write stray keys into the input once the modal dismisses.
856
- if (autoApproveActive && !this.autoApproveBusy) {
1371
+ // However, Claude Code can present a second approval immediately after the
1372
+ // first. Resolve a changed modal signature even while the previous write is
1373
+ // still inside the short busy window.
1374
+ if (!autoApproveActive) {
1375
+ this.lastAutoApprovalSignature = '';
1376
+ return autoApproveActive;
1377
+ }
1378
+ const modal = adapterStatus.activeModal;
1379
+ // (fix) Do not auto-approve when no concrete modal/buttons are present.
1380
+ // Claude TUI flaps between paints; without this guard adapterStatus
1381
+ // could report status=waiting_approval with activeModal=null (or with
1382
+ // an empty buttons array briefly) and we'd still call
1383
+ // resolveModal(-1) — which used to type "1" into the prompt
1384
+ // repeatedly. Skip until a real modal is captured.
1385
+ const buttons = Array.isArray(modal?.buttons)
1386
+ ? modal.buttons.map((b: any) => String(b || '').trim()).filter(Boolean)
1387
+ : [];
1388
+ if (!modal || buttons.length === 0) {
1389
+ return autoApproveActive;
1390
+ }
1391
+ const { index: buttonIndex, label: buttonLabel } = pickAutoApprovalButton(buttons);
1392
+ if (buttonIndex < 0) {
1393
+ // No concrete button matched — don't pick a random index, just
1394
+ // surface the modal so the user can decide.
1395
+ return autoApproveActive;
1396
+ }
1397
+ // Include the FSM's approval entry seq: two distinct back-to-back
1398
+ // approvals can carry identical message/buttons (common with
1399
+ // claude-cli). Without the seq their signatures collide and the 5s
1400
+ // busy-window re-entry guard below swallows the second auto-approve,
1401
+ // leaving it stuck. The seq is bumped by the FSM on every fresh
1402
+ // waiting_approval entry, so a new approval always yields a new
1403
+ // signature and fires through.
1404
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
1405
+ ? adapterStatus.approvalEntrySeq
1406
+ : 0;
1407
+ const signature = [
1408
+ approvalEntrySeq,
1409
+ typeof modal?.message === 'string' ? modal.message.trim() : '',
1410
+ buttons.join('|'),
1411
+ buttonIndex,
1412
+ ].join('::');
1413
+ if (!this.autoApproveBusy || signature !== this.lastAutoApprovalSignature) {
857
1414
  this.autoApproveBusy = true;
1415
+ this.lastAutoApprovalSignature = signature;
858
1416
  if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
859
1417
  this.autoApproveBusyTimer = setTimeout(() => {
860
1418
  this.autoApproveBusy = false;
861
1419
  this.autoApproveBusyTimer = null;
862
- }, 2000);
863
- const modal = adapterStatus.activeModal;
864
- const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(modal?.buttons, this.provider);
1420
+ this.lastAutoApprovalSignature = '';
1421
+ }, 5000);
865
1422
  this.recordAutoApproval(modal?.message, buttonLabel, now);
866
1423
  setTimeout(() => {
867
1424
  this.adapter.resolveModal(buttonIndex);
@@ -876,10 +1433,23 @@ export class CliProviderInstance implements ProviderInstance {
876
1433
  // during long-running CLI sessions. Keep this path on adapter-owned light
877
1434
  // state only; rich provider parsing is reserved for getState/read_chat.
878
1435
  const adapterStatus = this.adapter.getStatus({ allowParse: false });
1436
+ const adapterProviderSessionId = normalizeProviderSessionId(
1437
+ this.provider,
1438
+ typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
1439
+ );
1440
+ if (adapterProviderSessionId) {
1441
+ this.promoteProviderSessionId(adapterProviderSessionId);
1442
+ }
879
1443
  const parsedStatus = null;
880
1444
  const rawStatus = adapterStatus.status;
881
1445
  const autoApproveActive = this.maybeAutoApproveStatus(adapterStatus, now);
882
- const newStatus = autoApproveActive ? 'generating' : rawStatus;
1446
+ // During the autoApproveBusy window (2s after firing approval key), the PTY
1447
+ // can briefly report 'idle' before the next generating phase starts. Treat that
1448
+ // transient idle as 'generating' to suppress a spurious agent:generating_completed
1449
+ // push notification. The adapter's status is otherwise authoritative — native
1450
+ // transcript shape does NOT override the FSM's busy/idle decision.
1451
+ const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
1452
+ const newStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus;
883
1453
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
884
1454
  const chatTitle = `${this.provider.name} · ${dirName}`;
885
1455
  const partial = this.adapter.getPartialResponse();
@@ -891,10 +1461,18 @@ export class CliProviderInstance implements ProviderInstance {
891
1461
  if (newStatus !== this.lastStatus) {
892
1462
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
893
1463
  if (this.lastStatus === 'idle' && newStatus === 'generating') {
1464
+ // If a completion event is already pending and the turn has ended
1465
+ // (generatingStartedAt===0), the PTY is painting its prompt area
1466
+ // after completing. Ignore this blip — do not cancel the pending
1467
+ // completion and do not advance lastStatus to generating.
1468
+ if (this.completedDebouncePending && this.generatingStartedAt === 0) {
1469
+ LOG.debug('CLI', `[${this.type}] ignoring post-completion PTY generating blip (generatingStartedAt=0)`);
1470
+ return;
1471
+ }
894
1472
  this.suppressIdleHistoryReplay = false;
895
1473
  // Cancel any pending completed event (multi-step: idle→generating resume)
896
1474
  if (this.completedDebouncePending) {
897
- LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
1475
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating) generatingStartedAt=${this.generatingStartedAt} isWaitingForResponse=${!!(this.adapter as any)?.isWaitingForResponse}`);
898
1476
  if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
899
1477
  this.completedDebouncePending = null;
900
1478
  }
@@ -926,10 +1504,15 @@ export class CliProviderInstance implements ProviderInstance {
926
1504
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
927
1505
  const modal = adapterStatus.activeModal;
928
1506
  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;
1507
+ const approvalFingerprint = JSON.stringify({
1508
+ message: typeof modal?.message === 'string' ? modal.message.trim() : '',
1509
+ buttons: Array.isArray(modal?.buttons) ? modal.buttons.map((button: unknown) => String(button).trim()) : [],
1510
+ });
1511
+ // PTY redraws repeat the same modal content; fingerprint dedup prevents duplicate events.
1512
+ // Do NOT also gate on lastStatus: consecutive approvals can arrive waiting_approval→waiting_approval
1513
+ // (e.g. antigravity-cli resolves one prompt and immediately shows the next) and would be silently dropped.
1514
+ if (approvalFingerprint !== this.lastApprovalEventFingerprint) {
1515
+ this.lastApprovalEventFingerprint = approvalFingerprint;
933
1516
  this.appendRuntimeSystemMessage(
934
1517
  this.formatApprovalRequestMessage(modal?.message, modal?.buttons),
935
1518
  `approval_request:${now}`,
@@ -943,20 +1526,102 @@ export class CliProviderInstance implements ProviderInstance {
943
1526
  }
944
1527
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
945
1528
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
946
- // If debounce still pending (generating lasted < 1s), cancel both events
1529
+ // Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
1530
+ // was entered from 'starting' state (startup PTY noise), not from a real idle→generating
1531
+ // task dispatch. The idle→generating handler is the only code path that sets
1532
+ // generatingStartedAt and generatingDebouncePending, so both being absent means no
1533
+ // task was ever dispatched. Suppress the spurious completion event and fall through
1534
+ // to a simple lastStatus update.
1535
+ if (!this.generatingStartedAt && !this.generatingDebouncePending) {
1536
+ LOG.debug('CLI', `[${this.type}] suppressed startup-phase generating→idle blip (generatingStartedAt=0, no debounce pending)`);
1537
+ } else
1538
+ // If debounce still pending (generating lasted < 1s), cancel both UI events.
1539
+ // Still emit agent:generating_completed so mesh orchestration can record
1540
+ // task_completed for direct dispatches that complete faster than the debounce.
947
1541
  if (this.generatingDebouncePending) {
948
- LOG.info('CLI', `[${this.type}] suppressed short generating (${now - this.generatingStartedAt}ms)`);
1542
+ const shortDurationMs = this.generatingStartedAt ? now - this.generatingStartedAt : 0;
1543
+ LOG.info('CLI', `[${this.type}] suppressed short generating (${shortDurationMs}ms)`);
949
1544
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
1545
+ // Emit completion for mesh task association even though the UI generating
1546
+ // started/completed pair is suppressed (too short for visible UI update).
1547
+ let shortFinalSummary: string | undefined;
1548
+ let shortEvidenceSource: CompletionFinalAssistantEvidence['source'] = 'unavailable';
1549
+ try {
1550
+ const parsedMessages = this.adapter?.getScriptParsedStatus()?.messages;
1551
+ const evidence = this.completionFinalAssistantEvidence(parsedMessages);
1552
+ shortEvidenceSource = evidence.source;
1553
+ shortFinalSummary = extractFinalSummaryFromMessages(evidence.messages as any);
1554
+ } catch { /* best-effort */ }
1555
+ // If a real response is confirmed, retroactively emit started so the chat
1556
+ // bubble appears even though the debounce suppressed the original event.
1557
+ if (shortFinalSummary) {
1558
+ this.pushEvent({ event: 'agent:generating_started', chatTitle, timestamp: now - shortDurationMs });
1559
+ }
950
1560
  this.generatingDebouncePending = null;
951
1561
  this.generatingStartedAt = 0;
1562
+ const missingEvidence = ((this.provider as any).requiresFinalAssistantBeforeIdle === true || shortEvidenceSource === 'external-native') && !shortFinalSummary;
1563
+ if (missingEvidence) {
1564
+ LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
1565
+ }
1566
+ // When evidence is missing and there is no active mesh task context, suppress
1567
+ // the completion event. Providers with requiresFinalAssistantBeforeIdle or
1568
+ // external-native history must confirm a final assistant message before the
1569
+ // coordinator records task_completed. Only emit here if a mesh task is active
1570
+ // so the coordinator can apply its own timeout/retry logic.
1571
+ const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1572
+ if (missingEvidence && !hasMeshContext) {
1573
+ LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
1574
+ // completedDebouncePending intentionally left null — the session is now idle
1575
+ // with no confirmed turn, matching the startup-blip suppression semantics.
1576
+ } else {
1577
+ this.pushEvent({
1578
+ event: 'agent:generating_completed',
1579
+ chatTitle,
1580
+ duration: 0,
1581
+ timestamp: now,
1582
+ finalSummary: shortFinalSummary,
1583
+ completionDiagnostic: {
1584
+ reason: 'short_generating_suppressed',
1585
+ shortDurationMs,
1586
+ finalAssistantEvidenceSource: shortEvidenceSource,
1587
+ ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
1588
+ },
1589
+ });
1590
+ }
952
1591
  } else {
953
1592
  // Debounce completed, then require the rich transcript path that read_chat
954
1593
  // 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);
1594
+ this.completedDebouncePending = {
1595
+ chatTitle,
1596
+ duration,
1597
+ timestamp: now,
1598
+ firstObservedAt: now,
1599
+ previousStatus: this.lastStatus,
1600
+ };
1601
+ const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
1602
+ const flushDelay = ownsExternalHistory ? 0 : 3000;
1603
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
1604
+ this.scheduleCompletedDebounceFlush(flushDelay);
957
1605
  }
958
1606
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {
959
1607
  this.pushEvent({ event: 'agent:ready', chatTitle, timestamp: now });
1608
+ } else if (newStatus === 'error') {
1609
+ if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
1610
+ this.generatingDebouncePending = null;
1611
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
1612
+ this.completedDebouncePending = null;
1613
+ this.errorMessage = adapterStatus.errorMessage || this.errorMessage;
1614
+ this.errorReason = (adapterStatus.errorReason as ProviderErrorReason) || this.errorReason;
1615
+ this.pushEvent({
1616
+ event: 'agent:stopped',
1617
+ chatTitle,
1618
+ timestamp: now,
1619
+ finalSummary: adapterStatus.errorMessage || adapterStatus.errorReason || 'Provider reported an error',
1620
+ completionDiagnostic: {
1621
+ reason: adapterStatus.errorReason || 'provider_error',
1622
+ errorMessage: adapterStatus.errorMessage || undefined,
1623
+ },
1624
+ });
960
1625
  } else if (newStatus === 'stopped') {
961
1626
  // Cancel any pending debounce
962
1627
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -977,7 +1642,34 @@ export class CliProviderInstance implements ProviderInstance {
977
1642
  // Monitor check (cooldown based notification, IDE/CLI common)
978
1643
  const agentKey = `${this.type}:cli`;
979
1644
  const monitorEvents = this.monitor.check(agentKey, newStatus, now, progressFingerprint);
1645
+ const monitorParsedStatus: any = parsedStatus;
980
1646
  for (const me of monitorEvents) {
1647
+ if (
1648
+ me.type === 'monitor:long_generating'
1649
+ && this.completionHasFinalAssistantMessage(monitorParsedStatus?.messages)
1650
+ && !this.hasAdapterPendingResponse()
1651
+ && !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
1652
+ ) {
1653
+ this.pushEvent({
1654
+ event: 'agent:generating_completed',
1655
+ chatTitle,
1656
+ duration: this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : undefined,
1657
+ timestamp: me.timestamp,
1658
+ finalSummary: extractFinalSummaryFromMessages(monitorParsedStatus?.messages),
1659
+ completionDiagnostic: {
1660
+ providerType: this.type,
1661
+ sessionId: this.instanceId,
1662
+ providerSessionId: this.providerSessionId || null,
1663
+ reconciliationReason: 'long_generating_monitor_final_summary',
1664
+ finalAssistantPresent: true,
1665
+ },
1666
+ });
1667
+ this.generatingStartedAt = 0;
1668
+ // Cancel any pending debounce flush — monitor already fired completion.
1669
+ if (this.completedDebounceTimer) { clearTimeout(this.completedDebounceTimer); this.completedDebounceTimer = null; }
1670
+ this.completedDebouncePending = null;
1671
+ continue;
1672
+ }
981
1673
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
982
1674
  }
983
1675
  }
@@ -1003,9 +1695,18 @@ export class CliProviderInstance implements ProviderInstance {
1003
1695
  };
1004
1696
  if (this.context?.emitProviderEvent) {
1005
1697
  this.context.emitProviderEvent(enrichedEvent);
1006
- return;
1698
+ } else {
1699
+ this.events.push(enrichedEvent);
1700
+ }
1701
+ // Auto-detach a direct-dispatch mesh assignment once the dispatched
1702
+ // task reaches a terminal state. Leaving meshNodeFor pinned would
1703
+ // route this session's next unrelated turn (a dashboard chat) into
1704
+ // the coordinator as if it were the completion of another task.
1705
+ // We schedule after the emit so the originating coordinator still
1706
+ // observes the completion event with its routing marker intact.
1707
+ if (TERMINAL_MESH_EVENTS.has(event.event) && this.settings.meshActiveTaskId) {
1708
+ try { this.detachMeshAssignment(); } catch { /* best-effort */ }
1007
1709
  }
1008
- this.events.push(enrichedEvent);
1009
1710
  }
1010
1711
 
1011
1712
  private flushEvents(): ProviderEvent[] {
@@ -1258,12 +1959,28 @@ export class CliProviderInstance implements ProviderInstance {
1258
1959
  index,
1259
1960
  source: 'parsed',
1260
1961
  }));
1962
+ const getRole = (message: ChatMessage): string => typeof message.role === 'string'
1963
+ ? message.role.trim().toLowerCase()
1964
+ : '';
1261
1965
  const runtimeEntries: MergeEntry[] = this.runtimeMessages.map((entry, index) => ({
1262
1966
  message: entry.message,
1263
1967
  index: parsedMessages.length + index,
1264
- source: 'runtime',
1968
+ source: 'runtime' as const,
1265
1969
  runtimeKey: entry.key,
1266
- }));
1970
+ })).filter((entry) => {
1971
+ const meta = entry.message.meta && typeof entry.message.meta === 'object' && !Array.isArray(entry.message.meta)
1972
+ ? entry.message.meta as Record<string, unknown>
1973
+ : {};
1974
+ if (meta.runtimeInputAck !== true) return true;
1975
+ const runtimeText = flattenContent(entry.message.content).replace(/\s+/g, ' ').trim();
1976
+ if (!runtimeText) return false;
1977
+ return !parsedEntries.some((parsedEntry) => {
1978
+ const parsedRole = getRole(parsedEntry.message);
1979
+ if (parsedRole !== 'user' && parsedRole !== 'human') return false;
1980
+ const parsedText = flattenContent(parsedEntry.message.content).replace(/\s+/g, ' ').trim();
1981
+ return parsedText === runtimeText;
1982
+ });
1983
+ });
1267
1984
  const getTime = (message: ChatMessage): number => {
1268
1985
  const value = typeof message.receivedAt === 'number'
1269
1986
  ? message.receivedAt
@@ -1273,9 +1990,6 @@ export class CliProviderInstance implements ProviderInstance {
1273
1990
  return Number.isFinite(value) && value > 0 ? value : 0;
1274
1991
  };
1275
1992
 
1276
- const getRole = (message: ChatMessage): string => typeof message.role === 'string'
1277
- ? message.role.trim().toLowerCase()
1278
- : '';
1279
1993
  const isRuntimeOverlay = (entry: MergeEntry): boolean => {
1280
1994
  if (entry.source !== 'runtime') return false;
1281
1995
  const key = typeof entry.runtimeKey === 'string' ? entry.runtimeKey.trim().toLowerCase() : '';
@@ -1341,7 +2055,9 @@ export class CliProviderInstance implements ProviderInstance {
1341
2055
  this.providerSessionId = nextSessionId;
1342
2056
  this.historyWriter.promoteHistorySession(this.type, previousHistorySessionId, nextSessionId);
1343
2057
  this.historyWriter.writeSessionStart(this.type, nextSessionId, this.workingDir, this.instanceId);
1344
- this.restorePersistedHistoryFromCurrentSession();
2058
+ if (this.shouldHydrateExistingProviderHistory()) {
2059
+ this.restorePersistedHistoryFromCurrentSession();
2060
+ }
1345
2061
  this.adapter.updateRuntimeMeta({ providerSessionId: nextSessionId });
1346
2062
  this.onProviderSessionResolved?.({
1347
2063
  instanceId: this.instanceId,
@@ -1354,9 +2070,27 @@ export class CliProviderInstance implements ProviderInstance {
1354
2070
  LOG.info('CLI', `[${this.type}] discovered provider session id: ${nextSessionId}`);
1355
2071
  }
1356
2072
 
2073
+ private shouldHydrateExistingProviderHistory(): boolean {
2074
+ return this.launchMode === 'resume' || this.launchMode === 'manual';
2075
+ }
2076
+
2077
+ private shouldSuppressFreshLaunchStartupReplay(parsedMessages: unknown[], parsedStatus: any, adapterStatus: any, parsedProviderSessionId = ''): boolean {
2078
+ if (this.launchMode !== 'new') return false;
2079
+ if (this.providerSessionId) return false;
2080
+ if (!Array.isArray(parsedMessages) || parsedMessages.length === 0) return false;
2081
+ if (!isIdleStatus(adapterStatus?.status) || !isIdleStatus(parsedStatus?.status)) return false;
2082
+ if (parsedProviderSessionId) return true;
2083
+
2084
+ const newestMessageAt = parsedMessages.reduce<number>((newest, message) => Math.max(newest, getMessageTime(message)), 0);
2085
+
2086
+ // Untimestamped idle parser output during a fresh launch is usually the
2087
+ // provider's last workspace transcript before a new turn exists.
2088
+ return newestMessageAt === 0;
2089
+ }
2090
+
1357
2091
  private syncCanonicalSavedHistoryIfNeeded(): boolean {
1358
2092
  if (!this.providerSessionId) return false;
1359
- const canonicalHistory = this.provider.canonicalHistory;
2093
+ const canonicalHistory = this.provider.nativeHistory;
1360
2094
  if (!canonicalHistory) return false;
1361
2095
 
1362
2096
  if (isNativeSourceCanonicalHistory(canonicalHistory)) {
@@ -1418,9 +2152,9 @@ export class CliProviderInstance implements ProviderInstance {
1418
2152
  private restorePersistedHistoryFromCurrentSession(): void {
1419
2153
  if (!this.providerSessionId) return;
1420
2154
  this.syncCanonicalSavedHistoryIfNeeded();
1421
- const restoredHistory = isNativeSourceCanonicalHistory(this.provider.canonicalHistory)
2155
+ const restoredHistory = isNativeSourceCanonicalHistory(this.provider.nativeHistory)
1422
2156
  ? readProviderChatHistory(this.type, {
1423
- canonicalHistory: this.provider.canonicalHistory,
2157
+ canonicalHistory: this.provider.nativeHistory,
1424
2158
  historySessionId: this.providerSessionId,
1425
2159
  workspace: this.workingDir,
1426
2160
  offset: 0,