@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.
@@ -20,6 +20,7 @@ import { createCliAdapter } from './spec/route.js';
20
20
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
21
21
  import { StatusMonitor } from './status-monitor.js';
22
22
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
23
+ import { loadPersistedProviderSessionPins } from '../config/state-store.js';
23
24
  import { LOG } from '../logging/logger.js';
24
25
  import { recordDebugTrace } from '../logging/debug-trace.js';
25
26
  import { shouldCollectTraceCategory } from '../logging/debug-config.js';
@@ -572,7 +573,7 @@ export class CliProviderInstance implements ProviderInstance {
572
573
  const canonicalBackedHistory = this.shouldHydrateExistingProviderHistory()
573
574
  ? this.syncCanonicalSavedHistoryIfNeeded()
574
575
  : false;
575
- const statusMessages = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
576
+ const statusMessages: any[] = canonicalBackedHistory && this.lastPersistedHistoryMessages.length > 0
576
577
  ? this.lastPersistedHistoryMessages.map((message) => ({
577
578
  role: message.role,
578
579
  content: message.content,
@@ -582,6 +583,42 @@ export class CliProviderInstance implements ProviderInstance {
582
583
  }))
583
584
  : mergedMessages;
584
585
 
586
+ // Dashboard-tail repair (native-source providers, e.g. antigravity): the
587
+ // assistant answer lives only in native-history, so the PTY-parsed
588
+ // statusMessages end on the user prompt / auto-approve system lines and the
589
+ // snapshot's preview / lastMessageRole / completionMarker never see the
590
+ // answer — the session looks stuck on the user turn. We already cached the
591
+ // real final assistant summary at completion time (lastCompletionSummary),
592
+ // so append it as the trailing assistant bubble when the current tail has no
593
+ // assistant message at/after it. Purely additive to the status view; no
594
+ // per-tick native read, no effect on providers whose PTY carries the
595
+ // assistant (they surface it themselves and the guard below is a no-op).
596
+ const adapterOwnsMessagesElsewhereForTail = (this.adapter as any)?.chatMessagesOwnedExternally === true;
597
+ if (adapterOwnsMessagesElsewhereForTail && this.lastCompletionSummary) {
598
+ const summary = this.lastCompletionSummary;
599
+ let hasTrailingAssistant = false;
600
+ for (let i = statusMessages.length - 1; i >= 0; i -= 1) {
601
+ const m = statusMessages[i] as { role?: string; kind?: string; receivedAt?: number };
602
+ const role = typeof m?.role === 'string' ? m.role : '';
603
+ if (role === 'system') continue;
604
+ if (typeof m?.kind === 'string' && m.kind === 'tool') continue;
605
+ // First non-system/non-tool message from the tail: if it's already an
606
+ // assistant reply not older than our cached summary, the tail is fine.
607
+ hasTrailingAssistant = role === 'assistant'
608
+ && typeof m?.receivedAt === 'number'
609
+ && m.receivedAt >= summary.receivedAt - 1000;
610
+ break;
611
+ }
612
+ if (!hasTrailingAssistant) {
613
+ statusMessages.push({
614
+ role: 'assistant',
615
+ content: summary.content,
616
+ kind: 'standard',
617
+ receivedAt: summary.receivedAt,
618
+ });
619
+ }
620
+ }
621
+
585
622
  const dirName = workingDirBasename(this.workingDir);
586
623
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
587
624
  ? parsedStatus.status.trim()
@@ -1132,6 +1169,18 @@ export class CliProviderInstance implements ProviderInstance {
1132
1169
  private completedDebounceTimer: NodeJS.Timeout | null = null;
1133
1170
  private completedDebouncePending: CompletedDebouncePending | null = null;
1134
1171
  private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
1172
+ /**
1173
+ * The final assistant summary of the last completed turn, cached at
1174
+ * completion-emit time. For a native-source provider (antigravity) whose
1175
+ * assistant answer lives only in native-history — never in the PTY parse that
1176
+ * feeds activeChat.messages — the dashboard's preview / lastMessageRole /
1177
+ * completionMarker would otherwise never see the answer and show the session
1178
+ * stuck on the user prompt. getState() appends this cached assistant bubble to
1179
+ * the status messages when the PTY tail has none, so those fields reflect the
1180
+ * real last answer with ZERO per-tick native reads (the native read already ran
1181
+ * once at completion). Reset on the next turn's start.
1182
+ */
1183
+ private lastCompletionSummary: { content: string; receivedAt: number } | null = null;
1135
1184
 
1136
1185
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
1137
1186
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -1219,21 +1268,59 @@ export class CliProviderInstance implements ProviderInstance {
1219
1268
  private readExternalCompletionMessages(): unknown[] | null {
1220
1269
  const adapterOwnsMessagesElsewhere = (this.adapter as any)?.chatMessagesOwnedExternally === true;
1221
1270
  if (!adapterOwnsMessagesElsewhere) return null;
1222
- if (!this.providerSessionId) return null;
1223
1271
  if (!isNativeSourceCanonicalHistory(this.provider.nativeHistory)) return null;
1224
1272
 
1273
+ // Resolve a CONCRETE native-history handle for this session's OWN
1274
+ // conversation. A provider that exposes its session id on the CLI
1275
+ // (codex/claude/hermes) sets this.providerSessionId. antigravity takes no
1276
+ // --session-id, so this.providerSessionId stays empty — recover the real
1277
+ // on-disk conversation id from the read pin persisted across restart
1278
+ // (state.json sessionProviderSessionPins, keyed by this session's
1279
+ // instanceId — the same map a binding read_chat / mesh_read_chat records
1280
+ // the resolved uuid into). The OLD `if (!this.providerSessionId) return
1281
+ // null` guard blocked antigravity's completion transcript entirely, so its
1282
+ // final-assistant evidence was permanently 'unavailable' and the turn
1283
+ // completion never emitted → the mesh reconcile loop reclaimed the
1284
+ // "delivered but no completion" task and re-dispatched the same MAGI prompt
1285
+ // (ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP, completion side).
1286
+ //
1287
+ // Prefer a concrete handle (providerSessionId, else the persisted pin). When
1288
+ // neither exists yet — a fresh antigravity turn whose conversation has not
1289
+ // been bound by any read_chat — fall through with an EMPTY handle and let the
1290
+ // dispatcher resolve this session's own conversations/<uuid>.db by the
1291
+ // spawn-floor + workspace + instanceId claim. This is safe now that the
1292
+ // claim owner token is single-form iid:<instanceId> (never collapses to '')
1293
+ // and pickUnboundConversationDb picks the oldest store born at/after THIS
1294
+ // session's floor: the probe resolves the session's OWN db under its own
1295
+ // owner token, so it can never steal a sibling's conversation (the earlier
1296
+ // theft required a floor=0 / owner='' collapse that no longer happens). It
1297
+ // means the completion summary is available on the FIRST completion — before
1298
+ // any read_chat has recorded a pin — which the dashboard tail-repair needs.
1299
+ let resolvedHandle = this.providerSessionId || '';
1300
+ if (!resolvedHandle) {
1301
+ try {
1302
+ const pinned = loadPersistedProviderSessionPins()[this.instanceId];
1303
+ if (typeof pinned === 'string' && pinned.trim()) resolvedHandle = pinned.trim();
1304
+ } catch { /* best-effort pin hydration */ }
1305
+ }
1306
+
1225
1307
  if (this.lastExternalCompletionProbe?.sourcePath) {
1226
1308
  try { fs.statSync(this.lastExternalCompletionProbe.sourcePath); } catch { /* best-effort metadata refresh */ }
1227
1309
  }
1228
1310
  const restoredHistory = readProviderChatHistory(this.type, {
1229
1311
  canonicalHistory: this.provider.nativeHistory,
1230
- historySessionId: this.providerSessionId,
1312
+ historySessionId: resolvedHandle || undefined,
1231
1313
  workspace: this.workingDir,
1232
1314
  offset: 0,
1233
1315
  limit: Number.MAX_SAFE_INTEGER,
1234
1316
  historyBehavior: this.provider.historyBehavior,
1235
1317
  scripts: this.provider.scripts as any,
1236
1318
  sessionStartedAtMs: this.startedAt,
1319
+ // The claim owner token must match read_chat's so the exact-bind on our
1320
+ // own conversation stays idempotent rather than looking foreign, and so
1321
+ // the floor-based resolution above claims THIS session's db under its
1322
+ // own owner (never a sibling's).
1323
+ instanceId: this.instanceId,
1237
1324
  envOverrides: this.spawnedEnvOverrides(),
1238
1325
  forceRefresh: true,
1239
1326
  });
@@ -1249,6 +1336,27 @@ export class CliProviderInstance implements ProviderInstance {
1249
1336
  return restoredHistory.messages;
1250
1337
  }
1251
1338
 
1339
+ /**
1340
+ * The content of the LAST visible assistant bubble in a message list, or ''
1341
+ * when the tail is not an assistant reply. Skips trailing system/tool/activity
1342
+ * bubbles; stops (returns '') at the first user/human message. Used only for
1343
+ * the dashboard tail-repair cache — a display value, not a completion decision.
1344
+ */
1345
+ private lastVisibleAssistantSummary(messages: unknown): string {
1346
+ if (!Array.isArray(messages)) return '';
1347
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1348
+ const m = messages[i] as { role?: string; kind?: string; content?: unknown };
1349
+ const role = typeof m?.role === 'string' ? m.role : '';
1350
+ const kind = typeof m?.kind === 'string' ? m.kind : '';
1351
+ if (role === 'system') continue;
1352
+ if (kind === 'tool' || kind === 'activity') continue;
1353
+ if (role === 'user' || role === 'human') return '';
1354
+ if (role === 'assistant') return flattenContent(m.content as any).trim();
1355
+ return '';
1356
+ }
1357
+ return '';
1358
+ }
1359
+
1252
1360
  private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1253
1361
  // (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
1254
1362
  // pure message-content check ("does the last visible bubble read as a finalized assistant
@@ -1274,8 +1382,22 @@ export class CliProviderInstance implements ProviderInstance {
1274
1382
 
1275
1383
  const externalMessages = this.readExternalCompletionMessages();
1276
1384
  if (externalMessages) {
1385
+ const present = turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt);
1386
+ // Dashboard tail-repair cache: this runs on EVERY completion check
1387
+ // (mesh AND non-mesh — the non-mesh path suppresses the
1388
+ // generating_completed emit, so completionFinalSummary never runs there
1389
+ // and cannot cache). Cache whenever the external transcript's LAST
1390
+ // visible bubble is an assistant reply — this is a display value only, so
1391
+ // it is intentionally looser than the strict `present` completion gate
1392
+ // (which also requires turnClosed and turn-scoping): the dashboard should
1393
+ // show the answer as soon as native-history has it, even if the FSM has
1394
+ // not yet ratified the turn end. getState() replaces it on the next turn.
1395
+ const lastVisibleAssistant = this.lastVisibleAssistantSummary(externalMessages);
1396
+ if (lastVisibleAssistant) {
1397
+ this.lastCompletionSummary = { content: lastVisibleAssistant, receivedAt: Date.now() };
1398
+ }
1277
1399
  return {
1278
- present: turnClosed && this.completionHasFinalAssistantMessage(externalMessages, turnStartedAt),
1400
+ present,
1279
1401
  messages: externalMessages,
1280
1402
  source: 'external-native',
1281
1403
  };
@@ -1328,7 +1450,15 @@ export class CliProviderInstance implements ProviderInstance {
1328
1450
  // The transcript is authoritative for native-source providers. Use it unless it is
1329
1451
  // empty (not yet written, or no in-turn bubble) — only then fall back to the screen
1330
1452
  // parse, which reflects the LIVE screen (this turn's output), not the stale tail.
1331
- if (externalSummary) return externalSummary;
1453
+ if (externalSummary) {
1454
+ // Cache the resolved final assistant for the dashboard tail-repair in
1455
+ // getState(). This runs on EVERY completion attempt (including non-mesh
1456
+ // sessions whose generating_completed is suppressed, and short-gen
1457
+ // settle paths), so the dashboard sees the answer even when no
1458
+ // agent:generating_completed is ever emitted. Native read already ran.
1459
+ this.lastCompletionSummary = { content: externalSummary, receivedAt: Date.now() };
1460
+ return externalSummary;
1461
+ }
1332
1462
  return parsedSummary || undefined;
1333
1463
  }
1334
1464
  return parsedSummary || undefined;
@@ -1941,6 +2071,14 @@ export class CliProviderInstance implements ProviderInstance {
1941
2071
  evidenceLevel?: string;
1942
2072
  completionDiagnostic?: Record<string, unknown>;
1943
2073
  }): void {
2074
+ // Cache the final assistant summary so the dashboard snapshot can surface it
2075
+ // for native-source providers whose assistant answer is absent from the PTY
2076
+ // parse (antigravity). completionFinalSummary already read native-history to
2077
+ // produce this, so nothing extra is read here.
2078
+ const summary = typeof opts.finalSummary === 'string' ? opts.finalSummary.trim() : '';
2079
+ if (summary) {
2080
+ this.lastCompletionSummary = { content: summary, receivedAt: opts.timestamp };
2081
+ }
1944
2082
  this.pushEvent({
1945
2083
  event: 'agent:generating_completed',
1946
2084
  chatTitle: opts.chatTitle,
@@ -2465,6 +2603,11 @@ export class CliProviderInstance implements ProviderInstance {
2465
2603
  }
2466
2604
 
2467
2605
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
2606
+ // A genuinely new turn is underway — drop the previous turn's cached
2607
+ // final-summary so the dashboard does not keep showing the old answer
2608
+ // as "done" while the new turn generates. Re-populated when this turn
2609
+ // completes.
2610
+ this.lastCompletionSummary = null;
2468
2611
  // FALSE-IDLE continuity: entering a busy phase invalidates any
2469
2612
  // completedDebouncePending armed earlier in this settle window.
2470
2613
  this.busyEpoch++;
@@ -51,25 +51,32 @@ function normalizeUuid(uuid: string): string {
51
51
  }
52
52
 
53
53
  /**
54
- * Derive the per-session owner token. Both the dispatcher (from the read input:
55
- * workspace + sessionStartedAtMs) and the provider instance (from its
56
- * workingDir + startedAt, or its instanceId) call this with the same inputs so
57
- * claims and releases line up. Returns '' when there is no stable identity to
58
- * key on (e.g. a workspace-less discovery with no spawn time) — the caller then
59
- * skips claiming but the exclusion checks still run against existing claims.
54
+ * Derive the per-session owner token, keyed ONLY on the stable instanceId
55
+ * (== the session registry sessionId == the read path's targetSessionId). Both
56
+ * the dispatcher (read side) and the provider instance (claim/release side) pass
57
+ * this same id, so their tokens always agree and the claim isolation holds.
58
+ *
59
+ * Returns '' when no instanceId is available the caller then skips claiming
60
+ * (the exclusion checks still run against existing claims). This is the SSOT
61
+ * rule: there is exactly ONE token form. The removed legacy fallback derived a
62
+ * `spawn:<workspace>:<sessionStartedAtMs>` token from the spawn timestamp when
63
+ * the instanceId was missing; because one session's spawn time is sampled
64
+ * independently at three sites (instance startedAt, adapter spawnedAtMs, registry
65
+ * spawnedAtMs) those never matched, so the SAME session's instance-side and
66
+ * read-side tokens silently diverged and the claim mutual-exclusion collapsed
67
+ * (the antigravity conversation crosswire). An empty token (skip-claim) is
68
+ * strictly safer than a token that disagrees with the same session's other
69
+ * token. workspace/sessionStartedAtMs are kept in the signature for call-site
70
+ * compatibility but no longer affect the token.
60
71
  */
61
72
  export function antigravityOwnerToken(
62
73
  workspace: string,
63
74
  sessionStartedAtMs: number,
64
75
  instanceId?: string,
65
76
  ): string {
77
+ void workspace; void sessionStartedAtMs;
66
78
  const iid = typeof instanceId === 'string' ? instanceId.trim() : '';
67
- if (iid) return `iid:${iid}`;
68
- if (typeof sessionStartedAtMs === 'number' && sessionStartedAtMs > 0) {
69
- const ws = String(workspace || '').trim().toLowerCase();
70
- return `spawn:${ws}:${sessionStartedAtMs}`;
71
- }
72
- return '';
79
+ return iid ? `iid:${iid}` : '';
73
80
  }
74
81
 
75
82
  /**
@@ -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
@@ -276,24 +314,51 @@ function resolveAntigravityPath(
276
314
  }
277
315
 
278
316
  // (2) brain/<uuid>/.system_generated/logs/transcript.jsonl (legacy full source).
279
- // Only bind to a brain transcript that is NON-EMPTY: current antigravity
280
- // writes this file but leaves it 0 bytes (all real conversation data now
281
- // lives in the per-session .db), so an empty transcript here would
282
- // otherwise shadow the .db fallback below and return no messages. Skip
283
- // empty transcripts so an unbound read still reaches the .db. Exclude any
317
+ // Only bind to a brain transcript that is NON-EMPTY (antigravity may leave
318
+ // it 0 bytes when the real data lives in the per-session .db). Exclude any
284
319
  // brain conversation already claimed by a DIFFERENT live session.
320
+ //
321
+ // Selection MUST mirror pickUnboundConversationDb (step 3): when a spawn
322
+ // floor is known, a brain dir born at/after the floor is THIS session's own,
323
+ // and among those the OLDEST-created wins (the store created first after the
324
+ // session started). The previous newest-by-mtime sort silently mis-bound: in
325
+ // a MAGI panel every co-located antigravity session (coordinator + replicas)
326
+ // has a non-empty brain transcript, and the replica that finished its turn
327
+ // last has the newest mtime — so a coordinator's read grabbed the replica's
328
+ // transcript here, BEFORE step 3's floor-aware pick could run. That is the
329
+ // antigravity coordinator↔replica crosswire, and it lives in THIS step, not
330
+ // step 3. Keep newest-by-mtime only in the floor-less legacy path.
285
331
  const brainRoot = path.join(agyRoot, 'brain');
286
332
  if (fs.existsSync(brainRoot)) {
287
333
  const cutoff = spawnAwareCutoff(sessionStartedAtMs);
288
- const entries = fs.readdirSync(brainRoot, { withFileTypes: true })
334
+ const nonEmptyBrain = (uuid: string, p: string): string | null => {
335
+ const t = path.join(p, '.system_generated', 'logs', 'transcript.jsonl');
336
+ return (fs.existsSync(t) && safeSize(t) > 0) ? t : null;
337
+ };
338
+ const all = fs.readdirSync(brainRoot, { withFileTypes: true })
289
339
  .filter(e => e.isDirectory() && isUuidLikeSessionId(e.name))
290
340
  .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)) }))
292
- .filter(e => e.mtime >= cutoff)
293
- .sort((a, b) => b.mtime - a.mtime);
294
- for (const e of entries) {
295
- const t = path.join(e.p, '.system_generated', 'logs', 'transcript.jsonl');
296
- if (fs.existsSync(t) && safeSize(t) > 0) {
341
+ .map(e => {
342
+ const p = path.join(brainRoot, e.name);
343
+ return { uuid: e.name, p, mtime: safeMtime(p), birth: safeBirthtime(p) };
344
+ })
345
+ .filter(e => e.mtime >= cutoff);
346
+ let ordered: Array<{ uuid: string; p: string }> = [];
347
+ if (sessionStartedAtMs > 0) {
348
+ // Floor branch: this session's own = born at/after (floor - grace),
349
+ // oldest-birth first. Mirrors pickUnboundConversationDb exactly so the
350
+ // two steps can never resolve DIFFERENT conversations for one session.
351
+ const floor = sessionStartedAtMs - AGY_SPAWN_CLAIM_GRACE_MS;
352
+ ordered = all
353
+ .filter(e => (e.birth > 0 ? e.birth : e.mtime) >= floor)
354
+ .sort((a, b) => (a.birth || a.mtime) - (b.birth || b.mtime));
355
+ } else {
356
+ // Floor-less legacy/unpinned discovery: newest-by-mtime (single-session).
357
+ ordered = [...all].sort((a, b) => b.mtime - a.mtime);
358
+ }
359
+ for (const e of ordered) {
360
+ const t = nonEmptyBrain(e.uuid, e.p);
361
+ if (t) {
297
362
  if (owner) claimAntigravityConversation(e.uuid, owner);
298
363
  return t;
299
364
  }
@@ -341,6 +406,19 @@ function pickUnboundConversationDb(
341
406
  let entries: fs.Dirent[] = [];
342
407
  try { entries = fs.readdirSync(convRoot, { withFileTypes: true }); } catch { return null; }
343
408
 
409
+ // A known spawn floor already pins a candidate to THIS session by birth time
410
+ // (a store created at/after the session spawned is its own). Once that floor
411
+ // is available, the recency window is not just unnecessary but harmful: an
412
+ // antigravity session that has sat idle longer than RECENT_WINDOW_MS still
413
+ // owns its conversation .db, but the recency cutoff would drop it from the
414
+ // candidate set, collapsing the read to native_history_empty and forcing the
415
+ // dashboard onto the PTY parse (user echo only, assistant tail lost —
416
+ // ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP, most visible right after a daemon
417
+ // restart clears the in-memory read pin). So only apply the recency cutoff in
418
+ // the floor-less (legacy/unpinned) discovery path, where it is the sole guard
419
+ // against binding an unrelated old store. When a floor is known the birth-time
420
+ // filter below is the authoritative, idle-agnostic owner check.
421
+ const applyRecencyCutoff = !(sessionFloorMs > 0);
344
422
  const recencyCutoff = Date.now() - RECENT_WINDOW_MS;
345
423
  const candidates: Array<{ path: string; uuid: string; mtime: number; birth: number }> = [];
346
424
  for (const entry of entries) {
@@ -352,7 +430,7 @@ function pickUnboundConversationDb(
352
430
  if (isAntigravityConversationClaimedByOther(uuid, owner)) continue;
353
431
  const p = path.join(convRoot, entry.name);
354
432
  const mtime = safeMtime(p);
355
- if (mtime < recencyCutoff) continue;
433
+ if (applyRecencyCutoff && mtime < recencyCutoff) continue;
356
434
  candidates.push({ path: p, uuid, mtime, birth: safeBirthtime(p) });
357
435
  }
358
436
  if (candidates.length === 0) return null;
@@ -14,6 +14,19 @@ export interface SessionRuntimeTarget {
14
14
  /** Wall clock at register time. native-history readers use it as a
15
15
  * cutoff so a fresh session can't show records from a prior one. */
16
16
  spawnedAtMs?: number;
17
+ /**
18
+ * Authoritative provider-native conversation id for this session (SSOT).
19
+ * For providers that expose a session id on the CLI (codex/claude/hermes)
20
+ * this equals that id. For antigravity — which takes no --session-id — this
21
+ * is the on-disk conversations/<uuid>.db basename, discovered by the
22
+ * native-history dispatcher and written back here via setProviderSessionId
23
+ * the first time it resolves. Every downstream reader (read_chat, the
24
+ * completion probe, the dashboard) should prefer this over re-deriving the
25
+ * conversation by spawn-floor/mtime heuristics — that re-derivation is the
26
+ * source of the antigravity conversation crosswire/theft class. Empty until
27
+ * the first successful native read binds it.
28
+ */
29
+ providerSessionId?: string;
17
30
  }
18
31
 
19
32
  export class SessionRegistry {
@@ -23,7 +36,15 @@ export class SessionRegistry {
23
36
  private readonly byParentSessionId = new Map<string, Set<string>>();
24
37
 
25
38
  register(target: SessionRuntimeTarget): void {
39
+ // Preserve an already-resolved conversation binding across a
40
+ // re-register (attach-restore, meta refresh): the caller rarely knows
41
+ // the antigravity conv uuid at register time, so a plain replace would
42
+ // drop the SSOT binding and force a fresh (crosswire-prone) re-resolve.
43
+ const priorProviderSessionId = this.bySessionId.get(target.sessionId)?.providerSessionId;
26
44
  this.unregister(target.sessionId);
45
+ if (priorProviderSessionId && !target.providerSessionId) {
46
+ target = { ...target, providerSessionId: priorProviderSessionId };
47
+ }
27
48
  this.bySessionId.set(target.sessionId, target);
28
49
  if (target.cdpManagerKey) this.addIndex(this.byManagerKey, target.cdpManagerKey, target.sessionId);
29
50
  if (target.instanceKey) this.addIndex(this.byInstanceKey, target.instanceKey, target.sessionId);
@@ -35,6 +56,23 @@ export class SessionRegistry {
35
56
  return this.bySessionId.get(sessionId);
36
57
  }
37
58
 
59
+ /**
60
+ * Record the authoritative provider-native conversation id for a session
61
+ * (SSOT). Idempotent; a no-op when the session is unknown or the value is
62
+ * empty or unchanged. Never overwrites a known binding with an empty one.
63
+ * Returns whether the stored value changed.
64
+ */
65
+ setProviderSessionId(sessionId: string | undefined | null, providerSessionId: string | undefined | null): boolean {
66
+ const sid = typeof sessionId === 'string' ? sessionId.trim() : '';
67
+ const value = typeof providerSessionId === 'string' ? providerSessionId.trim() : '';
68
+ if (!sid || !value) return false;
69
+ const target = this.bySessionId.get(sid);
70
+ if (!target) return false;
71
+ if (target.providerSessionId === value) return false;
72
+ target.providerSessionId = value;
73
+ return true;
74
+ }
75
+
38
76
  unregister(sessionId: string | undefined | null): void {
39
77
  if (!sessionId) return;
40
78
  const target = this.bySessionId.get(sessionId);
@@ -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 {