@adhdev/daemon-core 0.9.82-rc.566 → 0.9.82-rc.568

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.
@@ -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 —
@@ -1344,7 +1392,7 @@ export class CliProviderInstance implements ProviderInstance {
1344
1392
  * real last answer with ZERO per-tick native reads (the native read already ran
1345
1393
  * once at completion). Reset on the next turn's start.
1346
1394
  */
1347
- private lastCompletionSummary: { content: string; receivedAt: number } | null = null;
1395
+ private lastCompletionSummary: { content: string; receivedAt: number; sourceTimestampMs?: number } | null = null;
1348
1396
 
1349
1397
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
1350
1398
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -1546,18 +1594,31 @@ export class CliProviderInstance implements ProviderInstance {
1546
1594
  * the dashboard tail-repair cache — a display value, not a completion decision.
1547
1595
  */
1548
1596
  private lastVisibleAssistantSummary(messages: unknown): string {
1549
- if (!Array.isArray(messages)) return '';
1597
+ return this.lastVisibleAssistantSummaryDetail(messages).content;
1598
+ }
1599
+
1600
+ // Like lastVisibleAssistantSummary but also returns the source bubble's own
1601
+ // timestamp (ms), so a cached summary can later be turn-scoped: the display
1602
+ // cache is populated from an UNSCOPED tail read (it must show the answer as
1603
+ // soon as native-history has it), so it can hold a bubble that predates the
1604
+ // current turn. Recording the bubble's timestamp lets the weak-completion
1605
+ // fallback reject a turn-stale cached summary instead of re-leaking the exact
1606
+ // stale bubble the turn-boundary gate already rejected (FALSE-IDLE Defect 1c).
1607
+ private lastVisibleAssistantSummaryDetail(messages: unknown): { content: string; timestampMs?: number } {
1608
+ if (!Array.isArray(messages)) return { content: '' };
1550
1609
  for (let i = messages.length - 1; i >= 0; i -= 1) {
1551
1610
  const m = messages[i] as { role?: string; kind?: string; content?: unknown };
1552
1611
  const role = typeof m?.role === 'string' ? m.role : '';
1553
1612
  const kind = typeof m?.kind === 'string' ? m.kind : '';
1554
1613
  if (role === 'system') continue;
1555
1614
  if (kind === 'tool' || kind === 'activity') continue;
1556
- if (role === 'user' || role === 'human') return '';
1557
- if (role === 'assistant') return flattenContent(m.content as any).trim();
1558
- return '';
1615
+ if (role === 'user' || role === 'human') return { content: '' };
1616
+ if (role === 'assistant') {
1617
+ return { content: flattenContent(m.content as any).trim(), timestampMs: readChatMessageTimestampMs(m as any) };
1618
+ }
1619
+ return { content: '' };
1559
1620
  }
1560
- return '';
1621
+ return { content: '' };
1561
1622
  }
1562
1623
 
1563
1624
  /**
@@ -1580,6 +1641,29 @@ export class CliProviderInstance implements ProviderInstance {
1580
1641
  return content;
1581
1642
  }
1582
1643
 
1644
+ // FALSE-IDLE Defect 1c: turn-scoped view of the cached completion summary.
1645
+ // The cache is populated from an UNSCOPED tail read (lastVisibleAssistant‑
1646
+ // SummaryDetail) so the dashboard can show the answer the instant native-history
1647
+ // has it — which means it can hold a bubble that PREDATES the producing turn.
1648
+ // The weak-completion (missing_final_assistant) emit path falls back to the cache
1649
+ // for finalSummary; without turn-scoping it would re-surface the exact stale
1650
+ // mid-turn bubble the turn-boundary gate already rejected as evidence, freezing
1651
+ // that stale text as the completion's finalSummary. Consult the cache only when
1652
+ // its source bubble is proven in-turn (timestamp at/after turnStartedAt). When no
1653
+ // boundary is known (turnStartedAt falsy) or the cache carries no source timestamp
1654
+ // (legacy writes), behaviour is identical to the unscoped read.
1655
+ private cachedInTurnCompletionSummaryContent(turnStartedAt?: number): string {
1656
+ const cached = this.lastCompletionSummary;
1657
+ const content = typeof cached?.content === 'string' ? cached.content.trim() : '';
1658
+ if (!content) return '';
1659
+ const hasBoundary = typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0;
1660
+ const ts = cached?.sourceTimestampMs;
1661
+ if (hasBoundary && typeof ts === 'number' && Number.isFinite(ts) && ts < (turnStartedAt as number)) {
1662
+ return '';
1663
+ }
1664
+ return content;
1665
+ }
1666
+
1583
1667
  private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1584
1668
  // (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
1585
1669
  // pure message-content check ("does the last visible bubble read as a finalized assistant
@@ -1633,9 +1717,9 @@ export class CliProviderInstance implements ProviderInstance {
1633
1717
  // (which also requires turnClosed and turn-scoping): the dashboard should
1634
1718
  // show the answer as soon as native-history has it, even if the FSM has
1635
1719
  // not yet ratified the turn end. getState() replaces it on the next turn.
1636
- const lastVisibleAssistant = this.lastVisibleAssistantSummary(externalMessages);
1637
- if (lastVisibleAssistant) {
1638
- this.lastCompletionSummary = { content: lastVisibleAssistant, receivedAt: Date.now() };
1720
+ const lastVisibleAssistant = this.lastVisibleAssistantSummaryDetail(externalMessages);
1721
+ if (lastVisibleAssistant.content) {
1722
+ this.lastCompletionSummary = { content: lastVisibleAssistant.content, receivedAt: Date.now(), sourceTimestampMs: lastVisibleAssistant.timestampMs };
1639
1723
  }
1640
1724
  return {
1641
1725
  present,
@@ -1697,7 +1781,11 @@ export class CliProviderInstance implements ProviderInstance {
1697
1781
  // sessions whose generating_completed is suppressed, and short-gen
1698
1782
  // settle paths), so the dashboard sees the answer even when no
1699
1783
  // agent:generating_completed is ever emitted. Native read already ran.
1700
- this.lastCompletionSummary = { content: externalSummary, receivedAt: Date.now() };
1784
+ // externalSummary is already turn-scoped (extractFinalSummaryFromMessagesAfter
1785
+ // dropped any bubble predating turnStartedAt), so it is in-turn by construction.
1786
+ // Record the turn boundary as its source timestamp so the weak-completion
1787
+ // fallback (cachedInTurnCompletionSummaryContent) accepts it.
1788
+ this.lastCompletionSummary = { content: externalSummary, receivedAt: Date.now(), sourceTimestampMs: typeof turnStartedAt === 'number' ? turnStartedAt : undefined };
1701
1789
  return externalSummary;
1702
1790
  }
1703
1791
  return parsedSummary || undefined;
@@ -1721,7 +1809,12 @@ export class CliProviderInstance implements ProviderInstance {
1721
1809
  parseError = error?.message || String(error);
1722
1810
  }
1723
1811
 
1724
- const evidence = this.completionFinalAssistantEvidence(parsed?.messages);
1812
+ // FALSE-IDLE Defect 1c: turn-scope the diagnostic's evidence probe too. Passing
1813
+ // pending.turnStartedAt makes completionHasFinalAssistantMessage reject a stale
1814
+ // mid-turn bubble (predating the turn) just as the finalization gate did, so the
1815
+ // diagnostic cannot credit finalAssistantPresent (or clear missing_final_assistant)
1816
+ // off a bubble the gate already rejected. With no boundary this is unchanged.
1817
+ const evidence = this.completionFinalAssistantEvidence(parsed?.messages, args.pending.turnStartedAt);
1725
1818
  if (evidence.source === 'external-native') {
1726
1819
  this.recordPendingTranscriptProbe(args.pending);
1727
1820
  }
@@ -1742,7 +1835,7 @@ export class CliProviderInstance implements ProviderInstance {
1742
1835
  // missing_final_assistant with an empty payload. Only ever UPGRADES a
1743
1836
  // point-sample miss — a genuine present=true is unchanged, and an empty cache
1744
1837
  // leaves the missing-evidence diagnostic exactly as before.
1745
- const cachedSummary = evidence.present ? '' : this.cachedCompletionSummaryContent();
1838
+ const cachedSummary = evidence.present ? '' : this.cachedInTurnCompletionSummaryContent(args.pending.turnStartedAt);
1746
1839
  const creditedFromCache = !evidence.present && cachedSummary.length > 0;
1747
1840
  const finalAssistantPresent = evidence.present || creditedFromCache;
1748
1841
  const finalAssistantEvidenceSource = evidence.present
@@ -2707,7 +2800,7 @@ export class CliProviderInstance implements ProviderInstance {
2707
2800
  // carries the summary that mesh_read_chat.summary already shows — consistent with
2708
2801
  // completionDiagnostic.finalAssistantPresent being credited from the same cache.
2709
2802
  finalSummary: (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt)
2710
- || this.cachedCompletionSummaryContent()
2803
+ || this.cachedInTurnCompletionSummaryContent(pending.turnStartedAt)
2711
2804
  || (blockReason.startsWith('parsed_status:') ? '' : undefined)),
2712
2805
  completionDiagnostic,
2713
2806
  });
@@ -3139,8 +3232,34 @@ export class CliProviderInstance implements ProviderInstance {
3139
3232
  this.lastAutoApprovalSignature = '';
3140
3233
  }, 5000);
3141
3234
  this.recordAutoApproval(modal?.message, buttonLabel, now);
3235
+ // APPROVAL-INBOX-BLINDSPOT (Fix A) + BUTTON-INDEX-MISMAP (Fix C): stamp the
3236
+ // local-resolution clock so the mesh forwarder can distinguish "auto-approve just
3237
+ // fired / is firing" (suppress the coordinator notification — modal is being resolved
3238
+ // locally) from "auto-approve is merely configured but has not fired for this modal"
3239
+ // (forward it so the coordinator is told and a task_approval_needed ledger row is
3240
+ // created). Only stamp when the click actually MATCHED a button: resolveModalMatched
3241
+ // maps the array position to the real FSM display index and reports whether a button
3242
+ // was pressed, so a mis-mapped/never-pressed modal does NOT falsely mark itself
3243
+ // locally-resolved (which would suppress the coordinator notification for a modal that
3244
+ // never got answered — the exact blind spot). Legacy adapters keep the void resolveModal.
3245
+ this.lastAutoApproveFiredAt = now;
3142
3246
  setTimeout(() => {
3143
- this.adapter.resolveModal(buttonIndex);
3247
+ const adapter = this.adapter as {
3248
+ resolveModalMatched?: (i: number) => boolean;
3249
+ resolveModal?: (i: number) => void;
3250
+ };
3251
+ if (typeof adapter.resolveModalMatched === 'function') {
3252
+ const matched = adapter.resolveModalMatched(buttonIndex);
3253
+ if (!matched) {
3254
+ // Click did not land on any button — undo the local-resolution stamp so the
3255
+ // next agent:waiting_approval is FORWARDED to the coordinator/inbox rather
3256
+ // than suppressed as "resolved locally".
3257
+ if (this.lastAutoApproveFiredAt === now) this.lastAutoApproveFiredAt = 0;
3258
+ LOG.warn('CLI', `[${this.type}] auto-approve resolveModal matched no button (index ${buttonIndex}) — surfacing approval to coordinator`);
3259
+ }
3260
+ } else {
3261
+ adapter.resolveModal?.(buttonIndex);
3262
+ }
3144
3263
  }, 0);
3145
3264
  return autoApproveActive;
3146
3265
  }
@@ -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
  /**