@adhdev/daemon-core 0.9.82-rc.354 → 0.9.82-rc.356
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 +637 -209
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +637 -209
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-event-trace.d.ts +21 -0
- package/dist/mesh/mesh-runtime-store.d.ts +1 -1
- package/dist/mesh/mesh-work-queue.d.ts +1 -1
- package/dist/providers/cli-provider-instance.d.ts +2 -0
- package/dist/providers/spec/adapter.d.ts +22 -0
- package/dist/providers/spec/cli-adapter.d.ts +6 -0
- package/dist/providers/spec/evaluator.d.ts +1 -0
- package/dist/providers/spec/fsm-driver.d.ts +49 -7
- package/dist/providers/spec/fsm-evaluator.d.ts +4 -0
- package/dist/providers/spec/types.d.ts +47 -5
- package/package.json +2 -2
- package/src/commands/router.ts +19 -6
- package/src/mesh/mesh-event-trace.ts +67 -0
- package/src/mesh/mesh-events-coordinator.ts +117 -12
- package/src/mesh/mesh-events-pending.ts +33 -0
- package/src/mesh/mesh-events-stale.ts +3 -1
- package/src/mesh/mesh-reconcile-loop.ts +47 -0
- package/src/mesh/mesh-runtime-store.ts +18 -2
- package/src/mesh/mesh-work-queue.ts +8 -1
- package/src/providers/cli-provider-instance.ts +86 -4
- package/src/providers/spec/adapter.ts +67 -0
- package/src/providers/spec/cli-adapter.ts +63 -7
- package/src/providers/spec/evaluator.ts +38 -13
- package/src/providers/spec/fsm-driver.ts +158 -14
- package/src/providers/spec/fsm-evaluator.ts +19 -2
- package/src/providers/spec/types.ts +41 -5
|
@@ -51,6 +51,43 @@ export interface TerminalAdapterHandlers {
|
|
|
51
51
|
tick?(): void;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* One entry in the PTY input/output event timeline (debug-only). Captured at
|
|
56
|
+
* the single common point every spec@4 provider funnels through — this adapter
|
|
57
|
+
* — so the Spec Debug Snapshot can answer "what input / output preceded a status
|
|
58
|
+
* transition?". Observation only; nothing here feeds the FSM decision.
|
|
59
|
+
*/
|
|
60
|
+
export interface SpecPtyEvent {
|
|
61
|
+
/** Wall-clock ms. */
|
|
62
|
+
ts: number;
|
|
63
|
+
kind: 'spawn' | 'input' | 'output' | 'resize' | 'cursor' | 'exit';
|
|
64
|
+
/** Human-readable, control-char-escaped, length-capped preview. */
|
|
65
|
+
content: string;
|
|
66
|
+
/** Raw byte length before truncation (output/input only). */
|
|
67
|
+
bytes?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MAX_PTY_EVENTS = 300;
|
|
71
|
+
const EVENT_CONTENT_CAP = 240;
|
|
72
|
+
|
|
73
|
+
/** Escape control characters into a visible form so the timeline is readable
|
|
74
|
+
* (CR/LF/ESC/tab become \r \n \x1b \t; other C0 controls become \xNN). */
|
|
75
|
+
function escapeControl(text: string): string {
|
|
76
|
+
// eslint-disable-next-line no-control-regex
|
|
77
|
+
return String(text).replace(/[\x00-\x1f\x7f]/g, (ch) => {
|
|
78
|
+
const code = ch.charCodeAt(0);
|
|
79
|
+
if (ch === '\r') return '\\r';
|
|
80
|
+
if (ch === '\n') return '\\n';
|
|
81
|
+
if (ch === '\t') return '\\t';
|
|
82
|
+
if (code === 0x1b) return '\\x1b';
|
|
83
|
+
return '\\x' + code.toString(16).padStart(2, '0');
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function capPreview(text: string): string {
|
|
88
|
+
return text.length > EVENT_CONTENT_CAP ? text.slice(0, EVENT_CONTENT_CAP) + `…(+${text.length - EVENT_CONTENT_CAP})` : text;
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
export class TerminalAdapter {
|
|
55
92
|
private rows: number;
|
|
56
93
|
private cols: number;
|
|
@@ -62,6 +99,9 @@ export class TerminalAdapter {
|
|
|
62
99
|
private screenTimer: ReturnType<typeof setTimeout> | null = null;
|
|
63
100
|
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
64
101
|
private lastScreen = '';
|
|
102
|
+
/** Debug-only ring buffer of PTY input/output/resize/cursor events. */
|
|
103
|
+
private events: SpecPtyEvent[] = [];
|
|
104
|
+
private lastCursorKey = '';
|
|
65
105
|
|
|
66
106
|
constructor(
|
|
67
107
|
private readonly opts: TerminalAdapterOpts,
|
|
@@ -95,10 +135,12 @@ export class TerminalAdapter {
|
|
|
95
135
|
cols: this.cols,
|
|
96
136
|
rows: this.rows,
|
|
97
137
|
});
|
|
138
|
+
this.recordEvent('spawn', `${this.opts.binary} (${this.cols}x${this.rows})`);
|
|
98
139
|
this.handlers.init?.({ pid: this.pty.pid });
|
|
99
140
|
this.pty.onData((chunk) => this.onChunk(chunk));
|
|
100
141
|
this.pty.onExit((info) => {
|
|
101
142
|
this.stopTimers();
|
|
143
|
+
this.recordEvent('exit', `exitCode=${typeof info.exitCode === 'number' ? info.exitCode : 0}`);
|
|
102
144
|
this.handlers.on_exit?.({ exitCode: typeof info.exitCode === 'number' ? info.exitCode : 0 });
|
|
103
145
|
this.pty = null;
|
|
104
146
|
});
|
|
@@ -109,6 +151,7 @@ export class TerminalAdapter {
|
|
|
109
151
|
|
|
110
152
|
resize(cols: number, rows: number): void {
|
|
111
153
|
this.cols = cols; this.rows = rows;
|
|
154
|
+
this.recordEvent('resize', `${cols}x${rows}`);
|
|
112
155
|
this.pty?.resize(cols, rows);
|
|
113
156
|
this.screen.resize(rows, cols);
|
|
114
157
|
}
|
|
@@ -133,9 +176,24 @@ export class TerminalAdapter {
|
|
|
133
176
|
}
|
|
134
177
|
|
|
135
178
|
send_keys(text: string): void {
|
|
179
|
+
this.recordEvent('input', capPreview(escapeControl(text)), text.length);
|
|
136
180
|
this.pty?.write(text);
|
|
137
181
|
}
|
|
138
182
|
|
|
183
|
+
/** Debug-only: most-recent PTY input/output/resize/cursor events, oldest
|
|
184
|
+
* first. Pure observation — never consulted by the FSM. */
|
|
185
|
+
getEventTimeline(limit = MAX_PTY_EVENTS): SpecPtyEvent[] {
|
|
186
|
+
const n = Math.max(0, Math.min(limit, this.events.length));
|
|
187
|
+
return this.events.slice(this.events.length - n);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private recordEvent(kind: SpecPtyEvent['kind'], content: string, bytes?: number): void {
|
|
191
|
+
const ev: SpecPtyEvent = { ts: Date.now(), kind, content };
|
|
192
|
+
if (typeof bytes === 'number') ev.bytes = bytes;
|
|
193
|
+
this.events.push(ev);
|
|
194
|
+
if (this.events.length > MAX_PTY_EVENTS) this.events.splice(0, this.events.length - MAX_PTY_EVENTS);
|
|
195
|
+
}
|
|
196
|
+
|
|
139
197
|
kill(): void {
|
|
140
198
|
this.stopTimers();
|
|
141
199
|
try { this.pty?.kill(); } catch { /* ignore */ }
|
|
@@ -144,6 +202,7 @@ export class TerminalAdapter {
|
|
|
144
202
|
}
|
|
145
203
|
|
|
146
204
|
private onChunk(chunk: string): void {
|
|
205
|
+
this.recordEvent('output', capPreview(escapeControl(chunk)), chunk.length);
|
|
147
206
|
try { this.handlers.on_pty_data?.(chunk); } catch { /* user side */ }
|
|
148
207
|
this.screen.write(chunk);
|
|
149
208
|
// Coalesce snapshot emission — rapid bursts shouldn't fire 200x.
|
|
@@ -151,6 +210,14 @@ export class TerminalAdapter {
|
|
|
151
210
|
this.screenTimer = setTimeout(() => {
|
|
152
211
|
this.screenTimer = null;
|
|
153
212
|
const snap = this.computeScreen();
|
|
213
|
+
// Record cursor movement at the (debounced) screen-change boundary
|
|
214
|
+
// rather than per output chunk, so the timeline isn't flooded.
|
|
215
|
+
const cur = this.screen.getCursorPosition();
|
|
216
|
+
const curKey = `${cur.row},${cur.col}`;
|
|
217
|
+
if (curKey !== this.lastCursorKey) {
|
|
218
|
+
this.lastCursorKey = curKey;
|
|
219
|
+
this.recordEvent('cursor', `(${cur.row},${cur.col})`);
|
|
220
|
+
}
|
|
154
221
|
if (snap === this.lastScreen) return;
|
|
155
222
|
this.lastScreen = snap;
|
|
156
223
|
try { this.handlers.on_screen_changed?.(snap); } catch { /* user side */ }
|
|
@@ -413,11 +413,18 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
413
413
|
choiceLabel: string | undefined,
|
|
414
414
|
): Promise<unknown> {
|
|
415
415
|
// Open + wait so the choice list is on screen before we resolve the
|
|
416
|
-
// label → index mapping
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
416
|
+
// label → index mapping. The picker is normally ALREADY open here (a
|
|
417
|
+
// preceding list invoke leaves it rendered), so only send the trigger
|
|
418
|
+
// when it is not on screen. Re-sending the trigger to an open picker is
|
|
419
|
+
// NOT a harmless no-op on claude-cli: the trailing CR of `/model\r`
|
|
420
|
+
// lands as Enter on the cursor's current row and commits the wrong
|
|
421
|
+
// model before we navigate. De-dup the open to avoid that.
|
|
422
|
+
let options = this.extractPickerChoicesIfRendered(action);
|
|
423
|
+
if (!options) {
|
|
424
|
+
this.driver.dispatch({ kind: 'click_control', control_id: ctl.id });
|
|
425
|
+
await this.waitForPickerRendered(action);
|
|
426
|
+
options = this.extractPickerChoices(action);
|
|
427
|
+
}
|
|
421
428
|
|
|
422
429
|
let index = choiceIndex;
|
|
423
430
|
if ((index == null || !Number.isFinite(index)) && choiceLabel) {
|
|
@@ -432,8 +439,34 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
432
439
|
return { ok: false, error: 'choiceIndex or choiceLabel required to select' };
|
|
433
440
|
}
|
|
434
441
|
|
|
435
|
-
|
|
436
|
-
|
|
442
|
+
if (action.select_mode === 'arrow_keys') {
|
|
443
|
+
// Cursor-list picker (claude-cli /model): number keys are ignored.
|
|
444
|
+
// The cursor starts on the active row (extract flags it `current`);
|
|
445
|
+
// step it to the target row with arrows, then confirm.
|
|
446
|
+
const current = options.find(o => o.current);
|
|
447
|
+
if (current == null) {
|
|
448
|
+
// Without a known cursor position a blind Enter would commit
|
|
449
|
+
// whatever row the cursor sits on — fail loud instead.
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
error: 'arrow-nav picker: current cursor row not detected on screen',
|
|
453
|
+
controlResult: { options: options.map(o => ({ value: o.label, label: o.label, current: o.current })) },
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const up = action.cursor_keys?.up ?? '[A';
|
|
457
|
+
const down = action.cursor_keys?.down ?? '[B';
|
|
458
|
+
const delta = index - current.index;
|
|
459
|
+
const step = delta >= 0 ? down : up;
|
|
460
|
+
const nav = step.repeat(Math.abs(delta));
|
|
461
|
+
// Confirm key = submit_key with the (unused) {index} placeholder
|
|
462
|
+
// stripped — e.g. `{index}\r` → `\r`.
|
|
463
|
+
const confirm = (action.submit_key || '\r').replace(/\{index\}/g, '') || '\r';
|
|
464
|
+
if (nav) this.driver.dispatch({ kind: 'pty_write', data: nav });
|
|
465
|
+
this.driver.dispatch({ kind: 'pty_write', data: confirm });
|
|
466
|
+
} else {
|
|
467
|
+
const keys = (action.submit_key || '{index}\r').replace(/\{index\}/g, String(index));
|
|
468
|
+
this.driver.dispatch({ kind: 'pty_write', data: keys });
|
|
469
|
+
}
|
|
437
470
|
const selected = options.find(o => o.index === index);
|
|
438
471
|
return {
|
|
439
472
|
ok: true,
|
|
@@ -446,6 +479,23 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
446
479
|
};
|
|
447
480
|
}
|
|
448
481
|
|
|
482
|
+
/** Parse the picker choices only if the picker already appears rendered on
|
|
483
|
+
* the live screen (its `wait_for` condition currently matches and at least
|
|
484
|
+
* one choice parses). Returns the parsed choices when open, else null so
|
|
485
|
+
* the caller knows it must send the trigger to open it. Used to de-dup the
|
|
486
|
+
* picker open in {@link selectPickerChoice}. */
|
|
487
|
+
private extractPickerChoicesIfRendered(
|
|
488
|
+
action: Extract<ControlAction, { type: 'open_picker' }>,
|
|
489
|
+
): Array<{ index: number; label: string; current: boolean }> | null {
|
|
490
|
+
const wf = action.wait_for;
|
|
491
|
+
if (wf?.regex) {
|
|
492
|
+
const re = new RegExp(wf.regex, wf.flags ?? 'i');
|
|
493
|
+
if (!re.test(this.readScreenSectionText(wf.section))) return null;
|
|
494
|
+
}
|
|
495
|
+
const options = this.extractPickerChoices(action);
|
|
496
|
+
return options.length > 0 ? options : null;
|
|
497
|
+
}
|
|
498
|
+
|
|
449
499
|
/** Poll the live screen until the picker's `wait_for` condition matches,
|
|
450
500
|
* up to a short budget. Returns true if it rendered, false on timeout. */
|
|
451
501
|
private async waitForPickerRendered(action: Extract<ControlAction, { type: 'open_picker' }>): Promise<boolean> {
|
|
@@ -552,6 +602,10 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
552
602
|
// answers "why did this rule fire" after the fact, unlike the live
|
|
553
603
|
// `fsm` field which only reflects the current instant.
|
|
554
604
|
fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
|
|
605
|
+
// PTY input/output/resize/cursor event timeline (debug-only) so the
|
|
606
|
+
// snapshot shows what we typed / what the PTY printed around each
|
|
607
|
+
// status transition. Null for drivers without the timeline.
|
|
608
|
+
eventTimeline: this.driver.getEventTimeline?.() ?? null,
|
|
555
609
|
// Extended fields
|
|
556
610
|
name: this.cliName,
|
|
557
611
|
status: this.getStatus().status,
|
|
@@ -962,6 +1016,8 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
962
1016
|
// v4 FSM transition snapshot history — the captured pre-transition
|
|
963
1017
|
// evaluation table at each transition (null for v3 specs).
|
|
964
1018
|
fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
|
|
1019
|
+
// PTY input/output/resize/cursor event timeline (debug-only).
|
|
1020
|
+
eventTimeline: this.driver.getEventTimeline?.() ?? null,
|
|
965
1021
|
messages,
|
|
966
1022
|
committedMessages: messages,
|
|
967
1023
|
};
|
|
@@ -55,12 +55,24 @@ export function resolveSections(
|
|
|
55
55
|
// Normalize anchor + context into parallel candidate lists. A
|
|
56
56
|
// scalar anchor becomes a single-entry array; a single context
|
|
57
57
|
// object applies to every entry; an array context is positional.
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
58
|
+
// Each candidate resolves its own anchor line independently
|
|
59
|
+
// (anchor_last → that candidate's LAST matching line, else its
|
|
60
|
+
// FIRST). Across candidates we then pick the TOPMOST resolved
|
|
61
|
+
// line, because a section's anchor marks the TOP of the block:
|
|
62
|
+
// among several recognized landmark shapes, the highest one
|
|
63
|
+
// bounds the whole block. This keeps a scalar anchor identical
|
|
64
|
+
// (one candidate), preserves a genuine box-top divider (it sits
|
|
65
|
+
// ABOVE the question line, so it still wins), and — crucially —
|
|
66
|
+
// stops a stray lower landmark from clipping the block: e.g. a
|
|
67
|
+
// claude approval whose numbered choices sit ABOVE the input-box
|
|
68
|
+
// `────` rule. anchor_last on the bare-divider pattern alone
|
|
69
|
+
// would latch that LOWER chrome rule and strand the buttons
|
|
70
|
+
// above it (deriveModal sees < min_count → auto-approve never
|
|
71
|
+
// fires); preferring the topmost landmark (here the question
|
|
72
|
+
// line just above the choices, matched by the fallback context)
|
|
73
|
+
// captures the whole modal. The fallback still only contributes
|
|
74
|
+
// when its own pattern matches, so non-modal screens are
|
|
75
|
+
// unaffected.
|
|
64
76
|
const anchorPatterns = Array.isArray(sec.anchor) ? sec.anchor : [sec.anchor];
|
|
65
77
|
const sharedCtx: AnchorContext | null = Array.isArray(sec.anchor_context)
|
|
66
78
|
? null
|
|
@@ -84,12 +96,15 @@ export function resolveSections(
|
|
|
84
96
|
&& (c.nextRe === null || (i < total - 1 && c.nextRe.test(lines[i + 1])));
|
|
85
97
|
let idx = -1;
|
|
86
98
|
for (const c of candidates) {
|
|
99
|
+
let candIdx = -1;
|
|
87
100
|
if (sec.anchor_last) {
|
|
88
|
-
for (let i = total - 1; i >= 0; i--) { if (matchesCandidate(c, i)) {
|
|
101
|
+
for (let i = total - 1; i >= 0; i--) { if (matchesCandidate(c, i)) { candIdx = i; break; } }
|
|
89
102
|
} else {
|
|
90
|
-
for (let i = 0; i < total; i++) { if (matchesCandidate(c, i)) {
|
|
103
|
+
for (let i = 0; i < total; i++) { if (matchesCandidate(c, i)) { candIdx = i; break; } }
|
|
91
104
|
}
|
|
92
|
-
|
|
105
|
+
// Topmost resolved anchor across candidates wins (see note
|
|
106
|
+
// above): keep the smallest matching line index.
|
|
107
|
+
if (candIdx !== -1 && (idx === -1 || candIdx < idx)) idx = candIdx;
|
|
93
108
|
}
|
|
94
109
|
if (idx !== -1) {
|
|
95
110
|
from = idx;
|
|
@@ -299,10 +314,10 @@ function compileLinePattern(ref: { pattern: string; flags?: string }): RegExp {
|
|
|
299
314
|
export function extractButtonsFromRule(
|
|
300
315
|
rule: ExtractButtons,
|
|
301
316
|
hay: string,
|
|
302
|
-
): { index: number; label: string; key: string }[] {
|
|
317
|
+
): { index: number; label: string; key: string; current: boolean }[] {
|
|
303
318
|
const keyTemplate = rule.key_for_index;
|
|
304
319
|
const continuationLines = rule.continuation_lines ?? false;
|
|
305
|
-
const buttons: { index: number; label: string; key: string }[] = [];
|
|
320
|
+
const buttons: { index: number; label: string; key: string; current: boolean }[] = [];
|
|
306
321
|
|
|
307
322
|
if (continuationLines) {
|
|
308
323
|
const re = compileLinePattern(rule);
|
|
@@ -313,6 +328,7 @@ export function extractButtonsFromRule(
|
|
|
313
328
|
const idx = Number(m[1]);
|
|
314
329
|
let label = String(m[2] ?? '').trim();
|
|
315
330
|
if (!Number.isFinite(idx) || idx <= 0 || !label) continue;
|
|
331
|
+
const current = hasCursorMarker(lines[i]);
|
|
316
332
|
let j = i + 1;
|
|
317
333
|
while (j < lines.length) {
|
|
318
334
|
const next = lines[j];
|
|
@@ -324,7 +340,7 @@ export function extractButtonsFromRule(
|
|
|
324
340
|
}
|
|
325
341
|
if (buttons.some(b => b.index === idx)) continue;
|
|
326
342
|
const key = keyTemplate.replace(/\{index\}/g, String(idx));
|
|
327
|
-
buttons.push({ index: idx, label, key });
|
|
343
|
+
buttons.push({ index: idx, label, key, current });
|
|
328
344
|
i = j - 1;
|
|
329
345
|
}
|
|
330
346
|
} else {
|
|
@@ -336,10 +352,19 @@ export function extractButtonsFromRule(
|
|
|
336
352
|
if (!Number.isFinite(idx) || idx <= 0 || !label) continue;
|
|
337
353
|
if (buttons.some(b => b.index === idx)) continue;
|
|
338
354
|
const key = keyTemplate.replace(/\{index\}/g, String(idx));
|
|
339
|
-
|
|
355
|
+
// The matched text begins at the cursor marker (the pattern's
|
|
356
|
+
// optional `[❯›>]` prefix); flag this row as the cursor's current
|
|
357
|
+
// position so `select_mode: 'arrow_keys'` can step from it.
|
|
358
|
+
buttons.push({ index: idx, label, key, current: hasCursorMarker(m[0]) });
|
|
340
359
|
}
|
|
341
360
|
}
|
|
342
361
|
|
|
343
362
|
buttons.sort((a, b) => a.index - b.index);
|
|
344
363
|
return buttons;
|
|
345
364
|
}
|
|
365
|
+
|
|
366
|
+
/** True when a button line carries a TUI cursor marker (`❯`, `›`, `>`) before
|
|
367
|
+
* its number — i.e. the cursor currently sits on that row. */
|
|
368
|
+
function hasCursorMarker(text: string): boolean {
|
|
369
|
+
return /^\s*[❯›>]/.test(text);
|
|
370
|
+
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import * as fs from 'node:fs';
|
|
22
22
|
import * as os from 'node:os';
|
|
23
23
|
import * as path from 'node:path';
|
|
24
|
-
import { TerminalAdapter, type TerminalAdapterOpts } from './adapter.js';
|
|
24
|
+
import { TerminalAdapter, type TerminalAdapterOpts, type SpecPtyEvent } from './adapter.js';
|
|
25
25
|
import { resolveCliSpawnPlanFromParts } from '../../cli-adapters/provider-cli-runtime.js';
|
|
26
26
|
import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
|
|
27
27
|
import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
|
|
@@ -123,6 +123,7 @@ export interface ISpecDriver {
|
|
|
123
123
|
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
|
|
124
124
|
getFsmDebug?(): unknown;
|
|
125
125
|
getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
|
|
126
|
+
getEventTimeline?(limit?: number): ReadonlyArray<SpecPtyEvent>;
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
export interface SpecDriverOpts {
|
|
@@ -162,6 +163,23 @@ const SUBMIT_DELAY_FLOOR_MS = 200;
|
|
|
162
163
|
// trimmed by the TUI on submit.
|
|
163
164
|
const WIN32_SUBMIT_RESEND_GAP_MS = 350;
|
|
164
165
|
const WIN32_SUBMIT_MAX_RESENDS = 14;
|
|
166
|
+
// Settle-gate for the win32 FIRST submit CR. Hold the CR until the PTY output has
|
|
167
|
+
// gone quiet for WIN32_SUBMIT_SETTLE_MS after the last input write — i.e. the full
|
|
168
|
+
// (possibly multi-KB / multiline) message body has finished arriving in the
|
|
169
|
+
// composer and echoing back. A long message waits until it actually lands; a short
|
|
170
|
+
// one settles almost immediately. WIN32_SUBMIT_MAX_SETTLE_WAIT_MS bounds the wait
|
|
171
|
+
// so a perpetually-noisy screen can never hang the submit. This is what stops a
|
|
172
|
+
// blind fixed-delay CR from submitting a half-arrived prompt and dropping its
|
|
173
|
+
// leading lines. The phase-2 verified-resend loop (below) is unchanged.
|
|
174
|
+
const WIN32_SUBMIT_SETTLE_MS = 500;
|
|
175
|
+
const WIN32_SUBMIT_MAX_SETTLE_WAIT_MS = 10_000;
|
|
176
|
+
const WIN32_SUBMIT_SETTLE_POLL_MS = 120;
|
|
177
|
+
// Defensive paced PTY write. A single unbounded ConPTY write can overflow the
|
|
178
|
+
// input pipe and drop leading bytes; split a large body into bounded chunks with a
|
|
179
|
+
// short inter-chunk gap so the console input buffer keeps up. Small bodies still
|
|
180
|
+
// write in one shot.
|
|
181
|
+
const WIN32_PTY_WRITE_CHUNK_CHARS = 1024;
|
|
182
|
+
const WIN32_PTY_WRITE_CHUNK_GAP_MS = 8;
|
|
165
183
|
|
|
166
184
|
export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number {
|
|
167
185
|
const lines = countNewlines(text);
|
|
@@ -170,6 +188,27 @@ export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text:
|
|
|
170
188
|
return Math.max(spec, SUBMIT_DELAY_FLOOR_MS + linesBonus);
|
|
171
189
|
}
|
|
172
190
|
|
|
191
|
+
/** Split `text` into chunks of at most `size` UTF-16 units without ever cutting
|
|
192
|
+
* between a high and low surrogate (which would corrupt an astral char — emoji,
|
|
193
|
+
* etc. — on the UTF-8 PTY write). */
|
|
194
|
+
export function chunkPreservingSurrogates(text: string, size: number): string[] {
|
|
195
|
+
const chunks: string[] = [];
|
|
196
|
+
let offset = 0;
|
|
197
|
+
while (offset < text.length) {
|
|
198
|
+
let end = Math.min(text.length, offset + size);
|
|
199
|
+
if (end < text.length) {
|
|
200
|
+
const code = text.charCodeAt(end - 1);
|
|
201
|
+
// Boundary lands on a high surrogate → pull back one so the pair stays
|
|
202
|
+
// together in the next chunk.
|
|
203
|
+
if (code >= 0xd800 && code <= 0xdbff) end -= 1;
|
|
204
|
+
}
|
|
205
|
+
if (end <= offset) end = Math.min(text.length, offset + size); // size 1 on a lone surrogate
|
|
206
|
+
chunks.push(text.slice(offset, end));
|
|
207
|
+
offset = end;
|
|
208
|
+
}
|
|
209
|
+
return chunks;
|
|
210
|
+
}
|
|
211
|
+
|
|
173
212
|
export function guessExt(mime: string): string {
|
|
174
213
|
if (/png/i.test(mime)) return '.png';
|
|
175
214
|
if (/jpe?g/i.test(mime)) return '.jpg';
|
|
@@ -182,7 +221,7 @@ export function guessExt(mime: string): string {
|
|
|
182
221
|
|
|
183
222
|
interface ModalSnapshot {
|
|
184
223
|
title: string | null;
|
|
185
|
-
buttons: { index: number; label: string; key: string }[];
|
|
224
|
+
buttons: { index: number; label: string; key: string; current: boolean }[];
|
|
186
225
|
}
|
|
187
226
|
|
|
188
227
|
interface VisibleControl {
|
|
@@ -231,6 +270,16 @@ export class FsmDriver implements ISpecDriver {
|
|
|
231
270
|
* WIN32_SUBMIT_* and scheduleWin32Submit). Re-arms itself until the FSM
|
|
232
271
|
* leaves idle (submitted) or the resend budget is spent. */
|
|
233
272
|
private win32SubmitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
273
|
+
/** Wall-clock (ms) of the most recent raw PTY output chunk. Advances on every
|
|
274
|
+
* on_pty_data — including the echo of text written into the composer — so the
|
|
275
|
+
* win32 submit settle-gate can tell when input has finished landing. */
|
|
276
|
+
private lastPtyDataAt = 0;
|
|
277
|
+
/** Wall-clock (ms) of the most recent win32 message-body input write. Bridges
|
|
278
|
+
* the gap between writing a chunk and its echo so the settle-gate does not
|
|
279
|
+
* declare "quiet" mid-write. */
|
|
280
|
+
private lastWin32WriteAt = 0;
|
|
281
|
+
/** Pending paced chunk-write timer for a large win32 body (see writeWin32Body). */
|
|
282
|
+
private win32WriteTimer: ReturnType<typeof setTimeout> | null = null;
|
|
234
283
|
|
|
235
284
|
private currentEval: CurrentEval | null = null;
|
|
236
285
|
private stateHistory: HistoryEntry[] = [];
|
|
@@ -257,7 +306,7 @@ export class FsmDriver implements ISpecDriver {
|
|
|
257
306
|
this.buildAdapterOpts(),
|
|
258
307
|
{
|
|
259
308
|
init: () => this.emitInitialState(),
|
|
260
|
-
on_pty_data: (chunk) => this.emit({ kind: 'pty_data', chunk }),
|
|
309
|
+
on_pty_data: (chunk) => { this.lastPtyDataAt = Date.now(); this.emit({ kind: 'pty_data', chunk }); },
|
|
261
310
|
on_screen_changed: () => this.reevaluate(),
|
|
262
311
|
on_exit: ({ exitCode }) => this.handleExit(exitCode),
|
|
263
312
|
},
|
|
@@ -349,6 +398,7 @@ export class FsmDriver implements ISpecDriver {
|
|
|
349
398
|
if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
|
|
350
399
|
if (this.stallTimer) { clearTimeout(this.stallTimer); this.stallTimer = null; }
|
|
351
400
|
if (this.win32SubmitTimer) { clearTimeout(this.win32SubmitTimer); this.win32SubmitTimer = null; }
|
|
401
|
+
if (this.win32WriteTimer) { clearTimeout(this.win32WriteTimer); this.win32WriteTimer = null; }
|
|
352
402
|
this.specWatcher?.close();
|
|
353
403
|
this.adapter.kill();
|
|
354
404
|
}
|
|
@@ -385,6 +435,10 @@ export class FsmDriver implements ISpecDriver {
|
|
|
385
435
|
|
|
386
436
|
getStateHistory(): ReadonlyArray<HistoryEntry> { return this.stateHistory; }
|
|
387
437
|
getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry> { return this.fsmSnapshotHistory; }
|
|
438
|
+
/** Debug-only PTY input/output/resize/cursor timeline from the adapter. */
|
|
439
|
+
getEventTimeline(limit?: number): ReadonlyArray<SpecPtyEvent> {
|
|
440
|
+
return this.adapter.getEventTimeline(limit);
|
|
441
|
+
}
|
|
388
442
|
getSections(): Array<{ id: string; text: string }> | null {
|
|
389
443
|
try {
|
|
390
444
|
const screen = this.adapter.snapshot();
|
|
@@ -867,7 +921,7 @@ export class FsmDriver implements ISpecDriver {
|
|
|
867
921
|
// typing simulation is skipped on win32; correctness of submission wins
|
|
868
922
|
// over the typing visual there.
|
|
869
923
|
if (process.platform === 'win32') {
|
|
870
|
-
this.
|
|
924
|
+
this.writeWin32Body(text);
|
|
871
925
|
this.scheduleWin32Submit(sm.submit_key, beforeSubmit);
|
|
872
926
|
return;
|
|
873
927
|
}
|
|
@@ -896,17 +950,70 @@ export class FsmDriver implements ISpecDriver {
|
|
|
896
950
|
return st ? statusForState(st) : 'idle';
|
|
897
951
|
}
|
|
898
952
|
|
|
953
|
+
/** Record a win32 body write so the settle-gate counts it as input activity
|
|
954
|
+
* even before the echo arrives. */
|
|
955
|
+
private markWin32Write(): void {
|
|
956
|
+
this.lastWin32WriteAt = Date.now();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Most recent win32 input activity — a write we issued OR a PTY output chunk
|
|
960
|
+
* (echo). The submit settle-gate waits for this to go quiet. */
|
|
961
|
+
private lastWin32InputActivityAt(): number {
|
|
962
|
+
return Math.max(this.lastPtyDataAt, this.lastWin32WriteAt);
|
|
963
|
+
}
|
|
964
|
+
|
|
899
965
|
/**
|
|
900
|
-
*
|
|
901
|
-
*
|
|
902
|
-
* a
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
|
|
966
|
+
* Write the message body to the PTY for win32, paced into bounded chunks. A
|
|
967
|
+
* single unbounded ConPTY write can overflow the input pipe and drop leading
|
|
968
|
+
* bytes; splitting it with a short inter-chunk gap keeps the console input
|
|
969
|
+
* buffer from overflowing. Small bodies still go out in a single write. Each
|
|
970
|
+
* chunk advances lastWin32WriteAt so the submit settle-gate keeps waiting until
|
|
971
|
+
* the final chunk is out and echoed.
|
|
972
|
+
*/
|
|
973
|
+
private writeWin32Body(text: string): void {
|
|
974
|
+
if (this.win32WriteTimer) { clearTimeout(this.win32WriteTimer); this.win32WriteTimer = null; }
|
|
975
|
+
if (text.length <= WIN32_PTY_WRITE_CHUNK_CHARS) {
|
|
976
|
+
this.markWin32Write();
|
|
977
|
+
this.adapter.send_keys(text);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const chunks = chunkPreservingSurrogates(text, WIN32_PTY_WRITE_CHUNK_CHARS);
|
|
981
|
+
let idx = 0;
|
|
982
|
+
const writeNext = (): void => {
|
|
983
|
+
this.win32WriteTimer = null;
|
|
984
|
+
if (idx >= chunks.length) return;
|
|
985
|
+
this.markWin32Write();
|
|
986
|
+
this.adapter.send_keys(chunks[idx]);
|
|
987
|
+
idx += 1;
|
|
988
|
+
if (idx < chunks.length) {
|
|
989
|
+
this.win32WriteTimer = setTimeout(writeNext, WIN32_PTY_WRITE_CHUNK_GAP_MS);
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
writeNext();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* win32 submit. Two phases:
|
|
997
|
+
*
|
|
998
|
+
* Phase 1 (settle-gate): hold the first CR until the PTY output has been quiet
|
|
999
|
+
* for WIN32_SUBMIT_SETTLE_MS after the last input write — i.e. the full
|
|
1000
|
+
* (possibly multi-KB / multiline) body has finished arriving in the composer
|
|
1001
|
+
* and echoing. Honors an initial minimum delay and is bounded by
|
|
1002
|
+
* WIN32_SUBMIT_MAX_SETTLE_WAIT_MS so a noisy screen can never hang the submit.
|
|
1003
|
+
* This is what stops a long message from being submitted half-arrived (its
|
|
1004
|
+
* leading lines lost). A short message settles almost immediately.
|
|
1005
|
+
*
|
|
1006
|
+
* Phase 2 (verified resend — unchanged): send the submit key, wait a gap, and
|
|
1007
|
+
* if the FSM is still 'idle' (the CR was absorbed as a multiline-paste
|
|
1008
|
+
* newline) resend, up to WIN32_SUBMIT_MAX_RESENDS. The first CR always fires
|
|
1009
|
+
* (a stale/edge status never suppresses it); resends are gated on still being
|
|
1010
|
+
* idle and stop the instant the agent leaves idle (submitted → generating /
|
|
1011
|
+
* approval). This preserves the win32 lone-CR-swallow handling.
|
|
907
1012
|
*/
|
|
908
1013
|
private scheduleWin32Submit(submitKey: string, initialDelayMs: number): void {
|
|
909
1014
|
if (this.win32SubmitTimer) { clearTimeout(this.win32SubmitTimer); this.win32SubmitTimer = null; }
|
|
1015
|
+
const startedAt = Date.now();
|
|
1016
|
+
|
|
910
1017
|
const fire = (attempt: number): void => {
|
|
911
1018
|
this.win32SubmitTimer = null;
|
|
912
1019
|
this.adapter.send_keys(submitKey);
|
|
@@ -917,8 +1024,22 @@ export class FsmDriver implements ISpecDriver {
|
|
|
917
1024
|
fire(attempt + 1);
|
|
918
1025
|
}, WIN32_SUBMIT_RESEND_GAP_MS);
|
|
919
1026
|
};
|
|
920
|
-
|
|
921
|
-
|
|
1027
|
+
|
|
1028
|
+
const waitForSettle = (): void => {
|
|
1029
|
+
this.win32SubmitTimer = null;
|
|
1030
|
+
const now = Date.now();
|
|
1031
|
+
const quietFor = now - this.lastWin32InputActivityAt();
|
|
1032
|
+
const waited = now - startedAt;
|
|
1033
|
+
if (quietFor >= WIN32_SUBMIT_SETTLE_MS || waited >= WIN32_SUBMIT_MAX_SETTLE_WAIT_MS) {
|
|
1034
|
+
fire(0);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const recheckIn = Math.min(WIN32_SUBMIT_SETTLE_MS - quietFor, WIN32_SUBMIT_SETTLE_POLL_MS);
|
|
1038
|
+
this.win32SubmitTimer = setTimeout(waitForSettle, Math.max(recheckIn, 30));
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
if (initialDelayMs > 0) this.win32SubmitTimer = setTimeout(waitForSettle, initialDelayMs);
|
|
1042
|
+
else waitForSettle();
|
|
922
1043
|
}
|
|
923
1044
|
|
|
924
1045
|
private handleClickControl(controlId: string, payload?: unknown): void {
|
|
@@ -958,6 +1079,28 @@ export class FsmDriver implements ISpecDriver {
|
|
|
958
1079
|
if (!m) return;
|
|
959
1080
|
const btn = m.buttons.find(b => b.index === index);
|
|
960
1081
|
if (!btn) return;
|
|
1082
|
+
|
|
1083
|
+
const rule = stateById(this.spec, this.currentStateId)?.extract?.buttons;
|
|
1084
|
+
if (rule?.select_mode === 'arrow_keys') {
|
|
1085
|
+
// Cursor-list approval modal (claude-cli new TUI): number keys are
|
|
1086
|
+
// IGNORED — sending `btn.key` ("1\r") types a literal "1" into the
|
|
1087
|
+
// composer and the trailing CR submits it as a chat message. Drive
|
|
1088
|
+
// the cursor from its current row to the target row with arrows,
|
|
1089
|
+
// then confirm. The cursor opens on the first option, so when the
|
|
1090
|
+
// marker isn't detected we step down from row 1 (index - 1).
|
|
1091
|
+
const from = m.buttons.find(b => b.current)?.index ?? 1;
|
|
1092
|
+
const up = rule.cursor_keys?.up ?? '\x1b[A';
|
|
1093
|
+
const down = rule.cursor_keys?.down ?? '\x1b[B';
|
|
1094
|
+
const delta = btn.index - from;
|
|
1095
|
+
const step = delta >= 0 ? down : up;
|
|
1096
|
+
const nav = step.repeat(Math.abs(delta));
|
|
1097
|
+
// Confirm = key_for_index with the (now unused) {index} stripped:
|
|
1098
|
+
// `{index}\r` → `\r`.
|
|
1099
|
+
const confirm = (rule.key_for_index || '\r').replace(/\{index\}/g, '') || '\r';
|
|
1100
|
+
if (nav) this.adapter.send_keys(nav);
|
|
1101
|
+
this.adapter.send_keys(confirm);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
961
1104
|
this.adapter.send_keys(btn.key);
|
|
962
1105
|
}
|
|
963
1106
|
|
|
@@ -1039,7 +1182,8 @@ function summarizeTransition(t: TransitionEval): string[] {
|
|
|
1039
1182
|
}
|
|
1040
1183
|
|
|
1041
1184
|
function flattenCond(c: import('./fsm-evaluator.js').CondResult, out: string[], depth: number): void {
|
|
1042
|
-
|
|
1185
|
+
const matched = c.matchedText ? ` matched=${JSON.stringify(c.matchedText)}` : '';
|
|
1186
|
+
out.push(`${' '.repeat(depth)}${c.kind} ${c.detail} = ${c.result}${c.remainingMs ? ` (${c.remainingMs}ms left)` : ''}${matched}`);
|
|
1043
1187
|
for (const child of c.children ?? []) flattenCond(child, out, depth + 1);
|
|
1044
1188
|
}
|
|
1045
1189
|
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type { Condition, SectionDef } from './types.js';
|
|
18
18
|
import {
|
|
19
|
-
resolveSections, evaluateCondition, type ResolvedSection, type TraceEntry,
|
|
19
|
+
resolveSections, evaluateCondition, sectionText, type ResolvedSection, type TraceEntry,
|
|
20
20
|
} from './evaluator.js';
|
|
21
21
|
import {
|
|
22
22
|
type CliSpecV4, type FsmCondition, type FsmTransition,
|
|
@@ -42,6 +42,10 @@ export interface CondResult {
|
|
|
42
42
|
/** Remaining ms until a time-based condition would flip to true. 0 if
|
|
43
43
|
* already true or not applicable. Lets the UI show a countdown. */
|
|
44
44
|
remainingMs?: number;
|
|
45
|
+
/** Debug-only: the actual substring a TRUE regex condition matched, so the
|
|
46
|
+
* snapshot shows WHAT text the rule fired on — not just which regex. Never
|
|
47
|
+
* read by the FSM; purely for the Spec Debug Snapshot. */
|
|
48
|
+
matchedText?: string;
|
|
45
49
|
children?: CondResult[];
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -164,7 +168,20 @@ function evalCond(
|
|
|
164
168
|
const detail = isRegex(cond)
|
|
165
169
|
? `${(cond as any).section ?? '*'}~/${(cond as any).matches}/`
|
|
166
170
|
: `cursor_above=${(cond as any).cursor_above} changed=${(cond as any).changed}`;
|
|
167
|
-
|
|
171
|
+
// Debug-only: when a regex condition is TRUE, also capture the substring
|
|
172
|
+
// it matched so the snapshot can show the exact text the rule fired on.
|
|
173
|
+
// This re-runs the regex (read-only) and CANNOT change `result` above —
|
|
174
|
+
// the FSM decision is still entirely owned by evaluateCondition().
|
|
175
|
+
let matchedText: string | undefined;
|
|
176
|
+
if (result && isRegex(cond)) {
|
|
177
|
+
try {
|
|
178
|
+
const hay = sectionText(sections, (cond as any).section, fullScreen);
|
|
179
|
+
const re = new RegExp((cond as any).matches, (cond as any).flags ?? 'i');
|
|
180
|
+
const m = re.exec(hay);
|
|
181
|
+
if (m && m[0]) matchedText = m[0].replace(/\s+/g, ' ').trim().slice(0, 160);
|
|
182
|
+
} catch { /* capture is best-effort; never affects result */ }
|
|
183
|
+
}
|
|
184
|
+
return matchedText ? { kind, result, detail, matchedText } : { kind, result, detail };
|
|
168
185
|
}
|
|
169
186
|
return { kind: 'all', result: false, detail: 'unknown condition' };
|
|
170
187
|
}
|