@adhdev/daemon-core 0.9.82-rc.33 → 0.9.82-rc.331

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 (316) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +8 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/build-info.d.ts +37 -0
  4. package/dist/chat/source-machine.d.ts +166 -0
  5. package/dist/chat/source-resolver.d.ts +104 -0
  6. package/dist/chat/subscription-updates.d.ts +1 -0
  7. package/dist/cli-adapter-types.d.ts +46 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +198 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +115 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +53 -0
  14. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  15. package/dist/cli-adapters/resolve-executable.d.ts +14 -0
  16. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  17. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  18. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  19. package/dist/commands/cli-manager.d.ts +2 -1
  20. package/dist/commands/handler.d.ts +110 -0
  21. package/dist/commands/mesh-coordinator.d.ts +85 -1
  22. package/dist/commands/router.d.ts +279 -1
  23. package/dist/commands/upgrade-helper.d.ts +12 -0
  24. package/dist/config/chat-history.d.ts +9 -0
  25. package/dist/config/config.d.ts +5 -0
  26. package/dist/config/mesh-config.d.ts +77 -1
  27. package/dist/detection/ide-detector.d.ts +13 -0
  28. package/dist/detection/win32-ide-version.d.ts +37 -0
  29. package/dist/git/change-impact-config.d.ts +159 -0
  30. package/dist/git/git-commands.d.ts +11 -1
  31. package/dist/git/git-diff.d.ts +6 -0
  32. package/dist/git/git-executor.d.ts +11 -0
  33. package/dist/git/git-status.d.ts +23 -0
  34. package/dist/git/git-types.d.ts +2 -50
  35. package/dist/git/index.d.ts +2 -0
  36. package/dist/index.d.ts +51 -12
  37. package/dist/index.js +38419 -13805
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +41329 -16835
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/installer.d.ts +1 -4
  42. package/dist/ipc/local-ipc-server.d.ts +91 -0
  43. package/dist/launch.d.ts +1 -1
  44. package/dist/logging/async-batch-writer.d.ts +10 -0
  45. package/dist/logging/log-redactor.d.ts +24 -0
  46. package/dist/logging/log-tail-reader.d.ts +46 -0
  47. package/dist/mesh/contracts.d.ts +164 -0
  48. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  49. package/dist/mesh/coordinator-registry.d.ts +59 -0
  50. package/dist/mesh/mesh-active-work.d.ts +174 -0
  51. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  52. package/dist/mesh/mesh-events-coordinator.d.ts +169 -0
  53. package/dist/mesh/mesh-events-pending.d.ts +45 -0
  54. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  55. package/dist/mesh/mesh-events-utils.d.ts +47 -0
  56. package/dist/mesh/mesh-events.d.ts +6 -49
  57. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  58. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  59. package/dist/mesh/mesh-init.d.ts +86 -0
  60. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  61. package/dist/mesh/mesh-ledger.d.ts +77 -1
  62. package/dist/mesh/mesh-missions.d.ts +116 -0
  63. package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
  64. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  65. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  66. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  67. package/dist/mesh/mesh-routing.d.ts +70 -0
  68. package/dist/mesh/mesh-runtime-store.d.ts +401 -0
  69. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  70. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  71. package/dist/mesh/mesh-work-queue.d.ts +207 -5
  72. package/dist/mesh/preview-freshness.d.ts +18 -0
  73. package/dist/mesh/refine-config.d.ts +215 -0
  74. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  75. package/dist/providers/acp-provider-instance.d.ts +2 -0
  76. package/dist/providers/approval-utils.d.ts +13 -0
  77. package/dist/providers/chat-message-normalization.d.ts +12 -0
  78. package/dist/providers/cli-provider-instance.d.ts +61 -3
  79. package/dist/providers/contracts.d.ts +130 -6
  80. package/dist/providers/external-sources.d.ts +71 -0
  81. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  82. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  83. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  84. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  85. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  86. package/dist/providers/native-history/index.d.ts +13 -0
  87. package/dist/providers/provider-instance-manager.d.ts +13 -0
  88. package/dist/providers/provider-instance.d.ts +13 -1
  89. package/dist/providers/provider-loader.d.ts +26 -4
  90. package/dist/providers/provider-trust.d.ts +31 -0
  91. package/dist/providers/read-chat-contract.d.ts +29 -0
  92. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  93. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  94. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  95. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  96. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  97. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  98. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  99. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  100. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  101. package/dist/providers/sdk/v1/index.d.ts +30 -0
  102. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  103. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  104. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  105. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  106. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  107. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  108. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  109. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  110. package/dist/providers/spec/adapter.d.ts +69 -0
  111. package/dist/providers/spec/cli-adapter.d.ts +217 -0
  112. package/dist/providers/spec/evaluator.d.ts +23 -0
  113. package/dist/providers/spec/fsm-driver.d.ts +306 -0
  114. package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
  115. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  116. package/dist/providers/spec/fsm-types.d.ts +144 -0
  117. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  118. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  119. package/dist/providers/spec/route.d.ts +4 -0
  120. package/dist/providers/spec/types.d.ts +180 -0
  121. package/dist/providers/status-monitor.d.ts +7 -7
  122. package/dist/providers/transcript-v2.d.ts +176 -0
  123. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  124. package/dist/providers/working-dir.d.ts +17 -0
  125. package/dist/repo-mesh-types.d.ts +295 -13
  126. package/dist/sessions/registry.d.ts +6 -0
  127. package/dist/shared-types-extra.d.ts +2 -4
  128. package/dist/shared-types.d.ts +52 -2
  129. package/dist/status/normalize.d.ts +1 -1
  130. package/dist/status/normalize.js +1 -0
  131. package/dist/status/normalize.js.map +1 -1
  132. package/dist/status/normalize.mjs +1 -0
  133. package/dist/status/normalize.mjs.map +1 -1
  134. package/dist/status/reporter.d.ts +2 -0
  135. package/dist/status/snapshot.d.ts +1 -0
  136. package/dist/types.d.ts +5 -0
  137. package/package.json +7 -3
  138. package/src/agent-stream/poller.ts +2 -3
  139. package/src/agent-stream/provider-adapter.ts +1 -1
  140. package/src/boot/daemon-lifecycle.ts +55 -12
  141. package/src/boot/process-hardening.ts +89 -0
  142. package/src/build-info.ts +73 -0
  143. package/src/chat/source-machine.ts +534 -0
  144. package/src/chat/source-resolver.ts +0 -0
  145. package/src/chat/subscription-updates.ts +20 -1
  146. package/src/cli-adapter-types.d.ts +1 -0
  147. package/src/cli-adapter-types.ts +47 -2
  148. package/src/cli-adapters/cli-script-runner.ts +421 -0
  149. package/src/cli-adapters/cli-state-engine.ts +1215 -0
  150. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  151. package/src/cli-adapters/provider-cli-adapter.ts +713 -1141
  152. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  153. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  154. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  155. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  156. package/src/cli-adapters/provider-cli-shared.ts +100 -11
  157. package/src/cli-adapters/pty-transport.ts +2 -1
  158. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  159. package/src/cli-adapters/resolve-executable.ts +90 -0
  160. package/src/cli-adapters/session-host-transport.ts +2 -1
  161. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  162. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  163. package/src/cli-adapters/terminal-screen.ts +16 -81
  164. package/src/commands/chat-commands.ts +1823 -62
  165. package/src/commands/cli-manager.ts +286 -14
  166. package/src/commands/handler.ts +809 -2
  167. package/src/commands/mesh-coordinator.ts +332 -122
  168. package/src/commands/router.ts +7270 -1306
  169. package/src/commands/stream-commands.ts +8 -0
  170. package/src/commands/upgrade-helper.ts +81 -30
  171. package/src/config/chat-history.ts +483 -24
  172. package/src/config/config.ts +12 -0
  173. package/src/config/mesh-config.ts +388 -6
  174. package/src/config/recent-activity.ts +8 -2
  175. package/src/daemon/dev-auto-implement.ts +3 -2
  176. package/src/daemon/dev-cli-debug.ts +10 -1
  177. package/src/detection/cli-detector.ts +28 -9
  178. package/src/detection/ide-detector.ts +55 -16
  179. package/src/detection/win32-ide-version.ts +106 -0
  180. package/src/git/change-impact-config.ts +354 -0
  181. package/src/git/git-commands.ts +55 -14
  182. package/src/git/git-diff.ts +53 -0
  183. package/src/git/git-executor.ts +12 -0
  184. package/src/git/git-status.ts +574 -43
  185. package/src/git/git-types.ts +14 -62
  186. package/src/git/git-worktree.ts +8 -1
  187. package/src/git/index.ts +16 -0
  188. package/src/index.ts +145 -11
  189. package/src/installer.d.ts +1 -1
  190. package/src/installer.ts +8 -6
  191. package/src/ipc/local-ipc-server.ts +278 -0
  192. package/src/launch.d.ts +1 -1
  193. package/src/launch.ts +37 -28
  194. package/src/logging/async-batch-writer.ts +55 -0
  195. package/src/logging/log-redactor.ts +100 -0
  196. package/src/logging/log-tail-reader.ts +220 -0
  197. package/src/logging/logger.ts +2 -1
  198. package/src/mesh/contracts.ts +329 -0
  199. package/src/mesh/coordinator-prompt.ts +234 -31
  200. package/src/mesh/coordinator-registry.ts +121 -0
  201. package/src/mesh/mesh-active-work.ts +623 -0
  202. package/src/mesh/mesh-delivery-policy.ts +315 -0
  203. package/src/mesh/mesh-events-coordinator.ts +2110 -0
  204. package/src/mesh/mesh-events-pending.ts +508 -0
  205. package/src/mesh/mesh-events-stale.ts +290 -0
  206. package/src/mesh/mesh-events-utils.ts +254 -0
  207. package/src/mesh/mesh-events.ts +30 -1035
  208. package/src/mesh/mesh-fast-forward.ts +839 -0
  209. package/src/mesh/mesh-host-ownership.ts +73 -0
  210. package/src/mesh/mesh-init.ts +260 -0
  211. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  212. package/src/mesh/mesh-ledger.ts +589 -102
  213. package/src/mesh/mesh-missions.ts +248 -0
  214. package/src/mesh/mesh-reconcile-loop.ts +786 -0
  215. package/src/mesh/mesh-refine-batch.ts +197 -0
  216. package/src/mesh/mesh-refine-status.ts +231 -0
  217. package/src/mesh/mesh-review-inbox.ts +307 -0
  218. package/src/mesh/mesh-routing.ts +291 -0
  219. package/src/mesh/mesh-runtime-store.ts +1578 -0
  220. package/src/mesh/mesh-task-stats.ts +154 -0
  221. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  222. package/src/mesh/mesh-work-queue.ts +874 -141
  223. package/src/mesh/preview-freshness.ts +118 -0
  224. package/src/mesh/refine-config.ts +403 -0
  225. package/src/mesh/worktree-bootstrap-config.ts +326 -0
  226. package/src/providers/acp-provider-instance.ts +25 -9
  227. package/src/providers/approval-utils.d.ts +4 -0
  228. package/src/providers/approval-utils.ts +47 -5
  229. package/src/providers/chat-message-normalization.ts +45 -5
  230. package/src/providers/cli-provider-instance.ts +986 -99
  231. package/src/providers/contracts.d.ts +55 -0
  232. package/src/providers/contracts.ts +141 -6
  233. package/src/providers/extension-provider-instance.ts +12 -7
  234. package/src/providers/external-sources.ts +218 -0
  235. package/src/providers/ide-provider-instance.ts +35 -12
  236. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  237. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  238. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  239. package/src/providers/native-history/dispatcher.ts +340 -0
  240. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  241. package/src/providers/native-history/index.ts +30 -0
  242. package/src/providers/provider-instance-manager.ts +30 -0
  243. package/src/providers/provider-instance.ts +10 -1
  244. package/src/providers/provider-loader.ts +662 -50
  245. package/src/providers/provider-schema.ts +87 -14
  246. package/src/providers/provider-trust.ts +114 -0
  247. package/src/providers/read-chat-contract.ts +76 -16
  248. package/src/providers/sdk/README.md +49 -0
  249. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  250. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  251. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  252. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  253. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  254. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  255. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  256. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  257. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  258. package/src/providers/sdk/v1/index.ts +152 -0
  259. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  260. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  261. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  262. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  263. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  264. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  265. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  266. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  267. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  268. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  269. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  270. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  271. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  272. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  273. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  274. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  275. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  276. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  277. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  278. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  279. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  280. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  281. package/src/providers/sdk/v1/validators/index.ts +19 -0
  282. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  283. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  284. package/src/providers/spec/adapter.ts +168 -0
  285. package/src/providers/spec/cli-adapter.ts +986 -0
  286. package/src/providers/spec/evaluator.ts +345 -0
  287. package/src/providers/spec/fsm-driver.ts +993 -0
  288. package/src/providers/spec/fsm-evaluator.ts +255 -0
  289. package/src/providers/spec/fsm-loader.ts +120 -0
  290. package/src/providers/spec/fsm-types.ts +238 -0
  291. package/src/providers/spec/native-history-executor.ts +1136 -0
  292. package/src/providers/spec/pre-launch-trust.ts +104 -0
  293. package/src/providers/spec/route.ts +51 -0
  294. package/src/providers/spec/types.ts +226 -0
  295. package/src/providers/status-monitor.d.ts +7 -7
  296. package/src/providers/status-monitor.ts +37 -22
  297. package/src/providers/transcript-v2.ts +567 -0
  298. package/src/providers/types/interactive-prompt.ts +536 -0
  299. package/src/providers/version-archive.ts +64 -24
  300. package/src/providers/working-dir.ts +23 -0
  301. package/src/repo-mesh-types.ts +378 -14
  302. package/src/sessions/registry.ts +6 -0
  303. package/src/shared-types-extra.ts +2 -4
  304. package/src/shared-types.d.ts +8 -0
  305. package/src/shared-types.ts +57 -1
  306. package/src/status/builders.ts +26 -6
  307. package/src/status/normalize.ts +2 -0
  308. package/src/status/reporter.ts +19 -1
  309. package/src/status/snapshot.ts +84 -25
  310. package/src/system/host-memory.ts +29 -12
  311. package/src/types.ts +5 -0
  312. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  313. package/dist/mesh/mesh-sync.d.ts +0 -53
  314. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  315. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  316. package/src/mesh/mesh-sync.ts +0 -111
@@ -15,10 +15,12 @@
15
15
  */
16
16
 
17
17
  import * as os from 'os';
18
- import type { CliAdapter } from '../cli-adapter-types.js';
18
+ import type { CliAdapter, CliLaunchInfo } from '../cli-adapter-types.js';
19
+ import type { InteractivePromptResponse } from '../providers/types/interactive-prompt.js';
19
20
  import { LOG } from '../logging/logger.js';
20
21
  import { getDebugRuntimeConfig } from '../logging/debug-config.js';
21
22
  import { TerminalScreen } from './terminal-screen.js';
