@adhdev/daemon-core 0.9.82-rc.441 → 0.9.82-rc.443

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
@@ -1411,7 +1430,7 @@ export class CliProviderInstance implements ProviderInstance {
1411
1430
  this.applyProviderResponse(parsed.payload, { phase: 'immediate' });
1412
1431
  }
1413
1432
 
1414
- private completionHasFinalAssistantMessage(messages: unknown): boolean {
1433
+ private completionHasFinalAssistantMessage(messages: unknown, turnStartedAt?: number): boolean {
1415
1434
  const visibleMessages = (Array.isArray(messages) ? messages : [])
1416
1435
  .filter((message: any) => isUserFacingChatMessage(message as ChatMessage));
1417
1436
  const lastVisible = visibleMessages[visibleMessages.length - 1] as ChatMessage | undefined;
@@ -1421,6 +1440,21 @@ export class CliProviderInstance implements ProviderInstance {
1421
1440
  // Guard: if the last assistant message looks like an active approval/input prompt,
1422
1441
  // it is not a real completion — the session is still awaiting user input.
1423
1442
  if (looksLikeActiveApprovalPromptText(content)) return false;
1443
+ // FALSE-IDLE turn-boundary evidence (Defect 1b): when a producing-turn start is
1444
+ // known, the final assistant bubble must POST-DATE it. A STALE mid-turn assistant
1445
+ // (predating this turn's start — e.g. the last bubble of a prior sub-turn observed
1446
+ // during an inter-approval valley) must NOT satisfy the finalization gate, or a
1447
+ // false-idle blip emits a completion carrying that stale summary. A bubble with no
1448
+ // parseable timestamp cannot be proven stale, so it is kept (fails open — behaviour
1449
+ // identical to before for providers/paths that carry no timestamps).
1450
+ if (typeof turnStartedAt === 'number' && Number.isFinite(turnStartedAt) && turnStartedAt > 0) {
1451
+ // readChatMessageTimestampMs mirrors the summary turn-scoping reader
1452
+ // (extractFinalSummaryFromMessagesAfter) — same seconds-vs-ms heuristic and
1453
+ // field precedence — so the present-check and the summary-scope agree on which
1454
+ // bubbles predate the turn.
1455
+ const ts = readChatMessageTimestampMs(lastVisible);
1456
+ if (typeof ts === 'number' && ts < turnStartedAt) return false;
1457
+ }
1424
1458
  return true;
1425
1459
  }
1426
1460
 
@@ -1485,8 +1519,8 @@ export class CliProviderInstance implements ProviderInstance {
1485
1519
  return restoredHistory.messages;
1486
1520
  }
1487
1521
 
1488
- private completionFinalAssistantEvidence(parsedMessages: unknown): CompletionFinalAssistantEvidence {
1489
- if (this.completionHasFinalAssistantMessage(parsedMessages)) {
1522
+ private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1523
+ if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
1490
1524
  return {
1491
1525
  present: true,
1492
1526
  messages: Array.isArray(parsedMessages) ? parsedMessages : [],
@@ -1497,7 +1531,7 @@ export class CliProviderInstance implements ProviderInstance {
1497
1531
  const externalMessages = this.readExternalCompletionMessages();
1498
1532
  if (externalMessages) {
1499
1533
  return {
1500
- present: this.completionHasFinalAssistantMessage(externalMessages),
1534
+ present: this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1501
1535
  messages: externalMessages,
1502
1536
  source: 'external-native',
1503
1537
  };
@@ -1528,10 +1562,17 @@ export class CliProviderInstance implements ProviderInstance {
1528
1562
  // at/after turnStartedAt yields '' in that race instead of the stale tail; the weak/empty
1529
1563
  // summary is later upgraded by the mesh reconcile loop once the real bubble is written.
1530
1564
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1531
- const parsedSummary = extractFinalSummaryFromMessages(
1532
- (this.completionHasFinalAssistantMessage(parsedMessages)
1565
+ // FALSE-IDLE Defect 1b: turn-scope the PARSED screen fallback too. Without this a stale
1566
+ // mid-turn assistant (predating turnStartedAt) that the turn-boundary gate already
1567
+ // rejected as evidence could still leak into the finalSummary via this parsed fallback
1568
+ // when the external transcript's turn-scoped read is empty — freezing the very stale text
1569
+ // the gate rejected. extractFinalSummaryFromMessagesAfter drops bubbles before the turn
1570
+ // start; with no boundary known (turnStartedAt falsy) it is identical to the unscoped read.
1571
+ const parsedSummary = extractFinalSummaryFromMessagesAfter(
1572
+ (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)
1533
1573
  ? (Array.isArray(parsedMessages) ? parsedMessages : [])
1534
1574
  : []) as any,
1575
+ turnStartedAt,
1535
1576
  );
1536
1577
  if (adapterOwnsMessagesElsewhere) {
1537
1578
  const externalMessages = this.readExternalCompletionMessages();
@@ -1669,7 +1710,11 @@ export class CliProviderInstance implements ProviderInstance {
1669
1710
  }
1670
1711
  if (parsed?.activeModal || parsed?.modal) return { reason: 'parsed_modal_active', terminal: true };
1671
1712
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1672
- const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages);
1713
+ // FALSE-IDLE turn-boundary evidence (Defect 1b): turn-scope the present-check so a
1714
+ // STALE mid-turn assistant (predating pending.turnStartedAt) cannot satisfy the
1715
+ // finalization gate. Only the confirming final-assistant bubble that POST-DATES this
1716
+ // turn's start counts as evidence the turn genuinely ended.
1717
+ const finalAssistantEvidence = this.completionFinalAssistantEvidence(parsed?.messages, pending.turnStartedAt);
1673
1718
  const allowMissingAssistantTimeout = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1674
1719
  LOG.debug('CLI', `[${this.type}] finalAssistantEvidence: present=${finalAssistantEvidence.present} source=${finalAssistantEvidence.source} adapterOwnsMessagesElsewhere=${adapterOwnsMessagesElsewhere} parsedStatus=${parsedStatus}`);
1675
1720
  if (!finalAssistantEvidence.present) {
@@ -1844,6 +1889,32 @@ export class CliProviderInstance implements ProviderInstance {
1844
1889
  return;
1845
1890
  }
1846
1891
 
1892
+ // FALSE-IDLE continuity guard (Defect 1a): the point-sample above only proves the
1893
+ // session is idle at THIS instant. A momentary busy→idle blip inside an inter-approval
1894
+ // valley (auto-approved tool turns) opens AND closes a generating phase entirely within
1895
+ // the settle window — so the single sample reads 'idle' even though the turn is still in
1896
+ // flight (it re-enters generating ~0.5s later). Require instead that the session stayed
1897
+ // CONTINUOUSLY idle since the debounce was armed: (1) no entry into a busy phase
1898
+ // (busyEpoch unchanged), and (2) no new raw PTY output (lastOutputAt did not advance).
1899
+ // Either signal ⇒ the idle was not continuous ⇒ cancel; the still-live turn re-arms its
1900
+ // own completion when it genuinely finishes. This only ever cancels (never emits more),
1901
+ // so shared behaviour for claude/codex/antigravity is strictly stricter, never looser.
1902
+ if (typeof pending.busyEpochAtArm === 'number' && this.busyEpoch !== pending.busyEpochAtArm) {
1903
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (busy re-entry during settle: epoch ${pending.busyEpochAtArm}→${this.busyEpoch})`);
1904
+ this.completedDebouncePending = null;
1905
+ this.completedDebounceTimer = null;
1906
+ return;
1907
+ }
1908
+ const latestOutputAt = typeof (latestStatus as any)?.lastOutputAt === 'number' ? (latestStatus as any).lastOutputAt as number : undefined;
1909
+ if (typeof pending.lastOutputAtArm === 'number'
1910
+ && typeof latestOutputAt === 'number'
1911
+ && latestOutputAt > pending.lastOutputAtArm) {
1912
+ LOG.info('CLI', `[${this.type}] cancelled pending completed (new PTY output during settle: ${pending.lastOutputAtArm}→${latestOutputAt})`);
1913
+ this.completedDebouncePending = null;
1914
+ this.completedDebounceTimer = null;
1915
+ return;
1916
+ }
1917
+
1847
1918
  const block = this.getCompletedFinalizationBlock(latestVisibleStatus, pending);
1848
1919
  if (block) {
1849
1920
  const blockReason = block.reason;
@@ -2357,6 +2428,9 @@ export class CliProviderInstance implements ProviderInstance {
2357
2428
  }
2358
2429
 
2359
2430
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
2431
+ // FALSE-IDLE continuity: entering a busy phase invalidates any
2432
+ // completedDebouncePending armed earlier in this settle window.
2433
+ this.busyEpoch++;
2360
2434
  // Defer the generating_started event — if idle comes back within 3s,
2361
2435
  // the whole started→completed pair was a false positive from PTY noise
2362
2436
  if (this.generatingDebounceTimer) clearTimeout(this.generatingDebounceTimer);
@@ -2381,6 +2455,11 @@ export class CliProviderInstance implements ProviderInstance {
2381
2455
  this.completedDebouncePending = null;
2382
2456
 
2383
2457
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
2458
+ // FALSE-IDLE continuity: waiting_approval is a busy phase (the agent
2459
+ // resumes into it), so bump the epoch too — the completedDebouncePending
2460
+ // cancel above covers the currently-armed pending, and the epoch covers
2461
+ // a pending that re-arms and flushes across this same valley.
2462
+ this.busyEpoch++;
2384
2463
  const modal = adapterStatus.activeModal;
2385
2464
  LOG.info('CLI', `[${this.type}] approval modal: "${modal?.message?.slice(0, 80) ?? 'none'}"`);
2386
2465
  // Include the FSM's approval entry seq, mirroring the auto-approve
@@ -2533,6 +2612,14 @@ export class CliProviderInstance implements ProviderInstance {
2533
2612
  const turnStartedAt = engineTurnStart || this.generatingStartedAt || 0;
2534
2613
  return turnStartedAt ? { turnStartedAt } : {};
2535
2614
  })()),
2615
+ // FALSE-IDLE continuity: snapshot the busy epoch + raw PTY output
2616
+ // clock at arm time so the flush guard can prove the session stayed
2617
+ // continuously idle (no busy re-entry, no new PTY output) through the
2618
+ // settle window rather than merely reading 'idle' once at flush.
2619
+ busyEpochAtArm: this.busyEpoch,
2620
+ ...(typeof adapterStatus?.lastOutputAt === 'number' && Number.isFinite(adapterStatus.lastOutputAt)
2621
+ ? { lastOutputAtArm: adapterStatus.lastOutputAt as number }
2622
+ : {}),
2536
2623
  };
2537
2624
  const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
2538
2625
  // (FALSEIDLE-BGCHILD-a) Native-history providers flush immediately (the
@@ -264,22 +264,30 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
264
264
 
265
265
  try {
266
266
  const requested = input.providerSessionId || '';
267
- let sessionId: string;
268
- if (requested) {
269
- // Session pin: the caller already bound this instance to a
270
- // specific provider session, so read THAT session directly and
271
- // skip the newest-wins `session_query` entirely. hermes ≥0.14
272
- // spawns a fresh `sessions` row per internal sub-session, so the
273
- // `ORDER BY started_at DESC LIMIT 1` pick drifts to a different
274
- // id on every read. Left unpinned that churns the bound session
275
- // (each re-bind re-hydrates unbounded history daemon
276
- // saturation) and reads completion evidence from the wrong
277
- // session (turn never finalizes). Binding straight to the
278
- // requested id fixes both. Existence is validated below by the
279
- // spec's own `message_query` returning rows for this id, so we
280
- // don't hardcode any schema here.
281
- sessionId = requested;
282
- } else {
267
+ // Resolve the session id the message query runs against. The `requested`
268
+ // pin path is tried first, but a pinned id that has NO rows in the store
269
+ // is not a real session fall back to the newest-session `session_query`
270
+ // instead of returning empty. This is the hermes read_chat gap: hermes
271
+ // never surfaces its own provider session id to the daemon (the spec
272
+ // declares no session-id extraction and the adapter's screen-scrape is
273
+ // codex-only), so the read pipeline falls back to threading the mesh
274
+ // RUNTIME session id through as `providerSessionId`. That runtime id does
275
+ // not exist in ~/.hermes/state.db, so the old unconditional pin path ran
276
+ // `message_query WHERE session_id = '<runtime id>'` 0 rows → null, and
277
+ // the answer (physically present under the real cli session) was never
278
+ // returned. Validating the pin by the spec's own `message_query` keeps
279
+ // this schema-agnostic and only rescues the mis-bound-id case: a genuine
280
+ // discovered pin (codex/claude use jsonl sources and never reach here;
281
+ // any real sqlite pin has rows) still short-circuits on its own rows.
282
+ const resolveMessagesFor = (sessionId: string): any[] | null => {
283
+ if (!sessionId) return null;
284
+ let rows: any[];
285
+ try { rows = db.prepare(src.message_query).all(sessionId); }
286
+ catch { return null; }
287
+ return rows && rows.length > 0 ? rows : null;
288
+ };
289
+
290
+ const resolveNewestSessionId = (): string => {
283
291
  let sessionRow: any;
284
292
  try {
285
293
  // session_query may reference `?` to receive the session's
@@ -299,15 +307,38 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
299
307
  } catch {
300
308
  sessionRow = stmt.get();
301
309
  }
302
- } catch { return null; }
303
- if (!sessionRow) return null;
310
+ } catch { return ''; }
311
+ if (!sessionRow) return '';
304
312
  // First column of the first row is the session id.
305
313
  const sessionIdRaw = Object.values(sessionRow)[0];
306
- sessionId = sessionIdRaw == null ? '' : String(sessionIdRaw);
314
+ return sessionIdRaw == null ? '' : String(sessionIdRaw);
315
+ };
316
+
317
+ let sessionId: string;
318
+ let messageRows: any[] | null;
319
+ if (requested) {
320
+ // Pin path: read the requested session directly and skip the
321
+ // newest-wins `session_query`. hermes ≥0.14 spawns a fresh
322
+ // `sessions` row per internal sub-session, so an unpinned
323
+ // `ORDER BY started_at DESC LIMIT 1` pick drifts to a different id
324
+ // on every read (re-bind churn + reading completion evidence from
325
+ // the wrong session). A pin that resolves rows is authoritative.
326
+ messageRows = resolveMessagesFor(requested);
327
+ if (messageRows) {
328
+ sessionId = requested;
329
+ } else {
330
+ // The pinned id has no rows — it is not a real session in this
331
+ // store (the mis-bound mesh runtime-id case). Recover by letting
332
+ // the spec's own newest-session query self-resolve instead of
333
+ // returning empty.
334
+ sessionId = resolveNewestSessionId();
335
+ messageRows = resolveMessagesFor(sessionId);
336
+ }
337
+ } else {
338
+ sessionId = resolveNewestSessionId();
339
+ messageRows = resolveMessagesFor(sessionId);
307
340
  }
308
341
  if (!sessionId) return null;
309
-
310
- const messageRows: any[] = db.prepare(src.message_query).all(sessionId);
311
342
  if (!messageRows || messageRows.length === 0) return null;
312
343
 
313
344
  const mtime = safeMtimeMs(resolved);