@adhdev/daemon-core 0.9.82-rc.474 → 0.9.82-rc.476

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.
@@ -287,9 +287,21 @@ export declare class CliProviderInstance implements ProviderInstance {
287
287
  private pruneRecentUserInputAcks;
288
288
  /**
289
289
  * Owner token for this session in the antigravity conversation-claim
290
- * registry. Derived identically to the dispatcher's read-side token
291
- * (workspace + spawn time) so the claims the dispatcher records under this
292
- * session are the ones dispose() releases.
290
+ * registry. Keyed on the daemon instance id — the SAME value the session
291
+ * registry stores as this session's `sessionId` (see cli-manager
292
+ * `sessionRegistry.register({ sessionId: cliInstance.instanceId })`) and the
293
+ * read side hands the dispatcher as `instanceId`. Both sides therefore
294
+ * derive the identical `iid:<instanceId>` token, so the claims the
295
+ * dispatcher records under this session are exactly the ones dispose()
296
+ * releases.
297
+ *
298
+ * This must NOT be derived from a spawn timestamp: the instance's
299
+ * `startedAt`, the adapter's `spawnedAtMs`, and the session registry's
300
+ * `spawnedAtMs` are three INDEPENDENT `Date.now()` samples for the one
301
+ * session, so a workspace+spawn-time token computed here would never equal
302
+ * the read side's — the claim isolation then silently collapses and two
303
+ * concurrent antigravity sessions cross-bind each other's conversation .db
304
+ * (coordinator+worker chat crosswire).
293
305
  */
294
306
  private antigravityClaimOwner;
295
307
  dispose(): 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.474",
3
+ "version": "0.9.82-rc.476",
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.474",
51
- "@adhdev/session-host-core": "0.9.82-rc.474",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.476",
51
+ "@adhdev/session-host-core": "0.9.82-rc.476",
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,69 @@ 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;
77
+
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
+ }
66
93
 
67
94
  function recordBoundProviderSessionId(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;
71
98
  lastBoundProviderSessionIdByMeshSession.set(key, value);
99
+ // Always attempt the disk mirror — recordPersistedProviderSessionPin is itself a
100
+ // no-op when the ON-DISK value already matches, so it does not rewrite state.json
101
+ // on steady re-reads, yet it still lands a pin the in-memory map already holds but
102
+ // disk lost (a prior write clobbered by another state-store writer, or a restart
103
+ // whose hydration ran before this bind). Gating on the in-memory previous value
104
+ // let the in-memory and on-disk pin diverge permanently, defeating the persistence.
105
+ try { recordPersistedProviderSessionPin(key, value); } catch { /* best-effort disk mirror */ }
72
106
  }
73
107
 
74
108
  function getBoundProviderSessionIdPin(meshSessionId: string | undefined): string | undefined {
75
109
  const key = typeof meshSessionId === 'string' ? meshSessionId.trim() : '';
76
110
  if (!key) return undefined;
111
+ hydratePersistedProviderSessionPinsOnce();
77
112
  const pinned = lastBoundProviderSessionIdByMeshSession.get(key);
78
113
  return pinned && pinned.trim() ? pinned.trim() : undefined;
79
114
  }
80
115
 
116
+ /**
117
+ * Test-only: clear the in-memory read-pin map and re-arm cold-start hydration so
118
+ * each test starts from a clean pin state. The on-disk mirror is isolated per
119
+ * test process via ADHDEV_CONFIG_DIR (test/helpers/setup-env.ts); this resets the
120
+ * module-level cache that would otherwise leak a pin across tests sharing the
121
+ * worker. Not part of the runtime contract.
122
+ */
123
+ export function __resetProviderSessionPinsForTest(): void {
124
+ lastBoundProviderSessionIdByMeshSession.clear();
125
+ persistedProviderSessionPinsHydrated = false;
126
+ try { clearPersistedProviderSessionPins(); } catch { /* best-effort */ }
127
+ }
128
+
81
129
  const warnedLegacyNativeAllowlistHits = new Set<string>();
