@adhdev/daemon-core 0.9.82-rc.22 → 0.9.82-rc.220

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 (277) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +1 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/chat/source-machine.d.ts +166 -0
  4. package/dist/chat/source-resolver.d.ts +104 -0
  5. package/dist/chat/subscription-updates.d.ts +1 -0
  6. package/dist/cli-adapter-types.d.ts +23 -2
  7. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  8. package/dist/cli-adapters/cli-state-engine.d.ts +178 -0
  9. package/dist/cli-adapters/provider-cli-adapter.d.ts +106 -63
  10. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  11. package/dist/cli-adapters/provider-cli-shared.d.ts +29 -0
  12. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  13. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +3 -3
  14. package/dist/cli-adapters/terminal-backends/types.d.ts +1 -2
  15. package/dist/cli-adapters/terminal-screen.d.ts +2 -11
  16. package/dist/commands/cli-manager.d.ts +2 -1
  17. package/dist/commands/handler.d.ts +110 -0
  18. package/dist/commands/mesh-coordinator.d.ts +85 -1
  19. package/dist/commands/router.d.ts +32 -1
  20. package/dist/config/chat-history.d.ts +9 -0
  21. package/dist/config/config.d.ts +5 -0
  22. package/dist/config/mesh-config.d.ts +72 -1
  23. package/dist/git/git-commands.d.ts +9 -1
  24. package/dist/git/git-diff.d.ts +6 -0
  25. package/dist/git/git-types.d.ts +2 -0
  26. package/dist/index.d.ts +46 -11
  27. package/dist/index.js +36322 -15614
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +36182 -15568
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/installer.d.ts +1 -4
  32. package/dist/ipc/local-ipc-server.d.ts +91 -0
  33. package/dist/launch.d.ts +1 -1
  34. package/dist/logging/async-batch-writer.d.ts +10 -0
  35. package/dist/mesh/contracts.d.ts +164 -0
  36. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  37. package/dist/mesh/coordinator-registry.d.ts +59 -0
  38. package/dist/mesh/mesh-active-work.d.ts +90 -0
  39. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  40. package/dist/mesh/mesh-events-coordinator.d.ts +151 -0
  41. package/dist/mesh/mesh-events-pending.d.ts +33 -0
  42. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  43. package/dist/mesh/mesh-events-utils.d.ts +14 -0
  44. package/dist/mesh/mesh-events.d.ts +5 -40
  45. package/dist/mesh/mesh-fast-forward.d.ts +41 -0
  46. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  47. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  48. package/dist/mesh/mesh-ledger.d.ts +77 -1
  49. package/dist/mesh/mesh-missions.d.ts +58 -0
  50. package/dist/mesh/mesh-refine-status.d.ts +26 -0
  51. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  52. package/dist/mesh/mesh-runtime-store.d.ts +324 -0
  53. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  54. package/dist/mesh/mesh-work-queue.d.ts +137 -6
  55. package/dist/mesh/preview-freshness.d.ts +18 -0
  56. package/dist/mesh/refine-config.d.ts +215 -0
  57. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  58. package/dist/providers/acp-provider-instance.d.ts +2 -0
  59. package/dist/providers/approval-utils.d.ts +13 -0
  60. package/dist/providers/cli-provider-instance.d.ts +39 -3
  61. package/dist/providers/contracts.d.ts +130 -6
  62. package/dist/providers/external-sources.d.ts +71 -0
  63. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  64. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  65. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  66. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  67. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  68. package/dist/providers/native-history/index.d.ts +13 -0
  69. package/dist/providers/provider-instance-manager.d.ts +12 -0
  70. package/dist/providers/provider-instance.d.ts +13 -1
  71. package/dist/providers/provider-loader.d.ts +26 -4
  72. package/dist/providers/provider-trust.d.ts +31 -0
  73. package/dist/providers/read-chat-contract.d.ts +29 -0
  74. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  75. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  76. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  77. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  78. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  79. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  80. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  81. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  82. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  83. package/dist/providers/sdk/v1/index.d.ts +30 -0
  84. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  85. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  86. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  87. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  88. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  89. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  90. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  91. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  92. package/dist/providers/spec/adapter.d.ts +52 -0
  93. package/dist/providers/spec/cli-adapter.d.ts +102 -0
  94. package/dist/providers/spec/driver.d.ts +287 -0
  95. package/dist/providers/spec/evaluator.d.ts +70 -0
  96. package/dist/providers/spec/fsm-driver.d.ts +110 -0
  97. package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
  98. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  99. package/dist/providers/spec/fsm-types.d.ts +91 -0
  100. package/dist/providers/spec/loader.d.ts +15 -0
  101. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  102. package/dist/providers/spec/route.d.ts +4 -0
  103. package/dist/providers/spec/schema.gen.d.ts +2039 -0
  104. package/dist/providers/spec/types.d.ts +311 -0
  105. package/dist/providers/transcript-v2.d.ts +176 -0
  106. package/dist/providers/types/interactive-prompt.d.ts +48 -0
  107. package/dist/repo-mesh-types.d.ts +126 -1
  108. package/dist/sessions/registry.d.ts +3 -0
  109. package/dist/shared-types-extra.d.ts +2 -4
  110. package/dist/shared-types.d.ts +42 -1
  111. package/dist/status/normalize.d.ts +1 -1
  112. package/dist/status/normalize.js +1 -0
  113. package/dist/status/normalize.js.map +1 -1
  114. package/dist/status/normalize.mjs +1 -0
  115. package/dist/status/normalize.mjs.map +1 -1
  116. package/dist/status/reporter.d.ts +2 -0
  117. package/dist/status/snapshot.d.ts +1 -0
  118. package/dist/types.d.ts +5 -0
  119. package/package.json +6 -3
  120. package/src/agent-stream/poller.ts +2 -3
  121. package/src/boot/daemon-lifecycle.ts +22 -10
  122. package/src/boot/process-hardening.ts +89 -0
  123. package/src/chat/source-machine.ts +534 -0
  124. package/src/chat/source-resolver.ts +0 -0
  125. package/src/chat/subscription-updates.ts +20 -1
  126. package/src/cli-adapter-types.d.ts +1 -0
  127. package/src/cli-adapter-types.ts +22 -2
  128. package/src/cli-adapters/cli-script-runner.ts +421 -0
  129. package/src/cli-adapters/cli-state-engine.ts +1162 -0
  130. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  131. package/src/cli-adapters/provider-cli-adapter.ts +673 -1139
  132. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  133. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  134. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  135. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  136. package/src/cli-adapters/provider-cli-shared.ts +59 -11
  137. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  138. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +35 -29
  139. package/src/cli-adapters/terminal-backends/types.ts +1 -3
  140. package/src/cli-adapters/terminal-screen.ts +11 -81
  141. package/src/commands/chat-commands.ts +1787 -60
  142. package/src/commands/cli-manager.ts +283 -14
  143. package/src/commands/handler.ts +809 -2
  144. package/src/commands/mesh-coordinator.ts +332 -122
  145. package/src/commands/router.ts +4369 -558
  146. package/src/config/chat-history.ts +95 -24
  147. package/src/config/config.ts +12 -0
  148. package/src/config/mesh-config.ts +303 -3
  149. package/src/config/recent-activity.ts +8 -2
  150. package/src/daemon/dev-auto-implement.ts +3 -2
  151. package/src/daemon/dev-cli-debug.ts +10 -1
  152. package/src/detection/ide-detector.ts +26 -16
  153. package/src/git/git-commands.ts +42 -12
  154. package/src/git/git-diff.ts +53 -0
  155. package/src/git/git-status.ts +35 -6
  156. package/src/git/git-types.ts +2 -0
  157. package/src/git/git-worktree.ts +8 -1
  158. package/src/index.ts +126 -10
  159. package/src/installer.d.ts +1 -1
  160. package/src/installer.ts +8 -6
  161. package/src/ipc/local-ipc-server.ts +278 -0
  162. package/src/launch.d.ts +1 -1
  163. package/src/launch.ts +37 -28
  164. package/src/logging/async-batch-writer.ts +55 -0
  165. package/src/logging/logger.ts +2 -1
  166. package/src/mesh/contracts.ts +329 -0
  167. package/src/mesh/coordinator-prompt.ts +218 -31
  168. package/src/mesh/coordinator-registry.ts +121 -0
  169. package/src/mesh/mesh-active-work.ts +437 -0
  170. package/src/mesh/mesh-delivery-policy.ts +315 -0
  171. package/src/mesh/mesh-events-coordinator.ts +1447 -0
  172. package/src/mesh/mesh-events-pending.ts +371 -0
  173. package/src/mesh/mesh-events-stale.ts +283 -0
  174. package/src/mesh/mesh-events-utils.ts +166 -0
  175. package/src/mesh/mesh-events.ts +24 -908
  176. package/src/mesh/mesh-fast-forward.ts +438 -0
  177. package/src/mesh/mesh-host-ownership.ts +73 -0
  178. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  179. package/src/mesh/mesh-ledger.ts +587 -102
  180. package/src/mesh/mesh-missions.ts +151 -0
  181. package/src/mesh/mesh-refine-status.ts +144 -0
  182. package/src/mesh/mesh-review-inbox.ts +307 -0
  183. package/src/mesh/mesh-runtime-store.ts +1305 -0
  184. package/src/mesh/mesh-task-stats.ts +154 -0
  185. package/src/mesh/mesh-work-queue.ts +490 -166
  186. package/src/mesh/preview-freshness.ts +118 -0
  187. package/src/mesh/refine-config.ts +403 -0
  188. package/src/mesh/worktree-bootstrap-config.ts +326 -0
  189. package/src/providers/acp-provider-instance.ts +15 -1
  190. package/src/providers/approval-utils.d.ts +4 -0
  191. package/src/providers/approval-utils.ts +47 -5
  192. package/src/providers/chat-message-normalization.ts +4 -11
  193. package/src/providers/cli-provider-instance.ts +834 -64
  194. package/src/providers/contracts.d.ts +55 -0
  195. package/src/providers/contracts.ts +141 -6
  196. package/src/providers/external-sources.ts +218 -0
  197. package/src/providers/ide-provider-instance.ts +19 -5
  198. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  199. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  200. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  201. package/src/providers/native-history/dispatcher.ts +340 -0
  202. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  203. package/src/providers/native-history/index.ts +30 -0
  204. package/src/providers/provider-instance-manager.ts +30 -0
  205. package/src/providers/provider-instance.ts +10 -1
  206. package/src/providers/provider-loader.ts +592 -50
  207. package/src/providers/provider-schema.ts +87 -14
  208. package/src/providers/provider-trust.ts +114 -0
  209. package/src/providers/read-chat-contract.ts +76 -16
  210. package/src/providers/sdk/README.md +49 -0
  211. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  212. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  213. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  214. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  215. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  216. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  217. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  218. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  219. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  220. package/src/providers/sdk/v1/index.ts +152 -0
  221. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  222. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  223. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  224. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  225. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  226. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  227. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  228. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  229. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  230. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  231. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  232. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  233. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  234. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  235. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  236. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  237. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  238. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  239. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  240. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  241. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  242. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  243. package/src/providers/sdk/v1/validators/index.ts +19 -0
  244. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  245. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  246. package/src/providers/spec/adapter.ts +138 -0
  247. package/src/providers/spec/cli-adapter.ts +674 -0
  248. package/src/providers/spec/driver.ts +1082 -0
  249. package/src/providers/spec/evaluator.ts +604 -0
  250. package/src/providers/spec/fsm-driver.ts +663 -0
  251. package/src/providers/spec/fsm-evaluator.ts +255 -0
  252. package/src/providers/spec/fsm-loader.ts +102 -0
  253. package/src/providers/spec/fsm-types.ts +184 -0
  254. package/src/providers/spec/loader.ts +325 -0
  255. package/src/providers/spec/native-history-executor.ts +939 -0
  256. package/src/providers/spec/route.ts +51 -0
  257. package/src/providers/spec/schema.gen.ts +693 -0
  258. package/src/providers/spec/schema.json +342 -0
  259. package/src/providers/spec/types.ts +365 -0
  260. package/src/providers/transcript-v2.ts +567 -0
  261. package/src/providers/types/interactive-prompt.ts +425 -0
  262. package/src/providers/version-archive.ts +38 -20
  263. package/src/repo-mesh-types.ts +138 -1
  264. package/src/sessions/registry.ts +3 -0
  265. package/src/shared-types-extra.ts +2 -4
  266. package/src/shared-types.ts +45 -1
  267. package/src/status/builders.ts +24 -6
  268. package/src/status/normalize.ts +2 -0
  269. package/src/status/reporter.ts +15 -0
  270. package/src/status/snapshot.ts +84 -25
  271. package/src/system/host-memory.ts +29 -12
  272. package/src/types.ts +5 -0
  273. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  274. package/dist/mesh/mesh-sync.d.ts +0 -53
  275. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  276. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  277. package/src/mesh/mesh-sync.ts +0 -111
