@adhdev/daemon-core 0.9.82-rc.350 → 0.9.82-rc.352

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.
@@ -56,7 +56,8 @@ import {
56
56
  expireStaleUnresolvedDelegateForwards,
57
57
  } from './mesh-unresolved-forward-outbox.js';
58
58
  import { readNonEmptyString, readMeshCompletionSummary } from './mesh-events-utils.js';
59
- import { getActiveDirectDispatches, getQueue } from './mesh-work-queue.js';
59
+ import { expandDaemonIdForms } from '@adhdev/mesh-shared';
60
+ import { getActiveDirectDispatches, getQueue, reclaimStrandedAssignedTask } from './mesh-work-queue.js';
60
61
  import { readLedgerEntries } from './mesh-ledger.js';
61
62
  import { pruneStaleDirectDispatches } from './mesh-active-work.js';
62
63
  import { reconcileDirectDispatchCompletionFromTranscript } from './mesh-events-stale.js';
@@ -119,16 +120,19 @@ interface LiveCoordinator {
119
120
  // - the daemon's canonical status id (`standalone_<machineId>` / `daemon_<machineId>`),
120
121
  // stamped by the MCP layer via ctx.localDaemonId (= getStatus().status.instanceId), or
121
122
  // - the bare machineId, stamped by the local queue-assignment path (loadConfig().machineId).
123
+ // - the config-form node daemonId (`daemon_<machineId>`), which the MCP layer's
124
+ // resolveCoordinatorDaemonId prefers and stamps onto direct-dispatch workers.
122
125
  // Draining with only one of these silently misses events stamped with the other —
123
- // the exact reason a generating coordinator never self-received local completions.
124
- // We accept BOTH so the drain matches regardless of which path stamped the event.
126
+ // the exact reason a generating coordinator never self-received local completions,
127
+ // and the base-node completion-surface bug (base completions land full-form
128
+ // `daemon_<machineId>` while a coordinator that only knows itself as bare
129
+ // `<machineId>` never matches them). We expand to EVERY equivalent form so the
130
+ // scope match (host gate, self-node detection, and the drain IN-filter downstream)
131
+ // succeeds regardless of which path stamped the event.
125
132
  function resolveCoordinatorDaemonIds(components: DaemonComponents): string[] {
126
- const ids = new Set<string>();
127
133
  const statusInstanceId = readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId);
128
- if (statusInstanceId) ids.add(statusInstanceId);
129
134
  const machineId = readNonEmptyString(loadConfig().machineId);
130
- if (machineId) ids.add(machineId);
131
- return [...ids];
135
+ return expandDaemonIdForms([statusInstanceId, machineId]);
132
136
  }
133
137
 
134
138
  // Whether THIS daemon is the coordinator/host for a mesh — i.e. the daemon that
