@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.
- package/dist/index.js +2283 -1147
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2280 -1145
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/cli-adapter.d.ts +1 -0
- package/dist/providers/spec/driver.d.ts +53 -1
- package/dist/providers/spec/evaluator.d.ts +17 -1
- package/dist/providers/spec/fsm-driver.d.ts +110 -0
- package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
- package/dist/providers/spec/fsm-loader.d.ts +14 -0
- package/dist/providers/spec/fsm-types.d.ts +91 -0
- package/dist/providers/spec/schema.gen.d.ts +8 -2
- package/dist/providers/spec/types.d.ts +5 -2
- package/package.json +1 -1
- package/src/commands/router.ts +192 -0
- package/src/providers/provider-loader.ts +24 -14
- package/src/providers/spec/cli-adapter.ts +62 -14
- package/src/providers/spec/driver.ts +102 -9
- package/src/providers/spec/evaluator.ts +14 -7
- package/src/providers/spec/fsm-driver.ts +663 -0
- package/src/providers/spec/fsm-evaluator.ts +255 -0
- package/src/providers/spec/fsm-loader.ts +102 -0
- package/src/providers/spec/fsm-types.ts +184 -0
- package/src/providers/spec/schema.gen.ts +2 -1
- package/src/providers/spec/types.ts +5 -2
|
@@ -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
|
-
|
|
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 (
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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 {
|
|
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:
|
|
63
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
490
|
+
private readCurrentScreenSections(_screenText: string): Record<string, string> {
|
|
449
491
|
try {
|
|
450
|
-
const
|
|
451
|
-
return Object.fromEntries(
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
231
|
-
|
|
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 }[] {
|