@adhdev/daemon-core 0.9.82-rc.474 → 0.9.82-rc.476
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/chat-commands-read.d.ts +8 -0
- package/dist/commands/chat-commands.d.ts +1 -1
- package/dist/config/chat-history.d.ts +1 -0
- package/dist/config/state-store.d.ts +30 -0
- package/dist/index.js +110 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +110 -22
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +15 -3
- package/dist/shared-types.d.ts +12 -0
- package/package.json +3 -3
- package/src/commands/chat-commands-read.ts +86 -3
- package/src/commands/chat-commands.ts +1 -1
- package/src/config/chat-history.ts +19 -3
- package/src/config/state-store.ts +55 -0
- package/src/providers/cli-provider-instance.ts +16 -4
- package/src/providers/native-history/dispatcher.ts +53 -2
- package/src/shared-types.ts +12 -0
|
@@ -287,9 +287,21 @@ export declare class CliProviderInstance implements ProviderInstance {
|
|
|
287
287
|
private pruneRecentUserInputAcks;
|
|
288
288
|
/**
|
|
289
289
|
* Owner token for this session in the antigravity conversation-claim
|
|
290
|
-
* registry.
|
|
291
|
-
*
|
|
292
|
-
*
|
|
290
|
+
* registry. Keyed on the daemon instance id — the SAME value the session
|
|
291
|
+
* registry stores as this session's `sessionId` (see cli-manager
|
|
292
|
+
* `sessionRegistry.register({ sessionId: cliInstance.instanceId })`) and the
|
|
293
|
+
* read side hands the dispatcher as `instanceId`. Both sides therefore
|
|
294
|
+
* derive the identical `iid:<instanceId>` token, so the claims the
|
|
295
|
+
* dispatcher records under this session are exactly the ones dispose()
|
|
296
|
+
* releases.
|
|
297
|
+
*
|
|
298
|
+
* This must NOT be derived from a spawn timestamp: the instance's
|
|
299
|
+
* `startedAt`, the adapter's `spawnedAtMs`, and the session registry's
|
|
300
|
+
* `spawnedAtMs` are three INDEPENDENT `Date.now()` samples for the one
|
|
301
|
+
* session, so a workspace+spawn-time token computed here would never equal
|
|
302
|
+
* the read side's — the claim isolation then silently collapses and two
|
|
303
|
+
* concurrent antigravity sessions cross-bind each other's conversation .db
|
|
304
|
+
* (coordinator+worker chat crosswire).
|
|
293
305
|
*/
|
|
294
306
|
private antigravityClaimOwner;
|
|
295
307
|
dispose(): void;
|
package/dist/shared-types.d.ts
CHANGED
|
@@ -183,6 +183,18 @@ export interface DaemonMetadataUpdate {
|
|
|
183
183
|
userName?: string;
|
|
184
184
|
seq: number;
|
|
185
185
|
timestamp: number;
|
|
186
|
+
/**
|
|
187
|
+
* Per-mesh state-change revision counters (meshId → monotonically increasing
|
|
188
|
+
* integer), bumped whenever the daemon's mesh graph/queue/mission state for that
|
|
189
|
+
* mesh changes (onMeshStateChange). Lets the dashboard replace its client-side
|
|
190
|
+
* mesh_status polling with an event-driven background refresh: when the revision
|
|
191
|
+
* for the mesh it is viewing advances, it re-fetches the aggregate mesh_status
|
|
192
|
+
* (SWR, keeping the current graph on screen). This is a lightweight nudge — the
|
|
193
|
+
* full aggregate snapshot is fetched on demand, not embedded here, so the
|
|
194
|
+
* daemon.metadata payload stays small. Optional/absent for daemons/builds that
|
|
195
|
+
* don't emit it (the client then keeps its polling fallback).
|
|
196
|
+
*/
|
|
197
|
+
meshStateRevisions?: Record<string, number>;
|
|
186
198
|
}
|
|
187
199
|
export interface TopicUpdateEnvelopeMap {
|
|
188
200
|
'session.chat_tail': SessionChatTailUpdate;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.476",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
51
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "0.9.82-rc.476",
|
|
51
|
+
"@adhdev/session-host-core": "0.9.82-rc.476",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -9,6 +9,7 @@ import type { CliAdapter } from '../cli-adapter-types.js';
|
|
|
9
9
|
import { flattenContent, type ProviderModule, type ProviderScripts } from '../providers/contracts.js';
|
|
10
10
|
import { validateReadChatResultPayload } from '../providers/read-chat-contract.js';
|
|
11
11
|
import { isNativeSourceCanonicalHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
|
|
12
|
+
import { clearPersistedProviderSessionPins, loadPersistedProviderSessionPins, recordPersistedProviderSessionPin } from '../config/state-store.js';
|
|
12
13
|
import { getCoordinatorForSession } from '../mesh/coordinator-registry.js';
|
|
13
14
|
import { LOG } from '../logging/logger.js';
|
|
14
15
|
import { recordDebugTrace } from '../logging/debug-trace.js';
|
|
@@ -62,22 +63,69 @@ const CLI_NATIVE_TRANSCRIPT_PROVIDERS = new Set(['codex-cli', 'claude-cli', 'her
|
|
|
62
63
|
// and run the native query normally instead of fail-closing. Refreshed on every
|
|
63
64
|
// successful bind; never lets an empty id clear a known pin. Keyed by mesh
|
|
64
65
|
// session id so pins never alias across distinct sessions sharing a workspace.
|
|
66
|
+
//
|
|
67
|
+
// The map is ALSO mirrored to disk (state.json sessionProviderSessionPins) so a
|
|
68
|
+
// pin survives a daemon restart. Without that, an attach-restored antigravity
|
|
69
|
+
// session (spawnedAtMs=0, so no live spawn floor) that has sat idle past the
|
|
70
|
+
// native reader's recency window can no longer resolve its own conversation .db
|
|
71
|
+
// after the daemon comes back — read_chat falls to the PTY parse and the
|
|
72
|
+
// dashboard shows the user prompt with the assistant tail missing
|
|
73
|
+
// (ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP). The in-memory map stays the hot path;
|
|
74
|
+
// disk is the cold-start hydration source, read lazily on the first miss.
|
|
65
75
|
const lastBoundProviderSessionIdByMeshSession = new Map<string, string>();
|
|
76
|
+
let persistedProviderSessionPinsHydrated = false;
|
|
77
|
+
|
|
78
|
+
function hydratePersistedProviderSessionPinsOnce(): void {
|
|
79
|
+
if (persistedProviderSessionPinsHydrated) return;
|
|
80
|
+
persistedProviderSessionPinsHydrated = true;
|
|
81
|
+
try {
|
|
82
|
+
for (const [key, value] of Object.entries(loadPersistedProviderSessionPins())) {
|
|
83
|
+
// Never let a stale persisted value clobber a fresher in-memory bind
|
|
84
|
+
// recorded earlier this process lifetime.
|
|
85
|
+
if (!lastBoundProviderSessionIdByMeshSession.has(key)) {
|
|
86
|
+
lastBoundProviderSessionIdByMeshSession.set(key, value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Best-effort: a missing/corrupt state file just means no cold-start pins.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
66
93
|
|
|
67
94
|
function recordBoundProviderSessionId(meshSessionId: string | undefined, providerSessionId: string | undefined): void {
|
|
68
95
|
const key = typeof meshSessionId === 'string' ? meshSessionId.trim() : '';
|
|
69
96
|
const value = typeof providerSessionId === 'string' ? providerSessionId.trim() : '';
|
|
70
97
|
if (!key || !value) return;
|
|
71
98
|
lastBoundProviderSessionIdByMeshSession.set(key, value);
|
|
99
|
+
// Always attempt the disk mirror — recordPersistedProviderSessionPin is itself a
|
|
100
|
+
// no-op when the ON-DISK value already matches, so it does not rewrite state.json
|
|
101
|
+
// on steady re-reads, yet it still lands a pin the in-memory map already holds but
|
|
102
|
+
// disk lost (a prior write clobbered by another state-store writer, or a restart
|
|
103
|
+
// whose hydration ran before this bind). Gating on the in-memory previous value
|
|
104
|
+
// let the in-memory and on-disk pin diverge permanently, defeating the persistence.
|
|
105
|
+
try { recordPersistedProviderSessionPin(key, value); } catch { /* best-effort disk mirror */ }
|
|
72
106
|
}
|
|
73
107
|
|
|
74
108
|
function getBoundProviderSessionIdPin(meshSessionId: string | undefined): string | undefined {
|
|
75
109
|
const key = typeof meshSessionId === 'string' ? meshSessionId.trim() : '';
|
|
76
110
|
if (!key) return undefined;
|
|
111
|
+
hydratePersistedProviderSessionPinsOnce();
|
|
77
112
|
const pinned = lastBoundProviderSessionIdByMeshSession.get(key);
|
|
78
113
|
return pinned && pinned.trim() ? pinned.trim() : undefined;
|
|
79
114
|
}
|
|
80
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Test-only: clear the in-memory read-pin map and re-arm cold-start hydration so
|
|
118
|
+
* each test starts from a clean pin state. The on-disk mirror is isolated per
|
|
119
|
+
* test process via ADHDEV_CONFIG_DIR (test/helpers/setup-env.ts); this resets the
|
|
120
|
+
* module-level cache that would otherwise leak a pin across tests sharing the
|
|
121
|
+
* worker. Not part of the runtime contract.
|
|
122
|
+
*/
|
|
123
|
+
export function __resetProviderSessionPinsForTest(): void {
|
|
124
|
+
lastBoundProviderSessionIdByMeshSession.clear();
|
|
125
|
+
persistedProviderSessionPinsHydrated = false;
|
|
126
|
+
try { clearPersistedProviderSessionPins(); } catch { /* best-effort */ }
|
|
127
|
+
}
|
|
128
|
+
|
|
81
129
|
const warnedLegacyNativeAllowlistHits = new Set<string>();
|
|
82
130
|
function warnLegacyNativeAllowlistHit(providerType: string): void {
|
|
83
131
|
if (warnedLegacyNativeAllowlistHits.has(providerType)) return;
|
|
@@ -1095,6 +1143,13 @@ function readCliProviderNativeHistory(agentStr: string, args: {
|
|
|
1095
1143
|
// the downstream hasSafeNativeHistoryMapping workspace-overlap gate. Only the
|
|
1096
1144
|
// read_chat path opts in, and only with a concrete workspace.
|
|
1097
1145
|
allowWorkspaceLatestFallback?: boolean;
|
|
1146
|
+
// ADHDev session id of the reading session (== the session registry's
|
|
1147
|
+
// sessionId == the provider instance's instanceId). Threaded to the
|
|
1148
|
+
// native-history dispatcher so antigravity's conversation-claim owner token
|
|
1149
|
+
// is keyed on this stable identity and matches the instance-side token —
|
|
1150
|
+
// without it two concurrent antigravity sessions cross-bind each other's
|
|
1151
|
+
// conversation .db.
|
|
1152
|
+
instanceId?: string;
|
|
1098
1153
|
}): ReturnType<typeof readProviderChatHistory> & { lookup: 'session' | 'workspace' } {
|
|
1099
1154
|
const canBindFromLiveSession = !args.historySessionId
|
|
1100
1155
|
&& typeof args.sessionStartedAtMs === 'number'
|
|
@@ -1146,6 +1201,7 @@ function readCliProviderNativeHistory(agentStr: string, args: {
|
|
|
1146
1201
|
excludeInProgressTurn: args.excludeInProgressTurn,
|
|
1147
1202
|
sessionStartedAtMs: args.sessionStartedAtMs,
|
|
1148
1203
|
envOverrides: args.envOverrides,
|
|
1204
|
+
instanceId: args.instanceId,
|
|
1149
1205
|
});
|
|
1150
1206
|
const boundProviderSessionId = typeof (sessionHistory as any)?.providerSessionId === 'string'
|
|
1151
1207
|
? (sessionHistory as any).providerSessionId.trim()
|
|
@@ -1541,6 +1597,7 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
|
|
|
1541
1597
|
scripts: provider?.scripts as any,
|
|
1542
1598
|
sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
|
|
1543
1599
|
envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
|
|
1600
|
+
instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
|
|
1544
1601
|
pinnedProviderSessionId: getBoundProviderSessionIdPin(args?.targetSessionId),
|
|
1545
1602
|
})
|
|
1546
1603
|
: readProviderChatHistory(agentStr, {
|
|
@@ -1759,10 +1816,31 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
1759
1816
|
let nativeHistory: any | null = null;
|
|
1760
1817
|
let nativeHistoryError: unknown | undefined;
|
|
1761
1818
|
if (supportsNative) {
|
|
1819
|
+
// Runtime-fallback → pin substitution: nativeHistoryReadSessionId is
|
|
1820
|
+
// the bare runtime/session id when no explicit provider handle was
|
|
1821
|
+
// supplied and none was parsed (antigravity takes no --session-id, so
|
|
1822
|
+
// its this.providerSessionId stays empty and getHistorySessionId falls
|
|
1823
|
+
// back to targetSessionId). That runtime id is not the on-disk
|
|
1824
|
+
// conversations/<uuid>.db name, so a native read keyed on it can never
|
|
1825
|
+
// exact-bind and falls to the recency heuristic — which drops an idle
|
|
1826
|
+
// (or restored, spawnedAtMs=0) session's own store. Prefer a pin (a
|
|
1827
|
+
// real conversation id a prior read resolved for THIS session, now also
|
|
1828
|
+
// persisted across restart) over the runtime id, else drop the runtime
|
|
1829
|
+
// id so readCliProviderNativeHistory's pin / workspace-latest paths can
|
|
1830
|
+
// engage. Mirrors the handleChatHistory path's established handling.
|
|
1831
|
+
const pinnedProviderSessionIdForRead = getBoundProviderSessionIdPin(targetSessionId);
|
|
1832
|
+
const nativeReadSessionIdIsRuntimeFallback = Boolean(
|
|
1833
|
+
targetSessionId
|
|
1834
|
+
&& nativeHistoryReadSessionId === targetSessionId
|
|
1835
|
+
&& !getExplicitHistorySessionId(args),
|
|
1836
|
+
);
|
|
1837
|
+
const effectiveNativeReadSessionId = nativeReadSessionIdIsRuntimeFallback
|
|
1838
|
+
? (pinnedProviderSessionIdForRead || undefined)
|
|
1839
|
+
: nativeHistoryReadSessionId;
|
|
1762
1840
|
try {
|
|
1763
1841
|
nativeHistory = readCliProviderNativeHistory(agentStr, {
|
|
1764
1842
|
canonicalHistory: provider?.nativeHistory,
|
|
1765
|
-
historySessionId:
|
|
1843
|
+
historySessionId: effectiveNativeReadSessionId,
|
|
1766
1844
|
workspace,
|
|
1767
1845
|
offset: 0,
|
|
1768
1846
|
limit: nativeHistoryLimit,
|
|
@@ -1772,11 +1850,14 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
1772
1850
|
excludeInProgressTurn: returnedStatus === 'waiting_approval',
|
|
1773
1851
|
sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
|
|
1774
1852
|
envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
|
|
1775
|
-
|
|
1853
|
+
// Stable per-session identity for antigravity's conversation-claim
|
|
1854
|
+
// owner token (== session registry sessionId == instance instanceId).
|
|
1855
|
+
instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
|
|
1856
|
+
pinnedProviderSessionId: pinnedProviderSessionIdForRead,
|
|
1776
1857
|
// Last-resort only when no pin was ever recorded for this
|
|
1777
1858
|
// session; the downstream workspace-overlap safety gate
|
|
1778
1859
|
// still filters an aliased session out.
|
|
1779
|
-
allowWorkspaceLatestFallback: !
|
|
1860
|
+
allowWorkspaceLatestFallback: !pinnedProviderSessionIdForRead,
|
|
1780
1861
|
});
|
|
1781
1862
|
// Refresh the per-mesh-session pin whenever a native read
|
|
1782
1863
|
// resolves a concrete provider-native session id. A later
|
|
@@ -1847,6 +1928,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
1847
1928
|
excludeInProgressTurn: returnedStatus === 'waiting_approval',
|
|
1848
1929
|
sessionStartedAtMs,
|
|
1849
1930
|
envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
|
|
1931
|
+
instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
|
|
1850
1932
|
});
|
|
1851
1933
|
nativeHistoryError = undefined;
|
|
1852
1934
|
nativeMessages = nativeHistory && Array.isArray(nativeHistory.messages)
|
|
@@ -2145,6 +2227,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
|
|
|
2145
2227
|
scripts: provider?.scripts as any,
|
|
2146
2228
|
sessionStartedAtMs: sessionStartedAtMsFromRegistry(h, args?.targetSessionId),
|
|
2147
2229
|
envOverrides: sessionSpawnEnvFromAdapter(h, args?.targetSessionId),
|
|
2230
|
+
instanceId: typeof args?.targetSessionId === 'string' ? args.targetSessionId : undefined,
|
|
2148
2231
|
pinnedProviderSessionId: pinnedProviderSessionIdForHistory,
|
|
2149
2232
|
// Last-resort only when no pin was ever recorded AND the
|
|
2150
2233
|
// runtime fallback did not resolve a real provider session.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
export { READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS, buildSendInputSignature } from './chat-commands-shared.js';
|
|
12
12
|
export { evaluateReadChatNodeWorkspaceScope } from './chat-commands-scope.js';
|
|
13
13
|
export { sanitizeDebugBundleValue, handleGetChatDebugBundle } from './chat-commands-debug-bundle.js';
|
|
14
|
-
export { handleChatHistory, handleReadChat } from './chat-commands-read.js';
|
|
14
|
+
export { handleChatHistory, handleReadChat, __resetProviderSessionPinsForTest } from './chat-commands-read.js';
|
|
15
15
|
export {
|
|
16
16
|
handleSendChat,
|
|
17
17
|
handleListChats,
|
|
@@ -1813,10 +1813,12 @@ function callProviderNativeHistoryRead(
|
|
|
1813
1813
|
sessionStartedAtMs?: number,
|
|
1814
1814
|
envOverrides?: Record<string, string>,
|
|
1815
1815
|
forceRefresh?: boolean,
|
|
1816
|
+
instanceId?: string,
|
|
1816
1817
|
): ProviderNativeHistoryReadResult | null {
|
|
1817
1818
|
const fn = getProviderNativeHistoryScript(scripts, canonicalHistory, 'readSession');
|
|
1818
1819
|
if (!fn) return null;
|
|
1819
1820
|
const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId || '');
|
|
1821
|
+
const normalizedInstanceId = typeof instanceId === 'string' ? instanceId.trim() : '';
|
|
1820
1822
|
const result = fn({
|
|
1821
1823
|
agentType,
|
|
1822
1824
|
sessionId: normalizedSessionId,
|
|
@@ -1831,6 +1833,12 @@ function callProviderNativeHistoryRead(
|
|
|
1831
1833
|
// which leaves the guard disarmed so discovery still works.
|
|
1832
1834
|
providerSessionId: normalizedSessionId,
|
|
1833
1835
|
historySessionId: normalizedSessionId,
|
|
1836
|
+
// Stable per-session owner key for the antigravity conversation-claim
|
|
1837
|
+
// registry (see dispatcher.resolveAntigravityPath / antigravityOwnerToken).
|
|
1838
|
+
// Equals the session registry's sessionId and the provider instance's
|
|
1839
|
+
// instanceId, so read side and instance side derive the identical claim
|
|
1840
|
+
// owner token and two concurrent antigravity sessions never cross-bind.
|
|
1841
|
+
instanceId: normalizedInstanceId || undefined,
|
|
1834
1842
|
workspace,
|
|
1835
1843
|
format: canonicalHistory?.format,
|
|
1836
1844
|
watchPath: canonicalHistory?.watchPath,
|
|
@@ -1838,7 +1846,7 @@ function callProviderNativeHistoryRead(
|
|
|
1838
1846
|
sessionStartedAtMs,
|
|
1839
1847
|
envOverrides,
|
|
1840
1848
|
forceRefresh: forceRefresh === true,
|
|
1841
|
-
args: { sessionId: normalizedSessionId, historySessionId: normalizedSessionId, workspace, excludeInProgressTurn: excludeInProgressTurn === true, sessionStartedAtMs, envOverrides, forceRefresh: forceRefresh === true },
|
|
1849
|
+
args: { sessionId: normalizedSessionId, historySessionId: normalizedSessionId, instanceId: normalizedInstanceId || undefined, workspace, excludeInProgressTurn: excludeInProgressTurn === true, sessionStartedAtMs, envOverrides, forceRefresh: forceRefresh === true },
|
|
1842
1850
|
});
|
|
1843
1851
|
if (!result || typeof result !== 'object') return null;
|
|
1844
1852
|
const records = normalizeProviderNativeHistoryRecords(agentType, normalizedSessionId, (result as any).messages || (result as any).records);
|
|
@@ -1865,11 +1873,12 @@ function buildNativeHistoryReadResult(
|
|
|
1865
1873
|
sessionStartedAtMs?: number,
|
|
1866
1874
|
envOverrides?: Record<string, string>,
|
|
1867
1875
|
forceRefresh?: boolean,
|
|
1876
|
+
instanceId?: string,
|
|
1868
1877
|
): ProviderNativeHistoryReadResult | null {
|
|
1869
1878
|
const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId || '');
|
|
1870
1879
|
const normalizedWorkspace = typeof workspace === 'string' ? workspace.trim() : '';
|
|
1871
1880
|
if (!canonicalHistory || (!normalizedSessionId && !normalizedWorkspace) || !isNativeSourceCanonicalHistory(canonicalHistory)) return null;
|
|
1872
|
-
return callProviderNativeHistoryRead(agentType, canonicalHistory, scripts, normalizedSessionId, workspace, excludeInProgressTurn, sessionStartedAtMs, envOverrides, forceRefresh);
|
|
1881
|
+
return callProviderNativeHistoryRead(agentType, canonicalHistory, scripts, normalizedSessionId, workspace, excludeInProgressTurn, sessionStartedAtMs, envOverrides, forceRefresh, instanceId);
|
|
1873
1882
|
}
|
|
1874
1883
|
|
|
1875
1884
|
function materializeNativeHistoryToMirror(
|
|
@@ -1929,6 +1938,13 @@ export function readProviderChatHistory(
|
|
|
1929
1938
|
sessionStartedAtMs?: number;
|
|
1930
1939
|
envOverrides?: Record<string, string>;
|
|
1931
1940
|
forceRefresh?: boolean;
|
|
1941
|
+
// Daemon instance id of the reading session (== the session registry's
|
|
1942
|
+
// sessionId). Threaded to the native-history dispatcher so the
|
|
1943
|
+
// antigravity conversation-claim owner token is keyed on this stable
|
|
1944
|
+
// per-session identity — identical to the token the provider instance
|
|
1945
|
+
// derives — instead of a spawn timestamp that differs across sample
|
|
1946
|
+
// sites and silently breaks claim isolation.
|
|
1947
|
+
instanceId?: string;
|
|
1932
1948
|
} = {},
|
|
1933
1949
|
): {
|
|
1934
1950
|
messages: HistoryMessage[];
|
|
@@ -1943,7 +1959,7 @@ export function readProviderChatHistory(
|
|
|
1943
1959
|
unavailableReason?: string;
|
|
1944
1960
|
} {
|
|
1945
1961
|
if (isNativeSourceCanonicalHistory(options.canonicalHistory) && (options.historySessionId || options.workspace)) {
|
|
1946
|
-
const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace, options.excludeInProgressTurn, options.sessionStartedAtMs, options.envOverrides, options.forceRefresh);
|
|
1962
|
+
const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace, options.excludeInProgressTurn, options.sessionStartedAtMs, options.envOverrides, options.forceRefresh, options.instanceId);
|
|
1947
1963
|
if (!nativeResult) return { messages: [], hasMore: false, source: 'native-unavailable' };
|
|
1948
1964
|
return {
|
|
1949
1965
|
...pageHistoryRecords(agentType, nativeResult.records, options.offset || 0, options.limit || 30, options.excludeRecentCount || 0, options.historyBehavior),
|
|
@@ -26,6 +26,17 @@ export interface DaemonState {
|
|
|
26
26
|
sessionNotificationDismissals: Record<string, string>;
|
|
27
27
|
/** Current notification unread override ids keyed by stable session target */
|
|
28
28
|
sessionNotificationUnreadOverrides: Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolved provider-native conversation id for a live session, keyed by the
|
|
31
|
+
* ADHDev/mesh session id. Persisted so it survives a daemon restart: a
|
|
32
|
+
* provider whose on-disk store is keyed by an internally-generated id it
|
|
33
|
+
* never exposes on the CLI (antigravity — no --session-id) can then exact-bind
|
|
34
|
+
* its conversation .db after restart instead of re-running the mtime/recency
|
|
35
|
+
* heuristic, which drops the store once idle and collapses read_chat to the
|
|
36
|
+
* PTY parse (user echo only, assistant tail lost). Mirrors the in-memory read
|
|
37
|
+
* pin (chat-commands-read lastBoundProviderSessionIdByMeshSession).
|
|
38
|
+
*/
|
|
39
|
+
sessionProviderSessionPins: Record<string, string>;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
const DEFAULT_STATE: DaemonState = {
|
|
@@ -35,6 +46,7 @@ const DEFAULT_STATE: DaemonState = {
|
|
|
35
46
|
sessionReadMarkers: {},
|
|
36
47
|
sessionNotificationDismissals: {},
|
|
37
48
|
sessionNotificationUnreadOverrides: {},
|
|
49
|
+
sessionProviderSessionPins: {},
|
|
38
50
|
};
|
|
39
51
|
|
|
40
52
|
function isPlainObject(value: unknown): value is Record<string, any> {
|
|
@@ -77,6 +89,10 @@ function normalizeState(raw: unknown): DaemonState {
|
|
|
77
89
|
Object.entries(isPlainObject(parsed.sessionNotificationUnreadOverrides) ? parsed.sessionNotificationUnreadOverrides : {})
|
|
78
90
|
.filter(([, value]) => typeof value === 'string' && value.length > 0)
|
|
79
91
|
);
|
|
92
|
+
const sessionProviderSessionPins = Object.fromEntries(
|
|
93
|
+
Object.entries(isPlainObject(parsed.sessionProviderSessionPins) ? parsed.sessionProviderSessionPins : {})
|
|
94
|
+
.filter(([key, value]) => typeof key === 'string' && key.length > 0 && typeof value === 'string' && value.length > 0)
|
|
95
|
+
);
|
|
80
96
|
|
|
81
97
|
return {
|
|
82
98
|
recentActivity,
|
|
@@ -85,6 +101,7 @@ function normalizeState(raw: unknown): DaemonState {
|
|
|
85
101
|
sessionReadMarkers,
|
|
86
102
|
sessionNotificationDismissals,
|
|
87
103
|
sessionNotificationUnreadOverrides,
|
|
104
|
+
sessionProviderSessionPins,
|
|
88
105
|
};
|
|
89
106
|
}
|
|
90
107
|
|
|
@@ -121,3 +138,41 @@ export function saveState(state: DaemonState): void {
|
|
|
121
138
|
export function resetState(): void {
|
|
122
139
|
saveState({ ...DEFAULT_STATE });
|
|
123
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Load the full persisted session→provider-conversation pin map (sessionId →
|
|
144
|
+
* provider-native conversation id). Survives daemon restart. Empty object when
|
|
145
|
+
* none recorded or the state file is unreadable.
|
|
146
|
+
*/
|
|
147
|
+
export function loadPersistedProviderSessionPins(): Record<string, string> {
|
|
148
|
+
return { ...loadState().sessionProviderSessionPins };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Persist one session→provider-conversation pin. Load-mutate-save against the
|
|
153
|
+
* on-disk state so it survives a daemon restart; a no-op when the value already
|
|
154
|
+
* matches (avoids rewriting state.json on every read). Never clears a pin with an
|
|
155
|
+
* empty value.
|
|
156
|
+
*/
|
|
157
|
+
export function recordPersistedProviderSessionPin(sessionId: string, providerSessionId: string): void {
|
|
158
|
+
const key = typeof sessionId === 'string' ? sessionId.trim() : '';
|
|
159
|
+
const value = typeof providerSessionId === 'string' ? providerSessionId.trim() : '';
|
|
160
|
+
if (!key || !value) return;
|
|
161
|
+
const state = loadState();
|
|
162
|
+
if (state.sessionProviderSessionPins[key] === value) return;
|
|
163
|
+
saveState({
|
|
164
|
+
...state,
|
|
165
|
+
sessionProviderSessionPins: { ...state.sessionProviderSessionPins, [key]: value },
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Test-only: drop all persisted session→provider-conversation pins from disk.
|
|
171
|
+
* Used by tests that assert a clean "no pin" state so a sibling test's write to
|
|
172
|
+
* the shared per-process ADHDEV_CONFIG_DIR does not leak in.
|
|
173
|
+
*/
|
|
174
|
+
export function clearPersistedProviderSessionPins(): void {
|
|
175
|
+
const state = loadState();
|
|
176
|
+
if (Object.keys(state.sessionProviderSessionPins).length === 0) return;
|
|
177
|
+
saveState({ ...state, sessionProviderSessionPins: {} });
|
|
178
|
+
}
|
|
@@ -1089,12 +1089,24 @@ export class CliProviderInstance implements ProviderInstance {
|
|
|
1089
1089
|
|
|
1090
1090
|
/**
|
|
1091
1091
|
* Owner token for this session in the antigravity conversation-claim
|
|
1092
|
-
* registry.
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1092
|
+
* registry. Keyed on the daemon instance id — the SAME value the session
|
|
1093
|
+
* registry stores as this session's `sessionId` (see cli-manager
|
|
1094
|
+
* `sessionRegistry.register({ sessionId: cliInstance.instanceId })`) and the
|
|
1095
|
+
* read side hands the dispatcher as `instanceId`. Both sides therefore
|
|
1096
|
+
* derive the identical `iid:<instanceId>` token, so the claims the
|
|
1097
|
+
* dispatcher records under this session are exactly the ones dispose()
|
|
1098
|
+
* releases.
|
|
1099
|
+
*
|
|
1100
|
+
* This must NOT be derived from a spawn timestamp: the instance's
|
|
1101
|
+
* `startedAt`, the adapter's `spawnedAtMs`, and the session registry's
|
|
1102
|
+
* `spawnedAtMs` are three INDEPENDENT `Date.now()` samples for the one
|
|
1103
|
+
* session, so a workspace+spawn-time token computed here would never equal
|
|
1104
|
+
* the read side's — the claim isolation then silently collapses and two
|
|
1105
|
+
* concurrent antigravity sessions cross-bind each other's conversation .db
|
|
1106
|
+
* (coordinator+worker chat crosswire).
|
|
1095
1107
|
*/
|
|
1096
1108
|
private antigravityClaimOwner(): string {
|
|
1097
|
-
return antigravityOwnerToken(this.workingDir, this.startedAt);
|
|
1109
|
+
return antigravityOwnerToken(this.workingDir, this.startedAt, this.instanceId);
|
|
1098
1110
|
}
|
|
1099
1111
|
|
|
1100
1112
|
dispose(): void {
|
|
@@ -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
|
|
@@ -341,6 +379,19 @@ function pickUnboundConversationDb(
|
|
|
341
379
|
let entries: fs.Dirent[] = [];
|
|
342
380
|
try { entries = fs.readdirSync(convRoot, { withFileTypes: true }); } catch { return null; }
|
|
343
381
|
|
|
382
|
+
// A known spawn floor already pins a candidate to THIS session by birth time
|
|
383
|
+
// (a store created at/after the session spawned is its own). Once that floor
|
|
384
|
+
// is available, the recency window is not just unnecessary but harmful: an
|
|
385
|
+
// antigravity session that has sat idle longer than RECENT_WINDOW_MS still
|
|
386
|
+
// owns its conversation .db, but the recency cutoff would drop it from the
|
|
387
|
+
// candidate set, collapsing the read to native_history_empty and forcing the
|
|
388
|
+
// dashboard onto the PTY parse (user echo only, assistant tail lost —
|
|
389
|
+
// ANTIGRAVITY-FINAL-MESSAGE-TAIL-GAP, most visible right after a daemon
|
|
390
|
+
// restart clears the in-memory read pin). So only apply the recency cutoff in
|
|
391
|
+
// the floor-less (legacy/unpinned) discovery path, where it is the sole guard
|
|
392
|
+
// against binding an unrelated old store. When a floor is known the birth-time
|
|
393
|
+
// filter below is the authoritative, idle-agnostic owner check.
|
|
394
|
+
const applyRecencyCutoff = !(sessionFloorMs > 0);
|
|
344
395
|
const recencyCutoff = Date.now() - RECENT_WINDOW_MS;
|
|
345
396
|
const candidates: Array<{ path: string; uuid: string; mtime: number; birth: number }> = [];
|
|
346
397
|
for (const entry of entries) {
|
|
@@ -352,7 +403,7 @@ function pickUnboundConversationDb(
|
|
|
352
403
|
if (isAntigravityConversationClaimedByOther(uuid, owner)) continue;
|
|
353
404
|
const p = path.join(convRoot, entry.name);
|
|
354
405
|
const mtime = safeMtime(p);
|
|
355
|
-
if (mtime < recencyCutoff) continue;
|
|
406
|
+
if (applyRecencyCutoff && mtime < recencyCutoff) continue;
|
|
356
407
|
candidates.push({ path: p, uuid, mtime, birth: safeBirthtime(p) });
|
|
357
408
|
}
|
|
358
409
|
if (candidates.length === 0) return null;
|
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 {
|