@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.
- package/dist/commands/chat-commands-read.d.ts +8 -0
- package/dist/commands/chat-commands.d.ts +1 -1
- package/dist/config/state-store.d.ts +30 -0
- package/dist/index.js +231 -34
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +231 -34
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-event-forwarding.d.ts +8 -0
- package/dist/providers/cli-provider-instance.d.ts +19 -0
- package/dist/providers/native-history/antigravity-claim-registry.d.ts +17 -6
- package/dist/sessions/registry.d.ts +20 -0
- package/dist/shared-types.d.ts +12 -0
- package/package.json +3 -3
- package/src/commands/chat-commands-read.ts +114 -13
- package/src/commands/chat-commands.ts +1 -1
- package/src/config/state-store.ts +55 -0
- package/src/mesh/mesh-event-forwarding.ts +38 -0
- package/src/providers/cli-provider-instance.ts +148 -5
- package/src/providers/native-history/antigravity-claim-registry.ts +19 -12
- package/src/providers/native-history/dispatcher.ts +92 -14
- package/src/sessions/registry.ts +38 -0
- package/src/shared-types.ts +12 -0
|
@@ -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:
|
|
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
|
|
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)
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
|
280
|
-
//
|
|
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
|
|
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 =>
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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;
|
package/src/sessions/registry.ts
CHANGED
|
@@ -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);
|
package/src/shared-types.ts
CHANGED
|
@@ -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 {
|