@adhdev/daemon-core 0.9.82-rc.461 → 0.9.82-rc.463

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.
@@ -27,7 +27,7 @@ import { shouldCollectTraceCategory } from '../logging/debug-config.js';
27
27
  import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
28
28
  import type { ChatMessage } from '../types.js';
29
29
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
30
- import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText } from './approval-utils.js';
30
+ import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText, normalizeApprovalLabel } from './approval-utils.js';
31
31
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
32
32
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
33
33
  import { normalizeProviderSessionId } from './provider-session-id.js';
@@ -1517,6 +1517,22 @@ export class CliProviderInstance implements ProviderInstance {
1517
1517
  return probe;
1518
1518
  }
1519
1519
 
1520
+ /**
1521
+ * The spawned CLI's env overrides (e.g. the mesh coordinator points hermes
1522
+ * at a per-coordinator HERMES_HOME so its state.db lives in a tmpdir instead
1523
+ * of ~/.hermes). The native-history executor expands `${HERMES_HOME:-~/.hermes}`
1524
+ * from this map, so the completion gate MUST pass it through — otherwise the
1525
+ * gate reads ~/.hermes, finds no coordinator-session transcript, and
1526
+ * false-fires missing_final_assistant on every coordinator turn.
1527
+ */
1528
+ private spawnedEnvOverrides(): Record<string, string> | undefined {
1529
+ const meta = typeof (this.adapter as any)?.getRuntimeMetadata === 'function'
1530
+ ? (this.adapter as any).getRuntimeMetadata()
1531
+ : undefined;
1532
+ const env = meta && typeof meta === 'object' ? (meta as Record<string, unknown>).spawnedEnv : undefined;
1533
+ return env && typeof env === 'object' ? env as Record<string, string> : undefined;
1534
+ }
1535
+
1520
1536
  private readExternalCompletionMessages(): unknown[] | null {
1521
1537
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1522
1538
  if (!adapterOwnsMessagesElsewhere) return null;
@@ -1535,6 +1551,7 @@ export class CliProviderInstance implements ProviderInstance {
1535
1551
  historyBehavior: this.provider.historyBehavior,
1536
1552
  scripts: this.provider.scripts as any,
1537
1553
  sessionStartedAtMs: this.startedAt,
1554
+ envOverrides: this.spawnedEnvOverrides(),
1538
1555
  forceRefresh: true,
1539
1556
  });
1540
1557
  if (restoredHistory.source !== 'provider-native') {
@@ -2317,19 +2334,31 @@ export class CliProviderInstance implements ProviderInstance {
2317
2334
  // kind gate). Surface the modal so the user decides; never pick blindly.
2318
2335
  return autoApproveActive;
2319
2336
  }
2320
- // Modal *identity* signature — the question/button set only, NO volatile
2321
- // counters. This is what the settle gate tracks: the FSM bumps
2322
- // approvalEntrySeq on every fresh waiting_approval entry, and a
2323
- // modal→generating→modal flap (the question line scrolled out of the
2324
- // captured frame while the button block stays) re-enters and bumps it
2325
- // again. Folding that seq into the settle signature made the 600ms
2326
- // settle clock restart on every flap, so the modal never stayed stable
2327
- // long enough to fire — the gate was never satisfied. Identity excludes
2328
- // the seq so button/seq flap of the SAME modal keeps one settle clock.
2337
+ // Modal *identity* signature — the question plus the STABLE affirmative
2338
+ // anchor only, NO volatile counters and NO raw button set. This is what
2339
+ // the settle gate tracks: the FSM bumps approvalEntrySeq on every fresh
2340
+ // waiting_approval entry, and a modal→generating→modal flap (the question
2341
+ // line scrolled out of the captured frame while the button block stays)
2342
+ // re-enters and bumps it again. Folding that seq into the settle signature
2343
+ // made the 600ms settle clock restart on every flap, so the modal never
2344
+ // stayed stable long enough to fire — the gate was never satisfied.
2345
+ // Identity excludes the seq so seq flap of the SAME modal keeps one clock.
2346
+ //
2347
+ // The raw button set is also excluded: on a TALL Write/Edit diff claude's
2348
+ // TUI repaints the button block 3↔5↔none between frames (buttons scroll in
2349
+ // and out of the captured region), which flipped both buttons.join('|') and
2350
+ // the positional buttonIndex every frame → signature flap → the settle
2351
+ // clock reset 4–9s and only mask-stalled episodes leaked to the coordinator
2352
+ // (AUTOAPPROVE-SETTLE-FLAP). The affirmative the auto-approve will actually
2353
+ // press is the invariant across those repaints, so we anchor on its
2354
+ // NORMALIZED label (numbers/bullets/punctuation stripped, so "1. Yes" and
2355
+ // "3. Yes" collapse to "yes"). Message + affirmative label uniquely
2356
+ // identifies the consent question without tracking the volatile button
2357
+ // positions.
2358
+ const affirmativeAnchor = normalizeApprovalLabel(buttonLabel);
2329
2359
  const modalSignature = [
2330
2360
  typeof modal?.message === 'string' ? modal.message.trim() : '',
2331
- buttons.join('|'),
2332
- buttonIndex,
2361
+ affirmativeAnchor,
2333
2362
  ].join('::');
2334
2363
  // Busy-window re-entry guard still needs the seq: two DISTINCT
2335
2364
  // back-to-back approvals can carry identical message/buttons (common
@@ -69,7 +69,59 @@ function openDb(): any | null {
69
69
  }
70
70
  }
