@adhdev/daemon-core 0.9.82-rc.458 → 0.9.82-rc.459

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.
@@ -19,6 +19,11 @@ import { readSession as readCodexCliSession } from './codex-cli-transcript.js';
19
19
  import { readSession as readAntigravityCliSession } from './antigravity-cli-transcript.js';
20
20
  import { readSession as readHermesCliSession } from './hermes-cli-transcript.js';
21
21
  import { SPAWN_BIND_GRACE_MS } from './constants.js';
22
+ import {
23
+ antigravityOwnerToken,
24
+ claimAntigravityConversation,
25
+ isAntigravityConversationClaimedByOther,
26
+ } from './antigravity-claim-registry.js';
22
27
 
23
28
  export type ReaderId = 'claude-cli' | 'codex-cli' | 'antigravity-cli' | 'hermes-cli';
24
29
 
@@ -27,6 +32,10 @@ export interface NativeHistoryInput {
27
32
  sessionId?: string;
28
33
  providerSessionId?: string;
29
34
  historySessionId?: string;
35
+ /** Daemon instance id of the reading session. Used (with workspace +
36
+ * sessionStartedAtMs) to derive the antigravity conversation-claim owner
37
+ * token so two concurrent sessions never bind to the same .db. */
38
+ instanceId?: string;
30
39
  workspace?: string;
31
40
  sessionStartedAtMs?: number;
32
41
  format?: string;
@@ -60,7 +69,12 @@ export function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeH
60
69
  : typeof input.args?.sessionStartedAtMs === 'number'
61
70
  ? input.args.sessionStartedAtMs
62
71
  : 0;
63
- const sourcePath = resolveSourcePath(reader, workspace, sessionId, sessionStartedAtMs);
72
+ const instanceId = typeof input.instanceId === 'string'
73
+ ? input.instanceId
74
+ : typeof input.args?.instanceId === 'string'
75
+ ? (input.args.instanceId as string)
76
+ : '';
77
+ const sourcePath = resolveSourcePath(reader, workspace, sessionId, sessionStartedAtMs, instanceId);
64
78
  if (!sourcePath) return null;
65
79
  if (input.forceRefresh === true || input.args?.forceRefresh === true) {
66
80
  try { fs.statSync(sourcePath); } catch { /* best-effort metadata refresh */ }
@@ -93,11 +107,11 @@ export function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeH
93
107
  // Per-provider path resolution
94
108
  // ────────────────────────────────────────────────────────────────────────────
95
109
 
96
- function resolveSourcePath(reader: ReaderId, workspace: string, sessionId: string, sessionStartedAtMs: number): string | null {
110
+ function resolveSourcePath(reader: ReaderId, workspace: string, sessionId: string, sessionStartedAtMs: number, instanceId: string): string | null {
97
111
  switch (reader) {
98
112
  case 'claude-cli': return resolveClaudePath(workspace, sessionId);
99
113
  case 'codex-cli': return resolveCodexPath(workspace, sessionId, sessionStartedAtMs);
100
- case 'antigravity-cli': return resolveAntigravityPath(workspace, sessionId);
114
+ case 'antigravity-cli': return resolveAntigravityPath(workspace, sessionId, sessionStartedAtMs, instanceId);
101
115
  case 'hermes-cli': return resolveHermesPath(workspace, sessionId);
102
116
  }
103
117
  }
@@ -225,17 +239,40 @@ function resolveRealPath(value: string): string {
225
239
  try { return fs.realpathSync(value); } catch { return value; }
226
240
  }
227
241
 
228
- function resolveAntigravityPath(workspace: string, sessionId: string): string | null {
229
- void workspace;
230
- const agyRoot = path.join(os.homedir(), '.gemini', 'antigravity-cli');
242
+ /**
243
+ * The daemon may stamp a session's spawn time a hair before the CLI child
244
+ * actually creates its conversation .db, so treat a store born within this
245
+ * grace of the spawn floor as still belonging to this session. Kept small
246
+ * (< a typical concurrent-spawn gap) so a sibling's PRE-spawn store is still
247
+ * excluded rather than mis-bound.
248
+ */
249
+ const AGY_SPAWN_CLAIM_GRACE_MS = 2000;
231
250
 
232
- // (1) Exact session bind: current antigravity writes a per-session SQLite db
233
- // at conversations/<uuid>.db. If the caller knows the session id and the
234
- // db exists, bind straight to it — this is the authoritative source and
235
- // carries the assistant answers the brain/pty path was losing.
251
+ function resolveAntigravityPath(
252
+ workspace: string,
253
+ sessionId: string,
254
+ sessionStartedAtMs: number,
255
+ instanceId: string,
256
+ ): string | null {
257
+ const agyRoot = path.join(os.homedir(), '.gemini', 'antigravity-cli');
258
+ // Owner token identifies THIS reading session. The provider instance derives
259
+ // the identical token (workspace + startedAt, or instanceId) so it can
260
+ // release these claims on shutdown. '' when there is no stable identity to
261
+ // key on — claiming is then skipped but exclusion still runs.
262
+ const owner = antigravityOwnerToken(workspace, sessionStartedAtMs, instanceId);
263
+
264
+ // (1) Exact session bind + LOCK: current antigravity writes a per-session
265
+ // SQLite db at conversations/<uuid>.db. Once the caller knows the session
266
+ // id, bind straight to it — this is authoritative and never re-resolves
267
+ // by mtime, so an already-bound session cannot be hijacked by a newer
268
+ // .db on a later read. Claim it so a concurrent unbound sibling can never
269
+ // grab this same conversation.
236
270
  if (sessionId && isUuidLikeSessionId(sessionId)) {
237
271
  const dbPath = path.join(agyRoot, 'conversations', `${sessionId}.db`);
238
- if (fs.existsSync(dbPath)) return dbPath;
272
+ if (fs.existsSync(dbPath)) {
273
+ if (owner) claimAntigravityConversation(sessionId, owner);
274
+ return dbPath;
275
+ }
239
276
  }
240
277
 
241
278
  // (2) brain/<uuid>/.system_generated/logs/transcript.jsonl (legacy full source).
@@ -243,30 +280,108 @@ function resolveAntigravityPath(workspace: string, sessionId: string): string |
243
280
  // writes this file but leaves it 0 bytes (all real conversation data now
244
281
  // lives in the per-session .db), so an empty transcript here would
245
282
  // otherwise shadow the .db fallback below and return no messages. Skip
246
- // empty transcripts so an unbound read still reaches the .db.
283
+ // empty transcripts so an unbound read still reaches the .db. Exclude any
284
+ // brain conversation already claimed by a DIFFERENT live session.
247
285
  const brainRoot = path.join(agyRoot, 'brain');
248
286
  if (fs.existsSync(brainRoot)) {
249
- const cutoff = Date.now() - RECENT_WINDOW_MS;
287
+ const cutoff = spawnAwareCutoff(sessionStartedAtMs);
250
288
  const entries = fs.readdirSync(brainRoot, { withFileTypes: true })
251
- .filter(e => e.isDirectory())
252
- .map(e => ({ p: path.join(brainRoot, e.name), mtime: safeMtime(path.join(brainRoot, e.name)) }))
289
+ .filter(e => e.isDirectory() && isUuidLikeSessionId(e.name))
290
+ .filter(e => !isAntigravityConversationClaimedByOther(e.name, owner))
291
+ .map(e => ({ uuid: e.name, p: path.join(brainRoot, e.name), mtime: safeMtime(path.join(brainRoot, e.name)) }))
253
292
  .filter(e => e.mtime >= cutoff)
254
293
  .sort((a, b) => b.mtime - a.mtime);
255
294
  for (const e of entries) {
256
295
  const t = path.join(e.p, '.system_generated', 'logs', 'transcript.jsonl');
257
- if (fs.existsSync(t) && safeSize(t) > 0) return t;
296
+ if (fs.existsSync(t) && safeSize(t) > 0) {
297
+ if (owner) claimAntigravityConversation(e.uuid, owner);
298
+ return t;
299
+ }
258
300
  }
259
301
  }
260
302
 
261
- // (3) No brain transcript and no bound session id: fall back to the most
262
- // recently touched conversations/<uuid>.db (within the recency window).
303
+ // (3) No brain transcript and no bound session id: pick a conversations/<uuid>.db
304
+ // that is NOT claimed by another live session, guarded by this session's
305
+ // spawn time so we never mis-bind to a sibling's earlier store.
263
306
  const convRoot = path.join(agyRoot, 'conversations');
264
- const newestDb = newestRecentFile(convRoot, /^[0-9a-f-]+\.db$/i);
265
- if (newestDb) return newestDb;
307
+ const picked = pickUnboundConversationDb(convRoot, sessionStartedAtMs, owner);
308
+ if (picked) {
309
+ if (owner) claimAntigravityConversation(picked.uuid, owner);
310
+ return picked.path;
311
+ }
266
312
 
267
313
  return null;
268
314
  }
269
315
 
316
+ /**
317
+ * Choose the conversations/<uuid>.db for an as-yet-unbound antigravity session.
318
+ *
319
+ * Two isolation rules keep concurrent sessions apart:
320
+ * - claim exclusion: skip any .db already owned by a DIFFERENT live session,
321
+ * so two sessions can never resolve to the same conversation;
322
+ * - spawn-window guard: a session's own store is created at/after it spawned,
323
+ * so when a spawn floor is known, only stores born at/after (floor - grace)
324
+ * are eligible. A store that predates this session's spawn belongs to an
325
+ * earlier session and is never bound — if none qualifies we return null
326
+ * (native_history_empty) and let the caller retry once this session's own
327
+ * store appears, rather than binding a sibling's conversation.
328
+ *
329
+ * Among eligible unclaimed stores the OLDEST-created wins: in the normal case
330
+ * (each session polls native history only after its own store exists, and an
331
+ * earlier-spawned session polls earlier) this hands each session the first
332
+ * store created after it started — its own. When no spawn floor is available
333
+ * (legacy/unpinned discovery) we fall back to newest-by-mtime within the
334
+ * recency window, preserving the original single-session behaviour.
335
+ */
336
+ function pickUnboundConversationDb(
337
+ convRoot: string,
338
+ sessionFloorMs: number,
339
+ owner: string,
340
+ ): { path: string; uuid: string } | null {
341
+ let entries: fs.Dirent[] = [];
342
+ try { entries = fs.readdirSync(convRoot, { withFileTypes: true }); } catch { return null; }
343
+
344
+ const recencyCutoff = Date.now() - RECENT_WINDOW_MS;
345
+ const candidates: Array<{ path: string; uuid: string; mtime: number; birth: number }> = [];
346
+ for (const entry of entries) {
347
+ if (!entry.isFile()) continue;
348
+ const match = /^([0-9a-f-]+)\.db$/i.exec(entry.name);
349
+ if (!match || !isUuidLikeSessionId(match[1])) continue;
350
+ const uuid = match[1];
351
+ // (isolation) never consider a conversation a different live session owns.
352
+ if (isAntigravityConversationClaimedByOther(uuid, owner)) continue;
353
+ const p = path.join(convRoot, entry.name);
354
+ const mtime = safeMtime(p);
355
+ if (mtime < recencyCutoff) continue;
356
+ candidates.push({ path: p, uuid, mtime, birth: safeBirthtime(p) });
357
+ }
358
+ if (candidates.length === 0) return null;
359
+
360
+ if (sessionFloorMs > 0) {
361
+ const floor = sessionFloorMs - AGY_SPAWN_CLAIM_GRACE_MS;
362
+ const own = candidates.filter(c => (c.birth > 0 ? c.birth : c.mtime) >= floor);
363
+ // A store created before this session spawned belongs to an earlier
364
+ // session — do NOT bind to it. Wait for our own store on the next read.
365
+ if (own.length === 0) return null;
366
+ own.sort((a, b) => (a.birth || a.mtime) - (b.birth || b.mtime));
367
+ return { path: own[0].path, uuid: own[0].uuid };
368
+ }
369
+
370
+ candidates.sort((a, b) => b.mtime - a.mtime);
371
+ return { path: candidates[0].path, uuid: candidates[0].uuid };
372
+ }
373
+
374
+ /**
375
+ * mtime floor for the brain-transcript scan: the later of the recency window
376
+ * and this session's spawn floor (minus the claim grace), so a fresh read
377
+ * cannot surface a brain dir last touched before this session started.
378
+ */
379
+ function spawnAwareCutoff(sessionStartedAtMs: number): number {
380
+ const recency = Date.now() - RECENT_WINDOW_MS;
381
+ if (sessionStartedAtMs > 0) return Math.max(recency, sessionStartedAtMs - AGY_SPAWN_CLAIM_GRACE_MS);
382
+ return recency;
383
+ }
384
+
270
385
  function resolveHermesPath(workspace: string, sessionId: string): string | null {
271
386
  void workspace; void sessionId;
272
387
  // Hermes ≥ 0.14 persists all chat to ~/.hermes/state.db (SQLite). The
@@ -359,6 +474,21 @@ function safeMtime(p: string): number {
359
474
  try { return Math.floor(fs.statSync(p).mtimeMs); } catch { return 0; }
360
475
  }
361
476
 
477
+ /**
478
+ * File creation time in ms. birthtimeMs is high-precision on all shipped
479
+ * runners (NTFS/ext4/APFS), and a conversation .db is created by the CLI child
480
+ * strictly AFTER the daemon spawned it, so birthtime > the session's spawn
481
+ * floor for its own store. Falls back to mtime when birthtime is unavailable
482
+ * (0 / not tracked) so the caller still has a usable ordering key.
483
+ */
484
+ function safeBirthtime(p: string): number {
485
+ try {
486
+ const st = fs.statSync(p);
487
+ const birth = Math.floor(st.birthtimeMs);
488
+ return birth > 0 ? birth : Math.floor(st.mtimeMs);
489
+ } catch { return 0; }
490
+ }
491
+
362
492
  function safeSize(p: string): number {
363
493
  try { return fs.statSync(p).size; } catch { return 0; }
364
494
  }