@adhdev/daemon-core 0.9.82-rc.507 → 0.9.82-rc.509

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,6 +56,7 @@ import {
56
56
  COMPLETED_FINALIZATION_RETRY_MS,
57
57
  COMPLETED_FINALIZATION_MAX_WAIT_MS,
58
58
  NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
59
+ PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS,
59
60
  BACKGROUND_TASK_HOLD_MAX_MS,
60
61
  USER_INPUT_ACK_DEDUP_WINDOW_MS,
61
62
  STARTUP_GRACE_IDLE_COLLAPSE_WINDOW_MS,
@@ -194,6 +195,40 @@ export class CliProviderInstance implements ProviderInstance {
194
195
  */
195
196
  private static readonly AUTO_APPROVE_MASK_STALL_MS = 10500;
196
197
 
198
+ /**
199
+ * AUTOAPPROVE-FLAP-INBOX-MISSING: sticky-approval overlay window. Same time-tick
200
+ * hold idea as the FALSE-IDLE completion gate — an approval signal that was
201
+ * DOMINANT within this recent window is re-presented across a momentary busy blip
202
+ * instead of collapsing.
203
+ *
204
+ * RCA (live 2026-07-13): a claude-cli worker sitting at a Bash approval modal
205
+ * ("Do you want to proceed? ❯1.Yes") flaps waiting_approval↔busy on a ~2-3s period.
206
+ * The spec `approval→busy` transition fires whenever the footer/modal approval
207
+ * markers momentarily drop out of their parsed sections while the PRIOR command's
208
+ * residual spinner text ("✳ Checking vendor drift…") still matches the busy regex.
209
+ * On the busy frame the adapter reports status='generating', activeModal=null. That
210
+ * corrupts THREE consumers at once: (1) mesh_active_work samples 'generating' →
211
+ * collectPendingApprovals never sees 'awaiting_approval' → mesh_list_pending_approvals
212
+ * count:0 (inbox miss); (2) the auto-approve settle gate is torn down each busy phase
213
+ * so the 600ms settle never accrues → auto-approve never fires; (3) a mesh_approve
214
+ * landing on a busy frame hits "Not in approval state". The existing FLAP machinery
215
+ * (AUTO_APPROVE_FLAP_CONTINUITY_MS) only keeps the settle gate warm while status is
216
+ * STILL waiting_approval (buttons scrolled out) — it does nothing once the FSM fully
217
+ * commits to 'busy', and it never stabilises the status the inbox samples.
218
+ *
219
+ * Fix: when the raw adapter status flaps to generating/busy/idle but a
220
+ * waiting_approval WITH a concrete modal was observed within this window, overlay
221
+ * the cached modal and report status='waiting_approval'. This stabilized status
222
+ * feeds getState (→ inbox), detectStatusTransition (→ event emission), and
223
+ * maybeAutoApproveStatus (→ settle gate) uniformly, so the approval both registers
224
+ * in the inbox and settles for auto-approve across the flap. Bounded (a genuine
225
+ * resume that never returns to approval unmasks after this window) and scoped at the
226
+ * call site to autonomous mesh sessions. 4000ms bridges the observed ~2-3s flap with
227
+ * margin while staying well under AUTO_APPROVE_MASK_STALL_MS (a truly stalled/absent
228
+ * approval still surfaces).
229
+ */
230
+ private static readonly APPROVAL_STICKY_FLAP_MS = 4000;
231
+
197
232
  /**
198
233
  * FALSE-IDLE (inter-approval quiet valley): grace window after an auto-approve
199
234
  * (or mesh_approve) RESOLVES a modal during which a subsequent generating→idle
@@ -298,6 +333,14 @@ export class CliProviderInstance implements ProviderInstance {
298
333
  // signature) while a genuinely closed modal — buttons empty continuously past
299
334
  // the continuity window — is still recognised and resets the gate.
300
335
  private autoApproveLastModalSeenAt = 0;
336
+ // AUTOAPPROVE-FLAP-INBOX-MISSING sticky-approval overlay (see APPROVAL_STICKY_FLAP_MS).
337
+ // The wall-clock of the last frame where the RAW adapter reported waiting_approval with
338
+ // a CONCRETE modal (buttons present), the cached modal to re-present across a busy blip,
339
+ // and the approvalEntrySeq at that frame (so a stabilized frame carries the right seq to
340
+ // the emission-dedup fingerprint). All zero/null when no recent concrete approval.
341
+ private approvalStickyLastConcreteAt = 0;
342
+ private approvalStickyModal: { message?: string; buttons?: unknown[]; kind?: string | null } | null = null;
343
+ private approvalStickyEntrySeq = 0;
301
344
  // STATUS-MISMATCH: wall-clock when the CURRENT auto-approve episode (waiting_approval
302
345
  // + shouldAutoApprove) first began wanting to mask. Unlike pendingAutoApprovalSince it
303
346
  // is NOT reset when the modal signature changes (a still-streaming/flapping prompt) and
@@ -533,7 +576,11 @@ export class CliProviderInstance implements ProviderInstance {
533
576
  // in cli-script-runner.ts (CliScriptRunner.detectStatus / parseApproval /
534
577
  // parseSession), with provider-loader.ts updated to store script source strings
535
578
  // alongside the loaded function references for extended-legacy providers.
536
- const adapterStatus = this.adapter.getStatus();
579
+ // AUTOAPPROVE-FLAP-INBOX-MISSING: apply the same sticky-approval overlay the
580
+ // FSM path uses so the status this getState() surfaces to the mesh probe (and
581
+ // thus mesh_active_work → the pending-approval inbox) stays waiting_approval
582
+ // across a busy flap frame, instead of momentarily reading generating (count:0).
583
+ const adapterStatus = this.stabilizeFlappingApprovalStatus(this.adapter.getStatus());
537
584
  if (Object.prototype.hasOwnProperty.call(adapterStatus, 'activeInteractivePrompt')) {
538
585
  this.activeInteractivePrompt = adapterStatus.activeInteractivePrompt ?? null;
539
586
  }
@@ -1753,7 +1800,27 @@ export class CliProviderInstance implements ProviderInstance {
1753
1800
  if (probe?.lastRole === 'assistant' && (probe.contentLen ?? 0) > 0) {
1754
1801
  return null;
1755
1802
  }
1803
+ // (FALSE-IDLE-MIDTURN antigravity) A native-history session whose
1804
+ // external-native transcript has NO in-turn final assistant bubble yet
1805
+ // (present=false AND the probe's tail is not an assistant reply) is NOT
1806
+ // proven done. antigravity previously returned null here — an IMMEDIATE
1807
+ // clean emit with zero transcript evidence — so a momentary PTY-parser
1808
+ // idle blip MID-TURN (the parser reads idle while the turn is still in
1809
+ // flight and the transcript's answer has not landed) fired an early
1810
+ // agent:generating_completed the coordinator could never correct. Instead
1811
+ // HOLD for the transcript: re-probe each retry (readExternalCompletionMessages
1812
+ // runs forceRefresh every call) and clear the block only once the assistant
1813
+ // turn actually lands (the probe branch above → genuine emit). Non-terminal
1814
+ // and bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) so a turn that
1815
+ // genuinely produced no assistant bubble (tool-only) still force-emits a weak
1816
+ // completion rather than wedging — preserving the a0fb6b05 "antigravity always
1817
+ // eventually emits" fix while filtering the mid-turn false-idle. Scoped to
1818
+ // autonomous mesh sessions (allowMissingAssistantTimeout) so an interactive
1819
+ // antigravity session, which has no coordinator to misfire at, is untouched.
1756
1820
  if (this.type === 'antigravity-cli') {
1821
+ if (allowMissingAssistantTimeout) {
1822
+ return { reason: 'missing_final_assistant', terminal: false, holdForTranscript: true };
1823
+ }
1757
1824
  return null;
1758
1825
  }
1759
1826
  // (SETTLE-VALLEY) The inter-approval idle valley: a native-history mesh worker
@@ -1809,6 +1876,40 @@ export class CliProviderInstance implements ProviderInstance {
1809
1876
  }
1810
1877
  } catch { /* defensive: screen text read is best-effort */ }
1811
1878
 
1879
+ // (FALSE-IDLE-MIDTURN codex/PTY) The turn-complete quiet-dwell gate. We only reach
1880
+ // here with finalAssistantEvidence.present === true. For a PTY-PARSED provider
1881
+ // (codex: !adapterOwnsMessagesElsewhere), that "present" verdict is derived from the
1882
+ // on-screen assistant text, which can be a PARTIAL sentence fragment captured mid-stream
1883
+ // when the FSM momentarily read idle. completionHasFinalAssistantMessage accepts the
1884
+ // fragment and hasAdapterPendingResponse can transiently read clean between chunks, so
1885
+ // present flips true mid-turn and this path would clean-emit an early completion. The
1886
+ // flush's lastOutputAt continuity guard only cancels when NEW output ARRIVES during the
1887
+ // settle — it cannot catch a turn that fell quiet just before the arm. Require instead a
1888
+ // minimum QUIET DWELL since the last raw PTY output: a genuinely finished turn's screen
1889
+ // has been stable well past this bound, whereas a mid-stream fragment either just received
1890
+ // output or is about to. Non-terminal HOLD (bounded by COMPLETED_FINALIZATION_MAX_WAIT_MS),
1891
+ // so a real completion re-passes the gate one retry later once the dwell is met; and it
1892
+ // force-emits at the 30s cap rather than wedging. Scoped to autonomous mesh sessions
1893
+ // (allowMissingAssistantTimeout) and PTY-parsed sources only — native-history providers
1894
+ // (antigravity/claude) resolve evidence from the authoritative transcript above, not the
1895
+ // screen, so this dwell does not apply to them and interactive sessions are untouched.
1896
+ if (allowMissingAssistantTimeout
1897
+ && !adapterOwnsMessagesElsewhere
1898
+ && finalAssistantEvidence.source === 'parsed') {
1899
+ try {
1900
+ const outStatus = this.adapter.getStatus({ allowParse: false }) as any;
1901
+ const lastOutputAt = typeof outStatus?.lastOutputAt === 'number' && Number.isFinite(outStatus.lastOutputAt)
1902
+ ? outStatus.lastOutputAt as number
1903
+ : undefined;
1904
+ if (typeof lastOutputAt === 'number') {
1905
+ const quietMs = Date.now() - lastOutputAt;
1906
+ if (quietMs < PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS) {
1907
+ return { reason: 'parsed_final_assistant_quiet_dwell', terminal: false };
1908
+ }
1909
+ }
1910
+ } catch { /* defensive: dwell read is best-effort — fall through to emit */ }
1911
+ }
1912
+
1812
1913
  return null;
1813
1914
  }
