@adhdev/daemon-core 0.9.82-rc.490 → 0.9.82-rc.492

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.
@@ -114,6 +114,28 @@ export declare class CliProviderInstance implements ProviderInstance {
114
114
  * the nudge was NOT deferred) and leaked to the coordinator.
115
115
  */
116
116
  private static readonly AUTO_APPROVE_MASK_STALL_MS;
117
+ /**
118
+ * FALSE-IDLE (inter-approval quiet valley): grace window after an auto-approve
119
+ * (or mesh_approve) RESOLVES a modal during which a subsequent generating→idle
120
+ * quiet valley must NOT be treated as turn completion.
121
+ *
122
+ * The RCA: auto-approve resolves a modal → the agent resumes the same turn →
123
+ * between resolving that approval and preparing the next tool/approval the agent
124
+ * falls briefly silent. The FSM sees idle + a recorded mid-turn assistant bubble
125
+ * and fires an early agent:generating_completed even though the turn is still in
126
+ * flight. Live evidence showed the same session resuming waiting_approval ~13s
127
+ * after a "clean" completion emit.
128
+ *
129
+ * The window must be comfortably larger than the observed resume gap (~13s) so
130
+ * the valley is bridged, but not so large that a turn that genuinely ended right
131
+ * after an approval is held for an annoying stretch. 18s clears 13s with margin
132
+ * while capping the worst-case extra hold on a truly-finished turn at 18s (still
133
+ * well under COMPLETED_FINALIZATION_MAX_WAIT_MS's 30s hard bound). The recency is
134
+ * measured from the engine's lastApprovalResolvedAt, which is stamped ONLY by
135
+ * resolveModal (auto-approve fire / dashboard / mesh_approve) — so a plain turn
136
+ * with no approval never carries recency and is never held (no regression).
137
+ */
138
+ private static readonly APPROVAL_RESUME_GRACE_MS;
117
139
  private adapter;
118
140
  private context;
119
141
  private events;
@@ -395,6 +417,19 @@ export declare class CliProviderInstance implements ProviderInstance {
395
417
  */
396
418
  private approvableModalSignature;
397
419
  private isAutonomousMeshSession;
420
+ /**
421
+ * FALSE-IDLE: are we inside the post-approval resume grace window? True when this
422
+ * is an autonomous auto-approving mesh session AND the engine resolved a modal
423
+ * (auto-approve / mesh_approve) within APPROVAL_RESUME_GRACE_MS. This is the single
424
+ * "auto-approve recency" judgment shared by Fix 1 (the SETTLE-VALLEY completion
425
+ * hold below) and Fix 2 (the FSM-level applyIdle hysteresis in cli-state-engine).
426
+ *
427
+ * Scoped to autonomous auto-approving sessions so a foreground/attended session,
428
+ * or a session with auto-approve off (whose approvals a human answers), is never
429
+ * held. The recency clock (adapter.lastApprovalResolvedAt) is 0 until the first
430
+ * resolveModal, so a plain turn that never saw an approval always returns false.
431
+ */
432
+ private inApprovalResumeGrace;
398
433
  /**
399
434
  * ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
400
435
  * Prefers the per-turn binding (engine.currentTurnTaskId, set when the turn was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.490",
3
+ "version": "0.9.82-rc.492",
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.490",
51
- "@adhdev/session-host-core": "0.9.82-rc.490",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.492",
51
+ "@adhdev/session-host-core": "0.9.82-rc.492",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -66,6 +66,16 @@ export interface CliStateEngineCallbacks {
66
66
  onStatusChange(): void;
67
67
  onApplyParsedSession(session: ParsedSession): void;
68
68
  onTurnCompleted(): void;
69
+ /**
70
+ * FALSE-IDLE (Fix 2): true when the owning session is inside the post-approval
71
+ * resume grace — an autonomous auto-approving mesh session that resolved a modal
72
+ * within the resume-grace window. The engine cannot decide this on its own (it has
73
+ * no mesh/auto-approve awareness); the instance answers using the SAME
74
+ * `inApprovalResumeGrace` judgment Fix 1 uses, so the two fixes stay in lockstep.
75
+ * Optional: absent ⇒ treated as false (no hysteresis) so any non-instance embedder
76
+ * keeps the pre-fix behavior.
77
+ */
78
+ isInApprovalResumeGrace?(): boolean;
69
79
  }
