@adhdev/daemon-core 0.9.82-rc.475 → 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.
@@ -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.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.475",
51
- "@adhdev/session-host-core": "0.9.82-rc.475",
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;
@@ -1768,10 +1816,31 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1768
1816
  let nativeHistory: any | null = null;
1769
1817
  let nativeHistoryError: unknown | undefined;
1770
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;
1771
1840
  try {
1772
1841
  nativeHistory = readCliProviderNativeHistory(agentStr, {
1773
1842
  canonicalHistory: provider?.nativeHistory,
1774
- historySessionId: nativeHistoryReadSessionId,
1843
+ historySessionId: effectiveNativeReadSessionId,
1775
1844
  workspace,
1776
1845
  offset: 0,
1777
1846
  limit: nativeHistoryLimit,
@@ -1784,11 +1853,11 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1784
1853
  // Stable per-session identity for antigravity's conversation-claim
1785
1854
  // owner token (== session registry sessionId == instance instanceId).
1786
1855
  instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
1787
- pinnedProviderSessionId: getBoundProviderSessionIdPin(targetSessionId),
1856
+ pinnedProviderSessionId: pinnedProviderSessionIdForRead,
1788
1857
  // Last-resort only when no pin was ever recorded for this
1789
1858
  // session; the downstream workspace-overlap safety gate
1790
1859
  // still filters an aliased session out.
1791
- allowWorkspaceLatestFallback: !getBoundProviderSessionIdPin(targetSessionId),
1860
+ allowWorkspaceLatestFallback: !pinnedProviderSessionIdForRead,
1792
1861
  });
1793
1862
  // Refresh the per-mesh-session pin whenever a native read
1794
1863
  // resolves a concrete provider-native session id. A later
@@ -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
+ }
@@ -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 {