@adhdev/daemon-core 0.9.82-rc.20 → 0.9.82-rc.201

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 (241) 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/commands/cli-manager.d.ts +2 -1
  14. package/dist/commands/handler.d.ts +110 -0
  15. package/dist/commands/mesh-coordinator.d.ts +85 -1
  16. package/dist/commands/router.d.ts +26 -1
  17. package/dist/config/chat-history.d.ts +9 -0
  18. package/dist/config/config.d.ts +5 -0
  19. package/dist/config/mesh-config.d.ts +72 -1
  20. package/dist/git/git-commands.d.ts +7 -1
  21. package/dist/git/git-types.d.ts +2 -0
  22. package/dist/index.d.ts +39 -10
  23. package/dist/index.js +25488 -8333
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +29124 -12045
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/installer.d.ts +1 -4
  28. package/dist/ipc/local-ipc-server.d.ts +91 -0
  29. package/dist/launch.d.ts +1 -1
  30. package/dist/logging/async-batch-writer.d.ts +10 -0
  31. package/dist/mesh/contracts.d.ts +164 -0
  32. package/dist/mesh/coordinator-prompt.d.ts +30 -0
  33. package/dist/mesh/coordinator-registry.d.ts +59 -0
  34. package/dist/mesh/mesh-active-work.d.ts +90 -0
  35. package/dist/mesh/mesh-delivery-policy.d.ts +126 -0
  36. package/dist/mesh/mesh-events.d.ts +164 -6
  37. package/dist/mesh/mesh-fast-forward.d.ts +41 -0
  38. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  39. package/dist/mesh/mesh-ledger.d.ts +58 -1
  40. package/dist/mesh/mesh-refine-status.d.ts +26 -0
  41. package/dist/mesh/mesh-runtime-store.d.ts +149 -0
  42. package/dist/mesh/mesh-work-queue.d.ts +55 -6
  43. package/dist/mesh/preview-freshness.d.ts +18 -0
  44. package/dist/mesh/refine-config.d.ts +193 -0
  45. package/dist/mesh/worktree-bootstrap-config.d.ts +113 -0
  46. package/dist/providers/approval-utils.d.ts +13 -0
  47. package/dist/providers/cli-provider-instance.d.ts +39 -3
  48. package/dist/providers/contracts.d.ts +130 -6
  49. package/dist/providers/external-sources.d.ts +71 -0
  50. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  51. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  52. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  53. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  54. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  55. package/dist/providers/native-history/index.d.ts +13 -0
  56. package/dist/providers/provider-instance-manager.d.ts +12 -0
  57. package/dist/providers/provider-instance.d.ts +13 -1
  58. package/dist/providers/provider-loader.d.ts +26 -4
  59. package/dist/providers/provider-trust.d.ts +31 -0
  60. package/dist/providers/read-chat-contract.d.ts +29 -0
  61. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  62. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  63. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  64. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  65. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  66. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  67. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  68. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  69. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  70. package/dist/providers/sdk/v1/index.d.ts +30 -0
  71. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  72. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  73. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  74. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  75. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  76. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  77. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  78. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  79. package/dist/providers/spec/adapter.d.ts +60 -0
  80. package/dist/providers/spec/cli-adapter.d.ts +101 -0
  81. package/dist/providers/spec/driver.d.ts +195 -0
  82. package/dist/providers/spec/evaluator.d.ts +55 -0
  83. package/dist/providers/spec/loader.d.ts +14 -0
  84. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  85. package/dist/providers/spec/route.d.ts +4 -0
  86. package/dist/providers/spec/schema.gen.d.ts +599 -0
  87. package/dist/providers/spec/types.d.ts +282 -0
  88. package/dist/providers/transcript-v2.d.ts +176 -0
  89. package/dist/providers/types/interactive-prompt.d.ts +48 -0
  90. package/dist/repo-mesh-types.d.ts +108 -1
  91. package/dist/sessions/registry.d.ts +3 -0
  92. package/dist/shared-types-extra.d.ts +1 -1
  93. package/dist/shared-types.d.ts +42 -1
  94. package/dist/status/normalize.d.ts +1 -1
  95. package/dist/status/normalize.js +1 -0
  96. package/dist/status/normalize.js.map +1 -1
  97. package/dist/status/normalize.mjs +1 -0
  98. package/dist/status/normalize.mjs.map +1 -1
  99. package/dist/status/reporter.d.ts +2 -0
  100. package/dist/status/snapshot.d.ts +1 -0
  101. package/dist/types.d.ts +5 -0
  102. package/package.json +7 -2
  103. package/src/agent-stream/poller.ts +2 -3
  104. package/src/boot/daemon-lifecycle.ts +19 -10
  105. package/src/boot/process-hardening.ts +89 -0
  106. package/src/chat/source-machine.ts +534 -0
  107. package/src/chat/source-resolver.ts +0 -0
  108. package/src/chat/subscription-updates.ts +20 -1
  109. package/src/cli-adapter-types.d.ts +1 -0
  110. package/src/cli-adapter-types.ts +22 -2
  111. package/src/cli-adapters/cli-script-runner.ts +421 -0
  112. package/src/cli-adapters/cli-state-engine.ts +1138 -0
  113. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  114. package/src/cli-adapters/provider-cli-adapter.ts +670 -1137
  115. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  116. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  117. package/src/cli-adapters/provider-cli-runtime.ts +3 -1
  118. package/src/cli-adapters/provider-cli-shared.d.ts +2 -0
  119. package/src/cli-adapters/provider-cli-shared.ts +59 -11
  120. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  121. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +17 -1
  122. package/src/cli-adapters/terminal-backends/xterm-backend.ts +8 -1
  123. package/src/commands/chat-commands.ts +1787 -60
  124. package/src/commands/cli-manager.ts +283 -14
  125. package/src/commands/handler.ts +809 -2
  126. package/src/commands/mesh-coordinator.ts +331 -122
  127. package/src/commands/router.ts +3628 -515
  128. package/src/config/chat-history.ts +95 -24
  129. package/src/config/config.ts +12 -0
  130. package/src/config/mesh-config.ts +280 -2
  131. package/src/config/recent-activity.ts +8 -2
  132. package/src/daemon/dev-cli-debug.ts +10 -1
  133. package/src/detection/ide-detector.ts +26 -16
  134. package/src/git/git-commands.ts +37 -7
  135. package/src/git/git-status.ts +35 -6
  136. package/src/git/git-types.ts +2 -0
  137. package/src/git/git-worktree.ts +8 -1
  138. package/src/index.ts +119 -9
  139. package/src/installer.d.ts +1 -1
  140. package/src/installer.ts +8 -6
  141. package/src/ipc/local-ipc-server.ts +278 -0
  142. package/src/launch.d.ts +1 -1
  143. package/src/launch.ts +37 -28
  144. package/src/logging/async-batch-writer.ts +55 -0
  145. package/src/logging/logger.ts +2 -1
  146. package/src/mesh/contracts.ts +329 -0
  147. package/src/mesh/coordinator-prompt.ts +204 -30
  148. package/src/mesh/coordinator-registry.ts +121 -0
  149. package/src/mesh/mesh-active-work.ts +437 -0
  150. package/src/mesh/mesh-delivery-policy.ts +298 -0
  151. package/src/mesh/mesh-events.ts +1341 -109
  152. package/src/mesh/mesh-fast-forward.ts +438 -0
  153. package/src/mesh/mesh-host-ownership.ts +73 -0
  154. package/src/mesh/mesh-ledger.ts +457 -104
  155. package/src/mesh/mesh-refine-status.ts +144 -0
  156. package/src/mesh/mesh-runtime-store.ts +769 -0
  157. package/src/mesh/mesh-work-queue.ts +267 -159
  158. package/src/mesh/preview-freshness.ts +118 -0
  159. package/src/mesh/refine-config.ts +366 -0
  160. package/src/mesh/worktree-bootstrap-config.ts +247 -0
  161. package/src/providers/approval-utils.d.ts +4 -0
  162. package/src/providers/approval-utils.ts +47 -5
  163. package/src/providers/chat-message-normalization.ts +4 -11
  164. package/src/providers/cli-provider-instance.ts +806 -64
  165. package/src/providers/contracts.d.ts +55 -0
  166. package/src/providers/contracts.ts +141 -6
  167. package/src/providers/external-sources.ts +218 -0
  168. package/src/providers/ide-provider-instance.ts +19 -5
  169. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  170. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  171. package/src/providers/native-history/codex-cli-transcript.ts +419 -0
  172. package/src/providers/native-history/dispatcher.ts +340 -0
  173. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  174. package/src/providers/native-history/index.ts +30 -0
  175. package/src/providers/provider-instance-manager.ts +30 -0
  176. package/src/providers/provider-instance.ts +10 -1
  177. package/src/providers/provider-loader.ts +582 -50
  178. package/src/providers/provider-schema.ts +87 -14
  179. package/src/providers/provider-trust.ts +114 -0
  180. package/src/providers/read-chat-contract.ts +76 -16
  181. package/src/providers/sdk/README.md +49 -0
  182. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  183. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  184. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  185. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  186. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  187. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  188. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  189. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  190. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  191. package/src/providers/sdk/v1/index.ts +152 -0
  192. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  193. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  194. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  195. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  196. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  197. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  198. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  199. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  200. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  201. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  202. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  203. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  204. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  205. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  206. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  207. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  208. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  209. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  210. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  211. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  212. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  213. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  214. package/src/providers/sdk/v1/validators/index.ts +19 -0
  215. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  216. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  217. package/src/providers/spec/adapter.ts +176 -0
  218. package/src/providers/spec/cli-adapter.ts +573 -0
  219. package/src/providers/spec/driver.ts +733 -0
  220. package/src/providers/spec/evaluator.ts +373 -0
  221. package/src/providers/spec/loader.ts +130 -0
  222. package/src/providers/spec/native-history-executor.ts +939 -0
  223. package/src/providers/spec/route.ts +51 -0
  224. package/src/providers/spec/schema.gen.ts +559 -0
  225. package/src/providers/spec/schema.json +237 -0
  226. package/src/providers/spec/types.ts +301 -0
  227. package/src/providers/transcript-v2.ts +567 -0
  228. package/src/providers/types/interactive-prompt.ts +425 -0
  229. package/src/providers/version-archive.ts +38 -20
  230. package/src/repo-mesh-types.ts +118 -1
  231. package/src/sessions/registry.ts +3 -0
  232. package/src/shared-types-extra.ts +1 -1
  233. package/src/shared-types.ts +45 -1
  234. package/src/status/builders.ts +24 -6
  235. package/src/status/normalize.ts +2 -0
  236. package/src/status/reporter.ts +15 -0
  237. package/src/status/snapshot.ts +84 -25
  238. package/src/system/host-memory.ts +29 -12
  239. package/src/types.ts +5 -0
  240. package/dist/mesh/mesh-sync.d.ts +0 -53
  241. package/src/mesh/mesh-sync.ts +0 -111
