@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.
@@ -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 (a fresh invoke may arrive with the picker
417
- // closed; re-opening an open picker is a no-op on these CLIs).
418
- this.driver.dispatch({ kind: 'click_control', control_id: ctl.id });
419
- await this.waitForPickerRendered(action);
420
- const options = this.extractPickerChoices(action);
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
- const keys = (action.submit_key || '{index}\r').replace(/\{index\}/g, String(index));
436
- this.driver.dispatch({ kind: 'pty_write', data: keys });
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 ?? '';
457
+ const down = action.cursor_keys?.down ?? '';
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
- // Candidates are tried IN ORDER as independent passes: the first
59
- // pattern that finds any line wins. This keeps a scalar anchor's
60
- // behavior identical, and lets an array express a preferred shape
61
- // (e.g. a box divider) with later entries as fallbacks (e.g. a
62
- // divider-less modal anchored on its question line) — the
63
- // fallback only takes over when the preferred pattern is absent.
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)) { idx = i; break; } }
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)) { idx = i; break; } }
103
+ for (let i = 0; i < total; i++) { if (matchesCandidate(c, i)) { candIdx = i; break; } }
91
104
  }
92
- if (idx !== -1) break;
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
- buttons.push({ index: idx, label, key });
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.adapter.send_keys(text);
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
- * win32 verification-based submit. Sends the submit key, waits a gap, and if
901
- * the FSM is still 'idle' (the prompt did not submit — the CR was absorbed as
902
- * a multiline-paste newline) resends, up to WIN32_SUBMIT_MAX_RESENDS. The
903
- * first CR always fires (so a stale/edge status never suppresses the submit);
904
- * subsequent resends are gated on still being idle, and stop the instant the
905
- * agent leaves idle (submitted generating / approval). This converges the
906
- * nondeterministic multiline window without spamming Enter into the next turn.
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
- if (initialDelayMs > 0) this.win32SubmitTimer = setTimeout(() => fire(0), initialDelayMs);
921
- else fire(0);
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
- out.push(`${' '.repeat(depth)}${c.kind} ${c.detail} = ${c.result}${c.remainingMs ? ` (${c.remainingMs}ms left)` : ''}`);
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
- return { kind, result, detail };
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
  }