@adhdev/daemon-core 0.9.82-rc.442 → 0.9.82-rc.444

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.
@@ -29,7 +29,7 @@ import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOptio
29
29
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
30
30
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
31
31
  import { normalizeProviderSessionId } from './provider-session-id.js';
32
- import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter } from './chat-message-normalization.js';
32
+ import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter, readChatMessageTimestampMs } from './chat-message-normalization.js';
33
33
  import { workingDirBasename } from './working-dir.js';
34
34
  import { ManualAttendanceTracker } from './manual-attendance.js';
35
35
 
@@ -75,6 +75,16 @@ type CompletedDebouncePending = {
75
75
  // debounce that flushes before the producing turn's final assistant bubble lands
76
76
  // in the native transcript never echoes the PRIOR task's last bubble.
77
77
  turnStartedAt?: number;
78
+ // FALSE-IDLE continuity: the busyEpoch value at the instant this pending was armed.
79
+ // The flush guard requires this.busyEpoch to still equal this — proving no busy
80
+ // phase (generating/waiting_approval) opened since arming. A momentary busy→idle
81
+ // blip in an inter-approval valley bumps busyEpoch, so a completion armed before
82
+ // the blip is cancelled at flush instead of emitting a stale mid-turn summary.
83
+ busyEpochAtArm?: number;
84
+ // FALSE-IDLE continuity: the adapter's raw PTY lastOutputAt at arm time. New PTY
85
+ // output after arming means the session was not continuously idle through the
86
+ // settle window (the agent kept printing), so the completion is cancelled.
87
+ lastOutputAtArm?: number;
78
88
  };
79
89
 