@@ -0,0 +1,733 @@
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 { evaluate, type SpecEvaluation, type TraceEntry } from './evaluator.js';
42
+ import { loadSpec } from './loader.js';
43
+ import type { CliSpec, Control, DelegateTrigger } from './types.js';
44
+ import { LOG } from '../../logging/logger.js';
45
+
46
+ export type DashboardEvent =
47
+ | { kind: 'pty_data'; chunk: string }
48
+ | { kind: 'state_changed'; state: { id: string; label: string; title: string | null };
49
+ modal: { title: string | null; buttons: { index: number; label: string }[] } | null;
50
+ controls: { id: string; label: string; action_type: string }[] }
51
+ | { kind: 'notification'; id: string; title: string; body: string }
52
+ | { kind: 'delegate'; id: string; task: string }
53
+ | { kind: 'spec_trace'; entries: TraceEntry[] }
54
+ | { kind: 'exit'; exit_code: number }
55
+ | { kind: 'spec_error'; errors: string[] };
56
+
57
+ export type DashboardCommand =
58
+ | { kind: 'send_message'; text: string }
59
+ | { kind: 'pty_write'; data: string }
60
+ | { kind: 'click_control'; control_id: string; payload?: unknown }
61
+ | { kind: 'click_modal_button'; index: number }
62
+ | { kind: 'attach_image'; blob: string; mime: string }
63
+ | { kind: 'resize'; cols: number; rows: number }
64
+ | { kind: 'cancel' }
65
+ | { kind: 'shutdown' };
66
+
67
+ export interface SpecDriverOpts {
68
+ specPath: string;
69
+ workingDir: string;
70
+ extraEnv?: Record<string, string>;
71
+ cols?: number;
72
+ rows?: number;
73
+ /** Set false to skip the spec.json fs.watch. */
74
+ hotReload?: boolean;
75
+ /** Set true to forward trace entries on every state_changed. */
76
+ emitTrace?: boolean;
77
+ /** Inject the daemon's PTY transport (typically SessionHostPtyTransportFactory). */
78
+ transportFactory?: PtyTransportFactory;
79
+ /**
80
+ * Extra CLI args appended to spec.spawn_args. Used by the daemon to
81
+ * pass per-launch arguments like `--session-id <uuid>` so the agent
82
+ * uses the daemon's providerSessionId instead of generating its own.
83
+ */
84
+ extraCliArgs?: string[];
85
+ }
86
+
87
+ /** How long after start() to ignore "idle" readings before treating one
88
+ * as a real prompt-ready signal. Most TUIs paint a banner + tool list +
89
+ * status line on startup that can briefly score as "no state matches →
90
+ * default idle"; firing a send_keys into that buffer gets the input
91
+ * wiped when the banner clears. 2s covers the slow agents (hermes,
92
+ * agy) without delaying snappy ones (claude/codex) by much. */
93
+ const STARTUP_GRACE_MS = 2500;
94
+
95
+ /** Min time to stay in busy after the evaluator last reports it. Most TUIs
96
+ * flicker their busy spinner on and off as layout reflows (claude streams
97
+ * body text in the same region where the spinner lives, so the spinner
98
+ * disappears mid-response and the footer-only frames score as idle). The
99
+ * hold turns short idle gaps inside a single turn into "still busy", at
100
+ * the cost of looking busy for a couple seconds after a turn actually
101
+ * ends. Pick something long enough to cover the longest claude streaming
102
+ * gap we've observed (~5s between spinner refreshes) without dragging
103
+ * the post-turn idle indication noticeably. */
104
+ const BUSY_HOLD_MS = 6000;
105
+
106
+ /** Minimum delay between the prompt body and the submit_key in send_message.
107
+ * Claude / antigravity specs ship without an explicit delay_ms_before_submit
108
+ * and their TUIs sometimes drop the `\r` event when it arrives in the same
109
+ * PTY chunk as the text body — the prompt sits visible in the input field
110
+ * but is never submitted until the user hits Enter manually. 200ms matches
111
+ * codex's explicit setting and is barely perceptible to a human caller. */
112
+ const SUBMIT_DELAY_FLOOR_MS = 200;
113
+
114
+ function countNewlines(s: string): number {
115
+ let n = 0;
116
+ for (let i = 0; i < s.length; i += 1) if (s.charCodeAt(i) === 10) n += 1;
117
+ return n;
118
+ }
119
+
120
+ /**
121
+ * Pick the effective delay (ms) to wait between writing the prompt body and
122
+ * writing the submit_key. Exported for tests; production callers go through
123
+ * actuallySendMessage. Floors to SUBMIT_DELAY_FLOOR_MS so specs that omit
124
+ * delay_ms_before_submit (e.g. claude, antigravity) still get baseline
125
+ * protection against the paste→submit race. Adds line-count bonus so
126
+ * multi-line prompts get more settling time on TUIs that re-render per
127
+ * embedded `\n`.
128
+ */
129
+ export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number {
130
+ const lines = countNewlines(text);
131
+ const linesBonus = Math.min(800, lines * 80);
132
+ const spec = typeof specBeforeSubmit === 'number' && specBeforeSubmit > 0 ? specBeforeSubmit : 0;
133
+ return Math.max(spec, SUBMIT_DELAY_FLOOR_MS + linesBonus);
134
+ }
135
+
136
+ export function matchesCompletionIdleRule(spec: CliSpec, ev: SpecEvaluation, screen: string): string | null {
137
+ const rule = spec.debounce?.completion_idle_after;
138
+ if (!rule?.regex) return null;
139
+ const haystack = rule.section
140
+ ? ev.sections.find(section => section.id === rule.section)?.text ?? ''
141
+ : screen;
142
+ if (!haystack) return null;
143
+ try {
144
+ const regex = new RegExp(rule.regex, rule.flags || '');
145
+ const match = haystack.match(regex);
146
+ return match?.[0] || null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ export function matchesCompletionIdleTargetState(
153
+ spec: CliSpec,
154
+ ev: SpecEvaluation,
155
+ screen: string,
156
+ cursor?: { row: number; col: number },
157
+ ): boolean {
158
+ const target = spec.states.find(state => state.id === spec.default_state)
159
+ ?? spec.states.find(state => state.id === 'idle');
160
+ if (!target?.when) return false;
161
+
162
+ // Cursor-only idle: if the target state has cursor guards but no regex,
163
+ // treat a cursor match alone as sufficient.
164
+ const hasCursorGuard = target.when.cursor_row_min !== undefined
165
+ || target.when.cursor_row_max !== undefined
166
+ || target.when.cursor_col_min !== undefined
167
+ || target.when.cursor_col_max !== undefined;
168
+ if (hasCursorGuard && cursor !== undefined) {
169
+ const { cursor_row_min, cursor_row_max, cursor_col_min, cursor_col_max } = target.when;
170
+ const cursorOk = (cursor_row_min === undefined || cursor.row >= cursor_row_min)
171
+ && (cursor_row_max === undefined || cursor.row <= cursor_row_max)
172
+ && (cursor_col_min === undefined || cursor.col >= cursor_col_min)
173
+ && (cursor_col_max === undefined || cursor.col <= cursor_col_max);
174
+ if (cursorOk) return true;
175
+ }
176
+
177
+ if (!target.when.regex) return false;
178
+ const haystack = target.when.section
179
+ ? ev.sections.find(section => section.id === target.when.section)?.text ?? ''
180
+ : screen;
181
+ if (!haystack) return false;
182
+ try {
183
+ return new RegExp(target.when.regex, target.when.flags || 'i').test(haystack);
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ export class SpecDriver {
190
+ private spec!: CliSpec;
191
+ private adapter!: TerminalAdapter;
192
+ private listeners = new Set<(ev: DashboardEvent) => void>();
193
+ private currentStateId: string | null = null;
194
+ /** Have we ever seen the spec's idle state *after* the startup grace
195
+ * window? Until we do, the agent's startup banner may still be
196
+ * painting and any send_message we forward to the PTY will be wiped
197
+ * when the banner clears the screen. Queue + drain on first valid
198
+ * idle. */
199
+ private idleSeenOnce = false;
200
+ private startedAtMs = 0;
201
+ private pendingSends: string[] = [];
202
+ private currentEval: SpecEvaluation | null = null;
203
+ private pickerInProgress: { control_id: string; phase: 'waiting'; spec: Control } | null = null;
204
+ private delegateTimers = new Map<string, ReturnType<typeof setTimeout>>();
205
+ /** Timestamp of the last time the evaluator returned busy. Used to debounce
206
+ * the busy → idle transition (see reevaluate). */
207
+ private lastBusyAt = 0;
208
+ /** The exact busy state object we last saw — held alongside lastBusyAt so
209
+ * the hold can re-emit the same { id: 'busy', label, title } payload the
210
+ * dashboard already learned about. currentEval can't fill this role
211
+ * because the evaluator already moved past busy by the time the hold
212
+ * kicks in. */
213
+ private lastBusyState: SpecEvaluation['state'] | null = null;
214
+ private completionIdleFirstSeenAt = 0;
215
+ private completionIdleKey = '';
216
+ /** Timer that re-runs evaluate() once the hold window expires. Needed
217
+ * because the PTY stops emitting once the agent finishes; without an
218
+ * explicit wake-up there's nothing to trigger the busy → idle
219
+ * downshift. */
220
+ private busyExpiryTimer: ReturnType<typeof setTimeout> | null = null;
221
+ /** Pending idle-commit timer. Armed when the evaluator first returns idle;
222
+ * fires after idle_hold_ms if no non-idle reading has cancelled it. */
223
+ private idleHoldTimer: ReturnType<typeof setTimeout> | null = null;
224
+ /** State snapshot captured when the idle hold was armed — emitted on commit. */
225
+ private pendingIdleState: SpecEvaluation['state'] | null = null;
226
+ private specWatcher: fs.FSWatcher | null = null;
227
+ /** Ring buffer of committed state transitions (max 50). */
228
+ private stateHistory: Array<{ stateId: string; label: string; at: number; durationMs: number }> = [];
229
+ private prevStateAt = 0;
230
+
231
+ constructor(private readonly opts: SpecDriverOpts) {
232
+ this.loadSpecOrThrow();
233
+ this.adapter = new TerminalAdapter(
234
+ this.buildAdapterOpts(),
235
+ {
236
+ init: () => this.emitInitialState(),
237
+ on_pty_data: (chunk) => this.emit({ kind: 'pty_data', chunk }),
238
+ on_screen_changed: () => this.reevaluate(),
239
+ on_exit: ({ exitCode }) => this.handleExit(exitCode),
240
+ },
241
+ );
242
+ if (this.opts.hotReload !== false) this.armSpecWatcher();
243
+ }
244
+
245
+ /** Subscribe to outbound events. Returns an unsubscribe fn. */
246
+ subscribe(listener: (ev: DashboardEvent) => void): () => void {
247
+ this.listeners.add(listener);
248
+ return () => { this.listeners.delete(listener); };
249
+ }
250
+
251
+ start(): void {
252
+ this.startedAtMs = Date.now();
253
+ this.adapter.start();
254
+ // Make sure we always re-evaluate once the startup grace has
255
+ // expired. Without an explicit wake-up the PTY can go quiet
256
+ // immediately after the banner paints (agy: the screen stays at
257
+ // the input prompt forever), there's no on_screen_changed event
258
+ // to trigger reevaluate, and any send_message we queued during
259
+ // the grace window would never drain.
260
+ const graceMs = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
261
+ setTimeout(() => this.reevaluate(), graceMs + 100);
262
+ }
263
+
264
+ dispatch(cmd: DashboardCommand): void {
265
+ switch (cmd.kind) {
266
+ case 'send_message': this.handleSendMessage(cmd.text); return;
267
+ case 'pty_write': this.adapter.send_keys(cmd.data); return;
268
+ case 'click_control': this.handleClickControl(cmd.control_id, cmd.payload); return;
269
+ case 'click_modal_button': this.handleClickModalButton(cmd.index); return;
270
+ case 'attach_image': this.handleAttachImage(cmd.blob, cmd.mime); return;
271
+ case 'resize': this.adapter.resize(cmd.cols, cmd.rows); return;
272
+ case 'cancel': this.adapter.send_keys('\x03'); return;
273
+ case 'shutdown': this.shutdown(); return;
274
+ }
275
+ }
276
+
277
+ snapshot(): string {
278
+ return this.adapter.snapshot();
279
+ }
280
+
281
+ getCursorPosition(): { row: number; col: number } {
282
+ return this.adapter.getCursorPosition();
283
+ }
284
+
285
+ shutdown(): void {
286
+ for (const t of this.delegateTimers.values()) clearTimeout(t);
287
+ this.delegateTimers.clear();
288
+ this.cancelIdleHold();
289
+ if (this.busyExpiryTimer) { clearTimeout(this.busyExpiryTimer); this.busyExpiryTimer = null; }
290
+ this.specWatcher?.close();
291
+ this.adapter.kill();
292
+ }
293
+
294
+ private cancelIdleHold(): void {
295
+ if (this.idleHoldTimer) { clearTimeout(this.idleHoldTimer); this.idleHoldTimer = null; }
296
+ this.pendingIdleState = null;
297
+ }
298
+
299
+ private pushHistory(stateId: string, label: string): void {
300
+ const now = Date.now();
301
+ const durationMs = this.prevStateAt > 0 ? now - this.prevStateAt : 0;
302
+ this.prevStateAt = now;
303
+ this.stateHistory.push({ stateId, label, at: now, durationMs });
304
+ if (this.stateHistory.length > 50) this.stateHistory.shift();
305
+ }
306
+
307
+ getStateHistory(): ReadonlyArray<{ stateId: string; label: string; at: number; durationMs: number }> {
308
+ return this.stateHistory;
309
+ }
310
+
311
+ getLastBusyAt(): number { return this.lastBusyAt; }
312
+ hasIdleHoldPending(): boolean { return this.idleHoldTimer !== null; }
313
+ getSpecPath(): string { return this.opts.specPath; }
314
+ getScreen(): string { return this.adapter.snapshot(); }
315
+ getSections(): Array<{ id: string; text: string }> | null {
316
+ try {
317
+ const screen = this.adapter.snapshot();
318
+ const ev = evaluate(this.spec, screen, undefined);
319
+ return ev.sections.map(s => ({ id: s.id, text: s.text }));
320
+ } catch { return null; }
321
+ }
322
+
323
+ // ────────────────────────────────────────────────────────────────────
324
+ // Loading & adapter wiring
325
+ // ────────────────────────────────────────────────────────────────────
326
+
327
+ private loadSpecOrThrow(): void {
328
+ const res = loadSpec(this.opts.specPath);
329
+ if (!res.ok) throw new Error(`spec invalid: ${res.errors.join('; ')}`);
330
+ this.spec = res.spec;
331
+ }
332
+
333
+ private buildAdapterOpts(): TerminalAdapterOpts {
334
+ const baseArgs = this.spec.spawn_args ?? [];
335
+ const extra = this.opts.extraCliArgs ?? [];
336
+ return {
337
+ binary: this.spec.binary,
338
+ args: [...baseArgs, ...extra],
339
+ cwd: this.opts.workingDir,
340
+ env: { ...(this.spec.env ?? {}), ...(this.opts.extraEnv ?? {}) },
341
+ cols: this.opts.cols ?? 100,
342
+ rows: this.opts.rows ?? 30,
343
+ transportFactory: this.opts.transportFactory,
344
+ };
345
+ }
346
+
347
+ private armSpecWatcher(): void {
348
+ try {
349
+ // Watch the parent directory so we catch atomic replacements
350
+ // (cp, install scripts) that create a new inode — a file-level
351
+ // watch misses those on macOS because the original inode is gone.
352
+ const dir = path.dirname(this.opts.specPath);
353
+ const base = path.basename(this.opts.specPath);
354
+ this.specWatcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
355
+ if (filename && filename !== base) return;
356
+ const res = loadSpec(this.opts.specPath);
357
+ if (!res.ok) { this.emit({ kind: 'spec_error', errors: res.errors }); return; }
358
+ this.spec = res.spec;
359
+ this.reevaluate(/*forceEmit*/ true);
360
+ });
361
+ } catch { /* watch is best-effort */ }
362
+ }
363
+
364
+ // ────────────────────────────────────────────────────────────────────
365
+ // Evaluation pipeline
366
+ // ────────────────────────────────────────────────────────────────────
367
+
368
+ private emitInitialState(): void {
369
+ // Start with an empty-screen evaluation so the dashboard immediately
370
+ // knows about the default state.
371
+ this.reevaluate(true);
372
+ }
373
+
374
+ /** Re-arm the timer that wakes the driver up after BUSY_HOLD_MS so it
375
+ * can decide whether to downshift to idle. Always uses the most recent
376
+ * hold value so a spec hot-reload that shortens the hold takes effect
377
+ * on the next busy entry. Safe to call repeatedly; only the last call
378
+ * fires. */
379
+ private scheduleBusyExpiry(holdMs: number): void {
380
+ if (this.busyExpiryTimer) clearTimeout(this.busyExpiryTimer);
381
+ this.busyExpiryTimer = setTimeout(() => {
382
+ this.busyExpiryTimer = null;
383
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] busyExpiry fired holdMs=${holdMs}`);
384
+ this.reevaluate();
385
+ }, Math.max(holdMs + 50, 100));
386
+ }
387
+
388
+ private reevaluate(forceEmit = false): void {
389
+ const screen = this.adapter.snapshot();
390
+ const cursor = this.adapter.getCursorPosition();
391
+ const ev = evaluate(this.spec, screen, cursor);
392
+
393
+ // Busy hold: many TUIs flicker between busy and idle every frame
394
+ // (claude in particular — its token counter appears and disappears
395
+ // as the layout reflows). Once we see busy, hold that state for
396
+ // BUSY_HOLD_MS before allowing a downshift to idle. Any state
397
+ // *other* than idle clears the hold immediately (approval is
398
+ // strictly more interesting than busy, so it shouldn't be held
399
+ // back). The hold lives on the driver, not the evaluator, so the
400
+ // spec author doesn't have to think about debouncing.
401
+ let evState = ev.state;
402
+ const busyHoldMs = this.spec.debounce?.busy_hold_ms ?? BUSY_HOLD_MS;
403
+ if (this.currentStateId === 'busy' && evState.id === 'idle') {
404
+ const ageMs = Date.now() - this.lastBusyAt;
405
+ if (ageMs < busyHoldMs) {
406
+ // Pin to the last seen busy state directly — currentEval can
407
+ // already be idle at this point (it tracks the previous tick,
408
+ // which during the flicker is just as likely to be idle as
409
+ // busy), so we can't rely on it to recover the busy label.
410
+ evState = this.lastBusyState ?? evState;
411
+ }
412
+ }
413
+ const completionIdleRule = this.spec.debounce?.completion_idle_after;
414
+ let busyWakeMs = busyHoldMs;
415
+ if (evState.id === 'busy' && completionIdleRule) {
416
+ const completionKey = matchesCompletionIdleRule(this.spec, ev, screen);
417
+ if (completionKey) {
418
+ const now = Date.now();
419
+ if (completionKey !== this.completionIdleKey) {
420
+ this.completionIdleKey = completionKey;
421
+ this.completionIdleFirstSeenAt = now;
422
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] completion_idle_after matched: key="${completionKey}"`);
423
+ }
424
+ const holdMs = Math.max(0, completionIdleRule.hold_ms || 0);
425
+ const forceAfterMs = typeof completionIdleRule.force_after_ms === 'number'
426
+ ? completionIdleRule.force_after_ms
427
+ : null;
428
+ const ageMs = now - this.completionIdleFirstSeenAt;
429
+ if (ageMs >= holdMs) {
430
+ const targetMatches = matchesCompletionIdleTargetState(this.spec, ev, screen, cursor);
431
+ const forced = !targetMatches && forceAfterMs !== null && ageMs >= holdMs + forceAfterMs;
432
+ 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)}"`);
433
+ if (targetMatches || forced) {
434
+ const idle = this.spec.states.find(state => state.id === this.spec.default_state)
435
+ ?? this.spec.states.find(state => state.id === 'idle');
436
+ evState = idle
437
+ ? { id: idle.id, label: idle.label, title: null }
438
+ : { id: 'idle', label: 'Ready', title: null };
439
+ } else {
440
+ busyWakeMs = Math.min(busyWakeMs, 1000);
441
+ }
442
+ } else {
443
+ busyWakeMs = Math.min(busyWakeMs, Math.max(holdMs - ageMs, 0));
444
+ }
445
+ } else {
446
+ this.completionIdleKey = '';
447
+ this.completionIdleFirstSeenAt = 0;
448
+ }
449
+ } else if (evState.id !== 'busy') {
450
+ this.completionIdleKey = '';
451
+ this.completionIdleFirstSeenAt = 0;
452
+ }
453
+
454
+ if (evState.id === 'busy') {
455
+ this.lastBusyAt = Date.now();
456
+ this.lastBusyState = evState;
457
+ // Cancel any pending idle commit — non-idle reading invalidates it.
458
+ this.cancelIdleHold();
459
+ // Schedule a re-evaluation when the hold window expires. PTYs
460
+ // typically stop emitting once the agent stops printing (the
461
+ // footer settles), so without an explicit timer the driver
462
+ // never wakes up to downshift to idle and the dashboard sees
463
+ // status stuck at generating long after the turn ended.
464
+ this.scheduleBusyExpiry(busyWakeMs);
465
+ } else if (evState.id !== this.currentStateId && evState.id !== 'busy') {
466
+ // Non-busy modal states (approval, picker, signing_in) also cancel
467
+ // any in-flight idle hold — they are higher-priority than idle.
468
+ if (evState.id !== (this.spec.default_state ?? 'idle')) {
469
+ this.cancelIdleHold();
470
+ }
471
+ }
472
+
473
+ // Idle hold: if idle_hold_ms is set, don't commit idle immediately.
474
+ // Arm a timer; if a non-idle reading arrives before it fires, cancel.
475
+ const idleHoldMs = this.spec.debounce?.idle_hold_ms ?? 0;
476
+ const isIdleState = evState.id === (this.spec.default_state ?? 'idle');
477
+ if (isIdleState && idleHoldMs > 0 && this.currentStateId !== evState.id) {
478
+ if (!this.idleHoldTimer) {
479
+ this.pendingIdleState = evState;
480
+ this.idleHoldTimer = setTimeout(() => {
481
+ this.idleHoldTimer = null;
482
+ const committed = this.pendingIdleState;
483
+ this.pendingIdleState = null;
484
+ if (!committed) return;
485
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] idleHold committed after ${idleHoldMs}ms`);
486
+ this.currentStateId = committed.id;
487
+ this.currentEval = ev;
488
+ this.pushHistory(committed.id, committed.label);
489
+ this.emit({
490
+ kind: 'state_changed',
491
+ state: committed,
492
+ modal: null,
493
+ controls: ev.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
494
+ });
495
+ this.armOrCancelDelegateTimers(committed.id);
496
+ if (this.opts.emitTrace) this.emit({ kind: 'spec_trace', entries: ev.trace });
497
+ }, idleHoldMs);
498
+ }
499
+ // Don't fall through to the normal changed/emit path for idle.
500
+ this.currentEval = ev;
501
+ const graceMs2 = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
502
+ if (!this.idleSeenOnce && Date.now() - this.startedAtMs >= graceMs2) {
503
+ this.idleSeenOnce = true;
504
+ const queued = this.pendingSends.splice(0);
505
+ for (const text of queued) setTimeout(() => this.actuallySendMessage(text), 50);
506
+ }
507
+ if (this.pickerInProgress) this.tryAdvancePicker(screen);
508
+ return;
509
+ }
510
+
511
+ const changed = forceEmit
512
+ || evState.id !== this.currentStateId
513
+ || !shallowSameModal(ev, this.currentEval)
514
+ || !shallowSameControls(ev, this.currentEval);
515
+
516
+ // Picker state tracking — if a picker is waiting for its
517
+ // wait_for cue and now sees it, complete the picker flow.
518
+ if (this.pickerInProgress) this.tryAdvancePicker(screen);
519
+
520
+ this.currentEval = ev;
521
+ // First time we ever see idle (or any non-busy non-startup state),
522
+ // flush any queued send_message calls that arrived before the
523
+ // agent finished painting its banner. Subsequent idle/busy
524
+ // toggles don't retrigger.
525
+ // Wait at least the spec's startup_grace_ms after start() so we
526
+ // don't treat a transient "matches no state, default idle"
527
+ // reading during the banner paint as a real idle. After that,
528
+ // the first non-busy observation is a real prompt-ready signal
529
+ // and we drain any queued send_message calls.
530
+ const graceMs = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
531
+ const sinceStart = Date.now() - this.startedAtMs;
532
+ if (!this.idleSeenOnce && evState.id !== 'busy' && sinceStart >= graceMs) {
533
+ this.idleSeenOnce = true;
534
+ const queued = this.pendingSends.splice(0);
535
+ for (const text of queued) {
536
+ setTimeout(() => this.actuallySendMessage(text), 50);
537
+ }
538
+ }
539
+ if (changed) {
540
+ this.currentStateId = evState.id;
541
+ this.pushHistory(evState.id, evState.label);
542
+ this.emit({
543
+ kind: 'state_changed',
544
+ state: evState,
545
+ modal: ev.modal ? { title: ev.modal.title, buttons: ev.modal.buttons.map(b => ({ index: b.index, label: b.label })) } : null,
546
+ controls: ev.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
547
+ });
548
+ for (const n of ev.notifications) this.emit({ kind: 'notification', id: n.id, title: n.title, body: n.body });
549
+ this.armOrCancelDelegateTimers(evState.id);
550
+ if (this.opts.emitTrace) this.emit({ kind: 'spec_trace', entries: ev.trace });
551
+ }
552
+ }
553
+
554
+ private armOrCancelDelegateTimers(currentStateId: string): void {
555
+ for (const d of this.spec.delegate ?? []) {
556
+ const armed = this.delegateTimers.has(d.id);
557
+ const shouldFire = d.when_state === currentStateId;
558
+ if (shouldFire && !armed) {
559
+ const delay = d.after_duration_ms ?? 0;
560
+ const t = setTimeout(() => { this.fireDelegate(d); this.delegateTimers.delete(d.id); }, delay);
561
+ this.delegateTimers.set(d.id, t);
562
+ } else if (!shouldFire && armed) {
563
+ clearTimeout(this.delegateTimers.get(d.id)!);
564
+ this.delegateTimers.delete(d.id);
565
+ }
566
+ }
567
+ }
568
+
569
+ private fireDelegate(d: DelegateTrigger): void {
570
+ const ev = this.currentEval;
571
+ const task = (d.task_template)
572
+ .replace(/\{node\}/g, os.hostname())
573
+ .replace(/\{state\.label\}/g, ev?.state.label ?? '')
574
+ .replace(/\{state\.title\}/g, ev?.state.title ?? '')
575
+ .replace(/\{duration_ms\}/g, String(d.after_duration_ms ?? 0));
576
+ this.emit({ kind: 'delegate', id: d.id, task });
577
+ }
578
+
579
+ // ────────────────────────────────────────────────────────────────────
580
+ // Dashboard commands
581
+ // ────────────────────────────────────────────────────────────────────
582
+
583
+ private handleSendMessage(text: string): void {
584
+ if (!this.idleSeenOnce) {
585
+ // Agent is still drawing its startup banner. Queue and drain
586
+ // when we see idle so we don't fire keystrokes into a buffer
587
+ // that's about to be cleared.
588
+ this.pendingSends.push(text);
589
+ return;
590
+ }
591
+ this.actuallySendMessage(text);
592
+ }
593
+
594
+ private actuallySendMessage(text: string): void {
595
+ const sm = this.spec.send_message;
596
+ const perChar = sm.delay_ms_per_char ?? 0;
597
+ // Floor the gap between text and submit_key. Without this, claude /
598
+ // antigravity specs (which leave delay_ms_before_submit unset)
599
+ // race: text bytes and `\r` arrive back-to-back, the TUI processes
600
+ // the `\r` while still digesting the text input, and the prompt
601
+ // sits visible in the input field but never submits — the user has
602
+ // to press Enter manually. Scale with line count so multi-line
603
+ // pastes (which take longer for the TUI to render) get more
604
+ // settling time.
605
+ const beforeSubmit = resolveSubmitDelayMs(sm.delay_ms_before_submit, text);
606
+ if (perChar === 0) {
607
+ this.adapter.send_keys(text);
608
+ if (beforeSubmit > 0) setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
609
+ else this.adapter.send_keys(sm.submit_key);
610
+ return;
611
+ }
612
+ let i = 0;
613
+ const iv = setInterval(() => {
614
+ if (i >= text.length) {
615
+ clearInterval(iv);
616
+ setTimeout(() => this.adapter.send_keys(sm.submit_key), beforeSubmit);
617
+ return;
618
+ }
619
+ this.adapter.send_keys(text[i]);
620
+ i += 1;
621
+ }, perChar);
622
+ }
623
+
624
+ private handleClickControl(controlId: string, payload?: unknown): void {
625
+ const ctl = (this.spec.control_bar ?? []).find(c => c.id === controlId);
626
+ if (!ctl) return;
627
+ // Reject controls that aren't currently visible.
628
+ const ev = this.currentEval;
629
+ if (ctl.visible_when_state && ev && !ctl.visible_when_state.includes(ev.state.id)) return;
630
+
631
+ const a = ctl.action;
632
+ switch (a.type) {
633
+ case 'send_keys':
634
+ this.adapter.send_keys(a.keys);
635
+ return;
636
+ case 'open_picker':
637
+ this.adapter.send_keys(a.trigger_keys);
638
+ this.pickerInProgress = { control_id: ctl.id, phase: 'waiting', spec: ctl };
639
+ return;
640
+ case 'attach_image': {
641
+ const p = typeof payload === 'object' && payload && (payload as any).path;
642
+ if (typeof p === 'string') {
643
+ const keys = a.keys_template.replace(/\{path\}/g, p);
644
+ this.adapter.send_keys(keys);
645
+ }
646
+ return;
647
+ }
648
+ }
649
+ }
650
+
651
+ private handleClickModalButton(index: number): void {
652
+ const m = this.currentEval?.modal;
653
+ if (!m) return;
654
+ const btn = m.buttons.find(b => b.index === index);
655
+ if (!btn) return;
656
+ this.adapter.send_keys(btn.key);
657
+ }
658
+
659
+ private handleAttachImage(blob: string, mime: string): void {
660
+ // tempfile_then_keys: write decoded bytes to a temp file, then
661
+ // fire the attach_image control_bar entry with that path.
662
+ const ctl = (this.spec.control_bar ?? []).find(c => c.action.type === 'attach_image');
663
+ if (!ctl || ctl.action.type !== 'attach_image') return;
664
+ const ext = guessExt(mime);
665
+ const tmp = path.join(os.tmpdir(), `adhdev-attach-${Date.now()}${ext}`);
666
+ try {
667
+ fs.writeFileSync(tmp, Buffer.from(blob, 'base64'));
668
+ } catch { return; }
669
+ const keys = ctl.action.keys_template.replace(/\{path\}/g, tmp);
670
+ this.adapter.send_keys(keys);
671
+ }
672
+
673
+ private tryAdvancePicker(screen: string): void {
674
+ const picker = this.pickerInProgress;
675
+ if (!picker) return;
676
+ const action = picker.spec.action;
677
+ if (action.type !== 'open_picker') return;
678
+ const hay = sectionTextFromSnapshot(this.spec, screen, action.wait_for.section) ?? screen;
679
+ const re = new RegExp(action.wait_for.regex, action.wait_for.flags ?? 'i');
680
+ if (!re.test(hay)) return;
681
+ // Cue arrived — the dashboard now sees the picker modal via
682
+ // state_changed. We do NOT auto-submit; the dashboard must call
683
+ // click_modal_button(index). After the modal closes the picker
684
+ // tracking naturally clears the next time the screen settles.
685
+ this.pickerInProgress = null;
686
+ }
687
+
688
+ private handleExit(exitCode: number): void {
689
+ this.emit({ kind: 'exit', exit_code: exitCode });
690
+ this.shutdown();
691
+ }
692
+
693
+ private emit(ev: DashboardEvent): void {
694
+ for (const l of this.listeners) {
695
+ try { l(ev); } catch { /* listener side */ }
696
+ }
697
+ }
698
+ }
699
+
700
+ function shallowSameModal(a: SpecEvaluation, b: SpecEvaluation | null): boolean {
701
+ if (!b) return false;
702
+ if (!a.modal && !b.modal) return true;
703
+ if (!a.modal || !b.modal) return false;
704
+ if (a.modal.title !== b.modal.title) return false;
705
+ if (a.modal.buttons.length !== b.modal.buttons.length) return false;
706
+ for (let i = 0; i < a.modal.buttons.length; i += 1) {
707
+ if (a.modal.buttons[i].label !== b.modal.buttons[i].label) return false;
708
+ }
709
+ return true;
710
+ }
711
+
712
+ function shallowSameControls(a: SpecEvaluation, b: SpecEvaluation | null): boolean {
713
+ if (!b) return false;
714
+ if (a.controls.length !== b.controls.length) return false;
715
+ for (let i = 0; i < a.controls.length; i += 1) {
716
+ if (a.controls[i].id !== b.controls[i].id) return false;
717
+ }
718
+ return true;
719
+ }
720
+
721
+ function sectionTextFromSnapshot(spec: CliSpec, screen: string, sectionId: string | undefined): string | null {
722
+ if (!sectionId) return null;
723
+ const ev = evaluate(spec, screen);
724
+ return ev.sections.find(s => s.id === sectionId)?.text ?? null;
725
+ }
726
+
727
+ function guessExt(mime: string): string {
728
+ if (/png/i.test(mime)) return '.png';
729
+ if (/jpe?g/i.test(mime)) return '.jpg';
730
+ if (/gif/i.test(mime)) return '.gif';
731
+ if (/webp/i.test(mime)) return '.webp';
732
+ return '.bin';
733
+ }