@@ -0,0 +1,1082 @@
1
+ /**
2
+ * SpecDriver — the single fixed script that turns a CliSpec + a
3
+ * TerminalAdapter into the dashboard-facing wire protocol.
4
+ *
5
+ * Wire (dashboard ↔ daemon):
6
+ *
7
+ * dashboard → daemon:
8
+ * { kind: 'send_message', text }
9
+ * { kind: 'click_control', control_id, payload? }
10
+ * { kind: 'click_modal_button', index }
11
+ * { kind: 'attach_image', blob, mime }
12
+ * { kind: 'resize', cols, rows }
13
+ * { kind: 'cancel' }
14
+ * { kind: 'shutdown' }
15
+ *
16
+ * daemon → dashboard:
17
+ * { kind: 'pty_data', chunk }
18
+ * { kind: 'state_changed',
19
+ * state: { id, label, title },
20
+ * modal: null | { title, buttons: [{ index, label }] },
21
+ * controls: [{ id, label, action_type }] }
22
+ * { kind: 'notification', id, title, body }
23
+ * { kind: 'delegate', id, task }
24
+ * { kind: 'spec_trace', entries: [...] }
25
+ * { kind: 'exit', exit_code }
26
+ *
27
+ * Hot reload: spec.json is watched. A reload re-evaluates and emits a
28
+ * fresh state_changed; no PTY restart is required.
29
+ *
30
+ * Delegate timers: after_duration_ms is enforced here (the evaluator
31
+ * is stateless). A timer is armed when a delegate's when_state is
32
+ * entered and cancelled when it changes.
33
+ */
34
+ 'use strict';
35
+
36
+ import * as fs from 'node:fs';
37
+ import * as os from 'node:os';
38
+ import * as path from 'node:path';
39
+ import { TerminalAdapter, type TerminalAdapterOpts } from './adapter.js';
40
+ import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
41
+ import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
42
+ import { evaluate, type SpecEvaluation, type TraceEntry } from './evaluator.js';
43
+ import { loadSpec } from './loader.js';
44
+ import type { CliSpec, Condition, ChangedCondition, Control, DelegateTrigger, SectionDef } from './types.js';
45
+ import { LOG } from '../../logging/logger.js';
46
+
47
+ export type DashboardEvent =
48
+ | { kind: 'pty_data'; chunk: string }
49
+ | { kind: 'state_changed'; state: { id: string; label: string; title: string | null };
50
+ modal: { title: string | null; buttons: { index: number; label: string }[] } | null;
51
+ controls: { id: string; label: string; action_type: string }[] }
52
+ | { kind: 'notification'; id: string; title: string; body: string }
53
+ | { kind: 'delegate'; id: string; task: string }
54
+ | { kind: 'spec_trace'; entries: TraceEntry[] }
55
+ | { kind: 'exit'; exit_code: number }
56
+ | { kind: 'spec_error'; errors: string[] };
57
+
58
+ export type DashboardCommand =
59
+ | { kind: 'send_message'; text: string }
60
+ | { kind: 'pty_write'; data: string }
61
+ | { kind: 'click_control'; control_id: string; payload?: unknown }
62
+ | { kind: 'click_modal_button'; index: number }
63
+ | { kind: 'attach_image'; blob: string; mime: string }
64
+ | { kind: 'resize'; cols: number; rows: number }
65
+ | { kind: 'cancel' }
66
+ | { kind: 'shutdown' };
67
+
68
+ /** One state-history entry — the union of fields produced by the v3 SpecDriver
69
+ * (debounce-based) and the v4 FsmDriver (transition-based). The cli-adapter
70
+ * and debug panel read this shape from either driver. */
71
+ export interface DriverHistoryEntry {
72
+ stateId: string;
73
+ label: string;
74
+ at: number;
75
+ durationMs: number;
76
+ reason: string;
77
+ matchedStateId?: string;
78
+ matchedRules?: string[];
79
+ debounceKind?: string;
80
+ idleHoldMs?: number;
81
+ busyHoldMs?: number;
82
+ /** v4: the transition that fired, e.g. "idle→busy". */
83
+ via?: string;
84
+ }
85
+
86
+ /** The surface the cli-adapter drives. Implemented by both SpecDriver (v3,
87
+ * debounce) and FsmDriver (v4, FSM). Lets the adapter hold either without
88
+ * branching on the concrete type. */
89
+ export interface ISpecDriver {
90
+ subscribe(listener: (ev: DashboardEvent) => void): () => void;
91
+ start(): void;
92
+ dispatch(cmd: DashboardCommand): void;
93
+ snapshot(): string;
94
+ getCursorPosition(): { row: number; col: number };
95
+ getScreen(): string;
96
+ getSpecPath(): string;
97
+ shutdown(): void;
98
+ getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
99
+ getSections(): Array<{ id: string; text: string }> | null;
100
+ getLastBusyAt(): number;
101
+ hasIdleHoldPending(): boolean;
102
+ getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
103
+ /** v4 only — present on FsmDriver. Returns the live transition table. */
104
+ getFsmDebug?(): unknown;
105
+ }
106
+
107
+ export interface SpecDriverOpts {
108
+ specPath: string;
109
+ workingDir: string;
110
+ extraEnv?: Record<string, string>;
111
+ cols?: number;
112
+ rows?: number;
113
+ /** Set false to skip the spec.json fs.watch. */
114
+ hotReload?: boolean;
115
+ /** Set true to forward trace entries on every state_changed. */
116
+ emitTrace?: boolean;
117
+ /** Inject the daemon's PTY transport (typically SessionHostPtyTransportFactory). */
118
+ transportFactory?: PtyTransportFactory;
119
+ /**
120
+ * Extra CLI args appended to spec.spawn_args. Used by the daemon to
121
+ * pass per-launch arguments like `--session-id <uuid>` so the agent
122
+ * uses the daemon's providerSessionId instead of generating its own.
123
+ */
124
+ extraCliArgs?: string[];
125
+ }
126
+
127
+ /** How long after start() to ignore "idle" readings before treating one
128
+ * as a real prompt-ready signal. Most TUIs paint a banner + tool list +
129
+ * status line on startup that can briefly score as "no state matches →
130
+ * default idle"; firing a send_keys into that buffer gets the input
131
+ * wiped when the banner clears. 2s covers the slow agents (hermes,
132
+ * agy) without delaying snappy ones (claude/codex) by much. */
133
+ const STARTUP_GRACE_MS = 2500;
134
+
135
+ /** Min time to stay in busy after the evaluator last reports it. Most TUIs
136
+ * flicker their busy spinner on and off as layout reflows (claude streams
137
+ * body text in the same region where the spinner lives, so the spinner
138
+ * disappears mid-response and the footer-only frames score as idle). The
139
+ * hold turns short idle gaps inside a single turn into "still busy", at
140
+ * the cost of looking busy for a couple seconds after a turn actually
141
+ * ends. Pick something long enough to cover the longest claude streaming
142
+ * gap we've observed (~5s between spinner refreshes) without dragging
143
+ * the post-turn idle indication noticeably. */
144
+ const BUSY_HOLD_MS = 6000;
145
+
146
+ /** Minimum delay between the prompt body and the submit_key in send_message.
147
+ * Claude / antigravity specs ship without an explicit delay_ms_before_submit
148
+ * and their TUIs sometimes drop the `\r` event when it arrives in the same
149
+ * PTY chunk as the text body — the prompt sits visible in the input field
150
+ * but is never submitted until the user hits Enter manually. 200ms matches
151
+ * codex's explicit setting and is barely perceptible to a human caller. */
152
+ const SUBMIT_DELAY_FLOOR_MS = 200;
153
+
154
+ function collectChangedConditions(when: Condition | undefined): ChangedCondition[] {
155
+ if (!when) return [];
156
+ if ('cursor_above' in when && 'changed' in when) return [when as ChangedCondition];
157
+ if ('all' in when) return when.all.flatMap(c => collectChangedConditions(c));
158
+ if ('any' in when) return when.any.flatMap(c => collectChangedConditions(c));
159
+ return [];
160
+ }
161
+
162
+ function countNewlines(s: string): number {
163
+ let n = 0;
164
+ for (let i = 0; i < s.length; i += 1) if (s.charCodeAt(i) === 10) n += 1;
165
+ return n;
166
+ }
167
+
168
+ /**
169
+ * Pick the effective delay (ms) to wait between writing the prompt body and
170
+ * writing the submit_key. Exported for tests; production callers go through
171
+ * actuallySendMessage. Floors to SUBMIT_DELAY_FLOOR_MS so specs that omit
172
+ * delay_ms_before_submit (e.g. claude, antigravity) still get baseline
173
+ * protection against the paste→submit race. Adds line-count bonus so
174
+ * multi-line prompts get more settling time on TUIs that re-render per
175
+ * embedded `\n`.
176
+ */
177
+ export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number {
178
+ const lines = countNewlines(text);
179
+ const linesBonus = Math.min(800, lines * 80);
180
+ const spec = typeof specBeforeSubmit === 'number' && specBeforeSubmit > 0 ? specBeforeSubmit : 0;
181
+ return Math.max(spec, SUBMIT_DELAY_FLOOR_MS + linesBonus);
182
+ }
183
+
184
+ export function matchesCompletionIdleRule(spec: CliSpec, ev: SpecEvaluation, screen: string): string | null {
185
+ const rule = spec.debounce?.completion_idle_after;
186
+ if (!rule?.regex) return null;
187
+ const haystack = rule.section
188
+ ? ev.sections.find(section => section.id === rule.section)?.text ?? ''
189
+ : screen;
190
+ if (!haystack) return null;
191
+ try {
192
+ const regex = new RegExp(rule.regex, rule.flags || '');
193
+ const matched = regex.test(haystack);
194
+ // Return the regex pattern as the stable key rather than the match
195
+ // text. The match text often contains a live counter (e.g. "Compacted
196
+ // for 1m 6s") that changes every second, which would reset
197
+ // completionIdleFirstSeenAt on every PTY frame and prevent the hold
198
+ // window from ever expiring.
199
+ return matched ? rule.regex : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ export function matchesCompletionIdleTargetState(
206
+ spec: CliSpec,
207
+ ev: SpecEvaluation,
208
+ screen: string,
209
+ cursor?: { row: number; col: number },
210
+ ): boolean {
211
+ // Test whether the idle/default state's when-condition actually matches
212
+ // the current screen, WITHOUT the default fallback. We don't want to
213
+ // return true just because no other state matched (which is what
214
+ // evaluate() does via its default_state fallback).
215
+ const targetId = spec.default_state ?? 'idle';
216
+ const target = spec.states.find(state => state.id === targetId)
217
+ ?? spec.states.find(state => state.id === 'idle');
218
+ if (!target) return false;
219
+
220
+ // Re-use the resolved sections already computed by evaluate().
221
+ const sections = ev.sections;
222
+ const cleanScreen = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l).join('\n');
223
+
224
+ try {
225
+ // Import the condition evaluator — but we need it as a module-level fn.
226
+ // Instead, call evaluate() and check: if the evaluator chose the target
227
+ // via an *explicit* state match (not fallback), return true.
228
+ // We detect a fallback via trace: the fallback emits "(no state matched".
229
+ const result = evaluate(spec, screen, cursor);
230
+ const wasFallback = result.trace.some(t => t.text.startsWith('(no state matched'));
231
+ return result.state.id === targetId && !wasFallback;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ export class SpecDriver implements ISpecDriver {
238
+ private spec!: CliSpec;
239
+ private adapter!: TerminalAdapter;
240
+ private listeners = new Set<(ev: DashboardEvent) => void>();
241
+ private currentStateId: string | null = null;
242
+ /** Have we ever seen the spec's idle state *after* the startup grace
243
+ * window? Until we do, the agent's startup banner may still be
244
+ * painting and any send_message we forward to the PTY will be wiped
245
+ * when the banner clears the screen. Queue + drain on first valid
246
+ * idle. */
247
+ private idleSeenOnce = false;
248
+ private startedAtMs = 0;
249
+ private pendingSends: string[] = [];
250
+ private currentEval: SpecEvaluation | null = null;
251
+ private pickerInProgress: { control_id: string; phase: 'waiting'; spec: Control } | null = null;
252
+ private delegateTimers = new Map<string, ReturnType<typeof setTimeout>>();
253
+ /** Timestamp of the last time the evaluator returned busy. Used to debounce
254
+ * the busy → idle transition (see reevaluate). */
255
+ private lastBusyAt = 0;
256
+ /** The exact busy state object we last saw — held alongside lastBusyAt so
257
+ * the hold can re-emit the same { id: 'busy', label, title } payload the
258
+ * dashboard already learned about. currentEval can't fill this role
259
+ * because the evaluator already moved past busy by the time the hold
260
+ * kicks in. */
261
+ private lastBusyState: SpecEvaluation['state'] | null = null;
262
+ /** Timestamp of the last time we entered a modal state (approval/picker or
263
+ * any non-busy non-idle state). Used to suppress brief busy blips that
264
+ * appear while the modal is still on screen — Claude Code streams body
265
+ * text that transiently shows a spinner even while an approval modal is
266
+ * visible, causing rapid approval→busy→approval flicker on the dashboard. */
267
+ private lastModalAt = 0;
268
+ /** The modal state snapshot held across busy blips. */
269
+ private lastModalState: SpecEvaluation['state'] | null = null;
270
+ /** Timestamp of when we last *exited* a modal state (approval/picker → idle
271
+ * or directly via completion_idle_after). Used to suppress completion_idle_after
272
+ * firings that were queued before the modal appeared and expire immediately
273
+ * after the modal is dismissed — without this the agent appears idle even
274
+ * though it is still generating. */
275
+ private lastModalExitAt = 0;
276
+ private completionIdleFirstSeenAt = 0;
277
+ private completionIdleKey = '';
278
+ /** Previous screen lines — passed to evaluate() for `changed` condition detection. */
279
+ private prevScreenLines: string[] = [];
280
+ /** Timestamp when idle was last committed (either direct or via idle_hold_ms).
281
+ * Used to suppress immediate idle → busy re-entry from transient `changed`
282
+ * condition blips (e.g. completion-marker counter "Completed for Xs" updating
283
+ * every second, which triggers cursor_above:changed and bounces back to busy
284
+ * right after an idle commit). */
285
+ private lastIdleCommittedAt = 0;
286
+ /** Timestamp of the last PTY frame that changed the screen content.
287
+ * Used by screen_active_hold_ms to suppress idle downshifts while
288
+ * the terminal is still actively updating. */
289
+ private lastScreenChangedAt = 0;
290
+ /** Per-cursor_above region last-changed timestamps for stable_ms tracking.
291
+ * Key: cursor_above value. Value: last time that region changed. */
292
+ private regionLastChangedAt = new Map<number, number>();
293
+ /** Timer that re-runs evaluate() once the hold window expires. Needed
294
+ * because the PTY stops emitting once the agent finishes; without an
295
+ * explicit wake-up there's nothing to trigger the busy → idle
296
+ * downshift. */
297
+ private busyExpiryTimer: ReturnType<typeof setTimeout> | null = null;
298
+ /** Set true while reevaluate() is invoked from busyExpiryTimer callback. */
299
+ private busyExpiryFired = false;
300
+ /** Pending idle-commit timer. Armed when the evaluator first returns idle;
301
+ * fires after idle_hold_ms if no non-idle reading has cancelled it. */
302
+ private idleHoldTimer: ReturnType<typeof setTimeout> | null = null;
303
+ /** State snapshot captured when the idle hold was armed — emitted on commit. */
304
+ private pendingIdleState: SpecEvaluation['state'] | null = null;
305
+ private specWatcher: fs.FSWatcher | null = null;
306
+ /** Ring buffer of committed state transitions (max 50). */
307
+ private stateHistory: Array<{
308
+ stateId: string;
309
+ label: string;
310
+ at: number;
311
+ durationMs: number;
312
+ reason: string;
313
+ matchedStateId?: string;
314
+ matchedRules?: string[];
315
+ debounceKind?: string;
316
+ idleHoldMs?: number;
317
+ busyHoldMs?: number;
318
+ }> = [];
319
+ private prevStateAt = 0;
320
+
321
+ constructor(private readonly opts: SpecDriverOpts) {
322
+ this.loadSpecOrThrow();
323
+ this.adapter = new TerminalAdapter(
324
+ this.buildAdapterOpts(),
325
+ {
326
+ init: () => this.emitInitialState(),
327
+ on_pty_data: (chunk) => this.emit({ kind: 'pty_data', chunk }),
328
+ on_screen_changed: () => this.reevaluate(),
329
+ on_exit: ({ exitCode }) => this.handleExit(exitCode),
330
+ },
331
+ );
332
+ if (this.opts.hotReload !== false) this.armSpecWatcher();
333
+ }
334
+
335
+ /** Subscribe to outbound events. Returns an unsubscribe fn. */
336
+ subscribe(listener: (ev: DashboardEvent) => void): () => void {
337
+ this.listeners.add(listener);
338
+ return () => { this.listeners.delete(listener); };
339
+ }
340
+
341
+ start(): void {
342
+ this.startedAtMs = Date.now();
343
+ this.adapter.start();
344
+ // Make sure we always re-evaluate once the startup grace has
345
+ // expired. Without an explicit wake-up the PTY can go quiet
346
+ // immediately after the banner paints (agy: the screen stays at
347
+ // the input prompt forever), there's no on_screen_changed event
348
+ // to trigger reevaluate, and any send_message we queued during
349
+ // the grace window would never drain.
350
+ const graceMs = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
351
+ setTimeout(() => this.reevaluate(), graceMs + 100);
352
+ }
353
+
354
+ dispatch(cmd: DashboardCommand): void {
355
+ switch (cmd.kind) {
356
+ case 'send_message': this.handleSendMessage(cmd.text); return;
357
+ case 'pty_write': this.adapter.send_keys(cmd.data); return;
358
+ case 'click_control': this.handleClickControl(cmd.control_id, cmd.payload); return;
359
+ case 'click_modal_button': this.handleClickModalButton(cmd.index); return;
360
+ case 'attach_image': this.handleAttachImage(cmd.blob, cmd.mime); return;
361
+ case 'resize': this.adapter.resize(cmd.cols, cmd.rows); return;
362
+ case 'cancel': this.lastIdleCommittedAt = 0; this.adapter.send_keys('\x03'); return;
363
+ case 'shutdown': this.shutdown(); return;
364
+ }
365
+ }
366
+
367
+ snapshot(): string {
368
+ return this.adapter.snapshot();
369
+ }
370
+
371
+ getCursorPosition(): { row: number; col: number } {
372
+ return this.adapter.getCursorPosition();
373
+ }
374
+
375
+ shutdown(): void {
376
+ for (const t of this.delegateTimers.values()) clearTimeout(t);
377
+ this.delegateTimers.clear();
378
+ this.cancelIdleHold();
379
+ if (this.busyExpiryTimer) { clearTimeout(this.busyExpiryTimer); this.busyExpiryTimer = null; }
380
+ this.specWatcher?.close();
381
+ this.adapter.kill();
382
+ }
383
+
384
+ private cancelIdleHold(): void {
385
+ if (this.idleHoldTimer) {
386
+ clearTimeout(this.idleHoldTimer);
387
+ this.idleHoldTimer = null;
388
+ // If the idle hold was armed by a completion_idle_after forced-idle,
389
+ // cancelling it means new activity arrived while we were waiting.
390
+ // Reset the completion tracker so the hold window restarts fresh
391
+ // when the marker reappears — otherwise the stale firstSeenAt makes
392
+ // the hold appear already-expired on the next PTY frame that matches.
393
+ this.completionIdleKey = '';
394
+ this.completionIdleFirstSeenAt = 0;
395
+ }
396
+ this.pendingIdleState = null;
397
+ }
398
+
399
+ private pushHistory(stateId: string, label: string, meta?: {
400
+ reason?: string;
401
+ matchedStateId?: string;
402
+ matchedRules?: string[];
403
+ debounceKind?: string;
404
+ idleHoldMs?: number;
405
+ busyHoldMs?: number;
406
+ }): void {
407
+ const now = Date.now();
408
+ const durationMs = this.prevStateAt > 0 ? now - this.prevStateAt : 0;
409
+ this.prevStateAt = now;
410
+ this.stateHistory.push({
411
+ stateId,
412
+ label,
413
+ at: now,
414
+ durationMs,
415
+ reason: meta?.reason ?? 'eval_match',
416
+ ...(meta?.matchedStateId !== undefined ? { matchedStateId: meta.matchedStateId } : {}),
417
+ ...(meta?.matchedRules !== undefined ? { matchedRules: meta.matchedRules } : {}),
418
+ ...(meta?.debounceKind !== undefined ? { debounceKind: meta.debounceKind } : {}),
419
+ ...(meta?.idleHoldMs !== undefined ? { idleHoldMs: meta.idleHoldMs } : {}),
420
+ ...(meta?.busyHoldMs !== undefined ? { busyHoldMs: meta.busyHoldMs } : {}),
421
+ });
422
+ if (this.stateHistory.length > 50) this.stateHistory.shift();
423
+ }
424
+
425
+ getStateHistory(): ReadonlyArray<{
426
+ stateId: string;
427
+ label: string;
428
+ at: number;
429
+ durationMs: number;
430
+ reason: string;
431
+ matchedStateId?: string;
432
+ matchedRules?: string[];
433
+ debounceKind?: string;
434
+ idleHoldMs?: number;
435
+ busyHoldMs?: number;
436
+ }> {
437
+ return this.stateHistory;
438
+ }
439
+
440
+ getLastBusyAt(): number { return this.lastBusyAt; }
441
+ hasIdleHoldPending(): boolean { return this.idleHoldTimer !== null; }
442
+ getSpecPath(): string { return this.opts.specPath; }
443
+ getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null {
444
+ if (!this.completionIdleKey || !this.completionIdleFirstSeenAt) return null;
445
+ const rule = this.spec.debounce?.completion_idle_after;
446
+ if (!rule) return null;
447
+ return {
448
+ active: true,
449
+ ageMs: Date.now() - this.completionIdleFirstSeenAt,
450
+ holdMs: rule.hold_ms ?? 0,
451
+ forceAfterMs: typeof rule.force_after_ms === 'number' ? rule.force_after_ms : 0,
452
+ };
453
+ }
454
+ getScreen(): string { return this.adapter.snapshot(); }
455
+ getSections(): Array<{ id: string; text: string }> | null {
456
+ try {
457
+ const screen = this.adapter.snapshot();
458
+ const ev = evaluate(this.spec, screen, undefined);
459
+ return ev.sections.map(s => ({ id: s.id, text: s.text }));
460
+ } catch { return null; }
461
+ }
462
+
463
+ // ────────────────────────────────────────────────────────────────────
464
+ // Loading & adapter wiring
465
+ // ────────────────────────────────────────────────────────────────────
466
+
467
+ private loadSpecOrThrow(): void {
468
+ const res = loadSpec(this.opts.specPath);
469
+ if (!res.ok) throw new Error(`spec invalid: ${res.errors.join('; ')}`);
470
+ this.spec = res.spec;
471
+ }
472
+
473
+ private buildAdapterOpts(): TerminalAdapterOpts {
474
+ const baseArgs = this.spec.spawn_args ?? [];
475
+ const extra = this.opts.extraCliArgs ?? [];
476
+ return {
477
+ binary: this.spec.binary,
478
+ args: [...baseArgs, ...extra],
479
+ cwd: this.opts.workingDir,
480
+ env: { ...(this.spec.env ?? {}), ...(this.opts.extraEnv ?? {}) },
481
+ cols: this.opts.cols ?? DEFAULT_SESSION_HOST_COLS,
482
+ rows: this.opts.rows ?? DEFAULT_SESSION_HOST_ROWS,
483
+ transportFactory: this.opts.transportFactory,
484
+ };
485
+ }
486
+
487
+ private armSpecWatcher(): void {
488
+ try {
489
+ // Watch the parent directory so we catch atomic replacements
490
+ // (cp, install scripts) that create a new inode — a file-level
491
+ // watch misses those on macOS because the original inode is gone.
492
+ const dir = path.dirname(this.opts.specPath);
493
+ const base = path.basename(this.opts.specPath);
494
+ this.specWatcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
495
+ if (filename && filename !== base) return;
496
+ const res = loadSpec(this.opts.specPath);
497
+ if (!res.ok) { this.emit({ kind: 'spec_error', errors: res.errors }); return; }
498
+ this.spec = res.spec;
499
+ this.reevaluate(/*forceEmit*/ true);
500
+ });
501
+ } catch { /* watch is best-effort */ }
502
+ }
503
+
504
+ // ────────────────────────────────────────────────────────────────────
505
+ // Evaluation pipeline
506
+ // ────────────────────────────────────────────────────────────────────
507
+
508
+ private emitInitialState(): void {
509
+ // Start with an empty-screen evaluation so the dashboard immediately
510
+ // knows about the default state.
511
+ this.reevaluate(true);
512
+ }
513
+
514
+ /** Re-arm the timer that wakes the driver up after BUSY_HOLD_MS so it
515
+ * can decide whether to downshift to idle. Always uses the most recent
516
+ * hold value so a spec hot-reload that shortens the hold takes effect
517
+ * on the next busy entry. Safe to call repeatedly; only the last call
518
+ * fires. */
519
+ private scheduleBusyExpiry(holdMs: number): void {
520
+ if (this.busyExpiryTimer) clearTimeout(this.busyExpiryTimer);
521
+ this.busyExpiryTimer = setTimeout(() => {
522
+ this.busyExpiryTimer = null;
523
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] busyExpiry fired holdMs=${holdMs}`);
524
+ this.busyExpiryFired = true;
525
+ this.reevaluate();
526
+ this.busyExpiryFired = false;
527
+ }, Math.max(holdMs + 50, 100));
528
+ }
529
+
530
+ private reevaluate(forceEmit = false): void {
531
+ const screen = this.adapter.snapshot();
532
+ const cursor = this.adapter.getCursorPosition();
533
+ const currentLines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
534
+ const ev = evaluate(this.spec, screen, cursor, this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined);
535
+ const now = Date.now();
536
+ // Track when the screen last changed for screen_active_hold_ms.
537
+ if (this.prevScreenLines.length > 0 && currentLines.join('\n') !== this.prevScreenLines.join('\n')) {
538
+ this.lastScreenChangedAt = now;
539
+ }
540
+ // Track per-region last-changed timestamps for stable_ms conditions.
541
+ if (cursor && this.prevScreenLines.length > 0) {
542
+ for (const state of this.spec.states) {
543
+ const conditions = collectChangedConditions(state.when);
544
+ for (const cond of conditions) {
545
+ if (cond.stable_ms == null) continue;
546
+ const startRow = Math.max(0, cursor.row - cond.cursor_above);
547
+ const endRow = cursor.row;
548
+ const cur = currentLines.slice(startRow, endRow).join('\n');
549
+ const prev = this.prevScreenLines.slice(startRow, endRow).join('\n');
550
+ if (cur !== prev) this.regionLastChangedAt.set(cond.cursor_above, now);
551
+ }
552
+ }
553
+ }
554
+ // Update prevScreenLines for next evaluation's `changed` condition detection.
555
+ this.prevScreenLines = currentLines;
556
+
557
+ // Busy hold: many TUIs flicker between busy and idle every frame
558
+ // (claude in particular — its token counter appears and disappears
559
+ // as the layout reflows). Once we see busy, hold that state for
560
+ // BUSY_HOLD_MS before allowing a downshift to idle. Any state
561
+ // *other* than idle clears the hold immediately (approval is
562
+ // strictly more interesting than busy, so it shouldn't be held
563
+ // back). The hold lives on the driver, not the evaluator, so the
564
+ // spec author doesn't have to think about debouncing.
565
+ let evState = ev.state;
566
+ const busyHoldMs = this.spec.debounce?.busy_hold_ms ?? BUSY_HOLD_MS;
567
+ if (this.currentStateId === 'busy' && evState.id === 'idle') {
568
+ const ageMs = now - this.lastBusyAt;
569
+ if (ageMs < busyHoldMs) {
570
+ // Pin to the last seen busy state directly — currentEval can
571
+ // already be idle at this point (it tracks the previous tick,
572
+ // which during the flicker is just as likely to be idle as
573
+ // busy), so we can't rely on it to recover the busy label.
574
+ evState = this.lastBusyState ?? evState;
575
+ }
576
+ }
577
+ // Startup grace: suppress idle→busy transitions during the banner-paint
578
+ // window. The spec author sets startup_grace_ms to cover the time the
579
+ // terminal spends drawing its initial screen; any busy signal during
580
+ // that window is noise (cursor_above:changed, layout reflow, etc.).
581
+ const graceMs = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
582
+ const sinceStartMs = now - this.startedAtMs;
583
+ // Startup grace: suppress all busy transitions during the banner-paint
584
+ // window regardless of current state.
585
+ if (sinceStartMs < graceMs && evState.id === 'busy') {
586
+ LOG.info('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] startup grace suppressed busy (sinceStart=${sinceStartMs}ms grace=${graceMs}ms)`);
587
+ evState = { id: this.spec.default_state ?? 'idle', label: 'Ready', title: null };
588
+ this.scheduleBusyExpiry(graceMs - sinceStartMs + 50);
589
+ }
590
+ // stable_ms gate: if the matched idle state has a changed:false/stable_ms
591
+ // condition, verify the region has been stable long enough. If not, pin
592
+ // to busy and schedule a re-evaluation when the stable window expires.
593
+ if (evState.id === (this.spec.default_state ?? 'idle') && cursor) {
594
+ const stableConditions = collectChangedConditions(
595
+ this.spec.states.find(s => s.id === evState.id)?.when
596
+ ).filter(c => c.changed === false && c.stable_ms != null);
597
+ for (const cond of stableConditions) {
598
+ const lastChanged = this.regionLastChangedAt.get(cond.cursor_above) ?? 0;
599
+ const stableMs = cond.stable_ms!;
600
+ const stableAge = lastChanged > 0 ? now - lastChanged : Infinity;
601
+ if (stableAge < stableMs) {
602
+ evState = this.lastBusyState ?? { id: 'busy', label: 'Generating', title: null };
603
+ this.scheduleBusyExpiry(stableMs - stableAge + 50);
604
+ break;
605
+ }
606
+ }
607
+ }
608
+ // Modal hold: when in a modal state (approval, picker, etc.) a brief
609
+ // busy reading should not interrupt the modal. Claude Code streams
610
+ // body content while the approval modal is visible, causing a spinner
611
+ // to appear transiently — without this hold, the dashboard sees a
612
+ // rapid modal→busy→modal flicker and the approval UI disappears and
613
+ // reappears every few seconds. Apply the same busy_hold_ms window:
614
+ // if the modal was entered recently and the evaluator now returns
615
+ // busy, stay in the modal state until the hold expires or a non-busy
616
+ // non-modal reading arrives.
617
+ const idleStateId = this.spec.default_state ?? 'idle';
618
+ const isModalState = (id: string | null) =>
619
+ id !== null && id !== 'busy' && id !== idleStateId;
620
+ if (isModalState(this.currentStateId) && evState.id === 'busy') {
621
+ const ageMs = Date.now() - this.lastModalAt;
622
+ if (ageMs < busyHoldMs && this.lastModalState) {
623
+ evState = this.lastModalState;
624
+ }
625
+ }
626
+ const completionIdleRule = this.spec.debounce?.completion_idle_after;
627
+ const screenActiveHoldMs = this.spec.debounce?.screen_active_hold_ms;
628
+ let busyWakeMs = busyHoldMs;
629
+ // Don't fire completion_idle_after while in a modal state (approval,
630
+ // picker, etc.) or within a grace period after leaving one. Two cases:
631
+ // 1. Modal still active: the hold window may have expired so evState
632
+ // resolves to busy, but the user is still looking at the approval screen.
633
+ // 2. Modal just dismissed: the timer may have been queued *before* the
634
+ // approval appeared and its hold expires immediately after dismissal,
635
+ // causing a false-idle even though the agent is still generating.
636
+ const postModalGraceMs = busyHoldMs;
637
+ const recentlyInModal = isModalState(this.currentStateId) || (this.lastModalAt > 0 && now - this.lastModalAt < postModalGraceMs);
638
+ const recentlyLeftModal = !recentlyInModal && this.lastModalExitAt > 0 && now - this.lastModalExitAt < postModalGraceMs;
639
+ // screen_active_hold_ms: suppress idle downshift while the screen is
640
+ // actively changing. Any PTY frame that mutates screen content resets
641
+ // lastScreenChangedAt; we hold off until it has been stable for the
642
+ // configured duration. Applies to both completion_idle_after and the
643
+ // busy_hold expiry path below.
644
+ const screenActiveMs = screenActiveHoldMs ?? 0;
645
+ const screenStableMs = this.lastScreenChangedAt > 0 ? now - this.lastScreenChangedAt : Infinity;
646
+ const screenIsActive = screenActiveMs > 0 && screenStableMs < screenActiveMs;
647
+ if (screenIsActive) {
648
+ busyWakeMs = Math.min(busyWakeMs, screenActiveMs - screenStableMs + 50);
649
+ }
650
+ if (evState.id === 'busy' && completionIdleRule && !recentlyInModal && !recentlyLeftModal && !screenIsActive) {
651
+ const completionKey = matchesCompletionIdleRule(this.spec, ev, screen);
652
+ if (completionKey) {
653
+ if (completionKey !== this.completionIdleKey) {
654
+ this.completionIdleKey = completionKey;
655
+ this.completionIdleFirstSeenAt = now;
656
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] completion_idle_after matched: key="${completionKey}"`);
657
+ }
658
+ const holdMs = Math.max(0, completionIdleRule.hold_ms || 0);
659
+ const forceAfterMs = typeof completionIdleRule.force_after_ms === 'number'
660
+ ? completionIdleRule.force_after_ms
661
+ : null;
662
+ const ageMs = now - this.completionIdleFirstSeenAt;
663
+ if (ageMs >= holdMs) {
664
+ const targetMatches = matchesCompletionIdleTargetState(this.spec, ev, screen, cursor);
665
+ const forced = !targetMatches && forceAfterMs !== null && ageMs >= holdMs + forceAfterMs;
666
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] completion_idle_after hold expired ageMs=${ageMs} targetState=${targetMatches} forced=${forced} screenTail="${screen.split(/\r?\n/).slice(-3).join('\\n').slice(-200)}"`);
667
+ if (targetMatches || forced) {
668
+ const idle = this.spec.states.find(state => state.id === this.spec.default_state)
669
+ ?? this.spec.states.find(state => state.id === 'idle');
670
+ evState = idle
671
+ ? { id: idle.id, label: idle.label, title: null }
672
+ : { id: 'idle', label: 'Ready', title: null };
673
+ } else {
674
+ busyWakeMs = Math.min(busyWakeMs, 1000);
675
+ }
676
+ } else {
677
+ busyWakeMs = Math.min(busyWakeMs, Math.max(holdMs - ageMs, 0));
678
+ }
679
+ } else {
680
+ this.completionIdleKey = '';
681
+ this.completionIdleFirstSeenAt = 0;
682
+ }
683
+ } else if (evState.id !== 'busy') {
684
+ if (!screenIsActive) {
685
+ this.completionIdleKey = '';
686
+ this.completionIdleFirstSeenAt = 0;
687
+ }
688
+ }
689
+
690
+ // screen_active_hold_ms: if the screen is still changing and the
691
+ // evaluator wants to downshift from busy to idle, pin to busy instead.
692
+ // This catches paths not covered by the completion_idle_after gate above
693
+ // (e.g. direct idle via busy_hold expiry or default-state fallback).
694
+ if (screenIsActive && this.currentStateId === 'busy' && evState.id === (this.spec.default_state ?? 'idle')) {
695
+ evState = this.lastBusyState ?? evState;
696
+ }
697
+
698
+ // Idle → busy re-entry hold: suppress false-busy transitions that fire
699
+ // immediately after an idle commit. The most common source is the
700
+ // `cursor_above: 3, changed: true` condition matching tiny updates
701
+ // (e.g. "✻ Completed for Ns" counter ticking once per second) right
702
+ // after the completion hold expired and idle was committed. Without
703
+ // this guard the dashboard bounces back to generating within 1s of
704
+ // seeing idle.
705
+ //
706
+ // Guard window: prefer screen_active_hold_ms (already the "micro-change
707
+ // suppressor" window), floor to 1500ms so specs that omit it still
708
+ // get protection. This is intentionally shorter than busy_hold_ms —
709
+ // a real new generation (user types a message) will produce many
710
+ // more decisive signals (spinner, token counter, esc-to-interrupt)
711
+ // that override the hold long before it expires.
712
+ const idleReentryHoldMs = Math.max(screenActiveMs, 1500);
713
+ if (evState.id === 'busy' && this.currentStateId === (this.spec.default_state ?? 'idle')
714
+ && this.lastIdleCommittedAt > 0
715
+ && (now - this.lastIdleCommittedAt) < idleReentryHoldMs) {
716
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] idle→busy suppressed (idle_reentry_hold ageMs=${now - this.lastIdleCommittedAt} holdMs=${idleReentryHoldMs})`);
717
+ this.scheduleBusyExpiry(idleReentryHoldMs - (now - this.lastIdleCommittedAt) + 50);
718
+ return;
719
+ }
720
+
721
+ if (evState.id === 'busy') {
722
+ this.lastBusyAt = Date.now();
723
+ this.lastBusyState = evState;
724
+ // Cancel any pending idle commit — non-idle reading invalidates it.
725
+ this.cancelIdleHold();
726
+ // Reset completion_idle_after tracking only when the completion
727
+ // marker is NOT present. If the marker is on screen (key is set
728
+ // by the block above), clearing it here would restart the hold
729
+ // window on every PTY frame and the hold would never expire.
730
+ // Only reset when the marker disappeared (key is empty), meaning
731
+ // a new tool-output burst arrived that pushed the marker off
732
+ // screen — in that case the old firstSeenAt is stale and should
733
+ // restart when the marker reappears.
734
+ //
735
+ // Also reset on busy re-entry (transitioning back from a non-busy
736
+ // state). The previous generation may have set completionIdleKey
737
+ // and firstSeenAt; without this reset the hold window appears
738
+ // instantly expired on the new generation's first PTY frame that
739
+ // re-matches the same completion regex key, causing a false-idle
740
+ // before the new generation has a chance to run.
741
+ if (!this.completionIdleKey || this.currentStateId !== 'busy') {
742
+ this.completionIdleKey = '';
743
+ this.completionIdleFirstSeenAt = 0;
744
+ }
745
+ // Schedule a re-evaluation when the hold window expires. PTYs
746
+ // typically stop emitting once the agent stops printing (the
747
+ // footer settles), so without an explicit timer the driver
748
+ // never wakes up to downshift to idle and the dashboard sees
749
+ // status stuck at generating long after the turn ended.
750
+ this.scheduleBusyExpiry(busyWakeMs);
751
+ } else if (evState.id !== this.currentStateId && evState.id !== 'busy') {
752
+ // Non-busy modal states (approval, picker, signing_in) also cancel
753
+ // any in-flight idle hold — they are higher-priority than idle.
754
+ if (evState.id !== (this.spec.default_state ?? 'idle')) {
755
+ this.cancelIdleHold();
756
+ }
757
+ // Track the modal entry timestamp so the modal-hold above can
758
+ // suppress brief busy blips while the modal is still on screen.
759
+ if (isModalState(evState.id)) {
760
+ this.lastModalAt = Date.now();
761
+ this.lastModalState = evState;
762
+ } else {
763
+ // Leaving modal territory (going to idle) — clear the hold,
764
+ // but record the exit time so completion_idle_after can be
765
+ // suppressed during the grace period after dismissal.
766
+ if (this.lastModalAt > 0) this.lastModalExitAt = Date.now();
767
+ this.lastModalAt = 0;
768
+ this.lastModalState = null;
769
+ }
770
+ }
771
+
772
+ // Idle hold: if idle_hold_ms is set, don't commit idle immediately.
773
+ // Arm a timer; if a non-idle reading arrives before it fires, cancel.
774
+ const idleHoldMs = this.spec.debounce?.idle_hold_ms ?? 0;
775
+ const isIdleState = evState.id === (this.spec.default_state ?? 'idle');
776
+ if (isIdleState && idleHoldMs > 0 && this.currentStateId !== evState.id) {
777
+ if (!this.idleHoldTimer) {
778
+ this.pendingIdleState = evState;
779
+ const capturedEv = ev;
780
+ const capturedMatchedRules = extractMatchedRules(ev);
781
+ this.idleHoldTimer = setTimeout(() => {
782
+ this.idleHoldTimer = null;
783
+ const committed = this.pendingIdleState;
784
+ this.pendingIdleState = null;
785
+ if (!committed) return;
786
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] idleHold committed after ${idleHoldMs}ms`);
787
+ this.lastIdleCommittedAt = Date.now();
788
+ this.currentStateId = committed.id;
789
+ this.currentEval = capturedEv;
790
+ this.pushHistory(committed.id, committed.label, {
791
+ reason: 'idle_hold_committed',
792
+ matchedStateId: capturedEv.state.id,
793
+ matchedRules: capturedMatchedRules,
794
+ debounceKind: 'idle_hold',
795
+ idleHoldMs,
796
+ });
797
+ this.emit({
798
+ kind: 'state_changed',
799
+ state: committed,
800
+ modal: null,
801
+ controls: capturedEv.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
802
+ });
803
+ this.armOrCancelDelegateTimers(committed.id);
804
+ if (this.opts.emitTrace) this.emit({ kind: 'spec_trace', entries: capturedEv.trace });
805
+ }, idleHoldMs);
806
+ }
807
+ // Don't fall through to the normal changed/emit path for idle.
808
+ this.currentEval = ev;
809
+ const graceMs2 = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
810
+ if (!this.idleSeenOnce && Date.now() - this.startedAtMs >= graceMs2) {
811
+ this.idleSeenOnce = true;
812
+ const queued = this.pendingSends.splice(0);
813
+ for (const text of queued) setTimeout(() => this.actuallySendMessage(text), 50);
814
+ }
815
+ if (this.pickerInProgress) this.tryAdvancePicker(screen);
816
+ return;
817
+ }
818
+
819
+ const changed = forceEmit
820
+ || evState.id !== this.currentStateId
821
+ || !shallowSameModal(ev, this.currentEval)
822
+ || !shallowSameControls(ev, this.currentEval);
823
+
824
+ // Picker state tracking — if a picker is waiting for its
825
+ // wait_for cue and now sees it, complete the picker flow.
826
+ if (this.pickerInProgress) this.tryAdvancePicker(screen);
827
+
828
+ this.currentEval = ev;
829
+ // First time we ever see idle (or any non-busy non-startup state),
830
+ // flush any queued send_message calls that arrived before the
831
+ // agent finished painting its banner. Subsequent idle/busy
832
+ // toggles don't retrigger.
833
+ // Wait at least the spec's startup_grace_ms after start() so we
834
+ // don't treat a transient "matches no state, default idle"
835
+ // reading during the banner paint as a real idle. After that,
836
+ // the first non-busy observation is a real prompt-ready signal
837
+ // and we drain any queued send_message calls.
838
+ const sinceStart = now - this.startedAtMs;
839
+ if (!this.idleSeenOnce && evState.id !== 'busy' && sinceStart >= graceMs) {
840
+ this.idleSeenOnce = true;
841
+ const queued = this.pendingSends.splice(0);
842
+ for (const text of queued) {
843
+ setTimeout(() => this.actuallySendMessage(text), 50);
844
+ }
845
+ }
846
+ if (changed) {
847
+ if (evState.id === (this.spec.default_state ?? 'idle')) {
848
+ this.lastIdleCommittedAt = Date.now();
849
+ }
850
+ this.currentStateId = evState.id;
851
+ const matchedRules = extractMatchedRules(ev);
852
+ // Determine reason and debounce kind for this transition
853
+ let transitionReason: string;
854
+ let debounceKind: string;
855
+ let transitionBusyHoldMs: number | undefined;
856
+ if (forceEmit) {
857
+ transitionReason = 'forceEmit';
858
+ debounceKind = 'none';
859
+ } else if (evState.id !== ev.state.id) {
860
+ // evState was overridden — completion_idle_after forced idle
861
+ transitionReason = 'completion_idle_after';
862
+ debounceKind = 'completion_idle_after';
863
+ } else if (this.busyExpiryFired) {
864
+ // busyExpiryTimer woke us up and hold has now expired
865
+ transitionReason = 'busy_hold_expired';
866
+ debounceKind = 'busy_hold';
867
+ transitionBusyHoldMs = busyHoldMs;
868
+ } else {
869
+ transitionReason = 'eval_match';
870
+ debounceKind = 'none';
871
+ }
872
+ this.pushHistory(evState.id, evState.label, {
873
+ reason: transitionReason,
874
+ matchedStateId: ev.state.id,
875
+ matchedRules,
876
+ debounceKind,
877
+ ...(transitionBusyHoldMs !== undefined ? { busyHoldMs: transitionBusyHoldMs } : {}),
878
+ });
879
+ this.emit({
880
+ kind: 'state_changed',
881
+ state: evState,
882
+ modal: ev.modal ? { title: ev.modal.title, buttons: ev.modal.buttons.map(b => ({ index: b.index, label: b.label })) } : null,
883
+ controls: ev.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
884
+ });
885
+ for (const n of ev.notifications) this.emit({ kind: 'notification', id: n.id, title: n.title, body: n.body });
886
+ this.armOrCancelDelegateTimers(evState.id);
887
+ if (this.opts.emitTrace) this.emit({ kind: 'spec_trace', entries: ev.trace });
888
+ }
889
+ }
890
+
891
+ private armOrCancelDelegateTimers(currentStateId: string): void {
892
+ for (const d of this.spec.delegate ?? []) {
893
+ const armed = this.delegateTimers.has(d.id);
894
+ const shouldFire = d.when_state === currentStateId;
895
+ if (shouldFire && !armed) {
896
+ const delay = d.after_duration_ms ?? 0;
897
+ const t = setTimeout(() => { this.fireDelegate(d); this.delegateTimers.delete(d.id); }, delay);
898
+ this.delegateTimers.set(d.id, t);
899
+ } else if (!shouldFire && armed) {
900
+ clearTimeout(this.delegateTimers.get(d.id)!);
901
+ this.delegateTimers.delete(d.id);
902
+ }
903
+ }
904
+ }
905
+
906
+ private fireDelegate(d: DelegateTrigger): void {
907
+ const ev = this.currentEval;
908
+ const task = (d.task_template)
909
+ .replace(/\{node\}/g, os.hostname())
910
+ .replace(/\{state\.label\}/g, ev?.state.label ?? '')
911
+ .replace(/\{state\.title\}/g, ev?.state.title ?? '')
912
+ .replace(/\{duration_ms\}/g, String(d.after_duration_ms ?? 0));
913
+ this.emit({ kind: 'delegate', id: d.id, task });
914
+ }
915
+
916
+ // ────────────────────────────────────────────────────────────────────
917
+ // Dashboard commands
918
+ // ────────────────────────────────────────────────────────────────────
919
+
920
+ private handleSendMessage(text: string): void {
921
+ if (!this.idleSeenOnce) {
922
+ // Agent is still drawing its startup banner. Queue and drain
923
+ // when we see idle so we don't fire keystrokes into a buffer
924
+ // that's about to be cleared.
925
+ this.pendingSends.push(text);
926
+ return;
927
+ }
928
+ // Clear the idle re-entry hold so a new generation starting
929
+ // immediately after idle doesn't get its busy state suppressed.
930
+ this.lastIdleCommittedAt = 0;
931
+ this.actuallySendMessage(text);
932
+ }
933
+
934
+ private actuallySendMessage(text: string): void {
935
+ const sm = this.spec.send_message;
936
+ const perChar = sm.delay_ms_per_char ?? 0;
937
+ // Floor the gap between text and submit_key. Without this, claude /
938
+ // antigravity specs (which leave delay_ms_before_submit unset)
939
+ // race: text bytes and `\r` arrive back-to-back, the TUI processes
940
+ // the `\r` while still digesting the text input, and the prompt
941
+ // sits visible in the input field but never submits — the user has
942
+ // to press Enter manually. Scale with line count so multi-line
943
+ // pastes (which take longer for the TUI to render) get more
944
+ // settling time.
945
+ const beforeSubmit = resolveSubmitDelayMs(sm.delay_ms_before_submit, text);
946
+ if (perChar === 0) {
947
+ this.adapter.send_keys(text);
948
+ if (beforeSubmit > 0) setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
949
+ else this.adapter.send_keys(sm.submit_key);
950
+ return;
951
+ }
952
+ let i = 0;
953
+ const iv = setInterval(() => {
954
+ if (i >= text.length) {
955
+ clearInterval(iv);
956
+ setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
957
+ return;
958
+ }
959
+ this.adapter.send_keys(text[i]);
960
+ i += 1;
961
+ }, perChar);
962
+ }
963
+
964
+ private handleClickControl(controlId: string, payload?: unknown): void {
965
+ const ctl = (this.spec.control_bar ?? []).find(c => c.id === controlId);
966
+ if (!ctl) return;
967
+ // Reject controls that aren't currently visible.
968
+ const ev = this.currentEval;
969
+ if (ctl.visible_when_state && ev && !ctl.visible_when_state.includes(ev.state.id)) return;
970
+
971
+ const a = ctl.action;
972
+ switch (a.type) {
973
+ case 'send_keys':
974
+ this.adapter.send_keys(a.keys);
975
+ return;
976
+ case 'open_picker':
977
+ this.adapter.send_keys(a.trigger_keys);
978
+ this.pickerInProgress = { control_id: ctl.id, phase: 'waiting', spec: ctl };
979
+ return;
980
+ case 'attach_image': {
981
+ const p = typeof payload === 'object' && payload && (payload as any).path;
982
+ if (typeof p === 'string') {
983
+ const keys = a.keys_template.replace(/\{path\}/g, p);
984
+ this.adapter.send_keys(keys);
985
+ }
986
+ return;
987
+ }
988
+ }
989
+ }
990
+
991
+ private handleClickModalButton(index: number): void {
992
+ const m = this.currentEval?.modal;
993
+ if (!m) return;
994
+ const btn = m.buttons.find(b => b.index === index);
995
+ if (!btn) return;
996
+ this.adapter.send_keys(btn.key);
997
+ }
998
+
999
+ private handleAttachImage(blob: string, mime: string): void {
1000
+ // tempfile_then_keys: write decoded bytes to a temp file, then
1001
+ // fire the attach_image control_bar entry with that path.
1002
+ const ctl = (this.spec.control_bar ?? []).find(c => c.action.type === 'attach_image');
1003
+ if (!ctl || ctl.action.type !== 'attach_image') return;
1004
+ const ext = guessExt(mime);
1005
+ const tmp = path.join(os.tmpdir(), `adhdev-attach-${Date.now()}${ext}`);
1006
+ try {
1007
+ fs.writeFileSync(tmp, Buffer.from(blob, 'base64'));
1008
+ } catch { return; }
1009
+ const keys = ctl.action.keys_template.replace(/\{path\}/g, tmp);
1010
+ this.adapter.send_keys(keys);
1011
+ }
1012
+
1013
+ private tryAdvancePicker(screen: string): void {
1014
+ const picker = this.pickerInProgress;
1015
+ if (!picker) return;
1016
+ const action = picker.spec.action;
1017
+ if (action.type !== 'open_picker') return;
1018
+ const hay = sectionTextFromSnapshot(this.spec, screen, action.wait_for.section) ?? screen;
1019
+ if (!action.wait_for.regex) return;
1020
+ const re = new RegExp(action.wait_for.regex, action.wait_for.flags ?? 'i');
1021
+ if (!re.test(hay)) return;
1022
+ // Cue arrived — the dashboard now sees the picker modal via
1023
+ // state_changed. We do NOT auto-submit; the dashboard must call
1024
+ // click_modal_button(index). After the modal closes the picker
1025
+ // tracking naturally clears the next time the screen settles.
1026
+ this.pickerInProgress = null;
1027
+ }
1028
+
1029
+ private handleExit(exitCode: number): void {
1030
+ this.emit({ kind: 'exit', exit_code: exitCode });
1031
+ this.shutdown();
1032
+ }
1033
+
1034
+ private emit(ev: DashboardEvent): void {
1035
+ for (const l of this.listeners) {
1036
+ try { l(ev); } catch { /* listener side */ }
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ function shallowSameModal(a: SpecEvaluation, b: SpecEvaluation | null): boolean {
1042
+ if (!b) return false;
1043
+ if (!a.modal && !b.modal) return true;
1044
+ if (!a.modal || !b.modal) return false;
1045
+ if (a.modal.title !== b.modal.title) return false;
1046
+ if (a.modal.buttons.length !== b.modal.buttons.length) return false;
1047
+ for (let i = 0; i < a.modal.buttons.length; i += 1) {
1048
+ if (a.modal.buttons[i].label !== b.modal.buttons[i].label) return false;
1049
+ }
1050
+ return true;
1051
+ }
1052
+
1053
+ function shallowSameControls(a: SpecEvaluation, b: SpecEvaluation | null): boolean {
1054
+ if (!b) return false;
1055
+ if (a.controls.length !== b.controls.length) return false;
1056
+ for (let i = 0; i < a.controls.length; i += 1) {
1057
+ if (a.controls[i].id !== b.controls[i].id) return false;
1058
+ }
1059
+ return true;
1060
+ }
1061
+
1062
+ function sectionTextFromSnapshot(spec: CliSpec, screen: string, sectionId: string | undefined): string | null {
1063
+ if (!sectionId) return null;
1064
+ const ev = evaluate(spec, screen);
1065
+ return ev.sections.find(s => s.id === sectionId)?.text ?? null;
1066
+ }
1067
+
1068
+ export function guessExt(mime: string): string {
1069
+ if (/png/i.test(mime)) return '.png';
1070
+ if (/jpe?g/i.test(mime)) return '.jpg';
1071
+ if (/gif/i.test(mime)) return '.gif';
1072
+ if (/webp/i.test(mime)) return '.webp';
1073
+ return '.bin';
1074
+ }
1075
+
1076
+ function extractMatchedRules(ev: SpecEvaluation): string[] {
1077
+ if (!Array.isArray(ev.trace)) return [];
1078
+ return ev.trace
1079
+ .filter(t => t.kind === 'state_match')
1080
+ .map(t => t.text)
1081
+ .filter(Boolean);
1082
+ }