@adhdev/daemon-core 0.9.82-rc.475 → 0.9.82-rc.477

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.
@@ -11,6 +11,14 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
11
11
  } | {
12
12
  success: boolean;
13
13
  forwarded: number;
14
+ suppressed: boolean;
15
+ autoApprovingWorkerApproval: boolean;
16
+ error?: undefined;
17
+ } | {
18
+ success: boolean;
19
+ forwarded: number;
20
+ suppressed?: undefined;
21
+ autoApprovingWorkerApproval?: undefined;
14
22
  error?: undefined;
15
23
  } | {
16
24
  success: boolean;
@@ -308,6 +308,18 @@ export declare class CliProviderInstance implements ProviderInstance {
308
308
  private completedDebounceTimer;
309
309
  private completedDebouncePending;
310
310
  private lastExternalCompletionProbe;
311
+ /**
312
+ * The final assistant summary of the last completed turn, cached at
313
+ * completion-emit time. For a native-source provider (antigravity) whose
314
+ * assistant answer lives only in native-history — never in the PTY parse that
315
+ * feeds activeChat.messages — the dashboard's preview / lastMessageRole /
316
+ * completionMarker would otherwise never see the answer and show the session
317
+ * stuck on the user prompt. getState() appends this cached assistant bubble to
318
+ * the status messages when the PTY tail has none, so those fields reflect the
319
+ * real last answer with ZERO per-tick native reads (the native read already ran
320
+ * once at completion). Reset on the next turn's start.
321
+ */
322
+ private lastCompletionSummary;
311
323
  private enforceFreshSessionLaunchIfNeeded;
312
324
  private completionHasFinalAssistantMessage;
313
325
  private recordPendingTranscriptProbe;
@@ -321,6 +333,13 @@ export declare class CliProviderInstance implements ProviderInstance {
321
333
  */
322
334
  private spawnedEnvOverrides;
323
335
  private readExternalCompletionMessages;
336
+ /**
337
+ * The content of the LAST visible assistant bubble in a message list, or ''
338
+ * when the tail is not an assistant reply. Skips trailing system/tool/activity
339
+ * bubbles; stops (returns '') at the first user/human message. Used only for
340
+ * the dashboard tail-repair cache — a display value, not a completion decision.
341
+ */
342
+ private lastVisibleAssistantSummary;
324
343
  private completionFinalAssistantEvidence;
325
344
  private completionFinalSummary;
326
345
  private buildCompletedFinalizationDiagnostic;
@@ -1,12 +1,23 @@
1
1
  /** A claim older than this with no refresh is reclaimable (owner presumed dead). */
2
2
  export declare const CLAIM_STALE_MS: number;
3
3
  /**
4
- * Derive the per-session owner token. Both the dispatcher (from the read input:
5
- * workspace + sessionStartedAtMs) and the provider instance (from its
6
- * workingDir + startedAt, or its instanceId) call this with the same inputs so
7
- * claims and releases line up. Returns '' when there is no stable identity to
8
- * key on (e.g. a workspace-less discovery with no spawn time) — the caller then
9
- * skips claiming but the exclusion checks still run against existing claims.
4
+ * Derive the per-session owner token, keyed ONLY on the stable instanceId
5
+ * (== the session registry sessionId == the read path's targetSessionId). Both
6
+ * the dispatcher (read side) and the provider instance (claim/release side) pass
7
+ * this same id, so their tokens always agree and the claim isolation holds.
8
+ *
9
+ * Returns '' when no instanceId is available the caller then skips claiming
10
+ * (the exclusion checks still run against existing claims). This is the SSOT
11
+ * rule: there is exactly ONE token form. The removed legacy fallback derived a
12
+ * `spawn:<workspace>:<sessionStartedAtMs>` token from the spawn timestamp when
13
+ * the instanceId was missing; because one session's spawn time is sampled
14
+ * independently at three sites (instance startedAt, adapter spawnedAtMs, registry
15
+ * spawnedAtMs) those never matched, so the SAME session's instance-side and
16
+ * read-side tokens silently diverged and the claim mutual-exclusion collapsed
17
+ * (the antigravity conversation crosswire). An empty token (skip-claim) is
18
+ * strictly safer than a token that disagrees with the same session's other
19
+ * token. workspace/sessionStartedAtMs are kept in the signature for call-site
20
+ * compatibility but no longer affect the token.
10
21
  */
11
22
  export declare function antigravityOwnerToken(workspace: string, sessionStartedAtMs: number, instanceId?: string): string;
12
23
  /**
@@ -13,6 +13,19 @@ export interface SessionRuntimeTarget {
13
13
  /** Wall clock at register time. native-history readers use it as a
14
14
  * cutoff so a fresh session can't show records from a prior one. */
15
15
  spawnedAtMs?: number;
16
+ /**
17
+ * Authoritative provider-native conversation id for this session (SSOT).
18
+ * For providers that expose a session id on the CLI (codex/claude/hermes)
19
+ * this equals that id. For antigravity — which takes no --session-id — this
20
+ * is the on-disk conversations/<uuid>.db basename, discovered by the
21
+ * native-history dispatcher and written back here via setProviderSessionId
22
+ * the first time it resolves. Every downstream reader (read_chat, the
23
+ * completion probe, the dashboard) should prefer this over re-deriving the
24
+ * conversation by spawn-floor/mtime heuristics — that re-derivation is the
25
+ * source of the antigravity conversation crosswire/theft class. Empty until
26
+ * the first successful native read binds it.
27
+ */
28
+ providerSessionId?: string;
16
29
  }
17
30
  export declare class SessionRegistry {
18
31
  private readonly bySessionId;
@@ -21,6 +34,13 @@ export declare class SessionRegistry {
21
34
  private readonly byParentSessionId;
22
35
  register(target: SessionRuntimeTarget): void;
23
36
  get(sessionId: string | undefined | null): SessionRuntimeTarget | undefined;
37
+ /**
38
+ * Record the authoritative provider-native conversation id for a session
39
+ * (SSOT). Idempotent; a no-op when the session is unknown or the value is
40
+ * empty or unchanged. Never overwrites a known binding with an empty one.
41
+ * Returns whether the stored value changed.
42
+ */
43
+ setProviderSessionId(sessionId: string | undefined | null, providerSessionId: string | undefined | null): boolean;
24
44
  unregister(sessionId: string | undefined | null): void;
25
45
  unregisterByManagerKey(managerKey: string): void;
26
46
  unregisterByInstanceKey(instanceKey: string): void;
@@ -183,6 +183,18 @@ export interface DaemonMetadataUpdate {
183
183
  userName?: string;
184
184
  seq: number;
185
185
  timestamp: number;
186
+ /**
187
+ * Per-mesh state-change revision counters (meshId → monotonically increasing
188
+ * integer), bumped whenever the daemon's mesh graph/queue/mission state for that
189
+ * mesh changes (onMeshStateChange). Lets the dashboard replace its client-side
190
+ * mesh_status polling with an event-driven background refresh: when the revision
191
+ * for the mesh it is viewing advances, it re-fetches the aggregate mesh_status
192
+ * (SWR, keeping the current graph on screen). This is a lightweight nudge — the
193
+ * full aggregate snapshot is fetched on demand, not embedded here, so the
194
+ * daemon.metadata payload stays small. Optional/absent for daemons/builds that
195
+ * don't emit it (the client then keeps its polling fallback).
196
+ */
197
+ meshStateRevisions?: Record<string, number>;
186
198
  }
187
199
  export interface TopicUpdateEnvelopeMap {
188
200
  'session.chat_tail': SessionChatTailUpdate;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.475",
3
+ "version": "0.9.82-rc.477",
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",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.475",
51
- "@adhdev/session-host-core": "0.9.82-rc.475",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.477",
51
+ "@adhdev/session-host-core": "0.9.82-rc.477",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -9,6 +9,7 @@ import type { CliAdapter } from '../cli-adapter-types.js';
9
9
  import { flattenContent, type ProviderModule, type ProviderScripts } from '../providers/contracts.js';
10
10
  import { validateReadChatResultPayload } from '../providers/read-chat-contract.js';
11
11
  import { isNativeSourceCanonicalHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
12
+ import { clearPersistedProviderSessionPins, loadPersistedProviderSessionPins, recordPersistedProviderSessionPin } from '../config/state-store.js';
12
13
  import { getCoordinatorForSession } from '../mesh/coordinator-registry.js';
13
14
  import { LOG } from '../logging/logger.js';
14
15
  import { recordDebugTrace } from '../logging/debug-trace.js';
@@ -62,22 +63,77 @@ const CLI_NATIVE_TRANSCRIPT_PROVIDERS = new Set(['codex-cli', 'claude-cli', 'her
62
63
  // and run the native query normally instead of fail-closing. Refreshed on every
63
64
  // successful bind; never lets an empty id clear a known pin. Keyed by mesh
64
65
  // session id so pins never alias across distinct sessions sharing a workspace.
66
+ //
67
+ // The map is ALSO mirrored to disk (state.json sessionProviderSessionPins) so a
68
+ // pin survives a daemon restart. Without that, an attach-restored antigravity
69
+ // session (spawnedAtMs=0, so no live spawn floor) that has sat idle past the
70
+ // native reader's recency window can no longer resolve its own conversation .db
71
+ // after the daemon comes back — read_chat falls to the PTY parse and the
72
+ // dashboard shows the user prompt with the assistant tail missing
73
+ // (ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP). The in-memory map stays the hot path;
74
+ // disk is the cold-start hydration source, read lazily on the first miss.
65
75
  const lastBoundProviderSessionIdByMeshSession = new Map<string, string>();
76
+ let persistedProviderSessionPinsHydrated = false;
66
77
 
67
- function recordBoundProviderSessionId(meshSessionId: string | undefined, providerSessionId: string | undefined): void {
78
+ function hydratePersistedProviderSessionPinsOnce(): void {
79
+ if (persistedProviderSessionPinsHydrated) return;
80
+ persistedProviderSessionPinsHydrated = true;
81
+ try {
82
+ for (const [key, value] of Object.entries(loadPersistedProviderSessionPins())) {
83
+ // Never let a stale persisted value clobber a fresher in-memory bind
84
+ // recorded earlier this process lifetime.
85
+ if (!lastBoundProviderSessionIdByMeshSession.has(key)) {
86
+ lastBoundProviderSessionIdByMeshSession.set(key, value);
87
+ }
88
+ }
89
+ } catch {
90
+ // Best-effort: a missing/corrupt state file just means no cold-start pins.
91
+ }
92
+ }
93
+
94
+ function recordBoundProviderSessionId(h: CommandHelpers, meshSessionId: string | undefined, providerSessionId: string | undefined): void {
68
95
  const key = typeof meshSessionId === 'string' ? meshSessionId.trim() : '';
69
96
  const value = typeof providerSessionId === 'string' ? providerSessionId.trim() : '';
70
97
  if (!key || !value) return;
98
+ // SSOT: the session registry entry (keyed by sessionId == instanceId) is the
99
+ // authoritative sessionId → conversation-uuid record. Writing it here — the
100
+ // moment a native read resolves the real conversation id — makes
101
+ // getHistorySessionId return it directly on every subsequent read, so the
102
+ // conversation is exact-bound instead of re-resolved by the spawn-floor/mtime
103
+ // heuristic (the crosswire/theft source). The pin below stays as the durable
104
+ // cross-restart mirror (the registry is in-memory and cleared on restart).
105
+ try { h.ctx?.sessionRegistry?.setProviderSessionId?.(key, value); } catch { /* best-effort SSOT write-back */ }
71
106
  lastBoundProviderSessionIdByMeshSession.set(key, value);
107
+ // Always attempt the disk mirror — recordPersistedProviderSessionPin is itself a
108
+ // no-op when the ON-DISK value already matches, so it does not rewrite state.json
109
+ // on steady re-reads, yet it still lands a pin the in-memory map already holds but
110
+ // disk lost (a prior write clobbered by another state-store writer, or a restart
111
+ // whose hydration ran before this bind). Gating on the in-memory previous value
112
+ // let the in-memory and on-disk pin diverge permanently, defeating the persistence.
113
+ try { recordPersistedProviderSessionPin(key, value); } catch { /* best-effort disk mirror */ }
72
114
  }
73
115
 
74
116
  function getBoundProviderSessionIdPin(meshSessionId: string | undefined): string | undefined {
75
117
  const key = typeof meshSessionId === 'string' ? meshSessionId.trim() : '';
76
118
  if (!key) return undefined;
119
+ hydratePersistedProviderSessionPinsOnce();
77
120
  const pinned = lastBoundProviderSessionIdByMeshSession.get(key);
78
121
  return pinned && pinned.trim() ? pinned.trim() : undefined;
79
122
  }
80
123
 
124
+ /**
125
+ * Test-only: clear the in-memory read-pin map and re-arm cold-start hydration so
126
+ * each test starts from a clean pin state. The on-disk mirror is isolated per
127
+ * test process via ADHDEV_CONFIG_DIR (test/helpers/setup-env.ts); this resets the
128
+ * module-level cache that would otherwise leak a pin across tests sharing the
129
+ * worker. Not part of the runtime contract.
130
+ */
131
+ export function __resetProviderSessionPinsForTest(): void {
132
+ lastBoundProviderSessionIdByMeshSession.clear();
133
+ persistedProviderSessionPinsHydrated = false;
134
+ try { clearPersistedProviderSessionPins(); } catch { /* best-effort */ }
135
+ }
136
+
81
137
  const warnedLegacyNativeAllowlistHits = new Set<string>();
82
138
  function warnLegacyNativeAllowlistHit(providerType: string): void {
83
139
  if (warnedLegacyNativeAllowlistHits.has(providerType)) return;
@@ -1036,6 +1092,28 @@ function hasSafeNativeHistoryMapping(args: {
1036
1092
  // other. historySessionId (the provider-native session key) is required to
1037
1093
  // establish ownership. hasSafeNativeHistoryMapping() enforces the same
1038
1094
  // invariant after the read; both guards must hold for native history to be used.
1095
+ /**
1096
+ * The session id a native-history read should be scoped to: the explicit
1097
+ * targetSessionId when the caller named one (reading a specific/worker
1098
+ * session), otherwise the current live session (a self / dashboard read where
1099
+ * the current session IS the one being read). getTargetedCliAdapter already
1100
+ * uses this same fallback to resolve the adapter; the native-history floor and
1101
+ * claim-owner token must use it too. Without the fallback, a self-read arrives
1102
+ * with no targetSessionId → the floor collapses to undefined (→0) and the
1103
+ * antigravity claim-owner token collapses to '' → pickUnboundConversationDb
1104
+ * drops out of its spawn-floor branch into newest-by-mtime and binds whichever
1105
+ * conversation .db was written most recently. For an antigravity MAGI
1106
+ * coordinator that is exactly a co-located replica's .db (the replica finished
1107
+ * its turn last), so the coordinator's read cross-wires onto the replica's
1108
+ * conversation instead of its own (ANTIGRAVITY coordinator↔replica crosswire).
1109
+ */
1110
+ function effectiveReadSessionId(h: CommandHelpers, targetSessionId: string | undefined): string {
1111
+ const explicit = typeof targetSessionId === 'string' ? targetSessionId.trim() : '';
1112
+ if (explicit) return explicit;
1113
+ const current = (h.currentSession as any)?.sessionId;
1114
+ return typeof current === 'string' ? current.trim() : '';
1115
+ }
1116
+
1039
1117
  /**
1040
1118
  * Pull the session's spawnedAtMs out of the registry. Native-history
1041
1119
  * file pickers use it as a "files older than this can't be from this
@@ -1043,10 +1121,12 @@ function hasSafeNativeHistoryMapping(args: {
1043
1121
  * previous session's transcript whenever its file happened to be the
1044
1122
  * newest match. Returns undefined when the session isn't registered
1045
1123
  * (e.g. read_chat before the live session was wired up) — the executor
1046
- * treats undefined as "no floor".
1124
+ * treats undefined as "no floor". Resolves the effective session id
1125
+ * (targetSessionId or the current live session) so a self-read still gets its
1126
+ * real spawn floor rather than 0.
1047
1127
  */
1048
1128
  function sessionStartedAtMsFromRegistry(h: CommandHelpers, targetSessionId: string | undefined): number | undefined {
1049
- const sid = typeof targetSessionId === 'string' ? targetSessionId.trim() : '';
1129
+ const sid = effectiveReadSessionId(h, targetSessionId);
1050
1130
  if (!sid) return undefined;
1051
1131
  const target = h.ctx?.sessionRegistry?.get?.(sid);
1052
1132
  return typeof target?.spawnedAtMs === 'number' ? target.spawnedAtMs : undefined;
@@ -1549,7 +1629,7 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
1549
1629
  scripts: provider?.scripts as any,
1550
1630
  sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
1551
1631
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1552
- instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1632
+ instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
1553
1633
  pinnedProviderSessionId: getBoundProviderSessionIdPin(args?.targetSessionId),
1554
1634
  })
1555
1635
  : readProviderChatHistory(agentStr, {
@@ -1571,7 +1651,7 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
1571
1651
  ? (result as any).providerSessionId
1572
1652
  : readHistorySessionIdFromMessages(messages) || historySessionId;
1573
1653
  if (typeof (result as any)?.providerSessionId === 'string' && (result as any).providerSessionId.trim()) {
1574
- recordBoundProviderSessionId(args?.targetSessionId, (result as any).providerSessionId.trim());
1654
+ recordBoundProviderSessionId(h, effectiveReadSessionId(h, args?.targetSessionId), (result as any).providerSessionId.trim());
1575
1655
  }
1576
1656
  const safeMapping = hasSafeNativeHistoryMapping({
1577
1657
  historySessionId: lookup === 'workspace' ? undefined : historySessionId,
@@ -1768,10 +1848,31 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1768
1848
  let nativeHistory: any | null = null;
1769
1849
  let nativeHistoryError: unknown | undefined;
1770
1850
  if (supportsNative) {
1851
+ // Runtime-fallback → pin substitution: nativeHistoryReadSessionId is
1852
+ // the bare runtime/session id when no explicit provider handle was
1853
+ // supplied and none was parsed (antigravity takes no --session-id, so
1854
+ // its this.providerSessionId stays empty and getHistorySessionId falls
1855
+ // back to targetSessionId). That runtime id is not the on-disk
1856
+ // conversations/<uuid>.db name, so a native read keyed on it can never
1857
+ // exact-bind and falls to the recency heuristic — which drops an idle
1858
+ // (or restored, spawnedAtMs=0) session's own store. Prefer a pin (a
1859
+ // real conversation id a prior read resolved for THIS session, now also
1860
+ // persisted across restart) over the runtime id, else drop the runtime
1861
+ // id so readCliProviderNativeHistory's pin / workspace-latest paths can
1862
+ // engage. Mirrors the handleChatHistory path's established handling.
1863
+ const pinnedProviderSessionIdForRead = getBoundProviderSessionIdPin(targetSessionId);
1864
+ const nativeReadSessionIdIsRuntimeFallback = Boolean(
1865
+ targetSessionId
1866
+ && nativeHistoryReadSessionId === targetSessionId
1867
+ && !getExplicitHistorySessionId(args),
1868
+ );
1869
+ const effectiveNativeReadSessionId = nativeReadSessionIdIsRuntimeFallback
1870
+ ? (pinnedProviderSessionIdForRead || undefined)
1871
+ : nativeHistoryReadSessionId;
1771
1872
  try {
1772
1873
  nativeHistory = readCliProviderNativeHistory(agentStr, {
1773
1874
  canonicalHistory: provider?.nativeHistory,
1774
- historySessionId: nativeHistoryReadSessionId,
1875
+ historySessionId: effectiveNativeReadSessionId,
1775
1876
  workspace,
1776
1877
  offset: 0,
1777
1878
  limit: nativeHistoryLimit,
@@ -1783,12 +1884,12 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1783
1884
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1784
1885
  // Stable per-session identity for antigravity's conversation-claim
1785
1886
  // owner token (== session registry sessionId == instance instanceId).
1786
- instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1787
- pinnedProviderSessionId: getBoundProviderSessionIdPin(targetSessionId),
1887
+ instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
1888
+ pinnedProviderSessionId: pinnedProviderSessionIdForRead,
1788
1889
  // Last-resort only when no pin was ever recorded for this
1789
1890
  // session; the downstream workspace-overlap safety gate
1790
1891
  // still filters an aliased session out.
1791
- allowWorkspaceLatestFallback: !getBoundProviderSessionIdPin(targetSessionId),
1892
+ allowWorkspaceLatestFallback: !pinnedProviderSessionIdForRead,
1792
1893
  });
1793
1894
  // Refresh the per-mesh-session pin whenever a native read
1794
1895
  // resolves a concrete provider-native session id. A later
@@ -1799,7 +1900,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1799
1900
  ? nativeHistory.providerSessionId.trim()
1800
1901
  : '';
1801
1902
  if (resolvedProviderSessionId) {
1802
- recordBoundProviderSessionId(targetSessionId, resolvedProviderSessionId);
1903
+ recordBoundProviderSessionId(h, effectiveReadSessionId(h, targetSessionId), resolvedProviderSessionId);
1803
1904
  }
1804
1905
  } catch (error: any) {
1805
1906
  nativeHistoryError = error;
@@ -1859,7 +1960,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1859
1960
  excludeInProgressTurn: returnedStatus === 'waiting_approval',
1860
1961
  sessionStartedAtMs,
1861
1962
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1862
- instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1963
+ instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
1863
1964
  });
1864
1965
  nativeHistoryError = undefined;
1865
1966
  nativeMessages = nativeHistory && Array.isArray(nativeHistory.messages)
@@ -2158,7 +2259,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2158
2259
  scripts: provider?.scripts as any,
2159
2260
  sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
2160
2261
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
2161
- instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
2262
+ instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
2162
2263
  pinnedProviderSessionId: pinnedProviderSessionIdForHistory,
2163
2264
  // Last-resort only when no pin was ever recorded AND the
2164
2265
  // runtime fallback did not resolve a real provider session.
@@ -2183,7 +2284,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2183
2284
  : readHistorySessionIdFromMessages(historyMessages) || effectiveHistorySessionIdForRead;
2184
2285
  // Refresh the pin whenever this path resolves a real provider id.
2185
2286
  if (typeof (history as any)?.providerSessionId === 'string' && (history as any).providerSessionId.trim()) {
2186
- recordBoundProviderSessionId(targetSid, (history as any).providerSessionId.trim());
2287
+ recordBoundProviderSessionId(h, effectiveReadSessionId(h, targetSid), (history as any).providerSessionId.trim());
2187
2288
  }
2188
2289
  // Use the id we actually read with (pin / real provider id), NOT the
2189
2290
  // raw runtime-fallback historySessionId — otherwise the mapping guard
@@ -11,7 +11,7 @@
11
11
  export { READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS, buildSendInputSignature } from './chat-commands-shared.js';
12
12
  export { evaluateReadChatNodeWorkspaceScope } from './chat-commands-scope.js';
13
13
  export { sanitizeDebugBundleValue, handleGetChatDebugBundle } from './chat-commands-debug-bundle.js';
14
- export { handleChatHistory, handleReadChat } from './chat-commands-read.js';
14
+ export { handleChatHistory, handleReadChat, __resetProviderSessionPinsForTest } from './chat-commands-read.js';
15
15
  export {
16
16
  handleSendChat,
17
17
  handleListChats,
@@ -26,6 +26,17 @@ export interface DaemonState {
26
26
  sessionNotificationDismissals: Record<string, string>;
27
27
  /** Current notification unread override ids keyed by stable session target */
28
28
  sessionNotificationUnreadOverrides: Record<string, string>;
29
+ /**
30
+ * Resolved provider-native conversation id for a live session, keyed by the
31
+ * ADHDev/mesh session id. Persisted so it survives a daemon restart: a
32
+ * provider whose on-disk store is keyed by an internally-generated id it
33
+ * never exposes on the CLI (antigravity — no --session-id) can then exact-bind
34
+ * its conversation .db after restart instead of re-running the mtime/recency
35
+ * heuristic, which drops the store once idle and collapses read_chat to the
36
+ * PTY parse (user echo only, assistant tail lost). Mirrors the in-memory read
37
+ * pin (chat-commands-read lastBoundProviderSessionIdByMeshSession).
38
+ */
39
+ sessionProviderSessionPins: Record<string, string>;
29
40
  }
30
41
 
31
42
  const DEFAULT_STATE: DaemonState = {
@@ -35,6 +46,7 @@ const DEFAULT_STATE: DaemonState = {
35
46
  sessionReadMarkers: {},
36
47
  sessionNotificationDismissals: {},
37
48
  sessionNotificationUnreadOverrides: {},
49
+ sessionProviderSessionPins: {},
38
50
  };
39
51
 
40
52
  function isPlainObject(value: unknown): value is Record<string, any> {
@@ -77,6 +89,10 @@ function normalizeState(raw: unknown): DaemonState {
77
89
  Object.entries(isPlainObject(parsed.sessionNotificationUnreadOverrides) ? parsed.sessionNotificationUnreadOverrides : {})
78
90
  .filter(([, value]) => typeof value === 'string' && value.length > 0)
79
91
  );
92
+ const sessionProviderSessionPins = Object.fromEntries(
93
+ Object.entries(isPlainObject(parsed.sessionProviderSessionPins) ? parsed.sessionProviderSessionPins : {})
94
+ .filter(([key, value]) => typeof key === 'string' && key.length > 0 && typeof value === 'string' && value.length > 0)
95
+ );
80
96
 
81
97
  return {
82
98
  recentActivity,
@@ -85,6 +101,7 @@ function normalizeState(raw: unknown): DaemonState {
85
101
  sessionReadMarkers,
86
102
  sessionNotificationDismissals,
87
103
  sessionNotificationUnreadOverrides,
104
+ sessionProviderSessionPins,
88
105
  };
89
106
  }
90
107
 
@@ -121,3 +138,41 @@ export function saveState(state: DaemonState): void {
121
138
  export function resetState(): void {
122
139
  saveState({ ...DEFAULT_STATE });
123
140
  }
141
+
142
+ /**
143
+ * Load the full persisted session→provider-conversation pin map (sessionId →
144
+ * provider-native conversation id). Survives daemon restart. Empty object when
145
+ * none recorded or the state file is unreadable.
146
+ */
147
+ export function loadPersistedProviderSessionPins(): Record<string, string> {
148
+ return { ...loadState().sessionProviderSessionPins };
149
+ }
150
+
151
+ /**
152
+ * Persist one session→provider-conversation pin. Load-mutate-save against the
153
+ * on-disk state so it survives a daemon restart; a no-op when the value already
154
+ * matches (avoids rewriting state.json on every read). Never clears a pin with an
155
+ * empty value.
156
+ */
157
+ export function recordPersistedProviderSessionPin(sessionId: string, providerSessionId: string): void {
158
+ const key = typeof sessionId === 'string' ? sessionId.trim() : '';
159
+ const value = typeof providerSessionId === 'string' ? providerSessionId.trim() : '';
160
+ if (!key || !value) return;
161
+ const state = loadState();
162
+ if (state.sessionProviderSessionPins[key] === value) return;
163
+ saveState({
164
+ ...state,
165
+ sessionProviderSessionPins: { ...state.sessionProviderSessionPins, [key]: value },
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Test-only: drop all persisted session→provider-conversation pins from disk.
171
+ * Used by tests that assert a clean "no pin" state so a sibling test's write to
172
+ * the shared per-process ADHDEV_CONFIG_DIR does not leak in.
173
+ */
174
+ export function clearPersistedProviderSessionPins(): void {
175
+ const state = loadState();
176
+ if (Object.keys(state.sessionProviderSessionPins).length === 0) return;
177
+ saveState({ ...state, sessionProviderSessionPins: {} });
178
+ }
@@ -688,6 +688,31 @@ function evaluateMeshEventSuppression(
688
688
  return null;
689
689
  }
690
690
 
691
+ /**
692
+ * True when the worker session that emitted this event has auto-approve enabled
693
+ * (a MAGI/delegated worker is launched with autoApprove:true). Such a worker
694
+ * resolves its own approval modals locally, so an agent:waiting_approval event
695
+ * from it is transient noise for the coordinator: forwarding it injects a
696
+ * "[System] … is waiting for approval, use mesh_approve" turn that the
697
+ * coordinator cannot usefully act on (the modal is already auto-resolving) and,
698
+ * mid-MAGI-collect, HIJACKS the coordinator's synthesis turn — the observed
699
+ * failure where a replica's repeated auto-approvals drowned out the completion
700
+ * events and the coordinator answered about approvals instead of the RCA. Only
701
+ * suppress when we can positively confirm the source worker auto-approves; a
702
+ * worker that genuinely needs a human/coordinator approval (autoApprove off)
703
+ * still forwards so the coordinator is told.
704
+ */
705
+ function sourceWorkerAutoApproves(components: DaemonComponents, sessionId: string): boolean {
706
+ if (!sessionId) return false;
707
+ try {
708
+ const state = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
709
+ const settings = (state?.settings as Record<string, unknown>) || {};
710
+ return settings.autoApprove === true;
711
+ } catch {
712
+ return false;
713
+ }
714
+ }
715
+
691
716
  function injectMeshSystemMessage(components: DaemonComponents, args: {
692
717
  meshId: string;
693
718
  sourceInstanceId?: string;
@@ -805,6 +830,19 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
805
830
  }
806
831
 
807
832
  const eventTimestamp = readEventTimestamp(args.metadataEvent.timestamp);
833
+
834
+ // Auto-approving worker: never forward its approval prompts to the coordinator.
835
+ // The daemon resolves the modal locally, so the "[System] … waiting for
836
+ // approval" injection is pure noise that hijacks the coordinator's turn — the
837
+ // observed MAGI failure where a replica's repeated auto-approvals flooded the
838
+ // coordinator and derailed its final synthesis. A worker without auto-approve
839
+ // (genuinely blocked on a human/coordinator decision) still forwards.
840
+ if (args.event === 'agent:waiting_approval' && sourceWorkerAutoApproves(components, eventSessionId)) {
841
+ LOG.info('MeshEvents', `Suppressed agent:waiting_approval for auto-approving worker session ${eventSessionId || '(unknown)'} (mesh ${args.meshId}) — modal is resolved locally, coordinator not notified`);
842
+ traceMeshEventDrop('waiting_approval_auto_approving_worker', traceCtx);
843
+ return { success: true, forwarded: 0, suppressed: true, autoApprovingWorkerApproval: true };
844
+ }
845
+
808
846
  // Coordinator-side dedup/suppression gate (extracted, behavior-preserving). A non-null
809
847
  // outcome either short-circuits with a forwarded result or signals a no-progress→completion
810
848
  // reconciliation that we re-inject; null lets the event fall through to the ledger machinery.