1814
1915
 
@@ -2342,6 +2443,72 @@ export class CliProviderInstance implements ProviderInstance {
2342
2443
  });
2343
2444
  }
2344
2445
 
2446
+ /**
2447
+ * AUTOAPPROVE-FLAP-INBOX-MISSING sticky-approval overlay. Returns the adapterStatus a
2448
+ * flap-prone claude-cli approval SHOULD present this frame — either the raw status
2449
+ * unchanged, or, when the raw status has momentarily flapped OFF a recently-dominant
2450
+ * concrete approval, a synthetic `waiting_approval` re-presenting the cached modal.
2451
+ *
2452
+ * Records the concrete approval whenever the raw status is waiting_approval WITH
2453
+ * buttons. On a subsequent non-approval frame (the spec `approval→busy` flap), if that
2454
+ * concrete approval was seen within APPROVAL_STICKY_FLAP_MS AND the engine has NOT
2455
+ * resolved a modal since (lastApprovalResolvedAt not advanced past the sticky start),
2456
+ * overlay the cached modal + waiting_approval so the inbox / auto-approve / mesh_approve
2457
+ * all see the stable approval. A genuine resolution (auto-approve or mesh_approve fires
2458
+ * resolveModal → lastApprovalResolvedAt advances) clears the sticky immediately, so a
2459
+ * legitimate post-approval resume is NEVER masked as a lingering approval. Bounded by the
2460
+ * window, and scoped to autonomous mesh sessions (a foreground/attended or non-mesh
2461
+ * session, where a human answers the prompt, is returned untouched).
2462
+ */
2463
+ private stabilizeFlappingApprovalStatus(adapterStatus: any, now = Date.now()): any {
2464
+ // Only autonomous auto-approving mesh sessions are subject to the delegated flap;
2465
+ // never overlay for attended/foreground/non-mesh sessions.
2466
+ if (!this.isAutonomousMeshSession() || !this.shouldAutoApprove()) return adapterStatus;
2467
+
2468
+ const rawStatus = adapterStatus?.status;
2469
+ const resolvedAt = typeof (this.adapter as any)?.lastApprovalResolvedAt === 'number'
2470
+ ? (this.adapter as any).lastApprovalResolvedAt as number
2471
+ : 0;
2472
+
2473
+ if (rawStatus === 'waiting_approval') {
2474
+ // A concrete modal this frame refreshes the sticky anchor; an approval frame
2475
+ // with buttons momentarily scrolled out is left to the existing settle-gate
2476
+ // hysteresis (we do not touch it — status is already waiting_approval).
2477
+ if (hasNonEmptyCliModalButtons(adapterStatus?.activeModal)) {
2478
+ this.approvalStickyLastConcreteAt = now;
2479
+ this.approvalStickyModal = adapterStatus.activeModal;
2480
+ this.approvalStickyEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
2481
+ ? adapterStatus.approvalEntrySeq
2482
+ : this.approvalStickyEntrySeq;
2483
+ }
2484
+ return adapterStatus;
2485
+ }
2486
+
2487
+ // Non-approval frame. Overlay only if a concrete approval was dominant within the
2488
+ // window AND no resolution has happened since the sticky anchor (a resolveModal
2489
+ // advances lastApprovalResolvedAt to at/after the anchor → the flap is really a
2490
+ // genuine resume, so drop the sticky and report the raw status).
2491
+ if (this.approvalStickyLastConcreteAt > 0 && this.approvalStickyModal) {
2492
+ const withinWindow = (now - this.approvalStickyLastConcreteAt) < CliProviderInstance.APPROVAL_STICKY_FLAP_MS;
2493
+ const resolvedSinceAnchor = resolvedAt >= this.approvalStickyLastConcreteAt;
2494
+ if (withinWindow && !resolvedSinceAnchor) {
2495
+ return {
2496
+ ...adapterStatus,
2497
+ status: 'waiting_approval',
2498
+ activeModal: this.approvalStickyModal,
2499
+ ...(this.approvalStickyEntrySeq ? { approvalEntrySeq: this.approvalStickyEntrySeq } : {}),
2500
+ approvalStickyOverlay: true,
2501
+ };
2502
+ }
2503
+ // Window lapsed or a resolution landed — clear the sticky so a later approval
2504
+ // re-anchors from scratch and a genuine resume surfaces immediately.
2505
+ this.approvalStickyLastConcreteAt = 0;
2506
+ this.approvalStickyModal = null;
2507
+ this.approvalStickyEntrySeq = 0;
2508
+ }
2509
+ return adapterStatus;
2510
+ }
2511
+
2345
2512
  private maybeAutoApproveStatus(adapterStatus: any, now = Date.now()): boolean {
2346
2513
  // Manual-attendance suppression (provider-common): when a human is
2347
2514
  // Manual-attendance suppression (provider-common): when a human is
@@ -2777,7 +2944,12 @@ export class CliProviderInstance implements ProviderInstance {
2777
2944
  // Status-change handling is a hot path: PTY output can fire it many times
2778
2945
  // during long-running CLI sessions. Keep this path on adapter-owned light
2779
2946
  // state only; rich provider parsing is reserved for getState/read_chat.
2780
- const adapterStatus = this.adapter.getStatus({ allowParse: false });
2947
+ // AUTOAPPROVE-FLAP-INBOX-MISSING: stabilize a flap-prone approval BEFORE it feeds
2948
+ // maybeAutoApproveStatus / newStatus / the waiting_approval emission branch below,
2949
+ // so a momentary busy blip during the flap re-presents the cached modal + status
2950
+ // rather than emitting generating (which would corrupt the inbox and tear down the
2951
+ // settle gate). No-op for non-mesh/foreground/non-approval frames.
2952
+ const adapterStatus = this.stabilizeFlappingApprovalStatus(this.adapter.getStatus({ allowParse: false }), now);
2781
2953
  const adapterProviderSessionId = normalizeProviderSessionId(
2782
2954
  this.provider,
2783
2955
  typeof adapterStatus?.providerSessionId === 'string' ? adapterStatus.providerSessionId : '',
@@ -388,16 +388,15 @@ export interface RepoMeshRelatedRepo {
388
388
  * must satisfy both. Omitting `maxParallel` means this provider is bounded only
389
389
  * by the global/taskMode caps (full backward compatibility).
390
390
  *
391
- * Routing is governed exclusively by required_tags (see nodeSatisfiesRequiredTags);
392
- * this entry carries no routing role. To route work to a specific node, advertise an
393
- * ordinary capability tag on the node and require it on the task.
391
+ * Routing is governed exclusively by required_tags (see nodeSatisfiesRequiredTags).
392
+ * To route work to a specific node, advertise an ordinary capability tag on the
393
+ * node and require it on the task.
394
+ *
395
+ * NOTE: the per-(node, provider) parallelism cap now lives on `slots[].maxParallel`
396
+ * (see NodeCapabilitySlot). The former `providerRoles` field has been removed; a
397
+ * persisted meshes.json that still carries it is migrated to `slots` on load
398
+ * (see migrateLoadedMeshConfig).
394
399
  */
395
- export interface RepoMeshProviderRole {
396
- /** Provider type this entry governs (e.g. 'claude-cli', 'codex-cli'). */
397
- providerType: string;
398
- /** Max concurrent active tasks for this (node, provider). Omit = no per-provider cap. */
399
- maxParallel?: number;
400
- }
401
400
 
402
401
  export interface RepoMeshNodePolicy {
403
402
  readOnly?: boolean;
@@ -413,26 +412,14 @@ export interface RepoMeshNodePolicy {
413
412
  schedulingPriority?: number;
414
413
  /** Ordered provider preference used when mesh_launch_session omits an explicit type. */
415
414
  providerPriority?: string[];
416
- /**
417
- * Per-(node, provider) parallelism declarations. Each entry binds a
418
- * providerType on THIS node to an optional maxParallel cap. maxParallel is
419
- * enforced as an additional, stricter-wins constraint on top of the global
420
- * maxParallelTasks/taskMode caps. Missing/empty: the node behaves exactly as
421
- * before (global caps only). Routing is governed solely by required_tags.
422
- *
423
- * SUPERSEDED by `slots` (ORCHESTRATION_NODE_SLOTS.md). Kept for back-compat:
424
- * when `slots` is absent, providerRoles + providerPriority + the machine-global
425
- * difficultyBrains are auto-derived into slots via deriveSlotsFromLegacy.
426
- */
427
- providerRoles?: RepoMeshProviderRole[];
428
415
  /**
429
416
  * Node capability slots (ORCHESTRATION_NODE_SLOTS.md) — the ordered "Preferred
430
417
  * AI tools" profile that is the single source of truth for task routing, MAGI
431
418
  * fan-out, and orchestrator-proposed edits. Each slot bundles provider + model
432
419
  * + thinkingLevel + difficulty range + capability tags + per-slot maxParallel.
433
420
  * Order = preference. When absent, the scheduler derives slots from the legacy
434
- * providerPriority/providerRoles/difficultyBrains (deriveSlotsFromLegacy) so
435
- * existing nodes keep working without reconfiguration.
421
+ * providerPriority/difficultyBrains (deriveSlotsFromLegacy) so existing nodes
422
+ * keep working without reconfiguration.
436
423
  */
437
424
  slots?: NodeCapabilitySlot[];
438
425
  /**
@@ -696,29 +683,38 @@ export function resolveDelegatedWorkerAutoApprove(
696
683
  }
697
684
 
698
685
  /**
699
- * Resolve the enforced per-(node, provider) maxParallel cap, or undefined when
700
- * no finite, non-negative cap is declared for this provider. Used by the queue
701
- * claim path as a stricter-wins constraint layered on top of the global caps.
702
- * Case-insensitive, trimmed match on providerType. Defensive against malformed
703
- * config — non-object entries and blank providerTypes are skipped rather than throwing.
686
+ * Resolve the enforced per-(node, provider) maxParallel cap from a node's resolved
687
+ * capability slots, or undefined when no matching slot declares a finite cap. Used
688
+ * by the queue claim path as a stricter-wins constraint layered on top of the global
689
+ * caps. Case-insensitive, trimmed match on the slot's provider.
690
+ *
691
+ * When a node declares multiple slots for the same provider (e.g. distinct
692
+ * difficulty ranges), their caps SUM into a single per-(node, provider) pool — the
693
+ * provider can run up to the total across all its slots. Legacy-derived slots (via
694
+ * deriveSlotsFromLegacy) produce one slot per provider, so the sum equals that
695
+ * single slot's cap and behavior is preserved exactly.
696
+ *
697
+ * Callers pass the already-resolved slots (explicit policy.slots, else legacy-
698
+ * derived) — this keeps the resolver free of the difficultyBrains dependency and
699
+ * usable from any layer.
704
700
  */
705
701
  export function resolveProviderMaxParallel(
706
- nodePolicy: Pick<RepoMeshNodePolicy, 'providerRoles'> | null | undefined,
702
+ slots: NodeCapabilitySlot[] | null | undefined,
707
703
  providerType: string | null | undefined,
708
704
  ): number | undefined {
709
705
  const wanted = typeof providerType === 'string' ? providerType.trim().toLowerCase() : '';
710
706
  if (!wanted) return undefined;
711
- const roles = nodePolicy?.providerRoles;
712
- if (!Array.isArray(roles)) return undefined;
713
- for (const entry of roles) {
714
- if (!entry || typeof entry !== 'object') continue;
715
- const type = typeof entry.providerType === 'string' ? entry.providerType.trim().toLowerCase() : '';
707
+ if (!Array.isArray(slots)) return undefined;
708
+ let total: number | undefined;
709
+ for (const slot of slots) {
710
+ if (!slot || typeof slot !== 'object') continue;
711
+ const type = typeof slot.provider === 'string' ? slot.provider.trim().toLowerCase() : '';
716
712
  if (!type || type !== wanted) continue;
717
- const raw = Number(entry.maxParallel);
718
- if (!Number.isFinite(raw) || raw < 0) return undefined;
719
- return Math.floor(raw);
713
+ const raw = Number(slot.maxParallel);
714
+ if (!Number.isFinite(raw) || raw < 0) continue;
715
+ total = (total ?? 0) + Math.floor(raw);
720
716
  }
721
- return undefined;
717
+ return total;
722
718
  }
723
719
 
724
720
  // ─── Capabilities ───────────────────────────────
@@ -987,6 +983,11 @@ export interface RepoMeshNodeSchedulingStatus {
987
983
  load: number;
988
984
  schedulingPriority?: number;
989
985
  maxConcurrentSessions?: number;
986
+ /**
987
+ * Per-(node, provider) caps + consumption. Field name kept for dashboard
988
+ * back-compat; the cap source is now slots[].maxParallel (the removed
989
+ * policy.providerRoles no longer exists).
990
+ */
990
991
  providerRoles?: RepoMeshNodeProviderSchedulingStatus[];
991
992
  capReached: boolean;
992
993
  capReasons?: string[];