@adhdev/daemon-core 0.9.82-rc.351 → 0.9.82-rc.353

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.
@@ -376,6 +376,21 @@ export class CliProviderInstance implements ProviderInstance {
376
376
  */
377
377
  private static readonly AUTO_APPROVE_SETTLE_MS = 600;
378
378
 
379
+ /**
380
+ * Busy-side hysteresis for the settle gate. A momentary `generating` flip
381
+ * while the SAME approval modal's button block is still on screen (its
382
+ * question line scrolled out of the captured frame, only the buttons + a
383
+ * residual `esc to interrupt` spinner remain) briefly reports
384
+ * status!=waiting_approval. Without hysteresis that flip wipes the settle
385
+ * clock, and the modal→generating→modal flap restarts the 600ms window
386
+ * every time so auto-approve never fires. We keep the in-progress settle
387
+ * gate warm across an inactive blip up to this bound; only once the modal
388
+ * has genuinely stayed gone this long (a real resolution → idle) is the
389
+ * gate cleared. Bounded so a genuinely new, later approval still re-settles
390
+ * from scratch rather than firing on a stale timestamp.
391
+ */
392
+ private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS = 1500;
393
+
379
394
  private adapter: ProviderCliAdapter;
380
395
  private context: InstanceContext | null = null;
381
396
  private events: ProviderEvent[] = [];
@@ -397,6 +412,10 @@ export class CliProviderInstance implements ProviderInstance {
397
412
  private pendingAutoApprovalSignature = '';
398
413
  private pendingAutoApprovalSince = 0;
399
414
  private autoApproveSettleTimer: NodeJS.Timeout | null = null;
415
+ // Wall-clock when auto-approve first observed status!=waiting_approval while
416
+ // a settle gate was in progress. Drives AUTO_APPROVE_GATE_HYSTERESIS_MS so a
417
+ // brief generating flip does not immediately wipe the settle clock.
418
+ private autoApproveInactiveSince = 0;
400
419
  private controlValues: Record<string, string | number | boolean> = {};
401
420
  private summaryMetadata: unknown = undefined;
402
421
  private appliedEffectKeys = new Set<string>();
@@ -1471,13 +1490,37 @@ export class CliProviderInstance implements ProviderInstance {
1471
1490
  // still inside the short busy window.
1472
1491
  if (!autoApproveActive) {
1473
1492
  this.lastAutoApprovalSignature = '';
1493
+ // Hysteresis: if a settle gate is mid-progress, a momentary
1494
+ // status!=waiting_approval blip (a generating flip while the same
1495
+ // modal's button block is still on screen) must NOT wipe the settle
1496
+ // clock — otherwise the modal→generating→modal flap restarts the
1497
+ // 600ms window every time and auto-approve never fires. Keep the
1498
+ // gate warm for AUTO_APPROVE_GATE_HYSTERESIS_MS; re-arm a timer so
1499
+ // that if the modal does NOT come back the gate is cleared on the
1500
+ // re-check (a genuine resolution → idle frees the gate normally).
1501
+ if (this.pendingAutoApprovalSince) {
1502
+ if (!this.autoApproveInactiveSince) this.autoApproveInactiveSince = now;
1503
+ const goneForMs = now - this.autoApproveInactiveSince;
1504
+ if (goneForMs < CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS) {
1505
+ if (this.autoApproveSettleTimer) clearTimeout(this.autoApproveSettleTimer);
1506
+ this.autoApproveSettleTimer = setTimeout(() => {
1507
+ this.autoApproveSettleTimer = null;
1508
+ this.recheckAutoApproveSettled();
1509
+ }, CliProviderInstance.AUTO_APPROVE_GATE_HYSTERESIS_MS - goneForMs + 20);
1510
+ return autoApproveActive;
1511
+ }
1512
+ }
1474
1513
  // Clear the settle gate so the next approval starts its own quiet
1475
1514
  // window from scratch (a stale timestamp would let it fire instantly).
1476
1515
  this.pendingAutoApprovalSignature = '';
1477
1516
  this.pendingAutoApprovalSince = 0;
1517
+ this.autoApproveInactiveSince = 0;
1478
1518
  if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1479
1519
  return autoApproveActive;
1480
1520
  }
1521
+ // Active approval observed — reset the inactivity tracker so a later
1522
+ // blip starts its hysteresis window fresh.
1523
+ this.autoApproveInactiveSince = 0;
1481
1524
  const modal = adapterStatus.activeModal;
1482
1525
  // (fix) Do not auto-approve when no concrete modal/buttons are present.
1483
1526
  // Claude TUI flaps between paints; without this guard adapterStatus
@@ -1497,34 +1540,44 @@ export class CliProviderInstance implements ProviderInstance {
1497
1540
  // surface the modal so the user can decide.
1498
1541
  return autoApproveActive;
1499
1542
  }
1500
- // Include the FSM's approval entry seq: two distinct back-to-back
1501
- // approvals can carry identical message/buttons (common with
1502
- // claude-cli). Without the seq their signatures collide and the 5s
1503
- // busy-window re-entry guard below swallows the second auto-approve,
1543
+ // Modal *identity* signature the question/button set only, NO volatile
1544
+ // counters. This is what the settle gate tracks: the FSM bumps
1545
+ // approvalEntrySeq on every fresh waiting_approval entry, and a
1546
+ // modal→generating→modal flap (the question line scrolled out of the
1547
+ // captured frame while the button block stays) re-enters and bumps it
1548
+ // again. Folding that seq into the settle signature made the 600ms
1549
+ // settle clock restart on every flap, so the modal never stayed stable
1550
+ // long enough to fire — the gate was never satisfied. Identity excludes
1551
+ // the seq so button/seq flap of the SAME modal keeps one settle clock.
1552
+ const modalSignature = [
1553
+ typeof modal?.message === 'string' ? modal.message.trim() : '',
1554
+ buttons.join('|'),
1555
+ buttonIndex,
1556
+ ].join('::');
1557
+ // Busy-window re-entry guard still needs the seq: two DISTINCT
1558
+ // back-to-back approvals can carry identical message/buttons (common
1559
+ // with claude-cli). Without the seq their busy signatures collide and
1560
+ // the 5s busy-window guard below would swallow the second auto-approve,
1504
1561
  // leaving it stuck. The seq is bumped by the FSM on every fresh
1505
- // waiting_approval entry, so a new approval always yields a new
1562
+ // waiting_approval entry, so a new approval always yields a new busy
1506
1563
  // signature and fires through.
1507
1564
  const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
1508
1565
  ? adapterStatus.approvalEntrySeq
1509
1566
  : 0;
1510
- const signature = [
1511
- approvalEntrySeq,
1512
- typeof modal?.message === 'string' ? modal.message.trim() : '',
1513
- buttons.join('|'),
1514
- buttonIndex,
1515
- ].join('::');
1516
- // Already fired for this exact modal and still inside the busy window —
1517
- // nothing to do (re-entry guard for repeated snapshots of one modal).
1518
- if (this.autoApproveBusy && signature === this.lastAutoApprovalSignature) {
1567
+ const busySignature = `${approvalEntrySeq}::${modalSignature}`;
1568
+ // Already fired for this exact modal entry and still inside the busy
1569
+ // window nothing to do (re-entry guard for repeated snapshots of one
1570
+ // modal).
1571
+ if (this.autoApproveBusy && busySignature === this.lastAutoApprovalSignature) {
1519
1572
  return autoApproveActive;
1520
1573
  }
1521
1574
 
1522
- // Settle gate: only fire once this exact signature has been stable for
1575
+ // Settle gate: only fire once this modal identity has been stable for
1523
1576
  // AUTO_APPROVE_SETTLE_MS. A still-streaming prompt mutates its
1524
- // message/buttons each frame → new signature → clock restarts, so we
1577
+ // message/buttons each frame → new identity → clock restarts, so we
1525
1578
  // never approve a half-rendered prompt (the "resolves too fast" bug).
1526
- if (signature !== this.pendingAutoApprovalSignature) {
1527
- this.pendingAutoApprovalSignature = signature;
1579
+ if (modalSignature !== this.pendingAutoApprovalSignature) {
1580
+ this.pendingAutoApprovalSignature = modalSignature;
1528
1581
  this.pendingAutoApprovalSince = now;
1529
1582
  }
1530
1583
  const settledForMs = now - this.pendingAutoApprovalSince;
@@ -1543,9 +1596,10 @@ export class CliProviderInstance implements ProviderInstance {
1543
1596
  // Settled — fire the approve key.
1544
1597
  if (this.autoApproveSettleTimer) { clearTimeout(this.autoApproveSettleTimer); this.autoApproveSettleTimer = null; }
1545
1598
  this.autoApproveBusy = true;
1546
- this.lastAutoApprovalSignature = signature;
1599
+ this.lastAutoApprovalSignature = busySignature;
1547
1600
  this.pendingAutoApprovalSignature = '';
1548
1601
  this.pendingAutoApprovalSince = 0;
1602
+ this.autoApproveInactiveSince = 0;
1549
1603
  if (this.autoApproveBusyTimer) clearTimeout(this.autoApproveBusyTimer);
1550
1604
  this.autoApproveBusyTimer = setTimeout(() => {
1551
1605
  this.autoApproveBusy = false;
@@ -28,6 +28,10 @@ import {
28
28
  applyVisibleRegion,
29
29
  type VisibleRegionSpec,
30
30
  } from './visible-region.js';
31
+ import {
32
+ pickApprovalButton,
33
+ hasNegativeApprovalOption,
34
+ } from '../../../../approval-utils.js';
31
35
 
32
36
  // ─── Primitive spec shapes (mirror the JSON schemas) ───────────────────
33
37
 
@@ -69,6 +73,7 @@ interface ModalSpec {
69
73
  questionVariants?: Array<{ regex: string; flags?: string; label?: string }>;
70
74
  buttonPattern: string;
71
75
  buttonFlags?: string;
76
+ buttonLabelGroup?: number;
72
77
  }
73
78
 
74
79
  export type DispatchGroup =
@@ -163,6 +168,48 @@ function settledPromptMatches(spec: SettledPromptSpec, input: CliStatusInput): b
163
168
  return footers.every((f) => f.test(text));
164
169
  }
165
170
 
171
+ /**
172
+ * Extract approval-button labels from every line matching the modal spec's
173
+ * `buttonPattern`. Mirrors buildParseApprovalFromTui's per-line extraction but
174
+ * scans the whole screen with no question-line anchor, so the cue survives the
175
+ * question line scrolling out of the captured frame during long runs of
176
+ * consecutive approvals.
177
+ */
178
+ function extractButtonLabels(spec: ModalSpec, text: string): string[] {
179
+ if (!text) return [];
180
+ const flags = spec.buttonFlags && spec.buttonFlags.includes('m')
181
+ ? spec.buttonFlags
182
+ : `${spec.buttonFlags ?? ''}m`;
183
+ const buttonRe = compile(spec.buttonPattern, flags);
184
+ const labelGroup = Number.isInteger(spec.buttonLabelGroup) && (spec.buttonLabelGroup ?? 0) > 0
185
+ ? spec.buttonLabelGroup!
186
+ : 1;
187
+ const out: string[] = [];
188
+ for (const line of text.split('\n')) {
189
+ buttonRe.lastIndex = 0;
190
+ const m = buttonRe.exec(line);
191
+ if (!m) continue;
192
+ const captured = m[labelGroup] ?? (labelGroup === 1 && m.length > 2 ? m[m.length - 1] : undefined);
193
+ if (captured && captured.trim()) out.push(captured.trim());
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * (fixB ①) The approval's selectable button block is itself a modal cue.
200
+ * Anchored on approval *verbs* so it generalizes across CLIs without trusting
201
+ * any single spec's `buttonPattern` specificity: a real approval modal offers
202
+ * BOTH an affirmative option (Yes/Allow/Continue/…) and a decline (No/Deny/
203
+ * Cancel/Skip/…). A generic numbered menu, a single prose "1. Yes …" line, or
204
+ * an assistant enumeration lacks that pair and does not fire the cue.
205
+ */
206
+ function buttonBlockApprovalCue(spec: ModalSpec, text: string): boolean {
207
+ const labels = extractButtonLabels(spec, text);
208
+ if (labels.length < 2) return false;
209
+ if (pickApprovalButton(labels).index < 0) return false;
210
+ return hasNegativeApprovalOption(labels);
211
+ }
212
+
166
213
  function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean {
167
214
  // Status-level modal detection is cue-only — does the question appear at all?
168
215
  // Button extraction lives in buildParseApprovalFromTui.
@@ -173,6 +220,10 @@ function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean {
173
220
  const re = compile(variant.regex, variant.flags ?? 'i');
174
221
  if (re.test(text)) return true;
175
222
  }
223
+ // The question line can scroll out of the captured frame while the button
224
+ // block (and a residual spinner) remain. Hold the modal cue on the button
225
+ // block alone so waiting_approval does not flap to generating mid-approval.
226
+ if (buttonBlockApprovalCue(spec, text)) return true;
176
227
  return false;
177
228
  }
178
229