71
71
 
72
+ /**
73
+ * Expand an anchor session id to every session id in its logical cluster.
74
+ *
75
+ * hermes ≥0.14 splits a SINGLE logical turn across several `sessions` rows
76
+ * linked by `parent_session_id` (a 0-message intermediate row is common), and
77
+ * the turn's final assistant message lands in a DIFFERENT row than the one the
78
+ * daemon pins. A bidirectional walk — up the parent chain to the cluster root,
79
+ * then down through every descendant — returns the complete set from ANY anchor
80
+ * (root, middle, or leaf), mirroring the declarative executor's
81
+ * `session_cluster_query`. Falls back to the anchor alone if the schema has no
82
+ * `parent_session_id` column (older hermes) or the walk fails.
83
+ */
84
+ function resolveClusterSessionIds(db: any, anchorId: string): string[] {
85
+ if (!anchorId) return [];
86
+ try {
87
+ const rows: any[] = db.prepare(
88
+ `WITH RECURSIVE
89
+ up(id) AS (
90
+ SELECT id FROM sessions WHERE id = ?
91
+ UNION
92
+ SELECT s.parent_session_id FROM sessions s JOIN up ON s.id = up.id
93
+ WHERE s.parent_session_id IS NOT NULL
94
+ ),
95
+ cluster(id) AS (
96
+ SELECT id FROM up
97
+ UNION
98
+ SELECT s.id FROM sessions s JOIN cluster ON s.parent_session_id = cluster.id
99
+ )
100
+ SELECT id FROM cluster`,
101
+ ).all(anchorId);
102
+ const ids = new Set<string>([anchorId]);
103
+ for (const r of rows) {
104
+ if (r && r.id != null && String(r.id)) ids.add(String(r.id));
105
+ }
106
+ return Array.from(ids);
107
+ } catch {
108
+ // No parent_session_id column (older hermes) or a walk failure — the
109
+ // single anchor is still a valid (degenerate) cluster.
110
+ return [anchorId];
111
+ }
112
+ }
113
+
72
114
  function loadMessagesForSession(db: any, sessionId: string): NativeHistoryMessage[] {
115
+ // Read the WHOLE sub-session cluster, not just the pinned anchor. hermes
116
+ // ≥0.14 writes a turn's final assistant into a descendant sub-session row,
117
+ // so an anchor-only read misses it → read_chat shows zero assistant bubbles
118
+ // and the completion gate false-fires missing_final_assistant. Gather every
119
+ // cluster member (parent-chain walk) and merge; the SQL `ORDER BY timestamp`
120
+ // re-interleaves bubbles from different sub-sessions into true chronological
121
+ // order so the final assistant lands last.
122
+ const clusterIds = resolveClusterSessionIds(db, sessionId);
123
+ if (clusterIds.length === 0) return [];
124
+ const placeholders = clusterIds.map(() => '?').join(', ');
73
125
  // Assistant turns whose finish_reason='tool_calls' persist an EMPTY
74
126
  // `content` — their payload lives in the `tool_calls` column. Filtering on
75
127
  // `content != ''` alone drops those rows, so a turn whose terminal message
@@ -79,10 +131,10 @@ function loadMessagesForSession(db: any, sessionId: string): NativeHistoryMessag
79
131
  const rows: any[] = db.prepare(
80
132
  `SELECT id, role, COALESCE(NULLIF(content, ''), tool_calls) AS content, timestamp
81
133
  FROM messages
82
- WHERE session_id = ?
134
+ WHERE session_id IN (${placeholders})
83
135
  AND ((content IS NOT NULL AND content != '') OR (tool_calls IS NOT NULL AND tool_calls != ''))
84
136
  ORDER BY timestamp ASC, id ASC`,
85
- ).all(sessionId);
137
+ ).all(...clusterIds);
86
138
  const out: NativeHistoryMessage[] = [];
