@adhdev/daemon-core 0.9.82-rc.37 → 0.9.82-rc.371

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 (384) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +9 -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 +55 -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 +116 -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 +63 -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/chat-commands.d.ts +27 -0
  20. package/dist/commands/cli-manager.d.ts +15 -1
  21. package/dist/commands/handler.d.ts +125 -0
  22. package/dist/commands/high-family/index.d.ts +3 -0
  23. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  24. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  25. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  26. package/dist/commands/high-family/types.d.ts +60 -0
  27. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  28. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  29. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  30. package/dist/commands/low-family/index.d.ts +3 -0
  31. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  32. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  33. package/dist/commands/low-family/notification.d.ts +2 -0
  34. package/dist/commands/low-family/refine-config.d.ts +2 -0
  35. package/dist/commands/low-family/session-host.d.ts +2 -0
  36. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  37. package/dist/commands/low-family/status-meta.d.ts +2 -0
  38. package/dist/commands/low-family/types.d.ts +33 -0
  39. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  40. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  41. package/dist/commands/med-family/ide.d.ts +10 -0
  42. package/dist/commands/med-family/index.d.ts +3 -0
  43. package/dist/commands/med-family/mesh-crud.d.ts +2 -0
  44. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  45. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  46. package/dist/commands/med-family/mesh-restart.d.ts +2 -0
  47. package/dist/commands/med-family/types.d.ts +116 -0
  48. package/dist/commands/mesh-coordinator.d.ts +85 -1
  49. package/dist/commands/router.d.ts +666 -2
  50. package/dist/commands/upgrade-helper.d.ts +41 -1
  51. package/dist/config/chat-history.d.ts +9 -0
  52. package/dist/config/config.d.ts +5 -0
  53. package/dist/config/mesh-config.d.ts +77 -1
  54. package/dist/daemon/dev-server.d.ts +0 -2
  55. package/dist/detection/ide-detector.d.ts +13 -0
  56. package/dist/detection/win32-ide-version.d.ts +37 -0
  57. package/dist/git/change-impact-config.d.ts +159 -0
  58. package/dist/git/git-commands.d.ts +11 -1
  59. package/dist/git/git-diff.d.ts +6 -0
  60. package/dist/git/git-executor.d.ts +11 -0
  61. package/dist/git/git-status.d.ts +23 -0
  62. package/dist/git/git-types.d.ts +2 -50
  63. package/dist/git/index.d.ts +2 -0
  64. package/dist/index.d.ts +55 -13
  65. package/dist/index.js +46565 -19737
  66. package/dist/index.js.map +1 -1
  67. package/dist/index.mjs +43563 -16868
  68. package/dist/index.mjs.map +1 -1
  69. package/dist/installer.d.ts +1 -4
  70. package/dist/ipc/local-ipc-server.d.ts +91 -0
  71. package/dist/launch.d.ts +1 -1
  72. package/dist/logging/async-batch-writer.d.ts +10 -0
  73. package/dist/logging/log-redactor.d.ts +24 -0
  74. package/dist/logging/log-tail-reader.d.ts +46 -0
  75. package/dist/logging/logger.d.ts +1 -1
  76. package/dist/mesh/contracts.d.ts +164 -0
  77. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  78. package/dist/mesh/coordinator-registry.d.ts +59 -0
  79. package/dist/mesh/mesh-active-work.d.ts +174 -0
  80. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  81. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  82. package/dist/mesh/mesh-events-coordinator.d.ts +91 -0
  83. package/dist/mesh/mesh-events-pending.d.ts +55 -0
  84. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  85. package/dist/mesh/mesh-events-utils.d.ts +61 -0
  86. package/dist/mesh/mesh-events.d.ts +6 -49
  87. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  88. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  89. package/dist/mesh/mesh-init.d.ts +86 -0
  90. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  91. package/dist/mesh/mesh-ledger.d.ts +77 -1
  92. package/dist/mesh/mesh-missions.d.ts +164 -0
  93. package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
  94. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  95. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  96. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  97. package/dist/mesh/mesh-routing.d.ts +70 -0
  98. package/dist/mesh/mesh-runtime-store.d.ts +429 -0
  99. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  100. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  101. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  102. package/dist/mesh/mesh-work-queue.d.ts +246 -5
  103. package/dist/mesh/preview-freshness.d.ts +18 -0
  104. package/dist/mesh/refine-config.d.ts +216 -0
  105. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  106. package/dist/providers/acp-provider-instance.d.ts +5 -0
  107. package/dist/providers/approval-utils.d.ts +20 -0
  108. package/dist/providers/chat-message-normalization.d.ts +12 -0
  109. package/dist/providers/cli-provider-instance.d.ts +108 -3
  110. package/dist/providers/contracts.d.ts +139 -6
  111. package/dist/providers/external-sources.d.ts +71 -0
  112. package/dist/providers/manual-attendance.d.ts +63 -0
  113. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  114. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  115. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  116. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  117. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  118. package/dist/providers/native-history/index.d.ts +13 -0
  119. package/dist/providers/provider-instance-manager.d.ts +14 -0
  120. package/dist/providers/provider-instance.d.ts +23 -1
  121. package/dist/providers/provider-loader.d.ts +26 -4
  122. package/dist/providers/provider-trust.d.ts +31 -0
  123. package/dist/providers/read-chat-contract.d.ts +29 -0
  124. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  125. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  126. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  127. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  128. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  129. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  130. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  131. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  132. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  133. package/dist/providers/sdk/v1/index.d.ts +30 -0
  134. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  135. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  136. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  137. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  138. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  139. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  140. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  141. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  142. package/dist/providers/spec/adapter.d.ts +91 -0
  143. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  144. package/dist/providers/spec/evaluator.d.ts +45 -0
  145. package/dist/providers/spec/fsm-driver.d.ts +395 -0
  146. package/dist/providers/spec/fsm-evaluator.d.ts +76 -0
  147. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  148. package/dist/providers/spec/fsm-types.d.ts +174 -0
  149. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  150. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  151. package/dist/providers/spec/route.d.ts +4 -0
  152. package/dist/providers/spec/types.d.ts +222 -0
  153. package/dist/providers/status-monitor.d.ts +7 -7
  154. package/dist/providers/transcript-v2.d.ts +176 -0
  155. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  156. package/dist/providers/working-dir.d.ts +17 -0
  157. package/dist/repo-mesh-types.d.ts +295 -13
  158. package/dist/sessions/registry.d.ts +6 -0
  159. package/dist/shared-types-extra.d.ts +2 -4
  160. package/dist/shared-types.d.ts +59 -2
  161. package/dist/status/normalize.d.ts +1 -1
  162. package/dist/status/normalize.js +1 -0
  163. package/dist/status/normalize.js.map +1 -1
  164. package/dist/status/normalize.mjs +1 -0
  165. package/dist/status/normalize.mjs.map +1 -1
  166. package/dist/status/reporter.d.ts +2 -0
  167. package/dist/status/snapshot.d.ts +26 -0
  168. package/dist/system/hash.d.ts +8 -0
  169. package/dist/system/load-better-sqlite3.d.ts +21 -0
  170. package/dist/types.d.ts +5 -0
  171. package/package.json +7 -3
  172. package/src/agent-stream/poller.ts +2 -3
  173. package/src/agent-stream/provider-adapter.ts +1 -1
  174. package/src/boot/daemon-lifecycle.ts +63 -12
  175. package/src/boot/process-hardening.ts +89 -0
  176. package/src/build-info.ts +73 -0
  177. package/src/chat/source-machine.ts +534 -0
  178. package/src/chat/source-resolver.ts +0 -0
  179. package/src/chat/subscription-updates.ts +20 -1
  180. package/src/cli-adapter-types.d.ts +1 -0
  181. package/src/cli-adapter-types.ts +56 -2
  182. package/src/cli-adapters/cli-script-runner.ts +421 -0
  183. package/src/cli-adapters/cli-state-engine.ts +1221 -0
  184. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  185. package/src/cli-adapters/provider-cli-adapter.ts +775 -1141
  186. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  187. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  188. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  189. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  190. package/src/cli-adapters/provider-cli-shared.ts +110 -11
  191. package/src/cli-adapters/pty-transport.ts +2 -1
  192. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  193. package/src/cli-adapters/resolve-executable.ts +90 -0
  194. package/src/cli-adapters/session-host-transport.ts +2 -1
  195. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  196. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  197. package/src/cli-adapters/terminal-screen.ts +16 -81
  198. package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +198 -0
  199. package/src/commands/chat-commands.ts +1917 -62
  200. package/src/commands/cli-manager.ts +502 -24
  201. package/src/commands/handler.ts +841 -2
  202. package/src/commands/high-family/index.ts +28 -0
  203. package/src/commands/high-family/mesh-coordinator-launch.ts +592 -0
  204. package/src/commands/high-family/mesh-events.ts +47 -0
  205. package/src/commands/high-family/mesh-status.ts +642 -0
  206. package/src/commands/high-family/types.ts +76 -0
  207. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  208. package/src/commands/low-family/daemon-lifecycle.ts +107 -0
  209. package/src/commands/low-family/diagnostics.ts +57 -0
  210. package/src/commands/low-family/index.ts +37 -0
  211. package/src/commands/low-family/mesh-ledger.ts +62 -0
  212. package/src/commands/low-family/mesh-node-logs.ts +81 -0
  213. package/src/commands/low-family/notification.ts +116 -0
  214. package/src/commands/low-family/refine-config.ts +106 -0
  215. package/src/commands/low-family/session-host.ts +274 -0
  216. package/src/commands/low-family/spec-providerdev.ts +217 -0
  217. package/src/commands/low-family/status-meta.ts +112 -0
  218. package/src/commands/low-family/types.ts +39 -0
  219. package/src/commands/med-family/cli-agent.ts +218 -0
  220. package/src/commands/med-family/fast-forward.ts +198 -0
  221. package/src/commands/med-family/ide.ts +163 -0
  222. package/src/commands/med-family/index.ts +37 -0
  223. package/src/commands/med-family/mesh-crud.ts +788 -0
  224. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  225. package/src/commands/med-family/mesh-queue.ts +131 -0
  226. package/src/commands/med-family/mesh-restart.ts +92 -0
  227. package/src/commands/med-family/types.ts +120 -0
  228. package/src/commands/mesh-coordinator.ts +334 -124
  229. package/src/commands/router.ts +5121 -2369
  230. package/src/commands/stream-commands.ts +8 -0
  231. package/src/commands/upgrade-helper.ts +310 -45
  232. package/src/config/chat-history.ts +483 -24
  233. package/src/config/config.ts +12 -0
  234. package/src/config/mesh-config.ts +389 -6
  235. package/src/config/recent-activity.ts +8 -2
  236. package/src/daemon/dev-auto-implement.ts +3 -2
  237. package/src/daemon/dev-cli-debug.ts +10 -1
  238. package/src/daemon/dev-server.ts +0 -541
  239. package/src/detection/cli-detector.ts +28 -9
  240. package/src/detection/ide-detector.ts +55 -16
  241. package/src/detection/win32-ide-version.ts +106 -0
  242. package/src/git/change-impact-config.ts +354 -0
  243. package/src/git/git-commands.ts +55 -14
  244. package/src/git/git-diff.ts +81 -11
  245. package/src/git/git-executor.ts +12 -0
  246. package/src/git/git-status.ts +574 -43
  247. package/src/git/git-types.ts +14 -62
  248. package/src/git/git-worktree.ts +8 -1
  249. package/src/git/index.ts +16 -0
  250. package/src/index.ts +158 -12
  251. package/src/installer.d.ts +1 -1
  252. package/src/installer.ts +8 -6
  253. package/src/ipc/local-ipc-server.ts +278 -0
  254. package/src/launch.d.ts +1 -1
  255. package/src/launch.ts +37 -28
  256. package/src/logging/async-batch-writer.ts +55 -0
  257. package/src/logging/command-log.ts +7 -5
  258. package/src/logging/log-redactor.ts +100 -0
  259. package/src/logging/log-tail-reader.ts +220 -0
  260. package/src/logging/logger.ts +14 -7
  261. package/src/mesh/contracts.ts +338 -0
  262. package/src/mesh/coordinator-prompt.ts +234 -31
  263. package/src/mesh/coordinator-registry.ts +121 -0
  264. package/src/mesh/mesh-active-work.ts +645 -0
  265. package/src/mesh/mesh-delivery-policy.ts +315 -0
  266. package/src/mesh/mesh-event-trace.ts +67 -0
  267. package/src/mesh/mesh-events-coordinator.ts +2866 -0
  268. package/src/mesh/mesh-events-pending.ts +599 -0
  269. package/src/mesh/mesh-events-stale.ts +309 -0
  270. package/src/mesh/mesh-events-utils.ts +387 -0
  271. package/src/mesh/mesh-events.ts +30 -1035
  272. package/src/mesh/mesh-fast-forward.ts +839 -0
  273. package/src/mesh/mesh-host-ownership.ts +73 -0
  274. package/src/mesh/mesh-init.ts +260 -0
  275. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  276. package/src/mesh/mesh-ledger.ts +591 -102
  277. package/src/mesh/mesh-missions.ts +322 -0
  278. package/src/mesh/mesh-reconcile-loop.ts +1134 -0
  279. package/src/mesh/mesh-refine-batch.ts +197 -0
  280. package/src/mesh/mesh-refine-status.ts +231 -0
  281. package/src/mesh/mesh-review-inbox.ts +307 -0
  282. package/src/mesh/mesh-routing.ts +291 -0
  283. package/src/mesh/mesh-runtime-store.ts +1697 -0
  284. package/src/mesh/mesh-task-stats.ts +161 -0
  285. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  286. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  287. package/src/mesh/mesh-work-queue.ts +997 -141
  288. package/src/mesh/preview-freshness.ts +118 -0
  289. package/src/mesh/refine-config.ts +423 -0
  290. package/src/mesh/worktree-bootstrap-config.ts +331 -0
  291. package/src/providers/acp-provider-instance.ts +43 -10
  292. package/src/providers/approval-utils.d.ts +5 -0
  293. package/src/providers/approval-utils.ts +57 -5
  294. package/src/providers/chat-message-normalization.ts +45 -5
  295. package/src/providers/cli-provider-instance.ts +1310 -101
  296. package/src/providers/contracts.d.ts +55 -0
  297. package/src/providers/contracts.ts +150 -6
  298. package/src/providers/extension-provider-instance.ts +12 -7
  299. package/src/providers/external-sources.ts +218 -0
  300. package/src/providers/ide-provider-instance.ts +35 -12
  301. package/src/providers/manual-attendance.ts +85 -0
  302. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  303. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  304. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  305. package/src/providers/native-history/dispatcher.ts +340 -0
  306. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  307. package/src/providers/native-history/index.ts +30 -0
  308. package/src/providers/provider-instance-manager.ts +30 -0
  309. package/src/providers/provider-instance.ts +19 -1
  310. package/src/providers/provider-loader.ts +668 -50
  311. package/src/providers/provider-schema.ts +87 -14
  312. package/src/providers/provider-trust.ts +114 -0
  313. package/src/providers/read-chat-contract.ts +76 -16
  314. package/src/providers/sdk/README.md +49 -0
  315. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  316. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  317. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  318. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  319. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  320. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  321. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  322. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  323. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  324. package/src/providers/sdk/v1/index.ts +152 -0
  325. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  326. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  327. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  328. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  329. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  330. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  331. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  332. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  333. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  334. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  335. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  336. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  337. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  338. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  339. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  340. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  341. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  342. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  343. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  344. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  345. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  346. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  347. package/src/providers/sdk/v1/validators/index.ts +19 -0
  348. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  349. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  350. package/src/providers/spec/adapter.ts +235 -0
  351. package/src/providers/spec/cli-adapter.ts +1051 -0
  352. package/src/providers/spec/evaluator.ts +407 -0
  353. package/src/providers/spec/fsm-driver.ts +1305 -0
  354. package/src/providers/spec/fsm-evaluator.ts +272 -0
  355. package/src/providers/spec/fsm-loader.ts +120 -0
  356. package/src/providers/spec/fsm-types.ts +273 -0
  357. package/src/providers/spec/native-history-executor.ts +1136 -0
  358. package/src/providers/spec/pre-launch-trust.ts +104 -0
  359. package/src/providers/spec/route.ts +51 -0
  360. package/src/providers/spec/types.ts +262 -0
  361. package/src/providers/status-monitor.d.ts +7 -7
  362. package/src/providers/status-monitor.ts +37 -22
  363. package/src/providers/transcript-v2.ts +567 -0
  364. package/src/providers/types/interactive-prompt.ts +536 -0
  365. package/src/providers/version-archive.ts +64 -24
  366. package/src/providers/working-dir.ts +23 -0
  367. package/src/repo-mesh-types.ts +378 -14
  368. package/src/sessions/registry.ts +6 -0
  369. package/src/shared-types-extra.ts +2 -4
  370. package/src/shared-types.d.ts +8 -0
  371. package/src/shared-types.ts +64 -1
  372. package/src/status/builders.ts +26 -6
  373. package/src/status/normalize.ts +2 -0
  374. package/src/status/reporter.ts +19 -1
  375. package/src/status/snapshot.ts +95 -26
  376. package/src/system/hash.ts +23 -0
  377. package/src/system/host-memory.ts +29 -12
  378. package/src/system/load-better-sqlite3.ts +68 -0
  379. package/src/types.ts +5 -0
  380. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  381. package/dist/mesh/mesh-sync.d.ts +0 -53
  382. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  383. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  384. 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 '';
