@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.
- package/dist/index.js +95 -42
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +95 -42
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/fsm-driver.d.ts +26 -10
- package/dist/providers/spec/fsm-evaluator.d.ts +20 -3
- package/dist/providers/spec/fsm-types.d.ts +31 -3
- package/package.json +2 -2
- package/src/providers/spec/fsm-driver.ts +92 -30
- package/src/providers/spec/fsm-evaluator.ts +26 -8
- package/src/providers/spec/fsm-loader.ts +9 -1
- package/src/providers/spec/fsm-types.ts +31 -3
|
@@ -176,8 +176,10 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
176
176
|
private stateEnteredAt;
|
|
177
177
|
private startedAtMs;
|
|
178
178
|
private prevScreenLines;
|
|
179
|
-
/** Per
|
|
180
|
-
*
|
|
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
|
|
311
|
-
* stable_ms conditions can measure quiet time. We record every
|
|
312
|
-
*
|
|
313
|
-
* screen
|
|
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
|
-
/**
|
|
316
|
-
* current state's outgoing transitions, plus
|
|
317
|
-
*
|
|
318
|
-
|
|
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
|
|
10
|
-
* value (0 / undefined → whole-screen, keyed as -1).
|
|
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
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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
|
|
299
|
-
*
|
|
300
|
-
|
|
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
|
|
805
|
-
* stable_ms conditions can measure quiet time. We record every
|
|
806
|
-
*
|
|
807
|
-
* screen
|
|
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
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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 -
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
826
|
-
* current state's outgoing transitions, plus
|
|
827
|
-
*
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
|
|
854
|
+
collectStableDescriptors(t.when, byKey);
|
|
832
855
|
}
|
|
833
|
-
return
|
|
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
|
-
|
|
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) {
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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
|
|
32
|
-
* value (0 / undefined → whole-screen, keyed as -1).
|
|
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
|
-
|
|
86
|
-
|
|
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 =
|
|
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 =
|
|
162
|
-
|
|
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) {
|
|
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
|
|
49
|
-
*
|
|
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
|
-
/**
|
|
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 {
|