@adhdev/daemon-core 0.9.82-rc.34 → 0.9.82-rc.341

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 (318) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +8 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/build-info.d.ts +37 -0
  4. package/dist/chat/source-machine.d.ts +166 -0
  5. package/dist/chat/source-resolver.d.ts +104 -0
  6. package/dist/chat/subscription-updates.d.ts +1 -0
  7. package/dist/cli-adapter-types.d.ts +46 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +198 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +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 +53 -0
  14. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  15. package/dist/cli-adapters/resolve-executable.d.ts +14 -0
  16. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  17. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  18. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  19. package/dist/commands/cli-manager.d.ts +2 -1
  20. package/dist/commands/handler.d.ts +110 -0
  21. package/dist/commands/mesh-coordinator.d.ts +85 -1
  22. package/dist/commands/router.d.ts +308 -1
  23. package/dist/commands/upgrade-helper.d.ts +12 -0
  24. package/dist/config/chat-history.d.ts +9 -0
  25. package/dist/config/config.d.ts +5 -0
  26. package/dist/config/mesh-config.d.ts +77 -1
  27. package/dist/detection/ide-detector.d.ts +13 -0
  28. package/dist/detection/win32-ide-version.d.ts +37 -0
  29. package/dist/git/change-impact-config.d.ts +159 -0
  30. package/dist/git/git-commands.d.ts +11 -1
  31. package/dist/git/git-diff.d.ts +6 -0
  32. package/dist/git/git-executor.d.ts +11 -0
  33. package/dist/git/git-status.d.ts +23 -0
  34. package/dist/git/git-types.d.ts +2 -50
  35. package/dist/git/index.d.ts +2 -0
  36. package/dist/index.d.ts +52 -13
  37. package/dist/index.js +42012 -17098
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +41683 -16890
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/installer.d.ts +1 -4
  42. package/dist/ipc/local-ipc-server.d.ts +91 -0
  43. package/dist/launch.d.ts +1 -1
  44. package/dist/logging/async-batch-writer.d.ts +10 -0
  45. package/dist/logging/log-redactor.d.ts +24 -0
  46. package/dist/logging/log-tail-reader.d.ts +46 -0
  47. package/dist/mesh/contracts.d.ts +164 -0
  48. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  49. package/dist/mesh/coordinator-registry.d.ts +59 -0
  50. package/dist/mesh/mesh-active-work.d.ts +174 -0
  51. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  52. package/dist/mesh/mesh-events-coordinator.d.ts +169 -0
  53. package/dist/mesh/mesh-events-pending.d.ts +45 -0
  54. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  55. package/dist/mesh/mesh-events-utils.d.ts +66 -0
  56. package/dist/mesh/mesh-events.d.ts +6 -49
  57. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  58. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  59. package/dist/mesh/mesh-init.d.ts +86 -0
  60. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  61. package/dist/mesh/mesh-ledger.d.ts +77 -1
  62. package/dist/mesh/mesh-missions.d.ts +116 -0
  63. package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
  64. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  65. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  66. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  67. package/dist/mesh/mesh-routing.d.ts +70 -0
  68. package/dist/mesh/mesh-runtime-store.d.ts +418 -0
  69. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  70. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  71. package/dist/mesh/mesh-work-queue.d.ts +208 -5
  72. package/dist/mesh/preview-freshness.d.ts +18 -0
  73. package/dist/mesh/refine-config.d.ts +216 -0
  74. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  75. package/dist/providers/acp-provider-instance.d.ts +2 -0
  76. package/dist/providers/approval-utils.d.ts +13 -0
  77. package/dist/providers/chat-message-normalization.d.ts +12 -0
  78. package/dist/providers/cli-provider-instance.d.ts +76 -3
  79. package/dist/providers/contracts.d.ts +139 -6
  80. package/dist/providers/external-sources.d.ts +71 -0
  81. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  82. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  83. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  84. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  85. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  86. package/dist/providers/native-history/index.d.ts +13 -0
  87. package/dist/providers/provider-instance-manager.d.ts +13 -0
  88. package/dist/providers/provider-instance.d.ts +13 -1
  89. package/dist/providers/provider-loader.d.ts +26 -4
  90. package/dist/providers/provider-trust.d.ts +31 -0
  91. package/dist/providers/read-chat-contract.d.ts +29 -0
  92. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  93. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  94. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  95. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  96. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  97. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  98. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  99. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  100. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  101. package/dist/providers/sdk/v1/index.d.ts +30 -0
  102. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  103. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  104. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  105. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  106. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  107. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  108. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  109. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  110. package/dist/providers/spec/adapter.d.ts +69 -0
  111. package/dist/providers/spec/cli-adapter.d.ts +217 -0
  112. package/dist/providers/spec/evaluator.d.ts +23 -0
  113. package/dist/providers/spec/fsm-driver.d.ts +306 -0
  114. package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
  115. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  116. package/dist/providers/spec/fsm-types.d.ts +144 -0
  117. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  118. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  119. package/dist/providers/spec/route.d.ts +4 -0
  120. package/dist/providers/spec/types.d.ts +180 -0
  121. package/dist/providers/status-monitor.d.ts +7 -7
  122. package/dist/providers/transcript-v2.d.ts +176 -0
  123. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  124. package/dist/providers/working-dir.d.ts +17 -0
  125. package/dist/repo-mesh-types.d.ts +295 -13
  126. package/dist/sessions/registry.d.ts +6 -0
  127. package/dist/shared-types-extra.d.ts +2 -4
  128. package/dist/shared-types.d.ts +59 -2
  129. package/dist/status/normalize.d.ts +1 -1
  130. package/dist/status/normalize.js +1 -0
  131. package/dist/status/normalize.js.map +1 -1
  132. package/dist/status/normalize.mjs +1 -0
  133. package/dist/status/normalize.mjs.map +1 -1
  134. package/dist/status/reporter.d.ts +2 -0
  135. package/dist/status/snapshot.d.ts +26 -0
  136. package/dist/system/load-better-sqlite3.d.ts +21 -0
  137. package/dist/types.d.ts +5 -0
  138. package/package.json +7 -3
  139. package/src/agent-stream/poller.ts +2 -3
  140. package/src/agent-stream/provider-adapter.ts +1 -1
  141. package/src/boot/daemon-lifecycle.ts +55 -12
  142. package/src/boot/process-hardening.ts +89 -0
  143. package/src/build-info.ts +73 -0
  144. package/src/chat/source-machine.ts +534 -0
  145. package/src/chat/source-resolver.ts +0 -0
  146. package/src/chat/subscription-updates.ts +20 -1
  147. package/src/cli-adapter-types.d.ts +1 -0
  148. package/src/cli-adapter-types.ts +47 -2
  149. package/src/cli-adapters/cli-script-runner.ts +421 -0
  150. package/src/cli-adapters/cli-state-engine.ts +1215 -0
  151. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  152. package/src/cli-adapters/provider-cli-adapter.ts +774 -1141
  153. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  154. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  155. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  156. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  157. package/src/cli-adapters/provider-cli-shared.ts +100 -11
  158. package/src/cli-adapters/pty-transport.ts +2 -1
  159. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  160. package/src/cli-adapters/resolve-executable.ts +90 -0
  161. package/src/cli-adapters/session-host-transport.ts +2 -1
  162. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  163. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  164. package/src/cli-adapters/terminal-screen.ts +16 -81
  165. package/src/commands/chat-commands.ts +1823 -62
  166. package/src/commands/cli-manager.ts +286 -14
  167. package/src/commands/handler.ts +809 -2
  168. package/src/commands/mesh-coordinator.ts +332 -122
  169. package/src/commands/router.ts +7433 -1317
  170. package/src/commands/stream-commands.ts +8 -0
  171. package/src/commands/upgrade-helper.ts +81 -30
  172. package/src/config/chat-history.ts +483 -24
  173. package/src/config/config.ts +12 -0
  174. package/src/config/mesh-config.ts +388 -6
  175. package/src/config/recent-activity.ts +8 -2
  176. package/src/daemon/dev-auto-implement.ts +3 -2
  177. package/src/daemon/dev-cli-debug.ts +10 -1
  178. package/src/detection/cli-detector.ts +28 -9
  179. package/src/detection/ide-detector.ts +55 -16
  180. package/src/detection/win32-ide-version.ts +106 -0
  181. package/src/git/change-impact-config.ts +354 -0
  182. package/src/git/git-commands.ts +55 -14
  183. package/src/git/git-diff.ts +53 -0
  184. package/src/git/git-executor.ts +12 -0
  185. package/src/git/git-status.ts +574 -43
  186. package/src/git/git-types.ts +14 -62
  187. package/src/git/git-worktree.ts +8 -1
  188. package/src/git/index.ts +16 -0
  189. package/src/index.ts +146 -12
  190. package/src/installer.d.ts +1 -1
  191. package/src/installer.ts +8 -6
  192. package/src/ipc/local-ipc-server.ts +278 -0
  193. package/src/launch.d.ts +1 -1
  194. package/src/launch.ts +37 -28
  195. package/src/logging/async-batch-writer.ts +55 -0
  196. package/src/logging/log-redactor.ts +100 -0
  197. package/src/logging/log-tail-reader.ts +220 -0
  198. package/src/logging/logger.ts +2 -1
  199. package/src/mesh/contracts.ts +329 -0
  200. package/src/mesh/coordinator-prompt.ts +234 -31
  201. package/src/mesh/coordinator-registry.ts +121 -0
  202. package/src/mesh/mesh-active-work.ts +623 -0
  203. package/src/mesh/mesh-delivery-policy.ts +315 -0
  204. package/src/mesh/mesh-events-coordinator.ts +2144 -0
  205. package/src/mesh/mesh-events-pending.ts +508 -0
  206. package/src/mesh/mesh-events-stale.ts +290 -0
  207. package/src/mesh/mesh-events-utils.ts +300 -0
  208. package/src/mesh/mesh-events.ts +30 -1035
  209. package/src/mesh/mesh-fast-forward.ts +839 -0
  210. package/src/mesh/mesh-host-ownership.ts +73 -0
  211. package/src/mesh/mesh-init.ts +260 -0
  212. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  213. package/src/mesh/mesh-ledger.ts +589 -102
  214. package/src/mesh/mesh-missions.ts +248 -0
  215. package/src/mesh/mesh-reconcile-loop.ts +822 -0
  216. package/src/mesh/mesh-refine-batch.ts +197 -0
  217. package/src/mesh/mesh-refine-status.ts +231 -0
  218. package/src/mesh/mesh-review-inbox.ts +307 -0
  219. package/src/mesh/mesh-routing.ts +291 -0
  220. package/src/mesh/mesh-runtime-store.ts +1626 -0
  221. package/src/mesh/mesh-task-stats.ts +154 -0
  222. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  223. package/src/mesh/mesh-work-queue.ts +888 -141
  224. package/src/mesh/preview-freshness.ts +118 -0
  225. package/src/mesh/refine-config.ts +423 -0
  226. package/src/mesh/worktree-bootstrap-config.ts +331 -0
  227. package/src/providers/acp-provider-instance.ts +25 -9
  228. package/src/providers/approval-utils.d.ts +4 -0
  229. package/src/providers/approval-utils.ts +47 -5
  230. package/src/providers/chat-message-normalization.ts +45 -5
  231. package/src/providers/cli-provider-instance.ts +1038 -99
  232. package/src/providers/contracts.d.ts +55 -0
  233. package/src/providers/contracts.ts +150 -6
  234. package/src/providers/extension-provider-instance.ts +12 -7
  235. package/src/providers/external-sources.ts +218 -0
  236. package/src/providers/ide-provider-instance.ts +35 -12
  237. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  238. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  239. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  240. package/src/providers/native-history/dispatcher.ts +340 -0
  241. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  242. package/src/providers/native-history/index.ts +30 -0
  243. package/src/providers/provider-instance-manager.ts +30 -0
  244. package/src/providers/provider-instance.ts +10 -1
  245. package/src/providers/provider-loader.ts +670 -50
  246. package/src/providers/provider-schema.ts +87 -14
  247. package/src/providers/provider-trust.ts +114 -0
  248. package/src/providers/read-chat-contract.ts +76 -16
  249. package/src/providers/sdk/README.md +49 -0
  250. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  251. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  252. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  253. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  254. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  255. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  256. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  257. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  258. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  259. package/src/providers/sdk/v1/index.ts +152 -0
  260. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  261. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  262. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  263. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  264. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  265. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  266. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  267. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  268. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  269. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  270. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  271. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  272. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  273. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  274. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  275. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  276. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  277. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  278. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  279. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  280. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  281. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  282. package/src/providers/sdk/v1/validators/index.ts +19 -0
  283. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  284. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  285. package/src/providers/spec/adapter.ts +168 -0
  286. package/src/providers/spec/cli-adapter.ts +986 -0
  287. package/src/providers/spec/evaluator.ts +345 -0
  288. package/src/providers/spec/fsm-driver.ts +1015 -0
  289. package/src/providers/spec/fsm-evaluator.ts +255 -0
  290. package/src/providers/spec/fsm-loader.ts +120 -0
  291. package/src/providers/spec/fsm-types.ts +238 -0
  292. package/src/providers/spec/native-history-executor.ts +1136 -0
  293. package/src/providers/spec/pre-launch-trust.ts +104 -0
  294. package/src/providers/spec/route.ts +51 -0
  295. package/src/providers/spec/types.ts +226 -0
  296. package/src/providers/status-monitor.d.ts +7 -7
  297. package/src/providers/status-monitor.ts +37 -22
  298. package/src/providers/transcript-v2.ts +567 -0
  299. package/src/providers/types/interactive-prompt.ts +536 -0
  300. package/src/providers/version-archive.ts +64 -24
  301. package/src/providers/working-dir.ts +23 -0
  302. package/src/repo-mesh-types.ts +378 -14
  303. package/src/sessions/registry.ts +6 -0
  304. package/src/shared-types-extra.ts +2 -4
  305. package/src/shared-types.d.ts +8 -0
  306. package/src/shared-types.ts +64 -1
  307. package/src/status/builders.ts +26 -6
  308. package/src/status/normalize.ts +2 -0
  309. package/src/status/reporter.ts +19 -1
  310. package/src/status/snapshot.ts +95 -26
  311. package/src/system/host-memory.ts +29 -12
  312. package/src/system/load-better-sqlite3.ts +68 -0
  313. package/src/types.ts +5 -0
  314. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  315. package/dist/mesh/mesh-sync.d.ts +0 -53
  316. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  317. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  318. package/src/mesh/mesh-sync.ts +0 -111
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * FsmDriver — runs an adhdev:cli/spec@4 finite state machine against a PTY.
3
+ *
4
+ * Drop-in compatible with SpecDriver's public surface (subscribe / dispatch /
5
+ * start / snapshot / getStateHistory / getDebugState / …) so the cli-adapter
6
+ * can use either driver behind one interface. The difference is entirely
7
+ * internal: instead of a stack of hard-coded debounce layers, this driver
8
+ * holds ONE piece of state — the current FSM node — and on every screen change
9
+ * asks the FSM evaluator "which outgoing transition fires?". All timing
10
+ * (startup grace, busy hold, completion stability) is expressed in the spec as
11
+ * transition guards (min_hold_ms) and time conditions (elapsed_ms / stable_ms).
12
+ *
13
+ * Because the engine carries no CLI knowledge, the only way it can be "wrong"
14
+ * is if a spec's transitions are wrong — and that is fully inspectable via
15
+ * getFsmDebug(), which reports every outgoing transition with its per-condition
16
+ * match result and countdown. That is the contract: debug the spec, not the
17
+ * engine.
18
+ */
19
+ 'use strict';
20
+
21
+ import * as fs from 'node:fs';
22
+ import * as os from 'node:os';
23
+ import * as path from 'node:path';
24
+ import { TerminalAdapter, type TerminalAdapterOpts } from './adapter.js';
25
+ import { resolveCliSpawnPlanFromParts } from '../../cli-adapters/provider-cli-runtime.js';
26
+ import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
27
+ import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
28
+ import {
29
+ resolveSections, sectionText, extractTitle, extractButtonsFromRule,
30
+ type ResolvedSection, type TraceEntry,
31
+ } from './evaluator.js';
32
+ import { evaluateFsm, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
33
+ import {
34
+ type CliSpecV4, type FsmState, type FsmTransition,
35
+ initialState, stateById, statusForState, outgoingTransitions,
36
+ } from './fsm-types.js';
37
+ import { loadFsmSpec } from './fsm-loader.js';
38
+ import { applyPreLaunchTrust } from './pre-launch-trust.js';
39
+ import type { Control, DelegateTrigger } from './types.js';
40
+ import { LOG } from '../../logging/logger.js';
41
+
42
+ // ── Shared driver types (formerly in driver.ts) ───────────────────────────
43
+
44
+ export type DashboardEvent =
45
+ | { kind: 'pty_data'; chunk: string }
46
+ | { kind: 'state_changed'; state: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' };
47
+ modal: { title: string | null; buttons: { index: number; label: string }[] } | null;
48
+ controls: { id: string; label: string; action_type: string }[] }
49
+ | { kind: 'notification'; id: string; title: string; body: string }
50
+ | { kind: 'delegate'; id: string; task: string }
51
+ | { kind: 'spec_trace'; entries: TraceEntry[] }
52
+ | { kind: 'exit'; exit_code: number }
53
+ | { kind: 'spec_error'; errors: string[] };
54
+
55
+ export type DashboardCommand =
56
+ | { kind: 'send_message'; text: string }
57
+ | { kind: 'pty_write'; data: string }
58
+ | { kind: 'click_control'; control_id: string; payload?: unknown }
59
+ | { kind: 'click_modal_button'; index: number }
60
+ | { kind: 'attach_image'; blob: string; mime: string }
61
+ | { kind: 'resize'; cols: number; rows: number }
62
+ | { kind: 'cancel' }
63
+ | { kind: 'shutdown' };
64
+
65
+ export interface DriverHistoryEntry {
66
+ stateId: string;
67
+ label: string;
68
+ at: number;
69
+ durationMs: number;
70
+ reason: string;
71
+ matchedStateId?: string;
72
+ matchedRules?: string[];
73
+ debounceKind?: string;
74
+ idleHoldMs?: number;
75
+ busyHoldMs?: number;
76
+ via?: string;
77
+ }
78
+
79
+ /**
80
+ * A frozen snapshot of the FULL FSM evaluation captured at the instant a
81
+ * transition fired — the rich `transitions[]` table (per-transition eligible /
82
+ * hold countdown / per-condition CondResult + remainingMs) that `getFsmDebug()`
83
+ * otherwise only computes live for the current instant. Kept in a separate ring
84
+ * buffer from `stateHistory` (which stays intentionally lightweight) so the
85
+ * "why did this rule fire just before the transition" question is answerable
86
+ * after the fact. before-only: this is the evaluation that PRODUCED the
87
+ * transition, not the post-transition state.
88
+ */
89
+ export interface FsmSnapshotEntry {
90
+ /** State we transitioned out of. */
91
+ stateFrom: string;
92
+ /** State we transitioned into (the fired transition's destination). */
93
+ stateTo: string;
94
+ /** Wall-clock time the transition committed (ms). */
95
+ at: number;
96
+ /** The fired transition's destination state id (== stateTo; kept explicit
97
+ * to mirror the rule that fired). */
98
+ firedTo: string;
99
+ /** Human label of the fired transition (e.g. "approval→busy"). */
100
+ firedLabel: string;
101
+ /** Why-it-fired summary, same shape produced for stateHistory.matchedRules. */
102
+ reason: string[];
103
+ /** Every outgoing transition from `stateFrom` as evaluated at `at`, each
104
+ * with its eligible / hold / per-condition CondResult + remainingMs. This
105
+ * is the full pre-transition evaluation table — the whole point of the
106
+ * snapshot. */
107
+ transitions: TransitionEval[];
108
+ }
109
+
110
+ export interface ISpecDriver {
111
+ subscribe(listener: (ev: DashboardEvent) => void): () => void;
112
+ start(): void;
113
+ dispatch(cmd: DashboardCommand): void;
114
+ snapshot(): string;
115
+ getCursorPosition(): { row: number; col: number };
116
+ getScreen(): string;
117
+ getSpecPath(): string;
118
+ shutdown(): void;
119
+ getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
120
+ getSections(): Array<{ id: string; text: string }> | null;
121
+ getLastBusyAt(): number;
122
+ hasIdleHoldPending(): boolean;
123
+ getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
124
+ getFsmDebug?(): unknown;
125
+ getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
126
+ }
127
+
128
+ export interface SpecDriverOpts {
129
+ specPath: string;
130
+ workingDir: string;
131
+ extraEnv?: Record<string, string>;
132
+ cols?: number;
133
+ rows?: number;
134
+ hotReload?: boolean;
135
+ emitTrace?: boolean;
136
+ transportFactory?: PtyTransportFactory;
137
+ extraCliArgs?: string[];
138
+ }
139
+
140
+ function countNewlines(s: string): number {
141
+ let n = 0;
142
+ for (let i = 0; i < s.length; i += 1) if (s.charCodeAt(i) === 10) n += 1;
143
+ return n;
144
+ }
145
+
146
+ const SUBMIT_DELAY_FLOOR_MS = 200;
147
+
148
+ export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number {
149
+ const lines = countNewlines(text);
150
+ const linesBonus = Math.min(800, lines * 80);
151
+ const spec = typeof specBeforeSubmit === 'number' && specBeforeSubmit > 0 ? specBeforeSubmit : 0;
152
+ return Math.max(spec, SUBMIT_DELAY_FLOOR_MS + linesBonus);
153
+ }
154
+
155
+ export function guessExt(mime: string): string {
156
+ if (/png/i.test(mime)) return '.png';
157
+ if (/jpe?g/i.test(mime)) return '.jpg';
158
+ if (/gif/i.test(mime)) return '.gif';
159
+ if (/webp/i.test(mime)) return '.webp';
160
+ return '.bin';
161
+ }
162
+
163
+ // ─────────────────────────────────────────────────────────────────────────────
164
+
165
+ interface ModalSnapshot {
166
+ title: string | null;
167
+ buttons: { index: number; label: string; key: string }[];
168
+ }
169
+
170
+ interface VisibleControl {
171
+ id: string;
172
+ label: string;
173
+ actionType: 'send_keys' | 'open_picker' | 'attach_image';
174
+ }
175
+
176
+ type HistoryEntry = DriverHistoryEntry;
177
+
178
+ /** Per-state evaluation snapshot (mirrors v3 SpecEvaluation shape for the
179
+ * parts the cli-adapter / panel consume). */
180
+ interface CurrentEval {
181
+ state: { id: string; label: string; title: string | null; status: 'idle' | 'generating' | 'approval' };
182
+ modal: ModalSnapshot | null;
183
+ controls: VisibleControl[];
184
+ }
185
+
186
+ export class FsmDriver implements ISpecDriver {
187
+ private spec!: CliSpecV4;
188
+ private adapter: TerminalAdapter;
189
+ private listeners = new Set<(ev: DashboardEvent) => void>();
190
+
191
+ // ── The entire FSM state: which node we're in, and when we entered it.
192
+ private currentStateId = '';
193
+ private stateEnteredAt = 0;
194
+
195
+ // ── Clock bookkeeping for time conditions.
196
+ private startedAtMs = 0;
197
+ private prevScreenLines: string[] = [];
198
+ /** Per cursor_above region (key: cursor_above, -1 = whole screen) → last
199
+ * time that region's content changed. Drives stable_ms conditions. */
200
+ private regionLastChangedAt = new Map<number, number>();
201
+ /** Timer that re-runs evaluate() when a time-condition would flip true
202
+ * with no PTY frame to trigger it. */
203
+ private wakeTimer: ReturnType<typeof setTimeout> | null = null;
204
+ /** Timer driving the focus-gated stall watchdog (refocus_when_stalled_ms).
205
+ * Re-arms itself while the machine is generating so a re-prime can fire
206
+ * even when the PTY has gone completely quiet. */
207
+ private stallTimer: ReturnType<typeof setTimeout> | null = null;
208
+ /** Wall-clock time the last stall re-prime was injected. The cooldown gate:
209
+ * after a re-prime we don't re-inject until the screen changes (which
210
+ * resets the stall reference) or another full stall window lapses. */
211
+ private lastRefocusAt = 0;
212
+
213
+ private currentEval: CurrentEval | null = null;
214
+ private stateHistory: HistoryEntry[] = [];
215
+ private prevStateAt = 0;
216
+
217
+ // ── send_message queueing until the machine first reaches a non-initial,
218
+ // non-busy ("ready") state — same contract as v3's idleSeenOnce.
219
+ private readySeenOnce = false;
220
+ private pendingSends: string[] = [];
221
+
222
+ private pickerInProgress: { control_id: string; spec: Control } | null = null;
223
+ private delegateTimers = new Map<string, ReturnType<typeof setTimeout>>();
224
+ private specWatcher: fs.FSWatcher | null = null;
225
+ /** Last full FSM evaluation, kept for the debugger. */
226
+ private lastFsmEval: ReturnType<typeof evaluateFsm> | null = null;
227
+ /** Ring buffer (max 20) of the full FSM evaluation captured at each
228
+ * transition — the rich pre-transition table that lastFsmEval only keeps
229
+ * for the single most recent evaluation. Separate from stateHistory. */
230
+ private fsmSnapshotHistory: FsmSnapshotEntry[] = [];
231
+
232
+ constructor(private readonly opts: SpecDriverOpts) {
233
+ this.loadSpecOrThrow();
234
+ this.adapter = new TerminalAdapter(
235
+ this.buildAdapterOpts(),
236
+ {
237
+ init: () => this.emitInitialState(),
238
+ on_pty_data: (chunk) => this.emit({ kind: 'pty_data', chunk }),
239
+ on_screen_changed: () => this.reevaluate(),
240
+ on_exit: ({ exitCode }) => this.handleExit(exitCode),
241
+ },
242
+ );
243
+ if (this.opts.hotReload !== false) this.armSpecWatcher();
244
+ }
245
+
246
+ subscribe(listener: (ev: DashboardEvent) => void): () => void {
247
+ this.listeners.add(listener);
248
+ return () => { this.listeners.delete(listener); };
249
+ }
250
+
251
+ start(): void {
252
+ const now = Date.now();
253
+ this.startedAtMs = now;
254
+ const init = initialState(this.spec);
255
+ this.currentStateId = init.id;
256
+ this.stateEnteredAt = now;
257
+ this.prevStateAt = now;
258
+ // Pre-trust the workspace before spawning so a first-run folder-trust
259
+ // prompt never appears (best-effort; failures fall back to the FSM's
260
+ // trust-modal detection). Only runs for specs that declare it.
261
+ if (this.spec.pre_launch_trust) {
262
+ applyPreLaunchTrust(this.spec.pre_launch_trust, this.opts.workingDir);
263
+ }
264
+ this.adapter.start();
265
+ // Prime focus-gated TUIs (see CliSpecV4.send_on_spawn). Written once,
266
+ // shortly after spawn, so the input stream is awake before the first
267
+ // delegated message — without this, a focus-event CLI like antigravity
268
+ // drops the first programmatic write until a manual keystroke.
269
+ this.scheduleSpawnPrime();
270
+ // The initial state may have a purely time-based exit (elapsed_ms);
271
+ // schedule a wake so we leave it even if the PTY goes quiet.
272
+ this.scheduleWakeForState();
273
+ }
274
+
275
+ private scheduleSpawnPrime(): void {
276
+ const seqs = this.spec.send_on_spawn;
277
+ if (!Array.isArray(seqs) || seqs.length === 0) return;
278
+ const delay = Math.max(0, this.spec.send_on_spawn_delay_ms ?? 250);
279
+ setTimeout(() => this.sendSpawnPrime(), delay);
280
+ }
281
+
282
+ /** Write the declared `send_on_spawn` sequences to the PTY once. Used both
283
+ * at spawn (scheduleSpawnPrime) and, for focus-gated TUIs, by the stall
284
+ * watchdog to re-inject the focus-in wake mid-turn. No-op when the spec
285
+ * declares no prime, so non-focus-gated CLIs are never poked. */
286
+ private sendSpawnPrime(): void {
287
+ const seqs = this.spec.send_on_spawn;
288
+ if (!Array.isArray(seqs) || seqs.length === 0) return;
289
+ for (const seq of seqs) {
290
+ if (typeof seq === 'string' && seq.length > 0) this.adapter.send_keys(seq);
291
+ }
292
+ }
293
+
294
+ dispatch(cmd: DashboardCommand): void {
295
+ switch (cmd.kind) {
296
+ case 'send_message': this.handleSendMessage(cmd.text); return;
297
+ case 'pty_write': this.adapter.send_keys(cmd.data); return;
298
+ case 'click_control': this.handleClickControl(cmd.control_id, cmd.payload); return;
299
+ case 'click_modal_button': this.handleClickModalButton(cmd.index); return;
300
+ case 'attach_image': this.handleAttachImage(cmd.blob, cmd.mime); return;
301
+ case 'resize': this.adapter.resize(cmd.cols, cmd.rows); return;
302
+ case 'cancel': this.adapter.send_keys('\x03'); return;
303
+ case 'shutdown': this.shutdown(); return;
304
+ }
305
+ }
306
+
307
+ snapshot(): string { return this.adapter.snapshot(); }
308
+ getCursorPosition(): { row: number; col: number } { return this.adapter.getCursorPosition(); }
309
+ getScreen(): string { return this.adapter.snapshot(); }
310
+
311
+ /** Scrollback-inclusive screen as line array — used only for modal/button
312
+ * content extraction so a tall prompt's off-screen anchors stay matchable.
313
+ * Falls back to the viewport snapshot if scrollback read is unavailable. */
314
+ private scrollbackLines(): string[] {
315
+ let screen = '';
316
+ try {
317
+ screen = this.adapter.snapshotWithScrollback();
318
+ } catch { /* fall through to viewport */ }
319
+ if (!screen) screen = this.adapter.snapshot();
320
+ return screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
321
+ }
322
+ getSpecPath(): string { return this.opts.specPath; }
323
+
324
+ shutdown(): void {
325
+ for (const t of this.delegateTimers.values()) clearTimeout(t);
326
+ this.delegateTimers.clear();
327
+ if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
328
+ if (this.stallTimer) { clearTimeout(this.stallTimer); this.stallTimer = null; }
329
+ this.specWatcher?.close();
330
+ this.adapter.kill();
331
+ }
332
+
333
+ // ── Debug surface (the whole reason for the rewrite) ──────────────────
334
+ //
335
+ // getFsmDebug() answers, for the CURRENT instant: which state am I in,
336
+ // how long have I been here, and for every outgoing transition — does its
337
+ // guard pass right now, and if not, which sub-condition is blocking and
338
+ // how many ms until it would flip. No screenshots required.
339
+
340
+ getFsmDebug(): {
341
+ currentState: string;
342
+ label: string;
343
+ stateAgeMs: number;
344
+ status: string;
345
+ cursor: { row: number; col: number };
346
+ transitions: TransitionEval[];
347
+ } {
348
+ const now = Date.now();
349
+ const cursor = this.adapter.getCursorPosition();
350
+ const screen = this.adapter.snapshot();
351
+ const ev = this.evalFsmNow(screen, cursor, now);
352
+ const state = stateById(this.spec, this.currentStateId);
353
+ return {
354
+ currentState: this.currentStateId,
355
+ label: state?.label ?? this.currentStateId,
356
+ stateAgeMs: now - this.stateEnteredAt,
357
+ status: state ? statusForState(state) : 'idle',
358
+ cursor,
359
+ transitions: ev.transitions,
360
+ };
361
+ }
362
+
363
+ getStateHistory(): ReadonlyArray<HistoryEntry> { return this.stateHistory; }
364
+ getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry> { return this.fsmSnapshotHistory; }
365
+ getSections(): Array<{ id: string; text: string }> | null {
366
+ try {
367
+ const screen = this.adapter.snapshot();
368
+ const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
369
+ return resolveSections(this.spec.sections ?? {}, lines).map(s => ({ id: s.id, text: s.text }));
370
+ } catch { return null; }
371
+ }
372
+
373
+ /** v3-compat shims so the cli-adapter's existing debug snapshot keeps
374
+ * working without branching on driver type. */
375
+ getLastBusyAt(): number {
376
+ // Approximate: time we entered a generating-status state.
377
+ const st = stateById(this.spec, this.currentStateId);
378
+ return st && statusForState(st) === 'generating' ? this.stateEnteredAt : 0;
379
+ }
380
+ hasIdleHoldPending(): boolean {
381
+ // FSM has no separate idle-hold timer; min_hold_ms is the analog.
382
+ // Report true while any outgoing transition is hold-blocked.
383
+ return (this.lastFsmEval?.transitions ?? []).some(t => !t.holdSatisfied && t.condResult);
384
+ }
385
+ getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null {
386
+ // Surface the busy→ready transition's stable countdown, if any, so the
387
+ // existing panel field stays meaningful.
388
+ const out = outgoingTransitions(this.spec, this.currentStateId);
389
+ const toReady = this.lastFsmEval?.transitions.find((t, i) => {
390
+ const st = stateById(this.spec, out[i]?.to ?? '');
391
+ return st && statusForState(st) === 'idle';
392
+ });
393
+ if (!toReady || !toReady.cond) return null;
394
+ const stable = findStable(toReady.cond);
395
+ if (!stable) return null;
396
+ return { active: true, ageMs: 0, holdMs: stable.totalMs, forceAfterMs: 0 };
397
+ }
398
+
399
+ // ────────────────────────────────────────────────────────────────────
400
+ // Loading & adapter wiring
401
+ // ────────────────────────────────────────────────────────────────────
402
+
403
+ private loadSpecOrThrow(): void {
404
+ const res = loadFsmSpec(this.opts.specPath);
405
+ if (!res.ok) throw new Error(`fsm spec invalid: ${res.errors.join('; ')}`);
406
+ this.spec = res.spec;
407
+ }
408
+
409
+ private buildAdapterOpts(): TerminalAdapterOpts {
410
+ // Single-source spawn resolution: route the spec's binary/args/env
411
+ // through the SAME planner the legacy ProviderCliAdapter uses
412
+ // (resolveCliSpawnPlanFromParts). This gives the spec/FSM path
413
+ // findBinary (PATH + npm-global / Node-dir fallback so an off-PATH
414
+ // `codex`/`claude` resolves), `{{workingDir}}` token substitution, shell
415
+ // wrapping for script-shims / non-absolute / non-native binaries, and a
416
+ // sanitized env with TERMINAL_CWD — none of which it had when it passed
417
+ // `this.spec.binary` straight to the PTY.
418
+ const cols = this.opts.cols ?? DEFAULT_SESSION_HOST_COLS;
419
+ const rows = this.opts.rows ?? DEFAULT_SESSION_HOST_ROWS;
420
+ const plan = resolveCliSpawnPlanFromParts({
421
+ command: this.spec.binary,
422
+ baseArgs: this.spec.spawn_args ?? [],
423
+ baseEnv: this.spec.env ?? {},
424
+ workingDir: this.opts.workingDir,
425
+ extraArgs: this.opts.extraCliArgs ?? [],
426
+ extraEnv: this.opts.extraEnv ?? {},
427
+ geometry: { cols, rows },
428
+ });
429
+ return {
430
+ binary: plan.shellCmd,
431
+ args: plan.shellArgs,
432
+ cwd: plan.ptyOptions.cwd,
433
+ // plan.ptyOptions.env is already a complete, sanitized environment —
434
+ // pass it verbatim, do not overlay process.env (see envIsComplete).
435
+ env: plan.ptyOptions.env,
436
+ envIsComplete: true,
437
+ cols,
438
+ rows,
439
+ transportFactory: this.opts.transportFactory,
440
+ };
441
+ }
442
+
443
+ private armSpecWatcher(): void {
444
+ try {
445
+ const dir = path.dirname(this.opts.specPath);
446
+ const base = path.basename(this.opts.specPath);
447
+ this.specWatcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
448
+ if (filename && filename !== base) return;
449
+ const res = loadFsmSpec(this.opts.specPath);
450
+ if (!res.ok) {
451
+ this.emit({ kind: 'spec_error', errors: res.errors });
452
+ return;
453
+ }
454
+ this.spec = res.spec;
455
+ LOG.info('FsmDriver', `[${this.specTag()}] spec hot-reloaded`);
456
+ // Re-evaluate immediately with the new transitions.
457
+ this.reevaluate(true);
458
+ });
459
+ } catch { /* best-effort */ }
460
+ }
461
+
462
+ private emitInitialState(): void {
463
+ this.reevaluate(true);
464
+ }
465
+
466
+ // ────────────────────────────────────────────────────────────────────
467
+ // Core: evaluate the FSM and commit transitions
468
+ // ────────────────────────────────────────────────────────────────────
469
+
470
+ private buildClock(now: number): FsmClock {
471
+ return {
472
+ now,
473
+ stateEnteredAt: this.stateEnteredAt,
474
+ regionLastChangedAt: this.regionLastChangedAt,
475
+ };
476
+ }
477
+
478
+ private evalFsmNow(screen: string, cursor: { row: number; col: number }, now: number) {
479
+ const prev = this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined;
480
+ return evaluateFsm(this.spec, this.currentStateId, screen, cursor, prev, this.buildClock(now));
481
+ }
482
+
483
+ private reevaluate(forceEmit = false): void {
484
+ const now = Date.now();
485
+ const screen = this.adapter.snapshot();
486
+ const cursor = this.adapter.getCursorPosition();
487
+ const currentLines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
488
+
489
+ // Track per-region change timestamps for stable_ms conditions BEFORE
490
+ // we overwrite prevScreenLines.
491
+ this.trackRegionChanges(currentLines, cursor, now);
492
+
493
+ const ev = this.evalFsmNow(screen, cursor, now);
494
+ this.lastFsmEval = ev;
495
+ this.prevScreenLines = currentLines;
496
+
497
+ if (ev.fired) {
498
+ this.commitTransition(ev.fired, now, ev);
499
+ // After a transition, immediately re-derive controls/modal for the
500
+ // new state and emit. Re-run once so a chain like approval→busy
501
+ // that's already satisfied doesn't wait for the next PTY frame.
502
+ this.emitStateChanged(forceEmit);
503
+ this.scheduleWakeForState();
504
+ this.scheduleStallWatchdog();
505
+ // Drain queued sends on the SAME frame the machine reaches "ready".
506
+ // The first delegated message is queued in pendingSends until the
507
+ // FSM first enters a non-initial idle state (the prompt is drawn).
508
+ // That readiness is normally reached BY a transition (e.g.
509
+ // signing_in→idle / starting→idle) — and an idle state has no
510
+ // pending time-condition, so scheduleWakeForState() arms no timer
511
+ // and, agy being quiet at the prompt, no further PTY frame arrives.
512
+ // Without this call the queued first message would strand here
513
+ // forever (the "first input never processed" bug). maybeMarkReady is
514
+ // idempotent (guarded by readySeenOnce) so calling it on both
515
+ // branches is safe.
516
+ this.maybeMarkReady();
517
+ return;
518
+ }
519
+
520
+ // No transition — refresh modal/controls (content inside the same
521
+ // state can still change, e.g. modal title/buttons) and emit if changed.
522
+ this.emitStateChanged(forceEmit);
523
+ // Schedule a wake for the soonest pending time-condition.
524
+ this.scheduleWakeForState();
525
+ this.scheduleStallWatchdog();
526
+ // Drain queued sends once we first reach a "ready" state.
527
+ this.maybeMarkReady();
528
+ }
529
+
530
+ private commitTransition(fired: TransitionEval, now: number, ev: FsmEvaluation): void {
531
+ const from = this.currentStateId;
532
+ // Capture the full pre-transition evaluation BEFORE we mutate state, so
533
+ // the snapshot records why this transition fired from `from`.
534
+ this.pushFsmSnapshot(from, fired, now, ev);
535
+ this.currentStateId = fired.to;
536
+ this.stateEnteredAt = now;
537
+ // Region change timestamps are relative to the previous state's
538
+ // activity; reset so stable_ms in the new state measures from entry.
539
+ this.regionLastChangedAt.clear();
540
+ this.pushHistory(fired.to, stateById(this.spec, fired.to)?.label ?? fired.to, {
541
+ reason: 'transition',
542
+ via: `${from}→${fired.to}`,
543
+ matchedRules: summarizeTransition(fired),
544
+ });
545
+ LOG.info('FsmDriver', `[${this.specTag()}] ${from} → ${fired.to} (${fired.label})`);
546
+ }
547
+
548
+ /** Snapshot the full FSM evaluation that produced a transition into the
549
+ * separate fsmSnapshotHistory ring buffer (max 20). The transitions[]
550
+ * table is captured by reference — it is freshly built per evaluation in
551
+ * evaluateFsm and never mutated after, so no clone is needed. */
552
+ private pushFsmSnapshot(from: string, fired: TransitionEval, now: number, ev: FsmEvaluation): void {
553
+ this.fsmSnapshotHistory.push({
554
+ stateFrom: from,
555
+ stateTo: fired.to,
556
+ at: now,
557
+ firedTo: fired.to,
558
+ firedLabel: fired.label,
559
+ reason: summarizeTransition(fired),
560
+ transitions: ev.transitions,
561
+ });
562
+ if (this.fsmSnapshotHistory.length > 20) this.fsmSnapshotHistory.shift();
563
+ }
564
+
565
+ /** Re-derive the visible modal + controls for the current state and emit a
566
+ * state_changed if anything differs from the last emit. */
567
+ private emitStateChanged(forceEmit: boolean): void {
568
+ const state = stateById(this.spec, this.currentStateId);
569
+ if (!state) return;
570
+ const screen = this.adapter.snapshot();
571
+ const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
572
+ const sections = resolveSections(this.spec.sections ?? {}, lines);
573
+
574
+ // Modal states (approval / picker) extract their buttons + title from a
575
+ // SCROLLBACK-INCLUSIVE buffer: claude-cli renders an approval as a box,
576
+ // and when the prompt body (a big diff / long explanation) is tall the
577
+ // top of the box — including the `─────` separator that anchors the
578
+ // `modal` section — scrolls above the viewport. Matching only the
579
+ // viewport then yields < min_count buttons, deriveModal returns null,
580
+ // and auto-approve never fires (the "long prompts never auto-approve"
581
+ // bug). The viewport stays authoritative for transitions / cursor
582
+ // conditions; only this content-pattern extraction reads scrollback.
583
+ const modalLines = state.modal
584
+ ? this.scrollbackLines()
585
+ : lines;
586
+ const modalSections = state.modal
587
+ ? resolveSections(this.spec.sections ?? {}, modalLines)
588
+ : sections;
589
+ const modalFullScreen = modalLines.join('\n');
590
+
591
+ const modal = this.deriveModal(state, modalSections, modalFullScreen);
592
+ const controls = this.deriveControls(state.id);
593
+ const title = modal?.title ?? this.deriveTitle(state, modalSections, modalFullScreen);
594
+
595
+ const next: CurrentEval = {
596
+ // status is derived from the FSM state itself (statusForState), NOT from
597
+ // whether a modal was parsed this frame. A modal state whose buttons briefly
598
+ // fail to parse (PTY repaint → deriveModal returns null) must still report
599
+ // its authoritative status (e.g. 'approval'), so the adapter never collapses
600
+ // an approval/busy state to idle on a transient modal-parse miss.
601
+ state: { id: state.id, label: state.label, title, status: statusForState(state) },
602
+ modal,
603
+ controls,
604
+ };
605
+
606
+ const changed = forceEmit
607
+ || !this.currentEval
608
+ || this.currentEval.state.id !== next.state.id
609
+ || this.currentEval.state.title !== next.state.title
610
+ || !sameModal(this.currentEval.modal, next.modal)
611
+ || !sameControls(this.currentEval.controls, next.controls);
612
+
613
+ this.currentEval = next;
614
+ if (this.pickerInProgress) this.tryAdvancePicker(screen);
615
+
616
+ if (changed) {
617
+ this.emit({
618
+ kind: 'state_changed',
619
+ state: next.state,
620
+ modal: next.modal ? { title: next.modal.title, buttons: next.modal.buttons.map(b => ({ index: b.index, label: b.label })) } : null,
621
+ controls: next.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
622
+ });
623
+ this.fireNotifications(state.id, title);
624
+ this.armOrCancelDelegateTimers(state.id);
625
+ }
626
+ }
627
+
628
+ private deriveModal(state: FsmState, sections: ResolvedSection[], fullScreen: string): ModalSnapshot | null {
629
+ const rule = state.extract?.buttons;
630
+ if (!rule) return null;
631
+ const hay = sectionText(sections, rule.section, fullScreen);
632
+ const minCount = rule.min_count ?? 2;
633
+ const buttons = extractButtonsFromRule(rule, hay);
634
+ if (buttons.length < minCount) return null;
635
+ const title = this.deriveTitle(state, sections, fullScreen);
636
+ return { title, buttons };
637
+ }
638
+
639
+ private deriveTitle(state: FsmState, sections: ResolvedSection[], fullScreen: string): string | null {
640
+ const rule = state.extract?.title;
641
+ if (!rule) return null;
642
+ return extractTitle(rule, sections, fullScreen);
643
+ }
644
+
645
+ private deriveControls(stateId: string): VisibleControl[] {
646
+ const out: VisibleControl[] = [];
647
+ for (const c of this.spec.control_bar ?? []) {
648
+ if (c.visible_when_state && !c.visible_when_state.includes(stateId)) continue;
649
+ out.push({ id: c.id, label: c.label, actionType: c.action.type });
650
+ }
651
+ return out;
652
+ }
653
+
654
+ /** Track which cursor_above regions changed since the previous frame so
655
+ * stable_ms conditions can measure quiet time. We record every region
656
+ * size referenced by a stable_ms condition in the spec, plus the whole
657
+ * screen (-1). */
658
+ private trackRegionChanges(currentLines: string[], cursor: { row: number; col: number }, now: number): void {
659
+ if (this.prevScreenLines.length === 0) return;
660
+ const sizes = this.stableRegionSizes();
661
+ for (const size of sizes) {
662
+ let cur: string; let prev: string;
663
+ if (size < 0) {
664
+ cur = currentLines.join('\n');
665
+ prev = this.prevScreenLines.join('\n');
666
+ } else {
667
+ const start = Math.max(0, cursor.row - size);
668
+ cur = currentLines.slice(start, cursor.row).join('\n');
669
+ prev = this.prevScreenLines.slice(start, cursor.row).join('\n');
670
+ }
671
+ if (cur !== prev) this.regionLastChangedAt.set(size, now);
672
+ }
673
+ }
674
+
675
+ /** All cursor_above region sizes referenced by stable_ms conditions in the
676
+ * current state's outgoing transitions, plus whole-screen. Cached lazily
677
+ * per spec load would be nicer but the set is tiny. */
678
+ private stableRegionSizes(): Set<number> {
679
+ const sizes = new Set<number>([-1]);
680
+ for (const t of outgoingTransitions(this.spec, this.currentStateId)) {
681
+ collectStableSizes(t.when, sizes);
682
+ }
683
+ return sizes;
684
+ }
685
+
686
+ /** Schedule a re-evaluation for the soonest pending time-condition on any
687
+ * outgoing transition (elapsed_ms / stable_ms / min_hold_ms). Without
688
+ * this, a state whose only exit is time-based would never leave once the
689
+ * PTY goes quiet. */
690
+ private scheduleWakeForState(): void {
691
+ if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
692
+ const ev = this.lastFsmEval;
693
+ if (!ev) return;
694
+ let soonest = Infinity;
695
+ for (const t of ev.transitions) {
696
+ if (t.fires) continue;
697
+ if (!t.holdSatisfied) soonest = Math.min(soonest, t.holdRemainingMs);
698
+ const condRemain = t.cond ? t.cond.remainingMs ?? Infinity : Infinity;
699
+ // Only treat the cond countdown as a wake source when the rest of
700
+ // the guard (hold) is already or will be satisfied.
701
+ if (Number.isFinite(condRemain) && condRemain > 0) soonest = Math.min(soonest, condRemain);
702
+ }
703
+ if (!Number.isFinite(soonest)) return;
704
+ this.wakeTimer = setTimeout(() => { this.wakeTimer = null; this.reevaluate(); }, Math.max(soonest + 30, 50));
705
+ }
706
+
707
+ // ── Focus-gated stall watchdog (refocus_when_stalled_ms) ──────────────────
708
+ //
709
+ // A focus-event TUI (antigravity's `agy`) freezes its render loop the moment
710
+ // it thinks it has lost focus mid-turn: the screen stops updating and only
711
+ // repaints on the next keypress, which the daemon never sends. The output
712
+ // pump is wired entirely to PTY onData (no PTY data → no reevaluate, no
713
+ // on_screen_changed), so a normal time-wake that only re-reads the screen
714
+ // can't help — there is nothing new to read. The fix is to re-inject the
715
+ // focus-in wake (`send_on_spawn`) so the CLI flushes the held output itself.
716
+
717
+ /** True when this spec opts into stall recovery (declares a positive
718
+ * refocus window AND a wake sequence to re-inject). */
719
+ private stallRecoveryEnabled(): boolean {
720
+ const ms = this.spec.refocus_when_stalled_ms;
721
+ return typeof ms === 'number' && ms > 0
722
+ && Array.isArray(this.spec.send_on_spawn) && this.spec.send_on_spawn.length > 0;
723
+ }
724
+
725
+ /** Wall-clock time the screen last changed in the current state, falling
726
+ * back to state entry when it has not changed since (i.e. fully stalled
727
+ * from the start of the state). */
728
+ private lastScreenChangeAt(): number {
729
+ return this.regionLastChangedAt.get(-1) ?? this.stateEnteredAt;
730
+ }
731
+
732
+ /** Arm a timer to re-inject the focus-in prime if the screen stays frozen
733
+ * through a `generating` state. Only active for opted-in focus-gated specs;
734
+ * a no-op (and cleared) for every other CLI and every non-generating state.
735
+ * Re-arms itself so it keeps watching while the PTY is quiet. */
736
+ private scheduleStallWatchdog(): void {
737
+ if (this.stallTimer) { clearTimeout(this.stallTimer); this.stallTimer = null; }
738
+ if (!this.stallRecoveryEnabled()) return;
739
+ const st = stateById(this.spec, this.currentStateId);
740
+ if (!st || statusForState(st) !== 'generating') return;
741
+ const windowMs = this.spec.refocus_when_stalled_ms as number;
742
+ // Cooldown reference: a re-prime defers the next one by a full window,
743
+ // even if the screen has not yet repainted, so we don't tight-loop.
744
+ const since = Math.max(this.lastScreenChangeAt(), this.lastRefocusAt);
745
+ const remaining = windowMs - (Date.now() - since);
746
+ this.stallTimer = setTimeout(
747
+ () => { this.stallTimer = null; this.onStallTick(); },
748
+ Math.max(remaining + 30, 50),
749
+ );
750
+ }
751
+
752
+ /** Fire when the stall window elapses: if the screen is still frozen and we
753
+ * are still generating, re-inject the focus-in wake once, then re-arm. */
754
+ private onStallTick(): void {
755
+ if (!this.stallRecoveryEnabled()) return;
756
+ const st = stateById(this.spec, this.currentStateId);
757
+ if (!st || statusForState(st) !== 'generating') return;
758
+ const windowMs = this.spec.refocus_when_stalled_ms as number;
759
+ const now = Date.now();
760
+ const stalledFor = now - this.lastScreenChangeAt();
761
+ const sinceLastRefocus = now - this.lastRefocusAt;
762
+ if (stalledFor >= windowMs && sinceLastRefocus >= windowMs) {
763
+ LOG.info('FsmDriver', `[${this.specTag()}] stall detected (${stalledFor}ms quiet, generating) — re-injecting focus-in`);
764
+ this.sendSpawnPrime();
765
+ this.lastRefocusAt = now;
766
+ }
767
+ // Keep watching: the re-prime may not flush instantly, and a still-quiet
768
+ // screen needs the next window to come around.
769
+ this.scheduleStallWatchdog();
770
+ }
771
+
772
+ private maybeMarkReady(): void {
773
+ if (this.readySeenOnce) return;
774
+ const st = stateById(this.spec, this.currentStateId);
775
+ if (!st) return;
776
+ // "Ready" = a non-initial state whose status is idle (the prompt is up).
777
+ if (!st.initial && statusForState(st) === 'idle') {
778
+ this.readySeenOnce = true;
779
+ const queued = this.pendingSends.splice(0);
780
+ for (const text of queued) setTimeout(() => this.actuallySendMessage(text), 50);
781
+ }
782
+ }
783
+
784
+ // ────────────────────────────────────────────────────────────────────
785
+ // Notifications & delegates
786
+ // ────────────────────────────────────────────────────────────────────
787
+
788
+ private fireNotifications(stateId: string, title: string | null): void {
789
+ for (const n of this.spec.notifications ?? []) {
790
+ if (n.when_state !== stateId) continue;
791
+ const body = (n.body ?? '').replace(/\{state\.title\}/g, title ?? '');
792
+ this.emit({ kind: 'notification', id: n.id, title: n.title, body });
793
+ }
794
+ }
795
+
796
+ private armOrCancelDelegateTimers(currentStateId: string): void {
797
+ for (const d of this.spec.delegate ?? []) {
798
+ const armed = this.delegateTimers.has(d.id);
799
+ const shouldFire = d.when_state === currentStateId;
800
+ if (shouldFire && !armed) {
801
+ const delay = d.after_duration_ms ?? 0;
802
+ const t = setTimeout(() => { this.fireDelegate(d); this.delegateTimers.delete(d.id); }, delay);
803
+ this.delegateTimers.set(d.id, t);
804
+ } else if (!shouldFire && armed) {
805
+ clearTimeout(this.delegateTimers.get(d.id)!);
806
+ this.delegateTimers.delete(d.id);
807
+ }
808
+ }
809
+ }
810
+
811
+ private fireDelegate(d: DelegateTrigger): void {
812
+ const ev = this.currentEval;
813
+ const task = d.task_template
814
+ .replace(/\{node\}/g, os.hostname())
815
+ .replace(/\{state\.label\}/g, ev?.state.label ?? '')
816
+ .replace(/\{state\.title\}/g, ev?.state.title ?? '')
817
+ .replace(/\{duration_ms\}/g, String(d.after_duration_ms ?? 0));
818
+ this.emit({ kind: 'delegate', id: d.id, task });
819
+ }
820
+
821
+ // ────────────────────────────────────────────────────────────────────
822
+ // Dashboard commands (identical semantics to v3)
823
+ // ────────────────────────────────────────────────────────────────────
824
+
825
+ private handleSendMessage(text: string): void {
826
+ if (!this.readySeenOnce) { this.pendingSends.push(text); return; }
827
+ this.actuallySendMessage(text);
828
+ }
829
+
830
+ private actuallySendMessage(text: string): void {
831
+ const sm = this.spec.send_message;
832
+ const perChar = sm.delay_ms_per_char ?? 0;
833
+ const beforeSubmit = resolveSubmitDelayMs(sm.delay_ms_before_submit, text);
834
+
835
+ // win32 ConPTY swallows a lone submit key (CR) that arrives in its own
836
+ // PTY chunk after a delay: ConPTY does not treat a delayed, standalone
837
+ // CR as a submit, so the prompt sits typed-but-unsent until the user
838
+ // hits Enter manually. mac/linux PTYs do recognise the split write, so
839
+ // this asymmetry is win32-only. The fix mirrors the legacy
840
+ // ProviderCliAdapter.forceSendMessage fix: honour the settle delay so
841
+ // the TUI input handler is ready, then write `text + submit_key` as ONE
842
+ // PTY write — keeping the submit key in the same write unit as the text
843
+ // is the invariant ConPTY needs to recognise the submit. perChar typing
844
+ // simulation is skipped on win32 (a per-char loop necessarily ends with a
845
+ // separate trailing CR); correctness of submission wins over the typing
846
+ // visual there.
847
+ if (process.platform === 'win32') {
848
+ if (beforeSubmit > 0) {
849
+ setTimeout(() => this.adapter.send_keys(text + sm.submit_key), beforeSubmit);
850
+ } else {
851
+ this.adapter.send_keys(text + sm.submit_key);
852
+ }
853
+ return;
854
+ }
855
+
856
+ if (perChar === 0) {
857
+ this.adapter.send_keys(text);
858
+ if (beforeSubmit > 0) setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
859
+ else this.adapter.send_keys(sm.submit_key);
860
+ return;
861
+ }
862
+ let i = 0;
863
+ const iv = setInterval(() => {
864
+ if (i >= text.length) {
865
+ clearInterval(iv);
866
+ setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
867
+ return;
868
+ }
869
+ this.adapter.send_keys(text[i]);
870
+ i += 1;
871
+ }, perChar);
872
+ }
873
+
874
+ private handleClickControl(controlId: string, payload?: unknown): void {
875
+ const ctl = (this.spec.control_bar ?? []).find(c => c.id === controlId);
876
+ if (!ctl) return;
877
+ if (ctl.visible_when_state && !ctl.visible_when_state.includes(this.currentStateId)) return;
878
+ const a = ctl.action;
879
+ switch (a.type) {
880
+ case 'send_keys': this.adapter.send_keys(a.keys); return;
881
+ case 'open_picker':
882
+ // Some TUIs (e.g. codex) don't register a slash command if its
883
+ // text and the submitting Enter arrive in the same write — the
884
+ // composer needs a beat to recognise the command before the CR.
885
+ // Split a trailing CR/LF off the trigger and send it after a
886
+ // short delay, mirroring send_message's delay_ms_before_submit.
887
+ {
888
+ const m = /^([\s\S]*?)([\r\n]+)$/.exec(a.trigger_keys);
889
+ if (m && m[1]) {
890
+ this.adapter.send_keys(m[1]);
891
+ setTimeout(() => this.adapter.send_keys(m[2]), 200);
892
+ } else {
893
+ this.adapter.send_keys(a.trigger_keys);
894
+ }
895
+ }
896
+ this.pickerInProgress = { control_id: ctl.id, spec: ctl };
897
+ return;
898
+ case 'attach_image': {
899
+ const p = typeof payload === 'object' && payload && (payload as any).path;
900
+ if (typeof p === 'string') this.adapter.send_keys(a.keys_template.replace(/\{path\}/g, p));
901
+ return;
902
+ }
903
+ }
904
+ }
905
+
906
+ private handleClickModalButton(index: number): void {
907
+ const m = this.currentEval?.modal;
908
+ if (!m) return;
909
+ const btn = m.buttons.find(b => b.index === index);
910
+ if (!btn) return;
911
+ this.adapter.send_keys(btn.key);
912
+ }
913
+
914
+ private handleAttachImage(blob: string, mime: string): void {
915
+ const ctl = (this.spec.control_bar ?? []).find(c => c.action.type === 'attach_image');
916
+ if (!ctl || ctl.action.type !== 'attach_image') return;
917
+ const ext = guessExt(mime);
918
+ const tmp = path.join(os.tmpdir(), `adhdev-attach-${Date.now()}${ext}`);
919
+ try { fs.writeFileSync(tmp, Buffer.from(blob, 'base64')); } catch { return; }
920
+ this.adapter.send_keys(ctl.action.keys_template.replace(/\{path\}/g, tmp));
921
+ }
922
+
923
+ private tryAdvancePicker(screen: string): void {
924
+ const picker = this.pickerInProgress;
925
+ if (!picker) return;
926
+ const action = picker.spec.action;
927
+ if (action.type !== 'open_picker' || !action.wait_for.regex) return;
928
+ const lines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
929
+ const sections = resolveSections(this.spec.sections ?? {}, lines);
930
+ const hay = sectionText(sections, action.wait_for.section, lines.join('\n'));
931
+ const re = new RegExp(action.wait_for.regex, action.wait_for.flags ?? 'i');
932
+ if (!re.test(hay)) return;
933
+ this.pickerInProgress = null;
934
+ }
935
+
936
+ private handleExit(exitCode: number): void {
937
+ this.emit({ kind: 'exit', exit_code: exitCode });
938
+ this.shutdown();
939
+ }
940
+
941
+ private pushHistory(stateId: string, label: string, meta: { reason: string; via?: string; matchedRules?: string[] }): void {
942
+ const now = Date.now();
943
+ const durationMs = this.prevStateAt > 0 ? now - this.prevStateAt : 0;
944
+ this.prevStateAt = now;
945
+ this.stateHistory.push({
946
+ stateId, label, at: now, durationMs,
947
+ reason: meta.reason,
948
+ ...(meta.via ? { via: meta.via } : {}),
949
+ ...(meta.matchedRules ? { matchedRules: meta.matchedRules } : {}),
950
+ });
951
+ if (this.stateHistory.length > 50) this.stateHistory.shift();
952
+ }
953
+
954
+ private specTag(): string {
955
+ return this.opts.specPath.split('/').slice(-3).join('/');
956
+ }
957
+
958
+ private emit(ev: DashboardEvent): void {
959
+ for (const l of this.listeners) {
960
+ try { l(ev); } catch { /* listener side */ }
961
+ }
962
+ }
963
+ }
964
+
965
+ // ── helpers ──────────────────────────────────────────────────────────────
966
+
967
+ function sameModal(a: ModalSnapshot | null, b: ModalSnapshot | null): boolean {
968
+ if (!a && !b) return true;
969
+ if (!a || !b) return false;
970
+ if (a.title !== b.title) return false;
971
+ if (a.buttons.length !== b.buttons.length) return false;
972
+ for (let i = 0; i < a.buttons.length; i += 1) {
973
+ if (a.buttons[i].label !== b.buttons[i].label) return false;
974
+ }
975
+ return true;
976
+ }
977
+
978
+ function sameControls(a: VisibleControl[], b: VisibleControl[]): boolean {
979
+ if (a.length !== b.length) return false;
980
+ for (let i = 0; i < a.length; i += 1) if (a[i].id !== b[i].id) return false;
981
+ return true;
982
+ }
983
+
984
+ /** A compact one-line-per-condition summary of why a transition fired. */
985
+ function summarizeTransition(t: TransitionEval): string[] {
986
+ const out: string[] = [`${t.label} fired`];
987
+ if (t.cond) flattenCond(t.cond, out, 1);
988
+ return out;
989
+ }
990
+
991
+ function flattenCond(c: import('./fsm-evaluator.js').CondResult, out: string[], depth: number): void {
992
+ out.push(`${' '.repeat(depth)}${c.kind} ${c.detail} = ${c.result}${c.remainingMs ? ` (${c.remainingMs}ms left)` : ''}`);
993
+ for (const child of c.children ?? []) flattenCond(child, out, depth + 1);
994
+ }
995
+
996
+ function findStable(c: import('./fsm-evaluator.js').CondResult): { totalMs: number } | null {
997
+ if (c.kind === 'stable') {
998
+ const m = /\/ (\d+)ms/.exec(c.detail);
999
+ return { totalMs: m ? Number(m[1]) : 0 };
1000
+ }
1001
+ for (const child of c.children ?? []) {
1002
+ const r = findStable(child);
1003
+ if (r) return r;
1004
+ }
1005
+ return null;
1006
+ }
1007
+
1008
+ function collectStableSizes(when: FsmTransition['when'], sizes: Set<number>): void {
1009
+ if (!when) return;
1010
+ const w = when as any;
1011
+ if ('stable_ms' in w) { sizes.add(w.cursor_above && w.cursor_above > 0 ? w.cursor_above : -1); return; }
1012
+ if ('all' in w) { for (const c of w.all) collectStableSizes(c, sizes); return; }
1013
+ if ('any' in w) { for (const c of w.any) collectStableSizes(c, sizes); return; }
1014
+ if ('not' in w) { collectStableSizes(w.not, sizes); return; }
1015
+ }