82
130
  function warnLegacyNativeAllowlistHit(providerType: string): void {
83
131
  if (warnedLegacyNativeAllowlistHits.has(providerType)) return;
@@ -1095,6 +1143,13 @@ function readCliProviderNativeHistory(agentStr: string, args: {
1095
1143
  // the downstream hasSafeNativeHistoryMapping workspace-overlap gate. Only the
1096
1144
  // read_chat path opts in, and only with a concrete workspace.
1097
1145
  allowWorkspaceLatestFallback?: boolean;
1146
+ // ADHDev session id of the reading session (== the session registry's
1147
+ // sessionId == the provider instance's instanceId). Threaded to the
1148
+ // native-history dispatcher so antigravity's conversation-claim owner token
1149
+ // is keyed on this stable identity and matches the instance-side token —
1150
+ // without it two concurrent antigravity sessions cross-bind each other's
1151
+ // conversation .db.
1152
+ instanceId?: string;
1098
1153
  }): ReturnType<typeof readProviderChatHistory> & { lookup: 'session' | 'workspace' } {
1099
1154
  const canBindFromLiveSession = !args.historySessionId
1100
1155
  && typeof args.sessionStartedAtMs === 'number'
@@ -1146,6 +1201,7 @@ function readCliProviderNativeHistory(agentStr: string, args: {
1146
1201
  excludeInProgressTurn: args.excludeInProgressTurn,
1147
1202
  sessionStartedAtMs: args.sessionStartedAtMs,
1148
1203
  envOverrides: args.envOverrides,
1204
+ instanceId: args.instanceId,
1149
1205
  });
1150
1206
  const boundProviderSessionId = typeof (sessionHistory as any)?.providerSessionId === 'string'
1151
1207
  ? (sessionHistory as any).providerSessionId.trim()
@@ -1541,6 +1597,7 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
1541
1597
  scripts: provider?.scripts as any,
1542
1598
  sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
1543
1599
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1600
+ instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1544
1601
  pinnedProviderSessionId: getBoundProviderSessionIdPin(args?.targetSessionId),
1545
1602
  })
