@adhdev/daemon-core 0.9.82-rc.565 → 0.9.82-rc.567

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.
@@ -75,6 +75,10 @@ export interface BuildMeshActiveWorkOptions {
75
75
  /** Include terminal direct rows (idle/failed) for handoff/recent-work surfaces. Defaults false. */
76
76
  includeTerminalDirect?: boolean;
77
77
  }
78
+ export declare function sessionStatusFromNodes(nodes: any[] | undefined, nodeId?: string, sessionId?: string): {
79
+ status?: MeshActiveWorkStatus;
80
+ staleReason?: string;
81
+ };
78
82
  /**
79
83
  * One session awaiting an approval decision — the derived row `mesh_list_pending_approvals`
80
84
  * returns and the UI approvals inbox renders. A thin projection of the `awaiting_approval`
@@ -31,6 +31,15 @@ export declare class CliProviderInstance implements ProviderInstance {
31
31
  * keystroke until the modal *content* has settled.
32
32
  */
33
33
  private static readonly AUTO_APPROVE_SETTLE_MS;
34
+ /**
35
+ * APPROVAL-INBOX-BLINDSPOT (Fix A): how long after a LOCAL auto-approve fire the mesh
36
+ * event forwarder still treats the modal as "being resolved locally" and suppresses the
37
+ * coordinator notification. Chosen to comfortably cover the resolveModal → PTY absorb →
38
+ * status-leaves-approval round trip (incl. the win32 CR-resend loop) while staying short
39
+ * enough that a modal which auto-approve fired at but did NOT resolve re-surfaces to the
40
+ * coordinator on the next event. Aligned with the adapter's own approval cooldown scale.
41
+ */
42
+ private static readonly APPROVAL_LOCAL_RESOLUTION_COOLDOWN_MS;
34
43
  /**
35
44
  * Busy-side hysteresis for the settle gate. A momentary `generating` flip
36
45
  * while the SAME approval modal's button block is still on screen (its
@@ -200,6 +209,7 @@ export declare class CliProviderInstance implements ProviderInstance {
200
209
  private autoApproveSettleTimer;
201
210
  private autoApproveInactiveSince;
202
211
  private autoApproveLastModalSeenAt;
212
+ private lastAutoApproveFiredAt;
203
213
  private approvalStickyLastConcreteAt;
204
214
  private approvalStickyModal;
205
215
  private approvalStickyEntrySeq;
@@ -315,6 +325,21 @@ export declare class CliProviderInstance implements ProviderInstance {
315
325
  * absent from some of them.
316
326
  */
317
327
  resolveModalParkStatus(): 'waiting_choice' | 'waiting_approval' | null;
328
+ /**
329
+ * APPROVAL-INBOX-BLINDSPOT (Fix A): true when this session's approval modal was — or is
330
+ * being — resolved LOCALLY within the recent cooldown. Two independent positive signals:
331
+ * (1) auto-approve fired its resolveModal within APPROVAL_LOCAL_RESOLUTION_COOLDOWN_MS
332
+ * (lastAutoApproveFiredAt), or
333
+ * (2) the underlying adapter reports isApprovalRecentlyResolved() — its own resolve
334
+ * cooldown, which also covers a dashboard / mesh_approve resolution.
335
+ * The mesh event forwarder uses this to decide whether an agent:waiting_approval from an
336
+ * auto-approving worker can be safely SUPPRESSED (a local resolution is in flight) or must
337
+ * be FORWARDED (auto-approve is configured but has NOT actually resolved this modal, so the
338
+ * coordinator/inbox must be told). Keying suppression on real resolution — not just the
339
+ * autoApprove *intent* — is the blind-spot fix: a never-resolving worker approval is no
340
+ * longer silently dropped.
341
+ */
342
+ approvalRecentlyResolvedLocally(now?: number): boolean;
318
343
  /**
319
344
  * NOTIF-HELD-DRAIN: true when this `waiting_approval` is a routine, transient tool-consent
320
345
  * of an autonomously-progressing mesh session rather than a genuine human-await modal —
@@ -143,6 +143,7 @@ export declare class SpecCliAdapter implements CliAdapter {
143
143
  writeRaw(data: string): void;
144
144
  resize(cols: number, rows: number): void;
145
145
  resolveModal(buttonIndex: number): void;
146
+ resolveModalMatched(buttonIndex: number): boolean;
146
147
  resolveAction(data: unknown): Promise<void>;
147
148
  setInteractivePromptResponse(response: InteractivePromptResponse): Promise<void>;
148
149
  isApprovalRecentlyResolved(): boolean;
@@ -119,6 +119,13 @@ export interface ISpecDriver {
119
119
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
120
120
  start(): void;
121
121
  dispatch(cmd: DashboardCommand): void;
122
+ /**
123
+ * BUTTON-INDEX-MISMAP (Fix C.3): click a modal button by its FSM display index and report
124
+ * whether a button was actually matched and its confirm keys dispatched. Unlike the
125
+ * fire-and-forget `dispatch('click_modal_button')`, callers that need to know the click
126
+ * landed (mesh_approve → SpecCliAdapter.resolveModalMatched) can observe a miss.
127
+ */
128
+ clickModalButton(index: number): boolean;
122
129
  updateMeta(meta: Record<string, unknown>, replace?: boolean): void;
