@adhdev/daemon-core 0.9.82-rc.408 → 0.9.82-rc.409

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.
@@ -1,6 +1,24 @@
1
1
  import type { ChatMessage } from '../types.js';
2
2
  export declare const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 16000;
3
3
  export declare function extractFinalSummaryFromMessages(messages: ChatMessage[] | null | undefined, maxChars?: number): string;
4
+ /**
5
+ * Turn-scoped variant of extractFinalSummaryFromMessages. Selects the last
6
+ * user-facing assistant/model bubble whose own timestamp is at/after the
7
+ * producing turn's start (`minTimestampMs`). This is the NOTIF Defect-B fix:
8
+ * a completion event's finalSummary must describe the turn THAT completed, not
9
+ * the prior task's last bubble. For native-source providers (claude-cli) the
10
+ * external transcript holds the ENTIRE session history filtered only by session
11
+ * start, so a completion debounce that fires before the producing turn's final
12
+ * assistant bubble has landed would otherwise echo the previous task's tail.
13
+ * A message whose timestamp predates the turn start is skipped; if no in-turn
14
+ * assistant bubble exists yet, returns '' (weak/empty) — never the stale tail.
15
+ *
16
+ * Mirrors the reconcile path's transcriptAfterDispatch guard (mesh-events-stale):
17
+ * a bubble only counts if its timestamp proves it was produced after the turn began.
18
+ * When `minTimestampMs` is undefined the behaviour is identical to the unscoped
19
+ * extractor (no turn boundary known → no filtering).
20
+ */
21
+ export declare function extractFinalSummaryFromMessagesAfter(messages: ChatMessage[] | null | undefined, minTimestampMs: number | undefined, maxChars?: number): string;
4
22
  /**
5
23
  * Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
6
24
  * selected final assistant/model message. Completion reconciliation needs the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.408",
3
+ "version": "0.9.82-rc.409",
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",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.408",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.409",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -752,6 +752,17 @@ export async function handleResolveAction(h: CommandHelpers, args: any): Promise
752
752
  : status?.status;
753
753
  LOG.info('Command', `[resolveAction] CLI PTY gate target=${String(args?.targetSessionId || '')} rawStatus=${String(status?.status || '')} effectiveStatus=${String(effectiveStatus || '')} statusModal=${statusModal ? 'yes' : 'no'} surfacedModal=${surfacedModal ? 'yes' : 'no'} parsedModal=${parsedModal ? 'yes' : 'no'} instance=${targetInstance ? 'yes' : 'no'}`);
754
754
  if (!effectiveModal) {
755
+ // APPROVAL Defect-B (live re-probe race): the modal is gone because the worker
756
+ // already resolved this very approval moments ago (delegated auto-approve fired,
757
+ // or a prior resolveAction landed) and the coordinator's approve raced in just
758
+ // after. That is NOT a caller error — return a SOFT already_resolved result so the
759
+ // coordinator does not hard-fail the task on a benign race. Mirrors the in-modal
760
+ // idempotency guard below (isApprovalRecentlyResolved → stalePrompt). Only a session
761
+ // that never had a recently-resolved approval reports the hard 'Not in approval state'.
762
+ if (typeof adapter.isApprovalRecentlyResolved === 'function' && adapter.isApprovalRecentlyResolved()) {
763
+ LOG.info('Command', `[resolveAction] CLI PTY → already_resolved (modal gone, resolved within cooldown)`);
764
+ return { success: true, alreadyResolved: true, status: 'already_resolved' };
765
+ }
755
766
  return { success: false, error: 'Not in approval state' };
756
767
  }
757
768
  const buttons: string[] = Array.isArray(effectiveModal.buttons) ? effectiveModal.buttons : [];
@@ -27,23 +27,71 @@ export function extractFinalSummaryFromMessages(
27
27
  return '';
28
28
  }
29
29
 
30
- function readChatMessageTimestampIso(message: ChatMessage | null | undefined): string | undefined {
30
+ function readChatMessageTimestampMs(message: ChatMessage | null | undefined): number | undefined {
31
31
  if (!message) return undefined;
32
32
  const record = message as ChatMessage & Record<string, unknown>;
33
- for (const value of [record.timestamp, record.createdAt, record.created_at, record.updatedAt, record.time]) {
33
+ for (const value of [record.timestamp, record.createdAt, record.created_at, record.updatedAt, record.time, record.receivedAt]) {
34
34
  if (typeof value === 'number' && Number.isFinite(value)) {
35
35
  // Heuristic seconds-vs-ms detection mirrors the mesh transcript reader.
36
- const ms = value > 10_000_000_000 ? value : value * 1000;
37
- return new Date(ms).toISOString();
36
+ return value > 10_000_000_000 ? value : value * 1000;
38
37
  }
39
38
  if (typeof value === 'string' && value.trim()) {
40
39
  const ms = new Date(value.trim()).getTime();
41
- if (Number.isFinite(ms)) return new Date(ms).toISOString();
40
+ if (Number.isFinite(ms)) return ms;
42
41
  }
43
42
  }
44
43
  return undefined;
45
44
  }
46
45
 
46
+ function readChatMessageTimestampIso(message: ChatMessage | null | undefined): string | undefined {
47
+ const ms = readChatMessageTimestampMs(message);
48
+ return typeof ms === 'number' ? new Date(ms).toISOString() : undefined;
49
+ }
50
+
51
+ /**
52
+ * Turn-scoped variant of extractFinalSummaryFromMessages. Selects the last
53
+ * user-facing assistant/model bubble whose own timestamp is at/after the
54
+ * producing turn's start (`minTimestampMs`). This is the NOTIF Defect-B fix:
55
+ * a completion event's finalSummary must describe the turn THAT completed, not
56
+ * the prior task's last bubble. For native-source providers (claude-cli) the
57
+ * external transcript holds the ENTIRE session history filtered only by session
58
+ * start, so a completion debounce that fires before the producing turn's final
59
+ * assistant bubble has landed would otherwise echo the previous task's tail.
60
+ * A message whose timestamp predates the turn start is skipped; if no in-turn
61
+ * assistant bubble exists yet, returns '' (weak/empty) — never the stale tail.
62
+ *
63
+ * Mirrors the reconcile path's transcriptAfterDispatch guard (mesh-events-stale):
64
+ * a bubble only counts if its timestamp proves it was produced after the turn began.
65
+ * When `minTimestampMs` is undefined the behaviour is identical to the unscoped
66
+ * extractor (no turn boundary known → no filtering).
67
+ */
68
+ export function extractFinalSummaryFromMessagesAfter(
69
+ messages: ChatMessage[] | null | undefined,
70
+ minTimestampMs: number | undefined,
71
+ maxChars: number = DEFAULT_FINAL_SUMMARY_MAX_CHARS,
72
+ ): string {
73
+ if (!Array.isArray(messages) || messages.length === 0) return '';
74
+ const hasBoundary = typeof minTimestampMs === 'number' && Number.isFinite(minTimestampMs);
75
+
76
+ for (let i = messages.length - 1; i >= 0; i--) {
77
+ const msg = messages[i];
78
+ if (!msg) continue;
79
+ if (hasBoundary) {
80
+ // A bubble carrying a timestamp BEFORE the producing turn's start belongs to
81
+ // a prior task — skip it. A bubble with no parseable timestamp cannot be
82
+ // proven stale, so it is kept (the unscoped fallback) rather than dropped.
83
+ const ts = readChatMessageTimestampMs(msg);
84
+ if (typeof ts === 'number' && ts < (minTimestampMs as number)) continue;
85
+ }
86
+ const classification = classifyChatMessageVisibility(msg);
87
+ if (classification.isUserFacing && (msg.role === 'assistant' || msg.role === 'model')) {
88
+ const text = flattenContent(msg.content).trim();
89
+ if (text) return text.slice(0, maxChars);
90
+ }
91
+ }
92
+ return '';
93
+ }
94
+
47
95
  /**
48
96
  * Like extractFinalSummaryFromMessages but also returns the ISO timestamp of the
49
97
  * selected final assistant/model message. Completion reconciliation needs the
@@ -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 } from './chat-message-normalization.js';
32
+ import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages, extractFinalSummaryFromMessagesAfter } from './chat-message-normalization.js';
33
33
  import { workingDirBasename } from './working-dir.js';
34
34
  import { ManualAttendanceTracker } from './manual-attendance.js';
35
35
 
@@ -68,6 +68,13 @@ type CompletedDebouncePending = {
68
68
  // already have started its own turn and overwritten engine.currentTurnTaskId — so the
69
69
  // id must be snapshotted here, not re-read at flush time.
70
70
  taskId?: string;
71
+ // NOTIF Defect-B: the wall-clock start of the turn that produced this (debounced)
72
+ // completion, snapshotted SYNCHRONOUSLY at the generating→idle transition (same
73
+ // reason as taskId — a follow-up turn moves engine.currentTurnStartedAt). The
74
+ // completion's finalSummary is turn-scoped to bubbles at/after this instant so a
75
+ // debounce that flushes before the producing turn's final assistant bubble lands
76
+ // in the native transcript never echoes the PRIOR task's last bubble.
77
+ turnStartedAt?: number;
71
78
  };
72
79
 
73
80
  function isIdleStatus(value: unknown): boolean {
@@ -1404,7 +1411,7 @@ export class CliProviderInstance implements ProviderInstance {
1404
1411
  };
1405
1412
  }
1406
1413
 
1407
- private completionFinalSummary(parsedMessages: unknown): string | undefined {
1414
+ private completionFinalSummary(parsedMessages: unknown, turnStartedAt?: number): string | undefined {
1408
1415
  // For native-source providers (claude-cli: chatMessagesOwnedExternally), the PTY
1409
1416
  // screen parse is NOT the source of truth for the final summary — the terminal
1410
1417
  // wraps/scrolls/clips text, so a screen-parsed assistant message is often a partial
@@ -1413,6 +1420,14 @@ export class CliProviderInstance implements ProviderInstance {
1413
1420
  // to the parsed screen only when the transcript is unavailable. This is the real cause
1414
1421
  // of the truncated finalSummary — independent of cloud vs standalone (it surfaces on
1415
1422
  // any short, fast-completing task where screen parse wins the race).
1423
+ //
1424
+ // NOTIF Defect-B: `turnStartedAt` (the producing turn's start, snapshotted on the
1425
+ // completedDebouncePending record) turn-scopes the NATIVE transcript read. The native
1426
+ // transcript holds the WHOLE session filtered only by session start, so a debounce that
1427
+ // flushes before the producing turn's final assistant bubble has landed would otherwise
1428
+ // return the PRIOR task's last bubble (event taskId=B but summary=A). Filtering to bubbles
1429
+ // at/after turnStartedAt yields '' in that race instead of the stale tail; the weak/empty
1430
+ // summary is later upgraded by the mesh reconcile loop once the real bubble is written.
1416
1431
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1417
1432
  const parsedSummary = extractFinalSummaryFromMessages(
1418
1433
  (this.completionHasFinalAssistantMessage(parsedMessages)
@@ -1421,11 +1436,14 @@ export class CliProviderInstance implements ProviderInstance {
1421
1436
  );
1422
1437
  if (adapterOwnsMessagesElsewhere) {
1423
1438
  const externalMessages = this.readExternalCompletionMessages();
1439
+ // Turn-scope the external transcript: never return a bubble produced before this
1440
+ // turn started. With no boundary known (turnStartedAt falsy) behaviour is unchanged.
1424
1441
  const externalSummary = externalMessages
1425
- ? extractFinalSummaryFromMessages(externalMessages as any)
1442
+ ? extractFinalSummaryFromMessagesAfter(externalMessages as any, turnStartedAt)
1426
1443
  : '';
1427
1444
  // The transcript is authoritative for native-source providers. Use it unless it is
1428
- // empty (not yet written) — only then fall back to whatever the screen parsed.
1445
+ // empty (not yet written, or no in-turn bubble) — only then fall back to the screen
1446
+ // parse, which reflects the LIVE screen (this turn's output), not the stale tail.
1429
1447
  if (externalSummary) return externalSummary;
1430
1448
  return parsedSummary || undefined;
1431
1449
  }
@@ -1804,8 +1822,8 @@ export class CliProviderInstance implements ProviderInstance {
1804
1822
  // stuck on the dispatched user task. If the parser DID surface assistant text,
1805
1823
  // prefer it; only fall back to '' when no assistant summary can be derived.
1806
1824
  finalSummary: blockReason.startsWith('parsed_status:')
1807
- ? (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages) ?? '')
1808
- : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1825
+ ? (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) ?? '')
1826
+ : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
1809
1827
  completionDiagnostic,
1810
1828
  });
1811
1829
  this.completedDebouncePending = null;
@@ -1827,7 +1845,7 @@ export class CliProviderInstance implements ProviderInstance {
1827
1845
  timestamp: pending.timestamp,
1828
1846
  // ARCH-REFACTOR R1: attribute to the turn captured at idle-transition.
1829
1847
  ...(pending.taskId ? { taskId: pending.taskId } : {}),
1830
- finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages),
1848
+ finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
1831
1849
  });
1832
1850
  this.completedDebouncePending = null;
1833
1851
  this.completedDebounceTimer = null;
@@ -2037,7 +2055,18 @@ export class CliProviderInstance implements ProviderInstance {
2037
2055
  */
