@adhdev/daemon-core 0.9.82-rc.479 → 0.9.82-rc.480

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.
@@ -27,5 +27,18 @@ export interface NativeHistoryResult {
27
27
  sourcePath: string;
28
28
  sourceMtimeMs: number;
29
29
  nativeHistoryCoverage?: 'full' | 'partial' | 'best-effort';
30
+ /**
31
+ * True only when the resolved conversation is confirmed to belong to THIS
32
+ * reading session by the owner-token identity (an exact uuid bind, or a
33
+ * spawn-floor/birth-time pick born after this session started). False for a
34
+ * bare recency/newest-by-mtime pick made with no spawn floor — that pick may
35
+ * alias a co-located concurrent session (the antigravity coordinator↔replica
36
+ * crosswire), so its uuid must NEVER be recorded as a pin nor trusted to
37
+ * satisfy the same-pass safe-mapping identity check. Read-path callers gate
38
+ * the workspace-latest pin + first-read trust on this flag. Non-antigravity
39
+ * readers leave it undefined (their existing exact-file resolution is
40
+ * unaffected by this signal).
41
+ */
42
+ ownerConfirmed?: boolean;
30
43
  }
31
44
  export declare function createNativeHistoryDispatcher(reader: ReaderId): (input: NativeHistoryInput) => NativeHistoryResult | null;