@@ -316,6 +320,47 @@ function recordHeldTerminalEventsToLedger(
316
320
  // PHASE 2 — Live CLI inject. For each mesh that has a live CLI coordinator on
317
321
  // THIS daemon, drain the local queue and inject pending events into the PTY.
318
322
  // Unchanged from before.
323
+ // Bug B: how long a row may sit 'assigned' with an unconfirmed dispatch before the
324
+ // watchdog reclaims it. Must be comfortably larger than the per-dispatch confirm
325
+ // timeout (DISPATCH_CONFIRM_TIMEOUT_MS in mesh-events-coordinator) so a slow-but-live
326
+ // dispatch still inside its normal confirm window is never reclaimed early — this is
327
+ // the durable backstop for the case the in-process confirm timer can't cover (a timer
328
+ // lost to a daemon restart between claim and confirm).
329
+ const ASSIGNED_STRANDED_DEADLINE_MS = 5 * 60_000;
330
+
331
+ // PHASE 2.5 — assigned-stranded dispatch watchdog (Bug B). claimNextTask atomically
332
+ // flips a row to 'assigned' BEFORE the fire-and-forget dispatch runs. If that dispatch
333
+ // neither rejects (→ no .catch requeue) nor is confirmed delivered — a relay that hangs
334
+ // without acking, or a confirm timer lost across a restart — the row stays 'assigned'
335
+ // forever: it contributes 0 pending, so PHASE 3 (gated on pendingQueueTaskCount>0) never
336
+ // re-examines it, and nothing but a manual requeue clears it. This is that missing net.
337
+ //
338
+ // Regression guard: a row whose delivery IS confirmed (delivered/acked/completed) is a
339
+ // genuinely in-flight (or completion-lost) task — left to PHASE 4's completion reconcile,
340
+ // never reclaimed here. And the deadline is generous so a slow-but-live dispatch still in
341
+ // its normal confirm window is never reclaimed early. Reclaimed rows return to 'pending'
342
+ // with ownership cleared, so the PHASE 3 trigger below re-dispatches them this same tick.
343
+ function recoverStrandedAssignedDispatches(meshId: string, store: MeshRuntimeStore): void {
344
+ const assigned = getQueue(meshId, { status: ['assigned'] });
345
+ if (!assigned.length) return;
346
+ const nowMs = Date.now();
347
+ for (const row of assigned) {
348
+ const dispatchedAtMs = Date.parse(row.dispatchTimestamp ?? '');
349
+ if (!Number.isFinite(dispatchedAtMs)) continue; // no dispatch ts → can't age it
350
+ if (nowMs - dispatchedAtMs < ASSIGNED_STRANDED_DEADLINE_MS) continue; // still in confirm window
351
+ if (store.taskHasConfirmedDelivery(meshId, row.id)) continue; // dispatched → PHASE 4's job
352
+ const reclaimed = reclaimStrandedAssignedTask(meshId, row.id, {
353
+ reason: 'assigned_stranded_dispatch_unconfirmed',
354
+ ageMs: nowMs - dispatchedAtMs,
355
+ });
356
+ if (reclaimed) {
357
+ LOG.warn('MeshReconcile', `Reclaimed stranded assigned task ${row.id} on mesh ${meshId} `
358
+ + `(node=${row.assignedNodeId ?? '?'} session=${row.assignedSessionId ?? '?'}, dispatched `
359
+ + `${Math.round((nowMs - dispatchedAtMs) / 1000)}s ago, never confirmed delivered → ${reclaimed.status})`);
360
+ }
361
+ }
362
+ }
363
+
319
364
  export async function runMeshReconcileTick(components: DaemonComponents): Promise<void> {
320
365
  const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
321
366
  // The id-set used to scope the local queue drain (status id + machineId). See
@@ -360,6 +405,21 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
360
405
  }
361
406
  }
362
407
 
408
+ // ── PHASE 2.5: assigned-stranded dispatch watchdog (Bug B) ─────────────────
409
+ // Runs before PHASE 3 so any row it returns to 'pending' is re-dispatched by the
410
+ // PHASE 3 trigger in this same tick. See recoverStrandedAssignedDispatches.
411
+ if (store) {
412
+ for (const mesh of listMeshes()) {
413
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
414
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
415
+ try {
416
+ recoverStrandedAssignedDispatches(mesh.id, store);
417
+ } catch (e: any) {
418
+ LOG.warn('MeshReconcile', `Assigned-stranded watchdog failed for mesh ${mesh.id}: ${e?.message || e}`);
419
+ }
420
+ }
421
+ }
422
+
363
423
  // ── PHASE 3: recover pending queue claims for newly-idle sessions ──────────
364
424
  // The event-driven claim paths (agent:ready / agent:generating_completed in
365
425
  // mesh-events-coordinator) re-claim the queue the moment a session goes idle,
@@ -70,6 +70,10 @@ export class MeshRuntimeStore {
70
70
  private readonly migratedMeshIds = new Set<string>();
71
71
  private fingerprintSweepCounter = 0;
72
72
  private walWriteCounter = 0;
73
+ // Independent cadence for the tool-call-log sweep. Must NOT share walWriteCounter:
74
+ // sharing makes each store's threshold drift by the other's write volume (WAL
75
+ // checkpoint at 500 vs tool-log sweep at 200 would interfere arbitrarily).
76
+ private toolCallLogCounter = 0;
73
77
  private static readonly WAL_CHECK_INTERVAL = 500;
74
78
  private static readonly WAL_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
75
79
 
@@ -1071,6 +1075,23 @@ export class MeshRuntimeStore {
1071
1075
  }));
1072
1076
  }
1073
1077
 