2038
2056
  private recheckAutoApproveSettled(): void {
2039
2057
  try {
2040
- const adapterStatus = this.adapter.getStatus({ allowParse: false });
2058
+ // APPROVAL Defect-C (auto-approve gap): re-probe with a LIVE parse, not the cached
2059
+ // engine snapshot. This timer is the ONLY re-drive when the PTY goes silent after the
2060
+ // approval prompt finishes painting (its whole reason to exist) — but with
2061
+ // allowParse:false it read only engine.activeModal, which the engine's per-frame settle
2062
+ // pass can leave null/stale when the modal arrived between writes. The re-check then saw
2063
+ // no modal and never fired, so the delegated worker's transient/quiet approval was missed
2064
+ // and the coordinator had to step in with a manual mesh_approve. allowParse:true makes
2065
+ // getStatus re-run runDetectStatus/runParseApproval on the current screen buffer (the same
2066
+ // live re-probe the coordinator's resolveAction path uses), recovering the modal so
2067
+ // auto-approve fires on its own. The half-rendered-frame guard (buttons.length===0) and
2068
+ // the settle gate in maybeAutoApproveStatus still protect against firing on a partial modal.
2069
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
2041
2070
  this.maybeAutoApproveStatus(adapterStatus, Date.now());
2042
2071
  } catch { /* adapter gone / transient — next frame retries */ }
2043
2072
  }