87
139
  for (const r of rows) {
88
140
  const role = normalizeHermesRole(r.role);
@@ -279,12 +279,53 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
279
279
  // this schema-agnostic and only rescues the mis-bound-id case: a genuine
280
280
  // discovered pin (codex/claude use jsonl sources and never reach here;
281
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;
282
+ // Expand an anchor session id to every session id in its logical
283
+ // cluster. When the spec declares `session_cluster_query` the anchor is
284
+ // run through it (bound `?`) and each returned row's FIRST column is a
285
+ // cluster member id typically a WITH RECURSIVE walk up to the cluster
286
+ // root and back down through all descendants, so passing a root, middle,
287
+ // or leaf anchor all resolve the same complete set. The anchor is always
288
+ // included even if the query omits it (defensive) so a spec with no
289
+ // cluster query, or a query that returns nothing, still reads the anchor
290
+ // itself. Absent query → just the anchor (single-session behaviour).
291
+ const resolveClusterIds = (anchorId: string): string[] => {
292
+ const ids = new Set<string>();
293
+ if (anchorId) ids.add(anchorId);
294
+ if (src.session_cluster_query && anchorId) {
295
+ try {
296
+ const rows: any[] = db.prepare(src.session_cluster_query).all(anchorId);
297
+ for (const row of rows) {
298
+ const idRaw = Object.values(row)[0];
299
+ if (idRaw != null && String(idRaw)) ids.add(String(idRaw));
300
+ }
301
+ } catch { /* fall back to anchor-only on a malformed cluster query */ }
302
+ }
303
+ return Array.from(ids);
304
+ };
305
+
306
+ // Read messages for an anchor's WHOLE cluster, merged and re-sorted by
307
+ // their mapped timestamp so bubbles from different sub-sessions interleave
308
+ // in true chronological order (the turn's final assistant — written into a
309
+ // descendant sub-session in the split-turn case — lands last). No per-session
310
+ // short-circuit: an anchor whose OWN row has zero messages (hermes writes a
311
+ // 0-message intermediate `sessions` row) still yields the cluster's rows,
312
+ // and the whole cluster is scanned rather than stopping at the first
313
+ // non-empty session. Returns null only when the ENTIRE cluster is empty,
314
+ // preserving the pin-validation contract below (a pin that resolves no rows
315
+ // anywhere is a mis-bound id and falls through to newest-session recovery).
316
+ const resolveMessagesFor = (anchorId: string): any[] | null => {
317
+ if (!anchorId) return null;
318
+ const clusterIds = resolveClusterIds(anchorId);
319
+ const merged: any[] = [];
320
+ for (const id of clusterIds) {
321
+ let rows: any[];
322
+ try { rows = db.prepare(src.message_query).all(id); }
323
+ catch { continue; }
324
+ if (rows && rows.length > 0) merged.push(...rows);
325
+ }
326
+ if (merged.length === 0) return null;
327
+ if (clusterIds.length > 1) sortRowsByMappedTimestamp(merged, src.message_map);
328
+ return merged;
288
329
  };
289
330
 
290
331
  const resolveNewestSessionId = (): string => {
@@ -362,6 +403,32 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
362
403
  }
363
404
  }
364
405
 
406
+ /**
407
+ * Stable-sort merged cluster rows by their mapped timestamp so bubbles read
408
+ * from different sub-sessions interleave in true chronological order. Uses the
409
+ * same `message_map.timestamp_ms` jsonpath + `parseTimestamp` heuristic the
410
+ * projection uses, so the sort key agrees with the receivedAt each row will be
411
+ * given. Rows with no resolvable timestamp keep their pre-sort relative order
412
+ * (stable), and equal timestamps preserve insertion order — both matter because
413
+ * a turn's terminal bubbles can share a sub-second timestamp.
414
+ */
415
+ function sortRowsByMappedTimestamp(rows: any[], map: NativeHistoryMessageMap): void {
416
+ if (!map.timestamp_ms) return;
417
+ const keyed = rows.map((row, index) => {
418
+ const parsed = parseTimestamp(jsonPathGet(row, map.timestamp_ms as string));
419
+ return { row, index, ts: parsed == null ? Number.NaN : parsed };
420
+ });
421
+ keyed.sort((a, b) => {
422
+ const aHas = !Number.isNaN(a.ts);
423
+ const bHas = !Number.isNaN(b.ts);
424
+ if (aHas && bHas && a.ts !== b.ts) return a.ts - b.ts;
425
+ // Missing-timestamp rows and ties fall back to original insertion order
426
+ // so the sort stays stable.
427
+ return a.index - b.index;
428
+ });
429
+ for (let i = 0; i < keyed.length; i += 1) rows[i] = keyed[i].row;
430
+ }
431
+
365
432
  // ────────────────────────────────────────────────────────────────────────────