@@ -119,26 +118,52 @@ export function appendBoundedText(current: string, chunk: string, maxChars: numb
119
118
  return current.slice(-keepFromCurrent) + chunk;
120
119
  }
121
120
 
121
+ // Force-send (mesh coordinator dispatch / reconcile redelivery) writes raw
122
+ // keystrokes straight into the PTY, bypassing the normal echo-wait submit
123
+ // pipeline.
124
+ //
125
+ // History of this path's failure modes:
126
+ // 1. Originally it wrote `text + sendKey` in a single PTY write with zero
127
+ // settle time. Injected into an idle TUI input box that was still entering
128
+ // its input-accepting state, the trailing submit key could be swallowed —
129
+ // prompt typed but never submitted ("text injected, Enter not pressed").
130
+ // 2. The first fix split inject from submit: write(text) → echo-gated settle →
131
+ // write(sendKey) as a *separate* PTY write. That broke win32: ConPTY/TUI
132
+ // does not recognize a lone '\r' that arrives in its own PTY chunk
133
+ // 150ms+ after the text as a submit key, and the win32 echo gate never
134
+ // matched (ConPTY echo/parsing differences), so the cap was burned and the
135
+ // same detached lone CR was emitted — adding latency while the Enter still
136
+ // got swallowed.
137
+ //
138
+ // Current behavior: keep a fixed settle gap so the input handler is in its
139
+ // input-accepting state, then write `content + sendKey` as a SINGLE atomic PTY
140
+ // write — the same verified mechanism the normal `immediate` submit strategy
141
+ // uses (submitImmediatePrompt). With the Enter in the same write unit as the
142
+ // text, win32 ConPTY always sees it as a submit and the race that the split was
143
+ // trying to solve cannot happen (the Enter can never be separated from the
144
+ // text). FORCE_SUBMIT_SETTLE_MS is that minimum pre-submit gap.
145
+ const FORCE_SUBMIT_SETTLE_MS = 150;
146
+
122
147
  // ─── Adapter ────────────────────────────────────────
123
148
 
