@adhdev/daemon-core 0.9.82-rc.449 → 0.9.82-rc.450

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.
@@ -176,8 +176,10 @@ export declare class FsmDriver implements ISpecDriver {
176
176
  private stateEnteredAt;
177
177
  private startedAtMs;
178
178
  private prevScreenLines;
179
- /** Per cursor_above region (key: cursor_above, -1 = whole screen) last
180
- * time that region's content changed. Drives stable_ms conditions. */
179
+ /** Per stable region last time that region's content changed. Drives
180
+ * stable_ms conditions. Key = stableRegionKey(cond): numeric cursor_above
181
+ * (−1 = whole screen), or a `section:<id>` / `<region>#ignore:<pat>` string
182
+ * when the clause scopes to a section or declares an ignore_lines filter. */
181
183
  private regionLastChangedAt;
182
184
  /** Timer that re-runs evaluate() when a time-condition would flip true
183
185
  * with no PTY frame to trigger it. */
@@ -307,15 +309,25 @@ export declare class FsmDriver implements ISpecDriver {
307
309
  private deriveModal;
308
310
  private deriveTitle;
309
311
  private deriveControls;
310
- /** Track which cursor_above regions changed since the previous frame so
311
- * stable_ms conditions can measure quiet time. We record every region
312
- * size referenced by a stable_ms condition in the spec, plus the whole
313
- * screen (-1). */
312
+ /** Track which stable regions changed since the previous frame so
313
+ * stable_ms conditions can measure quiet time. We record every distinct
314
+ * stable region referenced in the current state (numeric cursor_above /
315
+ * whole-screen -1, and named `section:<id>` regions) plus, for each, the
316
+ * optional `ignore_lines` filter that folds into its key.
317
+ *
318
+ * `ignore_lines` is the content-aware fix for the busy→idle wedge: lines
319
+ * matching it are stripped from BOTH frames before the comparison, so a
320
+ * benign residual ticker (bare token counter / elapsed timer that repaints
321
+ * every frame post-generation) no longer resets the clock — while an active
322
+ * spinner line, which does NOT match the benign pattern, still does (the
323
+ * FALSEIDLE2 / FALSEBUSY-B whole-screen invariant is preserved). */
314
324
  private trackRegionChanges;
315
- /** All cursor_above region sizes referenced by stable_ms conditions in the
316
- * current state's outgoing transitions, plus whole-screen. Cached lazily
317
- * per spec load would be nicer but the set is tiny. */
318
- private stableRegionSizes;
325
+ /** Every distinct stable-region descriptor referenced by stable_ms
326
+ * conditions in the current state's outgoing transitions, plus the plain
327
+ * whole-screen key (-1) that other machinery (stall watchdog) reads.
328
+ * De-duplicated by key. Cached lazily per spec load would be nicer but the
329
+ * set is tiny. */
330
+ private stableRegionDescriptors;
319
331
  /** Schedule a re-evaluation for the soonest pending time-condition on any
320
332
  * outgoing transition (elapsed_ms / stable_ms / min_hold_ms). Without
321
333
  * this, a state whose only exit is time-based would never leave once the
@@ -425,4 +437,8 @@ export declare class FsmDriver implements ISpecDriver {
425
437
  private specTag;
426
438
  private emit;
427
439
  }
440
+ /** Drop lines matching `ignoreRe` so a per-frame repaint confined to them does
441
+ * not register as a region change. No filter → lines returned unchanged.
442
+ * Exported for unit tests of the stable_ms `ignore_lines` change-detection. */
443
+ export declare function filterIgnoredLines(lines: string[], ignoreRe: RegExp | undefined): string[];
428
444
  export {};
@@ -6,10 +6,11 @@ export interface FsmClock {
6
6
  now: number;
7
7
  /** When the current state was entered (ms). */
8
8
  stateEnteredAt: number;
9
- /** Last-changed timestamp per cursor_above region. Key = cursor_above
10
- * value (0 / undefined → whole-screen, keyed as -1). Missing key means
9
+ /** Last-changed timestamp per stable region. Numeric key = cursor_above
10
+ * value (0 / undefined → whole-screen, keyed as -1). String key =
11
+ * `section:<id>` for a section-scoped stable region. Missing key means
11
12
  * "never observed changing" → treated as stable since stateEnteredAt. */
12
- regionLastChangedAt: Map<number, number>;
13
+ regionLastChangedAt: Map<number | string, number>;
13
14
  }
14
15
  /** Per-condition evaluation detail — the debugging payload. */
15
16
  export interface CondResult {
@@ -54,6 +55,22 @@ export interface FsmEvaluation {
54
55
  /** v3-compatible trace lines for the legacy inspector. */
55
56
  trace: TraceEntry[];
56
57
  }
58
+ /** The `regionLastChangedAt` key for a stable condition. The key must capture
59
+ * everything that makes two stable clauses watch a DIFFERENT change signal:
60
+ * the geometric region (`section` wins over `cursor_above`, else whole-screen)
61
+ * AND the `ignore_lines` filter (two clauses on the same region but different
62
+ * ignore patterns see different "did it change" answers). Kept in one place so
63
+ * the driver (which populates the map) and the evaluator (which reads it)
64
+ * agree on the key.
65
+ *
66
+ * Bare whole-screen with no ignore filter keeps its historical numeric key -1
67
+ * so existing callers/tests that seed `regionLastChangedAt` with [-1, ...]
68
+ * keep working unchanged. */
69
+ export declare function stableRegionKey(cond: {
70
+ section?: string;
71
+ cursor_above?: number;
72
+ ignore_lines?: string;
73
+ }): number | string;
57
74
  /**
58
75
  * Evaluate the FSM: given the current state id and screen, return every
59
76
  * outgoing transition annotated with why it fires/doesn't, plus the one that
@@ -3,12 +3,40 @@ import type { RegexCondition, ChangedCondition, Control, NotificationRule, Deleg
3
3
  export interface ElapsedCondition {
4
4
  elapsed_ms: number;
5
5
  }
6
- /** True once the region `cursor_above` lines above the cursor has been
7
- * unchanged for `ms`. A stability gate — the inverse of a busy signal. */
6
+ /** True once a region has been unchanged for `ms`. A stability gate the
7
+ * inverse of a busy signal.
8
+ *
9
+ * Region selection (precedence order):
10
+ * - `section` : a named section from the spec's `sections{}` (e.g. "body").
11
+ * Only lines inside that section are watched for change.
12
+ * - `cursor_above`: the N lines directly above the cursor.
13
+ * - neither : the whole screen (default).
14
+ *
15
+ * `ignore_lines` is orthogonal to region choice: lines matching it are
16
+ * stripped from BOTH frames before the change comparison, so a per-frame
17
+ * animation on those lines cannot reset the stable clock. This is the
18
+ * content-aware escape hatch for the busy→idle wedge — a benign residual
19
+ * ticker (a bare token counter / elapsed timer that repaints every frame
20
+ * after generation has finished) is filtered out, so a genuinely settled
21
+ * transcript can reach `stable_ms`. It is deliberately CONTENT-based, not
22
+ * geometric: an ACTIVE spinner line (glyph + esc/token trailer) does NOT
23
+ * match the benign pattern, so a real below-prompt spinner tick still resets
24
+ * the clock and holds busy (the FALSEIDLE2 / FALSEBUSY-B invariant). */
8
25
  export interface StableCondition {
9
26
  stable_ms: number;
10
- /** Lines above the cursor that must be stable. Default: whole screen. */
27
+ /** Named section (from `sections{}`) that must be stable. Takes precedence
28
+ * over `cursor_above`. */
29
+ section?: string;
30
+ /** Lines above the cursor that must be stable. Default: whole screen.
31
+ * Ignored when `section` is set. */
11
32
  cursor_above?: number;
33
+ /** Regex (line-tested with `m` flag). Lines matching it are removed from
34
+ * both the current and previous frame before deciding whether the region
35
+ * changed — a per-frame repaint confined to these lines does NOT reset the
36
+ * stability clock. Use for benign residual animation (token counter /
37
+ * elapsed timer). Must NOT match an active-spinner line, or a real spinner
38
+ * tick would be masked. */
39
+ ignore_lines?: string;
12
40
  }
13
41
  export interface FsmAllCondition {
14
42
  all: FsmCondition[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.449",
3
+ "version": "0.9.82-rc.450",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.449",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.450",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -29,7 +29,7 @@ import {
29
29
  resolveSections, sectionText, extractTitle, extractButtonsFromRule,
30
30
  type ResolvedSection, type TraceEntry,
31
31
  } from './evaluator.js';
32
- import { evaluateFsm, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
32
+ import { evaluateFsm, stableRegionKey, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
33
33
  import {
34
34
  type CliSpecV4, type FsmState, type FsmTransition,
35
35
  initialState, stateById, statusForState, modalKindForState, outgoingTransitions,
@@ -295,9 +295,11 @@ export class FsmDriver implements ISpecDriver {
295
295
  // ── Clock bookkeeping for time conditions.
296
296
  private startedAtMs = 0;
297
297
  private prevScreenLines: string[] = [];
298
- /** Per cursor_above region (key: cursor_above, -1 = whole screen) last
299
- * time that region's content changed. Drives stable_ms conditions. */
300
- private regionLastChangedAt = new Map<number, number>();
298
+ /** Per stable region last time that region's content changed. Drives
299
+ * stable_ms conditions. Key = stableRegionKey(cond): numeric cursor_above
300
+ * (−1 = whole screen), or a `section:<id>` / `<region>#ignore:<pat>` string
301
+ * when the clause scopes to a section or declares an ignore_lines filter. */
302
+ private regionLastChangedAt = new Map<number | string, number>();
301
303
  /** Timer that re-runs evaluate() when a time-condition would flip true
302
304
  * with no PTY frame to trigger it. */
303
305
  private wakeTimer: ReturnType<typeof setTimeout> | null = null;
@@ -801,36 +803,57 @@ export class FsmDriver implements ISpecDriver {
801
803
  return out;
802
804
  }
803
805
 
804
- /** Track which cursor_above regions changed since the previous frame so
805
- * stable_ms conditions can measure quiet time. We record every region
806
- * size referenced by a stable_ms condition in the spec, plus the whole
807
- * screen (-1). */
806
+ /** Track which stable regions changed since the previous frame so
807
+ * stable_ms conditions can measure quiet time. We record every distinct
808
+ * stable region referenced in the current state (numeric cursor_above /
809
+ * whole-screen -1, and named `section:<id>` regions) plus, for each, the
810
+ * optional `ignore_lines` filter that folds into its key.
811
+ *
812
+ * `ignore_lines` is the content-aware fix for the busy→idle wedge: lines
813
+ * matching it are stripped from BOTH frames before the comparison, so a
814
+ * benign residual ticker (bare token counter / elapsed timer that repaints
815
+ * every frame post-generation) no longer resets the clock — while an active
816
+ * spinner line, which does NOT match the benign pattern, still does (the
817
+ * FALSEIDLE2 / FALSEBUSY-B whole-screen invariant is preserved). */
808
818
  private trackRegionChanges(currentLines: string[], cursor: { row: number; col: number }, now: number): void {
809
819
  if (this.prevScreenLines.length === 0) return;
810
- const sizes = this.stableRegionSizes();
811
- for (const size of sizes) {
812
- let cur: string; let prev: string;
813
- if (size < 0) {
814
- cur = currentLines.join('\n');
815
- prev = this.prevScreenLines.join('\n');
820
+ const descs = this.stableRegionDescriptors();
821
+ // Section ranges depend on screen content, so resolve per-frame for both
822
+ // frames but only when some tracked region is actually section-scoped.
823
+ const needsSections = descs.some(d => !!d.section);
824
+ const curSections = needsSections ? resolveSections(this.spec.sections ?? {}, currentLines) : [];
825
+ const prevSections = needsSections ? resolveSections(this.spec.sections ?? {}, this.prevScreenLines) : [];
826
+ for (const d of descs) {
827
+ let curLines: string[]; let prevLines: string[];
828
+ if (d.section) {
829
+ curLines = sliceSectionLines(currentLines, curSections, d.section);
830
+ prevLines = sliceSectionLines(this.prevScreenLines, prevSections, d.section);
831
+ } else if (!d.cursor_above || d.cursor_above <= 0) {
832
+ curLines = currentLines;
833
+ prevLines = this.prevScreenLines;
816
834
  } else {
817
- const start = Math.max(0, cursor.row - size);
818
- cur = currentLines.slice(start, cursor.row).join('\n');
819
- prev = this.prevScreenLines.slice(start, cursor.row).join('\n');
835
+ const start = Math.max(0, cursor.row - d.cursor_above);
836
+ curLines = currentLines.slice(start, cursor.row);
837
+ prevLines = this.prevScreenLines.slice(start, cursor.row);
820
838
  }
821
- if (cur !== prev) this.regionLastChangedAt.set(size, now);
839
+ const cur = filterIgnoredLines(curLines, d.ignoreRe).join('\n');
840
+ const prev = filterIgnoredLines(prevLines, d.ignoreRe).join('\n');
841
+ if (cur !== prev) this.regionLastChangedAt.set(d.key, now);
822
842
  }
823
843
  }
824
844
 
825
- /** All cursor_above region sizes referenced by stable_ms conditions in the
826
- * current state's outgoing transitions, plus whole-screen. Cached lazily
827
- * per spec load would be nicer but the set is tiny. */
828
- private stableRegionSizes(): Set<number> {
829
- const sizes = new Set<number>([-1]);
845
+ /** Every distinct stable-region descriptor referenced by stable_ms
846
+ * conditions in the current state's outgoing transitions, plus the plain
847
+ * whole-screen key (-1) that other machinery (stall watchdog) reads.
848
+ * De-duplicated by key. Cached lazily per spec load would be nicer but the
849
+ * set is tiny. */
850
+ private stableRegionDescriptors(): StableRegionDescriptor[] {
851
+ const byKey = new Map<number | string, StableRegionDescriptor>();
852
+ byKey.set(-1, { key: -1 });
830
853
  for (const t of outgoingTransitions(this.spec, this.currentStateId)) {
831
- collectStableSizes(t.when, sizes);
854
+ collectStableDescriptors(t.when, byKey);
832
855
  }
833
- return sizes;
856
+ return [...byKey.values()];
834
857
  }
835
858
 
836
859
  /** Schedule a re-evaluation for the soonest pending time-condition on any
@@ -1409,11 +1432,50 @@ function findStable(c: import('./fsm-evaluator.js').CondResult): { totalMs: numb
1409
1432
  return null;
1410
1433
  }
1411
1434
 
1412
- function collectStableSizes(when: FsmTransition['when'], sizes: Set<number>): void {
1435
+ /** Resolved description of one stable region the driver must track: its map
1436
+ * key, the geometry (section / cursor_above / whole-screen), and a compiled
1437
+ * `ignore_lines` matcher. */
1438
+ interface StableRegionDescriptor {
1439
+ key: number | string;
1440
+ section?: string;
1441
+ cursor_above?: number;
1442
+ ignoreRe?: RegExp;
1443
+ }
1444
+
1445
+ function collectStableDescriptors(when: FsmTransition['when'], byKey: Map<number | string, StableRegionDescriptor>): void {
1413
1446
  if (!when) return;
1414
1447
  const w = when as any;
1415
- if ('stable_ms' in w) { sizes.add(w.cursor_above && w.cursor_above > 0 ? w.cursor_above : -1); return; }
1416
- if ('all' in w) { for (const c of w.all) collectStableSizes(c, sizes); return; }
1417
- if ('any' in w) { for (const c of w.any) collectStableSizes(c, sizes); return; }
1418
- if ('not' in w) { collectStableSizes(w.not, sizes); return; }
1448
+ if ('stable_ms' in w) {
1449
+ const key = stableRegionKey(w);
1450
+ if (!byKey.has(key)) {
1451
+ let ignoreRe: RegExp | undefined;
1452
+ if (w.ignore_lines) {
1453
+ // Compile once here; a bad pattern is validated at load time, so
1454
+ // this is best-effort and simply skips the filter if it throws.
1455
+ try { ignoreRe = new RegExp(w.ignore_lines, 'm'); } catch { /* validated at load */ }
1456
+ }
1457
+ byKey.set(key, { key, section: w.section, cursor_above: w.cursor_above, ignoreRe });
1458
+ }
1459
+ return;
1460
+ }
1461
+ if ('all' in w) { for (const c of w.all) collectStableDescriptors(c, byKey); return; }
1462
+ if ('any' in w) { for (const c of w.any) collectStableDescriptors(c, byKey); return; }
1463
+ if ('not' in w) { collectStableDescriptors(w.not, byKey); return; }
1464
+ }
1465
+
1466
+ /** Lines of section `id` on the given frame, or [] if that section is absent
1467
+ * this frame. Used to compute per-frame change of a section-scoped stable
1468
+ * region. */
1469
+ function sliceSectionLines(lines: string[], sections: ResolvedSection[], id: string): string[] {
1470
+ const sec = sections.find(s => s.id === id);
1471
+ if (!sec) return [];
1472
+ return lines.slice(sec.fromLine, sec.toLine);
1473
+ }
1474
+
1475
+ /** Drop lines matching `ignoreRe` so a per-frame repaint confined to them does
1476
+ * not register as a region change. No filter → lines returned unchanged.
1477
+ * Exported for unit tests of the stable_ms `ignore_lines` change-detection. */
1478
+ export function filterIgnoredLines(lines: string[], ignoreRe: RegExp | undefined): string[] {
1479
+ if (!ignoreRe) return lines;
1480
+ return lines.filter(l => !ignoreRe.test(l));
1419
1481
  }
@@ -28,10 +28,11 @@ export interface FsmClock {
28
28
  now: number;
29
29
  /** When the current state was entered (ms). */
30
30
  stateEnteredAt: number;
31
- /** Last-changed timestamp per cursor_above region. Key = cursor_above
32
- * value (0 / undefined → whole-screen, keyed as -1). Missing key means
31
+ /** Last-changed timestamp per stable region. Numeric key = cursor_above
32
+ * value (0 / undefined → whole-screen, keyed as -1). String key =
33
+ * `section:<id>` for a section-scoped stable region. Missing key means
33
34
  * "never observed changing" → treated as stable since stateEnteredAt. */
34
- regionLastChangedAt: Map<number, number>;
35
+ regionLastChangedAt: Map<number | string, number>;
35
36
  }
36
37
 
37
38
  /** Per-condition evaluation detail — the debugging payload. */
@@ -82,8 +83,23 @@ export interface FsmEvaluation {
82
83
 
83
84
  const WHOLE_SCREEN = -1;
84
85
 
85
- function regionKey(cursorAbove: number | undefined): number {
86
- return cursorAbove && cursorAbove > 0 ? cursorAbove : WHOLE_SCREEN;
86
+ /** The `regionLastChangedAt` key for a stable condition. The key must capture
87
+ * everything that makes two stable clauses watch a DIFFERENT change signal:
88
+ * the geometric region (`section` wins over `cursor_above`, else whole-screen)
89
+ * AND the `ignore_lines` filter (two clauses on the same region but different
90
+ * ignore patterns see different "did it change" answers). Kept in one place so
91
+ * the driver (which populates the map) and the evaluator (which reads it)
92
+ * agree on the key.
93
+ *
94
+ * Bare whole-screen with no ignore filter keeps its historical numeric key -1
95
+ * so existing callers/tests that seed `regionLastChangedAt` with [-1, ...]
96
+ * keep working unchanged. */
97
+ export function stableRegionKey(cond: { section?: string; cursor_above?: number; ignore_lines?: string }): number | string {
98
+ const region = cond.section
99
+ ? `section:${cond.section}`
100
+ : (cond.cursor_above && cond.cursor_above > 0 ? cond.cursor_above : WHOLE_SCREEN);
101
+ if (!cond.ignore_lines) return region;
102
+ return `${region}#ignore:${cond.ignore_lines}`;
87
103
  }
88
104
 
89
105
  function isRegex(c: FsmCondition): c is import('./types.js').RegexCondition {
@@ -150,7 +166,7 @@ function evalCond(
150
166
  return { kind: 'elapsed', result, detail: `elapsed ${age}ms / ${cond.elapsed_ms}ms`, remainingMs };
151
167
  }
152
168
  if (isStable(cond)) {
153
- const key = regionKey(cond.cursor_above);
169
+ const key = stableRegionKey(cond);
154
170
  // If we never saw the region change, treat it as stable since the
155
171
  // state was entered (conservative — avoids a premature "stable" on
156
172
  // the very first frame).
@@ -158,8 +174,10 @@ function evalCond(
158
174
  const stableFor = clock.now - lastChanged;
159
175
  const result = stableFor >= cond.stable_ms;
160
176
  const remainingMs = result ? 0 : cond.stable_ms - stableFor;
161
- const where = key === WHOLE_SCREEN ? 'screen' : `cursor_above=${cond.cursor_above}`;
162
- return { kind: 'stable', result, detail: `stable ${where} ${stableFor}ms / ${cond.stable_ms}ms`, remainingMs };
177
+ const where = cond.section ? `section=${cond.section}`
178
+ : cond.cursor_above && cond.cursor_above > 0 ? `cursor_above=${cond.cursor_above}` : 'screen';
179
+ const ign = cond.ignore_lines ? ' (ignore_lines)' : '';
180
+ return { kind: 'stable', result, detail: `stable ${where}${ign} ${stableFor}ms / ${cond.stable_ms}ms`, remainingMs };
163
181
  }
164
182
  // regex / changed → shared evaluator (operates on v3 Condition shape)
165
183
  if (isRegex(cond) || isChanged(cond)) {
@@ -114,7 +114,15 @@ function validateCondition(c: FsmCondition, sectionIds: Set<string>, path: strin
114
114
  }
115
115
  if ('cursor_above' in w && 'changed' in w) return errs;
116
116
  if ('elapsed_ms' in w) { if (typeof w.elapsed_ms !== 'number') errs.push(`${path}.elapsed_ms must be a number`); return errs; }
117
- if ('stable_ms' in w) { if (typeof w.stable_ms !== 'number') errs.push(`${path}.stable_ms must be a number`); return errs; }
117
+ if ('stable_ms' in w) {
118
+ if (typeof w.stable_ms !== 'number') errs.push(`${path}.stable_ms must be a number`);
119
+ if (w.section && !sectionIds.has(w.section)) errs.push(`${path}.section "${w.section}" unknown`);
120
+ if (w.ignore_lines !== undefined) {
121
+ if (typeof w.ignore_lines !== 'string') errs.push(`${path}.ignore_lines must be a string`);
122
+ else try { new RegExp(w.ignore_lines, 'm'); } catch (e) { errs.push(`${path}.ignore_lines invalid regex: ${(e as Error).message}`); }
123
+ }
124
+ return errs;
125
+ }
118
126
  errs.push(`${path} is not a recognized condition`);
119
127
  return errs;
120
128
  }
@@ -45,12 +45,40 @@ export interface ElapsedCondition {
45
45
  elapsed_ms: number;
46
46
  }
47
47
 
48
- /** True once the region `cursor_above` lines above the cursor has been
49
- * unchanged for `ms`. A stability gate — the inverse of a busy signal. */
48
+ /** True once a region has been unchanged for `ms`. A stability gate the
49
+ * inverse of a busy signal.
50
+ *
51
+ * Region selection (precedence order):
52
+ * - `section` : a named section from the spec's `sections{}` (e.g. "body").
53
+ * Only lines inside that section are watched for change.
54
+ * - `cursor_above`: the N lines directly above the cursor.
55
+ * - neither : the whole screen (default).
56
+ *
57
+ * `ignore_lines` is orthogonal to region choice: lines matching it are
58
+ * stripped from BOTH frames before the change comparison, so a per-frame
59
+ * animation on those lines cannot reset the stable clock. This is the
60
+ * content-aware escape hatch for the busy→idle wedge — a benign residual
61
+ * ticker (a bare token counter / elapsed timer that repaints every frame
62
+ * after generation has finished) is filtered out, so a genuinely settled
63
+ * transcript can reach `stable_ms`. It is deliberately CONTENT-based, not
64
+ * geometric: an ACTIVE spinner line (glyph + esc/token trailer) does NOT
65
+ * match the benign pattern, so a real below-prompt spinner tick still resets
66
+ * the clock and holds busy (the FALSEIDLE2 / FALSEBUSY-B invariant). */
50
67
  export interface StableCondition {
51
68
  stable_ms: number;
52
- /** Lines above the cursor that must be stable. Default: whole screen. */
69
+ /** Named section (from `sections{}`) that must be stable. Takes precedence
70
+ * over `cursor_above`. */
71
+ section?: string;
72
+ /** Lines above the cursor that must be stable. Default: whole screen.
73
+ * Ignored when `section` is set. */
53
74
  cursor_above?: number;
75
+ /** Regex (line-tested with `m` flag). Lines matching it are removed from
76
+ * both the current and previous frame before deciding whether the region
77
+ * changed — a per-frame repaint confined to these lines does NOT reset the
78
+ * stability clock. Use for benign residual animation (token counter /
79
+ * elapsed timer). Must NOT match an active-spinner line, or a real spinner
80
+ * tick would be masked. */
81
+ ignore_lines?: string;
54
82
  }
55
83
 
56
84
  export interface FsmAllCondition {