@adhdev/daemon-core 0.9.82-rc.216 → 0.9.82-rc.218

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.
@@ -1247,29 +1247,39 @@ export class ProviderLoader {
1247
1247
  // Hand the resolved spec path off to route.ts via a hidden field
1248
1248
  // so the routing layer doesn't have to repeat the candidate walk.
1249
1249
  (resolved as any)._resolvedSpecPath = specPath;
1250
- const r = loadSpec(specPath);
1250
+ // Extract control_bar + native_history in a schema-agnostic way.
1251
+ // v3 goes through loadSpec (validates/migrates); v4 (FSM) reads the
1252
+ // header fields directly from JSON since loadSpec only knows v1/v3.
1253
+ let specControls: any[] | undefined;
1254
+ let nh: any | undefined;
1255
+ try {
1256
+ const rawSpec = JSON.parse(fs.readFileSync(specPath, 'utf8'));
1257
+ if (rawSpec?.$schema === 'adhdev:cli/spec@4') {
1258
+ specControls = rawSpec.control_bar;
1259
+ nh = rawSpec.native_history;
1260
+ } else {
1261
+ const r = loadSpec(specPath);
1262
+ if (r.ok) { specControls = r.spec.control_bar; nh = r.spec.native_history; }
1263
+ }
1264
+ } catch { /* unreadable spec — leave controls/native unavailable */ }
1251
1265
  // Stub each control_bar entry as a provider.scripts.<id>. The
1252
1266
  // upstream invoke_provider_script gate checks that the script
1253
1267
  // name exists on provider.scripts before calling adapter.invokeScript;
1254
1268
  // for spec providers the *actual* dispatch happens inside
1255
1269
  // SpecCliAdapter.invokeScript which maps the name to control_bar.
1256
1270
  // The stub is just a presence marker so the gate doesn't reject.
1257
- if (r.ok) {
1258
- const controls = r.spec.control_bar ?? [];
1259
- if (controls.length > 0) {
1260
- resolved.scripts = { ...(resolved.scripts || {}) };
1261
- for (const ctl of controls) {
1262
- if (!(resolved.scripts as any)[ctl.id]) {
1263
- (resolved.scripts as any)[ctl.id] = (..._args: unknown[]) => ({
1264
- __spec_control: true,
1265
- controlId: ctl.id,
1266
- actionType: ctl.action.type,
1267
- });
1268
- }
1271
+ if (specControls && specControls.length > 0) {
1272
+ resolved.scripts = { ...(resolved.scripts || {}) };
1273
+ for (const ctl of specControls) {
1274
+ if (!(resolved.scripts as any)[ctl.id]) {
1275
+ (resolved.scripts as any)[ctl.id] = (..._args: unknown[]) => ({
1276
+ __spec_control: true,
1277
+ controlId: ctl.id,
1278
+ actionType: ctl.action.type,
1279
+ });
1269
1280
  }
1270
1281
  }
1271
1282
  }
1272
- const nh = r.ok ? r.spec.native_history : undefined;
1273
1283
  if (nh) {
1274
1284
  let reader: ((input: any) => any) | null = null;
1275
1285
  let format = 'spec';
@@ -18,11 +18,13 @@
18
18
  */
19
19
  'use strict';
20
20
 
21
- import { SpecDriver, type DashboardEvent } from './driver.js';
22
- import { evaluate } from './evaluator.js';
21
+ import { SpecDriver, type DashboardEvent, type ISpecDriver } from './driver.js';
22
+ import { FsmDriver } from './fsm-driver.js';
23
23
  import { executeNativeHistory } from './native-history-executor.js';
24
24
  import { loadSpec } from './loader.js';
25
+ import * as fs from 'node:fs';
25
26
  import type { CliSpec } from './types.js';
27
+ import type { NativeHistoryConfig, Control } from './types.js';
26
28
  import type { CliAdapter, CliAdapterStatus } from '../../cli-adapter-types.js';
27
29
  import type { ChatMessage } from '../../types.js';
28
30
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
@@ -37,6 +39,14 @@ import {
37
39
  type InteractivePromptResponse,
38
40
  } from '../types/interactive-prompt.js';
39
41
 
42
+ /** Peek at the spec's $schema to choose the driver. Cheap header read. */
43
+ function detectV4Schema(specPath: string): boolean {
44
+ try {
45
+ const raw = JSON.parse(fs.readFileSync(specPath, 'utf8'));
46
+ return raw?.$schema === 'adhdev:cli/spec@4';
47
+ } catch { return false; }
48
+ }
49
+
40
50
  function stripAnsi(text: string): string {
41
51
  // eslint-disable-next-line no-control-regex
42
52
  return String(text || '')
@@ -59,8 +69,14 @@ export class SpecCliAdapter implements CliAdapter {
59
69
  */
60
70
  readonly chatMessagesOwnedExternally = true as const;
61
71
 
62
- private driver: SpecDriver;
63
- private spec: CliSpec;
72
+ private driver: ISpecDriver;
73
+ /** Common spec fields the adapter reads, present in both v3 and v4. */
74
+ private spec: {
75
+ id: string;
76
+ name: string;
77
+ control_bar?: Control[];
78
+ native_history?: NativeHistoryConfig;
79
+ };
64
80
  private lastEvent: DashboardEvent | null = null;
65
81
  private latestState: { id: string; label: string; title: string | null } | null = null;
66
82
  private latestModal: { title: string | null; buttons: { index: number; label: string }[] } | null = null;
@@ -97,9 +113,30 @@ export class SpecCliAdapter implements CliAdapter {
97
113
  extraEnv: Record<string, string>,
98
114
  transportFactory?: PtyTransportFactory,
99
115
  ) {
100
- const res = loadSpec(specPath);
101
- if (!res.ok) throw new Error(`spec invalid (${specPath}): ${res.errors.join('; ')}`);
102
- this.spec = res.spec;
116
+ // Detect the spec schema version up front. v4 (FSM) and v3 (debounce)
117
+ // use different drivers behind the same ISpecDriver interface.
118
+ const isV4 = detectV4Schema(specPath);
119
+
120
+ if (isV4) {
121
+ // v4 loader runs inside FsmDriver; read the common header fields
122
+ // we need here directly from the parsed JSON.
123
+ const raw = JSON.parse(fs.readFileSync(specPath, 'utf8'));
124
+ this.spec = {
125
+ id: raw.id,
126
+ name: raw.name,
127
+ control_bar: raw.control_bar,
128
+ native_history: raw.native_history,
129
+ };
130
+ } else {
131
+ const res = loadSpec(specPath);
132
+ if (!res.ok) throw new Error(`spec invalid (${specPath}): ${res.errors.join('; ')}`);
133
+ this.spec = {
134
+ id: res.spec.id,
135
+ name: res.spec.name,
136
+ control_bar: res.spec.control_bar,
137
+ native_history: res.spec.native_history,
138
+ };
139
+ }
103
140
  this.cliType = this.spec.id;
104
141
  this.cliName = this.spec.name;
105
142
  this.workingDir = workingDir;
@@ -107,11 +144,11 @@ export class SpecCliAdapter implements CliAdapter {
107
144
 
108
145
  // cli-manager.ts allocates providerSessionId per launch and threads
109
146
  // it through resume.newSessionArgs as additional cliArgs (e.g.
110
- // ["--session-id", "<uuid>"]). We must hand those to SpecDriver
147
+ // ["--session-id", "<uuid>"]). We must hand those to the driver
111
148
  // so the agent uses the daemon's id, otherwise (claude case) the
112
149
  // agent generates its own id and the chat-history pipeline can't
113
150
  // pair the on-disk transcript with the live session.
114
- this.driver = new SpecDriver({
151
+ const driverOpts = {
115
152
  specPath,
116
153
  workingDir,
117
154
  extraEnv,
@@ -119,7 +156,8 @@ export class SpecCliAdapter implements CliAdapter {
119
156
  emitTrace: false,
120
157
  transportFactory,
121
158
  extraCliArgs: cliArgs,
122
- });
159
+ };
160
+ this.driver = isV4 ? new FsmDriver(driverOpts) : new SpecDriver(driverOpts);
123
161
  this.driver.subscribe((ev) => this.handleEvent(ev));
124
162
  }
125
163
 
@@ -362,6 +400,10 @@ export class SpecCliAdapter implements CliAdapter {
362
400
  specPath: this.driver.getSpecPath(),
363
401
  cursorPosition: this.driver.getCursorPosition(),
364
402
  completionIdleDebounce: this.driver.getCompletionIdleDebounceState(),
403
+ // v4 FSM live transition table (null for v3 specs). Every outgoing
404
+ // transition from the current state with its per-condition match
405
+ // result + countdown — the canonical "why isn't it moving" answer.
406
+ fsm: this.driver.getFsmDebug?.() ?? null,
365
407
  // Extended fields
366
408
  name: this.cliName,
367
409
  status: this.getStatus().status,
@@ -445,10 +487,10 @@ export class SpecCliAdapter implements CliAdapter {
445
487
  }
446
488
  }
447
489
 
448
- private readCurrentScreenSections(screenText: string): Record<string, string> {
490
+ private readCurrentScreenSections(_screenText: string): Record<string, string> {
449
491
  try {
450
- const ev = evaluate(this.spec, screenText);
451
- return Object.fromEntries(ev.sections.map(section => [section.id, section.text]));
492
+ const sections = this.driver.getSections() ?? [];
493
+ return Object.fromEntries(sections.map(section => [section.id, section.text]));
452
494
  } catch {
453
495
  return {};
454
496
  }
@@ -601,7 +643,13 @@ export class SpecCliAdapter implements CliAdapter {
601
643
  providerSessionId: this.providerSessionId ?? null,
602
644
  sections: this.driver.getSections?.() ?? null,
603
645
  stateHistory: history,
604
- specPath: (this.driver as any).opts?.specPath ?? null,
646
+ specPath: this.driver.getSpecPath?.() ?? null,
647
+ // v4 FSM live transition table — present only for FsmDriver. Lets
648
+ // the panel (and the daemon API) show, for the current instant,
649
+ // every outgoing transition with its per-condition match result
650
+ // and countdown. This is the canonical "why isn't it transitioning"
651
+ // answer — no screenshots needed.
652
+ fsm: this.driver.getFsmDebug?.() ?? null,
605
653
  messages,
606
654
  committedMessages: messages,
607
655
  };
@@ -41,7 +41,7 @@ import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
41
41
  import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
42
42
  import { evaluate, type SpecEvaluation, type TraceEntry } from './evaluator.js';
43
43
  import { loadSpec } from './loader.js';
44
- import type { CliSpec, Control, DelegateTrigger, SectionDef } from './types.js';
44
+ import type { CliSpec, Condition, ChangedCondition, Control, DelegateTrigger, SectionDef } from './types.js';
45
45
  import { LOG } from '../../logging/logger.js';
46
46
 
47
47
  export type DashboardEvent =
@@ -65,6 +65,45 @@ export type DashboardCommand =
65
65
  | { kind: 'cancel' }
66
66
  | { kind: 'shutdown' };
67
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
+
68
107
  export interface SpecDriverOpts {
69
108
  specPath: string;
70
109
  workingDir: string;
@@ -112,6 +151,14 @@ const BUSY_HOLD_MS = 6000;
112
151
  * codex's explicit setting and is barely perceptible to a human caller. */
113
152
  const SUBMIT_DELAY_FLOOR_MS = 200;
114
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
+
115
162
  function countNewlines(s: string): number {
116
163
  let n = 0;
117
164
  for (let i = 0; i < s.length; i += 1) if (s.charCodeAt(i) === 10) n += 1;
@@ -187,7 +234,7 @@ export function matchesCompletionIdleTargetState(
187
234
  }
188
235
  }
189
236
 
190
- export class SpecDriver {
237
+ export class SpecDriver implements ISpecDriver {
191
238
  private spec!: CliSpec;
192
239
  private adapter!: TerminalAdapter;
193
240
  private listeners = new Set<(ev: DashboardEvent) => void>();
@@ -240,6 +287,9 @@ export class SpecDriver {
240
287
  * Used by screen_active_hold_ms to suppress idle downshifts while
241
288
  * the terminal is still actively updating. */
242
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>();
243
293
  /** Timer that re-runs evaluate() once the hold window expires. Needed
244
294
  * because the PTY stops emitting once the agent finishes; without an
245
295
  * explicit wake-up there's nothing to trigger the busy → idle
@@ -482,9 +532,24 @@ export class SpecDriver {
482
532
  const cursor = this.adapter.getCursorPosition();
483
533
  const currentLines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
484
534
  const ev = evaluate(this.spec, screen, cursor, this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined);
535
+ const now = Date.now();
485
536
  // Track when the screen last changed for screen_active_hold_ms.
486
537
  if (this.prevScreenLines.length > 0 && currentLines.join('\n') !== this.prevScreenLines.join('\n')) {
487
- this.lastScreenChangedAt = Date.now();
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
+ }
488
553
  }
489
554
  // Update prevScreenLines for next evaluation's `changed` condition detection.
490
555
  this.prevScreenLines = currentLines;
@@ -500,7 +565,7 @@ export class SpecDriver {
500
565
  let evState = ev.state;
501
566
  const busyHoldMs = this.spec.debounce?.busy_hold_ms ?? BUSY_HOLD_MS;
502
567
  if (this.currentStateId === 'busy' && evState.id === 'idle') {
503
- const ageMs = Date.now() - this.lastBusyAt;
568
+ const ageMs = now - this.lastBusyAt;
504
569
  if (ageMs < busyHoldMs) {
505
570
  // Pin to the last seen busy state directly — currentEval can
506
571
  // already be idle at this point (it tracks the previous tick,
@@ -509,6 +574,37 @@ export class SpecDriver {
509
574
  evState = this.lastBusyState ?? evState;
510
575
  }
511
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
+ }
512
608
  // Modal hold: when in a modal state (approval, picker, etc.) a brief
513
609
  // busy reading should not interrupt the modal. Claude Code streams
514
610
  // body content while the approval modal is visible, causing a spinner
@@ -538,7 +634,6 @@ export class SpecDriver {
538
634
  // approval appeared and its hold expires immediately after dismissal,
539
635
  // causing a false-idle even though the agent is still generating.
540
636
  const postModalGraceMs = busyHoldMs;
541
- const now = Date.now();
542
637
  const recentlyInModal = isModalState(this.currentStateId) || (this.lastModalAt > 0 && now - this.lastModalAt < postModalGraceMs);
543
638
  const recentlyLeftModal = !recentlyInModal && this.lastModalExitAt > 0 && now - this.lastModalExitAt < postModalGraceMs;
544
639
  // screen_active_hold_ms: suppress idle downshift while the screen is
@@ -555,7 +650,6 @@ export class SpecDriver {
555
650
  if (evState.id === 'busy' && completionIdleRule && !recentlyInModal && !recentlyLeftModal && !screenIsActive) {
556
651
  const completionKey = matchesCompletionIdleRule(this.spec, ev, screen);
557
652
  if (completionKey) {
558
- const now = Date.now();
559
653
  if (completionKey !== this.completionIdleKey) {
560
654
  this.completionIdleKey = completionKey;
561
655
  this.completionIdleFirstSeenAt = now;
@@ -741,8 +835,7 @@ export class SpecDriver {
741
835
  // reading during the banner paint as a real idle. After that,
742
836
  // the first non-busy observation is a real prompt-ready signal
743
837
  // and we drain any queued send_message calls.
744
- const graceMs = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
745
- const sinceStart = Date.now() - this.startedAtMs;
838
+ const sinceStart = now - this.startedAtMs;
746
839
  if (!this.idleSeenOnce && evState.id !== 'busy' && sinceStart >= graceMs) {
747
840
  this.idleSeenOnce = true;
748
841
  const queued = this.pendingSends.splice(0);
@@ -972,7 +1065,7 @@ function sectionTextFromSnapshot(spec: CliSpec, screen: string, sectionId: strin
972
1065
  return ev.sections.find(s => s.id === sectionId)?.text ?? null;
973
1066
  }
974
1067
 
975
- function guessExt(mime: string): string {
1068
+ export function guessExt(mime: string): string {
976
1069
  if (/png/i.test(mime)) return '.png';
977
1070
  if (/jpe?g/i.test(mime)) return '.jpg';
978
1071
  if (/gif/i.test(mime)) return '.gif';
@@ -81,7 +81,7 @@ function resolveSize(size: number | string | undefined, total: number): number {
81
81
  * Resolve v3 sections{} object into an ordered array of ResolvedSection.
82
82
  * Two-pass: first anchor/positional, then apply `until` cross-references.
83
83
  */
84
- function resolveSections(
84
+ export function resolveSections(
85
85
  sectionsObj: Record<string, SectionDef>,
86
86
  lines: string[],
87
87
  ): ResolvedSection[] {
@@ -167,7 +167,7 @@ function resolveSections(
167
167
  return resolved;
168
168
  }
169
169
 
170
- function sectionText(sections: ResolvedSection[], sectionId: string | undefined, fullScreen: string): string {
170
+ export function sectionText(sections: ResolvedSection[], sectionId: string | undefined, fullScreen: string): string {
171
171
  if (!sectionId) return fullScreen;
172
172
  const found = sections.find(s => s.id === sectionId);
173
173
  return found ? found.text : '';
@@ -193,7 +193,7 @@ function isAnyCondition(c: Condition): c is AnyCondition {
193
193
  return 'any' in c;
194
194
  }
195
195
 
196
- function evaluateCondition(
196
+ export function evaluateCondition(
197
197
  cond: Condition,
198
198
  sections: ResolvedSection[],
199
199
  fullScreen: string,
@@ -227,8 +227,15 @@ function evaluateCondition(
227
227
  const endRow = cursor.row; // exclusive
228
228
  const currentSlice = curLines.slice(startRow, endRow).join('\n');
229
229
  const prevSlice = prevLines.slice(startRow, endRow).join('\n');
230
- const result = currentSlice !== prevSlice;
231
- trace.push({ kind: 'section', text: `state[${stateId}] changed cond cursor_above=${cond.cursor_above} rows[${startRow},${endRow}) changed=${result}` });
230
+ const didChange = currentSlice !== prevSlice;
231
+ // changed:false means "region is currently stable" stable_ms duration
232
+ // is enforced by the driver, not here.
233
+ const result = cond.changed ? didChange : !didChange;
234
+ const stableSuffix = cond.stable_ms != null ? ` stable_ms=${cond.stable_ms}` : '';
235
+ trace.push({
236
+ kind: result ? 'state_match' : 'state_skip',
237
+ text: `state[${stateId}] changed cond cursor_above=${cond.cursor_above} rows[${startRow},${endRow}) changed=${didChange} expected=${cond.changed}${stableSuffix} result=${result}`,
238
+ });
232
239
  return result;
233
240
  }
234
241
 
@@ -314,7 +321,7 @@ function matchState(
314
321
  return { matched: true, title };
315
322
  }
316
323
 
317
- function extractTitle(
324
+ export function extractTitle(
318
325
  rule: ExtractTitle,
319
326
  sections: ResolvedSection[],
320
327
  fullScreen: string,
@@ -355,7 +362,7 @@ function compileLinePattern(ref: { pattern: string; flags?: string }): RegExp {
355
362
  return new RegExp(ref.pattern, flags);
356
363
  }
357
364
 
358
- function extractButtonsFromRule(
365
+ export function extractButtonsFromRule(
359
366
  rule: ExtractButtons,
360
367
  hay: string,
361
368
  ): { index: number; label: string; key: string }[] {