366
433
  // Path expansion + globbing
367
434
  // ────────────────────────────────────────────────────────────────────────────
@@ -103,6 +103,28 @@ export interface NativeHistorySqliteSource {
103
103
  path: string;
104
104
  session_query: string;
105
105
  message_query: string;
106
+ /**
107
+ * Optional sub-session cluster expansion. Some agents (hermes ≥0.14) split
108
+ * a SINGLE logical turn across several `sessions` rows linked by a parent
109
+ * pointer, and the turn's final assistant message lands in a DIFFERENT row
110
+ * than the one `session_query` / the daemon's pin resolves. Reading only
111
+ * the anchor session then surfaces zero (or stale) assistant bubbles even
112
+ * though the answer is physically present in a sibling/descendant row —
113
+ * `read_chat` returns no final assistant and the completion gate false-fires
114
+ * `missing_final_assistant`.
115
+ *
116
+ * When present, the executor treats the resolved session id as an ANCHOR
117
+ * and runs this query (bound `?` = anchor id) to expand it to every session
118
+ * id in the same logical cluster (typically a `WITH RECURSIVE` walk over the
119
+ * parent pointer, up to the root and back down through all descendants).
120
+ * `message_query` is then run once per cluster id and the rows merged and
121
+ * re-sorted by their mapped timestamp, so the turn's final assistant — in
122
+ * whichever sub-session it was written — is always included. Each returned
123
+ * row's first column is a cluster session id.
124
+ *
125
+ * Absent → single-session behaviour is unchanged (anchor session only).
126
+ */
127
+ session_cluster_query?: string;
106
128
  message_map: NativeHistoryMessageMap;
107
129
  }
108
130
 
@@ -1020,6 +1020,38 @@ export interface RepoMeshStatus {
1020
1020
  * Omitted when nothing was drained. Mirrors the MCP tool's meshProtocolMetrics.
1021
1021
  */
1022
1022
  meshProtocolMetrics?: MeshProtocolMetrics;
1023
+ /**
1024
+ * T6 (B3c): live process-lifetime mesh-protocol-v2 enforce counters from THIS
1025
+ * daemon — the enforce flag state, drain-routing tallies (deliver / route-away /
1026
+ * dedup / quarantine), and the last-resort backstop fire counts (PHASE-4 synth,
1027
+ * acked-hold fast-track / death-deadline). Diagnostic-only and never cached (a
1028
+ * live snapshot). Under enforce, non-zero quarantine or backstop counts are the
1029
+ * rollout-health signal (target 0). Omitted when unavailable.
1030
+ */
1031
+ meshProtocolV2Counters?: MeshProtocolV2Counters;
1032
+ }
1033
+
1034
+ /** T6 (B3c) live v2 enforce/observability counters (see RepoMeshStatus.meshProtocolV2Counters). */
1035
+ export interface MeshProtocolV2Counters {
1036
+ /** True when MESH_PROTOCOL_V2_ENFORCE is active on this daemon. */
1037
+ enforce: boolean;
1038
+ /** Drain-path routing tallies (accept + enforce). Process-lifetime totals. */
1039
+ drain: {
1040
+ v2Delivered: number;
1041
+ v2RoutedAway: number;
1042
+ v2DedupSkipped: number;
1043
+ v2ValidationFailedAccepted: number;
1044
+ v2ReattributedToDrainer: number;
1045
+ v1BroadcastAccepted: number;
1046
+ v2ValidationFailedQuarantined: number;
1047
+ v1UnversionedQuarantined: number;
1048
+ };
1049
+ /** Last-resort backstop fire counts. Target 0 under a healthy v2 contract. */
1050
+ backstop: {
1051
+ phase4SynthesisFired: number;
1052
+ ackedHoldFastTrackFired: number;
1053
+ ackedHoldDeathDeadlineFired: number;
1054
+ };
1023
1055
  }
1024
1056
 
1025
1057
  /** One provider's version skew across mesh nodes (see RepoMeshStatus.providerVersionSkew). */