124
149
  export class ProviderCliAdapter implements CliAdapter {
125
- readonly cliType: string;
150
+ cliType: string;
126
151
  readonly cliName: string;
127
152
  public workingDir: string;
128
153
 
129
154
  private provider: CliProviderModule;
130
155
  private ptyProcess: PtyRuntimeTransport | null = null;
131
156
  private transportFactory: PtyTransportFactory;
132
- private currentStatus: CliSessionStatus['status'] = 'starting';
133
157
  private onStatusChange: (() => void) | null = null;
134
158
 
159
+ // ─── State machine engine ─────────────────────────
160
+ readonly engine: CliStateEngine;
161
+
135
162
  private responseBuffer = '';
136
163
  private recentOutputBuffer = '';
137
- private isWaitingForResponse = false;
138
- private activeModal: { message: string; buttons: string[] } | null = null;
139
- private parseErrorMessage: string | null = null;
164
+ private get parseErrorMessage(): string | null { return this.runner.parseErrorMessage; }
165
+ private providerSessionId: string | null = null;
140
166
  private responseTimeout: NodeJS.Timeout | null = null;
141
- private idleTimeout: NodeJS.Timeout | null = null;
142
167
  private ready = false;
143
168
  private startupBuffer = '';
144
169
  private startupParseGate = false;
@@ -164,39 +189,48 @@ export class ProviderCliAdapter implements CliAdapter {
164
189
  private serverConn: any = null;
165
190
  private logBuffer: { message: string; level: string }[] = [];
166
191
 
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;
192
+ private pendingOutboundQueue: PendingOutboundMessage[] = [];
193
+ private pendingOutboundFlushTimer: NodeJS.Timeout | null = null;
194
+ private pendingOutboundFlushInFlight = false;
195
+ // Submit retry timer — PTY-level, not state machine
183
196
  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
197
 
190
198
  // Resize redraw suppression
191
199
  private resizeSuppressUntil: number = 0;
192
200
 
193
- // Debug: status transition history
194
- private statusHistory: { status: string; at: number; trigger?: string }[] = [];
201
+ // (A2.2) Native transcript anchor moved to CHAT_SOURCE_REGISTRY.
202
+ // ChatSourceMachine holds the lock by state, not by a mutable field on
203
+ // the adapter. Removed entirely; no callers remain after the readChat
204
+ // ladder was replaced.
205
+
206
+ // ─── Script runner (parsing isolated here, adapter stays as transport) ───
207
+ private readonly runner: CliScriptRunner;
208
+ /** @deprecated use runner.cliScripts for direct script access */
209
+ get cliScripts(): CliScripts { return this.runner.cliScripts; }
195
210
 
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;
211
+ /**
212
+ * Recent script invocations (oldest → newest). Exposed via the
213
+ * `get_chat_debug_bundle` daemon command so anyone debugging a
214
+ * stuck-status regression can read what each detectStatus /
215
+ * parseSession call actually saw and returned, instead of having
216
+ * to instrument the daemon.
217
+ */
218
+ getScriptInvocationTrace() {
219
+ return this.runner.getInvocationTrace();
220
+ }
221
+
222
+ /**
223
+ * Returns the full raw PTY byte stream captured since adapter start.
224
+ * Used by the `record_provider_pty` IPC command to produce fixtures.
225
+ * Bounded by MAX_ACCUMULATED_BUFFER; older bytes may have been dropped.
226
+ */
227
+ getAccumulatedRawBuffer(): { text: string; droppedChars: number } {
228
+ return {
229
+ text: this.accumulatedRawBuffer,
230
+ droppedChars: this.accumulatedRawBufferDroppedChars,
231
+ };
232
+ }
233
+ set cliScripts(scripts: CliScripts) { this.setCliScripts(scripts); }
200
234
  private runtimeSettings: Record<string, any> = {};
201
235
  /** Full accumulated rendered PTY transcript for parser/readback use */
202
236
  private accumulatedBuffer: string = '';
@@ -205,7 +239,7 @@ export class ProviderCliAdapter implements CliAdapter {
205
239
  /** Full accumulated raw PTY output (with ANSI) */
206
240
  private accumulatedRawBuffer: string = '';
207
241
  /** Current visible terminal screen snapshot */
208
- private terminalScreen = new TerminalScreen(24, 80);
242
+ private terminalScreen = new TerminalScreen(DEFAULT_SESSION_HOST_ROWS, DEFAULT_SESSION_HOST_COLS);
209
243
  private static readonly MAX_RESPONSE_BUFFER = 8000;
210
244
  private static readonly MAX_RECENT_OUTPUT_BUFFER = 1000;
211
245
  private responseBufferDroppedChars = 0;
@@ -216,10 +250,6 @@ export class ProviderCliAdapter implements CliAdapter {
216
250
  * Hermes turn (tool calls + reasoning + final bubble) without the
217
251
  * rolling window pushing the turn's ╭─ opening line out of view. */
218
252
  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
253
  private parsedStatusCache: {
224
254
  responseBuffer: string;
225
255
  currentTurnScope: TurnParseScope | null;
@@ -233,11 +263,8 @@ export class ProviderCliAdapter implements CliAdapter {
233
263
  result: any;
234
264
  } | null = null;
235
265
  private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
236
- private static readonly MAX_TRACE_ENTRIES = 250;
237
266
 
238
267
  private readonly providerResolutionMeta: ProviderResolutionMeta;
239
- private static readonly FINISH_RETRY_DELAY_MS = 300;
240
- private static readonly MAX_FINISH_RETRIES = 2;
241
268
 
242
269
  private getBufferState(): NonNullable<CliSessionStatus['bufferState']> | undefined {
243
270
  const build = (droppedChars: number, maxChars: number) => droppedChars > 0
@@ -313,13 +340,13 @@ export class ProviderCliAdapter implements CliAdapter {
313
340
  if (
314
341
  cached
315
342
  && cached.responseBuffer === this.responseBuffer
316
- && cached.currentTurnScope === this.currentTurnScope
343
+ && cached.currentTurnScope === this.engine.currentTurnScope
317
344
  && cached.recentOutputBuffer === this.recentOutputBuffer
318
345
  && cached.accumulatedBuffer === this.accumulatedBuffer
319
346
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
320
347
  && cached.screenText === this.lastScreenText
321
- && cached.currentStatus === this.currentStatus
322
- && cached.activeModal === this.activeModal
348
+ && cached.currentStatus === this.engine.currentStatus
349
+ && cached.activeModal === this.engine.activeModal
323
350
  && cached.cliName === this.cliName
324
351
  ) {
325
352
  return cached.result;
@@ -343,86 +370,6 @@ export class ProviderCliAdapter implements CliAdapter {
343
370
  return this.timeouts.statusActivityHold;
344
371
  }
345
372
 
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
373
  // Resolved timeouts
427
374
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
428
375
 
@@ -432,7 +379,6 @@ export class ProviderCliAdapter implements CliAdapter {
432
379
  private readonly sendKey: string;
433
380
  private readonly submitStrategy: 'wait_for_echo' | 'immediate';
434
381
  private readonly requirePromptEchoBeforeSubmit: boolean;
435
- private static readonly SCRIPT_STATUS_DEBOUNCE_MS = 3000;
436
382
 
437
383
  constructor(
438
384
  provider: CliProviderModule,
@@ -441,6 +387,7 @@ export class ProviderCliAdapter implements CliAdapter {
441
387
  private extraEnv: Record<string, string> = {},
442
388
  transportFactory: PtyTransportFactory = new NodePtyTransportFactory(),
443
389
  ) {
390
+ this.runner = new CliScriptRunner(provider.type);
444
391
  this.provider = provider;
445
392
  this.transportFactory = transportFactory;
446
393
  this.cliType = provider.type;
@@ -458,10 +405,33 @@ export class ProviderCliAdapter implements CliAdapter {
458
405
  this.requirePromptEchoBeforeSubmit = resolvedConfig.requirePromptEchoBeforeSubmit;
459
406
  this.providerResolutionMeta = resolvedConfig.providerResolutionMeta;
460
407
 
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);
408
+ // State machine engineowns all status transitions
409
+ this.engine = new CliStateEngine(
410
+ provider,
411
+ this.runner,
412
+ this as unknown as CliTransportAccess,
413
+ {
414
+ onStatusChange: () => { this.onStatusChange?.(); },
415
+ onApplyParsedSession: (session) => { this.applyParsedSessionMetadata(session); },
416
+ onTurnCompleted: () => { this.responseBuffer = ''; },
417
+ } satisfies CliStateEngineCallbacks,
418
+ resolvedConfig.timeouts,
419
+ );
420
+
421
+ // Scripts delegated to CliScriptRunner — adapter stays as transport.
422
+ // Pass the manifest tui block so the runner can build the
423
+ // declarativeDetectStatus / declarativeParseApproval SDK functions
424
+ // that v1 extended-tier overrides depend on (without these, scripts
425
+ // like codex-cli's detect_status v1 fail-closed with return 'idle'
426
+ // and the session sticks in `generating` forever).
427
+ this.runner.setScripts(provider.scripts || {}, provider.tui);
428
+ // Per-invocation wall-clock budget for provider scripts. Manifests may
429
+ // raise this for genuinely slow parsers, but the default (50ms) is the
430
+ // settle-loop safety net — any script that exceeds it gets flagged in
431
+ // the invocation trace and surfaced via the debug bundle so we can
432
+ // identify the offender instead of guessing why the settle loop hangs.
433
+ this.runner.setScriptCallBudget(provider.scriptCallBudgetMs ?? 50);
434
+ const scriptNames = this.runner.getScriptNames();
465
435
  if (scriptNames.length > 0) {
466
436
  LOG.info('CLI', `[${this.cliType}] CLI scripts: [${scriptNames.join(', ')}]`);
467
437
  LOG.info(
@@ -487,14 +457,9 @@ export class ProviderCliAdapter implements CliAdapter {
487
457
 
488
458
  /** Inject CLI scripts after construction (e.g. when resolved by ProviderLoader) */
489
459
  setCliScripts(scripts: CliScripts): void {
490
- this.cliScripts = scripts;
460
+ this.runner.setScripts(scripts);
491
461
  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(', ')}]`);
462
+ LOG.info('CLI', `[${this.cliType}] CLI scripts injected: [${this.runner.getScriptNames().join(', ')}]`);
498
463
  }
499
464
 
500
465
  /** Refresh provider scripts/config used by this adapter without restarting the PTY runtime. */
@@ -548,15 +513,6 @@ export class ProviderCliAdapter implements CliAdapter {
548
513
  });
549
514
 
550
515
  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
516
 
561
517
  try {
562
518
  this.ptyProcess = this.transportFactory.spawn(
@@ -620,13 +576,12 @@ export class ProviderCliAdapter implements CliAdapter {
620
576
  this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
621
577
  LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode}`);
622
578
  this.flushPendingOutputParse();
623
- this.recordTrace('exit', { exitCode });
624
579
  this.ptyProcess = null;
625
- this.setStatus('stopped', 'pty_exit');
580
+ this.engine.onPtyExit();
626
581
  this.ready = false;
627
582
  this.startupParseGate = false;
628
583
  this.spawnAt = 0;
629
- this.scriptState = null;
584
+ this.runner.resetSessionState();
630
585
  this.onStatusChange?.();
631
586
  });
632
587
 
@@ -635,17 +590,11 @@ export class ProviderCliAdapter implements CliAdapter {
635
590
  this.startupBuffer = '';
636
591
  this.startupFirstOutputAt = 0;
637
592
  if (this.startupSettleTimer) { clearTimeout(this.startupSettleTimer); this.startupSettleTimer = null; }
638
- this.resetTerminalScreen(24, 80);
593
+ this.resetTerminalScreen(DEFAULT_SESSION_HOST_ROWS, DEFAULT_SESSION_HOST_COLS);
639
594
  this.pendingTerminalQueryTail = '';
640
- this.currentTurnScope = null;
641
- this.finishRetryCount = 0;
642
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
643
595
  this.ready = false;
644
596
  await this.ptyProcess.ready;
645
- this.recordTrace('ready', {
646
- runtimeMeta: this.getRuntimeMetadata(),
647
- });
648
- this.setStatus('starting', 'pty_ready');
597
+ this.engine.onSpawnReady();
649
598
  this.scheduleStartupSettleCheck();
650
599
  this.onStatusChange?.();
651
600
  }
@@ -671,11 +620,11 @@ export class ProviderCliAdapter implements CliAdapter {
671
620
  if (this.startupParseGate && !this.startupFirstOutputAt && (cleanData.trim() || normalizedScreenSnapshot.trim())) {
672
621
  this.startupFirstOutputAt = now;
673
622
  }
674
- if (this.idleFinishCandidate && (rawData.length > 0 || cleanData.length > 0)) {
675
- this.clearIdleFinishCandidate('new_output');
623
+ if (rawData.length > 0 || cleanData.length > 0) {
624
+ this.engine.clearIdleFinishCandidate('new_output');
676
625
  }
677
626
  if (getDebugRuntimeConfig().collectDebugTrace) {
678
- this.recordTrace('output', {
627
+ this.engine.recordExternalTrace('output', {
679
628
  rawLength: rawData.length,
680
629
  cleanLength: cleanData.length,
681
630
  rawPreview: summarizeCliTraceText(rawData, 300),
@@ -687,7 +636,7 @@ export class ProviderCliAdapter implements CliAdapter {
687
636
  this.scheduleStartupSettleCheck();
688
637
  }
689
638
 
690
- if (this.isWaitingForResponse && cleanData) {
639
+ if (this.engine.isWaitingForResponse && cleanData) {
691
640
  const previousResponseLen = this.responseBuffer.length;
692
641
  this.responseBuffer = appendBoundedText(this.responseBuffer, cleanData, ProviderCliAdapter.MAX_RESPONSE_BUFFER);
693
642
  this.responseBufferDroppedChars += this.recordBoundedAppendDrop(previousResponseLen, cleanData.length, this.responseBuffer.length);
@@ -726,19 +675,19 @@ export class ProviderCliAdapter implements CliAdapter {
726
675
  // Keep turn-scope offsets aligned with the truncated buffer so scoped
727
676
  // parses don't lose the beginning of a long turn (e.g. the Hermes
728
677
  // ╭─ opening line) when the rolling window sheds bytes.
729
- if (this.currentTurnScope) {
678
+ if (this.engine.currentTurnScope) {
730
679
  if (droppedClean > 0) {
731
- this.currentTurnScope.bufferStart = Math.max(0, this.currentTurnScope.bufferStart - droppedClean);
680
+ this.engine.currentTurnScope.bufferStart = Math.max(0, this.engine.currentTurnScope.bufferStart - droppedClean);
732
681
  }
733
682
  if (droppedRaw > 0) {
734
- this.currentTurnScope.rawBufferStart = Math.max(0, this.currentTurnScope.rawBufferStart - droppedRaw);
683
+ this.engine.currentTurnScope.rawBufferStart = Math.max(0, this.engine.currentTurnScope.rawBufferStart - droppedRaw);
735
684
  }
736
685
  }
737
686
 
738
687
  this.resolveStartupState('output', screenText, normalizedScreenSnapshot, now);
739
688
 
740
689
  // ─── Script-based status detection
741
- this.scheduleSettle();
690
+ this.engine.scheduleSettle();
742
691
  }
743
692
 
744
693
  private resolveStartupState(
@@ -761,6 +710,11 @@ export class ProviderCliAdapter implements CliAdapter {
761
710
  if (stableMs < 2000) return;
762
711
 
763
712
  const startupModal = this.runParseApproval(this.recentOutputBuffer);
713
+ const startupStatus = this.runDetectStatus(screenText || this.recentOutputBuffer);
714
+ if (!startupModal && startupStatus !== 'idle') {
715
+ this.scheduleStartupSettleCheck();
716
+ return;
717
+ }
764
718
  this.startupParseGate = false;
765
719
  if (this.startupSettleTimer) {
766
720
  clearTimeout(this.startupSettleTimer);
@@ -768,14 +722,22 @@ export class ProviderCliAdapter implements CliAdapter {
768
722
  }
769
723
  this.ready = true;
770
724
  if (startupModal) {
771
- this.activeModal = startupModal;
772
- this.setStatus('waiting_approval', `startup_ready:${trigger}`);
725
+ this.engine.activeModal = startupModal;
726
+ this.engine.setStatus('waiting_approval', `startup_ready:${trigger}`);
773
727
  } else {
774
- if (this.currentStatus === 'waiting_approval' || this.activeModal) {
775
- this.lastApprovalResolvedAt = Date.now();
728
+ if (this.engine.currentStatus === 'waiting_approval' || this.engine.activeModal) {
729
+ this.engine.lastApprovalResolvedAt = Date.now();
776
730
  }
777
- this.activeModal = null;
778
- this.setStatus('idle', `startup_ready:${trigger}`);
731
+ this.engine.activeModal = null;
732
+ // Clear the in-flight turn flag at the same time we declare
733
+ // startup-idle. Otherwise the next settled evaluation sees
734
+ // isWaitingForResponse=true + recent CLI welcome-screen paints
735
+ // and flips us right back to generating via the hold path
736
+ // (the "startup → generating → idle → generating → idle"
737
+ // flicker the user observed on claude-cli launch).
738
+ this.engine.isWaitingForResponse = false;
739
+ this.engine.currentTurnScope = null;
740
+ this.engine.setStatus('idle', `startup_ready:${trigger}`);
779
741
  }
780
742
  LOG.info(
781
743
  'CLI',
@@ -801,88 +763,6 @@ export class ProviderCliAdapter implements CliAdapter {
801
763
  }, delayMs);
802
764
  }
803
765
 
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
766
  private async waitForInteractivePrompt(maxWaitMs = 5000): Promise<void> {
887
767
  const startedAt = Date.now();
888
768
  let loggedWait = false;
@@ -892,7 +772,7 @@ export class ProviderCliAdapter implements CliAdapter {
892
772
  const screenText = this.terminalScreen.getText() || '';
893
773
  const stableMs = this.lastScreenChangeAt ? (Date.now() - this.lastScreenChangeAt) : 0;
894
774
  const recentlyOutput = this.lastNonEmptyOutputAt ? (Date.now() - this.lastNonEmptyOutputAt) : Number.MAX_SAFE_INTEGER;
895
- const status = this.runDetectStatus(this.recentOutputBuffer) || this.currentStatus;
775
+ const status = this.runDetectStatus(this.recentOutputBuffer) || this.engine.currentStatus;
896
776
  const interactiveReady = status === 'idle'
897
777
  && stableMs >= 700
898
778
  && recentlyOutput >= 350;
@@ -926,637 +806,84 @@ export class ProviderCliAdapter implements CliAdapter {
926
806
 
927
807
  private clearAllTimers(): void {
928
808
  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
809
  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
810
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
936
811
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
812
+ this.engine.clearAllTimers();
937
813
  }
938
814
 
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
- }
815
+ // ─── Script dispatch — builds inputs for CliScriptRunner ──────────────────
996
816
 
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
- }),
817
+ runParseSession(): ParsedSession | null {
818
+ const screenText = this.terminalScreen.getText();
819
+ const parseScreenText = this.getParseScreenText(screenText);
820
+ const tail = this.recentOutputBuffer.slice(-500);
821
+ const input = buildCliParseInput({
822
+ accumulatedBuffer: this.accumulatedBuffer,
823
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
824
+ recentOutputBuffer: this.recentOutputBuffer,
825
+ terminalScreenText: parseScreenText,
826
+ workingDir: this.workingDir,
827
+ providerSessionId: this.providerSessionId || undefined,
828
+ historySessionId: this.providerSessionId || undefined,
829
+ baseMessages: [],
830
+ partialResponse: this.responseBuffer,
831
+ isWaitingForResponse: this.engine.isWaitingForResponse,
832
+ scope: this.engine.currentTurnScope,
833
+ runtimeSettings: this.runtimeSettings,
834
+ spawnAt: this.spawnAt,
1043
835
  });
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
- }),
836
+ const session = this.runner.parseSession({
837
+ ...input,
838
+ tail,
839
+ tailScreen: buildCliScreenSnapshot(tail),
1168
840
  });
1169
- this.onStatusChange?.();
841
+ if (session && typeof session === 'object') this.applyParsedSessionMetadata(session);
842
+ return session;
1170
843
  }
1171
844
 
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
- }),
845
+ runDetectStatus(text: string): string | null {
846
+ const screenText = this.terminalScreen.getText();
847
+ const tail = text.slice(-500);
848
+ return this.runner.detectStatus({
849
+ tail,
850
+ screenText,
851
+ rawBuffer: this.accumulatedRawBuffer,
852
+ isWaitingForResponse: this.engine.isWaitingForResponse,
853
+ screen: buildCliScreenSnapshot(screenText),
854
+ tailScreen: buildCliScreenSnapshot(tail),
1339
855
  });
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
856
  }
1376
857
 
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),
858
+ runParseApproval(tail: string): { message: string; buttons: string[] } | null {
859
+ const screenText = this.terminalScreen.getText();
860
+ const buffer = screenText || this.accumulatedBuffer;
861
+ return this.runner.parseApproval({
862
+ buffer,
863
+ screenText,
864
+ rawBuffer: this.accumulatedRawBuffer,
865
+ tail,
866
+ screen: buildCliScreenSnapshot(screenText),
867
+ bufferScreen: buildCliScreenSnapshot(buffer),
868
+ tailScreen: buildCliScreenSnapshot(tail),
1408
869
  });
1409
- return true;
1410
870
  }
1411
871
 
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
- }
1530
-
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;
872
+ private applyParsedSessionMetadata(parsed: any): void {
873
+ const providerSessionId = typeof parsed?.providerSessionId === 'string' && parsed.providerSessionId.trim()
874
+ ? parsed.providerSessionId.trim()
875
+ : '';
876
+ if (providerSessionId && providerSessionId !== this.providerSessionId) {
877
+ this.providerSessionId = providerSessionId;
878
+ this.updateRuntimeMeta({ providerSessionId });
1548
879
  }
1549
880
  }
1550
881
 
1551
- private hasActionableApproval(startupModal: { message: string; buttons: string[] } | null = null): boolean {
1552
- return !!(startupModal || this.activeModal);
1553
- }
1554
-
1555
882
  private projectEffectiveStatus(startupModal: { message: string; buttons: string[] } | null = null): CliSessionStatus['status'] {
1556
883
  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;
884
+ if (!!(startupModal || this.engine.activeModal)) return 'waiting_approval';
885
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope && this.engine.currentStatus !== 'stopped') return 'generating';
886
+ return this.engine.currentStatus;
1560
887
  }
1561
888
 
1562
889
  // ─── Public API (CliAdapter) ───────────────────
@@ -1564,17 +891,73 @@ export class ProviderCliAdapter implements CliAdapter {
1564
891
  getStatus(options: { allowParse?: boolean } = {}): CliSessionStatus {
1565
892
  const allowParse = options.allowParse !== false;
1566
893
  const startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
894
+ const startupDetectedStatus = allowParse && this.startupParseGate && !startupModal
895
+ ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
896
+ : null;
1567
897
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
1568
- let effectiveModal = startupModal || this.activeModal;
898
+ let effectiveModal = startupModal || this.engine.activeModal;
899
+ // (fix) When we have no captured modal yet, take one more live attempt
900
+ // with the current screen text — the engine's settle pass can miss
901
+ // the modal when it happens to fire exactly between writes, and
902
+ // without a modal here the dashboard could never show the buttons.
903
+ // We deliberately do NOT overwrite an existing engine.activeModal so
904
+ // a stable matched modal wins. This runs even outside the startup
905
+ // gate because Claude's approval frames can appear long after launch.
906
+ if (allowParse && !effectiveModal && this.engine.isWaitingForResponse) {
907
+ const liveDetect = this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText());
908
+ if (liveDetect === 'waiting_approval') {
909
+ const liveModal = this.runParseApproval(this.terminalScreen.getText())
910
+ || this.runParseApproval(this.recentOutputBuffer);
911
+ // (fix) Only surface modals that have at least one non-empty
912
+ // button. Half-rendered approval frames produced
913
+ // { message, buttons: [] } briefly — auto-approve would then
914
+ // pick buttonIndex=-1 and resolveModal would write the
915
+ // provider's index-0 key into the prompt. Skip those frames
916
+ // and let a later evaluate find the complete modal.
917
+ const buttonsOk = liveModal && Array.isArray(liveModal.buttons)
918
+ && liveModal.buttons.some((b: any) => typeof b === 'string' && b.trim());
919
+ if (liveModal && buttonsOk) {
920
+ effectiveModal = liveModal;
921
+ if (!this.engine.activeModal) this.engine.activeModal = liveModal;
922
+ }
923
+ }
924
+ }
925
+ // Only surface waiting_approval when we ALSO have a concrete modal
926
+ // (message + buttons). detectStatus alone can fire while parseApproval
927
+ // is still null — the engine logs "detectStatus=waiting_approval but
928
+ // parseApproval returned null; ignoring". Without this guard getStatus
929
+ // was shipping a bare waiting_approval with activeModal=null and the
930
+ // user perceived the flow as broken.
931
+ if (startupDetectedStatus === 'waiting_approval' && effectiveModal) {
932
+ effectiveStatus = 'waiting_approval';
933
+ } else if (startupDetectedStatus === 'idle' && !startupModal && !effectiveModal) {
934
+ effectiveStatus = 'idle';
935
+ }
1569
936
  if (allowParse && !startupModal && !effectiveModal) {
1570
937
  const parsed = this.getFreshParsedStatusCache();
1571
938
  const parsedModal = parsed?.activeModal && Array.isArray(parsed.activeModal.buttons)
1572
939
  && parsed.activeModal.buttons.some((button: any) => typeof button === 'string' && button.trim())
1573
940
  ? parsed.activeModal
1574
941
  : null;
942
+ const hasFinalAssistant = (p: any) => {
943
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
944
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
945
+ };
1575
946
  if (parsed?.status === 'waiting_approval' && parsedModal) {
1576
947
  effectiveStatus = 'waiting_approval';
1577
948
  effectiveModal = parsedModal;
949
+ } else if (
950
+ effectiveStatus === 'idle'
951
+ && parsed?.status === 'generating'
952
+ && !hasFinalAssistant(parsed)
953
+ ) {
954
+ effectiveStatus = 'generating';
955
+ } else if (
956
+ effectiveStatus === 'generating'
957
+ && parsed?.status === 'idle'
958
+ && hasFinalAssistant(parsed)
959
+ ) {
960
+ effectiveStatus = 'idle';
1578
961
  }
1579
962
  }
1580
963
  const bufferState = this.getBufferState();
@@ -1583,8 +966,21 @@ export class ProviderCliAdapter implements CliAdapter {
1583
966
  messages: [],
1584
967
  workingDir: this.workingDir,
1585
968
  activeModal: effectiveModal,
1586
- errorMessage: this.parseErrorMessage || undefined,
1587
- errorReason: this.parseErrorMessage ? 'parse_error' : undefined,
969
+ approvalEntrySeq: this.engine.approvalEntrySeq,
970
+ lastResolvedEntrySeq: this.engine.lastResolvedEntrySeq,
971
+ pendingOutboundCount: this.pendingOutboundQueue.length,
972
+ pendingOutboundMessages: this.pendingOutboundQueue.map((message) => ({
973
+ id: message.id,
974
+ role: message.role,
975
+ content: message.content,
976
+ queuedAt: message.queuedAt,
977
+ source: message.source,
978
+ })),
979
+ errorMessage: this.parseErrorMessage || this.engine.providerErrorMessage || undefined,
980
+ errorReason: this.parseErrorMessage ? 'parse_error' : (this.engine.providerErrorReason || undefined),
981
+ providerSessionId: this.providerSessionId || undefined,
982
+ lastOutputAt: this.lastOutputAt,
983
+ lastScreenChangeAt: this.lastScreenChangeAt,
1588
984
  ...(bufferState ? { bufferState } : {}),
1589
985
  };
1590
986
  }
@@ -1600,15 +996,16 @@ export class ProviderCliAdapter implements CliAdapter {
1600
996
  const cached = this.parsedStatusCache;
1601
997
  const accumulatedRawBufferKey = this.getAccumulatedRawBufferCacheKey();
1602
998
  if (
1603
- cached
999
+ !this.providerOwnsTranscript()
1000
+ && cached
1604
1001
  && cached.responseBuffer === this.responseBuffer
1605
- && cached.currentTurnScope === this.currentTurnScope
1002
+ && cached.currentTurnScope === this.engine.currentTurnScope
1606
1003
  && cached.recentOutputBuffer === this.recentOutputBuffer
1607
1004
  && cached.accumulatedBuffer === this.accumulatedBuffer
1608
1005
  && cached.accumulatedRawBufferKey === accumulatedRawBufferKey
1609
1006
  && cached.screenText === parseScreenText
1610
- && cached.currentStatus === this.currentStatus
1611
- && cached.activeModal === this.activeModal
1007
+ && cached.currentStatus === this.engine.currentStatus
1008
+ && cached.activeModal === this.engine.activeModal
1612
1009
  && cached.cliName === this.cliName
1613
1010
  ) {
1614
1011
  return cached.result;
@@ -1623,14 +1020,20 @@ export class ProviderCliAdapter implements CliAdapter {
1623
1020
  const bufferState = this.getBufferState();
1624
1021
  const result = {
1625
1022
  id: (parsed as any).id || 'cli_session',
1626
- status: parsed.status || this.currentStatus,
1023
+ status: parsed.status || this.engine.currentStatus,
1627
1024
  title: (parsed as any).title || this.cliName,
1628
1025
  messages: normalizeCliParsedMessages(parsed.messages, {
1629
1026
  scope: null,
1630
1027
  lastOutputAt: this.lastOutputAt,
1631
1028
  }),
1632
1029
  activeModal,
1633
- providerSessionId: typeof (parsed as any).providerSessionId === 'string' ? (parsed as any).providerSessionId : undefined,
1030
+ providerSessionId: this.providerSessionId || (typeof (parsed as any).providerSessionId === 'string' ? (parsed as any).providerSessionId : undefined),
1031
+ errorMessage: typeof (parsed as any).errorMessage === 'string' && (parsed as any).errorMessage.trim()
1032
+ ? (parsed as any).errorMessage.trim()
1033
+ : undefined,
1034
+ errorReason: typeof (parsed as any).errorReason === 'string' && (parsed as any).errorReason.trim()
1035
+ ? (parsed as any).errorReason.trim()
1036
+ : undefined,
1634
1037
  ...(bufferState ? { bufferState } : {}),
1635
1038
  ...((parsed as any).transcriptAuthority === 'provider' || (parsed as any).transcriptAuthority === 'daemon'
1636
1039
  ? { transcriptAuthority: (parsed as any).transcriptAuthority }
@@ -1642,13 +1045,13 @@ export class ProviderCliAdapter implements CliAdapter {
1642
1045
 
1643
1046
  this.parsedStatusCache = {
1644
1047
  responseBuffer: this.responseBuffer,
1645
- currentTurnScope: this.currentTurnScope,
1048
+ currentTurnScope: this.engine.currentTurnScope,
1646
1049
  recentOutputBuffer: this.recentOutputBuffer,
1647
1050
  accumulatedBuffer: this.accumulatedBuffer,
1648
1051
  accumulatedRawBufferKey,
1649
1052
  screenText: parseScreenText,
1650
- currentStatus: this.currentStatus,
1651
- activeModal: this.activeModal,
1053
+ currentStatus: this.engine.currentStatus,
1054
+ activeModal: this.engine.activeModal,
1652
1055
  cliName: this.cliName,
1653
1056
  result,
1654
1057
  };
@@ -1656,22 +1059,22 @@ export class ProviderCliAdapter implements CliAdapter {
1656
1059
  }
1657
1060
 
1658
1061
  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
1062
  const input = buildCliParseInput({
1664
1063
  accumulatedBuffer: this.accumulatedBuffer,
1665
1064
  accumulatedRawBuffer: this.accumulatedRawBuffer,
1666
1065
  recentOutputBuffer: this.recentOutputBuffer,
1667
1066
  terminalScreenText: this.getParseScreenText(this.terminalScreen.getText()),
1067
+ workingDir: this.workingDir,
1068
+ providerSessionId: this.providerSessionId || undefined,
1069
+ historySessionId: this.providerSessionId || undefined,
1668
1070
  baseMessages: [],
1669
1071
  partialResponse: this.responseBuffer,
1670
- isWaitingForResponse: this.isWaitingForResponse,
1671
- scope: this.currentTurnScope,
1072
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1073
+ scope: this.engine.currentTurnScope,
1672
1074
  runtimeSettings: this.runtimeSettings,
1075
+ spawnAt: this.spawnAt,
1673
1076
  });
1674
- return await Promise.resolve(this.invokeCliScript(fn, {
1077
+ return await Promise.resolve(this.runner.invokeByName(scriptName, {
1675
1078
  ...input,
1676
1079
  args: args && typeof args === 'object' ? { ...args } : {},
1677
1080
  }));
@@ -1683,7 +1086,7 @@ export class ProviderCliAdapter implements CliAdapter {
1683
1086
 
1684
1087
  /** Whether this adapter has CLI scripts loaded */
1685
1088
  hasCliScripts(): boolean {
1686
- return typeof this.cliScripts?.detectStatus === 'function';
1089
+ return this.runner.hasDetectStatus();
1687
1090
  }
1688
1091
 
1689
1092
  /**
@@ -1692,24 +1095,29 @@ export class ProviderCliAdapter implements CliAdapter {
1692
1095
  */
1693
1096
  async resolveAction(data: any): Promise<void> {
1694
1097
  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
- }
1098
+ try {
1099
+ promptText = this.runner.invokeByName('resolveAction', data);
1100
+ } catch {
1101
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script not available`);
1102
+ return;
1701
1103
  }
1702
1104
  if (!promptText) {
1703
- LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not supply a prompt`);
1105
+ LOG.warn('CLI', `[${this.cliType}] resolveAction skipped: provider script did not return a prompt`);
1704
1106
  return;
1705
1107
  }
1706
1108
  await this.sendMessage(promptText);
1707
1109
  }
1708
1110
 
1111
+ async setInteractivePromptResponse(_response: InteractivePromptResponse): Promise<void> {
1112
+ // Legacy TUI providers do not currently expose an interactive prompt
1113
+ // protocol. Spec-backed claude-cli implements the first real wiring.
1114
+ }
1115
+
1709
1116
  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;
1117
+ if (!this.ptyProcess || !this.engine.isWaitingForResponse || this.engine.submitRetryUsed) return false;
1118
+ if (this.engine.hasActionableApproval()) return false;
1119
+ // If there's already meaningful response content beyond the echoed prompt, not stuck
1120
+ if (this.hasMeaningfulResponseBufferLocal(normalizedPromptSnippet)) return false;
1713
1121
  const screenText = this.terminalScreen.getText();
1714
1122
  if (!promptLikelyVisible(screenText, normalizedPromptSnippet)) return false;
1715
1123
  const liveApproval = this.runParseApproval(screenText) || this.runParseApproval(this.recentOutputBuffer);
@@ -1718,20 +1126,41 @@ export class ProviderCliAdapter implements CliAdapter {
1718
1126
  return liveStatus !== 'generating' && liveStatus !== 'waiting_approval';
1719
1127
  }
1720
1128
 
1129
+ private hasMeaningfulResponseBufferLocal(promptSnippet: string): boolean {
1130
+ const raw = String(this.responseBuffer || '').trim();
1131
+ if (!raw) return false;
1132
+ const normalizedPrompt = compactPromptText(promptSnippet);
1133
+ if (!normalizedPrompt) return true;
1134
+ const normalizedBuffer = compactPromptText(raw);
1135
+ if (!normalizedBuffer) return false;
1136
+ if (normalizedBuffer === normalizedPrompt) return false;
1137
+ if (normalizedBuffer.startsWith(normalizedPrompt)) {
1138
+ const remainder = normalizedBuffer
1139
+ .slice(normalizedPrompt.length)
1140
+ .replace(/[─═\-]+/g, '')
1141
+ .replace(/⏵⏵accepteditson\([^)]*\)/gi, '')
1142
+ .replace(/accepteditson\([^)]*\)/gi, '')
1143
+ .replace(/(?:◐|◑|◒|◓|◔|◕|◉|●|·)?(?:x?high|medium|low|max)·?\/effort/gi, '')
1144
+ .replace(/updateavailable!run:[a-z0-9:._\-/]+/gi, '')
1145
+ .replace(/esctointerrupt/gi, '')
1146
+ .replace(/❯/g, '')
1147
+ .replace(/^[\s\-–—:;,.!/?]+/, '')
1148
+ .trim();
1149
+ return remainder.length > 0;
1150
+ }
1151
+ return true;
1152
+ }
1153
+
1721
1154
  private async writeToPty(data: string): Promise<void> {
1722
1155
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1723
1156
  await this.ptyProcess.write(data);
1724
1157
  }
1725
1158
 
1726
1159
  private resetPendingSendState(reason: string): void {
1727
- this.isWaitingForResponse = false;
1728
1160
  this.responseBuffer = '';
1729
- this.currentTurnScope = null;
1730
- this.submitPendingUntil = 0;
1731
- this.clearIdleFinishCandidate(reason);
1732
1161
  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; }
1162
+ this.engine.resetActiveTurnState();
1163
+ this.engine.clearIdleFinishCandidate(reason);
1735
1164
  }
1736
1165
 
1737
1166
  private commitSendUserTurn(state: SendMessageState): void {
@@ -1748,42 +1177,14 @@ export class ProviderCliAdapter implements CliAdapter {
1748
1177
  }
1749
1178
  this.responseTimeout = setTimeout(() => {
1750
1179
  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
- });
1180
+ if (!this.engine.isWaitingForResponse) return;
1768
1181
 
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
1182
+ // maxResponse is a watchdog/checkpoint, not a completion signal. Re-run the
1773
1183
  // normal settled parser instead and keep the turn open unless the provider
1774
1184
  // 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
- });
1185
+ this.engine.evaluateSettled(this.getSnapshot());
1186
+
1187
+ if (this.engine.isWaitingForResponse && !this.engine.hasActionableApproval()) {
1787
1188
  this.armResponseTimeout();
1788
1189
  }
1789
1190
  }, timeoutMs);
@@ -1798,34 +1199,20 @@ export class ProviderCliAdapter implements CliAdapter {
1798
1199
  private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
1799
1200
  this.submitRetryTimer = null;
1800
1201
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1801
- const screenText = this.terminalScreen.getText();
1802
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1202
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1803
1203
  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
1204
  this.writeSubmitKeyForRetry('submit_retry');
1811
- if (attempt >= 3) { this.submitRetryUsed = true; return; }
1205
+ if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
1812
1206
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
1813
1207
  }
1814
1208
 
1815
1209
  private retryImmediateSubmitIfStuck(state: SendMessageState): void {
1816
1210
  this.submitRetryTimer = null;
1817
1211
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1818
- const screenText = this.terminalScreen.getText();
1819
- this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1212
+ this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1820
1213
  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
1214
  this.writeSubmitKeyForRetry('immediate_retry');
1828
- this.submitRetryUsed = true;
1215
+ this.engine.submitRetryUsed = true;
1829
1216
  }
1830
1217
 
1831
1218
  private submitSendKey(state: SendMessageState, completion: SendMessageCompletion): void {
@@ -1833,13 +1220,7 @@ export class ProviderCliAdapter implements CliAdapter {
1833
1220
  completion.resolveOnce();
1834
1221
  return;
1835
1222
  }
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
- });
1223
+ this.engine.submitPendingUntil = 0;
1843
1224
  void this.writeToPty(this.sendKey).then(() => {
1844
1225
  this.commitSendUserTurn(state);
1845
1226
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, 1), state.retryDelayMs);
@@ -1849,13 +1230,7 @@ export class ProviderCliAdapter implements CliAdapter {
1849
1230
  }
1850
1231
 
1851
1232
  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
- });
1233
+ this.engine.submitPendingUntil = 0;
1859
1234
  void this.writeToPty(state.text + this.sendKey).then(() => {
1860
1235
  this.commitSendUserTurn(state);
1861
1236
  this.submitRetryTimer = setTimeout(() => this.retryImmediateSubmitIfStuck(state), state.retryDelayMs);
@@ -1899,7 +1274,8 @@ export class ProviderCliAdapter implements CliAdapter {
1899
1274
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
1900
1275
  screenText: summarizeCliTraceText(screenText, 1000),
1901
1276
  };
1902
- this.recordTrace('submit_echo_missing', diagnostic);
1277
+ LOG.warn('CLI', `[${this.cliType}] submit_echo_missing: ${JSON.stringify(diagnostic)}`);
1278
+
1903
1279
  if (this.requirePromptEchoBeforeSubmit) {
1904
1280
  // At this point the prompt text write already completed. Rejecting without
1905
1281
  // a submit key can leave the delegated CLI with an unsent prompt sitting at
@@ -1924,12 +1300,145 @@ export class ProviderCliAdapter implements CliAdapter {
1924
1300
  ), 50);
1925
1301
  }
1926
1302
 
1927
- async sendMessage(text: string): Promise<void> {
1303
+ async sendMessage(text: string, options: { force?: boolean } = {}): Promise<void> {
1304
+ if (options.force === true) {
1305
+ await this.forceSendMessage(text);
1306
+ return;
1307
+ }
1308
+ await this.sendMessageNow(text, true);
1309
+ }
1310
+
1311
+ async forceSendMessage(text: string): Promise<void> {
1312
+ if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1313
+ const content = String(text || '');
1314
+ if (!content.trim()) return;
1315
+ // Modal-park guard (defense-in-depth — the primary guard is at the
1316
+ // cli-provider-instance force-forward chokepoint). A force-write writes raw
1317
+ // keystrokes into the PTY, bypassing the busy send-guard. If the session is
1318
+ // parked on a tool-consent modal, the modal's key handler eats those bytes
1319
+ // and silently resolves an approval the user never made. Hold the write and
1320
+ // let the mesh reconcile loop redeliver once the modal is resolved. We only
1321
+ // hold for an actionable approval modal; plain generating is still force-written
1322
+ // (that is the deadlock the force path exists to break).
1323
+ if (this.engine.currentStatus === 'waiting_approval' || this.engine.hasActionableApproval()) {
1324
+ LOG.info('CLI', `[${this.cliType}] force-send held — session parked on approval modal (status=${this.engine.currentStatus})`);
1325
+ return;
1326
+ }
1327
+ LOG.info('CLI', `[${this.cliType}] force-sending prompt while status=${this.engine.currentStatus}`);
1328
+ // Settle, then submit atomically. The previous split-write fix
1329
+ // (write text → settle → write a separate '\r') regressed win32:
1330
+ // ConPTY does not treat a lone CR arriving in its own PTY chunk after a
1331
+ // delay as a submit key, so the Enter was swallowed and the prompt sat
1332
+ // typed-but-unsent. Instead, honor a fixed settle gap so the TUI input
1333
+ // handler is in its input-accepting state, then write `content + sendKey`
1334
+ // as ONE PTY write — the same atomic submit the normal `immediate`
1335
+ // strategy uses. Keeping the Enter in the same write unit as the text is
1336
+ // the invariant that makes win32 ConPTY recognize the submit, and it
1337
+ // also makes the original inject↔submit race impossible (the Enter can
1338
+ // never be separated from the text it submits). The settle still guards
1339
+ // the original concern — that a force-dispatch injected into a freshly
1340
+ // idle session lands before the TUI is ready to accept input.
1341
+ await this.waitForForceSubmitSettle();
1342
+ await this.writeToPty(content + this.sendKey);
1343
+ this.onStatusChange?.();
1344
+ }
1345
+
1346
+ private async waitForForceSubmitSettle(): Promise<void> {
1347
+ // A fixed minimum gap so the input handler is ready to accept the paste.
1348
+ // We deliberately do NOT echo-gate here: the submit key is written in the
1349
+ // same atomic PTY write as the text (forceSendMessage), so there is no
1350
+ // separate Enter that could race the echo, and on win32 the echo gate
1351
+ // never reliably matched anyway.
1352
+ await new Promise<void>(resolve => setTimeout(resolve, FORCE_SUBMIT_SETTLE_MS));
1353
+ }
1354
+
1355
+ private enqueuePendingOutboundMessage(text: string, reason: string): void {
1356
+ const content = String(text || '');
1357
+ const duplicate = this.pendingOutboundQueue.some((message) => message.content === content);
1358
+ if (duplicate) {
1359
+ return;
1360
+ }
1361
+ const queuedAt = Date.now();
1362
+ const message: PendingOutboundMessage = {
1363
+ id: `${queuedAt}:${this.pendingOutboundQueue.length}:${Math.random().toString(36).slice(2, 10)}`,
1364
+ role: 'user',
1365
+ content,
1366
+ queuedAt,
1367
+ source: 'sendMessage',
1368
+ };
1369
+ this.pendingOutboundQueue.push(message);
1370
+ LOG.info('CLI', `[${this.cliType}] queued outbound message while busy (${reason}); queue=${this.pendingOutboundQueue.length}`);
1371
+ this.onStatusChange?.();
1372
+ }
1373
+
1374
+ private shouldQueuePendingOutboundMessage(parsedStatusBeforeSend: any | null = null): string | null {
1375
+ if (this.provider.allowInputDuringGeneration === true) return null;
1376
+ if (this.engine.hasActionableApproval()) return null;
1377
+ const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1378
+ ? String(parsedStatusBeforeSend.status)
1379
+ : '';
1380
+ const hasFinalAssistant = (p: any) => {
1381
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1382
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1383
+ };
1384
+ if (parsedSessionStatus === 'idle' && hasFinalAssistant(parsedStatusBeforeSend)) return null;
1385
+ if (this.engine.currentStatus === 'generating') return 'current_status_generating';
1386
+ if (parsedSessionStatus === 'generating' || parsedSessionStatus === 'no_progress' || parsedSessionStatus === 'long_generating') {
1387
+ const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
1388
+ const parsedHasActionableModal = Boolean(
1389
+ parsedModal
1390
+ && Array.isArray(parsedModal.buttons)
1391
+ && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
1392
+ );
1393
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
1394
+ && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
1395
+ && !this.engine.isWaitingForResponse
1396
+ && !this.engine.currentTurnScope
1397
+ && !this.engine.hasActionableApproval()
1398
+ && !parsedHasActionableModal;
1399
+ return terminalLooksIdle ? null : `parsed_status_${parsedSessionStatus}`;
1400
+ }
1401
+ if (this.engine.isWaitingForResponse && this.engine.currentTurnScope) return 'active_turn_in_progress';
1402
+ return null;
1403
+ }
1404
+
1405
+ private schedulePendingOutboundFlush(delayMs = 0): void {
1406
+ if (this.pendingOutboundFlushTimer) clearTimeout(this.pendingOutboundFlushTimer);
1407
+ this.pendingOutboundFlushTimer = setTimeout(() => {
1408
+ this.pendingOutboundFlushTimer = null;
1409
+ void this.flushPendingOutboundQueue();
1410
+ }, Math.max(0, delayMs));
1411
+ }
1412
+
1413
+ private async flushPendingOutboundQueue(): Promise<void> {
1414
+ if (this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length === 0) return;
1415
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) return;
1416
+ this.pendingOutboundFlushInFlight = true;
1417
+ try {
1418
+ while (this.pendingOutboundQueue.length > 0) {
1419
+ if (this.engine.currentStatus !== 'idle' || this.engine.isWaitingForResponse || this.engine.hasActionableApproval()) break;
1420
+ const next = this.pendingOutboundQueue[0];
1421
+ try {
1422
+ await this.sendMessageNow(next.content, false);
1423
+ this.pendingOutboundQueue.shift();
1424
+ this.onStatusChange?.();
1425
+ } catch (error: any) {
1426
+ LOG.warn('CLI', `[${this.cliType}] queued outbound flush failed: ${error?.message || error}`);
1427
+ this.schedulePendingOutboundFlush(1000);
1428
+ break;
1429
+ }
1430
+ }
1431
+ } finally {
1432
+ this.pendingOutboundFlushInFlight = false;
1433
+ }
1434
+ }
1435
+
1436
+ private async sendMessageNow(text: string, allowQueue: boolean): Promise<void> {
1928
1437
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1929
1438
  const allowInputDuringGeneration = this.provider.allowInputDuringGeneration === true;
1930
1439
  const allowInterventionPrompt = allowInputDuringGeneration
1931
- && this.isWaitingForResponse
1932
- && !this.hasActionableApproval();
1440
+ && this.engine.isWaitingForResponse
1441
+ && !this.engine.hasActionableApproval();
1933
1442
  if (this.startupParseGate) {
1934
1443
  const deadline = Date.now() + 10000;
1935
1444
  while (this.startupParseGate && Date.now() < deadline) {
@@ -1937,76 +1446,85 @@ export class ProviderCliAdapter implements CliAdapter {
1937
1446
  await new Promise(resolve => setTimeout(resolve, 50));
1938
1447
  }
1939
1448
  }
1449
+ const parsedStatusBeforeSend = !allowInputDuringGeneration
1450
+ ? (() => {
1451
+ try {
1452
+ return this.getScriptParsedStatus?.() || null;
1453
+ } catch {
1454
+ return null;
1455
+ }
1456
+ })()
1457
+ : null;
1458
+ const queueReason = this.shouldQueuePendingOutboundMessage(parsedStatusBeforeSend);
1459
+ if (allowQueue && queueReason) {
1460
+ this.enqueuePendingOutboundMessage(text, queueReason);
1461
+ return;
1462
+ }
1940
1463
  if (!allowInterventionPrompt) {
1941
1464
  await this.waitForInteractivePrompt();
1942
1465
  }
1943
1466
  if (!this.ready) {
1944
1467
  this.resolveStartupState('send_precheck');
1945
- if (this.runDetectStatus(this.recentOutputBuffer) === 'idle' && this.currentStatus === 'idle') {
1468
+ if (this.runDetectStatus(this.recentOutputBuffer) === 'idle') {
1946
1469
  this.ready = true;
1947
1470
  this.startupParseGate = false;
1471
+ this.engine.setStatus('idle', 'send_message_idle_prompt_recovery');
1948
1472
  LOG.info('CLI', `[${this.cliType}] sendMessage recovered idle prompt readiness`);
1949
1473
  }
1950
1474
  }
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;
1475
+ if (!this.ready) throw new Error(`${this.cliName} not ready (status: ${this.engine.currentStatus})`);
1961
1476
  const parsedSessionStatus = typeof parsedStatusBeforeSend?.status === 'string'
1962
1477
  ? String(parsedStatusBeforeSend.status)
1963
1478
  : '';
1964
- if (!allowInputDuringGeneration && (parsedSessionStatus === 'generating' || parsedSessionStatus === 'long_generating')) {
1479
+ if (!allowInputDuringGeneration && (parsedSessionStatus === 'generating' || parsedSessionStatus === 'no_progress' || parsedSessionStatus === 'long_generating')) {
1965
1480
  const parsedModal = parsedStatusBeforeSend?.activeModal ?? parsedStatusBeforeSend?.modal ?? null;
1966
1481
  const parsedHasActionableModal = Boolean(
1967
1482
  parsedModal
1968
1483
  && Array.isArray(parsedModal.buttons)
1969
1484
  && parsedModal.buttons.some((candidate: unknown) => typeof candidate === 'string' && candidate.trim()),
1970
1485
  );
1971
- const terminalLooksIdle = this.currentStatus === 'idle'
1486
+ const terminalLooksIdle = this.engine.currentStatus === 'idle'
1972
1487
  && this.runDetectStatus(this.recentOutputBuffer) === 'idle'
1973
- && !this.isWaitingForResponse
1974
- && !this.currentTurnScope
1975
- && !this.hasActionableApproval()
1488
+ && !this.engine.isWaitingForResponse
1489
+ && !this.engine.currentTurnScope
1490
+ && !this.engine.hasActionableApproval()
1976
1491
  && !parsedHasActionableModal;
1977
1492
  if (!terminalLooksIdle) {
1493
+ if (allowQueue) {
1494
+ this.enqueuePendingOutboundMessage(text, `parsed_status_${parsedSessionStatus}`);
1495
+ return;
1496
+ }
1978
1497
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1979
1498
  }
1980
1499
  }
1981
- if (this.isWaitingForResponse && !allowInputDuringGeneration) {
1982
- if (!this.clearStaleIdleResponseGuard('send_message_guard')) {
1500
+ if (this.engine.isWaitingForResponse && !allowInputDuringGeneration) {
1501
+ const snap = this.getSnapshot();
1502
+ if (
1503
+ !this.engine.clearStaleIdleResponseGuard('send_message_guard', snap)
1504
+ && !this.engine.clearParsedIdleResponseGuard('send_message_parsed_idle_guard', parsedStatusBeforeSend, snap)
1505
+ ) {
1506
+ if (allowQueue) {
1507
+ this.enqueuePendingOutboundMessage(text, 'waiting_for_response');
1508
+ return;
1509
+ }
1983
1510
  throw new Error(`${this.cliName} is still processing the previous prompt`);
1984
1511
  }
1985
1512
  }
1986
- this.isWaitingForResponse = true;
1987
1513
  this.responseBuffer = '';
1988
- this.finishRetryCount = 0;
1989
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
1990
- this.clearIdleFinishCandidate('send_message');
1991
- this.currentTurnScope = {
1514
+ const turnScope: TurnParseScope = {
1992
1515
  prompt: text,
1993
1516
  startedAt: Date.now(),
1994
1517
  bufferStart: this.accumulatedBuffer.length,
1995
1518
  rawBufferStart: this.accumulatedRawBuffer.length,
1996
1519
  };
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);
1520
+ LOG.info('CLI', `[${this.cliType}] sendMessage turn scope buffer=${turnScope.bufferStart} raw=${turnScope.rawBufferStart} prompt=${JSON.stringify(text).slice(0, 120)}`);
2006
1521
  if (this.submitRetryTimer) {
2007
1522
  clearTimeout(this.submitRetryTimer);
2008
1523
  this.submitRetryTimer = null;
2009
1524
  }
1525
+ this.engine.onTurnStarted(turnScope);
1526
+ this.engine.submitRetryPromptSnippet = extractPromptRetrySnippet(text);
1527
+ const normalizedPromptSnippet = normalizePromptText(this.engine.submitRetryPromptSnippet);
2010
1528
  const estimatedLines = estimatePromptDisplayLines(text);
2011
1529
  const submitDelayMs = this.sendDelayMs + Math.min(2000, Math.max(0, estimatedLines - 1) * 350);
2012
1530
  const maxEchoWaitMs = submitDelayMs + Math.max(1500, Math.min(5000, estimatedLines * 500));
@@ -2019,12 +1537,7 @@ export class ProviderCliAdapter implements CliAdapter {
2019
1537
  retryDelayMs,
2020
1538
  didCommitUserTurn: false,
2021
1539
  };
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;
1540
+ this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
2028
1541
  await new Promise<void>((resolve, reject) => {
2029
1542
  let resolved = false;
2030
1543
  const completion: SendMessageCompletion = {
@@ -2047,24 +1560,20 @@ export class ProviderCliAdapter implements CliAdapter {
2047
1560
  }
2048
1561
 
2049
1562
  if (submitDelayMs > 0) {
2050
- this.submitPendingUntil = Date.now() + submitDelayMs;
1563
+ this.engine.submitPendingUntil = Date.now() + submitDelayMs;
2051
1564
  }
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
1565
  const submitStartedAt = Date.now();
2059
1566
  void this.writeToPty(text).then(
2060
1567
  () => this.waitForEchoAndSubmit(sendState, completion, submitStartedAt),
2061
1568
  completion.rejectOnce,
2062
1569
  );
2063
1570
  });
1571
+ // Schedule settle after successful send
1572
+ this.engine.scheduleSettle();
2064
1573
  }
2065
1574
 
2066
1575
  getPartialResponse(): string {
2067
- if (!this.isWaitingForResponse) return '';
1576
+ if (!this.engine.isWaitingForResponse) return '';
2068
1577
  return this.responseBuffer;
2069
1578
  }
2070
1579
 
@@ -2077,10 +1586,10 @@ export class ProviderCliAdapter implements CliAdapter {
2077
1586
  cliType: this.cliType,
2078
1587
  cliName: this.cliName,
2079
1588
  workingDir: this.workingDir,
2080
- currentStatus: this.currentStatus,
1589
+ currentStatus: this.engine.currentStatus,
2081
1590
  ready: this.ready,
2082
- isWaitingForResponse: this.isWaitingForResponse,
2083
- activeModal: this.activeModal,
1591
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1592
+ activeModal: this.engine.activeModal,
2084
1593
  parseErrorMessage: this.parseErrorMessage,
2085
1594
  messageCounts: {
2086
1595
  parsedCache: Array.isArray(parsedResult?.messages) ? parsedResult.messages.length : undefined,
@@ -2106,10 +1615,10 @@ export class ProviderCliAdapter implements CliAdapter {
2106
1615
  lastScreenSnapshotReadAt: this.lastScreenSnapshotReadAt,
2107
1616
  },
2108
1617
  parser: {
2109
- scriptNames: listCliScriptNames(this.cliScripts),
2110
- traceSessionId: this.traceSessionId,
2111
- traceSeq: this.traceSeq,
2112
- currentTurnScope: this.currentTurnScope,
1618
+ scriptNames: this.runner.getScriptNames(),
1619
+ traceSessionId: this.engine.getTraceSessionId(),
1620
+ traceSeq: this.engine.getTraceEntries().length,
1621
+ currentTurnScope: this.engine.currentTurnScope,
2113
1622
  parsedStatusCache: parsedResult
2114
1623
  ? {
2115
1624
  id: parsedResult.id,
@@ -2122,26 +1631,25 @@ export class ProviderCliAdapter implements CliAdapter {
2122
1631
  activeModal: parsedResult.activeModal,
2123
1632
  }
2124
1633
  : null,
2125
- pendingScriptStatus: this.pendingScriptStatus,
2126
- pendingScriptStatusSince: this.pendingScriptStatusSince,
1634
+ pendingScriptStatus: this.engine.pendingScriptStatus,
1635
+ pendingScriptStatusSince: this.engine.pendingScriptStatusSince,
2127
1636
  },
2128
1637
  runtimeMetadata: this.getRuntimeMetadata(),
2129
- statusHistory: this.statusHistory.slice(-80),
2130
- traceEntries: this.traceEntries.slice(-120),
1638
+ statusHistory: this.engine.getStatusHistory().slice(-80),
1639
+ traceEntries: this.engine.getTraceEntries().slice(-120),
2131
1640
  timing: {
2132
1641
  spawnAt: this.spawnAt,
2133
1642
  startupFirstOutputAt: this.startupFirstOutputAt,
2134
- submitPendingUntil: this.submitPendingUntil,
2135
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
2136
- responseEpoch: this.responseEpoch,
1643
+ submitPendingUntil: this.engine.submitPendingUntil,
1644
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
1645
+ responseEpoch: this.engine.responseEpoch,
2137
1646
  resizeSuppressUntil: this.resizeSuppressUntil,
2138
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
1647
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2139
1648
  },
2140
1649
  finish: {
2141
- idleFinishCandidate: this.idleFinishCandidate,
2142
- finishRetryCount: this.finishRetryCount,
2143
- submitRetryUsed: this.submitRetryUsed,
2144
- submitRetryPromptSnippet: this.submitRetryPromptSnippet,
1650
+ finishRetryCount: this.engine.finishRetryCount,
1651
+ submitRetryUsed: this.engine.submitRetryUsed,
1652
+ submitRetryPromptSnippet: this.engine.submitRetryPromptSnippet,
2145
1653
  },
2146
1654
  };
2147
1655
  }
@@ -2151,7 +1659,48 @@ export class ProviderCliAdapter implements CliAdapter {
2151
1659
  return this.ptyProcess.getMetadata();
2152
1660
  }
2153
1661
 
1662
+ /**
1663
+ * Launch metadata for the dashboard Session info panel. Re-derives the spawn plan
1664
+ * (pure — same inputs the live PTY was spawned with) so the dashboard sees the
1665
+ * resolved binary, full arg vector, and cwd without us having to persist it.
1666
+ * extraEnv values are intentionally dropped (keys only) so secrets passed at
1667
+ * launch time never reach the dashboard.
1668
+ */
1669
+ getLaunchInfo(): CliLaunchInfo {
1670
+ let command: string | undefined;
1671
+ let args: string[] = [...this.extraArgs];
1672
+ try {
1673
+ const plan = resolveCliSpawnPlan({
1674
+ provider: this.provider,
1675
+ runtimeSettings: this.runtimeSettings,
1676
+ workingDir: this.workingDir,
1677
+ extraArgs: this.extraArgs,
1678
+ extraEnv: this.extraEnv,
1679
+ });
1680
+ command = plan.binaryPath;
1681
+ args = plan.allArgs;
1682
+ } catch {
1683
+ // Spawn plan resolution can throw before the binary is resolvable
1684
+ // (e.g. CLI not installed). Fall back to the raw extra args so the
1685
+ // panel still shows what it can rather than failing the whole call.
1686
+ }
1687
+ return {
1688
+ command,
1689
+ args,
1690
+ extraArgs: [...this.extraArgs],
1691
+ cwd: this.workingDir,
1692
+ extraEnvKeys: Object.keys(this.extraEnv || {}),
1693
+ providerSessionId: this.providerSessionId || undefined,
1694
+ };
1695
+ }
1696
+
2154
1697
  updateRuntimeMeta(meta: Record<string, unknown>, replace = false): void {
1698
+ const nextProviderSessionId = typeof meta?.providerSessionId === 'string'
1699
+ ? meta.providerSessionId.trim()
1700
+ : '';
1701
+ if (nextProviderSessionId) {
1702
+ this.providerSessionId = nextProviderSessionId;
1703
+ }
2155
1704
  if (!this.ptyProcess || typeof this.ptyProcess.updateMeta !== 'function') return;
2156
1705
  this.ptyProcess.updateMeta(meta, replace);
2157
1706
  }
@@ -2172,7 +1721,7 @@ export class ProviderCliAdapter implements CliAdapter {
2172
1721
  this.timeouts.shutdownGrace,
2173
1722
  typeof resume.shutdownGraceMs === 'number' ? resume.shutdownGraceMs : 3000,
2174
1723
  );
2175
- const wasProcessing = this.currentStatus === 'generating' || this.currentStatus === 'waiting_approval';
1724
+ const wasProcessing = this.engine.currentStatus === 'generating' || this.engine.currentStatus === 'waiting_approval';
2176
1725
 
2177
1726
  try {
2178
1727
  if (wasProcessing) {
@@ -2210,7 +1759,7 @@ export class ProviderCliAdapter implements CliAdapter {
2210
1759
  return new Promise((resolve) => {
2211
1760
  const startedAt = Date.now();
2212
1761
  const timer = setInterval(() => {
2213
- if (!this.ptyProcess || this.currentStatus === 'stopped') {
1762
+ if (!this.ptyProcess || this.engine.currentStatus === 'stopped') {
2214
1763
  clearInterval(timer);
2215
1764
  resolve(true);
2216
1765
  return;
@@ -2224,18 +1773,20 @@ export class ProviderCliAdapter implements CliAdapter {
2224
1773
  }
2225
1774
 
2226
1775
  shutdown(): void {
2227
- this.clearIdleFinishCandidate('shutdown');
1776
+ this.engine.clearIdleFinishCandidate('shutdown');
2228
1777
  this.clearAllTimers();
2229
1778
  this.pendingOutputParseChunks = [];
2230
1779
  this.pendingTerminalQueryTail = '';
2231
1780
  this.ptyOutputChunks = [];
2232
- this.finishRetryCount = 0;
1781
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1782
+ this.pendingOutboundQueue = [];
1783
+ this.pendingOutboundFlushInFlight = false;
2233
1784
  if (this.ptyProcess) {
2234
1785
  this.ptyProcess.write('\x03');
2235
1786
  setTimeout(() => {
2236
1787
  try { this.ptyProcess?.kill(); } catch { }
2237
1788
  this.ptyProcess = null;
2238
- this.setStatus('stopped', 'stop_cmd');
1789
+ this.engine.setStatus('stopped', 'stop_cmd');
2239
1790
  this.ready = false;
2240
1791
  this.startupParseGate = false;
2241
1792
  this.spawnAt = 0;
@@ -2245,12 +1796,14 @@ export class ProviderCliAdapter implements CliAdapter {
2245
1796
  }
2246
1797
 
2247
1798
  detach(): void {
2248
- this.clearIdleFinishCandidate('detach');
1799
+ this.engine.clearIdleFinishCandidate('detach');
2249
1800
  this.clearAllTimers();
2250
1801
  this.pendingOutputParseChunks = [];
2251
1802
  this.pendingTerminalQueryTail = '';
2252
1803
  this.ptyOutputChunks = [];
2253
- this.finishRetryCount = 0;
1804
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1805
+ this.pendingOutboundQueue = [];
1806
+ this.pendingOutboundFlushInFlight = false;
2254
1807
  if (this.ptyProcess) {
2255
1808
  try {
2256
1809
  if (typeof this.ptyProcess.detach === 'function') {
@@ -2268,82 +1821,128 @@ export class ProviderCliAdapter implements CliAdapter {
2268
1821
  }
2269
1822
 
2270
1823
  clearHistory(): void {
2271
- this.clearIdleFinishCandidate('clear_history');
1824
+ this.engine.clearIdleFinishCandidate('clear_history');
2272
1825
  this.accumulatedBuffer = '';
2273
1826
  this.accumulatedRawBuffer = '';
2274
- this.currentTurnScope = null;
2275
- this.submitRetryUsed = false;
2276
- this.submitRetryPromptSnippet = '';
1827
+ this.engine.currentTurnScope = null;
1828
+ this.engine.submitRetryUsed = false;
1829
+ this.engine.submitRetryPromptSnippet = '';
2277
1830
  if (this.pendingOutputParseTimer) { clearTimeout(this.pendingOutputParseTimer); this.pendingOutputParseTimer = null; }
2278
1831
  this.pendingOutputParseChunks = [];
2279
1832
  this.pendingTerminalQueryTail = '';
2280
1833
  if (this.ptyOutputFlushTimer) { clearTimeout(this.ptyOutputFlushTimer); this.ptyOutputFlushTimer = null; }
2281
1834
  this.ptyOutputChunks = [];
2282
- if (this.finishRetryTimer) { clearTimeout(this.finishRetryTimer); this.finishRetryTimer = null; }
2283
- this.finishRetryCount = 0;
1835
+ if (this.pendingOutboundFlushTimer) { clearTimeout(this.pendingOutboundFlushTimer); this.pendingOutboundFlushTimer = null; }
1836
+ this.pendingOutboundQueue = [];
1837
+ this.pendingOutboundFlushInFlight = false;
2284
1838
  this.resetTerminalScreen();
2285
1839
  this.ptyProcess?.clearBuffer?.();
2286
1840
  this.onStatusChange?.();
2287
1841
  }
2288
1842
 
2289
- isProcessing(): boolean { return this.isWaitingForResponse; }
1843
+ isProcessing(): boolean { return this.engine.isWaitingForResponse; }
2290
1844
  isReady(): boolean { return this.ready; }
2291
1845
 
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);
1846
+ // ─── State machine property accessors (delegate to engine) ──────────────
1847
+ // These expose engine state for external callers (tests, debug tools, etc.)
1848
+
1849
+ get currentStatus(): CliSessionStatus['status'] { return this.engine.currentStatus; }
1850
+ set currentStatus(v: CliSessionStatus['status']) { this.engine.setStatus(v); }
1851
+
1852
+ get isWaitingForResponse(): boolean { return this.engine.isWaitingForResponse; }
1853
+ set isWaitingForResponse(v: boolean) { this.engine.isWaitingForResponse = v; }
1854
+
1855
+ get activeModal(): { message: string; buttons: string[] } | null { return this.engine.activeModal; }
1856
+ set activeModal(v: { message: string; buttons: string[] } | null) { this.engine.activeModal = v; }
1857
+
1858
+ get currentTurnScope(): TurnParseScope | null { return this.engine.currentTurnScope; }
1859
+ set currentTurnScope(v: TurnParseScope | null) { this.engine.currentTurnScope = v; }
1860
+
1861
+ get responseEpoch(): number { return this.engine.responseEpoch; }
1862
+ set responseEpoch(v: number) { this.engine.responseEpoch = v; }
1863
+
1864
+ get submitRetryUsed(): boolean { return this.engine.submitRetryUsed; }
1865
+ set submitRetryUsed(v: boolean) { this.engine.submitRetryUsed = v; }
1866
+
1867
+ get submitRetryPromptSnippet(): string { return this.engine.submitRetryPromptSnippet; }
1868
+ set submitRetryPromptSnippet(v: string) { this.engine.submitRetryPromptSnippet = v; }
1869
+
1870
+ get responseSettleIgnoreUntil(): number { return this.engine.responseSettleIgnoreUntil; }
1871
+ set responseSettleIgnoreUntil(v: number) { this.engine.responseSettleIgnoreUntil = v; }
1872
+
1873
+ get submitPendingUntil(): number { return this.engine.submitPendingUntil; }
1874
+ set submitPendingUntil(v: number) { this.engine.submitPendingUntil = v; }
1875
+
1876
+ get lastApprovalResolvedAt(): number { return this.engine.lastApprovalResolvedAt; }
1877
+ set lastApprovalResolvedAt(v: number) { this.engine.lastApprovalResolvedAt = v; }
1878
+
1879
+ get providerErrorMessage(): string | null { return this.engine.providerErrorMessage; }
1880
+ get providerErrorReason(): string | null { return this.engine.providerErrorReason; }
1881
+
1882
+ get pendingScriptStatus(): 'generating' | 'waiting_approval' | null { return this.engine.pendingScriptStatus; }
1883
+ get pendingScriptStatusSince(): number { return this.engine.pendingScriptStatusSince; }
1884
+
1885
+ get finishRetryCount(): number { return this.engine.finishRetryCount; }
1886
+ set finishRetryCount(v: number) { this.engine.finishRetryCount = v; }
1887
+
1888
+ get traceSessionId(): string { return this.engine.getTraceSessionId(); }
1889
+ get traceEntries(): CliTraceEntry[] { return this.engine.getTraceEntries(); }
1890
+ get statusHistory(): { status: string; at: number; trigger?: string }[] { return this.engine.getStatusHistory(); }
1891
+ get traceSeq(): number { return this.engine.getTraceEntries().length; }
1892
+
1893
+ /** Expose engine's evaluateSettled for test access */
1894
+ evaluateSettled(): void {
1895
+ LOG.debug(
1896
+ 'CLI',
1897
+ `[${this.cliType}] settled diagnostics delegated to state engine`);
1898
+ this.engine.evaluateSettled(this.getSnapshot());
1899
+ }
1900
+ /** Expose engine's scheduleSettle for test access */
1901
+ scheduleSettle(): void { this.engine.scheduleSettle(); }
1902
+ /** Expose engine's clearIdleFinishCandidate for test access */
1903
+ clearIdleFinishCandidate(reason: string): void { this.engine.clearIdleFinishCandidate(reason); }
1904
+ /** Expose engine's finishResponse for test access */
1905
+ finishResponse(): void { this.engine.finishResponse(); }
1906
+ /** Returns a point-in-time snapshot of all buffer/screen state for external consumers (e.g. CliStateEngine). */
1907
+ getSnapshot(): CliBufferSnapshot {
1908
+ const screenText = this.terminalScreen.getText() || '';
1909
+ return {
1910
+ accumulatedBuffer: this.accumulatedBuffer,
1911
+ accumulatedRawBuffer: this.accumulatedRawBuffer,
1912
+ recentOutputBuffer: this.recentOutputBuffer,
1913
+ responseBuffer: this.responseBuffer,
1914
+ screenText,
1915
+ parseScreenText: this.getParseScreenText(screenText),
1916
+ workingDir: this.workingDir,
1917
+ providerSessionId: this.providerSessionId,
1918
+ runtimeSettings: this.runtimeSettings,
1919
+ isWaitingForResponse: this.engine.isWaitingForResponse,
1920
+ currentTurnScope: this.engine.currentTurnScope,
1921
+ lastOutputAt: this.lastOutputAt,
1922
+ lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
1923
+ lastScreenChangeAt: this.lastScreenChangeAt,
1924
+ lastScreenSnapshot: this.lastScreenSnapshot,
1925
+ };
1926
+ }
1927
+ isAlive(): boolean { return this.ptyProcess !== null; }
1928
+ flushOutboundQueue(): void { this.schedulePendingOutboundFlush(); }
1929
+
1930
+ async writeRaw(data: string | Buffer): Promise<void> {
1931
+ const str = Buffer.isBuffer(data) ? data.toString('utf8') : data;
1932
+ await this.writeToPty(str);
2298
1933
  }
2299
1934
 
2300
1935
  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
- }
1936
+ this.engine.resolveModal(buttonIndex);
1937
+ }
1938
+
1939
+ getApprovalKeyForIndex(buttonIndex: number): string | undefined {
1940
+ return buttonIndex in this.approvalKeys ? this.approvalKeys[buttonIndex] : undefined;
1941
+ }
1942
+
1943
+ /** Returns true if an approval was resolved within the adapter's cooldown window. */
1944
+ isApprovalRecentlyResolved(): boolean {
1945
+ return this.engine.isApprovalRecentlyResolved();
2347
1946
  }
2348
1947
 
2349
1948
  resize(cols: number, rows: number): void {
@@ -2357,7 +1956,7 @@ export class ProviderCliAdapter implements CliAdapter {
2357
1956
  }
2358
1957
 
2359
1958
  private getParsedDebugState(): Record<string, any> | null {
2360
- if (this.startupParseGate || typeof this.cliScripts?.parseSession !== 'function') return null;
1959
+ if (this.startupParseGate || !this.runner.hasParseSession()) return null;
2361
1960
  try {
2362
1961
  const parsed = this.getScriptParsedStatus();
2363
1962
  return parsed && typeof parsed === 'object' ? parsed as Record<string, any> : null;
@@ -2369,18 +1968,43 @@ export class ProviderCliAdapter implements CliAdapter {
2369
1968
  getDebugState(): Record<string, any> {
2370
1969
  const screenText = sanitizeTerminalText(this.terminalScreen.getText());
2371
1970
  const startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2372
- const effectiveStatus = this.projectEffectiveStatus(startupModal);
2373
- const effectiveReady = this.ready || !!startupModal;
1971
+ const startupDetectedStatus = this.startupParseGate && !startupModal
1972
+ ? this.runDetectStatus(this.recentOutputBuffer || screenText)
1973
+ : null;
1974
+ const effectiveReady = this.ready || !!startupModal || startupDetectedStatus === 'waiting_approval';
2374
1975
  const parsedDebugState = this.getParsedDebugState();
2375
1976
  const parsedMessages = Array.isArray(parsedDebugState?.messages) ? parsedDebugState.messages : [];
1977
+ const hasFinalAssistant = (p: any) => {
1978
+ const msgs = Array.isArray(p?.messages) ? p.messages : [];
1979
+ return msgs.some((m: any) => m?.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
1980
+ };
1981
+ let effectiveStatus = this.projectEffectiveStatus(startupModal);
1982
+ if (parsedDebugState?.status === 'error') {
1983
+ effectiveStatus = 'error';
1984
+ }
1985
+ // (fix) Mirror the getStatus contract: never surface waiting_approval
1986
+ // without a concrete modal. detectStatus alone can fire while
1987
+ // parseApproval is still null, and a bare debugState waiting_approval
1988
+ // confused dashboards reading the debug payload.
1989
+ const debugEffectiveModal = startupModal || this.engine.activeModal;
1990
+ if (startupDetectedStatus === 'waiting_approval' && debugEffectiveModal) {
1991
+ effectiveStatus = 'waiting_approval';
1992
+ }
1993
+ if (
1994
+ effectiveStatus === 'idle'
1995
+ && parsedDebugState?.status === 'generating'
1996
+ && !hasFinalAssistant(parsedDebugState)
1997
+ ) {
1998
+ effectiveStatus = 'generating';
1999
+ }
2376
2000
  return {
2377
2001
  type: this.cliType,
2378
2002
  name: this.cliName,
2379
2003
  providerResolution: this.providerResolutionMeta,
2380
2004
  status: effectiveStatus,
2381
2005
  projectedStatus: effectiveStatus,
2382
- rawStatus: this.currentStatus,
2383
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
2006
+ rawStatus: this.engine.currentStatus,
2007
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
2384
2008
  ready: effectiveReady,
2385
2009
  startupParseGate: this.startupParseGate,
2386
2010
  spawnAt: this.spawnAt,
@@ -2394,38 +2018,47 @@ export class ProviderCliAdapter implements CliAdapter {
2394
2018
  providerSessionId: parsedDebugState.providerSessionId,
2395
2019
  transcriptAuthority: parsedDebugState.transcriptAuthority,
2396
2020
  coverage: parsedDebugState.coverage,
2021
+ errorMessage: parsedDebugState.errorMessage,
2022
+ errorReason: parsedDebugState.errorReason,
2397
2023
  activeModal: parsedDebugState.activeModal,
2398
2024
  messageCount: parsedMessages.length,
2399
2025
  } : null,
2400
2026
  screenText: screenText.slice(-4000),
2401
- currentTurnScope: this.currentTurnScope,
2027
+ currentTurnScope: this.engine.currentTurnScope,
2402
2028
  startupBuffer: this.startupBuffer.slice(-4000),
2403
2029
  recentOutputBuffer: this.recentOutputBuffer.slice(-500),
2404
- settledBuffer: this.settledBuffer.slice(-500),
2405
2030
  accumulatedBufferLength: this.accumulatedBuffer.length,
2406
2031
  accumulatedRawBufferLength: this.accumulatedRawBuffer.length,
2407
2032
  rawBufferPreview: this.accumulatedRawBuffer.slice(-1000),
2408
2033
  sanitizedRawPreview: sanitizeTerminalText(this.accumulatedRawBuffer).slice(-1000),
2409
2034
  responseBuffer: this.responseBuffer.slice(-1000),
2035
+ pendingOutboundQueue: this.pendingOutboundQueue.map((message) => ({
2036
+ id: message.id,
2037
+ role: message.role,
2038
+ content: message.content,
2039
+ queuedAt: message.queuedAt,
2040
+ source: message.source,
2041
+ })),
2042
+ pendingOutboundCount: this.pendingOutboundQueue.length,
2410
2043
  lastOutputAt: this.lastOutputAt,
2411
2044
  lastNonEmptyOutputAt: this.lastNonEmptyOutputAt,
2412
2045
  lastScreenChangeAt: this.lastScreenChangeAt,
2413
2046
  lastScreenSnapshot: this.lastScreenSnapshot.slice(-500),
2414
- isWaitingForResponse: this.isWaitingForResponse,
2415
- activeModal: startupModal || this.activeModal,
2416
- lastApprovalResolvedAt: this.lastApprovalResolvedAt,
2047
+ isWaitingForResponse: this.engine.isWaitingForResponse,
2048
+ activeModal: startupModal || this.engine.activeModal,
2049
+ lastApprovalResolvedAt: this.engine.lastApprovalResolvedAt,
2417
2050
  sendDelayMs: this.sendDelayMs,
2418
2051
  sendKey: this.sendKey,
2419
2052
  submitStrategy: this.submitStrategy,
2420
2053
  requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2421
- submitPendingUntil: this.submitPendingUntil,
2422
- responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
2054
+ submitPendingUntil: this.engine.submitPendingUntil,
2055
+ responseSettleIgnoreUntil: this.engine.responseSettleIgnoreUntil,
2423
2056
  resizeSuppressUntil: this.resizeSuppressUntil,
2424
2057
  hasCliScripts: this.hasCliScripts(),
2425
- scriptNames: listCliScriptNames(this.cliScripts),
2426
- traceSessionId: this.traceSessionId,
2427
- traceEntryCount: this.traceEntries.length,
2428
- statusHistory: this.statusHistory.slice(-30),
2058
+ scriptNames: this.runner.getScriptNames(),
2059
+ traceSessionId: this.engine.getTraceSessionId(),
2060
+ traceEntryCount: this.engine.getTraceEntries().length,
2061
+ statusHistory: this.engine.getStatusHistory().slice(-30),
2429
2062
  timeouts: this.timeouts,
2430
2063
  pendingOutputParseBufferLength: this.pendingOutputParseChunks.reduce((total, chunk) => total + chunk.length, 0),
2431
2064
  pendingOutputParseScheduled: !!this.pendingOutputParseTimer,
@@ -2435,20 +2068,21 @@ export class ProviderCliAdapter implements CliAdapter {
2435
2068
 
2436
2069
  getTraceState(limit = 120): Record<string, any> {
2437
2070
  const cappedLimit = Math.max(1, Math.min(500, Number.isFinite(limit) ? Math.floor(limit) : 120));
2071
+ const traceEntries = this.engine.getTraceEntries();
2438
2072
  return {
2439
- sessionId: this.traceSessionId,
2073
+ sessionId: this.engine.getTraceSessionId(),
2440
2074
  providerResolution: this.providerResolutionMeta,
2441
- entryCount: this.traceEntries.length,
2442
- entries: this.traceEntries.slice(-cappedLimit),
2075
+ entryCount: traceEntries.length,
2076
+ entries: traceEntries.slice(-cappedLimit),
2443
2077
  screenText: summarizeCliTraceText(this.terminalScreen.getText(), 4000),
2444
2078
  recentOutputBuffer: summarizeCliTraceText(this.recentOutputBuffer, 1000),
2445
2079
  responseBuffer: summarizeCliTraceText(this.responseBuffer, 1200),
2446
2080
  status: this.projectEffectiveStatus(),
2447
2081
  projectedStatus: this.projectEffectiveStatus(),
2448
- rawStatus: this.currentStatus,
2449
- lifecycleStatus: this.isWaitingForResponse ? 'awaiting_response' : 'idle',
2450
- activeModal: this.activeModal,
2451
- currentTurnScope: this.currentTurnScope,
2082
+ rawStatus: this.engine.currentStatus,
2083
+ lifecycleStatus: this.engine.isWaitingForResponse ? 'awaiting_response' : 'idle',
2084
+ activeModal: this.engine.activeModal,
2085
+ currentTurnScope: this.engine.currentTurnScope,
2452
2086
  messages: [],
2453
2087
  };
2454
2088
  }