23
+ import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
22
24
  import {
23
25
  NodePtyTransportFactory,
24
26
  type PtyRuntimeMetadata,
@@ -44,6 +46,13 @@ import {
44
46
  type CliTraceEntry,
45
47
  type ParsedSession,
46
48
  } from './provider-cli-shared.js';
49
+ import { CliScriptRunner } from './cli-script-runner.js';
50
+ import {
51
+ CliStateEngine,
52
+ type CliBufferSnapshot,
53
+ type CliTransportAccess,
54
+ type CliStateEngineCallbacks,
55
+ } from './cli-state-engine.js';
47
56
  import {
48
57
  buildCliParseInput,
49
58
  buildCliTraceParseSnapshot,
@@ -78,24 +87,6 @@ export {
78
87
  } from './provider-cli-shared.js';
79
88
 
80
89
 
81
- interface IdleFinishCandidate {
82
- armedAt: number;
83
- lastOutputAt: number;
84
- lastScreenChangeAt: number;
85
- responseEpoch: number;
86
- assistantLength: number;
87
- }
88
-
89
- interface SettledEvalContext {
90
- now: number;
91
- modal: any;
92
- status: string;
93
- parsedMessages: CliChatMessage[];
94
- lastParsedAssistant: CliChatMessage | undefined;
95
- parsedStatus: string | null;
96
- prevStatus: string;
97
- }
98
-
99
90
  interface SendMessageState {
100
91
  text: string;
101
92
  normalizedPromptSnippet: string;
@@ -110,6 +101,14 @@ interface SendMessageCompletion {
110
101
  rejectOnce: (error: unknown) => void;
111
102
  }
112
103
 
104
+ interface PendingOutboundMessage {
105
+ id: string;
106
+ role: 'user';
107
+ content: string;
108
+ queuedAt: number;
109
+ source: 'sendMessage';
110
+ }
111
+
113
112
  export function appendBoundedText(current: string, chunk: string, maxChars: number): string {
114
113
  if (!chunk) return current.length <= maxChars ? current : current.slice(-maxChars);
115
114
  if (maxChars <= 0) return '';
@@ -122,23 +121,23 @@ export function appendBoundedText(current: string, chunk: string, maxChars: numb
122
121
  // ─── Adapter ────────────────────────────────────────
123
122
 
124
123
  export class ProviderCliAdapter implements CliAdapter {
125
- readonly cliType: string;
124
+ cliType: string;
126
125
  readonly cliName: string;
127
126
  public workingDir: string;
128
127
 
129
128
  private provider: CliProviderModule;
130
129
  private ptyProcess: PtyRuntimeTransport | null = null;
131
130
  private transportFactory: PtyTransportFactory;
132
- private currentStatus: CliSessionStatus['status'] = 'starting';
133
131
  private onStatusChange: (() => void) | null = null;
134
132
 
133
+ // ─── State machine engine ─────────────────────────
134
+ readonly engine: CliStateEngine;
135
+
135
136
  private responseBuffer = '';
136
137
  private recentOutputBuffer = '';
137
- private isWaitingForResponse = false;
138
- private activeModal: { message: string; buttons: string[] } | null = null;
139
- private parseErrorMessage: string | null = null;
138
+ private get parseErrorMessage(): string | null { return this.runner.parseErrorMessage; }
139
+ private providerSessionId: string | null = null;
140
140
  private responseTimeout: NodeJS.Timeout | null = null;
141
- private idleTimeout: NodeJS.Timeout | null = null;
142
141
  private ready = false;
143
142
  private startupBuffer = '';
144
143
  private startupParseGate = false;
@@ -164,39 +163,48 @@ export class ProviderCliAdapter implements CliAdapter {
164
163
  private serverConn: any = null;
165
164
  private logBuffer: { message: string; level: string }[] = [];
166
165
 
167
- // Approval cooldown
168
- private lastApprovalResolvedAt: number = 0;
169
-
170
- // Approval state machine
171
- private approvalTransitionBuffer: string = '';
172
- private approvalExitTimeout: NodeJS.Timeout | null = null;
173
- private pendingScriptStatus: 'generating' | 'waiting_approval' | null = null;
174
- private pendingScriptStatusSince = 0;
175
- private pendingScriptStatusTimer: NodeJS.Timeout | null = null;
176
-
177
- // Output settle debounce — fires after PTY output goes quiet
178
- private settleTimer: NodeJS.Timeout | null = null;
179
- private settledBuffer: string = '';
180
- private submitPendingUntil = 0;
181
- private responseSettleIgnoreUntil = 0;
182
- private responseEpoch = 0;
166
+ private pendingOutboundQueue: PendingOutboundMessage[] = [];
167
+ private pendingOutboundFlushTimer: NodeJS.Timeout | null = null;
168
+ private pendingOutboundFlushInFlight = false;
169
+ // Submit retry timer — PTY-level, not state machine
183
170
  private submitRetryTimer: NodeJS.Timeout | null = null;
184
- private submitRetryUsed = false;
185
- private submitRetryPromptSnippet = '';
186
- private idleFinishCandidate: IdleFinishCandidate | null = null;
187
- private finishRetryTimer: NodeJS.Timeout | null = null;
188
- private finishRetryCount = 0;
189
171
 
190
172
  // Resize redraw suppression
191
173
  private resizeSuppressUntil: number = 0;
192
174
 
193
- // Debug: status transition history
194
- private statusHistory: { status: string; at: number; trigger?: string }[] = [];
175
+ // (A2.2) Native transcript anchor moved to CHAT_SOURCE_REGISTRY.
176
+ // ChatSourceMachine holds the lock by state, not by a mutable field on
177
+ // the adapter. Removed entirely; no callers remain after the readChat
178
+ // ladder was replaced.
195
179
 
196
- // ─── CLI Scripts (script-based parsing) ───
197
- private cliScripts: CliScripts;
198
- /** Per-session opaque state object created by cliScripts.createState(), reset on stop. */
199
- private scriptState: unknown = null;
180
+ // ─── Script runner (parsing isolated here, adapter stays as transport) ───
181
+ private readonly runner: CliScriptRunner;
182
+ /** @deprecated use runner.cliScripts for direct script access */
183
+ get cliScripts(): CliScripts { return this.runner.cliScripts; }
184
+
185
+ /**
186
+ * Recent script invocations (oldest → newest). Exposed via the
187
+ * `get_chat_debug_bundle` daemon command so anyone debugging a
188
+ * stuck-status regression can read what each detectStatus /
189
+ * parseSession call actually saw and returned, instead of having
190
+ * to instrument the daemon.
191
+ */
192
+ getScriptInvocationTrace() {
193
+ return this.runner.getInvocationTrace();
194
+ }
195
+
196
+ /**
197
+ * Returns the full raw PTY byte stream captured since adapter start.
198
+ * Used by the `record_provider_pty` IPC command to produce fixtures.
199
+ * Bounded by MAX_ACCUMULATED_BUFFER; older bytes may have been dropped.
200
+ */
201
+ getAccumulatedRawBuffer(): { text: string; droppedChars: number } {
202
+ return {
203
+ text: this.accumulatedRawBuffer,
204
+ droppedChars: this.accumulatedRawBufferDroppedChars,
205
+ };
206
+ }
207
+ set cliScripts(scripts: CliScripts) { this.setCliScripts(scripts); }
200
208
  private runtimeSettings: Record<string, any> = {};
201
209
  /** Full accumulated rendered PTY transcript for parser/readback use */
202
210
  private accumulatedBuffer: string = '';
@@ -205,7 +213,7 @@ export class ProviderCliAdapter implements CliAdapter {
205
213
  /** Full accumulated raw PTY output (with ANSI) */
206
214
  private accumulatedRawBuffer: string = '';
207
215
  /** Current visible terminal screen snapshot */
208
- private terminalScreen = new TerminalScreen(24, 80);
216
+ private terminalScreen = new TerminalScreen(DEFAULT_SESSION_HOST_ROWS, DEFAULT_SESSION_HOST_COLS);
209
217
  private static readonly MAX_RESPONSE_BUFFER = 8000;
210
218
  private static readonly MAX_RECENT_OUTPUT_BUFFER = 1000;
211
219
  private responseBufferDroppedChars = 0;
@@ -216,10 +224,6 @@ export class ProviderCliAdapter implements CliAdapter {
216
224
  * Hermes turn (tool calls + reasoning + final bubble) without the
217
225
  * rolling window pushing the turn's ╭─ opening line out of view. */
218
226
  private static readonly MAX_ACCUMULATED_BUFFER = 262144;
219
- private currentTurnScope: TurnParseScope | null = null;
220
- private traceEntries: CliTraceEntry[] = [];
221
- private traceSeq = 0;
222
- private traceSessionId = '';
223
227
  private parsedStatusCache: {
224
228
  responseBuffer: string;
225
229
  currentTurnScope: TurnParseScope | null;
@@ -233,11 +237,8 @@ export class ProviderCliAdapter implements CliAdapter {
233
237
  result: any;
234
238
  } | null = null;
235
239
  private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
236
- private static readonly MAX_TRACE_ENTRIES = 250;
237
240
 
238
241
  private readonly providerResolutionMeta: ProviderResolutionMeta;
239
- private static readonly FINISH_RETRY_DELAY_MS = 300;
240
- private static readonly MAX_FINISH_RETRIES = 2;
241
242
 
242
243
  private getBufferState(): NonNullable<CliSessionStatus['bufferState']> | undefined {
243
244
  const build = (droppedChars: number, maxChars: number) => droppedChars > 0
@@ -313,13 +314,13 @@ export class ProviderCliAdapter implements CliAdapter {
313
314
  if (
314
315
  cached
315
316
  && cached.responseBuffer === this.responseBuffer
316
- && cached.currentTurnScope === this.currentTurnScope
317
+ && cached.currentTurnScope === this.engine.currentTurnScope
317
318
  && cached.recentOutputBuffer === this.recentOutputBuffer
318
319
  && cached.accumulatedBuffer === this.accumulatedBuffer
319
320
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
320
321
  && cached.screenText === this.lastScreenText
321
- && cached.currentStatus === this.currentStatus
322
- && cached.activeModal === this.activeModal
322
+ && cached.currentStatus === this.engine.currentStatus
323
+ && cached.activeModal === this.engine.activeModal
323
324
  && cached.cliName === this.cliName
324
325
  ) {
325
326
  return cached.result;
@@ -343,86 +344,6 @@ export class ProviderCliAdapter implements CliAdapter {
343
344
  return this.timeouts.statusActivityHold;
344
345
  }
345
346
 
346
- private setStatus(status: CliSessionStatus['status'], trigger?: string): void {
347
- const prev = this.currentStatus;
348
- if (prev === status) return;
349
- this.currentStatus = status;
350
- this.statusHistory.push({ status, at: Date.now(), trigger });
351
- if (this.statusHistory.length > 50) this.statusHistory.shift();
352
- this.recordTrace('status', {
353
- previousStatus: prev,
354
- trigger: trigger || null,
355
- });
356
- LOG.info('CLI', `[${this.cliType}] status: ${prev} → ${status}${trigger ? ` (${trigger})` : ''}`);
357
- }
358
-
359
- private clearIdleFinishCandidate(reason: string): void {
360
- if (!this.idleFinishCandidate) return;
361
- this.recordTrace('idle_candidate_reset', {
362
- reason,
363
- candidate: this.idleFinishCandidate,
364
- });
365
- this.idleFinishCandidate = null;
366
- }
367
-
368
- private armIdleFinishCandidate(assistantLength: number): void {
369
- const now = Date.now();
370
- const idleFinishConfirmMs = this.getIdleFinishConfirmMs();
371
- this.idleFinishCandidate = {
372
- armedAt: now,
373
- lastOutputAt: this.lastOutputAt,
374
- lastScreenChangeAt: this.lastScreenChangeAt,
375
- responseEpoch: this.responseEpoch,
376
- assistantLength,
377
- };
378
- this.recordTrace('idle_candidate_armed', {
379
- confirmMs: idleFinishConfirmMs,
380
- candidate: this.idleFinishCandidate,
381
- ...buildCliTraceParseSnapshot({
382
- accumulatedBuffer: this.accumulatedBuffer,
383
- accumulatedRawBuffer: this.accumulatedRawBuffer,
384
- responseBuffer: this.responseBuffer,
385
- partialResponse: this.responseBuffer,
386
- scope: this.currentTurnScope,
387
- }),
388
- });
389
- if (this.settleTimer) clearTimeout(this.settleTimer);
390
- this.settleTimer = setTimeout(() => {
391
- this.settleTimer = null;
392
- this.settledBuffer = this.recentOutputBuffer;
393
- this.evaluateSettled();
394
- }, idleFinishConfirmMs);
395
- }
396
-
397
-
398
- private recordTrace(type: string, payload: Record<string, any> = {}): void {
399
- const entry: CliTraceEntry = {
400
- id: ++this.traceSeq,
401
- at: Date.now(),
402
- type,
403
- status: this.currentStatus,
404
- isWaitingForResponse: this.isWaitingForResponse,
405
- activeModal: this.activeModal
406
- ? { message: this.activeModal.message, buttons: [...this.activeModal.buttons] }
407
- : null,
408
- payload,
409
- };
410
- this.traceEntries.push(entry);
411
- if (this.traceEntries.length > ProviderCliAdapter.MAX_TRACE_ENTRIES) {
412
- this.traceEntries.splice(0, this.traceEntries.length - ProviderCliAdapter.MAX_TRACE_ENTRIES);
413
- }
414
- }
415
-
416
- private resetTraceSession(): void {
417
- this.traceEntries = [];
418
- this.traceSeq = 0;
419
- this.traceSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
420
- this.recordTrace('session_start', {
421
- providerType: this.cliType,
422
- workingDir: this.workingDir,
423
- });
424
- }
425
-
426
347
  // Resolved timeouts
427
348
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
428
349
 
@@ -432,7 +353,6 @@ export class ProviderCliAdapter implements CliAdapter {
432
353
  private readonly sendKey: string;
433
354
  private readonly submitStrategy: 'wait_for_echo' | 'immediate';
434
355
  private readonly requirePromptEchoBeforeSubmit: boolean;
435
- private static readonly SCRIPT_STATUS_DEBOUNCE_MS = 3000;
436
356
 
437
357
  constructor(
438
358
  provider: CliProviderModule,
@@ -441,6 +361,7 @@ export class ProviderCliAdapter implements CliAdapter {
441
361
  private extraEnv: Record<string, string> = {},
442
362
  transportFactory: PtyTransportFactory = new NodePtyTransportFactory(),
443
363
  ) {
364
+ this.runner = new CliScriptRunner(provider.type);
444
365
  this.provider = provider;
445
366
  this.transportFactory = transportFactory;
446
367
  this.cliType = provider.type;
@@ -458,10 +379,33 @@ export class ProviderCliAdapter implements CliAdapter {
458
379
  this.requirePromptEchoBeforeSubmit = resolvedConfig.requirePromptEchoBeforeSubmit;
459
380
  this.providerResolutionMeta = resolvedConfig.providerResolutionMeta;
460
381
 
461
- // Scripts are requiredloaded by ProviderLoader via compatibility array
462
- this.cliScripts = provider.scripts || {};
463
- this.scriptState = typeof this.cliScripts.createState === 'function' ? (this.cliScripts.createState() ?? null) : null;
464
- const scriptNames = listCliScriptNames(this.cliScripts);
382
+ // State machine engineowns all status transitions
383
+ this.engine = new CliStateEngine(
384
+ provider,
385
+ this.runner,
386
+ this as unknown as CliTransportAccess,
387
+ {
388
+ onStatusChange: () => { this.onStatusChange?.(); },
389
+ onApplyParsedSession: (session) => { this.applyParsedSessionMetadata(session); },
390
+ onTurnCompleted: () => { this.responseBuffer = ''; },
391
+ } satisfies CliStateEngineCallbacks,
392
+ resolvedConfig.timeouts,
393
+ );
394
+
395
+ // Scripts delegated to CliScriptRunner — adapter stays as transport.
396
+ // Pass the manifest tui block so the runner can build the
397
+ // declarativeDetectStatus / declarativeParseApproval SDK functions
398
+ // that v1 extended-tier overrides depend on (without these, scripts
399
+ // like codex-cli's detect_status v1 fail-closed with return 'idle'
400
+ // and the session sticks in `generating` forever).
401
+ this.runner.setScripts(provider.scripts || {}, provider.tui);
402
+ // Per-invocation wall-clock budget for provider scripts. Manifests may
403
+ // raise this for genuinely slow parsers, but the default (50ms) is the
404
+ // settle-loop safety net — any script that exceeds it gets flagged in
405
+ // the invocation trace and surfaced via the debug bundle so we can
406
+ // identify the offender instead of guessing why the settle loop hangs.
407
+ this.runner.setScriptCallBudget(provider.scriptCallBudgetMs ?? 50);
408
+ const scriptNames = this.runner.getScriptNames();
465
409
  if (scriptNames.length > 0) {
466
410
  LOG.info('CLI', `[${this.cliType}] CLI scripts: [${scriptNames.join(', ')}]`);
467
411
  LOG.info(
@@ -487,14 +431,9 @@ export class ProviderCliAdapter implements CliAdapter {
487
431
 
488
432
  /** Inject CLI scripts after construction (e.g. when resolved by ProviderLoader) */
489
433
  setCliScripts(scripts: CliScripts): void {
490
- this.cliScripts = scripts;
434
+ this.runner.setScripts(scripts);
491
435
  this.parsedStatusCache = null;
492
- this.parseErrorMessage = null;
493
- // Initialize per-session state: createState() is called once here and on script reload.
494
- // The returned object lives until the PTY exits (scriptState = null on exit).
495
- this.scriptState = typeof scripts.createState === 'function' ? (scripts.createState() ?? null) : null;
496
- const scriptNames = listCliScriptNames(scripts);
497
- LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${scriptNames.join(', ')}]`);
436
+ LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${this.runner.getScriptNames().join(', ')}]`);
498
437
  }
499
438
 
500
439
  /** Refresh provider scripts/config used by this adapter without restarting the PTY runtime. */
@@ -548,15 +487,6 @@ export class ProviderCliAdapter implements CliAdapter {
548
487
  });
549
488
 
550
489
  LOG.info('CLI', `[${this.cliType}] Spawning in ${this.workingDir}`);
551
- this.resetTraceSession();
552
- this.recordTrace('spawn', {
553
- shellCommand: spawnPlan.shellCmd,
554
- shellArgs: spawnPlan.shellArgs,
555
- cwd: spawnPlan.ptyOptions.cwd,
556
- cols: spawnPlan.ptyOptions.cols,
557
- rows: spawnPlan.ptyOptions.rows,
558
- providerResolution: this.providerResolutionMeta,
559
- });
560
490
 
561
491
  try {
562
492
  this.ptyProcess = this.transportFactory.spawn(
@@ -620,13 +550,12 @@ export class ProviderCliAdapter implements CliAdapter {
620
550
  this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
621
551
  LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode}`);
622
552
  this.flushPendingOutputParse();
623
- this.recordTrace('exit', { exitCode });
624
553
  this.ptyProcess = null;
625
- this.setStatus('stopped', 'pty_exit');
554
+ this.engine.onPtyExit();
626
555
  this.ready = false;
627
556
  this.startupParseGate = false;
628
557
  this.spawnAt = 0;
629
- this.scriptState = null;
558
+ this.runner.resetSessionState();
630
559
  this.onStatusChange?.();
631
560
  });
632
561
 
@@ -635,17 +564,11 @@ export class ProviderCliAdapter implements CliAdapter {
635
564
  this.startupBuffer = '';
636
565
  this.startupFirstOutputAt = 0;
637
566
  if (this.startupSettleTimer) { clearTimeout(this.startupSettleTimer); this.startupSettleTimer = null; }
638
- this.resetTerminalScreen(24, 80);
567
+ this.resetTerminalScreen(DEFAULT_SESSION_HOST_ROWS, DEFAULT_SESSION_HOST_COLS);
639
568
  this.pendingTerminalQueryTail = '';
640
- this.currentTurnScope = null;
641
- this.finishRetryCount = 0;
642
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
643
569
  this.ready = false;
644
570
  await this.ptyProcess.ready;
645
- this.recordTrace('ready', {
646
- runtimeMeta: this.getRuntimeMetadata(),
647
- });
648
- this.setStatus('starting', 'pty_ready');
571
+ this.engine.onSpawnReady();
649
572
  this.scheduleStartupSettleCheck();
650
573
  this.onStatusChange?.();
651
574
  }
@@ -671,11 +594,11 @@ export class ProviderCliAdapter implements CliAdapter {
671
594
  if (this.startupParseGate && !this.startupFirstOutputAt && (cleanData.trim() || normalizedScreenSnapshot.trim())) {
672
595
  this.startupFirstOutputAt = now;
673
596
  }
674
- if (this.idleFinishCandidate && (rawData.length > 0 || cleanData.length > 0)) {
675
- this.clearIdleFinishCandidate('new_output');
597
+ if (rawData.length > 0 || cleanData.length > 0) {
598
+ this.engine.clearIdleFinishCandidate('new_output');
676
599
  }
677
600
  if (getDebugRuntimeConfig().collectDebugTrace) {
678
- this.recordTrace('output', {
601
+ this.engine.recordExternalTrace('output', {
679
602
  rawLength: rawData.length,
680
603
  cleanLength: cleanData.length,
681
604
  rawPreview: summarizeCliTraceText(rawData, 300),
@@ -687,7 +610,7 @@ export class ProviderCliAdapter implements CliAdapter {
687
610
  this.scheduleStartupSettleCheck();
688
611
  }
689
612
 
690
- if (this.isWaitingForResponse && cleanData) {
613
+ if (this.engine.isWaitingForResponse && cleanData) {
691
614
  const previousResponseLen = this.responseBuffer.length;
692
615
  this.responseBuffer = appendBoundedText(this.responseBuffer, cleanData, ProviderCliAdapter.MAX_RESPONSE_BUFFER);
693
616
  this.responseBufferDroppedChars += this.recordBoundedAppendDrop(previousResponseLen, cleanData.length, this.responseBuffer.length);
@@ -726,19 +649,19 @@ export class ProviderCliAdapter implements CliAdapter {
726
649
  // Keep turn-scope offsets aligned with the truncated buffer so scoped
727
650
  // parses don't lose the beginning of a long turn (e.g. the Hermes
728
651
  // ╭─ opening line) when the rolling window sheds bytes.
729
- if (this.currentTurnScope) {
652
+ if (this.engine.currentTurnScope) {
730
653
  if (droppedClean > 0) {
731
- this.currentTurnScope.bufferStart = Math.max(0, this.currentTurnScope.bufferStart - droppedClean);
654
+ this.engine.currentTurnScope.bufferStart = Math.max(0, this.engine.currentTurnScope.bufferStart - droppedClean);
732
655
  }
733
656
  if (droppedRaw > 0) {
734
- this.currentTurnScope.rawBufferStart = Math.max(0, this.currentTurnScope.rawBufferStart - droppedRaw);
657
+ this.engine.currentTurnScope.rawBufferStart = Math.max(0, this.engine.currentTurnScope.rawBufferStart - droppedRaw);
735
658
  }
736
659
  }
737
660
 
738
661
  this.resolveStartupState('output', screenText, normalizedScreenSnapshot, now);
739
662
 
740
663
  // ─── Script-based status detection
741
- this.scheduleSettle();
664
+ this.engine.scheduleSettle();
742
665
  }
743
666
 
744
667
  private resolveStartupState(
@@ -761,6 +684,11 @@ export class ProviderCliAdapter implements CliAdapter {
761
684
  if (stableMs < 2000) return;
762
685
 
763
686
  const startupModal = this.runParseApproval(this.recentOutputBuffer);
687
+ const startupStatus = this.runDetectStatus(screenText || this.recentOutputBuffer);
688
+ if (!startupModal && startupStatus !== 'idle') {
689
+ this.scheduleStartupSettleCheck();
690
+ return;
691
+ }
764
692
  this.startupParseGate = false;
765
693
  if (this.startupSettleTimer) {
766
694
  clearTimeout(this.startupSettleTimer);
@@ -768,14 +696,22 @@ export class ProviderCliAdapter implements CliAdapter {
768
696
  }
769
697
  this.ready = true;
770
698
  if (startupModal) {
771
- this.activeModal = startupModal;
772
- this.setStatus('waiting_approval', `startup_ready:${trigger}`);
699
+ this.engine.activeModal = startupModal;
700
+ this.engine.setStatus('waiting_approval', `startup_ready:${trigger}`);
773
701
  } else {
774
- if (this.currentStatus === 'waiting_approval' || this.activeModal) {
775
- this.lastApprovalResolvedAt = Date.now();
702
+ if (this.engine.currentStatus === 'waiting_approval' || this.engine.activeModal) {
703
+ this.engine.lastApprovalResolvedAt = Date.now();
776
704
  }
777
- this.activeModal = null;
778
- this.setStatus('idle', `startup_ready:${trigger}`);
705
+ this.engine.activeModal = null;
706
+ // Clear the in-flight turn flag at the same time we declare
707
+ // startup-idle. Otherwise the next settled evaluation sees
708
+ // isWaitingForResponse=true + recent CLI welcome-screen paints
709
+ // and flips us right back to generating via the hold path
710
+ // (the "startup → generating → idle → generating → idle"
711
+ // flicker the user observed on claude-cli launch).
712
+ this.engine.isWaitingForResponse = false;
713
+ this.engine.currentTurnScope = null;
714
+ this.engine.setStatus('idle', `startup_ready:${trigger}`);
779
715
  }
780
716
  LOG.info(
781
717
  'CLI',
@@ -801,88 +737,6 @@ export class ProviderCliAdapter implements CliAdapter {
801
737
  }, delayMs);
802
738
  }
803
739
 
804
- private scheduleSettle(): void {
805
- if (this.settleTimer) clearTimeout(this.settleTimer);
806
- const settleEpoch = this.responseEpoch;
807
- const delay = Math.max(
808
- this.timeouts.outputSettle,
809
- this.submitPendingUntil > Date.now()
810
- ? (this.submitPendingUntil - Date.now()) + this.timeouts.outputSettle
811
- : 0,
812
- );
813
- this.settleTimer = setTimeout(() => {
814
- this.settleTimer = null;
815
- if (settleEpoch !== this.responseEpoch) return;
816
- this.settledBuffer = this.recentOutputBuffer;
817
- this.evaluateSettled();
818
- }, delay);
819
- }
820
-
821
- private armApprovalExitTimeout(): void {
822
- if (this.approvalExitTimeout) clearTimeout(this.approvalExitTimeout);
823
- this.approvalExitTimeout = setTimeout(() => {
824
- if (!this.hasActionableApproval()) return;
825
- const tail = this.recentOutputBuffer;
826
- const screenText = this.terminalScreen.getText() || '';
827
- const modal = this.runParseApproval(tail);
828
- const stillWaiting = this.runDetectStatus(tail) === 'waiting_approval' || !!modal;
829
- if (stillWaiting) {
830
- if (!modal) {
831
- LOG.warn('CLI', `[${this.cliType}] approval timeout check found no actionable modal; keeping approval state fail-closed`);
832
- this.activeModal = null;
833
- this.onStatusChange?.();
834
- this.armApprovalExitTimeout();
835
- return;
836
- }
837
- this.activeModal = modal;
838
- this.onStatusChange?.();
839
- this.armApprovalExitTimeout();
840
- return;
841
- }
842
- LOG.warn('CLI', `[${this.cliType}] Approval timeout — auto-clearing`);
843
- this.activeModal = null;
844
- this.lastApprovalResolvedAt = Date.now();
845
- this.setStatus('idle', 'approval_timeout');
846
- this.onStatusChange?.();
847
- }, 60000);
848
- }
849
-
850
- private shouldRetryFinishResponse(commitResult: { hasAssistant: boolean; assistantContent: string }): boolean {
851
- if (!this.currentTurnScope) return false;
852
- if (this.hasActionableApproval()) return false;
853
- if (this.finishRetryCount >= ProviderCliAdapter.MAX_FINISH_RETRIES) return false;
854
- if (commitResult.hasAssistant && commitResult.assistantContent.trim()) return false;
855
-
856
- if (this.runDetectStatus(this.recentOutputBuffer) !== 'idle') return false;
857
-
858
- const now = Date.now();
859
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
860
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : 0;
861
- return quietForMs < 1200 || screenStableMs < 1200 || !commitResult.hasAssistant;
862
- }
863
-
864
- private hasRecentInteractiveActivity(now: number): boolean {
865
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
866
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : Number.MAX_SAFE_INTEGER;
867
- const holdMs = this.getStatusActivityHoldMs();
868
- return quietForMs < holdMs
869
- || screenStableMs < holdMs;
870
- }
871
-
872
- private shouldDeferIdleTimeoutFinish(): boolean {
873
- if (!this.isWaitingForResponse || this.hasActionableApproval()) {
874
- return false;
875
- }
876
- const latestStatus = this.runDetectStatus(this.recentOutputBuffer) || this.currentStatus;
877
- if (latestStatus === 'generating') {
878
- this.settledBuffer = this.recentOutputBuffer;
879
- this.evaluateSettled();
880
- return true;
881
- }
882
- return false;
883
- }
884
-
885
-
886
740
  private async waitForInteractivePrompt(maxWaitMs = 5000): Promise<void> {
887
741
  const startedAt = Date.now();
888
742
  let loggedWait = false;
@@ -892,7 +746,7 @@ export class ProviderCliAdapter implements CliAdapter {
892
746
  const screenText = this.terminalScreen.getText() || '';
893
747
  const stableMs = this.lastScreenChangeAt ? (Date.now() - this.lastScreenChangeAt) : 0;
894
748
  const recentlyOutput = this.lastNonEmptyOutputAt ? (Date.now() - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
895
- const status = this.runDetectStatus(this.recentOutputBuffer) || this.currentStatus;
749
+ const status = this.runDetectStatus(this.recentOutputBuffer) || this.engine.currentStatus;
896
750
  const interactiveReady = status === 'idle'
897
751
  && stableMs >= 700
898
752
  && recentlyOutput >= 350;
@@ -926,637 +780,84 @@ export class ProviderCliAdapter implements CliAdapter {
926
780
 
927
781
  private clearAllTimers(): void {
928
782
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
929
- if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
930
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
931
783
  if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
932
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
933
- if (this.settleTimer) { clearTimeout(this.settleTimer); this.settleTimer = null; }
934
- if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
935
784
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
936
785
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
786
+ this.engine.clearAllTimers();
937
787
  }
938
788
 
939
- private clearStaleIdleResponseGuard(reason: string): boolean {
940
- const blockingModal = this.activeModal || this.runParseApproval(this.recentOutputBuffer);
941
- const isIdle = this.runDetectStatus(this.recentOutputBuffer) === 'idle';
942
- if (!this.isWaitingForResponse || this.currentStatus !== 'idle' || !isIdle || !!blockingModal) {
943
- return false;
944
- }
945
- this.clearAllTimers();
946
- this.clearIdleFinishCandidate(reason);
947
- this.responseBuffer = '';
948
- this.isWaitingForResponse = false;
949
- this.responseSettleIgnoreUntil = 0;
950
- this.submitRetryUsed = false;
951
- this.submitRetryPromptSnippet = '';
952
- this.finishRetryCount = 0;
953
- this.currentTurnScope = null;
954
- this.activeModal = null;
955
- this.recordTrace('stale_idle_response_cleared', { reason });
956
- return true;
957
- }
958
-
959
- private hasMeaningfulResponseBuffer(promptSnippet: string): boolean {
960
- const raw = String(this.responseBuffer || '').trim();
961
- if (!raw) return false;
962
- const normalizedPrompt = compactPromptText(promptSnippet);
963
- if (!normalizedPrompt) return true;
964
- const normalizedBuffer = compactPromptText(raw);
965
- if (!normalizedBuffer) return false;
966
- if (normalizedBuffer === normalizedPrompt) return false;
967
- if (normalizedBuffer.startsWith(normalizedPrompt)) {
968
- const remainder = normalizedBuffer
969
- .slice(normalizedPrompt.length)
970
- .replace(/[─═\-]+/g, '')
971
- .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
972
- .replace(/accepteditson\([^)]*\)/gi, '')
973
- .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
974
- .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
975
- .replace(/esctointerrupt/gi, '')
976
- .replace(/❯/g, '')
977
- .replace(/^[\s\-–—:;,.!/?]+/, '')
978
- .trim();
979
- return remainder.length > 0;
980
- }
981
- return true;
982
- }
983
-
984
- private evaluateSettled(): void {
985
- const now = Date.now();
986
- if (this.submitPendingUntil > now || this.responseSettleIgnoreUntil > now) {
987
- const delayTime = Math.max(this.submitPendingUntil - now, this.responseSettleIgnoreUntil - now) + 50;
988
- if (this.settleTimer) clearTimeout(this.settleTimer);
989
- this.settleTimer = setTimeout(() => {
990
- this.settleTimer = null;
991
- this.settledBuffer = this.recentOutputBuffer;
992
- this.evaluateSettled();
993
- }, delayTime);
994
- return;
995
- }
789
+ // ─── Script dispatch — builds inputs for CliScriptRunner ──────────────────
996
790
 
997
- this.resolveStartupState('settled');
998
- if (this.startupParseGate) return;
999
-
1000
- if (!this.isWaitingForResponse && !this.currentTurnScope && !this.activeModal && !this.parseErrorMessage) {
1001
- const tail = this.settledBuffer || this.recentOutputBuffer;
1002
- const modal = this.runParseApproval(tail);
1003
- const lightweightStatus = this.cliScripts?.detectStatus
1004
- ? this.runDetectStatus(tail)
1005
- : null;
1006
- if (!modal && lightweightStatus === 'idle' && this.currentStatus === 'idle') {
1007
- return;
1008
- }
1009
- }
1010
-
1011
- const session = this.runParseSession();
1012
- if (!session) return;
1013
-
1014
- const { status, messages, parsedStatus } = session;
1015
- const modal = (session as any).activeModal ?? session.modal ?? null;
1016
- const parsedMessages = normalizeCliParsedMessages(messages, {
1017
- scope: null,
1018
- lastOutputAt: this.lastOutputAt,
1019
- });
1020
-
1021
- if (this.maybeCommitVisibleIdleTranscript(session, parsedMessages)) return;
1022
-
1023
- const lastParsedAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant');
1024
- const normalizedPromptSnippet = normalizePromptText(this.submitRetryPromptSnippet || this.currentTurnScope?.prompt || '');
1025
- const screenText = this.terminalScreen.getText() || '';
1026
-
1027
- this.recordTrace('settled', {
1028
- tail: summarizeCliTraceText(this.settledBuffer, 500),
1029
- screenText: summarizeCliTraceText(screenText, 1200),
1030
- detectStatus: status,
1031
- parsedStatus: parsedStatus || null,
1032
- parsedMessageCount: parsedMessages.length,
1033
- parsedLastAssistant: lastParsedAssistant ? summarizeCliTraceText(lastParsedAssistant.content, 280) : '',
1034
- parsedActiveModal: modal,
1035
- approval: modal,
1036
- ...buildCliTraceParseSnapshot({
1037
- accumulatedBuffer: this.accumulatedBuffer,
1038
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1039
- responseBuffer: this.responseBuffer,
1040
- partialResponse: this.responseBuffer,
1041
- scope: this.currentTurnScope,
1042
- }),
791
+ runParseSession(): ParsedSession | null {
792
+ const screenText = this.terminalScreen.getText();
793
+ const parseScreenText = this.getParseScreenText(screenText);
794
+ const tail = this.recentOutputBuffer.slice(-500);
795
+ const input = buildCliParseInput({
796
+ accumulatedBuffer: this.accumulatedBuffer,
797
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
798
+ recentOutputBuffer: this.recentOutputBuffer,
799
+ terminalScreenText: parseScreenText,
800
+ workingDir: this.workingDir,
801
+ providerSessionId: this.providerSessionId || undefined,
802
+ historySessionId: this.providerSessionId || undefined,
803
+ baseMessages: [],
804
+ partialResponse: this.responseBuffer,
805
+ isWaitingForResponse: this.engine.isWaitingForResponse,
806
+ scope: this.engine.currentTurnScope,
807
+ runtimeSettings: this.runtimeSettings,
808
+ spawnAt: this.spawnAt,
1043
809
  });
1044
-
1045
- if (
1046
- this.currentTurnScope
1047
- && !lastParsedAssistant
1048
- && !this.submitRetryUsed
1049
- && this.ptyProcess
1050
- && !this.hasActionableApproval()
1051
- && promptLikelyVisible(screenText, normalizedPromptSnippet)
1052
- && !this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)
1053
- ) {
1054
- this.submitRetryUsed = true;
1055
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1056
- LOG.info('CLI', `[${this.cliType}] Retrying submit key from settled parser (no assistant yet)`);
1057
- this.recordTrace('submit_write', {
1058
- mode: 'settled_retry',
1059
- sendKey: this.sendKey,
1060
- screenText: summarizeCliTraceText(screenText, 500),
1061
- });
1062
- this.ptyProcess.write(this.sendKey);
1063
- if (this.settleTimer) clearTimeout(this.settleTimer);
1064
- this.settleTimer = setTimeout(() => {
1065
- this.settleTimer = null;
1066
- this.settledBuffer = this.recentOutputBuffer;
1067
- this.evaluateSettled();
1068
- }, this.timeouts.outputSettle + 150);
1069
- return;
1070
- }
1071
-
1072
- if (this.currentTurnScope && !lastParsedAssistant) {
1073
- LOG.debug(
1074
- 'CLI',
1075
- `[${this.cliType}] Settled without assistant: prompt=${JSON.stringify(this.currentTurnScope.prompt).slice(0, 140)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 220)).slice(0, 260)} screen=${JSON.stringify(summarizeCliTraceText(screenText, 220)).slice(0, 260)} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'}`
1076
- );
1077
- }
1078
-
1079
- if (!status) return;
1080
-
1081
- const prevStatus = this.currentStatus;
1082
- const ctx: SettledEvalContext = { now, modal, status, parsedMessages, lastParsedAssistant, parsedStatus: parsedStatus || null, prevStatus };
1083
-
1084
- if (!this.applyPendingScriptStatusDebounce(ctx)) return;
1085
-
1086
- const recentInteractiveActivity = this.hasRecentInteractiveActivity(now);
1087
- LOG.debug(
1088
- 'CLI',
1089
- `[${this.cliType}] settled diagnostics prompt=${JSON.stringify(this.currentTurnScope?.prompt || '').slice(0, 140)} status=${String(status || '')} parsedStatus=${String(parsedStatus || '')} parsedMsgCount=${parsedMessages.length} lastParsedAssistant=${JSON.stringify(summarizeCliTraceText(lastParsedAssistant?.content || '', 120)).slice(0, 160)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 160)).slice(0, 220)} screen=${JSON.stringify(summarizeCliTraceText(screenText, 160)).slice(0, 220)}`
1090
- );
1091
-
1092
- const shouldHoldGenerating =
1093
- status === 'idle'
1094
- && this.isWaitingForResponse
1095
- && !modal
1096
- && recentInteractiveActivity
1097
- && !(parsedStatus === 'idle' && !!lastParsedAssistant);
1098
-
1099
- if (shouldHoldGenerating) { this.applyHoldGenerating(ctx, recentInteractiveActivity); return; }
1100
- if (status === 'waiting_approval') { this.applyWaitingApproval(ctx); return; }
1101
- if (status === 'generating') { this.applyGenerating(ctx); return; }
1102
- if (status === 'idle') { this.applyIdle(ctx, now); }
1103
- }
1104
-
1105
- // Returns false if the caller should bail out (debounce pending).
1106
- private applyPendingScriptStatusDebounce(ctx: SettledEvalContext): boolean {
1107
- const { now, status, prevStatus } = ctx;
1108
- const shouldDebounce =
1109
- prevStatus === 'idle'
1110
- && !this.isWaitingForResponse
1111
- && !this.currentTurnScope
1112
- && (status === 'generating' || status === 'waiting_approval');
1113
-
1114
- if (!shouldDebounce) {
1115
- this.pendingScriptStatus = null;
1116
- this.pendingScriptStatusSince = 0;
1117
- if (this.pendingScriptStatusTimer) { clearTimeout(this.pendingScriptStatusTimer); this.pendingScriptStatusTimer = null; }
1118
- return true;
1119
- }
1120
-
1121
- const armPending = (delayMs: number) => {
1122
- if (this.pendingScriptStatusTimer) clearTimeout(this.pendingScriptStatusTimer);
1123
- this.pendingScriptStatusTimer = setTimeout(() => {
1124
- this.pendingScriptStatusTimer = null;
1125
- this.settledBuffer = this.recentOutputBuffer;
1126
- this.evaluateSettled();
1127
- }, delayMs);
1128
- };
1129
-
1130
- if (this.pendingScriptStatus !== status) {
1131
- this.pendingScriptStatus = status as 'generating' | 'waiting_approval';
1132
- this.pendingScriptStatusSince = now;
1133
- armPending(ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS);
1134
- return false;
1135
- }
1136
- const elapsed = now - this.pendingScriptStatusSince;
1137
- if (elapsed < ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS) {
1138
- armPending(ProviderCliAdapter.SCRIPT_STATUS_DEBOUNCE_MS - elapsed);
1139
- return false;
1140
- }
1141
- return true;
1142
- }
1143
-
1144
- private applyHoldGenerating(ctx: SettledEvalContext, recentInteractiveActivity: boolean): void {
1145
- const { status } = ctx;
1146
- this.clearIdleFinishCandidate('hold_generating_recent_activity');
1147
- this.setStatus('generating', 'recent_activity_hold');
1148
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1149
- this.idleTimeout = setTimeout(() => {
1150
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1151
- if (this.shouldDeferIdleTimeoutFinish()) return;
1152
- this.finishResponse();
1153
- }
1154
- }, this.timeouts.generatingIdle);
1155
- this.recordTrace('hold_generating_recent_activity', {
1156
- scriptStatus: status,
1157
- recentInteractiveActivity,
1158
- lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1159
- lastScreenChangeAt: this.lastScreenChangeAt,
1160
- holdMs: this.getStatusActivityHoldMs(),
1161
- ...buildCliTraceParseSnapshot({
1162
- accumulatedBuffer: this.accumulatedBuffer,
1163
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1164
- responseBuffer: this.responseBuffer,
1165
- partialResponse: this.responseBuffer,
1166
- scope: this.currentTurnScope,
1167
- }),
810
+ const session = this.runner.parseSession({
811
+ ...input,
812
+ tail,
813
+ tailScreen: buildCliScreenSnapshot(tail),
1168
814
  });
1169
- this.onStatusChange?.();
815
+ if (session && typeof session === 'object') this.applyParsedSessionMetadata(session);
816
+ return session;
1170
817
  }
1171
818
 
1172
- private applyWaitingApproval(ctx: SettledEvalContext): void {
1173
- const { modal } = ctx;
1174
- this.clearIdleFinishCandidate('waiting_approval');
1175
- const inCooldown = this.lastApprovalResolvedAt && (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown;
1176
- if (inCooldown && !modal) {
1177
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1178
- this.activeModal = null;
1179
- if (this.isWaitingForResponse) {
1180
- this.setStatus('idle', inCooldown ? 'approval_cooldown_non_actionable' : 'approval_prompt_gone_non_actionable');
1181
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1182
- this.idleTimeout = setTimeout(() => {
1183
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1184
- if (this.shouldDeferIdleTimeoutFinish()) return;
1185
- this.finishResponse();
1186
- }
1187
- }, this.timeouts.generatingIdle);
1188
- } else {
1189
- this.setStatus('idle', inCooldown ? 'approval_cooldown_non_actionable' : 'approval_prompt_gone_non_actionable');
1190
- }
1191
- this.onStatusChange?.();
1192
- return;
1193
- }
1194
- if (!inCooldown) {
1195
- if (!modal) {
1196
- LOG.warn('CLI', `[${this.cliType}] detectStatus reported waiting_approval without parseApproval modal; ignoring non-actionable approval state`);
1197
- return;
1198
- }
1199
- this.isWaitingForResponse = true;
1200
- this.setStatus('waiting_approval', 'script_detect');
1201
- this.activeModal = modal;
1202
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1203
- this.armApprovalExitTimeout();
1204
- this.onStatusChange?.();
1205
- }
1206
- }
1207
-
1208
- private applyGenerating(ctx: SettledEvalContext): void {
1209
- const { modal, parsedMessages, lastParsedAssistant, parsedStatus, prevStatus } = ctx;
1210
- this.clearIdleFinishCandidate('generating');
1211
- const screenText = this.terminalScreen.getText() || '';
1212
- const effectiveScreenText = screenText || this.accumulatedBuffer;
1213
- const noActiveTurn = !this.currentTurnScope;
1214
- const looksIdleChrome = /(^|\n)\s*[❯›>]\s*(?:\n|$)/m.test(effectiveScreenText);
1215
- const parsedShowsLiveAssistantProgress = parsedStatus === 'generating'
1216
- && !!lastParsedAssistant
1217
- ;
1218
- if (prevStatus === 'idle' && !this.isWaitingForResponse && noActiveTurn && !modal && looksIdleChrome && !parsedShowsLiveAssistantProgress) {
1219
- return;
1220
- }
1221
- if (prevStatus === 'waiting_approval') {
1222
- // Transitioned out of approval → generating
1223
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1224
- this.activeModal = null;
1225
- this.lastApprovalResolvedAt = Date.now();
1226
- }
1227
- if (!this.isWaitingForResponse) {
1228
- this.isWaitingForResponse = true;
1229
- this.responseBuffer = '';
1230
- }
1231
- this.setStatus('generating', 'script_detect');
1232
- // Reset idle timeout
1233
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1234
- this.idleTimeout = setTimeout(() => {
1235
- if (this.isWaitingForResponse) {
1236
- if (this.shouldDeferIdleTimeoutFinish()) return;
1237
- this.finishResponse();
1238
- }
1239
- }, this.timeouts.generatingIdle);
1240
- this.onStatusChange?.();
1241
- }
1242
-
1243
- private applyIdle(ctx: SettledEvalContext, now: number): void {
1244
- const { modal, lastParsedAssistant, prevStatus } = ctx;
1245
- if (prevStatus === 'waiting_approval') {
1246
- if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
1247
- this.activeModal = null;
1248
- this.lastApprovalResolvedAt = Date.now();
1249
- this.setStatus('idle', 'approval_prompt_gone_script_idle');
1250
- }
1251
- if (!this.isWaitingForResponse) {
1252
- if (prevStatus !== 'idle') {
1253
- this.clearIdleFinishCandidate('idle_without_response');
1254
- this.setStatus('idle', 'script_detect');
1255
- this.onStatusChange?.();
1256
- }
1257
- return;
1258
- }
1259
- const quietForMs = this.lastNonEmptyOutputAt ? (now - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
1260
- const screenStableMs = this.lastScreenChangeAt ? (now - this.lastScreenChangeAt) : 0;
1261
- const hasAssistantTurn = !!lastParsedAssistant;
1262
- const assistantLength = lastParsedAssistant?.content?.length || 0;
1263
- const idleFinishConfirmMs = this.getIdleFinishConfirmMs();
1264
- const idleQuietThresholdMs = Math.max(idleFinishConfirmMs, this.timeouts.outputSettle);
1265
- const idleReady = !modal
1266
- && hasAssistantTurn
1267
- && quietForMs >= idleQuietThresholdMs
1268
- && screenStableMs >= idleFinishConfirmMs;
1269
- const candidate = this.idleFinishCandidate;
1270
- const candidateQuiet = !!candidate
1271
- && candidate.responseEpoch === this.responseEpoch
1272
- && candidate.lastOutputAt === this.lastOutputAt
1273
- && candidate.lastScreenChangeAt === this.lastScreenChangeAt
1274
- && assistantLength >= candidate.assistantLength
1275
- && (now - candidate.armedAt) >= idleFinishConfirmMs;
1276
-
1277
- this.recordTrace('idle_decision', {
1278
- quietForMs,
1279
- screenStableMs,
1280
- hasAssistantTurn,
1281
- assistantLength,
1282
- hasModal: !!modal,
1283
- idleQuietThresholdMs,
1284
- idleStableThresholdMs: idleFinishConfirmMs,
1285
- idleReady,
1286
- idleFinishConfirmMs,
1287
- idleFinishCandidate: candidate,
1288
- candidateQuiet,
1289
- canFinishImmediately: idleReady && candidateQuiet,
1290
- submitPendingUntil: this.submitPendingUntil,
1291
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1292
- ...buildCliTraceParseSnapshot({
1293
- accumulatedBuffer: this.accumulatedBuffer,
1294
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1295
- responseBuffer: this.responseBuffer,
1296
- partialResponse: this.responseBuffer,
1297
- scope: this.currentTurnScope,
1298
- }),
1299
- });
1300
-
1301
- if (idleReady && candidateQuiet) {
1302
- this.clearIdleFinishCandidate('finish_response');
1303
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1304
- this.finishResponse();
1305
- return;
1306
- }
1307
-
1308
- if (idleReady) {
1309
- if (!candidate) {
1310
- this.armIdleFinishCandidate(assistantLength);
1311
- return;
1312
- }
1313
- } else {
1314
- this.clearIdleFinishCandidate('idle_not_ready');
1315
- }
1316
-
1317
- if (this.idleTimeout) clearTimeout(this.idleTimeout);
1318
- this.idleTimeout = setTimeout(() => {
1319
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1320
- if (this.shouldDeferIdleTimeoutFinish()) return;
1321
- this.clearIdleFinishCandidate('idle_timeout_finish');
1322
- this.finishResponse();
1323
- }
1324
- }, this.timeouts.idleFinish);
1325
- }
1326
-
1327
- private finishResponse(): void {
1328
- if (this.submitPendingUntil > Date.now()) return;
1329
- if (this.responseSettleIgnoreUntil > Date.now()) return;
1330
- this.clearIdleFinishCandidate('finish_response_enter');
1331
- this.recordTrace('finish_response', {
1332
- ...buildCliTraceParseSnapshot({
1333
- accumulatedBuffer: this.accumulatedBuffer,
1334
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1335
- responseBuffer: this.responseBuffer,
1336
- partialResponse: this.responseBuffer,
1337
- scope: this.currentTurnScope,
1338
- }),
819
+ runDetectStatus(text: string): string | null {
820
+ const screenText = this.terminalScreen.getText();
821
+ const tail = text.slice(-500);
822
+ return this.runner.detectStatus({
823
+ tail,
824
+ screenText,
825
+ rawBuffer: this.accumulatedRawBuffer,
826
+ isWaitingForResponse: this.engine.isWaitingForResponse,
827
+ screen: buildCliScreenSnapshot(screenText),
828
+ tailScreen: buildCliScreenSnapshot(tail),
1339
829
  });
1340
- const commitResult = this.commitCurrentTranscript();
1341
- if (this.shouldRetryFinishResponse(commitResult)) {
1342
- this.finishRetryCount += 1;
1343
- this.recordTrace('finish_response_retry', {
1344
- retryCount: this.finishRetryCount,
1345
- retryDelayMs: ProviderCliAdapter.FINISH_RETRY_DELAY_MS,
1346
- assistantContent: summarizeCliTraceText(commitResult.assistantContent, 220),
1347
- ...buildCliTraceParseSnapshot({
1348
- accumulatedBuffer: this.accumulatedBuffer,
1349
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1350
- responseBuffer: this.responseBuffer,
1351
- partialResponse: this.responseBuffer,
1352
- scope: this.currentTurnScope,
1353
- }),
1354
- });
1355
- if (this.finishRetryTimer) clearTimeout(this.finishRetryTimer);
1356
- this.finishRetryTimer = setTimeout(() => {
1357
- this.finishRetryTimer = null;
1358
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1359
- this.finishResponse();
1360
- }
1361
- }, ProviderCliAdapter.FINISH_RETRY_DELAY_MS);
1362
- return;
1363
- }
1364
- this.clearAllTimers();
1365
- this.responseBuffer = '';
1366
- this.isWaitingForResponse = false;
1367
- this.responseSettleIgnoreUntil = 0;
1368
- this.submitRetryUsed = false;
1369
- this.submitRetryPromptSnippet = '';
1370
- this.finishRetryCount = 0;
1371
- this.currentTurnScope = null;
1372
- this.activeModal = null;
1373
- this.setStatus('idle', 'response_finished');
1374
- this.onStatusChange?.();
1375
830
  }
1376
831
 
1377
- private maybeCommitVisibleIdleTranscript(session: ParsedSession, parsedMessages: CliChatMessage[]): boolean {
1378
- const allowImmediateScriptIdleCommit = this.provider.allowInputDuringGeneration === true;
1379
- if (!allowImmediateScriptIdleCommit) return false;
1380
- if (
1381
- !session
1382
- || session.status !== 'idle'
1383
- || !this.isWaitingForResponse
1384
- || !this.currentTurnScope
1385
- || this.activeModal
1386
- || session.modal
1387
- ) {
1388
- return false;
1389
- }
1390
-
1391
- const visibleAssistant = [...parsedMessages].reverse().find((m) => m.role === 'assistant' && m.content.trim());
1392
- if (!visibleAssistant) return false;
1393
-
1394
- this.clearAllTimers();
1395
- this.responseBuffer = '';
1396
- this.isWaitingForResponse = false;
1397
- this.responseSettleIgnoreUntil = 0;
1398
- this.submitRetryUsed = false;
1399
- this.submitRetryPromptSnippet = '';
1400
- this.finishRetryCount = 0;
1401
- this.currentTurnScope = null;
1402
- this.activeModal = null;
1403
- this.setStatus('idle', 'script_idle_commit');
1404
- this.onStatusChange?.();
1405
- this.recordTrace('script_idle_commit', {
1406
- messageCount: parsedMessages.length,
1407
- lastAssistant: summarizeCliTraceText(visibleAssistant.content, 320),
832
+ runParseApproval(tail: string): { message: string; buttons: string[] } | null {
833
+ const screenText = this.terminalScreen.getText();
834
+ const buffer = screenText || this.accumulatedBuffer;
835
+ return this.runner.parseApproval({
836
+ buffer,
837
+ screenText,
838
+ rawBuffer: this.accumulatedRawBuffer,
839
+ tail,
840
+ screen: buildCliScreenSnapshot(screenText),
841
+ bufferScreen: buildCliScreenSnapshot(buffer),
842
+ tailScreen: buildCliScreenSnapshot(tail),
1408
843
  });
1409
- return true;
1410
- }
1411
-
1412
- private commitCurrentTranscript(): { hasAssistant: boolean; assistantContent: string } {
1413
- const parsed = this.parseCurrentTranscript(
1414
- [],
1415
- this.responseBuffer,
1416
- this.currentTurnScope,
1417
- );
1418
- if (parsed && Array.isArray(parsed.messages)) {
1419
- const parsedMessages = normalizeCliParsedMessages(parsed.messages, {
1420
- scope: null,
1421
- lastOutputAt: this.lastOutputAt,
1422
- });
1423
- const lastAssistant = [...parsedMessages].reverse().find((message) => message.role === 'assistant');
1424
- if (this.currentTurnScope) {
1425
- LOG.info(
1426
- 'CLI',
1427
- `[${this.cliType}] commitCurrentTranscript parserMessages=${parsedMessages.length} finalLastAssistant=${JSON.stringify(summarizeCliTraceText(lastAssistant?.content || '', 220)).slice(0, 260)}`
1428
- );
1429
- }
1430
- this.recordTrace('commit_transcript', {
1431
- parsedStatus: parsed.status || null,
1432
- messageCount: parsedMessages.length,
1433
- lastAssistant: lastAssistant ? summarizeCliTraceText(lastAssistant.content, 320) : '',
1434
- messages: summarizeCliTraceMessages(parsedMessages),
1435
- ...buildCliTraceParseSnapshot({
1436
- accumulatedBuffer: this.accumulatedBuffer,
1437
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1438
- responseBuffer: this.responseBuffer,
1439
- partialResponse: this.responseBuffer,
1440
- scope: this.currentTurnScope,
1441
- }),
1442
- });
1443
- if (!lastAssistant && this.currentTurnScope) {
1444
- LOG.warn(
1445
- 'CLI',
1446
- `[${this.cliType}] Commit without assistant turn: prompt=${JSON.stringify(this.currentTurnScope.prompt).slice(0, 140)} responseBuffer=${JSON.stringify(summarizeCliTraceText(this.responseBuffer, 220)).slice(0, 260)} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'} scriptsPath=${this.providerResolutionMeta.scriptsPath || '-'}`
1447
- );
1448
- }
1449
- const hasAssistant = !!lastAssistant;
1450
- return {
1451
- hasAssistant,
1452
- assistantContent: lastAssistant?.content || '',
1453
- };
1454
- }
1455
- if (this.currentTurnScope) {
1456
- LOG.info(
1457
- 'CLI',
1458
- `[${this.cliType}] commitCurrentTranscript parsed.messages=none responseBufferLen=${this.responseBuffer.length} accumulatedBufferLen=${this.accumulatedBuffer.length} parsedStatus=${parsed?.status || '-'} providerDir=${this.providerResolutionMeta.providerDir || '-'} scriptDir=${this.providerResolutionMeta.scriptDir || '-'}`
1459
- );
1460
- }
1461
- return {
1462
- hasAssistant: false,
1463
- assistantContent: '',
1464
- };
1465
- }
1466
-
1467
-
1468
- // ─── Script Execution ──────────────────────────
1469
-
1470
- private invokeCliScript<T>(script: Function, input: any): T {
1471
- const hasStateFactory = typeof this.cliScripts?.createState === 'function';
1472
- const expectsStateArgument = hasStateFactory || this.scriptState !== null || script.length >= 2;
1473
- return expectsStateArgument
1474
- ? script(this.scriptState, input)
1475
- : script(input);
1476
- }
1477
-
1478
- private runParseSession(): ParsedSession | null {
1479
- if (typeof this.cliScripts?.parseSession !== 'function') {
1480
- this.parseErrorMessage = `${this.cliType} parseSession unavailable`;
1481
- return null;
1482
- }
1483
- try {
1484
- const screenText = this.terminalScreen.getText();
1485
- const parseScreenText = this.getParseScreenText(screenText);
1486
- const tail = this.recentOutputBuffer.slice(-500);
1487
- const input = buildCliParseInput({
1488
- accumulatedBuffer: this.accumulatedBuffer,
1489
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1490
- recentOutputBuffer: this.recentOutputBuffer,
1491
- terminalScreenText: parseScreenText,
1492
- baseMessages: [],
1493
- partialResponse: this.responseBuffer,
1494
- isWaitingForResponse: this.isWaitingForResponse,
1495
- scope: this.currentTurnScope,
1496
- runtimeSettings: this.runtimeSettings,
1497
- });
1498
- const session = this.invokeCliScript<ParsedSession | null>(
1499
- this.cliScripts.parseSession,
1500
- { ...input, tail, tailScreen: buildCliScreenSnapshot(tail) },
1501
- );
1502
- this.parseErrorMessage = null;
1503
- return session && typeof session === 'object' ? session : null;
1504
- } catch (e: any) {
1505
- const message = e?.message || String(e);
1506
- this.parseErrorMessage = message;
1507
- LOG.warn('CLI', `[${this.cliType}] parseSession error: ${message}`);
1508
- return null;
1509
- }
1510
- }
1511
-
1512
- private runDetectStatus(text: string): string | null {
1513
- if (!this.cliScripts?.detectStatus) return null;
1514
- try {
1515
- const screenText = this.terminalScreen.getText();
1516
- const status = this.invokeCliScript<string | null>(this.cliScripts.detectStatus, {
1517
- tail: text.slice(-500),
1518
- screenText,
1519
- rawBuffer: this.accumulatedRawBuffer,
1520
- isWaitingForResponse: this.isWaitingForResponse,
1521
- screen: buildCliScreenSnapshot(screenText),
1522
- tailScreen: buildCliScreenSnapshot(text.slice(-500)),
1523
- });
1524
- return status;
1525
- } catch (e: any) {
1526
- LOG.warn('CLI', `[${this.cliType}] detectStatus error: ${e.message}`);
1527
- return null;
1528
- }
1529
844
  }
1530
845
 
1531
- private runParseApproval(tail: string): { message: string; buttons: string[] } | null {
1532
- if (!this.cliScripts?.parseApproval) return null;
1533
- try {
1534
- const screenText = this.terminalScreen.getText();
1535
- const buffer = screenText || this.accumulatedBuffer;
1536
- return this.invokeCliScript<{ message: string; buttons: string[] } | null>(this.cliScripts.parseApproval, {
1537
- buffer,
1538
- screenText,
1539
- rawBuffer: this.accumulatedRawBuffer,
1540
- tail,
1541
- screen: buildCliScreenSnapshot(screenText),
1542
- bufferScreen: buildCliScreenSnapshot(buffer),
1543
- tailScreen: buildCliScreenSnapshot(tail),
1544
- });
1545
- } catch (e: any) {
1546
- LOG.warn('CLI', `[${this.cliType}] parseApproval error: ${e.message}`);
1547
- return null;
846
+ private applyParsedSessionMetadata(parsed: any): void {
847
+ const providerSessionId = typeof parsed?.providerSessionId === 'string' && parsed.providerSessionId.trim()
848
+ ? parsed.providerSessionId.trim()
849
+ : '';
850
+ if (providerSessionId && providerSessionId !== this.providerSessionId) {
851
+ this.providerSessionId = providerSessionId;
852
+ this.updateRuntimeMeta({ providerSessionId });
1548
853
  }
1549
854
  }
1550
855
 
1551
- private hasActionableApproval(startupModal: { message: string; buttons: string[] } | null = null): boolean {
1552
- return !!(startupModal || this.activeModal);
1553
- }
1554
-
1555
856
  private projectEffectiveStatus(startupModal: { message: string; buttons: string[] } | null = null): CliSessionStatus['status'] {
1556
857
  if (this.parseErrorMessage) return 'error';
1557
- if (this.hasActionableApproval(startupModal)) return 'waiting_approval';
1558
- if (this.isWaitingForResponse && this.currentTurnScope && this.currentStatus !== 'stopped') return 'generating';
1559
- return this.currentStatus;
858
+ if (!!(startupModal || this.engine.activeModal)) return 'waiting_approval';
859
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope && this.engine.currentStatus !== 'stopped') return 'generating';
860
+ return this.engine.currentStatus;
1560
861
  }
1561
862
 
1562
863
  // ─── Public API (CliAdapter) ───────────────────
@@ -1564,17 +865,73 @@ export class ProviderCliAdapter implements CliAdapter {
1564
865
  getStatus(options: { allowParse?: boolean } = {}): CliSessionStatus {
1565
866
  const allowParse = options.allowParse !== false;
1566
867
  const startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
868
+ const startupDetectedStatus = allowParse && this.startupParseGate && !startupModal
869
+ ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
870
+ : null;
1567
871
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
1568
- let effectiveModal = startupModal || this.activeModal;
872
+ let effectiveModal = startupModal || this.engine.activeModal;
873
+ // (fix) When we have no captured modal yet, take one more live attempt
874
+ // with the current screen text — the engine's settle pass can miss
875
+ // the modal when it happens to fire exactly between writes, and
876
+ // without a modal here the dashboard could never show the buttons.
877
+ // We deliberately do NOT overwrite an existing engine.activeModal so
878
+ // a stable matched modal wins. This runs even outside the startup
879
+ // gate because Claude's approval frames can appear long after launch.
880
+ if (allowParse && !effectiveModal && this.engine.isWaitingForResponse) {
881
+ const liveDetect = this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText());
882
+ if (liveDetect === 'waiting_approval') {
883
+ const liveModal = this.runParseApproval(this.terminalScreen.getText())
884
+ || this.runParseApproval(this.recentOutputBuffer);
885
+ // (fix) Only surface modals that have at least one non-empty
886
+ // button. Half-rendered approval frames produced
887
+ // { message, buttons: [] } briefly — auto-approve would then
888
+ // pick buttonIndex=-1 and resolveModal would write the
889
+ // provider's index-0 key into the prompt. Skip those frames
890
+ // and let a later evaluate find the complete modal.
891
+ const buttonsOk = liveModal && Array.isArray(liveModal.buttons)
892
+ && liveModal.buttons.some((b: any) => typeof b === 'string' && b.trim());
893
+ if (liveModal && buttonsOk) {
894
+ effectiveModal = liveModal;
895
+ if (!this.engine.activeModal) this.engine.activeModal = liveModal;
896
+ }
897
+ }
898
+ }
899
+ // Only surface waiting_approval when we ALSO have a concrete modal
900
+ // (message + buttons). detectStatus alone can fire while parseApproval
901
+ // is still null — the engine logs "detectStatus=waiting_approval but
902
+ // parseApproval returned null; ignoring". Without this guard getStatus
903
+ // was shipping a bare waiting_approval with activeModal=null and the
904
+ // user perceived the flow as broken.
905
+ if (startupDetectedStatus === 'waiting_approval' && effectiveModal) {
906
+ effectiveStatus = 'waiting_approval';
907
+ } else if (startupDetectedStatus === 'idle' && !startupModal && !effectiveModal) {
908
+ effectiveStatus = 'idle';
909
+ }
1569
910
  if (allowParse && !startupModal && !effectiveModal) {
1570
911
  const parsed = this.getFreshParsedStatusCache();
1571
912
  const parsedModal = parsed?.activeModal && Array.isArray(parsed.activeModal.buttons)
1572
913
  && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
1573
914
  ? parsed.activeModal
1574
915
  : null;
916
+ const hasFinalAssistant = (p: any) => {
917
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
918
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
919
+ };
1575
920
  if (parsed?.status === 'waiting_approval' && parsedModal) {
1576
921
  effectiveStatus = 'waiting_approval';
1577
922
  effectiveModal = parsedModal;
923
+ } else if (
924
+ effectiveStatus === 'idle'
925
+ && parsed?.status === 'generating'
926
+ && !hasFinalAssistant(parsed)
927
+ ) {
928
+ effectiveStatus = 'generating';
929
+ } else if (
930
+ effectiveStatus === 'generating'
931
+ && parsed?.status === 'idle'
932
+ && hasFinalAssistant(parsed)
933
+ ) {
934
+ effectiveStatus = 'idle';
1578
935
  }
1579
936
  }
1580
937
  const bufferState = this.getBufferState();
@@ -1583,8 +940,20 @@ export class ProviderCliAdapter implements CliAdapter {
1583
940
  messages: [],
1584
941
  workingDir: this.workingDir,
1585
942
  activeModal: effectiveModal,
1586
- errorMessage: this.parseErrorMessage || undefined,
1587
- errorReason: this.parseErrorMessage ? 'parse_error' : undefined,
943
+ approvalEntrySeq: this.engine.approvalEntrySeq,
944
+ pendingOutboundCount: this.pendingOutboundQueue.length,
945
+ pendingOutboundMessages: this.pendingOutboundQueue.map((message) => ({
946
+ id: message.id,
947
+ role: message.role,
948
+ content: message.content,
949
+ queuedAt: message.queuedAt,
950
+ source: message.source,
951
+ })),
952
+ errorMessage: this.parseErrorMessage || this.engine.providerErrorMessage || undefined,
953
+ errorReason: this.parseErrorMessage ? 'parse_error' : (this.engine.providerErrorReason || undefined),
954
+ providerSessionId: this.providerSessionId || undefined,
955
+ lastOutputAt: this.lastOutputAt,
956
+ lastScreenChangeAt: this.lastScreenChangeAt,
1588
957
  ...(bufferState ? { bufferState } : {}),
1589
958
  };
1590
959
  }
@@ -1600,15 +969,16 @@ export class ProviderCliAdapter implements CliAdapter {
1600
969
  const cached = this.parsedStatusCache;
1601
970
  const accumulatedRawBufferKey = this.getAccumulatedRawBufferCacheKey();
1602
971
  if (
1603
- cached
972
+ !this.providerOwnsTranscript()
973
+ && cached
1604
974
  && cached.responseBuffer === this.responseBuffer
1605
- && cached.currentTurnScope === this.currentTurnScope
975
+ && cached.currentTurnScope === this.engine.currentTurnScope
1606
976
  && cached.recentOutputBuffer === this.recentOutputBuffer
1607
977
  && cached.accumulatedBuffer === this.accumulatedBuffer
1608
978
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
1609
979
  && cached.screenText === parseScreenText
1610
- && cached.currentStatus === this.currentStatus
1611
- && cached.activeModal === this.activeModal
980
+ && cached.currentStatus === this.engine.currentStatus
981
+ && cached.activeModal === this.engine.activeModal
1612
982
  && cached.cliName === this.cliName
1613
983
  ) {
1614
984
  return cached.result;
@@ -1623,14 +993,20 @@ export class ProviderCliAdapter implements CliAdapter {
1623
993
  const bufferState = this.getBufferState();
1624
994
  const result = {
1625
995
  id: (parsed as any).id || 'cli_session',
1626
- status: parsed.status || this.currentStatus,
996
+ status: parsed.status || this.engine.currentStatus,
1627
997
  title: (parsed as any).title || this.cliName,
1628
998
  messages: normalizeCliParsedMessages(parsed.messages, {
1629
999
  scope: null,
1630
1000
  lastOutputAt: this.lastOutputAt,
1631
1001
  }),
1632
1002
  activeModal,
1633
- providerSessionId: typeof (parsed as any).providerSessionId === 'string' ? (parsed as any).providerSessionId : undefined,
1003
+ providerSessionId: this.providerSessionId || (typeof (parsed as any).providerSessionId === 'string' ? (parsed as any).providerSessionId : undefined),
1004
+ errorMessage: typeof (parsed as any).errorMessage === 'string' && (parsed as any).errorMessage.trim()
1005
+ ? (parsed as any).errorMessage.trim()
1006
+ : undefined,
1007
+ errorReason: typeof (parsed as any).errorReason === 'string' && (parsed as any).errorReason.trim()
1008
+ ? (parsed as any).errorReason.trim()
1009
+ : undefined,
1634
1010
  ...(bufferState ? { bufferState } : {}),
1635
1011
  ...((parsed as any).transcriptAuthority === 'provider' || (parsed as any).transcriptAuthority === 'daemon'
1636
1012
  ? { transcriptAuthority: (parsed as any).transcriptAuthority }
@@ -1642,13 +1018,13 @@ export class ProviderCliAdapter implements CliAdapter {
1642
1018
 
1643
1019
  this.parsedStatusCache = {
1644
1020
  responseBuffer: this.responseBuffer,
1645
- currentTurnScope: this.currentTurnScope,
1021
+ currentTurnScope: this.engine.currentTurnScope,
1646
1022
  recentOutputBuffer: this.recentOutputBuffer,
1647
1023
  accumulatedBuffer: this.accumulatedBuffer,
1648
1024
  accumulatedRawBufferKey,
1649
1025
  screenText: parseScreenText,
1650
- currentStatus: this.currentStatus,
1651
- activeModal: this.activeModal,
1026
+ currentStatus: this.engine.currentStatus,
1027
+ activeModal: this.engine.activeModal,
1652
1028
  cliName: this.cliName,
1653
1029
  result,
1654
1030
  };
@@ -1656,22 +1032,22 @@ export class ProviderCliAdapter implements CliAdapter {
1656
1032
  }
1657
1033
 
1658
1034
  async invokeScript(scriptName: string, args?: Record<string, any>): Promise<any> {
1659
- const fn = this.cliScripts?.[scriptName];
1660
- if (typeof fn !== 'function') {
1661
- throw new Error(`CLI script '${scriptName}' not available`);
1662
- }
1663
1035
  const input = buildCliParseInput({
1664
1036
  accumulatedBuffer: this.accumulatedBuffer,
1665
1037
  accumulatedRawBuffer: this.accumulatedRawBuffer,
1666
1038
  recentOutputBuffer: this.recentOutputBuffer,
1667
1039
  terminalScreenText: this.getParseScreenText(this.terminalScreen.getText()),
1040
+ workingDir: this.workingDir,
1041
+ providerSessionId: this.providerSessionId || undefined,
1042
+ historySessionId: this.providerSessionId || undefined,
1668
1043
  baseMessages: [],
1669
1044
  partialResponse: this.responseBuffer,
1670
- isWaitingForResponse: this.isWaitingForResponse,
1671
- scope: this.currentTurnScope,
1045
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1046
+ scope: this.engine.currentTurnScope,
1672
1047
  runtimeSettings: this.runtimeSettings,
1048
+ spawnAt: this.spawnAt,
1673
1049
  });
1674
- return await Promise.resolve(this.invokeCliScript(fn, {
1050
+ return await Promise.resolve(this.runner.invokeByName(scriptName, {
1675
1051
  ...input,
1676
1052
  args: args && typeof args === 'object' ? { ...args } : {},
1677
1053
  }));
@@ -1683,7 +1059,7 @@ export class ProviderCliAdapter implements CliAdapter {
1683
1059
 
1684
1060
  /** Whether this adapter has CLI scripts loaded */
1685
1061
  hasCliScripts(): boolean {
1686
- return typeof this.cliScripts?.detectStatus === 'function';
1062
+ return this.runner.hasDetectStatus();
1687
1063
  }
1688
1064
 
1689
1065
  /**
@@ -1692,24 +1068,29 @@ export class ProviderCliAdapter implements CliAdapter {
1692
1068
  */
1693
1069
  async resolveAction(data: any): Promise<void> {
1694
1070
  let promptText = '';
1695
- if (this.cliScripts && typeof this.cliScripts.resolveAction === 'function') {
1696
- try {
1697
- promptText = this.cliScripts.resolveAction(data);
1698
- } catch (e: any) {
1699
- LOG.warn('CLI', `[${this.cliType}] resolveAction error: ${e.message}`);
1700
- }
1071
+ try {
1072
+ promptText = this.runner.invokeByName('resolveAction', data);
1073
+ } catch {
1074
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script not available`);
1075
+ return;
1701
1076
  }
1702
1077
  if (!promptText) {
1703
- LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not supply a prompt`);
1078
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not return a prompt`);
1704
1079
  return;
1705
1080
  }
1706
1081
  await this.sendMessage(promptText);
1707
1082
  }
1708
1083
 
1084
+ async setInteractivePromptResponse(_response: InteractivePromptResponse): Promise<void> {
1085
+ // Legacy TUI providers do not currently expose an interactive prompt
1086
+ // protocol. Spec-backed claude-cli implements the first real wiring.
1087
+ }
1088
+
1709
1089
  private isSubmitStuck(normalizedPromptSnippet: string): boolean {
1710
- if (!this.ptyProcess || !this.isWaitingForResponse || this.submitRetryUsed) return false;
1711
- if (this.hasActionableApproval()) return false;
1712
- if (this.hasMeaningfulResponseBuffer(normalizedPromptSnippet)) return false;
1090
+ if (!this.ptyProcess || !this.engine.isWaitingForResponse || this.engine.submitRetryUsed) return false;
1091
+ if (this.engine.hasActionableApproval()) return false;
1092
+ // If there's already meaningful response content beyond the echoed prompt, not stuck
1093
+ if (this.hasMeaningfulResponseBufferLocal(normalizedPromptSnippet)) return false;
1713
1094
  const screenText = this.terminalScreen.getText();
1714
1095
  if (!promptLikelyVisible(screenText, normalizedPromptSnippet)) return false;
1715
1096
  const liveApproval = this.runParseApproval(screenText) || this.runParseApproval(this.recentOutputBuffer);
@@ -1718,20 +1099,41 @@ export class ProviderCliAdapter implements CliAdapter {
1718
1099
  return liveStatus !== 'generating' && liveStatus !== 'waiting_approval';
1719
1100
  }
1720
1101
 
1102
+ private hasMeaningfulResponseBufferLocal(promptSnippet: string): boolean {
1103
+ const raw = String(this.responseBuffer || '').trim();
1104
+ if (!raw) return false;
1105
+ const normalizedPrompt = compactPromptText(promptSnippet);
1106
+ if (!normalizedPrompt) return true;
1107
+ const normalizedBuffer = compactPromptText(raw);
1108
+ if (!normalizedBuffer) return false;
1109
+ if (normalizedBuffer === normalizedPrompt) return false;
1110
+ if (normalizedBuffer.startsWith(normalizedPrompt)) {
1111
+ const remainder = normalizedBuffer
1112
+ .slice(normalizedPrompt.length)
1113
+ .replace(/[─═\-]+/g, '')
1114
+ .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
1115
+ .replace(/accepteditson\([^)]*\)/gi, '')
1116
+ .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
1117
+ .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
1118
+ .replace(/esctointerrupt/gi, '')
1119
+ .replace(/❯/g, '')
1120
+ .replace(/^[\s\-–—:;,.!/?]+/, '')
1121
+ .trim();
1122
+ return remainder.length > 0;
1123
+ }
1124
+ return true;
1125
+ }
1126
+
1721
1127
  private async writeToPty(data: string): Promise<void> {
1722
1128
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1723
1129
  await this.ptyProcess.write(data);
1724
1130
  }
1725
1131
 
1726
1132
  private resetPendingSendState(reason: string): void {
1727
- this.isWaitingForResponse = false;
1728
1133
  this.responseBuffer = '';
1729
- this.currentTurnScope = null;
1730
- this.submitPendingUntil = 0;
1731
- this.clearIdleFinishCandidate(reason);
1732
1134
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
1733
- if (this.submitRetryTimer) { clearTimeout(this.submitRetryTimer); this.submitRetryTimer = null; }
1734
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1135
+ this.engine.resetActiveTurnState();
1136
+ this.engine.clearIdleFinishCandidate(reason);
1735
1137
  }
1736
1138
 
1737
1139
  private commitSendUserTurn(state: SendMessageState): void {
@@ -1748,42 +1150,14 @@ export class ProviderCliAdapter implements CliAdapter {
1748
1150
  }
1749
1151
  this.responseTimeout = setTimeout(() => {
1750
1152
  this.responseTimeout = null;
1751
- if (!this.isWaitingForResponse) return;
1752
-
1753
- const detectedStatusBeforeEval = this.runDetectStatus(this.recentOutputBuffer);
1754
- this.recordTrace('response_timeout_check', {
1755
- timeoutMs,
1756
- detectedStatus: detectedStatusBeforeEval,
1757
- currentStatus: this.currentStatus,
1758
- isWaitingForResponse: this.isWaitingForResponse,
1759
- hasActionableApproval: this.hasActionableApproval(),
1760
- ...buildCliTraceParseSnapshot({
1761
- accumulatedBuffer: this.accumulatedBuffer,
1762
- accumulatedRawBuffer: this.accumulatedRawBuffer,
1763
- responseBuffer: this.responseBuffer,
1764
- partialResponse: this.responseBuffer,
1765
- scope: this.currentTurnScope,
1766
- }),
1767
- });
1153
+ if (!this.engine.isWaitingForResponse) return;
1768
1154
 
1769
- // maxResponse is a watchdog/checkpoint, not a completion signal. The old
1770
- // behavior called finishResponse() unconditionally at the default 300s,
1771
- // which fabricated idle transitions and downstream generating_completed
1772
- // notifications while long-running CLIs were still generating. Re-run the
1155
+ // maxResponse is a watchdog/checkpoint, not a completion signal. Re-run the
1773
1156
  // normal settled parser instead and keep the turn open unless the provider
1774
1157
  // actually reports an idle, commit-ready state.
1775
- this.settledBuffer = this.recentOutputBuffer;
1776
- this.evaluateSettled();
1777
-
1778
- if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1779
- const detectedStatusAfterEval = this.runDetectStatus(this.recentOutputBuffer);
1780
- this.recordTrace('response_timeout_kept_open', {
1781
- timeoutMs,
1782
- detectedStatusBeforeEval,
1783
- detectedStatusAfterEval,
1784
- currentStatus: this.currentStatus,
1785
- isWaitingForResponse: this.isWaitingForResponse,
1786
- });
1158
+ this.engine.evaluateSettled(this.getSnapshot());
1159
+
1160
+ if (this.engine.isWaitingForResponse && !this.engine.hasActionableApproval()) {
1787
1161
  this.armResponseTimeout();
1788
1162
  }
1789
1163
  }, timeoutMs);
@@ -1798,34 +1172,20 @@ export class ProviderCliAdapter implements CliAdapter {
1798
1172
  private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
1799
1173
  this.submitRetryTimer = null;
1800
1174
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1801
- const screenText = this.terminalScreen.getText();
1802
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1175
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1803
1176
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt ${attempt})`);
1804
- this.recordTrace('submit_write', {
1805
- mode: 'submit_retry',
1806
- attempt,
1807
- sendKey: this.sendKey,
1808
- screenText: summarizeCliTraceText(screenText, 500),
1809
- });
1810
1177
  this.writeSubmitKeyForRetry('submit_retry');
1811
- if (attempt >= 3) { this.submitRetryUsed = true; return; }
1178
+ if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
1812
1179
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
1813
1180
  }
1814
1181
 
1815
1182
  private retryImmediateSubmitIfStuck(state: SendMessageState): void {
1816
1183
  this.submitRetryTimer = null;
1817
1184
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1818
- const screenText = this.terminalScreen.getText();
1819
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1185
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1820
1186
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt 1)`);
1821
- this.recordTrace('submit_write', {
1822
- mode: 'immediate_retry',
1823
- attempt: 1,
1824
- sendKey: this.sendKey,
1825
- screenText: summarizeCliTraceText(screenText, 500),
1826
- });
1827
1187
  this.writeSubmitKeyForRetry('immediate_retry');
1828
- this.submitRetryUsed = true;
1188
+ this.engine.submitRetryUsed = true;
1829
1189
  }
1830
1190
 
1831
1191
  private submitSendKey(state: SendMessageState, completion: SendMessageCompletion): void {
@@ -1833,13 +1193,7 @@ export class ProviderCliAdapter implements CliAdapter {
1833
1193
  completion.resolveOnce();
1834
1194
  return;
1835
1195
  }
1836
- this.submitPendingUntil = 0;
1837
- const screenText = this.terminalScreen.getText();
1838
- this.recordTrace('submit_write', {
1839
- mode: 'submit_key',
1840
- sendKey: this.sendKey,
1841
- screenText: summarizeCliTraceText(screenText, 500),
1842
- });
1196
+ this.engine.submitPendingUntil = 0;
1843
1197
  void this.writeToPty(this.sendKey).then(() => {
1844
1198
  this.commitSendUserTurn(state);
1845
1199
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, 1), state.retryDelayMs);
@@ -1849,13 +1203,7 @@ export class ProviderCliAdapter implements CliAdapter {
1849
1203
  }
1850
1204
 
1851
1205
  private submitImmediatePrompt(state: SendMessageState, completion: SendMessageCompletion): void {
1852
- this.submitPendingUntil = 0;
1853
- this.recordTrace('submit_write', {
1854
- mode: 'immediate',
1855
- text: summarizeCliTraceText(state.text, 500),
1856
- sendKey: this.sendKey,
1857
- screenText: summarizeCliTraceText(this.terminalScreen.getText(), 500),
1858
- });
1206
+ this.engine.submitPendingUntil = 0;
1859
1207
  void this.writeToPty(state.text + this.sendKey).then(() => {
1860
1208
  this.commitSendUserTurn(state);
1861
1209
  this.submitRetryTimer = setTimeout(() => this.retryImmediateSubmitIfStuck(state), state.retryDelayMs);
@@ -1899,7 +1247,8 @@ export class ProviderCliAdapter implements CliAdapter {
1899
1247
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
1900
1248
  screenText: summarizeCliTraceText(screenText, 1000),
1901
1249
  };
1902
- this.recordTrace('submit_echo_missing', diagnostic);
1250
+ LOG.warn('CLI', `[${this.cliType}] submit_echo_missing: ${JSON.stringify(diagnostic)}`);
1251
+
1903
1252
  if (this.requirePromptEchoBeforeSubmit) {
1904
1253
  // At this point the prompt text write already completed. Rejecting without
1905
1254
  // a submit key can leave the delegated CLI with an unsent prompt sitting at
@@ -1924,12 +1273,110 @@ export class ProviderCliAdapter implements CliAdapter {
1924
1273
  ), 50);
1925
1274
  }
1926
1275
 
1927
- async sendMessage(text: string): Promise<void> {
1276
+ async sendMessage(text: string, options: { force?: boolean } = {}): Promise<void> {
1277
+ if (options.force === true) {
1278
+ await this.forceSendMessage(text);
1279
+ return;
1280
+ }
1281
+ await this.sendMessageNow(text, true);
1282
+ }
1283
+
1284
+ async forceSendMessage(text: string): Promise<void> {
1285
+ if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1286
+ const content = String(text || '');
1287
+ if (!content.trim()) return;
1288
+ LOG.info('CLI', `[${this.cliType}] force-sending prompt while status=${this.engine.currentStatus}`);
1289
+ await this.writeToPty(content + this.sendKey);
1290
+ this.onStatusChange?.();
1291
+ }
1292
+
1293
+ private enqueuePendingOutboundMessage(text: string, reason: string): void {
1294
+ const content = String(text || '');
1295
+ const duplicate = this.pendingOutboundQueue.some((message) => message.content === content);
1296
+ if (duplicate) {
1297
+ return;
1298
+ }
1299
+ const queuedAt = Date.now();
1300
+ const message: PendingOutboundMessage = {
1301
+ id: `${queuedAt}:${this.pendingOutboundQueue.length}:${Math.random().toString(36).slice(2, 10)}`,
1302
+ role: 'user',
1303
+ content,
1304
+ queuedAt,
1305
+ source: 'sendMessage',
1306
+ };
1307
+ this.pendingOutboundQueue.push(message);
1308
+ LOG.info('CLI', `[${this.cliType}] queued outbound message while busy (${reason}); queue=${this.pendingOutboundQueue.length}`);
1309
+ this.onStatusChange?.();
1310
+ }
1311
+
1312
+ private shouldQueuePendingOutboundMessage(parsedStatusBeforeSend: any | null = null): string | null {
1313
+ if (this.provider.allowInputDuringGeneration === true) return null;
1314
+ if (this.engine.hasActionableApproval()) return null;
1315
+ const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1316
+ ? String(parsedStatusBeforeSend.status)
1317
+ : '';
1318
+ const hasFinalAssistant = (p: any) => {
1319
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1320
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1321
+ };
1322
+ if (parsedSessionStatus === 'idle' && hasFinalAssistant(parsedStatusBeforeSend)) return null;
1323
+ if (this.engine.currentStatus === 'generating') return 'current_status_generating';
1324
+ if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'no_progress' || parsedSessionStatus === 'long_generating') {
1325
+ const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
1326
+ const parsedHasActionableModal = Boolean(
1327
+ parsedModal
1328
+ && Array.isArray(parsedModal.buttons)
1329
+ && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
1330
+ );
1331
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
1332
+ && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
1333
+ && !this.engine.isWaitingForResponse
1334
+ && !this.engine.currentTurnScope
1335
+ && !this.engine.hasActionableApproval()
1336
+ && !parsedHasActionableModal;
1337
+ return terminalLooksIdle ? null : `parsed_status_${parsedSessionStatus}`;
1338
+ }
1339
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope) return 'active_turn_in_progress';
1340
+ return null;
1341
+ }
1342
+
1343
+ private schedulePendingOutboundFlush(delayMs = 0): void {
1344
+ if (this.pendingOutboundFlushTimer) clearTimeout(this.pendingOutboundFlushTimer);
1345
+ this.pendingOutboundFlushTimer = setTimeout(() => {
1346
+ this.pendingOutboundFlushTimer = null;
1347
+ void this.flushPendingOutboundQueue();
1348
+ }, Math.max(0, delayMs));
1349
+ }
1350
+
1351
+ private async flushPendingOutboundQueue(): Promise<void> {
1352
+ if (this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length === 0) return;
1353
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) return;
1354
+ this.pendingOutboundFlushInFlight = true;
1355
+ try {
1356
+ while (this.pendingOutboundQueue.length > 0) {
1357
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) break;
1358
+ const next = this.pendingOutboundQueue[0];
1359
+ try {
1360
+ await this.sendMessageNow(next.content, false);
1361
+ this.pendingOutboundQueue.shift();
1362
+ this.onStatusChange?.();
1363
+ } catch (error: any) {
1364
+ LOG.warn('CLI', `[${this.cliType}] queued outbound flush failed: ${error?.message || error}`);
1365
+ this.schedulePendingOutboundFlush(1000);
1366
+ break;
1367
+ }
1368
+ }
1369
+ } finally {
1370
+ this.pendingOutboundFlushInFlight = false;
1371
+ }
1372
+ }
1373
+
1374
+ private async sendMessageNow(text: string, allowQueue: boolean): Promise<void> {
1928
1375
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1929
1376
  const allowInputDuringGeneration = this.provider.allowInputDuringGeneration === true;
1930
1377
  const allowInterventionPrompt = allowInputDuringGeneration
1931
- && this.isWaitingForResponse
1932
- && !this.hasActionableApproval();
1378
+ && this.engine.isWaitingForResponse
1379
+ && !this.engine.hasActionableApproval();
1933
1380
  if (this.startupParseGate) {
1934
1381
  const deadline = Date.now() + 10000;
1935
1382
  while (this.startupParseGate && Date.now() < deadline) {
@@ -1937,76 +1384,85 @@ export class ProviderCliAdapter implements CliAdapter {
1937
1384
  await new Promise(resolve => setTimeout(resolve, 50));
1938
1385
  }
1939
1386
  }
1387
+ const parsedStatusBeforeSend = !allowInputDuringGeneration
1388
+ ? (() => {
1389
+ try {
1390
+ return this.getScriptParsedStatus?.() || null;
1391
+ } catch {
1392
+ return null;
1393
+ }
1394
+ })()
1395
+ : null;
1396
+ const queueReason = this.shouldQueuePendingOutboundMessage(parsedStatusBeforeSend);
1397
+ if (allowQueue && queueReason) {
1398
+ this.enqueuePendingOutboundMessage(text, queueReason);
1399
+ return;
1400
+ }
1940
1401
  if (!allowInterventionPrompt) {
1941
1402
  await this.waitForInteractivePrompt();
1942
1403
  }
1943
1404
  if (!this.ready) {
1944
1405
  this.resolveStartupState('send_precheck');
1945
- if (this.runDetectStatus(this.recentOutputBuffer) === 'idle' && this.currentStatus === 'idle') {
1406
+ if (this.runDetectStatus(this.recentOutputBuffer) === 'idle') {
1946
1407
  this.ready = true;
1947
1408
  this.startupParseGate = false;
1409
+ this.engine.setStatus('idle', 'send_message_idle_prompt_recovery');
1948
1410
  LOG.info('CLI', `[${this.cliType}] sendMessage recovered idle prompt readiness`);
1949
1411
  }
1950
1412
  }
1951
- if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.currentStatus})`);
1952
- const parsedStatusBeforeSend = !allowInputDuringGeneration
1953
- ? (() => {
1954
- try {
1955
- return this.getScriptParsedStatus?.() || null;
1956
- } catch {
1957
- return null;
1958
- }
1959
- })()
1960
- : null;
1413
+ if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.engine.currentStatus})`);
1961
1414
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1962
1415
  ? String(parsedStatusBeforeSend.status)
1963
1416
  : '';
1964
- if (!allowInputDuringGeneration && (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating')) {
1417
+ if (!allowInputDuringGeneration && (parsedSessionStatus === 'generating' || parsedSessionStatus === 'no_progress' || parsedSessionStatus === 'long_generating')) {
1965
1418
  const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
1966
1419
  const parsedHasActionableModal = Boolean(
1967
1420
  parsedModal
1968
1421
  && Array.isArray(parsedModal.buttons)
1969
1422
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
1970
1423
  );
1971
- const terminalLooksIdle = this.currentStatus === 'idle'
1424
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
1972
1425
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
1973
- && !this.isWaitingForResponse
1974
- && !this.currentTurnScope
1975
- && !this.hasActionableApproval()
1426
+ && !this.engine.isWaitingForResponse
1427
+ && !this.engine.currentTurnScope
1428
+ && !this.engine.hasActionableApproval()
1976
1429
  && !parsedHasActionableModal;
1977
1430
  if (!terminalLooksIdle) {
1431
+ if (allowQueue) {
1432
+ this.enqueuePendingOutboundMessage(text, `parsed_status_${parsedSessionStatus}`);
1433
+ return;
1434
+ }
1978
1435
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1979
1436
  }
1980
1437
  }
1981
- if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1982
- if (!this.clearStaleIdleResponseGuard('send_message_guard')) {
1438
+ if (this.engine.isWaitingForResponse && !allowInputDuringGeneration) {
1439
+ const snap = this.getSnapshot();
1440
+ if (
1441
+ !this.engine.clearStaleIdleResponseGuard('send_message_guard', snap)
1442
+ && !this.engine.clearParsedIdleResponseGuard('send_message_parsed_idle_guard', parsedStatusBeforeSend, snap)
1443
+ ) {
1444
+ if (allowQueue) {
1445
+ this.enqueuePendingOutboundMessage(text, 'waiting_for_response');
1446
+ return;
1447
+ }
1983
1448
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1984
1449
  }
1985
1450
  }
1986
- this.isWaitingForResponse = true;
1987
1451
  this.responseBuffer = '';
1988
- this.finishRetryCount = 0;
1989
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1990
- this.clearIdleFinishCandidate('send_message');
1991
- this.currentTurnScope = {
1452
+ const turnScope: TurnParseScope = {
1992
1453
  prompt: text,
1993
1454
  startedAt: Date.now(),
1994
1455
  bufferStart: this.accumulatedBuffer.length,
1995
1456
  rawBufferStart: this.accumulatedRawBuffer.length,
1996
1457
  };
1997
- this.recordTrace('send_message', {
1998
- text: summarizeCliTraceText(text, 500),
1999
- estimatedLines: estimatePromptDisplayLines(text),
2000
- turnScope: this.currentTurnScope,
2001
- });
2002
- LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${this.currentTurnScope.bufferStart} raw=${this.currentTurnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2003
- this.submitRetryUsed = false;
2004
- this.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
2005
- const normalizedPromptSnippet = normalizePromptText(this.submitRetryPromptSnippet);
1458
+ LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${turnScope.bufferStart} raw=${turnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2006
1459
  if (this.submitRetryTimer) {
2007
1460
  clearTimeout(this.submitRetryTimer);
2008
1461
  this.submitRetryTimer = null;
2009
1462
  }
1463
+ this.engine.onTurnStarted(turnScope);
1464
+ this.engine.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
1465
+ const normalizedPromptSnippet = normalizePromptText(this.engine.submitRetryPromptSnippet);
2010
1466
  const estimatedLines = estimatePromptDisplayLines(text);
2011
1467
  const submitDelayMs = this.sendDelayMs + Math.min(2000, Math.max(0, estimatedLines - 1) * 350);
2012
1468
  const maxEchoWaitMs = submitDelayMs + Math.max(1500, Math.min(5000, estimatedLines * 500));
@@ -2019,12 +1475,7 @@ export class ProviderCliAdapter implements CliAdapter {
2019
1475
  retryDelayMs,
2020
1476
  didCommitUserTurn: false,
2021
1477
  };
2022
- if (this.settleTimer) {
2023
- clearTimeout(this.settleTimer);
2024
- this.settleTimer = null;
2025
- }
2026
- this.responseEpoch += 1;
2027
- this.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
1478
+ this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
2028
1479
  await new Promise<void>((resolve, reject) => {
2029
1480
  let resolved = false;
2030
1481
  const completion: SendMessageCompletion = {
@@ -2047,24 +1498,20 @@ export class ProviderCliAdapter implements CliAdapter {
2047
1498
  }
2048
1499
 
2049
1500
  if (submitDelayMs > 0) {
2050
- this.submitPendingUntil = Date.now() + submitDelayMs;
1501
+ this.engine.submitPendingUntil = Date.now() + submitDelayMs;
2051
1502
  }
2052
- this.recordTrace('submit_write', {
2053
- mode: 'type_then_submit',
2054
- text: summarizeCliTraceText(text, 500),
2055
- sendKey: this.sendKey,
2056
- screenText: summarizeCliTraceText(this.terminalScreen.getText(), 500),
2057
- });
2058
1503
  const submitStartedAt = Date.now();
2059
1504
  void this.writeToPty(text).then(
2060
1505
  () => this.waitForEchoAndSubmit(sendState, completion, submitStartedAt),
2061
1506
  completion.rejectOnce,
2062
1507
  );
2063
1508
  });
1509
+ // Schedule settle after successful send
1510
+ this.engine.scheduleSettle();
2064
1511
  }
2065
1512
 
2066
1513
  getPartialResponse(): string {
2067
- if (!this.isWaitingForResponse) return '';
1514
+ if (!this.engine.isWaitingForResponse) return '';
2068
1515
  return this.responseBuffer;
2069
1516
  }
2070
1517
 
@@ -2077,10 +1524,10 @@ export class ProviderCliAdapter implements CliAdapter {
2077
1524
  cliType: this.cliType,
2078
1525
  cliName: this.cliName,
2079
1526
  workingDir: this.workingDir,
2080
- currentStatus: this.currentStatus,
1527
+ currentStatus: this.engine.currentStatus,
2081
1528
  ready: this.ready,
2082
- isWaitingForResponse: this.isWaitingForResponse,
2083
- activeModal: this.activeModal,
1529
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1530
+ activeModal: this.engine.activeModal,
2084
1531
  parseErrorMessage: this.parseErrorMessage,
2085
1532
  messageCounts: {
2086
1533
  parsedCache: Array.isArray(parsedResult?.messages) ? parsedResult.messages.length : undefined,
@@ -2106,10 +1553,10 @@ export class ProviderCliAdapter implements CliAdapter {
2106
1553
  lastScreenSnapshotReadAt: this.lastScreenSnapshotReadAt,
2107
1554
  },
2108
1555
  parser: {
2109
- scriptNames: listCliScriptNames(this.cliScripts),
2110
- traceSessionId: this.traceSessionId,
2111
- traceSeq: this.traceSeq,
2112
- currentTurnScope: this.currentTurnScope,
1556
+ scriptNames: this.runner.getScriptNames(),
1557
+ traceSessionId: this.engine.getTraceSessionId(),
1558
+ traceSeq: this.engine.getTraceEntries().length,
1559
+ currentTurnScope: this.engine.currentTurnScope,
2113
1560
  parsedStatusCache: parsedResult
2114
1561
  ? {
2115
1562
  id: parsedResult.id,
@@ -2122,26 +1569,25 @@ export class ProviderCliAdapter implements CliAdapter {
2122
1569
  activeModal: parsedResult.activeModal,
2123
1570
  }
2124
1571
  : null,
2125
- pendingScriptStatus: this.pendingScriptStatus,
2126
- pendingScriptStatusSince: this.pendingScriptStatusSince,
1572
+ pendingScriptStatus: this.engine.pendingScriptStatus,
1573
+ pendingScriptStatusSince: this.engine.pendingScriptStatusSince,
2127
1574
  },
2128
1575
  runtimeMetadata: this.getRuntimeMetadata(),
2129
- statusHistory: this.statusHistory.slice(-80),
2130
- traceEntries: this.traceEntries.slice(-120),
1576
+ statusHistory: this.engine.getStatusHistory().slice(-80),
1577
+ traceEntries: this.engine.getTraceEntries().slice(-120),
2131
1578
  timing: {
2132
1579
  spawnAt: this.spawnAt,
2133
1580
  startupFirstOutputAt: this.startupFirstOutputAt,
2134
- submitPendingUntil: this.submitPendingUntil,
2135
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
2136
- responseEpoch: this.responseEpoch,
1581
+ submitPendingUntil: this.engine.submitPendingUntil,
1582
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
1583
+ responseEpoch: this.engine.responseEpoch,
2137
1584
  resizeSuppressUntil: this.resizeSuppressUntil,
2138
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1585
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2139
1586
  },
2140
1587
  finish: {
2141
- idleFinishCandidate: this.idleFinishCandidate,
2142
- finishRetryCount: this.finishRetryCount,
2143
- submitRetryUsed: this.submitRetryUsed,
2144
- submitRetryPromptSnippet: this.submitRetryPromptSnippet,
1588
+ finishRetryCount: this.engine.finishRetryCount,
1589
+ submitRetryUsed: this.engine.submitRetryUsed,
1590
+ submitRetryPromptSnippet: this.engine.submitRetryPromptSnippet,
2145
1591
  },
2146
1592
  };
2147
1593
  }
@@ -2151,7 +1597,48 @@ export class ProviderCliAdapter implements CliAdapter {
2151
1597
  return this.ptyProcess.getMetadata();
2152
1598
  }
2153
1599
 
1600
+ /**
1601
+ * Launch metadata for the dashboard Session info panel. Re-derives the spawn plan
1602
+ * (pure — same inputs the live PTY was spawned with) so the dashboard sees the
1603
+ * resolved binary, full arg vector, and cwd without us having to persist it.
1604
+ * extraEnv values are intentionally dropped (keys only) so secrets passed at
1605
+ * launch time never reach the dashboard.
1606
+ */
1607
+ getLaunchInfo(): CliLaunchInfo {
1608
+ let command: string | undefined;
1609
+ let args: string[] = [...this.extraArgs];
1610
+ try {
1611
+ const plan = resolveCliSpawnPlan({
1612
+ provider: this.provider,
1613
+ runtimeSettings: this.runtimeSettings,
1614
+ workingDir: this.workingDir,
1615
+ extraArgs: this.extraArgs,
1616
+ extraEnv: this.extraEnv,
1617
+ });
1618
+ command = plan.binaryPath;
1619
+ args = plan.allArgs;
1620
+ } catch {
1621
+ // Spawn plan resolution can throw before the binary is resolvable
1622
+ // (e.g. CLI not installed). Fall back to the raw extra args so the
1623
+ // panel still shows what it can rather than failing the whole call.
1624
+ }
1625
+ return {
1626
+ command,
1627
+ args,
1628
+ extraArgs: [...this.extraArgs],
1629
+ cwd: this.workingDir,
1630
+ extraEnvKeys: Object.keys(this.extraEnv || {}),
1631
+ providerSessionId: this.providerSessionId || undefined,
1632
+ };
1633
+ }
1634
+
2154
1635
  updateRuntimeMeta(meta: Record<string, unknown>, replace = false): void {
1636
+ const nextProviderSessionId = typeof meta?.providerSessionId === 'string'
1637
+ ? meta.providerSessionId.trim()
1638
+ : '';
1639
+ if (nextProviderSessionId) {
1640
+ this.providerSessionId = nextProviderSessionId;
1641
+ }
2155
1642
  if (!this.ptyProcess || typeof this.ptyProcess.updateMeta !== 'function') return;
2156
1643
  this.ptyProcess.updateMeta(meta, replace);
2157
1644
  }
@@ -2172,7 +1659,7 @@ export class ProviderCliAdapter implements CliAdapter {
2172
1659
  this.timeouts.shutdownGrace,
2173
1660
  typeof resume.shutdownGraceMs === 'number' ? resume.shutdownGraceMs : 3000,
2174
1661
  );
2175
- const wasProcessing = this.currentStatus === 'generating' || this.currentStatus === 'waiting_approval';
1662
+ const wasProcessing = this.engine.currentStatus === 'generating' || this.engine.currentStatus === 'waiting_approval';
2176
1663
 
2177
1664
  try {
2178
1665
  if (wasProcessing) {
@@ -2210,7 +1697,7 @@ export class ProviderCliAdapter implements CliAdapter {
2210
1697
  return new Promise((resolve) => {
2211
1698
  const startedAt = Date.now();
2212
1699
  const timer = setInterval(() => {
2213
- if (!this.ptyProcess || this.currentStatus === 'stopped') {
1700
+ if (!this.ptyProcess || this.engine.currentStatus === 'stopped') {
2214
1701
  clearInterval(timer);
2215
1702
  resolve(true);
2216
1703
  return;
@@ -2224,18 +1711,20 @@ export class ProviderCliAdapter implements CliAdapter {
2224
1711
  }
2225
1712
 
2226
1713
  shutdown(): void {
2227
- this.clearIdleFinishCandidate('shutdown');
1714
+ this.engine.clearIdleFinishCandidate('shutdown');
2228
1715
  this.clearAllTimers();
2229
1716
  this.pendingOutputParseChunks = [];
2230
1717
  this.pendingTerminalQueryTail = '';
2231
1718
  this.ptyOutputChunks = [];
2232
- this.finishRetryCount = 0;
1719
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1720
+ this.pendingOutboundQueue = [];
1721
+ this.pendingOutboundFlushInFlight = false;
2233
1722
  if (this.ptyProcess) {
2234
1723
  this.ptyProcess.write('\x03');
2235
1724
  setTimeout(() => {
2236
1725
  try { this.ptyProcess?.kill(); } catch { }
2237
1726
  this.ptyProcess = null;
2238
- this.setStatus('stopped', 'stop_cmd');
1727
+ this.engine.setStatus('stopped', 'stop_cmd');
2239
1728
  this.ready = false;
2240
1729
  this.startupParseGate = false;
2241
1730
  this.spawnAt = 0;
@@ -2245,12 +1734,14 @@ export class ProviderCliAdapter implements CliAdapter {
2245
1734
  }
2246
1735
 
2247
1736
  detach(): void {
2248
- this.clearIdleFinishCandidate('detach');
1737
+ this.engine.clearIdleFinishCandidate('detach');
2249
1738
  this.clearAllTimers();
2250
1739
  this.pendingOutputParseChunks = [];
2251
1740
  this.pendingTerminalQueryTail = '';
2252
1741
  this.ptyOutputChunks = [];
2253
- this.finishRetryCount = 0;
1742
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1743
+ this.pendingOutboundQueue = [];
1744
+ this.pendingOutboundFlushInFlight = false;
2254
1745
  if (this.ptyProcess) {
2255
1746
  try {
2256
1747
  if (typeof this.ptyProcess.detach === 'function') {
@@ -2268,82 +1759,128 @@ export class ProviderCliAdapter implements CliAdapter {
2268
1759
  }
2269
1760
 
2270
1761
  clearHistory(): void {
2271
- this.clearIdleFinishCandidate('clear_history');
1762
+ this.engine.clearIdleFinishCandidate('clear_history');
2272
1763
  this.accumulatedBuffer = '';
2273
1764
  this.accumulatedRawBuffer = '';
2274
- this.currentTurnScope = null;
2275
- this.submitRetryUsed = false;
2276
- this.submitRetryPromptSnippet = '';
1765
+ this.engine.currentTurnScope = null;
1766
+ this.engine.submitRetryUsed = false;
1767
+ this.engine.submitRetryPromptSnippet = '';
2277
1768
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
2278
1769
  this.pendingOutputParseChunks = [];
2279
1770
  this.pendingTerminalQueryTail = '';
2280
1771
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
2281
1772
  this.ptyOutputChunks = [];
2282
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2283
- this.finishRetryCount = 0;
1773
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1774
+ this.pendingOutboundQueue = [];
1775
+ this.pendingOutboundFlushInFlight = false;
2284
1776
  this.resetTerminalScreen();
2285
1777
  this.ptyProcess?.clearBuffer?.();
2286
1778
  this.onStatusChange?.();
2287
1779
  }
2288
1780
 
2289
- isProcessing(): boolean { return this.isWaitingForResponse; }
1781
+ isProcessing(): boolean { return this.engine.isWaitingForResponse; }
2290
1782
  isReady(): boolean { return this.ready; }
2291
1783
 
2292
- async writeRaw(data: string): Promise<void> {
2293
- this.recordTrace('write_raw', {
2294
- keys: JSON.stringify(data),
2295
- length: data.length,
2296
- });
2297
- await this.writeToPty(data);
1784
+ // ─── State machine property accessors (delegate to engine) ──────────────
1785
+ // These expose engine state for external callers (tests, debug tools, etc.)
1786
+
1787
+ get currentStatus(): CliSessionStatus['status'] { return this.engine.currentStatus; }
1788
+ set currentStatus(v: CliSessionStatus['status']) { this.engine.setStatus(v); }
1789
+
1790
+ get isWaitingForResponse(): boolean { return this.engine.isWaitingForResponse; }
1791
+ set isWaitingForResponse(v: boolean) { this.engine.isWaitingForResponse = v; }
1792
+
1793
+ get activeModal(): { message: string; buttons: string[] } | null { return this.engine.activeModal; }
1794
+ set activeModal(v: { message: string; buttons: string[] } | null) { this.engine.activeModal = v; }
1795
+
1796
+ get currentTurnScope(): TurnParseScope | null { return this.engine.currentTurnScope; }
1797
+ set currentTurnScope(v: TurnParseScope | null) { this.engine.currentTurnScope = v; }
1798
+
1799
+ get responseEpoch(): number { return this.engine.responseEpoch; }
1800
+ set responseEpoch(v: number) { this.engine.responseEpoch = v; }
1801
+
1802
+ get submitRetryUsed(): boolean { return this.engine.submitRetryUsed; }
1803
+ set submitRetryUsed(v: boolean) { this.engine.submitRetryUsed = v; }
1804
+
1805
+ get submitRetryPromptSnippet(): string { return this.engine.submitRetryPromptSnippet; }
1806
+ set submitRetryPromptSnippet(v: string) { this.engine.submitRetryPromptSnippet = v; }
1807
+
1808
+ get responseSettleIgnoreUntil(): number { return this.engine.responseSettleIgnoreUntil; }
1809
+ set responseSettleIgnoreUntil(v: number) { this.engine.responseSettleIgnoreUntil = v; }
1810
+
1811
+ get submitPendingUntil(): number { return this.engine.submitPendingUntil; }
1812
+ set submitPendingUntil(v: number) { this.engine.submitPendingUntil = v; }
1813
+
1814
+ get lastApprovalResolvedAt(): number { return this.engine.lastApprovalResolvedAt; }
1815
+ set lastApprovalResolvedAt(v: number) { this.engine.lastApprovalResolvedAt = v; }
1816
+
1817
+ get providerErrorMessage(): string | null { return this.engine.providerErrorMessage; }
1818
+ get providerErrorReason(): string | null { return this.engine.providerErrorReason; }
1819
+
1820
+ get pendingScriptStatus(): 'generating' | 'waiting_approval' | null { return this.engine.pendingScriptStatus; }
1821
+ get pendingScriptStatusSince(): number { return this.engine.pendingScriptStatusSince; }
1822
+
1823
+ get finishRetryCount(): number { return this.engine.finishRetryCount; }
1824
+ set finishRetryCount(v: number) { this.engine.finishRetryCount = v; }
1825
+
1826
+ get traceSessionId(): string { return this.engine.getTraceSessionId(); }
1827
+ get traceEntries(): CliTraceEntry[] { return this.engine.getTraceEntries(); }
1828
+ get statusHistory(): { status: string; at: number; trigger?: string }[] { return this.engine.getStatusHistory(); }
1829
+ get traceSeq(): number { return this.engine.getTraceEntries().length; }
1830
+
1831
+ /** Expose engine's evaluateSettled for test access */
1832
+ evaluateSettled(): void {
1833
+ LOG.debug(
1834
+ 'CLI',
1835
+ `[${this.cliType}] settled diagnostics delegated to state engine`);
1836
+ this.engine.evaluateSettled(this.getSnapshot());
1837
+ }
1838
+ /** Expose engine's scheduleSettle for test access */
1839
+ scheduleSettle(): void { this.engine.scheduleSettle(); }
1840
+ /** Expose engine's clearIdleFinishCandidate for test access */
1841
+ clearIdleFinishCandidate(reason: string): void { this.engine.clearIdleFinishCandidate(reason); }
1842
+ /** Expose engine's finishResponse for test access */
1843
+ finishResponse(): void { this.engine.finishResponse(); }
1844
+ /** Returns a point-in-time snapshot of all buffer/screen state for external consumers (e.g. CliStateEngine). */
1845
+ getSnapshot(): CliBufferSnapshot {
1846
+ const screenText = this.terminalScreen.getText() || '';
1847
+ return {
1848
+ accumulatedBuffer: this.accumulatedBuffer,
1849
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
1850
+ recentOutputBuffer: this.recentOutputBuffer,
1851
+ responseBuffer: this.responseBuffer,
1852
+ screenText,
1853
+ parseScreenText: this.getParseScreenText(screenText),
1854
+ workingDir: this.workingDir,
1855
+ providerSessionId: this.providerSessionId,
1856
+ runtimeSettings: this.runtimeSettings,
1857
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1858
+ currentTurnScope: this.engine.currentTurnScope,
1859
+ lastOutputAt: this.lastOutputAt,
1860
+ lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1861
+ lastScreenChangeAt: this.lastScreenChangeAt,
1862
+ lastScreenSnapshot: this.lastScreenSnapshot,
1863
+ };
1864
+ }
1865
+ isAlive(): boolean { return this.ptyProcess !== null; }
1866
+ flushOutboundQueue(): void { this.schedulePendingOutboundFlush(); }
1867
+
1868
+ async writeRaw(data: string | Buffer): Promise<void> {
1869
+ const str = Buffer.isBuffer(data) ? data.toString('utf8') : data;
1870
+ await this.writeToPty(str);
2298
1871
  }
2299
1872
 
2300
1873
  resolveModal(buttonIndex: number): void {
2301
- let modal = this.activeModal || this.runParseApproval(this.recentOutputBuffer);
2302
- if (!modal && typeof this.cliScripts?.parseSession === 'function') {
2303
- try {
2304
- const parsed = this.getScriptParsedStatus();
2305
- const parsedModal = parsed?.activeModal && Array.isArray(parsed.activeModal.buttons)
2306
- && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
2307
- ? parsed.activeModal
2308
- : null;
2309
- if (parsed?.status === 'waiting_approval' && parsedModal) {
2310
- modal = parsedModal;
2311
- this.activeModal = parsedModal;
2312
- if (this.currentStatus !== 'waiting_approval') {
2313
- this.setStatus('waiting_approval', 'resolve_modal_parse');
2314
- this.onStatusChange?.();
2315
- }
2316
- }
2317
- } catch {
2318
- // Ignore parse failures here; resolveModal falls back to current state.
2319
- }
2320
- }
2321
- if (!this.ptyProcess || ((this.currentStatus !== 'waiting_approval') && !modal)) return;
2322
- this.clearIdleFinishCandidate('resolve_modal');
2323
- this.recordTrace('resolve_modal', {
2324
- buttonIndex,
2325
- activeModal: modal,
2326
- });
2327
- this.activeModal = null;
2328
- this.lastApprovalResolvedAt = Date.now();
2329
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
2330
- if (this.approvalExitTimeout) {
2331
- clearTimeout(this.approvalExitTimeout);
2332
- this.approvalExitTimeout = null;
2333
- }
2334
- this.setStatus('generating', 'approval_resolved');
2335
- this.onStatusChange?.();
2336
- if (buttonIndex in this.approvalKeys) {
2337
- this.ptyProcess.write(this.approvalKeys[buttonIndex]);
2338
- } else {
2339
- const buttonCount = Array.isArray(modal?.buttons) ? modal.buttons.length : 0;
2340
- const clampedIndex = buttonCount > 0
2341
- ? Math.min(Math.max(0, buttonIndex), buttonCount - 1)
2342
- : Math.max(0, buttonIndex);
2343
- const DOWN = '\x1B[B';
2344
- const keys = DOWN.repeat(clampedIndex) + '\r';
2345
- this.ptyProcess.write(keys);
2346
- }
1874
+ this.engine.resolveModal(buttonIndex);
1875
+ }
1876
+
1877
+ getApprovalKeyForIndex(buttonIndex: number): string | undefined {
1878
+ return buttonIndex in this.approvalKeys ? this.approvalKeys[buttonIndex] : undefined;
1879
+ }
1880
+
1881
+ /** Returns true if an approval was resolved within the adapter's cooldown window. */
1882
+ isApprovalRecentlyResolved(): boolean {
1883
+ return this.engine.isApprovalRecentlyResolved();
2347
1884
  }
2348
1885
 
2349
1886
  resize(cols: number, rows: number): void {
@@ -2357,7 +1894,7 @@ export class ProviderCliAdapter implements CliAdapter {
2357
1894
  }
2358
1895
 
2359
1896
  private getParsedDebugState(): Record<string, any> | null {
2360
- if (this.startupParseGate || typeof this.cliScripts?.parseSession !== 'function') return null;
1897
+ if (this.startupParseGate || !this.runner.hasParseSession()) return null;
2361
1898
  try {
2362
1899
  const parsed = this.getScriptParsedStatus();
2363
1900
  return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null;
@@ -2369,18 +1906,43 @@ export class ProviderCliAdapter implements CliAdapter {
2369
1906
  getDebugState(): Record<string, any> {
2370
1907
  const screenText = sanitizeTerminalText(this.terminalScreen.getText());
2371
1908
  const startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2372
- const effectiveStatus = this.projectEffectiveStatus(startupModal);
2373
- const effectiveReady = this.ready || !!startupModal;
1909
+ const startupDetectedStatus = this.startupParseGate && !startupModal
1910
+ ? this.runDetectStatus(this.recentOutputBuffer || screenText)
1911
+ : null;
1912
+ const effectiveReady = this.ready || !!startupModal || startupDetectedStatus === 'waiting_approval';
2374
1913
  const parsedDebugState = this.getParsedDebugState();
2375
1914
  const parsedMessages = Array.isArray(parsedDebugState?.messages) ? parsedDebugState.messages : [];
1915
+ const hasFinalAssistant = (p: any) => {
1916
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1917
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1918
+ };
1919
+ let effectiveStatus = this.projectEffectiveStatus(startupModal);
1920
+ if (parsedDebugState?.status === 'error') {
1921
+ effectiveStatus = 'error';
1922
+ }
1923
+ // (fix) Mirror the getStatus contract: never surface waiting_approval
1924
+ // without a concrete modal. detectStatus alone can fire while
1925
+ // parseApproval is still null, and a bare debugState waiting_approval
1926
+ // confused dashboards reading the debug payload.
1927
+ const debugEffectiveModal = startupModal || this.engine.activeModal;
1928
+ if (startupDetectedStatus === 'waiting_approval' && debugEffectiveModal) {
1929
+ effectiveStatus = 'waiting_approval';
1930
+ }
1931
+ if (
1932
+ effectiveStatus === 'idle'
1933
+ && parsedDebugState?.status === 'generating'
1934
+ && !hasFinalAssistant(parsedDebugState)
1935
+ ) {
1936
+ effectiveStatus = 'generating';
1937
+ }
2376
1938
  return {
2377
1939
  type: this.cliType,
2378
1940
  name: this.cliName,
2379
1941
  providerResolution: this.providerResolutionMeta,
2380
1942
  status: effectiveStatus,
2381
1943
  projectedStatus: effectiveStatus,
2382
- rawStatus: this.currentStatus,
2383
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
1944
+ rawStatus: this.engine.currentStatus,
1945
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
2384
1946
  ready: effectiveReady,
2385
1947
  startupParseGate: this.startupParseGate,
2386
1948
  spawnAt: this.spawnAt,
@@ -2394,38 +1956,47 @@ export class ProviderCliAdapter implements CliAdapter {
2394
1956
  providerSessionId: parsedDebugState.providerSessionId,
2395
1957
  transcriptAuthority: parsedDebugState.transcriptAuthority,
2396
1958
  coverage: parsedDebugState.coverage,
1959
+ errorMessage: parsedDebugState.errorMessage,
1960
+ errorReason: parsedDebugState.errorReason,
2397
1961
  activeModal: parsedDebugState.activeModal,
2398
1962
  messageCount: parsedMessages.length,
2399
1963
  } : null,
2400
1964
  screenText: screenText.slice(-4000),
2401
- currentTurnScope: this.currentTurnScope,
1965
+ currentTurnScope: this.engine.currentTurnScope,
2402
1966
  startupBuffer: this.startupBuffer.slice(-4000),
2403
1967
  recentOutputBuffer: this.recentOutputBuffer.slice(-500),
2404
- settledBuffer: this.settledBuffer.slice(-500),
2405
1968
  accumulatedBufferLength: this.accumulatedBuffer.length,
2406
1969
  accumulatedRawBufferLength: this.accumulatedRawBuffer.length,
2407
1970
  rawBufferPreview: this.accumulatedRawBuffer.slice(-1000),
2408
1971
  sanitizedRawPreview: sanitizeTerminalText(this.accumulatedRawBuffer).slice(-1000),
2409
1972
  responseBuffer: this.responseBuffer.slice(-1000),
1973
+ pendingOutboundQueue: this.pendingOutboundQueue.map((message) => ({
1974
+ id: message.id,
1975
+ role: message.role,
1976
+ content: message.content,
1977
+ queuedAt: message.queuedAt,
1978
+ source: message.source,
1979
+ })),
1980
+ pendingOutboundCount: this.pendingOutboundQueue.length,
2410
1981
  lastOutputAt: this.lastOutputAt,
2411
1982
  lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
2412
1983
  lastScreenChangeAt: this.lastScreenChangeAt,
2413
1984
  lastScreenSnapshot: this.lastScreenSnapshot.slice(-500),
2414
- isWaitingForResponse: this.isWaitingForResponse,
2415
- activeModal: startupModal || this.activeModal,
2416
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1985
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1986
+ activeModal: startupModal || this.engine.activeModal,
1987
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2417
1988
  sendDelayMs: this.sendDelayMs,
2418
1989
  sendKey: this.sendKey,
2419
1990
  submitStrategy: this.submitStrategy,
2420
1991
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2421
- submitPendingUntil: this.submitPendingUntil,
2422
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
1992
+ submitPendingUntil: this.engine.submitPendingUntil,
1993
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
2423
1994
  resizeSuppressUntil: this.resizeSuppressUntil,
2424
1995
  hasCliScripts: this.hasCliScripts(),
2425
- scriptNames: listCliScriptNames(this.cliScripts),
2426
- traceSessionId: this.traceSessionId,
2427
- traceEntryCount: this.traceEntries.length,
2428
- statusHistory: this.statusHistory.slice(-30),
1996
+ scriptNames: this.runner.getScriptNames(),
1997
+ traceSessionId: this.engine.getTraceSessionId(),
1998
+ traceEntryCount: this.engine.getTraceEntries().length,
1999
+ statusHistory: this.engine.getStatusHistory().slice(-30),
2429
2000
  timeouts: this.timeouts,
2430
2001
  pendingOutputParseBufferLength: this.pendingOutputParseChunks.reduce((total, chunk) => total + chunk.length, 0),
2431
2002
  pendingOutputParseScheduled: !!this.pendingOutputParseTimer,
@@ -2435,20 +2006,21 @@ export class ProviderCliAdapter implements CliAdapter {
2435
2006
 
2436
2007
  getTraceState(limit = 120): Record<string, any> {
2437
2008
  const cappedLimit = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 120));
2009
+ const traceEntries = this.engine.getTraceEntries();
2438
2010
  return {
2439
- sessionId: this.traceSessionId,
2011
+ sessionId: this.engine.getTraceSessionId(),
2440
2012
  providerResolution: this.providerResolutionMeta,
2441
- entryCount: this.traceEntries.length,
2442
- entries: this.traceEntries.slice(-cappedLimit),
2013
+ entryCount: traceEntries.length,
2014
+ entries: traceEntries.slice(-cappedLimit),
2443
2015
  screenText: summarizeCliTraceText(this.terminalScreen.getText(), 4000),
2444
2016
  recentOutputBuffer: summarizeCliTraceText(this.recentOutputBuffer, 1000),
2445
2017
  responseBuffer: summarizeCliTraceText(this.responseBuffer, 1200),
2446
2018
  status: this.projectEffectiveStatus(),
2447
2019
  projectedStatus: this.projectEffectiveStatus(),
2448
- rawStatus: this.currentStatus,
2449
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
2450
- activeModal: this.activeModal,
2451
- currentTurnScope: this.currentTurnScope,
2020
+ rawStatus: this.engine.currentStatus,
2021
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
2022
+ activeModal: this.engine.activeModal,
2023
+ currentTurnScope: this.engine.currentTurnScope,
2452
2024
  messages: [],
2453
2025
  };
2454
2026
  }