70
80
 
71
81
  interface IdleFinishCandidate {
@@ -94,6 +104,13 @@ const FINISH_RETRY_DELAY_MS = 300;
94
104
  const MAX_TRACE_ENTRIES = 250;
95
105
  const APPROVAL_EXIT_TIMEOUT_MS = 60_000;
96
106
  const IDLE_CONFIRMATION_GRACE_MS = 2_000;
107
+ // FALSE-IDLE (Fix 2): hard upper bound on how long applyIdle may keep deferring
108
+ // finishResponse inside the post-approval resume grace. Mirrors the instance-side
109
+ // APPROVAL_RESUME_GRACE_MS (18s) — kept as a local constant since the engine has no
110
+ // access to the instance's static. If the turn truly ended right after an approval
111
+ // and stays silent this long, we stop suppressing and let the normal idle-finish run,
112
+ // so a genuinely-finished turn can never be held past this bound (no infinite defer).
113
+ const APPROVAL_RESUME_IDLE_DEFER_CAP_MS = 18_000;
97
114
 
98
115
  // ─── Engine ────────────────────────────────────────────────────────────────
99
116
 
@@ -185,6 +202,13 @@ export class CliStateEngine {
185
202
  // ── Idle candidate ───────────────────────────────
186
203
  private idleFinishCandidate: IdleFinishCandidate | null = null;
187
204
 
205
+ // FALSE-IDLE (Fix 2): wall-clock when the current post-approval resume-grace idle
206
+ // defer began, scoped to a responseEpoch. Zero when not deferring. Bounds the defer
207
+ // to APPROVAL_RESUME_IDLE_DEFER_CAP_MS so a turn that genuinely ended right after an
208
+ // approval eventually finishes. Reset whenever a new turn/response starts or completes.
209
+ private approvalResumeDeferSince = 0;
210
+ private approvalResumeDeferEpoch = -1;
211
+
188
212
  // ── Idle confirmation grace ──────────────────────
189
213
  /**
190
214
  * `finishResponse` produces the `generating → idle` transition that
@@ -450,6 +474,8 @@ export class CliStateEngine {
450
474
  this.activeModal = null;
451
475
  this.pendingScriptStatus = null;
452
476
  this.pendingScriptStatusSince = 0;
477
+ this.approvalResumeDeferSince = 0;
478
+ this.approvalResumeDeferEpoch = -1;
453
479
  }
454
480
 
455
481
  clearIdleFinishCandidate(reason: string): void {
@@ -959,6 +985,28 @@ export class CliStateEngine {
959
985
  && assistantLength >= candidate.assistantLength
960
986
  && (now - candidate.armedAt) >= idleFinishConfirmMs;
961
987
 
988
+ // FALSE-IDLE (Fix 2) FSM-level hysteresis: an autonomous auto-approving mesh
989
+ // worker that just auto-resolved an approval resumes the same turn and falls
990
+ // briefly silent (the inter-approval quiet valley) before the next tool/approval.
991
+ // resetActiveTurnState tearing the turn down in that valley is the root cause the
992
+ // downstream Fix 1 / hasAdapterPendingResponse gates then inherit as "turn closed".
993
+ // Suppress the idle declaration here — at the FSM level — while inside the
994
+ // post-approval resume grace, so the turn scope is NOT torn down mid-flight.
995
+ // Bounded by APPROVAL_RESUME_IDLE_DEFER_CAP_MS: if the turn genuinely ended right
996
+ // after an approval and stays silent past the cap, we stop deferring and let the
997
+ // normal idle-finish run. Re-arm the idle timeout so we re-evaluate after the
998
+ // quiet grows (either the worker resumes → this path releases, or the cap lapses
999
+ // → finish). Scoped through the instance callback so a plain/interactive turn with
1000
+ // no autonomous auto-approve is never affected.
1001
+ if (this.shouldDeferIdleForApprovalResume(now)) {
1002
+ this.clearIdleFinishCandidate('approval_resume_grace_defer');
1003
+ if (this.idleTimeout) clearTimeout(this.idleTimeout);
1004
+ this.idleTimeout = setTimeout(() => {
1005
+ if (this.isWaitingForResponse) this.evaluateSettled(this.transport.getSnapshot());
1006
+ }, this.timeouts.idleFinish);
1007
+ return;
1008
+ }
1009
+
962
1010
  if (idleReady && candidateQuiet) {
963
1011
  this.clearIdleFinishCandidate('finish_response');
964
1012
  if (this.idleTimeout) clearTimeout(this.idleTimeout);
@@ -975,6 +1023,12 @@ export class CliStateEngine {
975
1023
  if (this.idleTimeout) clearTimeout(this.idleTimeout);
976
1024
  this.idleTimeout = setTimeout(() => {
977
1025
  if (this.isWaitingForResponse && !this.hasActionableApproval()) {
1026
+ if (this.shouldDeferIdleForApprovalResume(Date.now())) {
1027
+ this.idleTimeout = setTimeout(() => {
1028
+ if (this.isWaitingForResponse) this.evaluateSettled(this.transport.getSnapshot());
1029
+ }, this.timeouts.idleFinish);
1030
+ return;
1031
+ }
978
1032
  if (this.shouldDeferIdleTimeoutFinish()) return;
979
1033
  const parsed = this.runParseSession(this.transport.getSnapshot());
980
1034
  if (this.shouldDeferFinishForTranscript(parsed)) {
@@ -987,6 +1041,49 @@ export class CliStateEngine {
987
1041
  }, this.timeouts.idleFinish);
988
1042
  }
989
1043
 
1044
+ /**
1045
+ * FALSE-IDLE (Fix 2): should applyIdle suppress the idle/finish for the current
1046
+ * turn because we are inside the post-approval resume grace?
1047
+ *
1048
+ * True only when: (a) the owning instance reports it is an autonomous auto-approving
1049
+ * mesh session that resolved a modal within the resume-grace window
1050
+ * (isInApprovalResumeGrace callback — the SAME judgment Fix 1 uses), AND (b) the defer
1051
+ * for THIS response epoch has not yet exceeded APPROVAL_RESUME_IDLE_DEFER_CAP_MS.
1052
+ * The cap guarantees no infinite defer: a turn that genuinely ended right after an
1053
+ * approval and stays silent past the cap stops being suppressed and finishes normally.
1054
+ * The per-epoch anchor means a fresh turn/response restarts the clock.
1055
+ */
1056
+ private shouldDeferIdleForApprovalResume(now: number): boolean {
1057
+ if (typeof this.callbacks.isInApprovalResumeGrace !== 'function') { this.clearApprovalResumeDefer(); return false; }
1058
+ let inGrace = false;
1059
+ try { inGrace = this.callbacks.isInApprovalResumeGrace() === true; } catch { inGrace = false; }
1060
+ // Probe says the session is no longer in the post-approval resume grace — reset the
1061
+ // cap clock so a LATER approval-resume valley in this same response starts fresh.
1062
+ if (!inGrace) { this.clearApprovalResumeDefer(); return false; }
1063
+ if (this.approvalResumeDeferEpoch !== this.responseEpoch || this.approvalResumeDeferSince === 0) {
1064
+ // First defer for this response — start the cap clock. Note: the clock is only
1065
+ // reset by clearApprovalResumeDefer (probe false, turn teardown), NOT by a
1066
+ // cap-release below — so once the cap trips it STAYS released for the whole
1067
+ // grace episode (no arm/clear cycle can restart the 18s and defer forever).
1068
+ this.approvalResumeDeferEpoch = this.responseEpoch;
1069
+ this.approvalResumeDeferSince = now;
1070
+ return true;
1071
+ }
1072
+ if ((now - this.approvalResumeDeferSince) >= APPROVAL_RESUME_IDLE_DEFER_CAP_MS) {
1073
+ // Cap reached: stop suppressing so the normal idle-finish can run. Deliberately
1074
+ // does NOT clear the clock — leaving deferSince set keeps this branch returning
1075
+ // false on every subsequent call until the probe drops (clearApprovalResumeDefer)
1076
+ // or the turn ends (resetActiveTurnState), preventing an infinite re-defer.
1077
+ return false;
1078
+ }
1079
+ return true;
1080
+ }
1081
+
1082
+ private clearApprovalResumeDefer(): void {
1083
+ this.approvalResumeDeferSince = 0;
1084
+ this.approvalResumeDeferEpoch = -1;
1085
+ }
1086
+
990
1087
  finishResponse(): void {
991
1088
  if (this.submitPendingUntil > Date.now()) return;
992
1089
  if (this.responseSettleIgnoreUntil > Date.now()) return;
@@ -169,6 +169,9 @@ export class ProviderCliAdapter implements CliAdapter {
169
169
  private ptyProcess: PtyRuntimeTransport | null = null;
170
170
  private transportFactory: PtyTransportFactory;
171
171
  private onStatusChange: (() => void) | null = null;
172
+ // FALSE-IDLE (Fix 2): probe the owning instance for the post-approval resume grace.
173
+ // Null until the instance registers it; the engine treats absence as "not in grace".
174
+ private inApprovalResumeGraceProbe: (() => boolean) | null = null;
172
175
 
173
176
  // ─── State machine engine ─────────────────────────
174
177
  readonly engine: CliStateEngine;
@@ -477,6 +480,7 @@ export class ProviderCliAdapter implements CliAdapter {
477
480
  onStatusChange: () => { this.onStatusChange?.(); },
478
481
  onApplyParsedSession: (session) => { this.applyParsedSessionMetadata(session); },
479
482
  onTurnCompleted: () => { this.responseBuffer = ''; },
483
+ isInApprovalResumeGrace: () => this.inApprovalResumeGraceProbe?.() === true,
480
484
  } satisfies CliStateEngineCallbacks,
481
485
  resolvedConfig.timeouts,
482
486
  );
@@ -549,6 +553,13 @@ export class ProviderCliAdapter implements CliAdapter {
549
553
  this.onStatusChange = callback;
550
554
  }
551
555
 
556
+ // FALSE-IDLE (Fix 2): the instance registers its inApprovalResumeGrace judgment so the
557
+ // engine's applyIdle hysteresis can scope itself to autonomous auto-approving sessions
558
+ // without the engine needing any mesh/auto-approve awareness of its own.
559
+ setInApprovalResumeGraceProbe(probe: () => boolean): void {
560
+ this.inApprovalResumeGraceProbe = probe;
561
+ }
562
+
552
563
  setOnPtyData(callback: (data: string) => void): void {
553
564
  this.onPtyDataCallback = callback;
554
565
  }
@@ -537,7 +537,7 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
537
537
  const ownerFailure = await ctx.requireMeshHostMutationOwner(meshId, args?.inlineMesh, 'node update');
538
538
  if (ownerFailure) return ownerFailure;
539
539
  try {
540
- const { updateNode } = await import('../../config/mesh-config.js');
540
+ const { updateNode, normalizeCapabilityTags } = await import('../../config/mesh-config.js');
541
541
  const policy = args?.policy && typeof args.policy === 'object' && !Array.isArray(args.policy)
542
542
  ? { ...(args.policy as Record<string, unknown>) }
543
543
  : {};
@@ -579,13 +579,54 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
579
579
  .filter(Boolean);
580
580
  }
581
581
  const node = updateNode(meshId, nodeId, patch as any);
582
- if (!node) return { success: false, error: 'Mesh node not found' };
583
- // Provider priority / systemPrompt changes don't touch
584
- // the queue revision, so without a manual bust the
585
- // cached aggregate keeps surfacing pre-update values
586
- // (priority chip, coordinator prompt preview, etc.).
587
- ctx.invalidateAggregateMeshStatus(meshId);
588
- return { success: true, node };
582
+ if (node) {
583
+ // Provider priority / systemPrompt changes don't touch
584
+ // the queue revision, so without a manual bust the
585
+ // cached aggregate keeps surfacing pre-update values
586
+ // (priority chip, coordinator prompt preview, etc.).
587
+ ctx.invalidateAggregateMeshStatus(meshId);
588
+ return { success: true, node };
589
+ }
590
+ // NODE-SLOTS-REMOTE-WRITE: updateNode reads ONLY this daemon's local
591
+ // meshes.json. When update_mesh_node is forwarded to a node's home-daemon
592
+ // that has no local config entry for a coordinator-owned mesh (a remote
593
+ // member daemon, or a cloud coordinator that holds the mesh solely in its
594
+ // inline cache), updateNode returns undefined and the write failed with
595
+ // "Mesh node not found" — even though the coordinator attached the mesh
596
+ // snapshot as inlineMesh and the read paths (get_mesh / dry-run / list)
597
+ // resolve it fine via getMeshForCommand's inline fallback. Mirror those
598
+ // read paths here: resolve the mesh from the inline cache and apply the
599
+ // same field semantics as updateNode, persisting to the inline cache.
600
+ const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
601
+ const mesh = meshRecord?.mesh;
602
+ if (!mesh) return { success: false, error: 'Mesh not found' };
603
+ const inlineNode = Array.isArray(mesh.nodes)
604
+ ? mesh.nodes.find((n: any) => meshNodeIdMatches(n, nodeId))
605
+ : undefined;
606
+ if (!inlineNode) return { success: false, error: 'Mesh node not found' };
607
+ // Apply the SAME field semantics updateNode uses so the inline write and a
608
+ // local-config write are indistinguishable: shallow-merge policy, honor an
609
+ // explicit systemPrompt clear, replace/normalize capability tags.
610
+ inlineNode.policy = {
611
+ ...(inlineNode.policy && typeof inlineNode.policy === 'object' && !Array.isArray(inlineNode.policy)
612
+ ? inlineNode.policy as Record<string, unknown>
613
+ : {}),
614
+ ...(patch.policy as Record<string, unknown>),
615
+ };
616
+ if (Object.prototype.hasOwnProperty.call(patch, 'systemPrompt')) {
617
+ const sp = (patch as any).systemPrompt;
618
+ if (typeof sp === 'string' && sp.trim()) inlineNode.systemPrompt = sp;
619
+ else delete inlineNode.systemPrompt;
620
+ }
621
+ if (Object.prototype.hasOwnProperty.call(patch, 'capabilities')) {
622
+ const tags = normalizeCapabilityTags((patch as any).capabilities);
623
+ if (tags && tags.length) inlineNode.capabilities = tags;
624
+ else delete inlineNode.capabilities;
625
+ }
626
+ // updateInlineMeshNode canonicalizes node identity, persists the mutated
627
+ // mesh back to the inline cache, and busts the aggregate-status cache.
628
+ ctx.updateInlineMeshNode(meshId, mesh, inlineNode);
629
+ return { success: true, node: inlineNode };
589
630
  } catch (e: any) {
590
631
  return { success: false, error: e.message };
591
632
  }
@@ -104,7 +104,7 @@ function stripDeadRoleFromProviderRoles(policy: unknown): boolean {
104
104
  return changed;
105
105
  }
106
106
 
107
- function normalizeCapabilityTags(value: unknown): string[] | undefined {
107
+ export function normalizeCapabilityTags(value: unknown): string[] | undefined {
108
108
  if (!Array.isArray(value)) return undefined;
109
109
  const seen = new Set<string>();
110
110
  const tags = value
@@ -193,6 +193,29 @@ export class CliProviderInstance implements ProviderInstance {
193
193
  */
194
194
  private static readonly AUTO_APPROVE_MASK_STALL_MS = 10500;
195
195
 
196
+ /**
197
+ * FALSE-IDLE (inter-approval quiet valley): grace window after an auto-approve
198
+ * (or mesh_approve) RESOLVES a modal during which a subsequent generating→idle
199
+ * quiet valley must NOT be treated as turn completion.
200
+ *
201
+ * The RCA: auto-approve resolves a modal → the agent resumes the same turn →
202
+ * between resolving that approval and preparing the next tool/approval the agent
203
+ * falls briefly silent. The FSM sees idle + a recorded mid-turn assistant bubble
204
+ * and fires an early agent:generating_completed even though the turn is still in
205
+ * flight. Live evidence showed the same session resuming waiting_approval ~13s
206
+ * after a "clean" completion emit.
207
+ *
208
+ * The window must be comfortably larger than the observed resume gap (~13s) so
209
+ * the valley is bridged, but not so large that a turn that genuinely ended right
210
+ * after an approval is held for an annoying stretch. 18s clears 13s with margin
211
+ * while capping the worst-case extra hold on a truly-finished turn at 18s (still
212
+ * well under COMPLETED_FINALIZATION_MAX_WAIT_MS's 30s hard bound). The recency is
213
+ * measured from the engine's lastApprovalResolvedAt, which is stamped ONLY by
214
+ * resolveModal (auto-approve fire / dashboard / mesh_approve) — so a plain turn
215
+ * with no approval never carries recency and is never held (no regression).
216
+ */
217
+ private static readonly APPROVAL_RESUME_GRACE_MS = 18_000;
218
+
196
219
  private adapter: ProviderCliAdapter;
197
220
  private context: InstanceContext | null = null;
198
221
  private events: ProviderEvent[] = [];
@@ -400,6 +423,12 @@ export class CliProviderInstance implements ProviderInstance {
400
423
  this.detectStatusTransition();
401
424
  });
402
425
 
426
+ // FALSE-IDLE (Fix 2): let the engine's applyIdle hysteresis consult THIS instance's
427
+ // auto-approve/mesh-scoped resume-grace judgment (the same one Fix 1 uses).
428
+ if (typeof this.adapter.setInApprovalResumeGraceProbe === 'function') {
429
+ this.adapter.setInApprovalResumeGraceProbe(() => this.inApprovalResumeGrace());
430
+ }
431
+
403
432
  // PTY spawn
404
433
  await this.adapter.spawn();
405
434
  await this.enforceFreshSessionLaunchIfNeeded();
@@ -1630,6 +1659,26 @@ export class CliProviderInstance implements ProviderInstance {
1630
1659
  if (adapterAny?.currentTurnScope) return { reason: 'adapter_turn_scope_active', terminal: !approvalResolvedIdle };
1631
1660
  if (this.hasAdapterPendingResponse()) return { reason: 'adapter_pending_response', terminal: !approvalResolvedIdle };
1632
1661
 
1662
+ // (FALSE-IDLE, Fix 1) SETTLE-VALLEY hold extension for the generating→idle valley.
1663
+ // The existing SETTLE-VALLEY hold below only covers previousStatus==='waiting_approval'.
1664
+ // But when auto-approve RESOLVES a modal the engine flips straight to 'generating'
1665
+ // (resolveModal → setStatus('generating')), so the resumed turn's brief inter-approval
1666
+ // quiet valley arrives with previousStatus==='generating' and a recorded mid-turn
1667
+ // assistant bubble — which satisfies every gate below and fires an early completion
1668
+ // mid-turn (RCA: same session re-enters waiting_approval ~13s later). Here we HOLD:
1669
+ // by this point the adapter's own pending-response evidence (above) is clean — the
1670
+ // engine has torn down currentTurnScope/isWaitingForResponse in the valley — so we
1671
+ // rely on the approval-resume recency signal instead. Non-terminal, so the retry loop
1672
+ // re-runs the resume guard (busy_reentry / new_pty_output / resumed_status in the
1673
+ // flush) and cancels the moment the turn actually resumes; and it clears on its own
1674
+ // once the grace window lapses (a turn that genuinely ended right after an approval
1675
+ // then emits normally). Bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS as a hard floor
1676
+ // against a wedge. Scoped by inApprovalResumeGrace to autonomous auto-approving mesh
1677
+ // sessions with a recent resolveModal, so a plain non-approval turn is untouched.
1678
+ if (!approvalResolvedIdle && this.inApprovalResumeGrace()) {
1679
+ return { reason: 'approval_resume_grace', terminal: false };
1680
+ }
1681
+
1633
1682
  const partial = typeof this.adapter.getPartialResponse === 'function'
1634
1683
  ? this.adapter.getPartialResponse()
1635
1684
  : '';
@@ -1837,6 +1886,27 @@ export class CliProviderInstance implements ProviderInstance {
1837
1886
  return this.isMeshWorkerSession() || !!this.settings.meshCoordinatorFor;
1838
1887
  }
1839
1888
 
1889
+ /**
1890
+ * FALSE-IDLE: are we inside the post-approval resume grace window? True when this
1891
+ * is an autonomous auto-approving mesh session AND the engine resolved a modal
1892
+ * (auto-approve / mesh_approve) within APPROVAL_RESUME_GRACE_MS. This is the single
1893
+ * "auto-approve recency" judgment shared by Fix 1 (the SETTLE-VALLEY completion
1894
+ * hold below) and Fix 2 (the FSM-level applyIdle hysteresis in cli-state-engine).
1895
+ *
1896
+ * Scoped to autonomous auto-approving sessions so a foreground/attended session,
1897
+ * or a session with auto-approve off (whose approvals a human answers), is never
1898
+ * held. The recency clock (adapter.lastApprovalResolvedAt) is 0 until the first
1899
+ * resolveModal, so a plain turn that never saw an approval always returns false.
1900
+ */
1901
+ private inApprovalResumeGrace(now = Date.now()): boolean {
1902
+ if (!this.isAutonomousMeshSession() || !this.shouldAutoApprove()) return false;
1903
+ const resolvedAt = typeof (this.adapter as any)?.lastApprovalResolvedAt === 'number'
1904
+ ? (this.adapter as any).lastApprovalResolvedAt as number
1905
+ : 0;
1906
+ if (resolvedAt <= 0) return false;
1907
+ return (now - resolvedAt) < CliProviderInstance.APPROVAL_RESUME_GRACE_MS;
1908
+ }
1909
+
1840
1910
  /**
1841
1911
  * ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
1842
1912
  * Prefers the per-turn binding (engine.currentTurnTaskId, set when the turn was
@@ -2308,8 +2378,31 @@ export class CliProviderInstance implements ProviderInstance {
2308
2378
  // Mirrors the SDK v1 detect-status approval heuristic (detect-status.ts).
2309
2379
  const modalKind = typeof modal?.kind === 'string' ? modal.kind : 'approval';
2310
2380
  if (modalKind !== 'approval') {
2311
- // Picker/confirm leave it for the user; keep the modal surfaced.
2312
- return autoApproveActive;
2381
+ // Defense-in-depth (APPROVAL-PICKER-MISROUTE): a genuine tool-consent
2382
+ // modal ("Do you want to proceed?" + 1. Yes / 3. No) can be MIS-routed
2383
+ // to modal_kind='picker' by the spec FSM when a picker matcher wins the
2384
+ // priority tie in the wrong state. The spec-level negative guard (Fix A)
2385
+ // is the root fix, but a stale/undeployed spec would leave the worker
2386
+ // wedged on a modal it could safely have approved. So don't bail on the
2387
+ // kind label alone: only bail when this modal is ALSO a genuine SELECTION
2388
+ // picker (/model, /mode — "Select a model/mode/option" / "Switch
2389
+ // between") with NO consent structure. A modal that carries approval
2390
+ // text or a decline/grant anchor is treated as an approval and falls
2391
+ // through to the structural gate below, even under kind='picker'.
2392
+ const modalText = `${String(modal?.title || '')}\n${String(modal?.message || '')}\n${buttons.join('\n')}`;
2393
+ const looksLikeSelectionPicker = /Select (?:a |an )?(?:model|mode|option)\b|Switch between/i.test(modalText);
2394
+ const looksLikeConsent = looksLikeActiveApprovalPromptText(modalText)
2395
+ || /Do you want to (?:proceed|create|make|edit|apply|run|delete|modify|allow)\b|allow all edits\b|don'?t ask again\b/i.test(modalText)
2396
+ || hasNegativeApprovalOption(buttons)
2397
+ || hasReliableApprovalAffirmative(buttons);
2398
+ if (looksLikeSelectionPicker && !looksLikeConsent) {
2399
+ // Genuine /model or /mode selection picker — no safe default to
2400
+ // auto-pick. Leave it for the user; keep the modal surfaced.
2401
+ return autoApproveActive;
2402
+ }
2403
+ // Otherwise: mis-routed consent modal (or an ambiguous picker that still
2404
+ // carries consent structure) — fall through to the structural approval
2405
+ // gate below, which only fires on a real affirmative+decline/grant set.
2313
2406
  }
2314
2407
  const { index: buttonIndex, label: buttonLabel } = pickApprovalButton(buttons, this.provider);
2315
2408
  // Structural decline anchor. A real approval offers BOTH an affirmative