@@ -2380,6 +2409,18 @@ export class CliProviderInstance implements ProviderInstance {
2380
2409
  // before any follow-up task's flush can start a new turn and move
2381
2410
  // engine.currentTurnTaskId.
2382
2411
  ...(this.completingTurnTaskId() ? { taskId: this.completingTurnTaskId() } : {}),
2412
+ // NOTIF Defect-B: snapshot the producing turn's START instant NOW, for the
2413
+ // same reason as taskId — a follow-up turn moves engine.currentTurnStartedAt.
2414
+ // Prefer the engine's per-turn start (set at onTurnStarted, earliest reliable
2415
+ // anchor) and fall back to generatingStartedAt (when generating was observed).
2416
+ ...((() => {
2417
+ const engineTurnStart = typeof (this.adapter as any)?.currentTurnStartedAt === 'number'
2418
+ && Number.isFinite((this.adapter as any).currentTurnStartedAt)
2419
+ ? (this.adapter as any).currentTurnStartedAt as number
2420
+ : 0;
2421
+ const turnStartedAt = engineTurnStart || this.generatingStartedAt || 0;
2422
+ return turnStartedAt ? { turnStartedAt } : {};
2423
+ })()),
2383
2424
  };
2384
2425
  const ownsExternalHistory = !!(this.adapter as any)?.chatMessagesOwnedExternally;
2385
2426
  // (FALSEIDLE-BGCHILD-a) Native-history providers flush immediately (the