@@ -16,7 +16,47 @@ export declare function classifyHotChatSessionsForSubscriptionFlush(sessions: Ho
16
16
  recentMessageGraceMs?: number;
17
17
  activeStatuses?: ReadonlySet<string>;
18
18
  activeSessionIds?: ReadonlySet<string>;
19
+ /**
20
+ * Per-session `lastMessageAt` of the most recent completion tail that has
21
+ * already been flushed to subscribers. A completed-but-unseen session is
22
+ * kept hot for delivery REGARDLESS of the 8s recency timer, but only until
23
+ * its current tail has been delivered once — bounding the guaranteed
24
+ * delivery so a slow-finalizing turn is not re-pushed every tick forever.
25
+ * A newer `lastMessageAt` (fresh turn) re-arms delivery.
26
+ */
27
+ deliveredCompletionTailAt?: ReadonlyMap<string, number>;
28
+ /**
29
+ * (D8) PER-SUBSCRIPTION ACK gate — the authoritative guaranteed-delivery
30
+ * signal. Session ids for which at least one currently-subscribed ws has
31
+ * NOT yet been sent the session's finalized tail (its per-ws
32
+ * `lastDeliveredSignature` is empty / does not match the session's current
33
+ * tail). The daemon computes this from its live ws-subscription map; the
34
+ * classifier keeps every such session hot-for-delivery INDEPENDENT of the
35
+ * seen/unread badge, so a session whose unread badge cleared (or a fresh
36
+ * browser that re-subscribed after the single completion flush already
37
+ * fired) still gets its assistant-final tail delivered. Bounded because a
38
+ * subscriber's `lastDeliveredSignature` converges to the current tail the
39
+ * moment it is actually sent, dropping the session out of this set. When
40
+ * provided, this set is the guaranteed-delivery driver and the
41
+ * `deliveredCompletionTailAt` recency-watermark path is bypassed for these
42
+ * sessions (it stays only as the legacy opt-in for callers that don't pass
43
+ * a per-subscription set).
44
+ */
45
+ underDeliveredSessionIds?: ReadonlySet<string>;
19
46
  }): {
20
47
  active: Set<string>;
21
48
  finalizing: Set<string>;
49
+ guaranteedDelivery: Set<string>;
50
+ };
51
+ /**
52
+ * Detect sessions that just transitioned from an active/generating state into
53
+ * a settled/completed-but-unseen state, so their finalized completion tail can
54
+ * be flushed exactly once regardless of the recency window. Pure: the caller
55
+ * owns the `previousStatus` map and updates it from the returned `nextStatus`.
56
+ */
57
+ export declare function detectNewlySettledCompletedSessions(sessions: HotChatSessionLike[], previousStatus: ReadonlyMap<string, string>, options?: {
58
+ activeStatuses?: ReadonlySet<string>;
59
+ }): {
60
+ settled: Set<string>;
61
+ nextStatus: Map<string, string>;
22
62
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.479",
3
+ "version": "0.9.82-rc.480",
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.479",
51
- "@adhdev/session-host-core": "0.9.82-rc.479",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.480",
51
+ "@adhdev/session-host-core": "0.9.82-rc.480",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -458,6 +458,42 @@ export class CliStateEngine {
458
458
  this.idleFinishCandidate = null;
459
459
  }
460
460
 
461
+ /**
462
+ * Poll-driven static-idle confirm (D4). A hosted CLI session (e.g. a fresh
463
+ * antigravity coordinator) whose boot banner drove the FSM into 'generating'
464
+ * can then sit at a STATIC ready prompt emitting no further PTY output. Every
465
+ * output-driven busy→idle re-eval (handleOutput/resolveStartupState/settle)
466
+ * is starved because there is no new output, and the startup-settle loop has
467
+ * hard-stopped past spawnAt+10s — so currentStatus stays frozen at generating
468
+ * and the dashboard disables Send. This is the ONE path that can release that
469
+ * wedge from the read-only status poll.
470
+ *
471
+ * Safety: this must NEVER flip a real generating turn to idle. The gate is
472
+ * done by the caller (getStatus) reusing resolveStartupState's proven
473
+ * predicates: no recent PTY output for a grace window, runDetectStatus of the
474
+ * current screen === 'idle', and no active/parsed modal. Here we add the
475
+ * final structural guard: there must be NO active turn scope. A live user
476
+ * turn always carries a currentTurnScope (set in onTurnStarted), so this only
477
+ * releases the boot-banner wedge and the post-turn static-idle case, both of
478
+ * which have already had their scope nulled. Returns true when it transitioned.
479
+ */
480
+ confirmPollStaticIdle(reason: string): boolean {
481
+ if (this.currentStatus !== 'generating') return false;
482
+ if (this.currentTurnScope || this.activeModal) return false;
483
+ this.clearAllTimers();
484
+ this.clearIdleFinishCandidate(reason);
485
+ this.isWaitingForResponse = false;
486
+ this.responseSettleIgnoreUntil = 0;
487
+ this.submitRetryUsed = false;
488
+ this.submitRetryPromptSnippet = '';
489
+ this.finishRetryCount = 0;
490
+ this.currentTurnScope = null;
491
+ this.activeModal = null;
492
+ this.setStatus('idle', reason);
493
+ this.recordTrace('poll_static_idle_confirmed', { reason });
494
+ return true;
495
+ }
496
+
461
497
  hasActionableApproval(startupModal?: { message: string; buttons: string[] } | null): boolean {
462
498
  return !!(startupModal ?? this.activeModal);
463
499
  }
@@ -926,6 +926,46 @@ export class ProviderCliAdapter implements CliAdapter {
926
926
  const startupDetectedStatus = allowParse && this.startupParseGate && !startupModal
927
927
  ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
928
928
  : null;
929
+ // (D4) Poll-driven static-idle confirm. A hosted CLI session whose boot
930
+ // banner drove the FSM to 'generating' can then sit at a STATIC ready
931
+ // prompt emitting NO further PTY output — every output-driven busy→idle
932
+ // re-eval is starved and the startup-settle loop has hard-stopped past
933
+ // spawnAt+10s, so currentStatus stays frozen at generating and the
934
+ // dashboard disables Send. This read-path poll is the only place that can
935
+ // release the wedge. Gate PRECISELY (reuse resolveStartupState's proven
936
+ // predicates so a real generating turn is NEVER mis-flipped):
937
+ // (a) no recent output for >= statusActivityHold (the 2000ms stable
938
+ // window resolveStartupState uses),
939
+ // (b) runDetectStatus(current screen) === 'idle' (same detector),
940
+ // (c) no active/parsed modal — an approval/choice screen is never
941
+ // flipped to idle.
942
+ // A real generating turn keeps producing output (fresh
943
+ // lastNonEmptyOutputAt) and/or shows 'esc to cancel' (detects busy) → it
944
+ // fails (a) or (b). confirmPollStaticIdle adds the final structural guard
945
+ // (currentStatus==='generating' AND no currentTurnScope/activeModal), so
946
+ // this only releases the boot-banner wedge and the post-turn static-idle
947
+ // case. Direct-spawn sessions already settle idle in the startup window
948
+ // → this is a no-op for them.
949
+ if (
950
+ allowParse
951
+ && this.engine.currentStatus === 'generating'
952
+ && !this.engine.currentTurnScope
953
+ && !this.engine.activeModal
954
+ ) {
955
+ const now = Date.now();
956
+ const quietForMs = this.lastNonEmptyOutputAt
957
+ ? (now - this.lastNonEmptyOutputAt)
958
+ : Number.MAX_SAFE_INTEGER;
959
+ if (quietForMs >= this.getStatusActivityHoldMs()) {
960
+ const screenText = this.terminalScreen.getText();
961
+ const pollDetect = this.runDetectStatus(screenText || this.recentOutputBuffer);
962
+ const pollModal = this.runParseApproval(screenText)
963
+ || this.runParseApproval(this.recentOutputBuffer);
964
+ if (pollDetect === 'idle' && !pollModal) {
965
+ this.engine.confirmPollStaticIdle('poll_static_idle');
966
+ }
967
+ }
968
+ }
929
969
  let effectiveStatus = this.projectEffectiveStatus(startupModal);
930
970
  let effectiveModal = startupModal || this.engine.activeModal;
931
971
  // (fix) When we have no captured modal yet, take one more live attempt
@@ -134,6 +134,17 @@ export function __resetProviderSessionPinsForTest(): void {
134
134
  try { clearPersistedProviderSessionPins(); } catch { /* best-effort */ }
135
135
  }
136
136
 
137
+ /**
138
+ * Test-only: read the in-memory read-pin (the mesh-session → conversation-uuid
139
+ * bind recorded by recordBoundProviderSessionId and mirrored to state.json
140
+ * sessionProviderSessionPins). Lets the antigravity-coordinator-pin tests assert
141
+ * that an owner-confirmed workspace-latest read recorded the pin — and that a
142
+ * non-owner-confirmed read did NOT. Not part of the runtime contract.
143
+ */
144
+ export function __getProviderSessionPinForTest(meshSessionId: string): string | undefined {
145
+ return getBoundProviderSessionIdPin(meshSessionId);
146
+ }
147
+
137
148
  const warnedLegacyNativeAllowlistHits = new Set<string>();
138
149
  function warnLegacyNativeAllowlistHit(providerType: string): void {
139
150
  if (warnedLegacyNativeAllowlistHits.has(providerType)) return;
@@ -155,6 +166,35 @@ function getExplicitHistorySessionId(args: any): string | undefined {
155
166
 
156
167
  return undefined;
157
168
  }
169
+
170
+ /**
171
+ * A native-history session id is a "runtime fallback" — the daemon's own
172
+ * ADHDev session id (targetSessionId) standing in for a real provider-native
173
+ * conversation uuid — when it exactly equals targetSessionId. For an
174
+ * antigravity coordinator (agy takes no --session-id, so its providerSessionId
175
+ * never surfaces to the web), getConversationHistorySessionId falls back to the
176
+ * ADHDev sessionId, and the browser then sends that runtime id back as
177
+ * args.historySessionId. That id is NOT the on-disk conversations/<uuid>.db
178
+ * name (e.g. targetSessionId 28c530af vs stamped conv uuid 07f6ed3e), so a
179
+ * native read keyed on it can never exact-bind — it fail-closes to pty-parser
180
+ * (user-echo only) AND bypasses the owner-confirmed pin/live-bind resolution
181
+ * (which only runs when historySessionId is empty). Detect it whether it
182
+ * arrived EXPLICITLY (args.historySessionId === targetSessionId, the browser's
183
+ * poisoned read) OR only via getHistorySessionId's internal fallback (empty
184
+ * args), and in both cases treat historySessionId as ABSENT so the owner-
185
+ * confirmed native resolution engages and returns [user, assistant, ...].
186
+ * A REAL, DISTINCT provider conv uuid (≠ targetSessionId) is never a runtime
187
+ * fallback and must still exact-bind as before.
188
+ */
189
+ function isRuntimeFallbackHistorySessionId(
190
+ candidateHistorySessionId: string | undefined,
191
+ targetSessionId: string | undefined,
192
+ ): boolean {
193
+ const target = typeof targetSessionId === 'string' ? targetSessionId.trim() : '';
194
+ if (!target) return false;
195
+ const candidate = typeof candidateHistorySessionId === 'string' ? candidateHistorySessionId.trim() : '';
196
+ return candidate === target;
197
+ }
158
198
  function getHistorySessionId(h: CommandHelpers, args: any): string | undefined {
159
199
  const explicit = getExplicitHistorySessionId(args);
160
200
  if (explicit) return explicit;
@@ -1612,15 +1652,34 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
1612
1652
  : typeof (h.currentSession as any)?.workspace === 'string'
1613
1653
  ? (h.currentSession as any).workspace
1614
1654
  : undefined;
1655
+ // Same runtime-fallback poison guard as the subscribe / history-only
1656
+ // paths: getHistorySessionId falls back to targetSessionId (the ADHDev
1657
+ // id) for an agy coordinator, and the browser may also send that id back
1658
+ // explicitly. Reading native history keyed on it can never exact-bind
1659
+ // (it is not the on-disk conv uuid). Drop it here too so the pin /
1660
+ // workspace-latest / owner-confirmed resolution engages instead of
1661
+ // fail-closing to pty-parser. A real DISTINCT provider uuid is preserved.
1662
+ const targetSidForHistory = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
1663
+ const explicitHistorySessionIdForHistory = getExplicitHistorySessionId(args);
1664
+ const historySessionIdIsRuntimeFallback = Boolean(
1665
+ targetSidForHistory
1666
+ && isRuntimeFallbackHistorySessionId(historySessionId, targetSidForHistory)
1667
+ && (!explicitHistorySessionIdForHistory
1668
+ || isRuntimeFallbackHistorySessionId(explicitHistorySessionIdForHistory, targetSidForHistory)),
1669
+ );
1670
+ const pinnedProviderSessionIdForHistory = getBoundProviderSessionIdPin(args?.targetSessionId);
1671
+ const effectiveHistorySessionId = historySessionIdIsRuntimeFallback
1672
+ ? (pinnedProviderSessionIdForHistory || undefined)
1673
+ : historySessionId;
1615
1674
  const exactNativeHistoryScope = Boolean(
1616
1675
  (typeof args?.targetSessionId === 'string' && args.targetSessionId.trim())
1617
- || (typeof args?.historySessionId === 'string' && args.historySessionId.trim())
1676
+ || (typeof args?.historySessionId === 'string' && args.historySessionId.trim() && !historySessionIdIsRuntimeFallback)
1618
1677
  || (typeof args?.providerSessionId === 'string' && args.providerSessionId.trim())
1619
1678
  );
1620
1679
  const result = supportsCliNativeTranscript(agentStr, provider) && isNativeSourceCanonicalHistory(provider?.nativeHistory)
1621
1680
  ? readCliProviderNativeHistory(agentStr, {
1622
1681
  canonicalHistory: provider?.nativeHistory,
1623
- historySessionId,
1682
+ historySessionId: effectiveHistorySessionId,
1624
1683
  workspace,
1625
1684
  offset: offset || 0,
1626
1685
  limit: limit || 30,
@@ -1630,7 +1689,8 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
1630
1689
  sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
1631
1690
  envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
1632
1691
  instanceId: effectiveReadSessionId(h, args?.targetSessionId) || undefined,
1633
- pinnedProviderSessionId: getBoundProviderSessionIdPin(args?.targetSessionId),
1692
+ pinnedProviderSessionId: pinnedProviderSessionIdForHistory,
1693
+ allowWorkspaceLatestFallback: !pinnedProviderSessionIdForHistory && historySessionIdIsRuntimeFallback,
1634
1694
  })
1635
1695
  : readProviderChatHistory(agentStr, {
1636
1696
  canonicalHistory: provider?.nativeHistory,
@@ -1649,13 +1709,26 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
1649
1709
  : [];
1650
1710
  const historyProviderSessionId = typeof (result as any)?.providerSessionId === 'string'
1651
1711
  ? (result as any).providerSessionId
1652
- : readHistorySessionIdFromMessages(messages) || historySessionId;
1653
- if (typeof (result as any)?.providerSessionId === 'string' && (result as any).providerSessionId.trim()) {
1654
- recordBoundProviderSessionId(h, effectiveReadSessionId(h, args?.targetSessionId), (result as any).providerSessionId.trim());
1712
+ : readHistorySessionIdFromMessages(messages) || effectiveHistorySessionId;
1713
+ // Mirror of the subscribe path (see handleReadChat): an antigravity
1714
+ // workspace-latest read still surfaces the on-disk uuid, but that uuid is
1715
+ // only safe to persist / trust when it was OWNER-token-confirmed as this
1716
+ // session's own — a bare recency pick could be a co-located replica's
1717
+ // conversation. Gate the pin and the same-pass identity on ownerConfirmed.
1718
+ const resolvedProviderSessionId = typeof (result as any)?.providerSessionId === 'string'
1719
+ ? (result as any).providerSessionId.trim()
1720
+ : '';
1721
+ const resultLookupIsWorkspace = lookup === 'workspace';
1722
+ const resultOwnerConfirmed = (result as any)?.ownerConfirmed === true;
1723
+ const ownerConfirmedUuid = resultOwnerConfirmed && typeof historyProviderSessionId === 'string' && historyProviderSessionId.trim()
1724
+ ? historyProviderSessionId.trim()
1725
+ : '';
1726
+ if (resolvedProviderSessionId && (!resultLookupIsWorkspace || resultOwnerConfirmed)) {
1727
+ recordBoundProviderSessionId(h, effectiveReadSessionId(h, args?.targetSessionId), resolvedProviderSessionId);
1655
1728
  }
1656
1729
  const safeMapping = hasSafeNativeHistoryMapping({
1657
- historySessionId: lookup === 'workspace' ? undefined : historySessionId,
1658
- providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId,
1730
+ historySessionId: ownerConfirmedUuid || (lookup === 'workspace' ? undefined : effectiveHistorySessionId),
1731
+ providerSessionId: ownerConfirmedUuid || (lookup === 'workspace' ? undefined : historyProviderSessionId),
1659
1732
  workspace,
1660
1733
  nativeMessages: messages,
1661
1734
  });
@@ -1861,10 +1934,18 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1861
1934
  // id so readCliProviderNativeHistory's pin / workspace-latest paths can
1862
1935
  // engage. Mirrors the handleChatHistory path's established handling.
1863
1936
  const pinnedProviderSessionIdForRead = getBoundProviderSessionIdPin(targetSessionId);
1937
+ // Runtime fallback whether the runtime id was reached via
1938
+ // getHistorySessionId's internal fallback (empty args) OR the
1939
+ // browser explicitly sent historySessionId === targetSessionId
1940
+ // (the poisoned agy-coordinator read). Both must drop the
1941
+ // runtime id so pin / live-bind resolution engages; only a real
1942
+ // DISTINCT provider uuid stays as an exact-bind id.
1943
+ const explicitHistorySessionIdForRead = getExplicitHistorySessionId(args);
1864
1944
  const nativeReadSessionIdIsRuntimeFallback = Boolean(
1865
1945
  targetSessionId
1866
- && nativeHistoryReadSessionId === targetSessionId
1867
- && !getExplicitHistorySessionId(args),
1946
+ && isRuntimeFallbackHistorySessionId(nativeHistoryReadSessionId, targetSessionId)
1947
+ && (!explicitHistorySessionIdForRead
1948
+ || isRuntimeFallbackHistorySessionId(explicitHistorySessionIdForRead, targetSessionId)),
1868
1949
  );
1869
1950
  const effectiveNativeReadSessionId = nativeReadSessionIdIsRuntimeFallback
1870
1951
  ? (pinnedProviderSessionIdForRead || undefined)
@@ -1899,7 +1980,23 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1899
1980
  const resolvedProviderSessionId = typeof nativeHistory?.providerSessionId === 'string'
1900
1981
  ? nativeHistory.providerSessionId.trim()
1901
1982
  : '';
1902
- if (resolvedProviderSessionId) {
1983
+ // Pin gating for the antigravity workspace-latest branch: a
1984
+ // coordinator session has no pin (agy takes no --session-id) and
1985
+ // spawnedAtMs=0 after attach-restore, so the read resolves via the
1986
+ // workspace-latest fallback (lookup === 'workspace') rather than an
1987
+ // exact bind. The dispatcher STILL surfaces the on-disk conversation
1988
+ // uuid there — but that uuid is only safe to persist as a pin when it
1989
+ // was OWNER-token-confirmed (exact uuid bind, or a spawn-floor/birth
1990
+ // pick). A bare recency/newest-by-mtime pick (ownerConfirmed=false)
1991
+ // could be a co-located replica's conversation, so recording it would
1992
+ // hard-wire the coordinator↔replica crosswire permanently — never pin
1993
+ // that. Exact-bind / session-scoped reads (lookup === 'session') are
1994
+ // already owner-scoped by construction, so keep pinning them as before.
1995
+ const resolvedLookupIsWorkspace = (nativeHistory as any)?.lookup === 'workspace';
1996
+ const nativeOwnerConfirmed = (nativeHistory as any)?.ownerConfirmed === true;
1997
+ const mayPinResolvedProviderSessionId = resolvedProviderSessionId
1998
+ && (!resolvedLookupIsWorkspace || nativeOwnerConfirmed);
1999
+ if (mayPinResolvedProviderSessionId) {
1903
2000
  recordBoundProviderSessionId(h, effectiveReadSessionId(h, targetSessionId), resolvedProviderSessionId);
1904
2001
  }
1905
2002
  } catch (error: any) {
@@ -1918,20 +2015,44 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1918
2015
  ? nativeHistory.providerSessionId
1919
2016
  : readHistorySessionIdFromMessages(nativeMessages) || nativeHistoryReadSessionId || historySessionId;
1920
2017
  let lookup = nativeHistory?.lookup === 'workspace' ? 'workspace' : 'session';
1921
- let nativeHistorySessionForMapping = adapter.cliType === 'antigravity-cli'
1922
- && historyProviderSessionId
1923
- && nativeHistoryReadSessionId
1924
- && historyProviderSessionId !== nativeHistoryReadSessionId
1925
- ? undefined
1926
- : nativeHistoryReadSessionId;
2018
+ // Owner-confirmed uuid for THIS read (antigravity): the dispatcher
2019
+ // resolved a conversation and confirmed it is this session's own via the
2020
+ // owner token (exact uuid bind, or a spawn-floor/birth pick) — NOT a bare
2021
+ // recency pick. When present it is the authoritative conversation
2022
+ // identity for the same-pass safe-mapping check below, even on a
2023
+ // workspace-latest (lookup === 'workspace') read where the coordinator
2024
+ // has no pin. A coordinator session hits this path (agy takes no
2025
+ // --session-id, spawnedAtMs=0 after attach-restore); without it the
2026
+ // safe-mapping check saw undefined identity → workspace-overlap branch →
2027
+ // the PTY snapshot has only the user echo → fail-closed → regress to
2028
+ // pty-parser (user-echo only). Trusting the owner-confirmed uuid lets the
2029
+ // assistant answer reach the dashboard on the FIRST read.
2030
+ const ownerConfirmedUuid = adapter.cliType === 'antigravity-cli'
2031
+ && (nativeHistory as any)?.ownerConfirmed === true
2032
+ && typeof historyProviderSessionId === 'string'
2033
+ && historyProviderSessionId.trim()
2034
+ ? historyProviderSessionId.trim()
2035
+ : '';
2036
+ let nativeHistorySessionForMapping = ownerConfirmedUuid
2037
+ ? ownerConfirmedUuid
2038
+ : adapter.cliType === 'antigravity-cli'
2039
+ && historyProviderSessionId
2040
+ && nativeHistoryReadSessionId
2041
+ && historyProviderSessionId !== nativeHistoryReadSessionId
2042
+ ? undefined
2043
+ : nativeHistoryReadSessionId;
2044
+ // For an owner-confirmed uuid, feed the uuid as the explicit session
2045
+ // identity to the safe-mapping check even on a workspace-latest read so
2046
+ // the session-branch identity test runs uuid-to-uuid (messages carry the
2047
+ // uuid as historySessionId) and trusts the assistant in this same pass.
1927
2048
  let safeMapping = supportsNative && nativeHistory
1928
2049
  ? hasSafeNativeHistoryMapping({
1929
- historySessionId: lookup === 'workspace' ? undefined : nativeHistorySessionForMapping,
1930
- providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId || providerSessionId,
2050
+ historySessionId: ownerConfirmedUuid || (lookup === 'workspace' ? undefined : nativeHistorySessionForMapping),
2051
+ providerSessionId: ownerConfirmedUuid || (lookup === 'workspace' ? undefined : historyProviderSessionId || providerSessionId),
1931
2052
  workspace,
1932
2053
  nativeMessages,
1933
2054
  ptyMessages: returnedMessages,
1934
- requireWorkspaceContentOverlap: lookup === 'workspace' && !exactNativeHistoryScope,
2055
+ requireWorkspaceContentOverlap: lookup === 'workspace' && !exactNativeHistoryScope && !ownerConfirmedUuid,
1935
2056
  })
1936
2057
  : false;
1937
2058
  if (skipLiveNativeHistoryWithoutProviderSession && (!safeMapping || returnedMessages.length === 0)) {
@@ -2234,10 +2355,19 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2234
2355
  // explicit id was passed) and we hold a pin from an earlier bound
2235
2356
  // read, prefer the pin so the query hits the real session.
2236
2357
  const pinnedProviderSessionIdForHistory = getBoundProviderSessionIdPin(targetSid);
2358
+ // Runtime fallback whether historySessionId reached targetSid via
2359
+ // getHistorySessionId's internal fallback (empty args) OR the browser
2360
+ // explicitly sent historySessionId === targetSid (the poisoned
2361
+ // agy-coordinator subscription / D8 refreshAuthoritativeTail read).
2362
+ // In both cases the runtime id is NOT a real provider conv uuid, so
2363
+ // drop it and let pin / workspace-latest / owner-confirmed resolution
2364
+ // run. A real DISTINCT provider uuid still exact-binds unchanged.
2365
+ const explicitHistorySessionId = getExplicitHistorySessionId(args);
2237
2366
  const historySessionIdIsRuntimeFallback = Boolean(
2238
2367
  targetSid
2239
- && historySessionId === targetSid
2240
- && !getExplicitHistorySessionId(args),
2368
+ && isRuntimeFallbackHistorySessionId(historySessionId, targetSid)
2369
+ && (!explicitHistorySessionId
2370
+ || isRuntimeFallbackHistorySessionId(explicitHistorySessionId, targetSid)),
2241
2371
  );
2242
2372
  // When this is the runtime fallback (not a real provider id): prefer
2243
2373
  // the pin if we have one, else drop the runtime id entirely so the
@@ -2282,24 +2412,58 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2282
2412
  const historyProviderSessionId = typeof (history as any)?.providerSessionId === 'string'
2283
2413
  ? (history as any).providerSessionId
2284
2414
  : readHistorySessionIdFromMessages(historyMessages) || effectiveHistorySessionIdForRead;
2285
- // Refresh the pin whenever this path resolves a real provider id.
2286
- if (typeof (history as any)?.providerSessionId === 'string' && (history as any).providerSessionId.trim()) {
2415
+ // Antigravity coordinator root fix (history-only path the post-turn
2416
+ // read a coordinator actually hits: no live adapter, no pin, agy takes no
2417
+ // --session-id so spawnedAtMs is 0 after attach-restore → the read resolves
2418
+ // via the workspace-latest fallback, lookup === 'workspace'). The dispatcher
2419
+ // STILL surfaces the on-disk conversation uuid there, and flags whether it
2420
+ // was OWNER-token-confirmed as this session's own (an exact/birth pick) vs a
2421
+ // bare recency pick that could be a co-located replica's conversation.
2422
+ // • Pin the uuid on a workspace-latest read ONLY when owner-confirmed —
2423
+ // recording a replica's uuid would hard-wire the coordinator↔replica
2424
+ // crosswire permanently. Exact/session-scoped reads pin as before.
2425
+ // • Feed the owner-confirmed uuid as the explicit identity to the
2426
+ // safe-mapping check even on a workspace-latest read so the identity
2427
+ // test runs uuid-to-uuid and trusts the assistant on this FIRST read
2428
+ // (else it saw undefined identity → workspace-overlap branch → the PTY
2429
+ // snapshot has only the user echo → fail-closed → regress to pty-parser).
2430
+ const historyLookupIsWorkspace = lookup === 'workspace';
2431
+ const historyOwnerConfirmed = agentStr === 'antigravity-cli' && (history as any)?.ownerConfirmed === true;
2432
+ const historyOwnerConfirmedUuid = historyOwnerConfirmed
2433
+ && typeof historyProviderSessionId === 'string' && historyProviderSessionId.trim()
2434
+ ? historyProviderSessionId.trim()
2435
+ : '';
2436
+ // Refresh the pin whenever this path resolves a real provider id — but for
2437
+ // a workspace-latest antigravity read, only when the uuid is owner-confirmed.
2438
+ if (typeof (history as any)?.providerSessionId === 'string'
2439
+ && (history as any).providerSessionId.trim()
2440
+ && (!historyLookupIsWorkspace || !agentStr || agentStr !== 'antigravity-cli' || historyOwnerConfirmed)) {
2287
2441
  recordBoundProviderSessionId(h, effectiveReadSessionId(h, targetSid), (history as any).providerSessionId.trim());
2288
2442
  }
2289
2443
  // Use the id we actually read with (pin / real provider id), NOT the
2290
2444
  // raw runtime-fallback historySessionId — otherwise the mapping guard
2291
2445
  // compares the stamped messages' real id against the runtime id and
2292
2446
  // fails closed, undoing the pin reuse.
2293
- const mappingSessionId = effectiveHistorySessionIdForRead;
2294
- const safeMapping = supportsNative
2447
+ const mappingSessionId = historyOwnerConfirmedUuid || effectiveHistorySessionIdForRead;
2448
+ // Fail closed for an antigravity workspace-latest read whose uuid was NOT
2449
+ // owner-confirmed: it is a bare recency/newest-by-mtime pick that could be
2450
+ // a co-located concurrent session's (replica's) conversation. Without an
2451
+ // owner-token confirmation we cannot prove ownership, so refuse it rather
2452
+ // than surface a sibling's transcript (the coordinator↔replica crosswire
2453
+ // guard). This is the same fail-closed default the design study protects —
2454
+ // only an owner-confirmed uuid escapes it above.
2455
+ const antigravityWorkspaceLatestUnconfirmed = agentStr === 'antigravity-cli'
2456
+ && historyLookupIsWorkspace
2457
+ && !historyOwnerConfirmedUuid;
2458
+ const safeMapping = supportsNative && !antigravityWorkspaceLatestUnconfirmed
2295
2459
  ? hasSafeNativeHistoryMapping({
2296
- historySessionId: lookup === 'workspace' ? undefined : mappingSessionId,
2297
- providerSessionId: lookup === 'workspace' ? undefined : historyProviderSessionId,
2460
+ historySessionId: historyOwnerConfirmedUuid || (lookup === 'workspace' ? undefined : mappingSessionId),
2461
+ providerSessionId: historyOwnerConfirmedUuid || (lookup === 'workspace' ? undefined : historyProviderSessionId),
2298
2462
  workspace,
2299
2463
  nativeMessages: historyMessages,
2300
2464
  })
2301
2465
  : false;
2302
- const trustedExactNativeIdentity = lookup !== 'workspace'
2466
+ const trustedExactNativeIdentity = (lookup !== 'workspace' || Boolean(historyOwnerConfirmedUuid))
2303
2467
  && Boolean(mappingSessionId)
2304
2468
  && Boolean(historyProviderSessionId)
2305
2469
  && mappingSessionId === historyProviderSessionId;
@@ -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, __resetProviderSessionPinsForTest } from './chat-commands-read.js';
14
+ export { handleChatHistory, handleReadChat, __resetProviderSessionPinsForTest, __getProviderSessionPinForTest } from './chat-commands-read.js';
15
15
  export {
16
16
  handleSendChat,
17
17
  handleListChats,
@@ -49,6 +49,8 @@ export interface HostedCliRuntimeDescriptor {
49
49
  workspace: string;
50
50
  cliArgs?: string[];
51
51
  providerSessionId?: string;
52
+ managedBy?: string;
53
+ startedAtMs?: number;
52
54
  }
53
55
  export declare function supportsExplicitSessionResume(resume?: ProviderResumeCapability): boolean;
54
56
  export declare class DaemonCliManager {
@@ -233,6 +233,14 @@ export interface HostedCliRuntimeDescriptor {
233
233
  cliArgs?: string[];
234
234
  providerSessionId?: string;
235
235
  managedBy?: string;
236
+ /**
237
+ * Real spawn time (ms epoch) of the underlying session-host runtime — a PAST
238
+ * timestamp recorded when the runtime first started. Threaded through so an
239
+ * attach can restore the native-history session-floor to the runtime's actual
240
+ * birth instead of collapsing spawnedAtMs to 0. Undefined when unrecoverable
241
+ * (genuine post-restart-unknown), in which case the caller keeps the 0 fallback.
242
+ */
243
+ startedAtMs?: number;
236
244
  }
237
245
 
238
246
  type CliPresentationInstance = ProviderInstance & {
@@ -294,6 +302,33 @@ function hasCliArg(args: string[], flag: string): boolean {
294
302
  return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
295
303
  }
296
304
 
305
+ /**
306
+ * Decide the session-registry spawnedAtMs (the native-history session-floor) for a
307
+ * newly registered CLI instance.
308
+ *
309
+ * - Fresh launch (attachExisting=false): now (nowMs). A real live spawn floor
310
+ * isolates a fresh session's own store and holds prior-session leak protection.
311
+ * - Attach WITH a recoverable record startedAt (a PAST timestamp): that startedAt.
312
+ * Restoring a hosted runtime (coordinator / MAGI replica / hermes / claude /
313
+ * codex) after a daemon restart, the real spawn time is in the past. Using it
314
+ * restores each session's per-session birth-floor so co-located antigravity
315
+ * runtimes resolve their OWN conversation (ownerConfirmed) instead of the
316
+ * floor-less newest-by-mtime path that let a replica claim the coordinator's conv.
317
+ * - Attach with NO recoverable startedAt: 0 — disables the floor for this session
318
+ * (recent_window_ms still bounds the look-back). NEVER use nowMs here: nowMs is in
319
+ * the FUTURE relative to existing transcripts and would push the floor past every
320
+ * transcript file, losing them (the ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP regression).
321
+ */
322
+ export function resolveHostedSpawnedAtMs(
323
+ attachExisting: boolean,
324
+ attachStartedAtMs: number | undefined,
325
+ nowMs: number,
326
+ ): number {
327
+ if (!attachExisting) return nowMs;
328
+ if (typeof attachStartedAtMs === 'number' && attachStartedAtMs > 0) return attachStartedAtMs;
329
+ return 0;
330
+ }
331
+
297
332
  function hasConfigOverride(args: string[], key: string): boolean {
298
333
  for (let index = 0; index < args.length; index += 1) {
299
334
  const arg = args[index];
@@ -673,6 +708,13 @@ export class DaemonCliManager {
673
708
  providerSessionId?: string;
674
709
  launchMode?: CliLaunchMode;
675
710
  extraEnv?: Record<string, string>;
711
+ /**
712
+ * On an attach (attachExisting=true), the real spawn time (ms epoch) of the
713
+ * session-host runtime being restored — a PAST timestamp. Used to restore the
714
+ * native-history session-floor to the runtime's actual birth instead of 0.
715
+ * See the spawnedAtMs computation below. Ignored for fresh launches.
716
+ */
717
+ attachStartedAtMs?: number;
676
718
  onProviderSessionResolved?: (info: {
677
719
  instanceId: string;
678
720
  providerType: string;
@@ -734,15 +776,30 @@ export class DaemonCliManager {
734
776
  workspace: resolvedDir,
735
777
  // attachExisting === true means we're restoring an already-spawned
736
778
  // hosted runtime after a daemon restart, not starting a fresh PTY.
737
- // The real spawn time is in the past and we don't have it on the
738
- // restored descriptor; pinning spawnedAtMs to Date.now() in that
739
- // case would push the native-history session-floor cutoff past
740
- // every existing transcript file, so the agy/hermes/claude reader
741
- // would return null even though the transcript on disk is fresh.
742
- // 0 disables the floor for this session — recent_window_ms in the
743
- // spec still bounds how far back we look. Fresh launches still
744
- // get a proper floor so prior-session leak protection holds.
745
- spawnedAtMs: attachExisting ? 0 : Date.now(),
779
+ //
780
+ // NEVER use Date.now() for the attach case: the real spawn time is in
781
+ // the PAST, and pinning the floor to now would push the native-history
782
+ // session-floor cutoff past every existing transcript file, so the
783
+ // agy/hermes/claude reader would return null even though the transcript
784
+ // on disk is fresh (the ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP regression).
785
+ //
786
+ // But collapsing to 0 for EVERY attach is also wrong: with the mesh
787
+ // coordinator + MAGI replicas all running as hosted runtimes sharing one
788
+ // workspace and attached with attachExisting=true, spawnedAtMs=0 disables
789
+ // the per-session native-history birth-floor for all of them. Without a
790
+ // floor, resolveAntigravityPath takes the floor-less newest-by-mtime
791
+ // branch (ownerConfirmed:false) and a replica's read can claim the
792
+ // coordinator's OWN conversation, which then reads as claimedByOther —
793
+ // regressing the coordinator chat to the pty-parser (user-only) path.
794
+ //
795
+ // So when the session-host record's REAL startedAt (a PAST timestamp) is
796
+ // recoverable, use it: the floor lands at the runtime's actual birth, the
797
+ // transcript is still found, AND each session's floor isolates its own
798
+ // conversation. Fall back to 0 ONLY when startedAt is unrecoverable (the
799
+ // genuine post-restart-unknown case) — that preserves the tail-gap
800
+ // protection. Fresh launches still get Date.now() so prior-session leak
801
+ // protection holds.
802
+ spawnedAtMs: resolveHostedSpawnedAtMs(attachExisting, options?.attachStartedAtMs, Date.now()),
746
803
  });
747
804
  } catch (spawnErr: any) {
748
805
  LOG.error('CLI', `[${cliType}] Spawn failed: ${spawnErr?.message}`);
@@ -1241,6 +1298,12 @@ export class DaemonCliManager {
1241
1298
  {
1242
1299
  providerSessionId: sessionBinding.providerSessionId,
1243
1300
  launchMode: 'manual',
1301
+ // Thread the runtime's REAL past spawn time so the attach restores
1302
+ // the per-session native-history birth-floor instead of collapsing
1303
+ // to spawnedAtMs:0 (which disabled the antigravity per-session floor
1304
+ // and let MAGI replicas claim the coordinator's own conversation).
1305
+ // Undefined → registerCliInstance keeps the 0 fallback.
1306
+ attachStartedAtMs: record.startedAtMs,
1244
1307
  },
1245
1308
  );
1246
1309
  restoredBindings.add(bindingKey);