1078
+ /**
1079
+ * Bug B watchdog support: true when at least one delivery record for the task has
1080
+ * reached a confirmed-handed-off status (delivered / acked / completed). The
1081
+ * assigned-stranded watchdog uses this to distinguish a dispatch that was never
1082
+ * confirmed (reclaimable) from one that WAS handed to the worker (a genuinely
1083
+ * in-flight or completion-lost task, which is PHASE 4's responsibility, not this
1084
+ * watchdog's). Indexed by (mesh_id, task_id).
1085
+ */
1086
+ taskHasConfirmedDelivery(meshId: string, taskId: string): boolean {
1087
+ const row = this.db.prepare(`
1088
+ SELECT 1 FROM mesh_session_delivery
1089
+ WHERE mesh_id = ? AND task_id = ? AND status IN ('delivered','acked','completed')
1090
+ LIMIT 1
1091
+ `).get(meshId, taskId) as { 1: number } | undefined;
1092
+ return !!row;
1093
+ }
1094
+
1074
1095
  expireStaleSessionDeliveries(meshId: string): void {
1075
1096
  const now = new Date().toISOString();
1076
1097
  this.db.prepare(`
@@ -1171,7 +1192,7 @@ export class MeshRuntimeStore {
1171
1192
  const callsInWindow = row?.cnt ?? 0;
1172
1193
 
1173
1194
  // Sweep old entries periodically to keep the table lean (every 200 calls across all tools).
1174
- if (++this.walWriteCounter % 200 === 0) {
1195
+ if (++this.toolCallLogCounter % 200 === 0) {
1175
1196
  this.db.prepare(
1176
1197
  'DELETE FROM mesh_tool_call_log WHERE called_at < ?'
1177
1198
  ).run(now - Math.max(windowMs * 10, 60_000));
@@ -91,9 +91,16 @@ export function computeMeshTaskStats(meshId: string, opts?: { taskIds?: string[]
91
91
 
92
92
  return targetIds.map(taskId => {
93
93
  const queueEntry = queueById.get(taskId);
94
- const status = queueEntry?.status ?? 'unknown';
95
94
  const dispatch = dispatches.get(taskId);
96
95
  const terminal = terminals.get(taskId);
96
+ // Direct dispatches (mesh_send_task) have no work-queue row, so queueEntry is undefined.
97
+ // Derive a terminal status from the attributed terminal ledger entry instead of reporting
98
+ // status='unknown' — without this a completed direct task showed unknown + terminalKind=null
99
+ // even though its task_completed event fired (see mesh-events-coordinator Fix B attribution).
100
+ const status = queueEntry?.status
101
+ ?? (terminal
102
+ ? (terminal.kind === 'task_completed' ? 'completed' : 'failed')
103
+ : 'unknown');
97
104
  const isTerminalStatus = status === 'completed' || status === 'failed' || status === 'cancelled';
98
105
  const dispatchTime = parseTime(dispatch?.first ?? queueEntry?.dispatchTimestamp);
99
106
  const terminalTime = parseTime(terminal?.at);
@@ -5,6 +5,8 @@ import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo
5
5
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
6
6
  import { getMesh } from '../config/mesh-config.js';
7
7
  import { LOG } from '../logging/logger.js';
8
+ import { appendLedgerEntry } from './mesh-ledger.js';
9
+ import type { MeshLedgerKind } from './mesh-ledger.js';
8
10
 
9
11
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
10
12
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
@@ -339,6 +341,14 @@ export interface MeshWorkQueueEntry {
339
341
  requeueCount?: number;
340
342
  /** Max automatic requeue attempts. When requeueCount reaches this, task is auto-failed. */
341
343
  maxRetries?: number;
344
+ /**
345
+ * Bug B: number of times the reconcile assigned-stranded watchdog has reclaimed this
346
+ * row from 'assigned' back to 'pending' because its dispatch was never confirmed
347
+ * delivered. Separate from requeueCount (operator/execution retries) and bounded by
348
+ * MAX_STRANDED_RECLAIMS so a permanently-undeliverable target auto-fails rather than
349
+ * cycling reclaim→re-dispatch→strand forever.
350
+ */
351
+ strandedReclaimCount?: number;
342
352
  /** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
343
353
  autoLaunch?: {
344
354
  status: 'skipped' | 'started' | 'failed' | 'completed';
@@ -887,6 +897,85 @@ export function requeueTask(
887
897
  });
888
898
  }
889
899
 
900
+ /**
901
+ * Max times the assigned-stranded watchdog will reclaim a single task before giving
902
+ * up and failing it. Bounds the reclaim→re-dispatch→strand cycle so a permanently
903
+ * undeliverable target (e.g. a node whose transport is wedged) eventually fails and
904
+ * unblocks its dependents instead of looping every reconcile tick.
905
+ */
906
+ const MAX_STRANDED_RECLAIMS = 3;
907
+
908
+ /**
909
+ * Bug B: reclaim a task stuck in 'assigned' because its dispatch was never confirmed.
910
+ *
911
+ * claimNextTask atomically marks a row 'assigned' BEFORE the fire-and-forget dispatch
912
+ * runs. If that dispatch neither rejects (→ no .catch requeue) nor is confirmed
913
+ * delivered — a relay that hangs without acking, or a confirm timer lost across a
914
+ * daemon restart — the row stays 'assigned' forever, contributing 0 pending so PHASE 3
915
+ * reconcile never re-examines it. This returns such a row to 'pending' and clears its
916
+ * dead assignment ownership (node / session / provider / dispatchTimestamp) — the same
917
+ * ownership-clear requeueTask applies — so PHASE 3 can re-dispatch it onto a fresh idle
918
+ * session.
919
+ *
920
+ * Guarded to 'assigned' rows only (a completion/cancel that already moved the row off
921
+ * 'assigned' must never be resurrected) and bounded by MAX_STRANDED_RECLAIMS (beyond
922
+ * which the task is failed so dependents unblock).
923
+ */
924
+ export function reclaimStrandedAssignedTask(
925
+ meshId: string,
926
+ taskId: string,
927
+ opts?: { reason?: string; ageMs?: number } & MeshQueueMutationOptions,
928
+ ): MeshWorkQueueEntry | null {
929
+ requireMeshHostQueueOwner(opts);
930
+ return withQueueLock(meshId, () => {
931
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
932
+ if (!entry) return null;
933
+ // Only a still-assigned row is stranded. If a completion/cancel already moved it
934
+ // off 'assigned', there is nothing to reclaim — never resurrect a terminal row.
935
+ if (entry.status !== 'assigned') return null;
936
+ const now = new Date().toISOString();
937
+ const reason = opts?.reason || 'assigned_stranded_dispatch_unconfirmed';
938
+ const reclaims = (entry.strandedReclaimCount || 0) + 1;
939
+ const prevNode = entry.assignedNodeId;
940
+ const prevSession = entry.assignedSessionId;
941
+ // Always clear the dead assignment ownership so a re-claim starts clean and the
942
+ // assigned-counters (which filter status==='assigned') stop counting this row.
943
+ delete entry.assignedNodeId;
944
+ delete entry.assignedSessionId;
945
+ delete entry.assignedProviderType;
946
+ delete entry.dispatchTimestamp;
947
+ entry.strandedReclaimCount = reclaims;
948
+ entry.updatedAt = now;
949
+ if (reclaims > MAX_STRANDED_RECLAIMS) {
950
+ // Repeatedly undeliverable — stop cycling and fail it so dependents unblock.
951
+ entry.status = 'failed';
952
+ entry.cancelReason = `stranded_dispatch_unrecovered: reclaimed ${reclaims - 1} time(s) without a confirmed dispatch`;
953
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
954
+ propagateDependencyFailure(meshId, taskId);
955
+ } else {
956
+ entry.status = 'pending';
957
+ entry.requeuedAt = now;
958
+ entry.requeueReason = reason;
959
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
960
+ }
961
+ try {
962
+ appendLedgerEntry(meshId, {
963
+ kind: 'task_reclaimed' as MeshLedgerKind,
964
+ nodeId: prevNode,
965
+ sessionId: prevSession,
966
+ payload: {
967
+ taskId,
968
+ reason,
969
+ ...(typeof opts?.ageMs === 'number' ? { ageMs: opts.ageMs } : {}),
970
+ reclaimCount: reclaims,
971
+ outcome: entry.status,
972
+ },
973
+ });
974
+ } catch { /* ledger write is best-effort */ }
975
+ return entry;
976
+ });
977
+ }
978
+
890
979
  /**
891
980
  * Update the status of the task currently assigned to a specific session.
892
981
  */
@@ -8,4 +8,5 @@ export declare function pickAutoApprovalButton(buttons: string[] | null | undefi
8
8
  index: number;
9
9
  label: string;
10
10
  };
11
+ export declare function hasNegativeApprovalOption(buttons: string[] | null | undefined): boolean;
11
12
  export declare function formatAutoApprovalMessage(modalMessage?: string, buttonLabel?: string): string;
@@ -31,6 +31,16 @@ function isNegativeApprovalLabel(value: string): boolean {
31
31
  || /\bdo not\b/.test(label);
32
32
  }
33
33
 
34
+ /**
35
+ * True when any of the given button labels reads as a decline/negative option
36
+ * (No / Deny / Cancel / Skip / …). Used as the second half of an approval-modal
37
+ * structural anchor: a real approval modal offers BOTH an affirmative and a
38
+ * decline, which distinguishes it from a generic numbered menu or prose list.
39
+ */
40
+ export function hasNegativeApprovalOption(buttons: string[] | null | undefined): boolean {
41
+ return (buttons || []).some((button) => isNegativeApprovalLabel(String(button || '')));
42
+ }
43
+
34
44
  export function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[] {
35
45
  const customHints = Array.isArray(provider?.approvalPositiveHints)
36
46
  ? provider.approvalPositiveHints
@@ -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