123
130
  snapshot(): string;
124
131
  getCursorPosition(): {
@@ -423,6 +430,17 @@ export declare class FsmDriver implements ISpecDriver {
423
430
  */
424
431
  private scheduleWin32Submit;
425
432
  private handleClickControl;
433
+ /**
434
+ * BUTTON-INDEX-MISMAP (Fix C.3): public modal-click entry that returns whether a button
435
+ * matching the requested FSM display index was actually found and its confirm keys were
436
+ * dispatched. The old private handleClickModalButton silently `return`ed on a miss (no
437
+ * modal captured, or no button whose `.index` equals the requested display index), so a
438
+ * mis-mapped index looked identical to a successful press. Callers that need to know
439
+ * whether the click landed (mesh_approve → resolveModal) can now observe the miss instead
440
+ * of reporting success into the void. The generic `dispatch('click_modal_button')` path
441
+ * keeps ignoring the return (fire-and-forget UI clicks).
442
+ */
443
+ clickModalButton(index: number): boolean;
426
444
  private handleClickModalButton;
427
445
  /**
428
446
  * Submit a modal-confirm key sequence (the choice key + its trailing CR).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.565",
3
+ "version": "0.9.82-rc.567",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.565",
51
- "@adhdev/session-host-core": "0.9.82-rc.565",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.567",
51
+ "@adhdev/session-host-core": "0.9.82-rc.567",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -54,6 +54,7 @@ export interface CliAdapter {
54
54
  clearHistory?(): void;
55
55
  resolveAction?(data: unknown): Promise<void>;
56
56
  resolveModal?(buttonIndex: number): void;
57
+ resolveModalMatched?(buttonIndex: number): boolean;
57
58
  isApprovalRecentlyResolved?(): boolean;
58
59
  setOnPtyData?(callback: (data: string) => void): void;
59
60
  writeRaw?(data: string): void;
@@ -15,6 +15,16 @@ export interface CliAdapterStatus {
15
15
  activeModal?: {
16
16
  message: string;
17
17
  buttons: string[];
18
+ /**
19
+ * BUTTON-INDEX-MISMAP (Fix C.1): each button's label paired with its real FSM
20
+ * DISPLAYED index (evaluator's Number(m[1])). `buttons` above is the label-only list
21
+ * every existing consumer reads (array position === pick order); `buttonMeta` preserves
22
+ * the index → label mapping so a partial / non-contiguous modal (display indices [1,3,4]
23
+ * at array positions [0,1,2]) does not lose its true indices once the modal leaves the
24
+ * adapter. Present only on spec/FSM adapters; absent for adapters that surface labels
25
+ * alone.
26
+ */
27
+ buttonMeta?: { index: number; label: string }[];
18
28
  /**
19
29
  * Semantic modal class, when the adapter knows it (spec/FSM path):
20
30
  * 'approval' = tool/command/trust consent (auto-approve may fire);
@@ -155,6 +165,13 @@ export interface CliAdapter {
155
165
  resolveAction?(data: unknown): Promise<void>;
156
166
  setInteractivePromptResponse?(response: InteractivePromptResponse): Promise<void>;
157
167
  resolveModal?(buttonIndex: number): void;
168
+ // BUTTON-INDEX-MISMAP (Fix C.3): resolve a modal button by ARRAY POSITION and report
169
+ // whether a real button was matched (the FSM found a button for the mapped display index
170
+ // and dispatched its confirm keys). A `false` verdict means the requested position mapped
171
+ // to no button — the caller (mesh_approve) must then NOT report success. Optional so
172
+ // legacy adapters that only expose the void resolveModal keep working (the caller falls
173
+ // back to resolveModal + the resolution-cooldown check).
174
+ resolveModalMatched?(buttonIndex: number): boolean;
158
175
  isApprovalRecentlyResolved?(): boolean;
159
176
  // Raw PTY I/O (for terminal view)
160
177
  setOnPtyData?(callback: (data: string) => void): void;
@@ -795,7 +795,21 @@ export async function handleResolveAction(h: CommandHelpers, args: any): Promise
795
795
  LOG.info('Command', `[resolveAction] CLI PTY → stale_prompt (already resolved within cooldown)`);
796
796
  return { success: true, stalePrompt: true, buttonIndex, button: buttons[buttonIndex] ?? button };
797
797
  }
798
- if (typeof adapter.resolveModal === 'function') {
798
+ // BUTTON-INDEX-MISMAP (Fix C.3): prefer resolveModalMatched — it maps the array
799
+ // position to the real FSM display index AND reports whether a button was actually
800
+ // matched. The old path called the void resolveModal and unconditionally returned
801
+ // success:true, so a mis-mapped index (partial/non-contiguous modal) that pressed
802
+ // NOTHING still reported success and left the worker wedged at the modal. When the
803
+ // adapter tells us no button matched, report failure so mesh_approve / the coordinator
804
+ // sees the miss instead of a false success. Legacy adapters without resolveModalMatched
805
+ // keep the prior void-resolveModal behaviour.
806
+ if (typeof adapter.resolveModalMatched === 'function') {
807
+ const matched = adapter.resolveModalMatched(buttonIndex);
808
+ if (!matched) {
809
+ LOG.warn('Command', `[resolveAction] CLI PTY → no button matched for buttonIndex=${buttonIndex} "${buttons[buttonIndex] ?? '?'}" (modal not resolved)`);
810
+ return { success: false, error: 'Approval button index did not map to a visible modal button', buttonIndex, button: buttons[buttonIndex] ?? button };
811
+ }
812
+ } else if (typeof adapter.resolveModal === 'function') {
799
813
  adapter.resolveModal(buttonIndex);
800
814
  } else {
801
815
  const keys = '\x1B[B'.repeat(Math.max(0, buttonIndex)) + '\r';
@@ -102,7 +102,7 @@ function elapsedSince(value: string | undefined, now: number): number {
102
102
  return Number.isFinite(started) ? Math.max(0, now - started) : 0;
103
103
  }
104
104
 
105
- function sessionStatusFromNodes(nodes: any[] | undefined, nodeId?: string, sessionId?: string): { status?: MeshActiveWorkStatus; staleReason?: string } {
105
+ export function sessionStatusFromNodes(nodes: any[] | undefined, nodeId?: string, sessionId?: string): { status?: MeshActiveWorkStatus; staleReason?: string } {
106
106
  if (!Array.isArray(nodes)) return {};
107
107
  if (!nodeId) return { staleReason: 'direct task has no node id' };
108
108
  const node = nodes.find(item => meshNodeIdMatches(item, nodeId));
@@ -365,12 +365,30 @@ export function buildMeshActiveWork(opts: BuildMeshActiveWorkOptions): { activeW
365
365
  for (const task of opts.queue || []) {
366
366
  if (task.status !== 'pending' && task.status !== 'assigned') continue;
367
367
  const { title, summary } = summarizeMessage(task.message || '');
368
+ const queueNodeId = task.assignedNodeId || task.targetNodeId;
369
+ const queueSessionId = task.assignedSessionId || task.targetSessionId;
370
+ // APPROVAL-INBOX-BLINDSPOT (Fix A.2): a queue task previously reported its raw DB
371
+ // status (pending|assigned) verbatim, while direct dispatches consulted the live
372
+ // session status via sessionStatusFromNodes. That asymmetry meant a queue-dispatched
373
+ // worker sitting on an approval modal was recorded as 'assigned', so
374
+ // collectPendingApprovals (which filters status==='awaiting_approval') never counted
375
+ // it and mesh_list_pending_approvals returned 0. Overlay the live session status for
376
+ // an ASSIGNED queue task so an approval (or an active generation) on its bound session
377
+ // is reflected — the exact promotion the direct-dispatch path already does. A 'pending'
378
+ // task has no bound session yet, so it keeps its queue status.
379
+ const queueLive = task.status === 'assigned'
380
+ ? sessionStatusFromNodes(opts.nodes, queueNodeId ?? undefined, queueSessionId ?? undefined)
381
+ : {};
382
+ const queueStatus: MeshActiveWorkStatus = queueLive.status === 'awaiting_approval'
383
+ || queueLive.status === 'generating'
384
+ ? queueLive.status
385
+ : task.status;
368
386
  records.push({
369
387
  taskId: task.id,
370
388
  source: 'queue',
371
- status: task.status,
372
- nodeId: task.assignedNodeId || task.targetNodeId,
373
- sessionId: task.assignedSessionId || task.targetSessionId,
389
+ status: queueStatus,
390
+ nodeId: queueNodeId,
391
+ sessionId: queueSessionId,
374
392
  taskTitle: title,
375
393
  taskSummary: summary,
376
394
  message: task.message,
@@ -727,25 +727,39 @@ function evaluateMeshEventSuppression(
727
727
  }
728
728
 
729
729
  /**
730
- * True when the worker session that emitted this event has auto-approve enabled
731
- * (a MAGI/delegated worker is launched with autoApprove:true). Such a worker
732
- * resolves its own approval modals locally, so an agent:waiting_approval event
733
- * from it is transient noise for the coordinator: forwarding it injects a
734
- * "[System] is waiting for approval, use mesh_approve" turn that the
735
- * coordinator cannot usefully act on (the modal is already auto-resolving) and,
736
- * mid-MAGI-collect, HIJACKS the coordinator's synthesis turn — the observed
737
- * failure where a replica's repeated auto-approvals drowned out the completion
738
- * events and the coordinator answered about approvals instead of the RCA. Only
739
- * suppress when we can positively confirm the source worker auto-approves; a
740
- * worker that genuinely needs a human/coordinator approval (autoApprove off)
741
- * still forwards so the coordinator is told.
730
+ * APPROVAL-INBOX-BLINDSPOT (Fix A): decide whether an agent:waiting_approval from an
731
+ * auto-approving worker can be SUPPRESSED (never forwarded to the coordinator).
732
+ *
733
+ * A MAGI/delegated worker launched with autoApprove:true resolves its own approval modals
734
+ * locally, so a forwarded agent:waiting_approval is transient noise: it injects a
735
+ * "[System] is waiting for approval, use mesh_approve" turn the coordinator cannot usefully
736
+ * act on (the modal is already auto-resolving) and, mid-MAGI-collect, HIJACKS the
737
+ * coordinator's synthesis turn — the observed failure where a replica's repeated
738
+ * auto-approvals drowned out the completion events.
739
+ *
740
+ * BUT the OLD gate suppressed on the autoApprove *intent* alone (settings.autoApprove===true),
741
+ * which is the blind spot: if the worker's local resolveModal never actually fired/resolved
742
+ * (button-index mismatch, a modal auto-approve declined to answer, an unattended stall), the
743
+ * event was STILL dropped — so no task_approval_needed ledger row was created,
744
+ * mesh_list_pending_approvals stayed 0, the coordinator was never told, and the remote
745
+ * UNKNOWN-grace reclaim eventually tore the worker off its task. This tightens the gate:
746
+ * suppress ONLY when auto-approve is on AND we can positively confirm the modal was — or is
747
+ * being — resolved LOCALLY within the recent cooldown (approvalRecentlyResolvedLocally). If
748
+ * auto-approve is configured but has NOT actually resolved this modal, we FORWARD so the
749
+ * coordinator/inbox is told and can act.
742
750
  */
743
- function sourceWorkerAutoApproves(components: DaemonComponents, sessionId: string): boolean {
751
+ function shouldSuppressAutoApprovingWorkerApproval(components: DaemonComponents, sessionId: string): boolean {
744
752
  if (!sessionId) return false;
745
753
  try {
746
- const state = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
754
+ const instance = components.instanceManager?.getInstance?.(sessionId);
755
+ const state = instance?.getState?.();
747
756
  const settings = (state?.settings as Record<string, unknown>) || {};
748
- return settings.autoApprove === true;
757
+ if (settings.autoApprove !== true) return false;
758
+ // Positive local-resolution signal required to suppress. Absent it, forward so the
759
+ // coordinator is told and a task_approval_needed ledger row is created.
760
+ const resolvedLocally = (instance as { approvalRecentlyResolvedLocally?: () => boolean } | undefined)
761
+ ?.approvalRecentlyResolvedLocally;
762
+ return typeof resolvedLocally === 'function' ? resolvedLocally.call(instance) === true : false;
749
763
  } catch {
750
764
  return false;
751
765
  }
@@ -1016,14 +1030,18 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1016
1030
 
1017
1031
  const eventTimestamp = readEventTimestamp(args.metadataEvent.timestamp);
1018
1032
 
1019
- // Auto-approving worker: never forward its approval prompts to the coordinator.
1020
- // The daemon resolves the modal locally, so the "[System] waiting for
1021
- // approval" injection is pure noise that hijacks the coordinator's turn — the
1022
- // observed MAGI failure where a replica's repeated auto-approvals flooded the
1023
- // coordinator and derailed its final synthesis. A worker without auto-approve
1024
- // (genuinely blocked on a human/coordinator decision) still forwards.
1025
- if (args.event === 'agent:waiting_approval' && sourceWorkerAutoApproves(components, eventSessionId)) {
1026
- LOG.info('MeshEvents', `Suppressed agent:waiting_approval for auto-approving worker session ${eventSessionId || '(unknown)'} (mesh ${args.meshId}) — modal is resolved locally, coordinator not notified`);
1033
+ // Auto-approving worker: suppress its approval prompt ONLY when the modal was — or is
1034
+ // being resolved LOCALLY within the recent cooldown. The daemon resolving the modal
1035
+ // locally makes the "[System] … waiting for approval" injection pure noise that hijacks
1036
+ // the coordinator's turn (the observed MAGI failure where a replica's repeated
1037
+ // auto-approvals flooded the coordinator and derailed its final synthesis). But a worker
1038
+ // whose auto-approve is merely CONFIGURED on and did NOT actually resolve this modal
1039
+ // (button-index mismatch, an unattended stall, a modal auto-approve declined to answer)
1040
+ // must still forward otherwise no task_approval_needed ledger row is created, the
1041
+ // coordinator/inbox is never told, and the remote UNKNOWN-grace reclaim eventually tears
1042
+ // the worker off its task (APPROVAL-INBOX-BLINDSPOT, Fix A).
1043
+ if (args.event === 'agent:waiting_approval' && shouldSuppressAutoApprovingWorkerApproval(components, eventSessionId)) {
1044
+ LOG.info('MeshEvents', `Suppressed agent:waiting_approval for auto-approving worker session ${eventSessionId || '(unknown)'} (mesh ${args.meshId}) — modal resolved locally within cooldown, coordinator not notified`);
1027
1045
  traceMeshEventDrop('waiting_approval_auto_approving_worker', traceCtx);
1028
1046
  return { success: true, forwarded: 0, suppressed: true, autoApprovingWorkerApproval: true };
1029
1047
  }
@@ -81,6 +81,7 @@ import {
81
81
  autoPruneStaleDirectDispatches,
82
82
  pollAssignedTaskTerminalEvidence,
83
83
  } from './mesh-completion-synthesis.js';
84
+ import { sessionStatusFromNodes } from './mesh-active-work.js';
84
85
 
85
86
  // Re-export the extracted public API so existing importers (mesh-events.ts barrel;
86
87
  // the reconcile-loop test suite) keep their `from './mesh-reconcile-loop.js'` paths.
@@ -726,6 +727,28 @@ export function __resetReclaimUnknownStreakForTests(): void {
726
727
  deliveredUnconsumedUnknownStreak.clear();
727
728
  }
728
729
 
730
+ // APPROVAL-INBOX-BLINDSPOT (Fix A.3): true when the assigned row's bound session is, per the
731
+ // LIVE mesh-node snapshots, sitting at an approval modal (waiting_approval). A REMOTE worker
732
+ // blocked on an approval reads UNKNOWN from resolveSessionBusyVerdict (it is not in THIS
733
+ // daemon's local instance map), so without this guard the delivered-no-turn / delivered-not-
734
+ // consumed UNKNOWN streak advances toward a false reclaim that tears the worker off a task it
735
+ // is legitimately paused on awaiting the coordinator's mesh_approve. The live status is read
736
+ // from the same node session snapshots mesh_status / the active-work builder use, so it is
737
+ // positive cross-daemon evidence (not a local-only observation). When present it HOLDS the row
738
+ // without accruing the streak; the reclaim resumes normally once the approval clears.
739
+ function assignedRowLiveStatusIsAwaitingApproval(
740
+ mesh: { nodes?: any[] },
741
+ nodeId?: string | null,
742
+ sessionId?: string | null,
743
+ ): boolean {
744
+ if (!nodeId || !sessionId) return false;
745
+ try {
746
+ return sessionStatusFromNodes(mesh.nodes, nodeId, sessionId).status === 'awaiting_approval';
747
+ } catch {
748
+ return false;
749
+ }
750
+ }
751
+
729
752
  // PHASE 2.5 — assigned-stranded dispatch watchdog (Bug B). claimNextTask atomically
730
753
  // flips a row to 'assigned' BEFORE the fire-and-forget dispatch runs. If that dispatch
731
754
  // neither rejects (→ no .catch requeue) nor is confirmed delivered — a relay that hangs
@@ -809,6 +832,20 @@ async function recoverStrandedAssignedDispatches(
809
832
  } else {
810
833
  if (verdict === 'IDLE_CONFIRMED') {
811
834
  deliveredUnconsumedUnknownStreak.delete(shortStreakKey);
835
+ } else if (assignedRowLiveStatusIsAwaitingApproval(mesh, row.assignedNodeId, row.assignedSessionId)) {
836
+ // APPROVAL-INBOX-BLINDSPOT (Fix A.3): the UNKNOWN (remote) worker is live and
837
+ // sitting at an approval modal — it is legitimately paused awaiting the
838
+ // coordinator's mesh_approve, NOT a lost delivery. HOLD without advancing the
839
+ // streak so a genuine approval-blocked worker is never re-driven out from
840
+ // under its pending approval.
841
+ traceMeshEventDrop('short_redrive_deferred_awaiting_approval', {
842
+ taskId: row.id,
843
+ sessionId: row.assignedSessionId,
844
+ nodeId: row.assignedNodeId,
845
+ meshId,
846
+ event: 'agent:waiting_approval',
847
+ }, 'live_awaiting_approval');
848
+ continue;
812
849
  } else {
813
850
  const streak = (deliveredUnconsumedUnknownStreak.get(shortStreakKey) ?? 0) + 1;
814
851
  deliveredUnconsumedUnknownStreak.set(shortStreakKey, streak);
@@ -899,6 +936,21 @@ async function recoverStrandedAssignedDispatches(
899
936
  if (verdict === 'IDLE_CONFIRMED') {
900
937
  deliveredNoTurnUnknownStreak.delete(streakKey);
901
938
  reclaimReason = 'delivered_no_turn_deadline';
939
+ } else if (assignedRowLiveStatusIsAwaitingApproval(mesh, row.assignedNodeId, row.assignedSessionId)) {
940
+ // APPROVAL-INBOX-BLINDSPOT (Fix A.3): the UNKNOWN (remote) worker is live and
941
+ // sitting at an approval modal — legitimately paused awaiting the coordinator's
942
+ // mesh_approve, NOT a delivered-but-lost completion. HOLD without advancing the
943
+ // streak so a genuine approval-blocked worker is never reclaimed at the
944
+ // delivered-no-turn deadline. The reclaim resumes normally once the approval
945
+ // clears (the live status leaves waiting_approval).
946
+ traceMeshEventDrop('reclaim_deferred_awaiting_approval', {
947
+ taskId: row.id,
948
+ sessionId: row.assignedSessionId,
949
+ nodeId: row.assignedNodeId,
950
+ meshId,
951
+ event: 'agent:waiting_approval',
952
+ }, 'live_awaiting_approval');
953
+ continue;
902
954
  } else {
903
955
  // UNKNOWN — defer and accumulate the consecutive-UNKNOWN streak.
904
956
  const streak = (deliveredNoTurnUnknownStreak.get(streakKey) ?? 0) + 1;
@@ -109,6 +109,16 @@ export class CliProviderInstance implements ProviderInstance {
109
109
  */
110
110
  private static readonly AUTO_APPROVE_SETTLE_MS = 600;
111
111
 
112
+ /**
113
+ * APPROVAL-INBOX-BLINDSPOT (Fix A): how long after a LOCAL auto-approve fire the mesh
114
+ * event forwarder still treats the modal as "being resolved locally" and suppresses the
115
+ * coordinator notification. Chosen to comfortably cover the resolveModal → PTY absorb →
116
+ * status-leaves-approval round trip (incl. the win32 CR-resend loop) while staying short
117
+ * enough that a modal which auto-approve fired at but did NOT resolve re-surfaces to the
118
+ * coordinator on the next event. Aligned with the adapter's own approval cooldown scale.
119
+ */
120
+ private static readonly APPROVAL_LOCAL_RESOLUTION_COOLDOWN_MS = 8000;
121
+
112
122
  /**
113
123
  * Busy-side hysteresis for the settle gate. A momentary `generating` flip
114
124
  * while the SAME approval modal's button block is still on screen (its
@@ -387,6 +397,16 @@ export class CliProviderInstance implements ProviderInstance {
387
397
  // signature) while a genuinely closed modal — buttons empty continuously past
388
398
  // the continuity window — is still recognised and resets the gate.
389
399
  private autoApproveLastModalSeenAt = 0;
400
+ // APPROVAL-INBOX-BLINDSPOT (Fix A): wall-clock of the last time this session actually
401
+ // FIRED a local auto-approve resolveModal (the settle gate passed → resolveModal
402
+ // dispatched). The mesh event forwarder keys its agent:waiting_approval suppression on
403
+ // this + a cooldown so it only drops the coordinator notification when we can positively
404
+ // confirm the modal was (or is being) resolved LOCALLY. If auto-approve is merely
405
+ // *configured* on but has NOT recently fired for this modal, the raw waiting_approval is
406
+ // forwarded so a task_approval_needed ledger row is created and the coordinator/inbox is
407
+ // told — closing the blind spot where a never-resolving worker approval was silently
408
+ // dropped just because settings.autoApprove===true.
409
+ private lastAutoApproveFiredAt = 0;
390
410
  // AUTOAPPROVE-FLAP-INBOX-MISSING sticky-approval overlay (see APPROVAL_STICKY_FLAP_MS).
391
411
  // The wall-clock of the last frame where the RAW adapter reported waiting_approval with
392
412
  // a CONCRETE modal (buttons present), the cached modal to re-present across a busy blip,
@@ -1099,6 +1119,34 @@ export class CliProviderInstance implements ProviderInstance {
1099
1119
  return null;
1100
1120
  }
1101
1121
 
1122
+ /**
1123
+ * APPROVAL-INBOX-BLINDSPOT (Fix A): true when this session's approval modal was — or is
1124
+ * being — resolved LOCALLY within the recent cooldown. Two independent positive signals:
1125
+ * (1) auto-approve fired its resolveModal within APPROVAL_LOCAL_RESOLUTION_COOLDOWN_MS
1126
+ * (lastAutoApproveFiredAt), or
1127
+ * (2) the underlying adapter reports isApprovalRecentlyResolved() — its own resolve
1128
+ * cooldown, which also covers a dashboard / mesh_approve resolution.
1129
+ * The mesh event forwarder uses this to decide whether an agent:waiting_approval from an
1130
+ * auto-approving worker can be safely SUPPRESSED (a local resolution is in flight) or must
1131
+ * be FORWARDED (auto-approve is configured but has NOT actually resolved this modal, so the
1132
+ * coordinator/inbox must be told). Keying suppression on real resolution — not just the
1133
+ * autoApprove *intent* — is the blind-spot fix: a never-resolving worker approval is no
1134
+ * longer silently dropped.
1135
+ */
1136
+ approvalRecentlyResolvedLocally(now = Date.now()): boolean {
1137
+ if (this.lastAutoApproveFiredAt
1138
+ && now - this.lastAutoApproveFiredAt < CliProviderInstance.APPROVAL_LOCAL_RESOLUTION_COOLDOWN_MS) {
1139
+ return true;
1140
+ }
1141
+ try {
1142
+ const adapter = this.adapter as { isApprovalRecentlyResolved?: () => boolean };
1143
+ if (typeof adapter.isApprovalRecentlyResolved === 'function') {
1144
+ return adapter.isApprovalRecentlyResolved() === true;
1145
+ }
1146
+ } catch { /* adapter gone / transient */ }
1147
+ return false;
1148
+ }
1149
+
1102
1150
  /**
1103
1151
  * NOTIF-HELD-DRAIN: true when this `waiting_approval` is a routine, transient tool-consent
1104
1152
  * of an autonomously-progressing mesh session rather than a genuine human-await modal —
@@ -3139,8 +3187,34 @@ export class CliProviderInstance implements ProviderInstance {
3139
3187
  this.lastAutoApprovalSignature = '';
3140
3188
  }, 5000);
3141
3189
  this.recordAutoApproval(modal?.message, buttonLabel, now);
3190
+ // APPROVAL-INBOX-BLINDSPOT (Fix A) + BUTTON-INDEX-MISMAP (Fix C): stamp the
3191
+ // local-resolution clock so the mesh forwarder can distinguish "auto-approve just
3192
+ // fired / is firing" (suppress the coordinator notification — modal is being resolved
3193
+ // locally) from "auto-approve is merely configured but has not fired for this modal"
3194
+ // (forward it so the coordinator is told and a task_approval_needed ledger row is
3195
+ // created). Only stamp when the click actually MATCHED a button: resolveModalMatched
3196
+ // maps the array position to the real FSM display index and reports whether a button
3197
+ // was pressed, so a mis-mapped/never-pressed modal does NOT falsely mark itself
3198
+ // locally-resolved (which would suppress the coordinator notification for a modal that
3199
+ // never got answered — the exact blind spot). Legacy adapters keep the void resolveModal.
3200
+ this.lastAutoApproveFiredAt = now;
3142
3201
  setTimeout(() => {
3143
- this.adapter.resolveModal(buttonIndex);
3202
+ const adapter = this.adapter as {
3203
+ resolveModalMatched?: (i: number) => boolean;
3204
+ resolveModal?: (i: number) => void;
3205
+ };
3206
+ if (typeof adapter.resolveModalMatched === 'function') {
3207
+ const matched = adapter.resolveModalMatched(buttonIndex);
3208
+ if (!matched) {
3209
+ // Click did not land on any button — undo the local-resolution stamp so the
3210
+ // next agent:waiting_approval is FORWARDED to the coordinator/inbox rather
3211
+ // than suppressed as "resolved locally".
3212
+ if (this.lastAutoApproveFiredAt === now) this.lastAutoApproveFiredAt = 0;
3213
+ LOG.warn('CLI', `[${this.type}] auto-approve resolveModal matched no button (index ${buttonIndex}) — surfacing approval to coordinator`);
3214
+ }
3215
+ } else {
3216
+ adapter.resolveModal?.(buttonIndex);
3217
+ }
3144
3218
  }, 0);
3145
3219
  return autoApproveActive;
3146
3220
  }
@@ -204,8 +204,21 @@ export class SpecCliAdapter implements CliAdapter {
204
204
  // modal this frame still stays waiting_approval (no activeModal yet).
205
205
  // `kind` carries the semantic modal class through to the auto-approve
206
206
  // gate so a /model picker (kind='picker') is never auto-answered.
207
+ // BUTTON-INDEX-MISMAP (Fix C.1): keep `buttons` as the label list every
208
+ // existing consumer (pickApprovalButton, mesh_approve, auto-approve) reads,
209
+ // but ALSO surface `buttonMeta` carrying each button's real FSM display index
210
+ // alongside its label. A partial/non-contiguous modal (display indices [1,3,4]
211
+ // at array positions [0,1,2]) then no longer loses the index → label mapping
212
+ // once it leaves the adapter: a consumer that has an array position can recover
213
+ // the true FSM index without re-parsing. resolveModal() below relies on the same
214
+ // ordered list to translate an array position to the correct FSM index.
207
215
  activeModal: modal
208
- ? { message: modal.title ?? state.label, buttons: modal.buttons.map(b => b.label), kind: modal.kind ?? null }
216
+ ? {
217
+ message: modal.title ?? state.label,
218
+ buttons: modal.buttons.map(b => b.label),
219
+ buttonMeta: modal.buttons.map(b => ({ index: b.index, label: b.label })),
220
+ kind: modal.kind ?? null,
221
+ }
209
222
  : null,
210
223
  activeInteractivePrompt: this.activeInteractivePrompt,
211
224
  ...sessionFields,
@@ -420,8 +433,29 @@ export class SpecCliAdapter implements CliAdapter {
420
433
  }
421
434
 
422
435
  resolveModal(buttonIndex: number): void {
423
- // CliAdapter buttonIndex is 0-based; spec buttons are 1-based.
424
- this.driver.dispatch({ kind: 'click_modal_button', index: buttonIndex + 1 });
436
+ this.resolveModalMatched(buttonIndex);
437
+ }
438
+
439
+ resolveModalMatched(buttonIndex: number): boolean {
440
+ // BUTTON-INDEX-MISMAP (Fix C): `buttonIndex` is an ARRAY POSITION into the
441
+ // label list this adapter surfaced via getStatus().activeModal.buttons (the
442
+ // same order pickApprovalButton / mesh_approve pick from). The FSM matches a
443
+ // click by the button's DISPLAYED number (evaluator sets button.index =
444
+ // Number(m[1])), which is NOT `arrayPos + 1` for a partial / non-contiguous
445
+ // modal — e.g. a "1. Yes / 3. Always / 4. No" set parses to display indices
446
+ // [1,3,4] at array positions [0,1,2]. Blindly sending `arrayPos + 1` then
447
+ // targets a non-existent display index (2) and handleClickModalButton finds
448
+ // no button → nothing is pressed. Look up the real FSM display index from the
449
+ // same ordered button list instead, and fall back to the legacy +1 only when
450
+ // no modal is captured (defensive; the driver's own guard rejects a miss).
451
+ const buttons = this.latestModal?.buttons ?? [];
452
+ const target = (buttonIndex >= 0 && buttonIndex < buttons.length)
453
+ ? buttons[buttonIndex].index
454
+ : buttonIndex + 1;
455
+ // clickModalButton returns whether the FSM actually found a button for `target`
456
+ // and dispatched its confirm keys — surfaced so mesh_approve can distinguish a
457
+ // real press from a silent miss (the exact false-success the mis-map produced).
458
+ return this.driver.clickModalButton(target);
425
459
  }
426
460
 
427
461
  async resolveAction(data: unknown): Promise<void> {
@@ -118,6 +118,13 @@ export interface ISpecDriver {
118
118
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
119
119
  start(): void;
120
120
  dispatch(cmd: DashboardCommand): void;
121
+ /**
122
+ * BUTTON-INDEX-MISMAP (Fix C.3): click a modal button by its FSM display index and report
123
+ * whether a button was actually matched and its confirm keys dispatched. Unlike the
124
+ * fire-and-forget `dispatch('click_modal_button')`, callers that need to know the click
125
+ * landed (mesh_approve → SpecCliAdapter.resolveModalMatched) can observe a miss.
126
+ */
127
+ clickModalButton(index: number): boolean;
121
128
  updateMeta(meta: Record<string, unknown>, replace?: boolean): void;
122
129
  snapshot(): string;
123
130
  getCursorPosition(): { row: number; col: number };
@@ -428,7 +435,7 @@ export class FsmDriver implements ISpecDriver {
428
435
  case 'send_message': this.handleSendMessage(cmd.text); return;
429
436
  case 'pty_write': this.adapter.send_keys(cmd.data); return;
430
437
  case 'click_control': this.handleClickControl(cmd.control_id, cmd.payload); return;
431
- case 'click_modal_button': this.handleClickModalButton(cmd.index); return;
438
+ case 'click_modal_button': this.clickModalButton(cmd.index); return;
432
439
  case 'attach_image': this.handleAttachImage(cmd.blob, cmd.mime); return;
433
440
  case 'resize': this.adapter.resize(cmd.cols, cmd.rows); return;
434
441
  case 'cancel': this.adapter.send_keys('\x03'); return;
@@ -1318,11 +1325,25 @@ export class FsmDriver implements ISpecDriver {
1318
1325
  }
1319
1326
  }
1320
1327
 
1321
- private handleClickModalButton(index: number): void {
1328
+ /**
1329
+ * BUTTON-INDEX-MISMAP (Fix C.3): public modal-click entry that returns whether a button
1330
+ * matching the requested FSM display index was actually found and its confirm keys were
1331
+ * dispatched. The old private handleClickModalButton silently `return`ed on a miss (no
1332
+ * modal captured, or no button whose `.index` equals the requested display index), so a
1333
+ * mis-mapped index looked identical to a successful press. Callers that need to know
1334
+ * whether the click landed (mesh_approve → resolveModal) can now observe the miss instead
1335
+ * of reporting success into the void. The generic `dispatch('click_modal_button')` path
1336
+ * keeps ignoring the return (fire-and-forget UI clicks).
1337
+ */
1338
+ clickModalButton(index: number): boolean {
1339
+ return this.handleClickModalButton(index);
1340
+ }
1341
+
1342
+ private handleClickModalButton(index: number): boolean {
1322
1343
  const m = this.currentEval?.modal;
1323
- if (!m) return;
1344
+ if (!m) return false;
1324
1345
  const btn = m.buttons.find(b => b.index === index);
1325
- if (!btn) return;
1346
+ if (!btn) return false;
1326
1347
 
1327
1348
  const rule = stateById(this.spec, this.currentStateId)?.extract?.buttons;
1328
1349
  if (rule?.select_mode === 'arrow_keys') {
@@ -1343,9 +1364,10 @@ export class FsmDriver implements ISpecDriver {
1343
1364
  const confirm = (rule.key_for_index || '\r').replace(/\{index\}/g, '') || '\r';
1344
1365
  if (nav) this.adapter.send_keys(nav);
1345
1366
  this.submitModalConfirm(confirm);
1346
- return;
1367
+ return true;
1347
1368
  }
1348
1369
  this.submitModalConfirm(btn.key);
1370
+ return true;
1349
1371
  }
1350
1372
 
1351
1373
  /**