1546
1603
  : readProviderChatHistory(agentStr, {
@@ -1759,10 +1816,31 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1759
1816
  let nativeHistory: any | null = null;
1760
1817
  let nativeHistoryError: unknown | undefined;
1761
1818
  if (supportsNative) {
1819
+ // Runtime-fallback → pin substitution: nativeHistoryReadSessionId is
1820
+ // the bare runtime/session id when no explicit provider handle was
1821
+ // supplied and none was parsed (antigravity takes no --session-id, so
1822
+ // its this.providerSessionId stays empty and getHistorySessionId falls
1823
+ // back to targetSessionId). That runtime id is not the on-disk
1824
+ // conversations/<uuid>.db name, so a native read keyed on it can never
1825
+ // exact-bind and falls to the recency heuristic — which drops an idle
1826
+ // (or restored, spawnedAtMs=0) session's own store. Prefer a pin (a
1827
+ // real conversation id a prior read resolved for THIS session, now also
1828
+ // persisted across restart) over the runtime id, else drop the runtime
1829
+ // id so readCliProviderNativeHistory's pin / workspace-latest paths can
1830
+ // engage. Mirrors the handleChatHistory path's established handling.
1831
+ const pinnedProviderSessionIdForRead = getBoundProviderSessionIdPin(targetSessionId);
1832
+ const nativeReadSessionIdIsRuntimeFallback = Boolean(
1833
+ targetSessionId
1834
+ && nativeHistoryReadSessionId === targetSessionId
1835
+ && !getExplicitHistorySessionId(args),
1836
+ );
1837
+ const effectiveNativeReadSessionId = nativeReadSessionIdIsRuntimeFallback
1838
+ ? (pinnedProviderSessionIdForRead || undefined)
1839
+ : nativeHistoryReadSessionId;
1762
1840
  try {
1763
1841
  nativeHistory = readCliProviderNativeHistory(agentStr, {
1764
1842
  canonicalHistory: provider?.nativeHistory,
1765
- historySessionId: nativeHistoryReadSessionId,
1843
+ historySessionId: effectiveNativeReadSessionId,
1766
1844
  workspace,
1767
1845
  offset: 0,
1768
1846
  limit: nativeHistoryLimit,
@@ -1772,11 +1850,14 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1772
1850
  excludeInProgressTurn: returnedStatus === 'waiting_approval',
1773
1851
  sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
1774
1852
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1775
- pinnedProviderSessionId: getBoundProviderSessionIdPin(targetSessionId),
1853
+ // Stable per-session identity for antigravity's conversation-claim
1854
+ // owner token (== session registry sessionId == instance instanceId).
1855
+ instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1856
+ pinnedProviderSessionId: pinnedProviderSessionIdForRead,
1776
1857
  // Last-resort only when no pin was ever recorded for this
1777
1858
  // session; the downstream workspace-overlap safety gate
1778
1859
  // still filters an aliased session out.
1779
- allowWorkspaceLatestFallback: !getBoundProviderSessionIdPin(targetSessionId),
1860
+ allowWorkspaceLatestFallback: !pinnedProviderSessionIdForRead,
1780
1861
  });
1781
1862
  // Refresh the per-mesh-session pin whenever a native read
1782
1863
  // resolves a concrete provider-native session id. A later
@@ -1847,6 +1928,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1847
1928
  excludeInProgressTurn: returnedStatus === 'waiting_approval',
1848
1929
  sessionStartedAtMs,
1849
1930
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1931
+ instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1850
1932
  });
1851
1933
  nativeHistoryError = undefined;
1852
1934
  nativeMessages = nativeHistory && Array.isArray(nativeHistory.messages)
@@ -2145,6 +2227,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2145
2227
  scripts: provider?.scripts as any,
2146
2228
  sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
2147
2229
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
2230
+ instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
2148
2231
  pinnedProviderSessionId: pinnedProviderSessionIdForHistory,
2149
2232
  // Last-resort only when no pin was ever recorded AND the
2150
2233
  // runtime fallback did not resolve a real provider session.
@@ -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,
@@ -1813,10 +1813,12 @@ function callProviderNativeHistoryRead(
1813
1813
  sessionStartedAtMs?: number,
1814
1814
  envOverrides?: Record<string, string>,
1815
1815
  forceRefresh?: boolean,
1816
+ instanceId?: string,
1816
1817
  ): ProviderNativeHistoryReadResult | null {
1817
1818
  const fn = getProviderNativeHistoryScript(scripts, canonicalHistory, 'readSession');
1818
1819
  if (!fn) return null;
1819
1820
  const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId || '');
1821
+ const normalizedInstanceId = typeof instanceId === 'string' ? instanceId.trim() : '';
1820
1822
  const result = fn({
1821
1823
  agentType,
1822
1824
  sessionId: normalizedSessionId,
@@ -1831,6 +1833,12 @@ function callProviderNativeHistoryRead(
1831
1833
  // which leaves the guard disarmed so discovery still works.
1832
1834
  providerSessionId: normalizedSessionId,
1833
1835
  historySessionId: normalizedSessionId,
1836
+ // Stable per-session owner key for the antigravity conversation-claim
1837
+ // registry (see dispatcher.resolveAntigravityPath / antigravityOwnerToken).
1838
+ // Equals the session registry's sessionId and the provider instance's
1839
+ // instanceId, so read side and instance side derive the identical claim
1840
+ // owner token and two concurrent antigravity sessions never cross-bind.
1841
+ instanceId: normalizedInstanceId || undefined,
1834
1842
  workspace,
1835
1843
  format: canonicalHistory?.format,
1836
1844
  watchPath: canonicalHistory?.watchPath,
@@ -1838,7 +1846,7 @@ function callProviderNativeHistoryRead(
1838
1846
  sessionStartedAtMs,
1839
1847
  envOverrides,
1840
1848
  forceRefresh: forceRefresh === true,
1841
- args: { sessionId: normalizedSessionId, historySessionId: normalizedSessionId, workspace, excludeInProgressTurn: excludeInProgressTurn === true, sessionStartedAtMs, envOverrides, forceRefresh: forceRefresh === true },
1849
+ args: { sessionId: normalizedSessionId, historySessionId: normalizedSessionId, instanceId: normalizedInstanceId || undefined, workspace, excludeInProgressTurn: excludeInProgressTurn === true, sessionStartedAtMs, envOverrides, forceRefresh: forceRefresh === true },
1842
1850
  });
1843
1851
  if (!result || typeof result !== 'object') return null;
1844
1852
  const records = normalizeProviderNativeHistoryRecords(agentType, normalizedSessionId, (result as any).messages || (result as any).records);
@@ -1865,11 +1873,12 @@ function buildNativeHistoryReadResult(
1865
1873
  sessionStartedAtMs?: number,
1866
1874
  envOverrides?: Record<string, string>,
1867
1875
  forceRefresh?: boolean,
1876
+ instanceId?: string,
1868
1877
  ): ProviderNativeHistoryReadResult | null {
1869
1878
  const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId || '');
1870
1879
  const normalizedWorkspace = typeof workspace === 'string' ? workspace.trim() : '';
1871
1880
  if (!canonicalHistory || (!normalizedSessionId && !normalizedWorkspace) || !isNativeSourceCanonicalHistory(canonicalHistory)) return null;
1872
- return callProviderNativeHistoryRead(agentType, canonicalHistory, scripts, normalizedSessionId, workspace, excludeInProgressTurn, sessionStartedAtMs, envOverrides, forceRefresh);
1881
+ return callProviderNativeHistoryRead(agentType, canonicalHistory, scripts, normalizedSessionId, workspace, excludeInProgressTurn, sessionStartedAtMs, envOverrides, forceRefresh, instanceId);
1873
1882
  }
1874
1883
 
1875
1884
  function materializeNativeHistoryToMirror(
@@ -1929,6 +1938,13 @@ export function readProviderChatHistory(
1929
1938
  sessionStartedAtMs?: number;
1930
1939
  envOverrides?: Record<string, string>;
1931
1940
  forceRefresh?: boolean;
1941
+ // Daemon instance id of the reading session (== the session registry's
1942
+ // sessionId). Threaded to the native-history dispatcher so the
1943
+ // antigravity conversation-claim owner token is keyed on this stable
1944
+ // per-session identity — identical to the token the provider instance
1945
+ // derives — instead of a spawn timestamp that differs across sample
1946
+ // sites and silently breaks claim isolation.
1947
+ instanceId?: string;
1932
1948
  } = {},
1933
1949
  ): {
1934
1950
  messages: HistoryMessage[];
@@ -1943,7 +1959,7 @@ export function readProviderChatHistory(
1943
1959
  unavailableReason?: string;
1944
1960
  } {
1945
1961
  if (isNativeSourceCanonicalHistory(options.canonicalHistory) && (options.historySessionId || options.workspace)) {
1946
- const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace, options.excludeInProgressTurn, options.sessionStartedAtMs, options.envOverrides, options.forceRefresh);
1962
+ const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace, options.excludeInProgressTurn, options.sessionStartedAtMs, options.envOverrides, options.forceRefresh, options.instanceId);
1947
1963
  if (!nativeResult) return { messages: [], hasMore: false, source: 'native-unavailable' };
1948
1964
  return {
1949
1965
  ...pageHistoryRecords(agentType, nativeResult.records, options.offset || 0, options.limit || 30, options.excludeRecentCount || 0, options.historyBehavior),
@@ -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
+ }
@@ -1089,12 +1089,24 @@ export class CliProviderInstance implements ProviderInstance {
1089
1089
 
1090
1090
  /**
1091
1091
  * Owner token for this session in the antigravity conversation-claim
1092
- * registry. Derived identically to the dispatcher's read-side token
1093
- * (workspace + spawn time) so the claims the dispatcher records under this
1094
- * session are the ones dispose() releases.
1092
+ * registry. Keyed on the daemon instance id — the SAME value the session
1093
+ * registry stores as this session's `sessionId` (see cli-manager
1094
+ * `sessionRegistry.register({ sessionId: cliInstance.instanceId })`) and the
1095
+ * read side hands the dispatcher as `instanceId`. Both sides therefore
1096
+ * derive the identical `iid:<instanceId>` token, so the claims the
1097
+ * dispatcher records under this session are exactly the ones dispose()
1098
+ * releases.
1099
+ *
1100
+ * This must NOT be derived from a spawn timestamp: the instance's
1101
+ * `startedAt`, the adapter's `spawnedAtMs`, and the session registry's
1102
+ * `spawnedAtMs` are three INDEPENDENT `Date.now()` samples for the one
1103
+ * session, so a workspace+spawn-time token computed here would never equal
1104
+ * the read side's — the claim isolation then silently collapses and two
1105
+ * concurrent antigravity sessions cross-bind each other's conversation .db
1106
+ * (coordinator+worker chat crosswire).
1095
1107
  */
1096
1108
  private antigravityClaimOwner(): string {
1097
- return antigravityOwnerToken(this.workingDir, this.startedAt);
1109
+ return antigravityOwnerToken(this.workingDir, this.startedAt, this.instanceId);
1098
1110
  }
1099
1111
 
1100
1112
  dispose(): void {
@@ -87,6 +87,23 @@ export function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeH
87
87
  return null;
88
88
  }
89
89
 
90
+ // For antigravity, the authoritative conversation id is the on-disk uuid
91
+ // embedded in the resolved path (conversations/<uuid>.db or
92
+ // brain/<uuid>/…/transcript.jsonl), NOT the ADHDev session id the caller
93
+ // threaded in. Surface that uuid as providerSessionId whenever the reader
94
+ // did not already return a distinct one, so the read_chat layer can pin
95
+ // the real conversation and (post-restart) exact-bind straight to it
96
+ // instead of re-running the mtime/recency heuristic that drops an idle
97
+ // store (ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP). Other providers keep the
98
+ // reader's value verbatim.
99
+ let resolvedProviderSessionId = session.providerSessionId;
100
+ if (reader === 'antigravity-cli') {
101
+ const onDiskUuid = extractAntigravityConversationUuid(session.sourcePath || sourcePath);
102
+ if (onDiskUuid && (!resolvedProviderSessionId || resolvedProviderSessionId === sessionId)) {
103
+ resolvedProviderSessionId = onDiskUuid;
104
+ }
105
+ }
106
+
90
107
  return {
91
108
  messages: session.messages.map((m: any) => ({
92
109
  role: normalizeRole(m.role),
@@ -95,7 +112,7 @@ export function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeH
95
112
  kind: typeof m.kind === 'string' ? m.kind : 'standard',
96
113
  workspace: typeof m.workspace === 'string' ? m.workspace : workspace || undefined,
97
114
  })),
98
- providerSessionId: session.providerSessionId,
115
+ providerSessionId: resolvedProviderSessionId,
99
116
  sourcePath: session.sourcePath,
100
117
  sourceMtimeMs: session.sourceMtimeMs,
101
118
  nativeHistoryCoverage: (session as any).nativeHistoryCoverage || 'full',
@@ -239,6 +256,27 @@ function resolveRealPath(value: string): string {
239
256
  try { return fs.realpathSync(value); } catch { return value; }
240
257
  }
241
258
 
259
+ /**
260
+ * Pull the antigravity conversation uuid out of a resolved source path. Both
261
+ * on-disk layouts embed it: conversations/<uuid>.db and
262
+ * brain/<uuid>/.system_generated/logs/transcript*.jsonl (and the legacy
263
+ * conversations/<uuid>.pb). Returns the uuid when a segment matches the
264
+ * canonical form, else ''.
265
+ */
266
+ function extractAntigravityConversationUuid(sourcePath: string): string {
267
+ if (!sourcePath) return '';
268
+ const segments = sourcePath.split(/[\\/]/);
269
+ // conversations/<uuid>.db|.pb — the basename minus extension.
270
+ const base = segments[segments.length - 1] || '';
271
+ const baseMatch = /^([0-9a-f-]+)\.(?:db|pb)$/i.exec(base);
272
+ if (baseMatch && isUuidLikeSessionId(baseMatch[1])) return baseMatch[1];
273
+ // brain/<uuid>/… — the first uuid-like path segment.
274
+ for (const seg of segments) {
275
+ if (isUuidLikeSessionId(seg)) return seg;
276
+ }
277
+ return '';
278
+ }
279
+
242
280
  /**
243
281
  * The daemon may stamp a session's spawn time a hair before the CLI child
244
282
  * actually creates its conversation .db, so treat a store born within this
@@ -341,6 +379,19 @@ function pickUnboundConversationDb(
341
379
  let entries: fs.Dirent[] = [];
342
380
  try { entries = fs.readdirSync(convRoot, { withFileTypes: true }); } catch { return null; }
343
381
 
382
+ // A known spawn floor already pins a candidate to THIS session by birth time
383
+ // (a store created at/after the session spawned is its own). Once that floor
384
+ // is available, the recency window is not just unnecessary but harmful: an
385
+ // antigravity session that has sat idle longer than RECENT_WINDOW_MS still
386
+ // owns its conversation .db, but the recency cutoff would drop it from the
387
+ // candidate set, collapsing the read to native_history_empty and forcing the
388
+ // dashboard onto the PTY parse (user echo only, assistant tail lost —
389
+ // ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP, most visible right after a daemon
390
+ // restart clears the in-memory read pin). So only apply the recency cutoff in
391
+ // the floor-less (legacy/unpinned) discovery path, where it is the sole guard
392
+ // against binding an unrelated old store. When a floor is known the birth-time
393
+ // filter below is the authoritative, idle-agnostic owner check.
394
+ const applyRecencyCutoff = !(sessionFloorMs > 0);
344
395
  const recencyCutoff = Date.now() - RECENT_WINDOW_MS;
345
396
  const candidates: Array<{ path: string; uuid: string; mtime: number; birth: number }> = [];
346
397
  for (const entry of entries) {
@@ -352,7 +403,7 @@ function pickUnboundConversationDb(
352
403
  if (isAntigravityConversationClaimedByOther(uuid, owner)) continue;
353
404
  const p = path.join(convRoot, entry.name);
354
405
  const mtime = safeMtime(p);
355
- if (mtime < recencyCutoff) continue;
406
+ if (applyRecencyCutoff && mtime < recencyCutoff) continue;
356
407
  candidates.push({ path: p, uuid, mtime, birth: safeBirthtime(p) });
357
408
  }
358
409
  if (candidates.length === 0) return null;
@@ -298,6 +298,18 @@ export interface DaemonMetadataUpdate {
298
298
  userName?: string;
299
299
  seq: number;
300
300
  timestamp: number;
301
+ /**
302
+ * Per-mesh state-change revision counters (meshId → monotonically increasing
303
+ * integer), bumped whenever the daemon's mesh graph/queue/mission state for that
304
+ * mesh changes (onMeshStateChange). Lets the dashboard replace its client-side
305
+ * mesh_status polling with an event-driven background refresh: when the revision
306
+ * for the mesh it is viewing advances, it re-fetches the aggregate mesh_status
307
+ * (SWR, keeping the current graph on screen). This is a lightweight nudge — the
308
+ * full aggregate snapshot is fetched on demand, not embedded here, so the
309
+ * daemon.metadata payload stays small. Optional/absent for daemons/builds that
310
+ * don't emit it (the client then keeps its polling fallback).
311
+ */
312
+ meshStateRevisions?: Record<string, number>;
301
313
  }
302
314
 
303
315
  export interface TopicUpdateEnvelopeMap {