80
90
  function isIdleStatus(value: unknown): boolean {
@@ -508,6 +518,15 @@ export class CliProviderInstance implements ProviderInstance {
508
518
  // first sets it; the other becomes a no-op.
509
519
  private agentReadyEmitted = false;
510
520
  private generatingStartedAt: number = 0;
521
+ // FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
522
+ // phase (→generating or →waiting_approval). The completedDebouncePending snapshots
523
+ // this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
524
+ // — proving the session did not re-enter a busy phase (a momentary busy→idle blip
525
+ // in an inter-approval valley) between arming the debounce and flushing it. A
526
+ // single point-sample of status at flush time cannot see a generating phase that
527
+ // opened AND closed within the settle window; the epoch can. See
528
+ // flushCompletedDebounceIfFinalized.
529
+ private busyEpoch: number = 0;
511
530
  // GENERATING-BOUNDARY (R4b): the per-turn taskId for which a startup-grace
512
531
  // started+completed pair was already synthesized. Both fast-collapse callers
513
532
  // (starting→idle transition AND the idle-stayed no-status-change poll) route
@@ -1188,8 +1207,7 @@ export class CliProviderInstance implements ProviderInstance {
1188
1207
  * to the genuine-modal classification.
1189
1208
  */
1190
1209
  private isTransientToolConsent(now = Date.now()): boolean {
1191
- const isAutonomousMeshSession = this.isMeshWorkerSession() || !!this.settings.meshCoordinatorFor;
1192
- return isAutonomousMeshSession
1210
+ return this.isAutonomousMeshSession()
1193
1211
  && this.hasAdapterPendingResponse()
1194
1212
  && !this.manualAttendance.isAttended(now);
1195
1213
  }
@@ -1411,7 +1429,7 @@ export class CliProviderInstance implements ProviderInstance {
1411
1429
  this.applyProviderResponse(parsed.payload, { phase: 'immediate' });
1412
1430
  }
1413
1431
 
1414
- private completionHasFinalAssistantMessage(messages: unknown): boolean {
1432
+ private completionHasFinalAssistantMessage(messages: unknown, turnStartedAt?: number): boolean {
1415
1433
  const visibleMessages = (Array.isArray(messages) ? messages : [])
1416
1434
  .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1417
1435
  const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
@@ -1421,6 +1439,21 @@ export class CliProviderInstance implements ProviderInstance {
1421
1439
  // Guard: if the last assistant message looks like an active approval/input prompt,
1422
1440
  // it is not a real completion — the session is still awaiting user input.
1423
1441
  if (looksLikeActiveApprovalPromptText(content)) return false;
1442
+ // FALSE-IDLE turn-boundary evidence (Defect 1b): when a producing-turn start is
1443
+ // known, the final assistant bubble must POST-DATE it. A STALE mid-turn assistant
1444
+ // (predating this turn's start — e.g. the last bubble of a prior sub-turn observed
1445
+ // during an inter-approval valley) must NOT satisfy the finalization gate, or a
1446
+ // false-idle blip emits a completion carrying that stale summary. A bubble with no
1447
+ // parseable timestamp cannot be proven stale, so it is kept (fails open — behaviour
1448
+ // identical to before for providers/paths that carry no timestamps).
1449
+ if (typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0) {
1450
+ // readChatMessageTimestampMs mirrors the summary turn-scoping reader
1451
+ // (extractFinalSummaryFromMessagesAfter) — same seconds-vs-ms heuristic and
1452
+ // field precedence — so the present-check and the summary-scope agree on which
1453
+ // bubbles predate the turn.
1454
+ const ts = readChatMessageTimestampMs(lastVisible);
1455
+ if (typeof ts === 'number' && ts < turnStartedAt) return false;
1456
+ }
1424
1457
  return true;
1425
1458
  }
1426
1459
 
@@ -1485,8 +1518,8 @@ export class CliProviderInstance implements ProviderInstance {
1485
1518
  return restoredHistory.messages;
1486
1519
  }
1487
1520
 
1488
- private completionFinalAssistantEvidence(parsedMessages: unknown): CompletionFinalAssistantEvidence {
1489
- if (this.completionHasFinalAssistantMessage(parsedMessages)) {
1521
+ private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1522
+ if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
1490
1523
  return {
1491
1524
  present: true,
1492
1525
  messages: Array.isArray(parsedMessages) ? parsedMessages : [],
@@ -1497,7 +1530,7 @@ export class CliProviderInstance implements ProviderInstance {
1497
1530
  const externalMessages = this.readExternalCompletionMessages();
1498
1531
  if (externalMessages) {
1499
1532
  return {
1500
- present: this.completionHasFinalAssistantMessage(externalMessages),
1533
+ present: this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1501
1534
  messages: externalMessages,
1502
1535
  source: 'external-native',
1503
1536
  };
@@ -1528,10 +1561,17 @@ export class CliProviderInstance implements ProviderInstance {
1528
1561
  // at/after turnStartedAt yields '' in that race instead of the stale tail; the weak/empty
1529
1562
  // summary is later upgraded by the mesh reconcile loop once the real bubble is written.
1530
1563
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1531
- const parsedSummary = extractFinalSummaryFromMessages(
1532
- (this.completionHasFinalAssistantMessage(parsedMessages)
1564
+ // FALSE-IDLE Defect 1b: turn-scope the PARSED screen fallback too. Without this a stale
1565
+ // mid-turn assistant (predating turnStartedAt) that the turn-boundary gate already
1566
+ // rejected as evidence could still leak into the finalSummary via this parsed fallback
1567
+ // when the external transcript's turn-scoped read is empty — freezing the very stale text
1568
+ // the gate rejected. extractFinalSummaryFromMessagesAfter drops bubbles before the turn
1569
+ // start; with no boundary known (turnStartedAt falsy) it is identical to the unscoped read.
1570
+ const parsedSummary = extractFinalSummaryFromMessagesAfter(
1571
+ (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)
1533
1572
  ? (Array.isArray(parsedMessages) ? parsedMessages : [])
1534
1573
  : []) as any,
1574
+ turnStartedAt,
1535
1575
  );
1536
1576
  if (adapterOwnsMessagesElsewhere) {
1537
1577
  const externalMessages = this.readExternalCompletionMessages();
@@ -1669,7 +1709,11 @@ export class CliProviderInstance implements ProviderInstance {
1669
1709
  }
1670
1710
  if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
1671
1711
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1672
- const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
1712
+ // FALSE-IDLE turn-boundary evidence (Defect 1b): turn-scope the present-check so a
1713
+ // STALE mid-turn assistant (predating pending.turnStartedAt) cannot satisfy the
1714
+ // finalization gate. Only the confirming final-assistant bubble that POST-DATES this
1715
+ // turn's start counts as evidence the turn genuinely ended.
1716
+ const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages, pending.turnStartedAt);
1673
1717
  const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1674
1718
  LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1675
1719
  if (!finalAssistantEvidence.present) {
@@ -1796,6 +1840,20 @@ export class CliProviderInstance implements ProviderInstance {
1796
1840
  || this.settings.meshNodeId || this.settings.launchedByCoordinator);
1797
1841
  }
1798
1842
 
1843
+ // FALSE-IDLE (self-coordinator settle): an autonomously-progressing mesh session
1844
+ // is either a delegated worker (isMeshWorkerSession) OR the coordinator's OWN
1845
+ // claude-cli session (meshCoordinatorFor). Both run auto-approved tool turns whose
1846
+ // inter-approval valley (busy→idle blip→generating re-entry ~0.5s later) must be
1847
+ // absorbed by the completedDebounce settle window, not flushed on the first idle
1848
+ // sample. The worker branch already gets NATIVE_HISTORY_MESH_IDLE_SETTLE_MS; the
1849
+ // self-coordinator session (worker markers absent, meshCoordinatorFor present) was
1850
+ // taking flushDelay=0 — no settle window — so its busyEpoch/lastOutputAt continuity
1851
+ // guard had no window to observe the valley and fired mid-turn "next-step" previews
1852
+ // as a finalSummary. Mirrors the isAutonomousMeshSession notion in isTransientToolConsent.
1853
+ private isAutonomousMeshSession(): boolean {
1854
+ return this.isMeshWorkerSession() || !!this.settings.meshCoordinatorFor;
1855
+ }
1856
+
1799
1857
  /**
1800
1858
  * ARCH-REFACTOR R1: the taskId to attribute the CURRENTLY-completing turn to.
1801
1859
  * Prefers the per-turn binding (engine.currentTurnTaskId, set when the turn was
@@ -1844,6 +1902,32 @@ export class CliProviderInstance implements ProviderInstance {
1844
1902
  return;
1845
1903
  }
1846
1904
 
1905
+ // FALSE-IDLE continuity guard (Defect 1a): the point-sample above only proves the
1906
+ // session is idle at THIS instant. A momentary busy→idle blip inside an inter-approval
1907
+ // valley (auto-approved tool turns) opens AND closes a generating phase entirely within
1908
+ // the settle window — so the single sample reads 'idle' even though the turn is still in
1909
+ // flight (it re-enters generating ~0.5s later). Require instead that the session stayed
1910
+ // CONTINUOUSLY idle since the debounce was armed: (1) no entry into a busy phase
1911
+ // (busyEpoch unchanged), and (2) no new raw PTY output (lastOutputAt did not advance).
1912
+ // Either signal ⇒ the idle was not continuous ⇒ cancel; the still-live turn re-arms its
1913
+ // own completion when it genuinely finishes. This only ever cancels (never emits more),
1914
+ // so shared behaviour for claude/codex/antigravity is strictly stricter, never looser.
1915
+ if (typeof pending.busyEpochAtArm === 'number' && this.busyEpoch !== pending.busyEpochAtArm) {
1916
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (busy re-entry during settle: epoch ${pending.busyEpochAtArm}→${this.busyEpoch})`);
1917
+ this.completedDebouncePending = null;
1918
+ this.completedDebounceTimer = null;
1919
+ return;
1920
+ }
1921
+ const latestOutputAt = typeof (latestStatus as any)?.lastOutputAt === 'number' ? (latestStatus as any).lastOutputAt as number : undefined;
1922
+ if (typeof pending.lastOutputAtArm === 'number'
1923
+ && typeof latestOutputAt === 'number'
1924
+ && latestOutputAt > pending.lastOutputAtArm) {
1925
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (new PTY output during settle: ${pending.lastOutputAtArm}→${latestOutputAt})`);
1926
+ this.completedDebouncePending = null;
1927
+ this.completedDebounceTimer = null;
1928
+ return;
1929
+ }
1930
+
1847
1931
  const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
1848
1932
  if (block) {
1849
1933
  const blockReason = block.reason;
@@ -2357,6 +2441,9 @@ export class CliProviderInstance implements ProviderInstance {
2357
2441
  }
2358
2442
 
2359
2443
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
2444
+ // FALSE-IDLE continuity: entering a busy phase invalidates any
2445
+ // completedDebouncePending armed earlier in this settle window.
2446
+ this.busyEpoch++;
2360
2447
  // Defer the generating_started event — if idle comes back within 3s,
2361
2448
  // the whole started→completed pair was a false positive from PTY noise
2362
2449
  if (this.generatingDebounceTimer) clearTimeout(this.generatingDebounceTimer);
@@ -2381,6 +2468,11 @@ export class CliProviderInstance implements ProviderInstance {
2381
2468
  this.completedDebouncePending = null;
2382
2469
 
2383
2470
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
2471
+ // FALSE-IDLE continuity: waiting_approval is a busy phase (the agent
2472
+ // resumes into it), so bump the epoch too — the completedDebouncePending
2473
+ // cancel above covers the currently-armed pending, and the epoch covers
2474
+ // a pending that re-arms and flushes across this same valley.
2475
+ this.busyEpoch++;
2384
2476
  const modal = adapterStatus.activeModal;
2385
2477
  LOG.info('CLI', `[${this.type}] approval modal: "${modal?.message?.slice(0, 80) ?? 'none'}"`);
2386
2478
  // Include the FSM's approval entry seq, mirroring the auto-approve
@@ -2533,19 +2625,40 @@ export class CliProviderInstance implements ProviderInstance {
2533
2625
  const turnStartedAt = engineTurnStart || this.generatingStartedAt || 0;
2534
2626
  return turnStartedAt ? { turnStartedAt } : {};
2535
2627
  })()),
2628
+ // FALSE-IDLE continuity: snapshot the busy epoch + raw PTY output
2629
+ // clock at arm time so the flush guard can prove the session stayed
2630
+ // continuously idle (no busy re-entry, no new PTY output) through the
2631
+ // settle window rather than merely reading 'idle' once at flush.
2632
+ busyEpochAtArm: this.busyEpoch,
2633
+ ...(typeof adapterStatus?.lastOutputAt === 'number' && Number.isFinite(adapterStatus.lastOutputAt)
2634
+ ? { lastOutputAtArm: adapterStatus.lastOutputAt as number }
2635
+ : {}),
2536
2636
  };
2537
2637
  const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
2538
2638
  // (FALSEIDLE-BGCHILD-a) Native-history providers flush immediately (the
2539
- // transcript is authoritative). For mesh worker sessions, give the
2540
- // generating→idle transition a short settle window so a background-child
2639
+ // transcript is authoritative). For autonomously-progressing mesh sessions,
2640
+ // give the generating→idle transition a short settle window so a background-child
2541
2641
  // false idle (quiet after a backgrounded test/command while the parent turn
2542
- // continues) gets caught by the resume guard in flushCompletedDebounceIfFinalized
2543
- // instead of firing an early completion the coordinator can never correct.
2544
- const meshWorkerSession = this.isMeshWorkerSession();
2642
+ // continues) or an inter-approval auto-approve valley gets caught by the resume
2643
+ // guard in flushCompletedDebounceIfFinalized instead of firing an early completion
2644
+ // the coordinator can never correct.
2645
+ //
2646
+ // (FALSE-IDLE self-coordinator settle) The settle window now covers BOTH mesh
2647
+ // worker sessions AND the coordinator's own claude-cli session (meshCoordinatorFor):
2648
+ // isAutonomousMeshSession(). Previously only isMeshWorkerSession() qualified, so a
2649
+ // self-coordinating daemon (worker + coordinator on the same daemon) ran the
2650
+ // coordinator's own turn at flushDelay=0 — no settle window at all — and the
2651
+ // busyEpoch/lastOutputAt continuity guard, being a flush-time point-check, had no
2652
+ // window in which to observe the ~0.5s auto-approve valley. Its mid-turn
2653
+ // "next-step" sentence was flushed as finalSummary. A genuinely non-mesh session
2654
+ // (neither worker nor self-coordinator) still flushes immediately (delay=0), so
2655
+ // no non-mesh behaviour changes; this only ADDS a settle window (strictly
2656
+ // stricter — the guard can only ever CANCEL a pending flush, never emit more).
2657
+ const meshSettleSession = this.isAutonomousMeshSession();
2545
2658
  const flushDelay = ownsExternalHistory
2546
- ? (meshWorkerSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
2659
+ ? (meshSettleSession ? NATIVE_HISTORY_MESH_IDLE_SETTLE_MS : 0)
2547
2660
  : 3000;
2548
- LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} meshWorker=${meshWorkerSession} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
2661
+ LOG.debug('CLI', `[${this.type}] set completedDebouncePending duration=${duration}s ownsExternalHistory=${ownsExternalHistory} meshSettle=${meshSettleSession} flushDelay=${flushDelay}ms generatingStartedAt=${this.generatingStartedAt}`);
2549
2662
  this.scheduleCompletedDebounceFlush(flushDelay);
2550
2663
  }
2551
2664
  } else if (newStatus === 'idle' && this.lastStatus === 'starting') {