From 055871ade60efa58cf1a9e24bdaac5f0cd30eccd Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 2 Oct 2026 23:01:12 -0700
Subject: [PATCH] Continue Antigravity IDE and 2.0 history in new CLI
conversations (#24692)
* feat(antigravity): bridge IDE history into new CLI conversations
* fix(antigravity): preserve fresh-launch model and environment for IDE references
* fix(antigravity): forward IDE history opt-in through desktop IPC
* fix(antigravity): rebuild remote IDE reference startup on its host
* fix(antigravity): register IDE continuation action labels
* fix(antigravity): confine IDE references and bound metadata reads
* fix(antigravity): localize IDE continuation badges
* Preserve scanner service cache assertions and refresh Antigravity opening metadata
* Preserve Antigravity opening joins and target folder runtime authority
---
.../ai-vault/antigravity-history-prompt.ts | 12 +
.../ai-vault/antigravity-ide-history.test.ts | 228 ++++++++++++
.../antigravity-index-file-admission.test.ts | 122 ++++++
.../ai-vault/antigravity-index-reader.test.ts | 67 ++++
src/main/ai-vault/antigravity-index-reader.ts | 48 +++
...tigravity-opening-prompt-workspace.test.ts | 205 ++++++++++
.../antigravity-recorded-history.test.ts | 60 +++
.../antigravity-transcript-candidates.ts | 100 +++++
src/main/ai-vault/cached-session-list.ts | 6 +-
.../remote-session-scan-concurrency.ts | 10 +-
.../remote-session-scanner-discovery.ts | 6 +-
.../remote-session-scanner-sources.ts | 18 +-
.../ai-vault/remote-session-scanner-types.ts | 7 +-
src/main/ai-vault/remote-session-scanner.ts | 38 +-
src/main/ai-vault/runtime-session-scanner.ts | 1 +
...on-parse-cache-antigravity-upgrade.test.ts | 110 ++++++
.../session-parse-cache-bounds.test.ts | 16 +-
.../session-parse-cache-persistence.test.ts | 1 +
...sion-parse-cache-snapshot-serialization.ts | 2 +-
src/main/ai-vault/session-root-dedup.ts | 18 +
.../ai-vault/session-scanner-accumulator.ts | 3 +
.../session-scanner-antigravity-history.ts | 122 ++++--
.../session-scanner-antigravity-metadata.ts | 84 +++++
.../session-scanner-antigravity-parser.ts | 33 +-
.../session-scanner-antigravity-paths.ts | 4 +-
.../session-scanner-antigravity-sources.ts | 23 +-
.../ai-vault/session-scanner-candidates.ts | 7 +-
...sion-scanner-parser-stream-cleanup.test.ts | 15 +-
src/main/ai-vault/session-scanner-types.ts | 5 +
src/main/ai-vault/session-scanner.ts | 9 +-
src/main/ai-vault/ssh-session-list.ts | 7 +
src/main/ipc/ai-vault-runtime-scan.ts | 3 +
src/main/ipc/ai-vault.test.ts | 7 +-
src/main/ipc/ai-vault.ts | 20 +-
src/main/runtime/rpc/methods/ai-vault.test.ts | 16 +
src/main/runtime/rpc/methods/ai-vault.ts | 3 +-
src/relay/ai-vault-handler.ts | 4 +
src/relay/ai-vault-service-entry.ts | 1 +
src/relay/ai-vault-transcript-stream.ts | 16 +-
.../components/right-sidebar/AiVaultPanel.tsx | 2 +
.../right-sidebar/AiVaultSessionDetails.tsx | 22 +-
.../right-sidebar/AiVaultSessionRow.tsx | 6 +-
.../right-sidebar/AiVaultVirtualRow.test.tsx | 23 +-
.../right-sidebar/AiVaultVirtualRow.tsx | 15 +-
.../ai-vault-session-launch-target.ts | 4 +-
.../right-sidebar/ai-vault-session-refresh.ts | 1 +
.../ai-vault-session-resume-in-chat.ts | 3 +-
.../right-sidebar/ai-vault-session-resume.ts | 7 +-
.../ai-vault-session-row-display.tsx | 18 +
.../tab-group/AiVaultSessionDropLayer.tsx | 4 +-
src/renderer/src/i18n/locales/en.json | 5 +
...i-vault-antigravity-reference-host.test.ts | 351 ++++++++++++++++++
.../ai-vault-antigravity-reference-startup.ts | 81 ++++
.../src/lib/ai-vault-resume-command.test.ts | 42 +++
.../src/lib/ai-vault-resume-command.ts | 23 ++
src/renderer/src/lib/ai-vault-resume-shell.ts | 50 +++
.../src/lib/ai-vault-resume-target.ts | 30 +-
.../src/lib/local-preflight-context.ts | 37 +-
.../lib/local-project-runtime-workspace.ts | 57 +++
.../src/web/preload-api/web-ai-vault-api.ts | 1 +
src/shared/ai-vault-resume-command.ts | 11 +-
src/shared/ai-vault-types.ts | 4 +
src/shared/antigravity-session-origin.ts | 27 ++
.../antigravity-transcript-reference.test.ts | 88 +++++
src/shared/node-bounded-file-reader.ts | 33 +-
src/shared/rpc-contract/ai-vault-params.ts | 1 +
src/shared/ssh-ai-vault-relay.ts | 1 +
67 files changed, 2257 insertions(+), 147 deletions(-)
create mode 100644 src/main/ai-vault/antigravity-history-prompt.ts
create mode 100644 src/main/ai-vault/antigravity-ide-history.test.ts
create mode 100644 src/main/ai-vault/antigravity-index-file-admission.test.ts
create mode 100644 src/main/ai-vault/antigravity-index-reader.test.ts
create mode 100644 src/main/ai-vault/antigravity-index-reader.ts
create mode 100644 src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts
create mode 100644 src/main/ai-vault/antigravity-recorded-history.test.ts
create mode 100644 src/main/ai-vault/antigravity-transcript-candidates.ts
create mode 100644 src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts
create mode 100644 src/main/ai-vault/session-scanner-antigravity-metadata.ts
create mode 100644 src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts
create mode 100644 src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts
create mode 100644 src/renderer/src/lib/local-project-runtime-workspace.ts
create mode 100644 src/shared/antigravity-session-origin.ts
create mode 100644 src/shared/antigravity-transcript-reference.test.ts
diff --git a/src/main/ai-vault/antigravity-history-prompt.ts b/src/main/ai-vault/antigravity-history-prompt.ts
new file mode 100644
index 00000000000..cc40dd3010a
--- /dev/null
+++ b/src/main/ai-vault/antigravity-history-prompt.ts
@@ -0,0 +1,12 @@
+import { createHash } from 'node:crypto'
+import { normalizeTitleText } from './session-scanner-values'
+
+export function antigravityHistoryPromptHash(value: unknown): string | null {
+ if (typeof value !== 'string' || value.length > 4096) {
+ return null
+ }
+ const prompt = normalizeTitleText(value)
+ return prompt && !prompt.endsWith('...')
+ ? createHash('sha256').update(prompt).digest('hex')
+ : null
+}
diff --git a/src/main/ai-vault/antigravity-ide-history.test.ts b/src/main/ai-vault/antigravity-ide-history.test.ts
new file mode 100644
index 00000000000..3a10b67c7b4
--- /dev/null
+++ b/src/main/ai-vault/antigravity-ide-history.test.ts
@@ -0,0 +1,228 @@
+import { runProcess } from '../../shared/child-process/run-process'
+import { mkdtemp, mkdir, rm, writeFile, utimes } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { scanAiVaultSessions } from './session-scanner'
+import { isolatedScanRoots, writeAntigravityTranscript } from './session-scanner-test-fixtures'
+import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
+import { scanRemoteAiVaultSessions } from './remote-session-scanner'
+import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
+import { dedupeScannedSessions } from './session-root-dedup'
+import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
+import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
+import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
+
+const temporaryDirectories: string[] = []
+const conversationId = 'same-conversation-id'
+const origins = ['antigravity-cli', 'antigravity-ide', 'antigravity'] as const
+function records(prompt = 'Continue this verified example') {
+ return [
+ {
+ source: 'USER_EXPLICIT',
+ type: 'USER_INPUT',
+ created_at: '2026-10-02T01:00:00Z',
+ content: `${prompt}`
+ }
+ ]
+}
+async function temporaryHome(): Promise {
+ const home = await mkdtemp(join(tmpdir(), 'orca-agy-ide-'))
+ temporaryDirectories.push(home)
+ return home
+}
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((home) => rm(home, { recursive: true, force: true }))
+ )
+})
+
+describe('Antigravity IDE history discovery', () => {
+ it('requires client opt-in and keeps equal IDs in separate origins while selecting the full transcript', async () => {
+ const home = await temporaryHome()
+ for (const origin of origins) {
+ const path = await writeAntigravityTranscript(
+ join(home, '.gemini', origin, 'brain'),
+ conversationId,
+ records(origin)
+ )
+ await writeFile(
+ join(dirname(path), 'transcript_full.jsonl'),
+ `${jsonLines(records(origin))}\n`
+ )
+ await writeFile(
+ join(dirname(path), 'artifact.jsonl'),
+ `${jsonLines(records('Not history'))}\n`
+ )
+ }
+ const options = {
+ ...isolatedScanRoots(home),
+ antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
+ antigravityAppHome: home
+ }
+ const legacy = await scanAiVaultSessions(options)
+ expect(legacy.sessions).toHaveLength(1)
+ expect(antigravitySessionOrigin(legacy.sessions[0]!.filePath)).toBe('antigravity-cli')
+ const result = await scanAiVaultSessions({ ...options, includeAntigravityIdeSessions: true })
+ expect(result.issues).toEqual([])
+ expect(result.sessions).toHaveLength(3)
+ expect(new Set(result.sessions.map((session) => session.id)).size).toBe(3)
+ for (const session of result.sessions) {
+ expect(session.filePath).toContain('transcript_full.jsonl')
+ if (antigravitySessionOrigin(session.filePath) === 'antigravity-cli') {
+ expect(session.resumeCommand).toContain('--conversation')
+ } else {
+ expect(session.resumeCommand).toContain('--prompt-interactive')
+ expect(session.resumeCommand).not.toContain('--conversation')
+ expect(session.resumeCommand).toContain(session.filePath)
+ }
+ }
+ })
+
+ it('joins bounded metadata by exact conversation ID within each origin and refuses conflicting workspaces', async () => {
+ const home = await temporaryHome()
+ for (const origin of origins) {
+ const brain = join(home, '.gemini', origin, 'brain')
+ await writeAntigravityTranscript(brain, conversationId, records('Same title across origins'))
+ const cache = join(dirname(brain), 'cache')
+ await mkdir(cache, { recursive: true })
+ await writeFile(
+ join(cache, 'conversation_metadata.json'),
+ JSON.stringify({
+ conversations: { [conversationId]: { summary: { ProjectID: 'project-id' } } }
+ })
+ )
+ await writeFile(
+ join(cache, 'projects.json'),
+ JSON.stringify({ 'project-id': `/projects/${origin}` })
+ )
+ if (origin === 'antigravity') {
+ await writeFile(
+ join(cache, 'last_conversations.json'),
+ JSON.stringify({ '/conflicting-workspace': conversationId })
+ )
+ }
+ }
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(home),
+ antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
+ antigravityAppHome: home,
+ includeAntigravityIdeSessions: true
+ })
+ expect(result.sessions).toHaveLength(3)
+ for (const session of result.sessions) {
+ const origin = antigravitySessionOrigin(session.filePath)
+ expect(session.cwd).toBe(origin === 'antigravity' ? null : `/projects/${origin}`)
+ }
+ })
+
+ it('discovers IDE and 2.0 history in the selected WSL home without falling into sibling artifacts', async () => {
+ const home = await temporaryHome()
+ const wslHome = join(home, 'wsl-home')
+ for (const origin of origins) {
+ await writeAntigravityTranscript(
+ join(wslHome, '.gemini', origin, 'brain'),
+ conversationId,
+ records(origin)
+ )
+ }
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(home),
+ wslHomeDirs: [wslHome],
+ includeAntigravityIdeSessions: true,
+ platform: 'linux'
+ })
+ expect(result.sessions).toHaveLength(3)
+ expect(result.sessions.every((session) => session.filePath.startsWith(wslHome))).toBe(true)
+ })
+
+ it('selects an older full sibling even when the raw discovery cap only retained its compact file', async () => {
+ const home = await temporaryHome()
+ const brain = join(home, '.gemini', 'antigravity-ide', 'brain')
+ const compact = await writeAntigravityTranscript(brain, conversationId, records())
+ const full = join(dirname(compact), 'transcript_full.jsonl')
+ await writeFile(full, `${jsonLines(records())}\n`)
+ await utimes(full, new Date('2026-10-01T00:00:00Z'), new Date('2026-10-01T00:00:00Z'))
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(home),
+ antigravityAppHome: home,
+ includeAntigravityIdeSessions: true,
+ limit: 1,
+ limitPerAgent: 1
+ })
+ expect(result.sessions).toHaveLength(1)
+ expect(result.sessions[0]?.filePath).toBe(full)
+ })
+
+ it.skipIf(process.platform === 'win32')(
+ 'lists all origins despite a FIFO metadata cache and retains transcript workspace fallback',
+ async () => {
+ const home = await temporaryHome()
+ for (const origin of origins) {
+ const brain = join(home, '.gemini', origin, 'brain')
+ await writeAntigravityTranscript(brain, conversationId, records(origin))
+ const cache = join(dirname(brain), 'cache')
+ await mkdir(cache, { recursive: true })
+ const result = await runProcess({
+ program: 'mkfifo',
+ args: [join(cache, 'projects.json')],
+ timeoutMs: 2000
+ })
+ expect(result.code).toBe(0)
+ }
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(home),
+ antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
+ antigravityAppHome: home,
+ includeAntigravityIdeSessions: true
+ })
+ expect(result.sessions).toHaveLength(3)
+ expect(result.issues).toEqual([])
+ expect(result.sessions.every((session) => session.cwd === null)).toBe(true)
+ },
+ 5000
+ )
+
+ it('does not retain an oversized metadata file', async () => {
+ const home = await temporaryHome()
+ const file = join(home, 'oversized.json')
+ await writeFile(file, ' '.repeat(ANTIGRAVITY_INDEX_MAX_BYTES + 1))
+ expect(await readLocalAntigravityHistory(file)).toBeNull()
+ })
+
+ it('reads only the execution host and never collapses equal origin IDs from different remote hosts', async () => {
+ const provider = new MemoryRemoteProvider()
+ const remoteHome = '/remote/home'
+ for (const origin of origins) {
+ const logs = `${remoteHome}/.gemini/${origin}/brain/${conversationId}/.system_generated/logs`
+ provider.addFile(`${logs}/transcript_full.jsonl`, jsonLines(records(origin)), 10)
+ provider.addFile(`${logs}/transcript.jsonl`, jsonLines(records(origin)), 11)
+ }
+ const read = vi.spyOn(provider, 'readFile')
+ const options = {
+ provider,
+ remoteHome,
+ hostPlatform: getRemoteHostPlatform('linux-x64'),
+ includeAntigravityIdeSessions: true
+ }
+ const first = await scanRemoteAiVaultSessions({ ...options, executionHostId: 'ssh:first-host' })
+ const second = await scanRemoteAiVaultSessions({
+ ...options,
+ executionHostId: 'ssh:second-host'
+ })
+ expect(first.sessions).toHaveLength(3)
+ expect(second.sessions).toHaveLength(3)
+ expect(dedupeScannedSessions([...first.sessions, ...second.sessions])).toHaveLength(6)
+ expect(read.mock.calls.every(([path]) => path.startsWith(remoteHome))).toBe(true)
+ expect(provider.readDirPaths.every((path) => path.startsWith(remoteHome))).toBe(true)
+ expect(
+ first.sessions.every((session) => session.filePath.endsWith('transcript_full.jsonl'))
+ ).toBe(true)
+ const legacy = await scanRemoteAiVaultSessions({
+ ...options,
+ executionHostId: 'ssh:first-host',
+ includeAntigravityIdeSessions: false
+ })
+ expect(legacy.sessions).toHaveLength(1)
+ })
+})
diff --git a/src/main/ai-vault/antigravity-index-file-admission.test.ts b/src/main/ai-vault/antigravity-index-file-admission.test.ts
new file mode 100644
index 00000000000..9e5a734370b
--- /dev/null
+++ b/src/main/ai-vault/antigravity-index-file-admission.test.ts
@@ -0,0 +1,122 @@
+import type * as NodeFsPromises from 'node:fs/promises'
+import { constants } from 'node:fs'
+import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { runProcess } from '../../shared/child-process/run-process'
+
+const { afterStat } = vi.hoisted(() => {
+ const afterStat: { run: null | (() => Promise) } = { run: null }
+ return { afterStat }
+})
+vi.mock('node:fs/promises', async (original) => {
+ const actual = await original()
+ return {
+ ...actual,
+ stat: async (...args: Parameters) => {
+ const result = await actual.stat(...args)
+ await afterStat.run?.()
+ return result
+ }
+ }
+})
+import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
+import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
+import { readRelayTranscriptBytes } from '../../relay/ai-vault-transcript-stream'
+
+const roots: string[] = []
+async function filePath() {
+ const root = await mkdtemp(join(tmpdir(), 'orca-regular-index-'))
+ roots.push(root)
+ return join(root, 'projects.json')
+}
+async function fifo(path: string) {
+ const result = await runProcess({ program: 'mkfifo', args: [path], timeoutMs: 2000 })
+ if (result.code !== 0) {
+ throw new Error('Could not create task-owned FIFO')
+ }
+}
+afterEach(async () => {
+ afterStat.run = null
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('bounded regular metadata reads', () => {
+ it.skipIf(process.platform === 'win32')(
+ 'rejects real FIFO paths and symlinks without waiting for a writer',
+ async () => {
+ const file = await filePath()
+ await fifo(file)
+ const alias = `${file}.link`
+ await symlink(file, alias)
+ for (const path of [file, alias]) {
+ expect(await readLocalAntigravityHistory(path)).toBeNull()
+ await expect(readNodeFileWithinLimit(path, 64, { regularFileOnly: true })).rejects.toThrow(
+ 'regular file'
+ )
+ await expect(
+ readRelayTranscriptBytes(path, undefined, { regularFileOnly: true, maxBytes: 64 }).next()
+ ).rejects.toThrow('regular file')
+ }
+ },
+ 2000
+ )
+
+ it.skipIf(process.platform === 'win32')(
+ 'rejects a regular path replaced by a FIFO between stat and open',
+ async () => {
+ const file = await filePath()
+ await writeFile(file, '{}')
+ afterStat.run = async () => {
+ afterStat.run = null
+ await rm(file)
+ await fifo(file)
+ }
+ await expect(readNodeFileWithinLimit(file, 64, { regularFileOnly: true })).rejects.toThrow(
+ 'regular file'
+ )
+ expect(constants.O_NONBLOCK).toBeGreaterThan(0)
+ },
+ 2000
+ )
+
+ it('retains ordinary metadata and regular-file symlinks within the byte bound', async () => {
+ const file = await filePath()
+ await writeFile(file, '{"project":"/workspace"}')
+ expect(await readLocalAntigravityHistory(file)).toBe('{"project":"/workspace"}')
+ await expect(readNodeFileWithinLimit(file, 1, { regularFileOnly: true })).rejects.toThrow(
+ 'File too large'
+ )
+ })
+
+ it('rejects growth beyond the bound after admission', async () => {
+ const file = await filePath()
+ await writeFile(file, '{}')
+ afterStat.run = async () => {
+ afterStat.run = null
+ await writeFile(file, 'x'.repeat(65))
+ }
+ await expect(readNodeFileWithinLimit(file, 64, { regularFileOnly: true })).rejects.toThrow(
+ 'File too large'
+ )
+ })
+
+ it('propagates cancellation before open and after admission', async () => {
+ const file = await filePath()
+ await writeFile(file, '{}')
+ const controller = new AbortController()
+ controller.abort()
+ await expect(readLocalAntigravityHistory(file, controller.signal)).rejects.toMatchObject({
+ name: 'AbortError'
+ })
+ const next = new AbortController()
+ afterStat.run = async () => {
+ afterStat.run = null
+ next.abort()
+ }
+ await expect(readLocalAntigravityHistory(file, next.signal)).rejects.toMatchObject({
+ name: 'AbortError'
+ })
+ })
+})
diff --git a/src/main/ai-vault/antigravity-index-reader.test.ts b/src/main/ai-vault/antigravity-index-reader.test.ts
new file mode 100644
index 00000000000..603c3401d13
--- /dev/null
+++ b/src/main/ai-vault/antigravity-index-reader.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it, vi } from 'vitest'
+import { readRemoteAntigravityIndex } from './antigravity-index-reader'
+import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
+import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
+import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
+
+function provider(): RemoteSessionFilesystemProvider {
+ return {
+ readDir: async () => [],
+ stat: async () => ({ type: 'file', size: 2, mtime: 0 }),
+ readFile: async () => ({ content: '{}', isBinary: false })
+ }
+}
+
+describe('execution-host Antigravity metadata reads', () => {
+ it('forwards regular-file admission and byte limits through the concurrency wrapper', async () => {
+ const host = provider()
+ const read = vi.fn(async function* () {
+ yield Buffer.from('{}')
+ })
+ host.readTranscriptBytes = read
+ expect(
+ await readRemoteAntigravityIndex(
+ limitRemoteScanFilesystemConcurrency(host),
+ '/host/projects.json'
+ )
+ ).toBe('{}')
+ expect(read).toHaveBeenCalledWith('/host/projects.json', undefined, {
+ regularFileOnly: true,
+ maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
+ })
+ })
+
+ it('preserves the existing bounded read-file options on legacy providers', async () => {
+ const host = provider()
+ const read = vi.spyOn(host, 'readFile')
+ expect(
+ await readRemoteAntigravityIndex(
+ limitRemoteScanFilesystemConcurrency(host),
+ '/host/projects.json'
+ )
+ ).toBe('{}')
+ expect(read).toHaveBeenCalledWith('/host/projects.json', {
+ maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES
+ })
+ })
+
+ it('does not read directories or symlinks from legacy providers', async () => {
+ for (const type of ['directory', 'symlink'] as const) {
+ const host = provider()
+ host.stat = async () => ({ type, size: 0, mtime: 0 })
+ const read = vi.spyOn(host, 'readFile')
+ expect(await readRemoteAntigravityIndex(host, '/host/projects.json')).toBeNull()
+ expect(read).not.toHaveBeenCalled()
+ }
+ })
+
+ it('stops waiting for a legacy request that cannot cancel on the wire', async () => {
+ const host = provider()
+ host.readFile = () => new Promise(() => {})
+ const controller = new AbortController()
+ const read = readRemoteAntigravityIndex(host, '/host/projects.json', controller.signal)
+ await Promise.resolve()
+ controller.abort()
+ await expect(read).rejects.toMatchObject({ name: 'AbortError' })
+ })
+})
diff --git a/src/main/ai-vault/antigravity-index-reader.ts b/src/main/ai-vault/antigravity-index-reader.ts
new file mode 100644
index 00000000000..a14d4cde503
--- /dev/null
+++ b/src/main/ai-vault/antigravity-index-reader.ts
@@ -0,0 +1,48 @@
+import {
+ ANTIGRAVITY_INDEX_MAX_BYTES,
+ readBoundedAntigravityIndex
+} from './session-scanner-antigravity-metadata'
+import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
+import {
+ abandonRemoteSessionScanOnCancel,
+ throwIfAiVaultScanCancelled
+} from './ai-vault-scan-cancellation'
+
+export async function readRemoteAntigravityIndex(
+ provider: RemoteSessionFilesystemProvider,
+ path: string,
+ signal?: AbortSignal
+): Promise {
+ try {
+ throwIfAiVaultScanCancelled(signal)
+ if (provider.readTranscriptBytes) {
+ return await abandonRemoteSessionScanOnCancel(
+ readBoundedAntigravityIndex(
+ provider.readTranscriptBytes(path, signal, {
+ regularFileOnly: true,
+ maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
+ })
+ ),
+ signal
+ )
+ }
+ const stat = await abandonRemoteSessionScanOnCancel(provider.stat(path), signal)
+ if (stat.type !== 'file' || stat.size > ANTIGRAVITY_INDEX_MAX_BYTES) {
+ return null
+ }
+ const read = await abandonRemoteSessionScanOnCancel(
+ provider.readFile(path, { maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES }),
+ signal
+ )
+ throwIfAiVaultScanCancelled(signal)
+ return read.isBinary || Buffer.byteLength(read.content) > ANTIGRAVITY_INDEX_MAX_BYTES
+ ? null
+ : read.content
+ } catch (error) {
+ throwIfAiVaultScanCancelled(signal)
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw error
+ }
+ return null
+ }
+}
diff --git a/src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts b/src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts
new file mode 100644
index 00000000000..54f294124ab
--- /dev/null
+++ b/src/main/ai-vault/antigravity-opening-prompt-workspace.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, it } from 'vitest'
+import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
+import {
+ createAntigravitySessionResumeState,
+ parseAntigravitySessionContent
+} from './session-scanner-antigravity-parser'
+import { jsonLines } from './session-scanner-test-fixtures'
+
+const openingTime = '2026-07-15T11:39:10.000Z'
+const historyPath = '/home/ada/.gemini/antigravity-cli/history.jsonl'
+const file = {
+ path: '/home/ada/.gemini/antigravity-cli/brain/brain-id/.system_generated/logs/transcript.jsonl',
+ mtimeMs: Date.parse(openingTime),
+ modifiedAt: openingTime
+}
+
+function user(prompt: string, timestamp: string | null = openingTime) {
+ return {
+ source: 'USER_EXPLICIT',
+ type: 'USER_INPUT',
+ created_at: timestamp,
+ content: `${prompt}`
+ }
+}
+
+function openingRecords(prompt: string, timestamp: string | null = openingTime) {
+ return [
+ {
+ source: 'SYSTEM',
+ type: 'CHECKPOINT',
+ created_at: '2026-07-15T11:38:00.000Z',
+ content: 'Earlier system metadata is not the first prompt timestamp'
+ },
+ user(prompt, timestamp),
+ ...Array.from({ length: 7 }, (_, index) =>
+ user(`Later request ${index}`, `2026-07-15T11:4${index}:10.000Z`)
+ )
+ ]
+}
+
+function historyRow(display: string, workspace = '/repo/original', conversationId?: string) {
+ return { display, workspace, timestamp: Date.parse(openingTime) / 1000, conversationId }
+}
+
+function resolver(rows: ReturnType[]) {
+ return createAntigravityWorkspaceResolver(async (path) =>
+ path === historyPath ? jsonLines(rows) : null
+ )
+}
+
+async function parse(prompt: string, timestamp: string | null = openingTime) {
+ const session = await parseAntigravitySessionContent(
+ file,
+ jsonLines(openingRecords(prompt, timestamp)),
+ 'linux'
+ )
+ if (!session) {
+ throw new Error('Missing parsed fixture session')
+ }
+ expect(session.previewMessagesTruncated).toBe(true)
+ expect(session.previewMessages.every((message) => message.text !== prompt)).toBe(true)
+ expect(session.firstUserPrompt).toBeUndefined()
+ return session
+}
+
+describe('Antigravity opening prompt workspace association', () => {
+ it.each([undefined, 'history-id'])(
+ 'retains a long session workspace with row id %s',
+ async (id) => {
+ const prompt = 'Locate the original workspace'
+ const session = await parse(prompt)
+ expect(session.createdAt).not.toBe(openingTime)
+ expect(
+ (await resolver([historyRow(prompt, '/repo/original', id)]).enrich(session, historyPath))
+ .cwd
+ ).toBe('/repo/original')
+ }
+ )
+
+ it('refuses fallback when unrelated asks share a truncated opening title', async () => {
+ const prefix = 'A long instruction shared by several unrelated projects '.repeat(3)
+ const prompt = `${prefix}original task`
+ const session = await parse(prompt)
+ expect(session.title.endsWith('...')).toBe(true)
+ const rows = [historyRow(`${prefix}other task`, '/repo/other'), historyRow(prompt)]
+ expect((await resolver(rows).enrich(session, historyPath)).cwd).toBeNull()
+ })
+
+ it('normalizes opening whitespace consistently with history display', async () => {
+ const session = await parse('Locate\n the original workspace')
+ expect(
+ (await resolver([historyRow('Locate the original workspace')]).enrich(session, historyPath))
+ .cwd
+ ).toBe('/repo/original')
+ })
+
+ it('keeps the established title normalization of hidden opening context', async () => {
+ const session = await parse('Locate the workspaceHidden')
+ expect(session.title).toBe('Locate the workspace')
+ expect(
+ (await resolver([historyRow('Locate the workspace')]).enrich(session, historyPath)).cwd
+ ).toBe('/repo/original')
+ })
+
+ it('refuses identical duplicate rows and keeps the two-second timestamp window', async () => {
+ const prompt = 'Original request'
+ const session = await parse(prompt)
+ expect(
+ (await resolver([historyRow(prompt), historyRow(prompt)]).enrich(session, historyPath)).cwd
+ ).toBeNull()
+ for (const [offset, expected] of [
+ [2, '/repo/original'],
+ [2.001, null]
+ ] as const) {
+ const row = historyRow(prompt)
+ row.timestamp += offset
+ expect((await resolver([row]).enrich(session, historyPath)).cwd).toBe(expected)
+ }
+ })
+
+ it('leaves conflicting prompt/time matches unknown even when one row has an id', async () => {
+ const prompt = 'Repeated request'
+ const rows = [historyRow(prompt), historyRow(prompt, '/repo/other', 'other-id')]
+ expect((await resolver(rows).enrich(await parse(prompt), historyPath)).cwd).toBeNull()
+ })
+
+ it('gives an exact id authority over a conflicting prompt match', async () => {
+ const prompt = 'Repeated request'
+ const rows = [historyRow(prompt), historyRow('Different display', '/repo/exact', 'brain-id')]
+ expect((await resolver(rows).enrich(await parse(prompt), historyPath)).cwd).toBe('/repo/exact')
+ })
+
+ it('does not rescue conflicting exact ids through the prompt fallback', async () => {
+ const prompt = 'Repeated request'
+ const rows = [
+ historyRow('One', '/repo/one', 'brain-id'),
+ historyRow('Two', '/repo/two', 'brain-id'),
+ historyRow(prompt)
+ ]
+ expect((await resolver(rows).enrich(await parse(prompt), historyPath)).cwd).toBeNull()
+ })
+
+ it('keeps ambiguous project metadata authoritative over history and prompt fallback', async () => {
+ const prompt = 'Original request'
+ const read = createAntigravityWorkspaceResolver(async (path) => {
+ if (path === historyPath) {
+ return jsonLines([historyRow(prompt, '/repo/history', 'brain-id')])
+ }
+ if (path.endsWith('projects.json')) {
+ return JSON.stringify({ '/repo/one': 'project', '/repo/two': 'project' })
+ }
+ if (path.endsWith('conversation_metadata.json')) {
+ return JSON.stringify({
+ conversations: { 'brain-id': { summary: { ProjectID: 'project' } } }
+ })
+ }
+ return null
+ })
+ expect((await read.enrich(await parse(prompt), historyPath)).cwd).toBeNull()
+ })
+
+ it('never joins a later preview turn or a title-only history row', async () => {
+ const session = await parse('Opening request')
+ const rows = [historyRow('Later request 3'), historyRow(session.title, '/repo/wrong')]
+ rows[1].timestamp -= 60
+ expect((await resolver(rows).enrich(session, historyPath)).cwd).toBeNull()
+ })
+
+ it.each([null, 'invalid timestamp'])(
+ 'does not substitute createdAt for opening time %s',
+ async (time) => {
+ const prompt = 'Opening request'
+ const session = await parse(prompt, time)
+ const row = historyRow(prompt)
+ row.timestamp = Date.parse(session.createdAt ?? '') / 1000
+ expect((await resolver([row]).enrich(session, historyPath)).cwd).toBeNull()
+ }
+ )
+
+ it('does not let a later short prompt replace an oversized opening prompt', async () => {
+ const session = await parse('x'.repeat(4097))
+ expect(
+ (await resolver([historyRow('Later request 3')]).enrich(session, historyPath)).cwd
+ ).toBeNull()
+ })
+
+ it('keeps the opening association across incremental clones and preview shifts', async () => {
+ const prompt = 'Original incremental request'
+ const state = createAntigravitySessionResumeState(file)
+ state.consumeLine(JSON.stringify(user(prompt)))
+ const clone = state.clone()
+ for (const record of openingRecords('Unrelated later prompt')) {
+ clone.consumeLine(JSON.stringify(record))
+ }
+ const session = await clone.finalize('linux')
+ if (!session) {
+ throw new Error('Missing cloned session')
+ }
+ expect(session.previewMessagesTruncated).toBe(true)
+ expect((await resolver([historyRow(prompt)]).enrich(session, historyPath)).cwd).toBe(
+ '/repo/original'
+ )
+ expect((await state.finalize('linux'))?.previewMessages).toHaveLength(1)
+ })
+})
diff --git a/src/main/ai-vault/antigravity-recorded-history.test.ts b/src/main/ai-vault/antigravity-recorded-history.test.ts
new file mode 100644
index 00000000000..64c7e8380c3
--- /dev/null
+++ b/src/main/ai-vault/antigravity-recorded-history.test.ts
@@ -0,0 +1,60 @@
+import { homedir, tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
+import { expect, it } from 'vitest'
+import { scanAiVaultSessions } from './session-scanner'
+import { isolatedScanRoots } from './session-scanner-test-fixtures'
+import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
+
+it.skipIf(process.env.ORCA_REAL_ANTIGRAVITY_HISTORY !== '1')(
+ 'scans bounded existing official artifacts without publishing their content',
+ async () => {
+ const sandbox = await mkdtemp(join(tmpdir(), 'orca-agy-recorded-'))
+ try {
+ const home = homedir()
+ const rootPresence = await Promise.all(
+ ['antigravity-cli', 'antigravity-ide', 'antigravity'].map(async (origin) => {
+ const present = await stat(join(home, '.gemini', origin, 'brain')).then(
+ (value) => value.isDirectory(),
+ () => false
+ )
+ return { origin, present }
+ })
+ )
+ const result = await scanAiVaultSessions({
+ ...isolatedScanRoots(sandbox),
+ antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
+ antigravityAppHome: home,
+ includeAntigravityIdeSessions: true,
+ limit: 5,
+ limitPerAgent: 10
+ })
+ expect(result.issues.length).toBe(0)
+ expect(result.sessions.length).toBeGreaterThan(0)
+ expect(result.sessions.length).toBeLessThanOrEqual(5)
+ expect(result.sessions.some((session) => session.messageCount > 0)).toBe(true)
+ const proof = {
+ provenance:
+ 'Existing official recorded artifacts through production scanner; no live IDE/model claim',
+ rootPresence,
+ sessionCount: result.sessions.length,
+ issueCount: result.issues.length,
+ origins: result.sessions.map((session) => ({
+ origin: antigravitySessionOrigin(session.filePath),
+ fullTranscript: session.filePath.endsWith('transcript_full.jsonl'),
+ messageCount: session.messageCount,
+ hasWorkspace: session.cwd !== null,
+ cliResume: session.resumeCommand.includes('--conversation')
+ }))
+ }
+ if (process.env.ORCA_ANTIGRAVITY_HISTORY_PROOF_PATH) {
+ await writeFile(
+ process.env.ORCA_ANTIGRAVITY_HISTORY_PROOF_PATH,
+ `${JSON.stringify(proof, null, 2)}\n`
+ )
+ }
+ } finally {
+ await rm(sandbox, { recursive: true, force: true })
+ }
+ }
+)
diff --git a/src/main/ai-vault/antigravity-transcript-candidates.ts b/src/main/ai-vault/antigravity-transcript-candidates.ts
new file mode 100644
index 00000000000..74e3370ffb3
--- /dev/null
+++ b/src/main/ai-vault/antigravity-transcript-candidates.ts
@@ -0,0 +1,100 @@
+import { dirname, join } from 'node:path'
+import { wslGatedLstat } from '../native-chat/wsl-transcript-fs-access'
+import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
+import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
+import { recordSessionScanIssue } from './session-scan-issues'
+import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
+
+function transcriptDirectory(file: FileWithMtime): string {
+ return file.path
+ .split(/[\\/]+/)
+ .slice(0, -1)
+ .join('/')
+}
+
+/** Keep the file's own stat intact; alias recency only drives discovery/cutoff. */
+export function prioritizeAntigravityTranscriptCandidates(
+ candidates: T[],
+ isAntigravity: (candidate: T) => boolean
+): T[] {
+ const newest = new Map()
+ for (const candidate of candidates) {
+ if (isAntigravity(candidate)) {
+ const key = transcriptDirectory(candidate.file)
+ newest.set(key, Math.max(newest.get(key) ?? 0, candidate.file.mtimeMs))
+ }
+ }
+ return candidates
+ .map((candidate) =>
+ isAntigravity(candidate)
+ ? {
+ ...candidate,
+ file: {
+ ...candidate.file,
+ aliasMtimeMs: newest.get(transcriptDirectory(candidate.file))
+ }
+ }
+ : candidate
+ )
+ .sort((left, right) => {
+ const difference = (candidateFileTime(right.file) ?? 0) - (candidateFileTime(left.file) ?? 0)
+ if (difference !== 0) {
+ return difference
+ }
+ if (
+ isAntigravity(left) &&
+ isAntigravity(right) &&
+ transcriptDirectory(left.file) === transcriptDirectory(right.file)
+ ) {
+ return (
+ Number(right.file.path.endsWith('transcript_full.jsonl')) -
+ Number(left.file.path.endsWith('transcript_full.jsonl'))
+ )
+ }
+ return 0
+ })
+}
+
+export function candidateFileTime(file: FileWithMtime | undefined): number | undefined {
+ return file?.aliasMtimeMs ?? file?.mtimeMs
+}
+
+/** The recency cap may retain a newer compact file but omit its older full sibling. */
+export async function completeAntigravityTranscriptPairs(
+ discovery: SessionFileDiscovery,
+ issues: AiVaultScanIssue[]
+): Promise {
+ const files = new Map(discovery.files.map((file) => [file.path, file]))
+ for (const file of discovery.files) {
+ const sibling = join(
+ dirname(file.path),
+ file.path.endsWith('transcript_full.jsonl') ? 'transcript.jsonl' : 'transcript_full.jsonl'
+ )
+ if (files.has(sibling)) {
+ continue
+ }
+ try {
+ const observed = await wslGatedLstat(sibling, 'scan')
+ if (observed.isFile()) {
+ files.set(sibling, {
+ path: sibling,
+ mtimeMs: observed.mtimeMs,
+ modifiedAt: new Date(observed.mtimeMs).toISOString(),
+ sizeBytes: observed.size,
+ dev: observed.dev,
+ ino: observed.ino,
+ nlink: observed.nlink
+ })
+ }
+ } catch (error) {
+ if (error instanceof WslTranscriptFsError) {
+ recordSessionScanIssue(issues, {
+ agent: 'antigravity',
+ path: sibling,
+ message: error.message
+ })
+ }
+ }
+ }
+ return { ...discovery, files: [...files.values()] }
+}
diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts
index 0d1cbabb7c8..3dff9a9bd8e 100644
--- a/src/main/ai-vault/cached-session-list.ts
+++ b/src/main/ai-vault/cached-session-list.ts
@@ -85,7 +85,10 @@ export async function listAiVaultSessions(
options: { signal?: AbortSignal } = {}
): Promise {
// Scope paths change the result set, so they must be part of the cache key.
- const key = JSON.stringify({ scopePaths: [...new Set(args?.scopePaths ?? [])].sort() })
+ const key = JSON.stringify({
+ scopePaths: [...new Set(args?.scopePaths ?? [])].sort(),
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true
+ })
const depth = requestedAiVaultSessionDepth(args)
const scanKey = JSON.stringify({ key, depth })
const now = Date.now()
@@ -113,6 +116,7 @@ export async function listAiVaultSessions(
start: async (scanSignal) => {
const result = await scanAiVaultSessionsInBackground(
{
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
limit: args?.limit,
unlimited: args?.unlimited,
scopePaths: args?.scopePaths,
diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts
index a336af84a06..7fe45d9f1a0 100644
--- a/src/main/ai-vault/remote-session-scan-concurrency.ts
+++ b/src/main/ai-vault/remote-session-scan-concurrency.ts
@@ -16,11 +16,15 @@ export function limitRemoteScanFilesystemConcurrency(
return {
openCode: provider.openCode,
readDir: (dirPath) => gate(() => provider.readDir(dirPath)),
- readFile: (filePath) => gate(() => provider.readFile(filePath)),
+ readFile: (filePath, limits) => gate(() => provider.readFile(filePath, limits)),
stat: (filePath) => gate(() => provider.stat(filePath)),
...(provider.readTranscriptBytes
? {
- readTranscriptBytes: async function* (path: string, signal?: AbortSignal) {
+ readTranscriptBytes: async function* (
+ path: string,
+ signal?: AbortSignal,
+ options?: { regularFileOnly: true; maxBytes: number }
+ ) {
let enter!: () => void
let release!: () => void
const entered = new Promise((resolve) => {
@@ -35,7 +39,7 @@ export function limitRemoteScanFilesystemConcurrency(
})
await entered
try {
- yield* provider.readTranscriptBytes!(path, signal)
+ yield* provider.readTranscriptBytes!(path, signal, options)
} finally {
release()
await held
diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts
index f6b13b24e30..9f01855097c 100644
--- a/src/main/ai-vault/remote-session-scanner-discovery.ts
+++ b/src/main/ai-vault/remote-session-scanner-discovery.ts
@@ -121,7 +121,11 @@ async function listRemoteFixedChildFiles(
// serialized SSH readDir round trips for every conversation directory.
return entries
.filter((entry) => entry.isDirectory && !entry.isSymlink)
- .map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments))
+ .flatMap((entry) =>
+ [segments, ...(source.additionalFixedChildFileSegments ?? [])].map((fileSegments) =>
+ joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...fileSegments)
+ )
+ )
.filter((path) => source.filePredicate?.(path) ?? true)
}
diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts
index 16b0938fee9..88d5a7ad2eb 100644
--- a/src/main/ai-vault/remote-session-scanner-sources.ts
+++ b/src/main/ai-vault/remote-session-scanner-sources.ts
@@ -1,3 +1,7 @@
+import {
+ ANTIGRAVITY_HISTORY_ROOTS,
+ type AntigravitySessionOrigin
+} from '../../shared/antigravity-session-origin'
import { parseQoderSessionContent } from './session-scanner-qoder-parser'
import { remoteSessionDocumentParsers } from './remote-session-document-parsers'
import type { RemoteSessionContent } from './remote-session-content-lines'
@@ -48,7 +52,8 @@ type RemoteContentParser = (
export function remoteSessionSources(
remoteHome: string,
- hostPlatform: RemoteHostPlatform
+ hostPlatform: RemoteHostPlatform,
+ includeAntigravityIdeSessions = false
): RemoteSessionSource[] {
return [
...remoteCodexSources(remoteHome, hostPlatform),
@@ -88,7 +93,10 @@ export function remoteSessionSources(
),
partitionSubagentTranscripts: partitionSubagentTranscriptPaths
},
- remoteAntigravitySource(remoteHome, hostPlatform),
+ ...(includeAntigravityIdeSessions
+ ? ANTIGRAVITY_HISTORY_ROOTS
+ : (['antigravity-cli'] as const)
+ ).map((origin) => remoteAntigravitySource(remoteHome, hostPlatform, origin)),
source(
'gemini',
remoteHome,
@@ -168,9 +176,10 @@ export function remoteSessionSources(
function remoteAntigravitySource(
remoteHome: string,
- hostPlatform: RemoteHostPlatform
+ hostPlatform: RemoteHostPlatform,
+ origin: AntigravitySessionOrigin
): RemoteSessionSource {
- const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli')
+ const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', origin)
const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl')
const parse = async (
file: FileWithMtime,
@@ -192,6 +201,7 @@ function remoteAntigravitySource(
extensions: ['.jsonl'],
filePredicate: isAntigravityTranscriptPath,
fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'],
+ additionalFixedChildFileSegments: [['.system_generated', 'logs', 'transcript_full.jsonl']],
parse,
parseLines: parse
}
diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts
index 408a2ca47be..1ae84858571 100644
--- a/src/main/ai-vault/remote-session-scanner-types.ts
+++ b/src/main/ai-vault/remote-session-scanner-types.ts
@@ -27,7 +27,11 @@ export type RemoteSessionFilesystemProvider = Pick<
'readDir' | 'readFile' | 'stat'
> & {
/** Available only beside the execution host's disk; never opens a client path. */
- readTranscriptBytes?: (path: string, signal?: AbortSignal) => AsyncIterable
+ readTranscriptBytes?: (
+ path: string,
+ signal?: AbortSignal,
+ options?: { regularFileOnly: true; maxBytes: number }
+ ) => AsyncIterable
/** Execution-host database access; absent from remote filesystem RPC providers. */
openCode?: RemoteOpenCodeSessionReader
}
@@ -55,6 +59,7 @@ export type RemoteSessionSource = {
directoryPredicate?: (name: string, depth: number) => boolean
// A canonical file directly beneath every top-level session directory.
fixedChildFileSegments?: readonly string[]
+ additionalFixedChildFileSegments?: readonly (readonly string[])[]
// Sibling-subagent layouts (Claude `/subagents/`, OMP's same-named
// artifact dir): count subagent transcripts from the walked listing and drop
// them from candidates instead of indexing them as sessions.
diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts
index 17f9bdc35a8..74d5a0d22aa 100644
--- a/src/main/ai-vault/remote-session-scanner.ts
+++ b/src/main/ai-vault/remote-session-scanner.ts
@@ -1,3 +1,8 @@
+import {
+ candidateFileTime,
+ prioritizeAntigravityTranscriptCandidates
+} from './antigravity-transcript-candidates'
+import { readRemoteAntigravityIndex } from './antigravity-index-reader'
import { parseRemoteSessionTranscript } from './remote-session-transcript-read'
import { BinarySessionTranscriptError } from './remote-session-content-lines'
import type {
@@ -52,6 +57,7 @@ export async function scanRemoteAiVaultSessions(args: {
executionHostId: ExecutionHostId
remoteHome: string
hostPlatform: RemoteHostPlatform
+ includeAntigravityIdeSessions?: boolean
limit?: number
unlimited?: boolean
scopePaths?: readonly string[]
@@ -69,25 +75,19 @@ export async function scanRemoteAiVaultSessions(args: {
hostPlatform: args.hostPlatform,
signal: args.signal,
titleCaches: new Map(),
- antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => {
- try {
- throwIfAiVaultScanCancelled(args.signal)
- const read = await provider.readFile(historyPath)
- throwIfAiVaultScanCancelled(args.signal)
- return read.isBinary ? null : read.content
- } catch (error) {
- if (error instanceof Error && error.name === 'AbortError') {
- throw error
- }
- return null
- }
- })
+ antigravityWorkspaceResolver: createAntigravityWorkspaceResolver((path) =>
+ readRemoteAntigravityIndex(provider, path, args.signal)
+ )
}
- const candidates = dedupeCodexRolloutFileAliases(
+ const discoveredCandidates = dedupeCodexRolloutFileAliases(
(
await mapRemoteScanBatches(
[
- ...remoteSessionSources(args.remoteHome, args.hostPlatform),
+ ...remoteSessionSources(
+ args.remoteHome,
+ args.hostPlatform,
+ args.includeAntigravityIdeSessions
+ ),
...remoteOpenCodeSources(
provider.openCode,
limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER +
@@ -109,6 +109,10 @@ export async function scanRemoteAiVaultSessions(args: {
}
)
+ const candidates = prioritizeAntigravityTranscriptCandidates(
+ discoveredCandidates,
+ (candidate) => candidate.source.agent === 'antigravity'
+ )
const parsed = await parseRemoteSessionCandidates({
candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER),
context,
@@ -153,7 +157,9 @@ async function parseRemoteSessionCandidates(args: {
let index = 0
while (index < args.candidates.length) {
- if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
+ if (
+ canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file))
+ ) {
break
}
diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts
index b876c029b45..1640f2f4b30 100644
--- a/src/main/ai-vault/runtime-session-scanner.ts
+++ b/src/main/ai-vault/runtime-session-scanner.ts
@@ -56,6 +56,7 @@ export async function scanRuntimeAiVaultSessions(
environmentId,
'aiVault.listSessions',
{
+ includeAntigravityIdeSessions: args.includeAntigravityIdeSessions,
limit: args.limit,
unlimited: args.unlimited,
force: args.force,
diff --git a/src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts b/src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts
new file mode 100644
index 00000000000..4a9da9af3e2
--- /dev/null
+++ b/src/main/ai-vault/session-parse-cache-antigravity-upgrade.test.ts
@@ -0,0 +1,110 @@
+import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, expect, it } from 'vitest'
+import {
+ ensureSessionParseCacheLoaded,
+ flushSessionParseCachePersist,
+ initSessionParseCachePersistence,
+ resetSessionParseCachePersistenceForTests,
+ scheduleSessionParseCachePersist
+} from './session-parse-cache-persistence'
+import {
+ createSessionParseStats,
+ parseAgentSessionFileCached,
+ resetSessionParseCacheForTests,
+ snapshotSessionParseCacheForPersistence
+} from './session-scanner-parse-cache'
+import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
+import { writeAntigravityTranscript } from './session-scanner-test-fixtures'
+import type { SessionFileCandidate } from './session-scanner-types'
+
+let root: string | undefined
+afterEach(async () => {
+ resetSessionParseCacheForTests()
+ resetSessionParseCachePersistenceForTests()
+ if (root) {
+ await rm(root, { recursive: true, force: true })
+ }
+})
+
+async function candidate(): Promise {
+ root = await mkdtemp(join(tmpdir(), 'orca-agy-cache-upgrade-'))
+ const path = await writeAntigravityTranscript(
+ join(root, 'brain'),
+ 'conversation',
+ Array.from({ length: 8 }, (_, index) => ({
+ source: 'USER',
+ type: 'REQUEST',
+ created_at: new Date(Date.parse('2026-07-15T11:39:10Z') + index * 60_000).toISOString(),
+ content: index === 0 ? 'Original request' : `Follow-up ${index}`
+ }))
+ )
+ const fileStat = await stat(path)
+ return {
+ agent: 'antigravity',
+ codexHome: null,
+ file: {
+ path,
+ mtimeMs: fileStat.mtimeMs,
+ modifiedAt: fileStat.mtime.toISOString(),
+ sizeBytes: fileStat.size
+ }
+ }
+}
+
+it('reparses an unchanged pre-fix cached Antigravity transcript after an update', async () => {
+ const input = await candidate()
+ await parseAgentSessionFileCached(input, 'linux')
+ const entries = snapshotSessionParseCacheForPersistence().map(([path, entry]) => [
+ path,
+ {
+ ...entry,
+ session: entry.session && { ...entry.session, antigravityOpeningPrompt: undefined }
+ }
+ ])
+ const cacheFile = join(root ?? '', 'cache.json')
+ await writeFile(cacheFile, JSON.stringify({ schemaVersion: 3, appVersion: 'pre-fix', entries }))
+ resetSessionParseCacheForTests()
+ initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'fixed' })
+ await ensureSessionParseCacheLoaded()
+ const stats = createSessionParseStats()
+ const session = await parseAgentSessionFileCached(input, 'linux', stats)
+ expect(stats.fullParses).toBe(1)
+ expect(stats.reused).toBe(0)
+ if (!session) {
+ throw new Error('Missing reparsed session')
+ }
+ expect(session.previewMessagesTruncated).toBe(true)
+ const resolver = createAntigravityWorkspaceResolver(async (path) =>
+ path === 'history.jsonl'
+ ? JSON.stringify({
+ display: 'Original request',
+ timestamp: Date.parse('2026-07-15T11:39:10Z'),
+ workspace: '/repo/original'
+ })
+ : null
+ )
+ expect((await resolver.enrich(session, 'history.jsonl')).cwd).toBe('/repo/original')
+})
+
+it('retains the opening identity in current caches without retaining full prompt text', async () => {
+ const input = await candidate()
+ const cacheFile = join(root ?? '', 'cache.json')
+ initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'fixed' })
+ const stats = createSessionParseStats()
+ const original = await parseAgentSessionFileCached(input, 'linux', stats)
+ scheduleSessionParseCachePersist(stats)
+ await flushSessionParseCachePersist()
+ resetSessionParseCacheForTests()
+ resetSessionParseCachePersistenceForTests()
+ initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'next-release' })
+ await ensureSessionParseCacheLoaded()
+ const reusedStats = createSessionParseStats()
+ const reused = await parseAgentSessionFileCached(input, 'linux', reusedStats)
+ expect(reused).toEqual(original)
+ expect(reusedStats.reused).toBe(1)
+ expect(reusedStats.fullParses).toBe(0)
+ expect(reused?.antigravityOpeningPrompt?.timestamp).toBe('2026-07-15T11:39:10.000Z')
+ expect(reused?.firstUserPrompt).toBeUndefined()
+})
diff --git a/src/main/ai-vault/session-parse-cache-bounds.test.ts b/src/main/ai-vault/session-parse-cache-bounds.test.ts
index 6ad56a3e4f5..51b7b293a18 100644
--- a/src/main/ai-vault/session-parse-cache-bounds.test.ts
+++ b/src/main/ai-vault/session-parse-cache-bounds.test.ts
@@ -25,6 +25,7 @@ import {
import {
SESSION_PARSE_CACHE_JSON_LIMITS,
SESSION_PARSE_CACHE_MAX_BYTES,
+ SESSION_PARSE_CACHE_SCHEMA_VERSION,
serializeSessionParseCacheSnapshotPiecesCooperatively
} from './session-parse-cache-snapshot-serialization'
@@ -59,7 +60,14 @@ it('loads the newest 4096 unique rows across duplicate-heavy input and keeps in-
entries.push(...Array.from({ length: 5000 }, (_, index) => row('p4199', 5000 + index)))
entries.push(row('in-process', 1))
seedSessionParseCache([row('in-process', 20_000)])
- await writeFile(file, JSON.stringify({ schemaVersion: 3, appVersion: 'old-release', entries }))
+ await writeFile(
+ file,
+ JSON.stringify({
+ schemaVersion: SESSION_PARSE_CACHE_SCHEMA_VERSION,
+ appVersion: 'old-release',
+ entries
+ })
+ )
await ensureSessionParseCacheLoaded()
expect(snapshotSessionParseCacheForPersistence()).toHaveLength(4096)
expect(getSessionParseCacheEntry('p4199')?.mtimeMs).toBe(9999)
@@ -93,10 +101,10 @@ it('rejects an oversized sparse file before decoding and preserves resident work
it('rejects excessive tokens, depth, and malformed older rows before seeding any tail', async () => {
for (const raw of [
- `{"schemaVersion":3,"appVersion":"old","entries":[],"wide":[${'0,'.repeat(1_000_000)}0]}`,
- `{"schemaVersion":3,"appVersion":"old","entries":[],"deep":${'['.repeat(33)}0${']'.repeat(33)}}`,
+ `{"schemaVersion":${SESSION_PARSE_CACHE_SCHEMA_VERSION},"appVersion":"old","entries":[],"wide":[${'0,'.repeat(1_000_000)}0]}`,
+ `{"schemaVersion":${SESSION_PARSE_CACHE_SCHEMA_VERSION},"appVersion":"old","entries":[],"deep":${'['.repeat(33)}0${']'.repeat(33)}}`,
JSON.stringify({
- schemaVersion: 3,
+ schemaVersion: SESSION_PARSE_CACHE_SCHEMA_VERSION,
appVersion: 'old',
entries: [['bad', {}], ...Array.from({ length: 4200 }, (_, index) => row(`p${index}`, index))]
})
diff --git a/src/main/ai-vault/session-parse-cache-persistence.test.ts b/src/main/ai-vault/session-parse-cache-persistence.test.ts
index 32328e89837..5be88cfe669 100644
--- a/src/main/ai-vault/session-parse-cache-persistence.test.ts
+++ b/src/main/ai-vault/session-parse-cache-persistence.test.ts
@@ -162,6 +162,7 @@ const CACHED_SESSION_FIELDS = {
totalTokens: true,
previewMessages: true,
previewMessagesTruncated: true,
+ antigravityOpeningPrompt: true,
firstUserPrompt: true,
lastUserPrompt: true,
queuedMessageCount: true,
diff --git a/src/main/ai-vault/session-parse-cache-snapshot-serialization.ts b/src/main/ai-vault/session-parse-cache-snapshot-serialization.ts
index 9900dcfa8de..a3a3fecdda2 100644
--- a/src/main/ai-vault/session-parse-cache-snapshot-serialization.ts
+++ b/src/main/ai-vault/session-parse-cache-snapshot-serialization.ts
@@ -11,7 +11,7 @@ import {
} from '../../shared/node-bounded-json-stringify'
import type { PersistedSessionParseCacheEntry } from './session-parse-cache-store'
-export const SESSION_PARSE_CACHE_SCHEMA_VERSION = 3
+export const SESSION_PARSE_CACHE_SCHEMA_VERSION = 4
export const SESSION_PARSE_CACHE_MAX_BYTES = 64 * 1024 * 1024
export const SESSION_PARSE_CACHE_JSON_LIMITS = {
structuralTokens: 1_000_000,
diff --git a/src/main/ai-vault/session-root-dedup.ts b/src/main/ai-vault/session-root-dedup.ts
index da1c239c76c..378ff86809d 100644
--- a/src/main/ai-vault/session-root-dedup.ts
+++ b/src/main/ai-vault/session-root-dedup.ts
@@ -1,8 +1,16 @@
+import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import { codexSessionAliasKey, codexSessionAliasBeats } from './codex-session-root-dedup'
import { sessionSortTime } from './session-scanner-accumulator'
function sessionAliasKey(session: AiVaultSession): string | null {
+ if (session.agent === 'antigravity') {
+ const root = session.filePath
+ .split(/[\\/]+/)
+ .slice(0, -4)
+ .join('/')
+ return `antigravity\0${session.executionHostId}\0${antigravitySessionOrigin(session.filePath) ?? 'cli'}\0${root}\0${session.sessionId}`
+ }
if (session.agent !== 'devin') {
const codexKey = codexSessionAliasKey(session)
return codexKey ? `codex\0${codexKey}` : null
@@ -13,6 +21,16 @@ function sessionAliasKey(session: AiVaultSession): string | null {
}
function sessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean {
+ if (candidate.agent === 'antigravity') {
+ if (candidate.messageCount > 0 !== best.messageCount > 0) {
+ return candidate.messageCount > 0
+ }
+ const full = (path: string): boolean => path.split(/[\\/]+/).at(-1) === 'transcript_full.jsonl'
+ if (full(candidate.filePath) !== full(best.filePath)) {
+ return full(candidate.filePath)
+ }
+ return sessionSortTime(candidate) > sessionSortTime(best)
+ }
if (candidate.agent !== 'devin') {
return codexSessionAliasBeats(candidate, best)
}
diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts
index 34f1134f270..cb664b41581 100644
--- a/src/main/ai-vault/session-scanner-accumulator.ts
+++ b/src/main/ai-vault/session-scanner-accumulator.ts
@@ -161,6 +161,9 @@ export function finalizeSession(
totalTokens: accumulator.totalTokens,
previewMessages: accumulator.previewMessages,
...(accumulator.previewMessagesTruncated ? { previewMessagesTruncated: true } : {}),
+ ...(accumulator.antigravityOpeningPrompt
+ ? { antigravityOpeningPrompt: accumulator.antigravityOpeningPrompt }
+ : {}),
...(accumulator.firstUserPrompt ? { firstUserPrompt: accumulator.firstUserPrompt } : {}),
...(accumulator.lastUserPrompt ? { lastUserPrompt: accumulator.lastUserPrompt } : {}),
queuedMessageCount: accumulator.queuedMessageCount,
diff --git a/src/main/ai-vault/session-scanner-antigravity-history.ts b/src/main/ai-vault/session-scanner-antigravity-history.ts
index ccfb506b7f7..885b8e6ca74 100644
--- a/src/main/ai-vault/session-scanner-antigravity-history.ts
+++ b/src/main/ai-vault/session-scanner-antigravity-history.ts
@@ -1,7 +1,17 @@
+import {
+ ANTIGRAVITY_INDEX_MAX_BYTES,
+ antigravityCachePath,
+ antigravityMetadataWorkspaces,
+ readBoundedAntigravityIndex
+} from './session-scanner-antigravity-metadata'
import type { AiVaultSession } from '../../shared/ai-vault-types'
-import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
+import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
+import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
+import { isWslUncPath } from '../../shared/wsl-paths'
+import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
-import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values'
+import { parseJsonObject, timestampMs } from './session-scanner-values'
+import { antigravityHistoryPromptHash } from './antigravity-history-prompt'
const HISTORY_MATCH_WINDOW_MS = 2_000
@@ -12,10 +22,39 @@ const HISTORY_MATCH_WINDOW_MS = 2_000
* null lists the session with a missing cwd and no retry signal, and the
* resolver's memo relies on the rejection to evict rather than pin a stall.
*/
-export async function readLocalAntigravityHistory(path: string): Promise {
+export async function readLocalAntigravityHistory(
+ path: string,
+ signal?: AbortSignal
+): Promise {
try {
- return await wslGatedReadFile(path, 'utf-8', 'scan')
+ throwIfAiVaultScanCancelled(signal)
+ if (!isWslUncPath(path)) {
+ const read = await readNodeFileWithinLimit(path, ANTIGRAVITY_INDEX_MAX_BYTES, {
+ regularFileOnly: true,
+ signal
+ })
+ return read.buffer.toString('utf8')
+ }
+ const stats = await wslGatedStat(path, 'scan', signal)
+ if (!stats.isFile() || stats.size > ANTIGRAVITY_INDEX_MAX_BYTES) {
+ return null
+ }
+ const input = openTranscriptReadStream(
+ path,
+ { end: ANTIGRAVITY_INDEX_MAX_BYTES },
+ 'scan',
+ signal
+ )
+ try {
+ return await readBoundedAntigravityIndex(input)
+ } finally {
+ input.destroy()
+ }
} catch (error) {
+ throwIfAiVaultScanCancelled(signal)
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw error
+ }
if (error instanceof WslTranscriptFsError) {
throw error
}
@@ -28,7 +67,10 @@ type AntigravityHistoryEntry = {
workspace: string
}
-type AntigravityHistoryIndex = Map
+type AntigravityHistoryIndex = {
+ byPrompt: Map
+ byId: Map
+}
export type AntigravityWorkspaceResolver = {
enrich(session: AiVaultSession, historyPath: string): Promise
@@ -50,7 +92,27 @@ export function createAntigravityWorkspaceResolver(
// so it must not be memoized — every later session under this history
// file would inherit the rejection for the process lifetime.
const pending: Promise = readHistory(historyPath)
- .then(indexAntigravityHistory)
+ .then(async (history) => {
+ const index = indexAntigravityHistory(history)
+ const [metadata, projects, lastConversations] = await Promise.all(
+ ['conversation_metadata.json', 'projects.json', 'last_conversations.json'].map(
+ (name) => readHistory(antigravityCachePath(historyPath, name))
+ )
+ )
+ const paths = antigravityMetadataWorkspaces({
+ metadata: metadata ?? null,
+ projects: projects ?? null,
+ lastConversations: lastConversations ?? null
+ })
+ for (const [id, path] of paths) {
+ if (!index.byId.has(id)) {
+ index.byId.set(id, path)
+ } else if (index.byId.get(id) !== path) {
+ index.byId.set(id, null)
+ }
+ }
+ return index
+ })
.catch((error: unknown) => {
if (indexes.get(historyPath) === pending) {
indexes.delete(historyPath)
@@ -67,18 +129,32 @@ export function createAntigravityWorkspaceResolver(
}
function indexAntigravityHistory(content: string | null): AntigravityHistoryIndex {
- const index: AntigravityHistoryIndex = new Map()
- for (const line of content?.split(/\r?\n/) ?? []) {
+ const index: AntigravityHistoryIndex = { byPrompt: new Map(), byId: new Map() }
+ if (content && Buffer.byteLength(content) > ANTIGRAVITY_INDEX_MAX_BYTES) {
+ return index
+ }
+ for (const line of content?.split(/\r?\n/).slice(0, 10_000) ?? []) {
const record = parseJsonObject(line)
- const display = typeof record?.display === 'string' ? normalizeTitleText(record.display) : null
+ const promptHash = antigravityHistoryPromptHash(record?.display)
const workspace = typeof record?.workspace === 'string' ? record.workspace.trim() : ''
const entryTimestampMs = timestampMs(record?.timestamp)
- if (!display || !workspace || !Number.isFinite(entryTimestampMs)) {
+ const id = typeof record?.conversationId === 'string' ? record.conversationId : null
+ if (!workspace || workspace.length > 4096 || !Number.isFinite(entryTimestampMs)) {
continue
}
- const entries = index.get(display) ?? []
+ if (id) {
+ if (!index.byId.has(id)) {
+ index.byId.set(id, workspace)
+ } else if (index.byId.get(id) !== workspace) {
+ index.byId.set(id, null)
+ }
+ }
+ if (!promptHash) {
+ continue
+ }
+ const entries = index.byPrompt.get(promptHash) ?? []
entries.push({ timestampMs: entryTimestampMs, workspace })
- index.set(display, entries)
+ index.byPrompt.set(promptHash, entries)
}
return index
}
@@ -87,22 +163,18 @@ function findAntigravityWorkspace(
session: AiVaultSession,
index: AntigravityHistoryIndex
): string | null {
- // Why: truncated titles are not prompt identities; long worker prompts often
- // share the same 96-character prefix across unrelated workspaces.
- if (session.title.endsWith('...')) {
+ if (index.byId.has(session.sessionId)) {
+ return index.byId.get(session.sessionId) ?? null
+ }
+ const opening = session.antigravityOpeningPrompt
+ const promptTimestampMs = timestampMs(opening?.timestamp)
+ // Titles, createdAt and rolling previews cannot identify the original prompt.
+ if (!opening || !Number.isFinite(promptTimestampMs)) {
return null
}
- const firstTitledUserTimestamp = session.previewMessages.find(
- (message) => message.role === 'user' && normalizeTitleText(message.text) === session.title
- )?.timestamp
- const promptTimestampMs = timestampMs(firstTitledUserTimestamp ?? session.createdAt)
- if (!Number.isFinite(promptTimestampMs)) {
- return null
- }
- const matches = (index.get(session.title) ?? []).filter(
+ const matches = (index.byPrompt.get(opening.hash) ?? []).filter(
(entry) => Math.abs(entry.timestampMs - promptTimestampMs) <= HISTORY_MATCH_WINDOW_MS
)
- // Why: history rows have no conversation id. A unique prompt/time match is
- // evidence for cwd; ambiguity must stay unknown instead of crossing projects.
+ // Exact prompt/time fallback is valid only when a single history row matches.
return matches.length === 1 ? (matches[0]?.workspace ?? null) : null
}
diff --git a/src/main/ai-vault/session-scanner-antigravity-metadata.ts b/src/main/ai-vault/session-scanner-antigravity-metadata.ts
new file mode 100644
index 00000000000..1f1c4263fd0
--- /dev/null
+++ b/src/main/ai-vault/session-scanner-antigravity-metadata.ts
@@ -0,0 +1,84 @@
+import { parseJsonObject } from './session-scanner-values'
+
+export const ANTIGRAVITY_INDEX_MAX_BYTES = 4 * 1024 * 1024
+const MAX_INDEX_ENTRIES = 10_000
+
+type JsonRecord = Record
+function isRecord(value: unknown): value is JsonRecord {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+}
+function record(value: unknown): JsonRecord | null {
+ return isRecord(value) ? value : null
+}
+function entries(value: unknown): [string, unknown][] {
+ const object = record(value)
+ return object ? Object.entries(object).slice(0, MAX_INDEX_ENTRIES) : []
+}
+function parse(content: string | null): JsonRecord | null {
+ return content && Buffer.byteLength(content) <= ANTIGRAVITY_INDEX_MAX_BYTES
+ ? parseJsonObject(content)
+ : null
+}
+function unique(map: Map, id: string, path: string | null): void {
+ if (!id || id.length > 512 || (path !== null && (!path || path.length > 4096))) {
+ return
+ }
+ if (map.has(id) && map.get(id) !== path) {
+ map.set(id, null)
+ } else if (!map.has(id)) {
+ map.set(id, path)
+ }
+}
+
+export function antigravityCachePath(historyPath: string, fileName: string): string {
+ const separator = historyPath.includes('\\') ? '\\' : '/'
+ return `${historyPath.replace(/[\\/]history\.jsonl$/, '') + separator}cache${separator}${fileName}`
+}
+
+export function antigravityMetadataWorkspaces(contents: {
+ metadata: string | null
+ projects: string | null
+ lastConversations: string | null
+}): Map {
+ const projectPaths = new Map()
+ for (const [key, value] of entries(parse(contents.projects))) {
+ if (typeof value !== 'string') {
+ continue
+ }
+ if (/^(?:[/\\]|[A-Za-z]:[/\\])/.test(key)) {
+ unique(projectPaths, value, key)
+ } else if (/^(?:[/\\]|[A-Za-z]:[/\\])/.test(value)) {
+ unique(projectPaths, key, value)
+ }
+ }
+ const paths = new Map()
+ for (const [path, id] of entries(parse(contents.lastConversations))) {
+ if (typeof id === 'string' && /^(?:[/\\]|[A-Za-z]:[/\\])/.test(path)) {
+ unique(paths, id, path)
+ }
+ }
+ for (const [id, value] of entries(parse(contents.metadata)?.conversations)) {
+ const summary = record(record(value)?.summary)
+ const projectId = summary?.ProjectID
+ const path = typeof projectId === 'string' ? projectPaths.get(projectId) : undefined
+ if (path !== undefined) {
+ unique(paths, id, path)
+ }
+ }
+ return paths
+}
+
+export async function readBoundedAntigravityIndex(
+ chunks: AsyncIterable
+): Promise {
+ const retained: Buffer[] = []
+ let bytes = 0
+ for await (const chunk of chunks) {
+ bytes += chunk.length
+ if (bytes > ANTIGRAVITY_INDEX_MAX_BYTES) {
+ return null
+ }
+ retained.push(chunk)
+ }
+ return Buffer.concat(retained, bytes).toString('utf8')
+}
diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts
index 775cc228370..c391f01bba7 100644
--- a/src/main/ai-vault/session-scanner-antigravity-parser.ts
+++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts
@@ -2,17 +2,19 @@ import {
remoteSessionContentLines,
type RemoteSessionContent
} from './remote-session-content-lines'
-import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
-import { createInterface } from 'node:readline'
+import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
+import { MAX_SESSION_TRANSCRIPT_RECORD_BYTES } from './session-transcript-record-budget'
import type { AiVaultSession } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
accumulatorFoldResumeState,
addPreviewMessage,
createAccumulator,
+ timestampIso,
updateTimeline
} from './session-scanner-accumulator'
import { antigravityConversationIdFromTranscriptPath } from './session-scanner-antigravity-paths'
+import { antigravityHistoryPromptHash } from './antigravity-history-prompt'
import type {
FileWithMtime,
ResumableSessionParseState,
@@ -31,16 +33,16 @@ export async function parseAntigravitySessionFile(
platform: NodeJS.Platform = process.platform,
messages?: TranscriptMessageSink
): Promise {
- const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
- const lines = createInterface({ input, crlfDelay: Infinity })
- try {
- return await parseAntigravitySessionLines({ file, lines, platform, messages })
- } finally {
- // readline.close() leaves the underlying stream open; destroy it so a
- // mid-parse throw cannot leak the gated transcript handle.
- lines.close()
- input.destroy()
+ const state = createAntigravitySessionResumeState(file, messages)
+ const read = await consumeCompleteJsonlLines({
+ path: file.path,
+ start: 0,
+ onLine: (line) => state.consumeLine(line)
+ })
+ if (read.trailingPartialLine) {
+ state.consumeLine(read.trailingPartialLine)
}
+ return state.finalize(platform)
}
export async function parseAntigravitySessionContent(
@@ -86,6 +88,9 @@ async function parseAntigravitySessionLines(args: {
}
function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: string): void {
+ if (Buffer.byteLength(line) > MAX_SESSION_TRANSCRIPT_RECORD_BYTES) {
+ return
+ }
const record = parseJsonObject(line)
if (!record) {
return
@@ -105,6 +110,12 @@ function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: str
return
}
accumulator.messageCount++
+ if (accumulator.antigravityOpeningPrompt === undefined) {
+ const hash = antigravityHistoryPromptHash(request)
+ accumulator.antigravityOpeningPrompt = hash
+ ? { hash, timestamp: timestampIso(record.created_at) }
+ : null
+ }
accumulator.title ??= normalizeTitleText(request)
addPreviewMessage(accumulator, { role: 'user', text: request, timestamp: record.created_at })
return
diff --git a/src/main/ai-vault/session-scanner-antigravity-paths.ts b/src/main/ai-vault/session-scanner-antigravity-paths.ts
index c505cb7e5ee..1930d78270b 100644
--- a/src/main/ai-vault/session-scanner-antigravity-paths.ts
+++ b/src/main/ai-vault/session-scanner-antigravity-paths.ts
@@ -8,7 +8,9 @@ export function antigravityConversationIdFromTranscriptPath(filePath: string): s
const segments = pathSegments(filePath)
const transcriptIndex = segments.length - 1
if (
- segments[transcriptIndex] !== ANTIGRAVITY_TRANSCRIPT_FILE ||
+ ![ANTIGRAVITY_TRANSCRIPT_FILE, 'transcript_full.jsonl'].includes(
+ segments[transcriptIndex] ?? ''
+ ) ||
segments[transcriptIndex - 1] !== ANTIGRAVITY_LOGS_DIR ||
segments[transcriptIndex - 2] !== ANTIGRAVITY_SYSTEM_DIR
) {
diff --git a/src/main/ai-vault/session-scanner-antigravity-sources.ts b/src/main/ai-vault/session-scanner-antigravity-sources.ts
index a79edc380e6..707384cd861 100644
--- a/src/main/ai-vault/session-scanner-antigravity-sources.ts
+++ b/src/main/ai-vault/session-scanner-antigravity-sources.ts
@@ -1,3 +1,5 @@
+import { completeAntigravityTranscriptPairs } from './antigravity-transcript-candidates'
+import { ANTIGRAVITY_HISTORY_ROOTS } from '../../shared/antigravity-session-origin'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
@@ -8,17 +10,28 @@ import {
import { discoverFiles } from './session-scanner-discovery'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
-const ANTIGRAVITY_BRAIN_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'brain')
-
export function antigravityDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
limit: number,
issues: AiVaultScanIssue[]
): Promise[] {
+ const origins =
+ options.includeAntigravityIdeSessions === true
+ ? ANTIGRAVITY_HISTORY_ROOTS
+ : (['antigravity-cli'] as const)
+ const home = options.antigravityAppHome ?? homedir()
const rootDirs = [
- options.antigravityBrainDir ?? ANTIGRAVITY_BRAIN_DIR,
- ...wslHomeDirs.map((homeDir) => join(homeDir, '.gemini', 'antigravity-cli', 'brain'))
+ options.antigravityBrainDir ?? join(home, '.gemini', 'antigravity-cli', 'brain'),
+ ...(options.includeAntigravityIdeSessions === true &&
+ (!options.antigravityBrainDir || options.antigravityAppHome)
+ ? origins
+ .filter((origin) => origin !== 'antigravity-cli')
+ .map((origin) => join(home, '.gemini', origin, 'brain'))
+ : []),
+ ...wslHomeDirs.flatMap((homeDir) =>
+ origins.map((origin) => join(homeDir, '.gemini', origin, 'brain'))
+ )
]
return rootDirs.map((rootDir) =>
discoverFiles({
@@ -29,6 +42,6 @@ export function antigravityDiscoveries(
extensions: ['.jsonl'],
filePredicate: isAntigravityTranscriptPath,
directoryPredicate: shouldDescendAntigravityBrainDirectory
- })
+ }).then((discovery) => completeAntigravityTranscriptPairs(discovery, issues))
)
}
diff --git a/src/main/ai-vault/session-scanner-candidates.ts b/src/main/ai-vault/session-scanner-candidates.ts
index 80f146367e5..a03c7ff5f5a 100644
--- a/src/main/ai-vault/session-scanner-candidates.ts
+++ b/src/main/ai-vault/session-scanner-candidates.ts
@@ -1,3 +1,4 @@
+import { prioritizeAntigravityTranscriptCandidates } from './antigravity-transcript-candidates'
import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta'
import { codexRolloutHardlinkIdentity, dedupeCodexRolloutAliases } from './codex-session-root-dedup'
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
@@ -14,7 +15,7 @@ export async function sessionCandidatesFromDiscoveries(
discoveries: SessionFileDiscovery[],
options: AiVaultScanOptions
): Promise {
- return dedupeCodexRolloutAliases(
+ const candidates = await dedupeCodexRolloutAliases(
discoveries
.flatMap((discovery) =>
discovery.files.map((file): SessionFileCandidate => ({
@@ -43,4 +44,8 @@ export async function sessionCandidatesFromDiscoveries(
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
options.signal
)
+ return prioritizeAntigravityTranscriptCandidates(
+ candidates,
+ (candidate) => candidate.agent === 'antigravity'
+ )
}
diff --git a/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts b/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts
index 7470b3430d2..59a67484fa1 100644
--- a/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts
+++ b/src/main/ai-vault/session-scanner-parser-stream-cleanup.test.ts
@@ -89,8 +89,21 @@ beforeEach(() => {
})
describe('session parsers that stop consuming a gated transcript early', () => {
+ it('destroys the bounded Antigravity stream when its read fails', async () => {
+ const stream = Readable.from(
+ (async function* () {
+ yield Buffer.from('{}\n')
+ throw new Error(PARSE_FAILURE)
+ })()
+ )
+ mocks.openStream.mockReturnValue(stream)
+ await expect(
+ parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')
+ ).rejects.toThrow(PARSE_FAILURE)
+ expect(stream.destroyed).toBe(true)
+ })
+
it.each([
- ['antigravity', () => parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')],
['droid', () => parseDroidSessionFile(file('/w/session.jsonl'), 'linux')],
['message graph', () => parseMessageGraphSessionFile('pi', file('/w/session.jsonl'), 'linux')]
])('destroys the stream when the %s parse throws', async (_agent, parse) => {
diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts
index 2a285409ac2..22ecc151d45 100644
--- a/src/main/ai-vault/session-scanner-types.ts
+++ b/src/main/ai-vault/session-scanner-types.ts
@@ -25,6 +25,8 @@ export type AiVaultScanOptions = {
wslOpenCodeReaders?: readonly OpenCodeWslRuntime[]
geminiSessionsDir?: string
antigravityBrainDir?: string
+ antigravityAppHome?: string
+ includeAntigravityIdeSessions?: boolean
copilotSessionsDir?: string
cursorProjectsDir?: string
opencodeStorageDir?: string
@@ -61,6 +63,8 @@ export type AiVaultScanOptions = {
}
export type FileWithMtime = {
+ /** Antigravity alias observation, separate from the actual file stat/cache key. */
+ aliasMtimeMs?: number
path: string
mtimeMs: number
modifiedAt: string
@@ -146,6 +150,7 @@ export type SessionAccumulator = {
// True once an older message fell out of the newest-N preview window, so the
// earliest preview turn is no longer the session's opening ask.
previewMessagesTruncated: boolean
+ antigravityOpeningPrompt?: AiVaultSession['antigravityOpeningPrompt'] | null
firstUserPrompt: string | null
lastUserPrompt: string | null
// Recoverable signal for a zero-turn transcript (see AiVaultSession).
diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts
index f56f17af796..8716440a565 100644
--- a/src/main/ai-vault/session-scanner.ts
+++ b/src/main/ai-vault/session-scanner.ts
@@ -1,3 +1,4 @@
+import { candidateFileTime } from './antigravity-transcript-candidates'
import type {
AiVaultListResult,
AiVaultScanIssue,
@@ -78,8 +79,8 @@ export async function scanAiVaultSessions(
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
const issues: AiVaultScanIssue[] = []
const parseStats = createSessionParseStats()
- const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
- readLocalAntigravityHistory
+ const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver((path) =>
+ readLocalAntigravityHistory(path, options.signal)
)
// Why: persisted entries must be seeded before any candidate is parsed, or
// the cold scan gains nothing from the cache file (#9210).
@@ -233,7 +234,9 @@ async function parseSessionCandidates(args: {
while (index < args.candidates.length) {
throwIfAiVaultScanCancelled(args.signal)
- if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
+ if (
+ canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file))
+ ) {
break
}
diff --git a/src/main/ai-vault/ssh-session-list.ts b/src/main/ai-vault/ssh-session-list.ts
index 79359873d69..bb1bd18c4da 100644
--- a/src/main/ai-vault/ssh-session-list.ts
+++ b/src/main/ai-vault/ssh-session-list.ts
@@ -63,6 +63,9 @@ async function scanOneSshHost(
const relayTimeoutMs = options.relayTimeoutMs ?? options.timeoutMs
try {
const params = {
+ ...(args?.includeAntigravityIdeSessions === true
+ ? { includeAntigravityIdeSessions: true }
+ : {}),
limit: args?.limit,
...(args?.unlimited === true ? { unlimited: true } : {}),
...(args?.force === true ? { force: true } : {}),
@@ -108,8 +111,12 @@ async function scanOneSshHost(
scanRemoteAiVaultSessions({
provider,
executionHostId,
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
remoteHome: hostInfo.remoteHome,
hostPlatform: hostInfo.hostPlatform,
+ ...(args?.includeAntigravityIdeSessions === true
+ ? { includeAntigravityIdeSessions: true }
+ : {}),
limit: args?.limit,
unlimited: args?.unlimited,
scopePaths,
diff --git a/src/main/ipc/ai-vault-runtime-scan.ts b/src/main/ipc/ai-vault-runtime-scan.ts
index f9432f143a8..0f7cb7a4109 100644
--- a/src/main/ipc/ai-vault-runtime-scan.ts
+++ b/src/main/ipc/ai-vault-runtime-scan.ts
@@ -80,6 +80,9 @@ function runtimeScanArgs(
if (listArgs?.scopePaths !== undefined) {
scanArgs.scopePaths = listArgs.scopePaths
}
+ if (listArgs?.includeAntigravityIdeSessions === true) {
+ scanArgs.includeAntigravityIdeSessions = true
+ }
return scanArgs
}
diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts
index 826b7986d62..ec5269be420 100644
--- a/src/main/ipc/ai-vault.test.ts
+++ b/src/main/ipc/ai-vault.test.ts
@@ -112,11 +112,16 @@ beforeEach(() => {
describe('listAiVaultSessions host routing', () => {
it('routes local scope to the local scanner', async () => {
- await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] })
+ await _internals.listAiVaultSessions({
+ executionHostScope: 'local',
+ scopePaths: ['/repo'],
+ includeAntigravityIdeSessions: true
+ })
expect(mocks.scanLocalSessions).toHaveBeenCalledWith(
expect.objectContaining({
scopePaths: ['/repo'],
+ includeAntigravityIdeSessions: true,
executionHostId: 'local'
}),
expect.any(AbortSignal)
diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts
index a2c4fc26766..3c0d258381a 100644
--- a/src/main/ipc/ai-vault.ts
+++ b/src/main/ipc/ai-vault.ts
@@ -86,24 +86,22 @@ async function listAiVaultSessions(
options: { signal?: AbortSignal } = {}
): Promise {
const executionHostScope = requestedExecutionHostScope(args?.executionHostScope)
- // Scope paths change the result set, so they must be part of the cache key.
- // A scanner consumes at most 64 paths, so smaller equivalent workspace sets
- // can share a snapshot regardless of which worktree was selected first.
+ // Canonicalize bounded workspace sets so equivalent scopes share a scan.
const scopePaths = args?.scopePaths ?? []
const key = JSON.stringify({
scopePaths:
scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT
? [...new Set(scopePaths)].sort()
: scopePaths,
- executionHostScope
+ executionHostScope,
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true
})
const depth = requestedAiVaultSessionDepth(args)
- const scanKey = JSON.stringify({ key, depth })
// Why: every renderer request carries its own cancellation signal, so
// coalescing has to survive them — the coordinator hands all same-key callers
// one scan and only aborts it once every one of them has cancelled.
return scanCoordinator.run({
- key: scanKey,
+ key: JSON.stringify({ key, depth }),
force: args?.force,
signal: options.signal,
start: (scanSignal) => {
@@ -252,15 +250,7 @@ async function scanLocalAiVaultSessions(
// Why: the shared cache module owns codex-home/WSL sourcing and the local
// scan cache, so the desktop IPC path and the runtime RPC method (mobile)
// share one cache instance and one source of managed-Codex homes.
- return listCachedLocalAiVaultSessions(
- {
- limit: args?.limit,
- unlimited: args?.unlimited,
- force: args?.force,
- scopePaths: args?.scopePaths
- },
- { signal }
- )
+ return listCachedLocalAiVaultSessions(args, { signal })
}
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts
index 6853b5f572f..7848ddcd033 100644
--- a/src/main/runtime/rpc/methods/ai-vault.test.ts
+++ b/src/main/runtime/rpc/methods/ai-vault.test.ts
@@ -299,6 +299,22 @@ describe('aiVault.listSessions handler + shared cache', () => {
resetAiVaultSessionListCacheForTests()
})
+ it('isolates IDE opt-in scans from legacy clients sharing the host cache', async () => {
+ const dispatcher = makeDispatcher()
+ await dispatcher.dispatch(
+ makeRequest('aiVault.listSessions', { includeAntigravityIdeSessions: true })
+ )
+ await dispatcher.dispatch(makeRequest('aiVault.listSessions', {}))
+ await dispatcher.dispatch(makeRequest('aiVault.listSessions', {}))
+ expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2)
+ expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({
+ includeAntigravityIdeSessions: true
+ })
+ expect(scanAiVaultSessionsInService.mock.calls[1]?.[0]?.includeAntigravityIdeSessions).not.toBe(
+ true
+ )
+ })
+
it('returns the AiVaultListResult unchanged', async () => {
const dispatcher = makeDispatcher()
const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 }))
diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts
index b4169145902..fc828649019 100644
--- a/src/main/runtime/rpc/methods/ai-vault.ts
+++ b/src/main/runtime/rpc/methods/ai-vault.ts
@@ -76,7 +76,8 @@ export const AI_VAULT_METHODS = [
limit: params.unlimited ? undefined : params.limit,
unlimited: params.unlimited,
force: params.force,
- scopePaths: params.scopePaths
+ scopePaths: params.scopePaths,
+ includeAntigravityIdeSessions: params.includeAntigravityIdeSessions
})
} catch (error) {
if (error instanceof Error) {
diff --git a/src/relay/ai-vault-handler.ts b/src/relay/ai-vault-handler.ts
index 75676a7be29..428253349bf 100644
--- a/src/relay/ai-vault-handler.ts
+++ b/src/relay/ai-vault-handler.ts
@@ -97,6 +97,7 @@ export class AiVaultHandler {
try {
result = await this.scanCoordinator.run({
key: JSON.stringify({
+ includeAntigravityIdeSessions: params.includeAntigravityIdeSessions,
limit: params.limit,
unlimited: params.unlimited,
scopePaths: params.scopePaths,
@@ -194,6 +195,9 @@ export function normalizeSshAiVaultRelayListParams(
params.scopePathsTruncated === true ||
(Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT)
return {
+ ...(params.includeAntigravityIdeSessions === true
+ ? { includeAntigravityIdeSessions: true }
+ : {}),
...(unlimited ? { unlimited: true } : {}),
...(limit === undefined ? {} : { limit }),
...(params.force === true ? { force: true } : {}),
diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts
index 3f0a2b02aaf..9380e08d4c1 100644
--- a/src/relay/ai-vault-service-entry.ts
+++ b/src/relay/ai-vault-service-entry.ts
@@ -53,6 +53,7 @@ async function execute(request: RelayAiVaultServiceRequest): Promise {
hostPlatform: init.hostPlatform,
limit: request.params.limit,
unlimited: request.params.unlimited,
+ includeAntigravityIdeSessions: request.params.includeAntigravityIdeSessions,
scopePaths: request.params.scopePaths,
signal: controller.signal
})
diff --git a/src/relay/ai-vault-transcript-stream.ts b/src/relay/ai-vault-transcript-stream.ts
index df040a166fa..42e24733551 100644
--- a/src/relay/ai-vault-transcript-stream.ts
+++ b/src/relay/ai-vault-transcript-stream.ts
@@ -2,13 +2,27 @@ import { open } from 'node:fs/promises'
import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation'
import { BinarySessionTranscriptError } from '../main/ai-vault/remote-session-content-lines'
import { BINARY_PROBE_BYTES, isBinaryBuffer } from './fs-handler-utils'
+import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader'
/** The same open handle supplies the probe and stream, including across renames. */
export async function* readRelayTranscriptBytes(
path: string,
- signal?: AbortSignal
+ signal?: AbortSignal,
+ options?: { regularFileOnly: true; maxBytes: number }
): AsyncGenerator {
throwIfAiVaultScanCancelled(signal)
+ if (options?.regularFileOnly) {
+ const read = await readNodeFileWithinLimit(path, options.maxBytes, {
+ regularFileOnly: true,
+ signal
+ })
+ if (isBinaryBuffer(read.buffer.subarray(0, BINARY_PROBE_BYTES))) {
+ throw new BinarySessionTranscriptError()
+ }
+ throwIfAiVaultScanCancelled(signal)
+ yield read.buffer
+ return
+ }
const handle = await open(path, 'r')
try {
const probe = Buffer.alloc(BINARY_PROBE_BYTES)
diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
index 25ab02c9548..9e56bc0a5a0 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx
@@ -68,6 +68,8 @@ export default function AiVaultPanel(): React.JSX.Element {
const projectHostSetupProjection = useProjectHostSetupProjection()
const resumeTargetState = useAppStore(
useShallow((state) => ({
+ projects: state.projects,
+ settings: state.settings,
folderWorkspaces: state.folderWorkspaces,
projectGroups: state.projectGroups,
repos: state.repos,
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
index 34fee190be6..fd7a8394f74 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx
@@ -1,3 +1,4 @@
+import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents'
import type React from 'react'
import {
@@ -59,6 +60,7 @@ export function SessionInlineDetails({
}): React.JSX.Element {
// A zero-turn transcript would resume into an empty conversation, so the plain
// resume affordances are withheld and a distinct "not saved" state is shown.
+ const referenceSession = isAntigravityReferenceSession(session)
const hasResumableContent = isAiVaultSessionResumableContent(session)
const showResumeInWorktree = hasResumableContent && Boolean(resumeActions.worktree.worktreeId)
const showResumeInNewTab =
@@ -100,10 +102,12 @@ export function SessionInlineDetails({
className="h-7 shrink-0 px-2.5 text-[11px]"
>
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
- 'Resume in Worktree'
- )}
+ {referenceSession
+ ? translate('aiVault.continueInCli', 'Continue in CLI')
+ : translate(
+ 'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
+ 'Resume in Worktree'
+ )}
) : null}
{showResumeInNewTab ? (
@@ -120,10 +124,12 @@ export function SessionInlineDetails({
className="h-7 shrink-0 px-2.5 text-[11px]"
>
- {translate(
- 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
- 'Resume in New Tab'
- )}
+ {referenceSession
+ ? translate('aiVault.continueInCliNewTab', 'Continue in CLI in New Tab')
+ : translate(
+ 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
+ 'Resume in New Tab'
+ )}
) : null}
{onResumeInNewChat ? (
diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
index ee0e6950e98..3a3035f14db 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx
@@ -1,3 +1,4 @@
+import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents'
import { useCallback } from 'react'
import type React from 'react'
@@ -44,7 +45,7 @@ export function VaultSessionRow({
onResume,
onContinueInNewSession,
onResumeInNewChat,
- resumeLabel,
+ resumeLabel: defaultResumeLabel,
resumeActions,
onResumeInWorktree,
onResumeInNewTab,
@@ -88,6 +89,9 @@ export function VaultSessionRow({
onRequestDelete?: (session: AiVaultSession) => void
searchHit?: AiVaultSearchHit
}) {
+ const resumeLabel = isAntigravityReferenceSession(session)
+ ? translate('aiVault.continueInCli', 'Continue in CLI')
+ : defaultResumeLabel
const updatedAt = session.updatedAt ?? session.modifiedAt
const detailsId = getSessionDetailsId(session.id)
const latestTurn = latestSessionConversationTurn(session)
diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx
index 820527f764b..4d655359b37 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.test.tsx
@@ -34,7 +34,7 @@ afterEach(() => {
cleanup()
})
-function renderSession(session: AiVaultSession) {
+function renderSession(session: AiVaultSession, blocked = false) {
const buildResumeStartup = vi.fn(() => ({ command: session.resumeCommand }))
const onCopyResume = vi.fn()
render(
@@ -54,13 +54,13 @@ function renderSession(session: AiVaultSession) {
getSessionLiveState={() => null}
getWorktreeInfo={() => null}
getSessionResumeState={() => ({
- blocked: false,
- worktreeId: 'worktree-1',
+ blocked,
+ worktreeId: blocked ? null : 'worktree-1',
usesSessionWorktree: false
})}
getSessionResumeActions={() => ({
worktree: { worktreeId: null, disabled: true },
- newTab: { worktreeId: 'worktree-1', disabled: false }
+ newTab: { worktreeId: 'worktree-1', disabled: blocked }
})}
getSessionResumeInChat={() => ({ available: false, reason: 'already-structured' })}
onToggleGroup={vi.fn()}
@@ -84,6 +84,21 @@ function renderSession(session: AiVaultSession) {
}
describe('AiVaultVirtualRow resume command actions', () => {
+ it('renders blocked IDE references without preparing an unsafe startup command', () => {
+ const { buildResumeStartup } = renderSession(
+ {
+ ...cliSession,
+ agent: 'antigravity',
+ title: 'IDE reference on another host',
+ filePath:
+ '//wsl.localhost/Debian/home/example/.gemini/antigravity-ide/brain/id/transcript_full.jsonl'
+ },
+ true
+ )
+ expect(screen.getByText('IDE reference on another host')).toBeTruthy()
+ expect(buildResumeStartup).not.toHaveBeenCalled()
+ })
+
it('keeps Copy Resume Command in overflow and context actions for CLI sessions', async () => {
const { buildResumeStartup, onCopyResume } = renderSession(cliSession)
const user = userEvent.setup()
diff --git a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx
index 8e2ac92f28c..92be9e9b874 100644
--- a/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx
+++ b/src/renderer/src/components/right-sidebar/AiVaultVirtualRow.tsx
@@ -1,4 +1,5 @@
import type { AgentStatusState } from '../../../../shared/agent-status-types'
+import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
@@ -135,10 +136,14 @@ export function AiVaultVirtualRow({
const searchResumeAllowed = searchHit ? canResumeAiVaultSearchHit(searchHit) : true
const searchPathAllowed = searchHit ? hasAiVaultSearchHitPath(searchHit) : true
const usesLegacyResumeCommand = row.type === 'session' && !row.session.structuredSession
- const resumeStartup =
- row.type === 'session' && searchResumeAllowed && usesLegacyResumeCommand
- ? buildResumeStartup(row.session, resumeState?.worktreeId)
- : { command: '' }
+ const canBuildResumeStartup =
+ row.type === 'session' &&
+ searchResumeAllowed &&
+ usesLegacyResumeCommand &&
+ (!isAntigravityReferenceSession(row.session) || !resumeGating.resumeDisabled)
+ const resumeStartup = canBuildResumeStartup
+ ? buildResumeStartup(row.session, resumeState?.worktreeId)
+ : { command: '' }
const visibleResumeActions =
searchResumeAllowed && resumeActions
? resumeActions
@@ -170,7 +175,7 @@ export function AiVaultVirtualRow({
liveState={getSessionLiveState(row.session)}
resumeStartup={resumeStartup}
realHomeResumeStartup={
- searchResumeAllowed && usesLegacyResumeCommand
+ canBuildResumeStartup
? buildResumeStartup({ ...row.session, codexHome: null }, resumeState?.worktreeId)
: resumeStartup
}
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts
index 98e136931d5..c4417ab2d0d 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-target.ts
@@ -1,3 +1,4 @@
+import { getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-shell'
import {
canResumeAiVaultSessionOnTarget,
getAiVaultResumeWorkspaceExecutionHostId,
@@ -60,7 +61,8 @@ export function resolveAiVaultSessionLaunchTarget(args: {
sessionFilePath: args.sessionFilePath,
sessionExecutionHostId: args.sessionExecutionHostId,
targetStatus,
- targetExecutionHostId
+ targetExecutionHostId,
+ targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.targetState, targetWorktreeId)
})
) {
return { status: 'unsupported', targetStatus }
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
index 9ace8fbd399..06ee98d47ad 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts
@@ -174,6 +174,7 @@ export function useAiVaultSessionRefresh(
const scanKey = `${baseKey}\n${selectedLimit}`
try {
const result = await window.api.aiVault.listSessions({
+ includeAntigravityIdeSessions: true,
limit,
unlimited: selectedLimit === 'unlimited',
scopePaths: scopePathsRef.current,
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts
index 92817807f77..6468ca5ef00 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts
@@ -1,3 +1,4 @@
+import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
// Whether an Agent Session History row can be resumed into a structured native chat, and where.
//
// Separate from `ai-vault-session-resume.ts` because the answer is not the same question: the
@@ -70,7 +71,7 @@ export function resolveAiVaultSessionResumeInChatEligibility(args: {
structuredRouteAvailable: boolean
}): AiVaultResumeInChatEligibility {
const { session } = args
- if (!isAgentSessionHandleProvider(session.agent)) {
+ if (isAntigravityReferenceSession(session) || !isAgentSessionHandleProvider(session.agent)) {
return { available: false, reason: 'agent' }
}
// An already-adopted row reopens its own chat instead; offering a second resume of it would ask
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
index f029d896e98..44820319035 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts
@@ -1,3 +1,4 @@
+import { getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-shell'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import {
@@ -22,7 +23,8 @@ import {
export type AiVaultSessionResumeTargetState = Pick<
AppState,
'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'
->
+> &
+ Partial>
export type AiVaultSessionResumeState = {
blocked: boolean
@@ -201,7 +203,8 @@ function resolveSupportedResumeWorktreeId(args: {
sessionFilePath: args.sessionFilePath,
sessionExecutionHostId: args.sessionExecutionHostId,
targetStatus,
- targetExecutionHostId
+ targetExecutionHostId,
+ targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.targetState, args.worktreeId)
})
) {
return null
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx
index 2ad0b1cca39..d33f1de0b97 100644
--- a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx
+++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx
@@ -1,3 +1,7 @@
+import {
+ antigravitySessionOrigin,
+ isAntigravityReferenceSession
+} from '../../../../shared/antigravity-session-origin'
import type React from 'react'
import { Badge } from '@/components/ui/badge'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
@@ -41,6 +45,7 @@ export function SessionMetadata({
vaultScope: AiVaultScope
}) {
const modelLabel = sessionModelLabel(session)
+ const origin = antigravitySessionOrigin(session.filePath)
return (
: null}
{agentLabel(session.agent)}
+ {isAntigravityReferenceSession(session) ? (
+
+ {origin === 'antigravity-ide'
+ ? translate('aiVault.antigravityIdeToCli', 'IDE → CLI')
+ : translate('aiVault.antigravity2ToCli', '2.0 → CLI')}
+
+ ) : null}
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
index 11569358c18..eb954cbe077 100644
--- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
+++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx
@@ -1,3 +1,4 @@
+import { getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-shell'
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
import { toast } from 'sonner'
import {
@@ -200,7 +201,8 @@ export default function AiVaultSessionDropLayer({
sessionFilePath: payload.sessionFilePath ?? null,
sessionExecutionHostId: payload.sessionExecutionHostId ?? null,
targetStatus,
- targetExecutionHostId
+ targetExecutionHostId,
+ targetWslDistro: getAiVaultResumeWorkspaceWslDistro(state, worktreeId)
})
) {
toast.error(
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index a187c118eeb..5406a7e8a33 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -18698,6 +18698,11 @@
}
},
"aiVault": {
+ "antigravityReferenceDescription": "Continues in a new Antigravity CLI conversation using this transcript as a reference.",
+ "antigravityIdeToCli": "IDE → CLI",
+ "antigravity2ToCli": "2.0 → CLI",
+ "continueInCli": "Continue in CLI",
+ "continueInCliNewTab": "Continue in CLI in New Tab",
"subagents": {
"loading": "Loading subagents…",
"loadError": "Could not load all subagents.",
diff --git a/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts
new file mode 100644
index 00000000000..f30739ae35c
--- /dev/null
+++ b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts
@@ -0,0 +1,351 @@
+import { describe, expect, it, vi } from 'vitest'
+import { makeFolderWorkspace, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
+import { resolveAiVaultSessionLaunchTarget } from '@/components/right-sidebar/ai-vault-session-launch-target'
+import {
+ buildAiVaultResumeCopyCommandForWorktree,
+ buildAiVaultResumeStartupForWorktree
+} from './ai-vault-resume-command'
+import { canResumeAiVaultSessionOnTarget } from './ai-vault-resume-target'
+import { getAiVaultResumeWorkspaceWslDistro } from './ai-vault-resume-shell'
+import { buildAgentLaunchRouteInput } from './agent-launch-route-input'
+import { getLocalProjectExecutionRuntimeContext } from './local-preflight-context'
+import type { ExecutionHostId } from '../../../shared/execution-host'
+import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture'
+
+vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' }))
+vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => 'win32' }))
+
+type State = Parameters[0]['state']
+const windowsFile = 'C:/Users/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl'
+const linuxFile = '/home/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl'
+const debianFile =
+ '//wsl.localhost/Debian/home/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl'
+
+function stateFor(host: ExecutionHostId, distro?: string): State {
+ const worktree = makeWorktree({
+ id: 'repo::workspace',
+ repoId: 'repo',
+ hostId: host,
+ path: distro ? `//wsl.localhost/${distro}/home/example/project` : 'C:/project'
+ })
+ return {
+ activeRepoId: 'repo',
+ activeWorktreeId: worktree.id,
+ folderWorkspaces: [],
+ projectGroups: [],
+ settings: null,
+ repos: [
+ {
+ id: 'repo',
+ path: worktree.path,
+ displayName: 'project',
+ badgeColor: '',
+ addedAt: 0,
+ projectGroupId: 'group-1',
+ executionHostId: host
+ }
+ ],
+ projects: [
+ {
+ id: 'repo',
+ displayName: 'project',
+ badgeColor: '',
+ sourceRepoIds: ['repo'],
+ createdAt: 0,
+ updatedAt: 0,
+ localWindowsRuntimePreference: distro ? { kind: 'wsl', distro } : { kind: 'windows-host' }
+ }
+ ],
+ worktreesByRepo: { repo: [worktree] }
+ }
+}
+
+const cases = [
+ { source: 'local', target: 'local', file: debianFile, distro: 'Debian', allowed: true },
+ { source: 'local', target: 'local', file: debianFile, distro: 'Ubuntu', allowed: false },
+ { source: 'local', target: 'local', file: debianFile, allowed: false },
+ { source: 'local', target: 'local', file: windowsFile, distro: 'Debian', allowed: false },
+ { source: 'local', target: 'local', file: windowsFile, allowed: true },
+ { source: 'local', target: 'ssh:other', file: debianFile, allowed: false },
+ { source: 'ssh:owner', target: 'ssh:other', file: linuxFile, allowed: false },
+ { source: 'ssh:owner', target: 'local', file: linuxFile, allowed: false },
+ { source: 'ssh:owner', target: 'ssh:owner', file: linuxFile, allowed: true },
+ { source: 'runtime:owner', target: 'runtime:other', file: linuxFile, allowed: false },
+ { source: 'runtime:owner', target: 'runtime:owner', file: linuxFile, allowed: true }
+] as const
+
+describe('Antigravity transcript reference ownership', () => {
+ it.each(cases)(
+ 'confines $file from $source to $target ($distro)',
+ ({ source, target, file, allowed, ...options }) => {
+ const distro = 'distro' in options ? options.distro : undefined
+ const state = stateFor(target, distro)
+ const session = {
+ agent: 'antigravity' as const,
+ sessionId: 'copied-id',
+ cwd: null,
+ codexHome: null,
+ executionHostId: source,
+ executionHostPlatform: target === 'local' ? ('win32' as const) : ('linux' as const),
+ filePath: file
+ }
+ const launch = resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: file,
+ sessionExecutionHostId: source,
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ })
+ expect(launch.status).toBe(allowed ? 'ready' : 'unsupported')
+ for (const build of [
+ buildAiVaultResumeStartupForWorktree,
+ buildAiVaultResumeCopyCommandForWorktree
+ ]) {
+ if (!allowed) {
+ expect(() => build({ state, session })).toThrow('execution host or WSL distro')
+ } else {
+ const result = build({ state, session })
+ const command = typeof result === 'string' ? result : result.command
+ expect(command).toContain('--prompt-interactive')
+ expect(command).not.toContain('--conversation')
+ expect(command).not.toContain('wsl.localhost')
+ }
+ }
+ }
+ )
+
+ it('preserves legacy WSL ID resume eligibility on SSH', () => {
+ expect(
+ canResumeAiVaultSessionOnTarget({
+ sessionFilePath: debianFile.replace('antigravity-ide', 'antigravity-cli'),
+ sessionExecutionHostId: 'local',
+ targetStatus: 'ssh',
+ targetExecutionHostId: 'ssh:other'
+ })
+ ).toBe(true)
+ })
+
+ it('uses a project runtime override before the workspace UNC distro', () => {
+ const state = stateFor('local', 'Ubuntu')
+ const workspace = state.worktreesByRepo.repo?.[0]
+ if (!workspace) {
+ throw new Error('Missing fixture workspace')
+ }
+ workspace.path = '//wsl.localhost/Debian/home/example/project'
+ expect(getAiVaultResumeWorkspaceWslDistro(state, workspace.id)).toBe('Ubuntu')
+ expect(getAiVaultResumeWorkspaceWslDistro(state, `worktree:${workspace.id}`)).toBe('Ubuntu')
+ })
+
+ it('applies the same confinement to folder workspaces', () => {
+ const state = stateFor('local')
+ state.activeRepoId = null
+ state.repos = []
+ state.projects = []
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [
+ makeFolderWorkspace({ folderPath: '//wsl.localhost/Debian/home/example/project' })
+ ]
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: debianFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: 'folder:folder-1',
+ targetState: state
+ }).status
+ ).toBe('ready')
+ state.folderWorkspaces[0].folderPath = '//wsl.localhost/Ubuntu/home/example/project'
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: debianFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: 'folder:folder-1',
+ targetState: state
+ }).status
+ ).toBe('unsupported')
+ })
+
+ it('uses the folder launch project runtime for a native folder path', () => {
+ const state = stateFor('local', 'Debian')
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [makeFolderWorkspace({ folderPath: 'C:/project/folder' })]
+ expect(
+ buildAgentLaunchRouteInput(state, {
+ agent: 'antigravity',
+ workspace: { kind: 'folder', repoId: 'repo' }
+ }).projectRuntime
+ ).toMatchObject({ status: 'resolved', runtime: { kind: 'wsl', distro: 'Debian' } })
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBe('Debian')
+ for (const [file, status] of [
+ [windowsFile, 'unsupported'],
+ [debianFile, 'ready']
+ ] as const) {
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: file,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe(status)
+ }
+ })
+
+ it('lets an explicit folder project runtime override the UNC path distro', () => {
+ const state = stateFor('local', 'Ubuntu')
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [
+ makeFolderWorkspace({
+ folderPath: '//wsl.localhost/Debian/home/example/project'
+ })
+ ]
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBe('Ubuntu')
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: debianFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe('unsupported')
+ })
+
+ it('does not substitute a local project runtime into an SSH folder launch', () => {
+ const state = stateFor('ssh:owner', 'Debian')
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [
+ makeFolderWorkspace({
+ folderPath: '/remote/non-git-folder',
+ executionHostId: 'ssh:owner'
+ })
+ ]
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBeNull()
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: linuxFile,
+ sessionExecutionHostId: 'ssh:owner',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe('ready')
+ })
+
+ it('keeps references refused for SSH aliases claiming local WSL ownership', () => {
+ for (const target of [
+ 'ssh:localhost',
+ 'ssh:127.0.0.1',
+ 'ssh:Debian',
+ 'ssh:local-wsl'
+ ] as const) {
+ expect(
+ canResumeAiVaultSessionOnTarget({
+ sessionFilePath: debianFile,
+ sessionExecutionHostId: 'local',
+ targetStatus: 'ssh',
+ targetExecutionHostId: target,
+ targetWslDistro: 'Debian'
+ })
+ ).toBe(false)
+ }
+ })
+
+ it('uses the target folder owner for both the pane and reference gate with another repo active', () => {
+ const state = stateFor('local', 'Ubuntu')
+ state.repos[0].path = 'C:/project'
+ state.repos[0].kind = 'folder'
+ state.activeRepoId = 'unrelated'
+ state.activeWorktreeId = 'unrelated::workspace'
+ state.repos = [
+ ...state.repos,
+ {
+ id: 'unrelated',
+ path: 'C:/elsewhere',
+ displayName: 'unrelated',
+ badgeColor: '',
+ addedAt: 0
+ }
+ ]
+ state.folderWorkspaces = [makeFolderWorkspace({ folderPath: 'C:/project/folder' })]
+ const target = 'folder:folder-1'
+ expect(getLocalProjectExecutionRuntimeContext(state, target, 'win32')).toMatchObject({
+ status: 'resolved',
+ runtime: { kind: 'wsl', distro: 'Ubuntu' }
+ })
+ expect(getAiVaultResumeWorkspaceWslDistro(state, target)).toBe('Ubuntu')
+ for (const [file, expected] of [
+ [windowsFile, 'unsupported'],
+ [debianFile.replace('Debian', 'Ubuntu'), 'ready']
+ ] as const) {
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: file,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetWorktreeId: target,
+ targetState: state
+ }).status
+ ).toBe(expected)
+ }
+ })
+
+ it('does not borrow an unrelated active project override for an orphan native folder', () => {
+ const state = stateFor('local', 'Debian')
+ state.folderWorkspaces = [
+ makeFolderWorkspace({ folderPath: 'C:/orphan', projectGroupId: 'orphan-group' })
+ ]
+ expect(getAiVaultResumeWorkspaceWslDistro(state, 'folder:folder-1')).toBeNull()
+ })
+
+ it('keeps explicit SSH folder authority ahead of a matching local project path', () => {
+ const state = stateFor('local', 'Debian')
+ state.repos[0].path = 'C:/project'
+ state.folderWorkspaces = [
+ makeFolderWorkspace({ folderPath: 'C:/project/folder', executionHostId: 'ssh:owner' })
+ ]
+ expect(
+ getLocalProjectExecutionRuntimeContext(state, 'folder:folder-1', 'win32')
+ ).toBeUndefined()
+ expect(getAiVaultResumeWorkspaceWslDistro(state, 'folder:folder-1')).toBeNull()
+ })
+
+ it('refuses local references while the project runtime requires repair', () => {
+ const state = stateFor('local')
+ state.projects[0].localWindowsRuntimePreference = { kind: 'inherit-global' }
+ state.settings = createGlobalSettingsFixture({
+ localWindowsRuntimeDefault: { kind: 'wsl', distro: null }
+ })
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBeUndefined()
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: windowsFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe('unsupported')
+ })
+
+ it('refuses references when a folder has multiple possible owning projects', () => {
+ const state = stateFor('local', 'Ubuntu')
+ state.activeWorktreeId = 'folder:folder-1'
+ state.folderWorkspaces = [makeFolderWorkspace({ folderPath: 'C:/project/folder' })]
+ state.repos = [
+ ...state.repos,
+ {
+ id: 'second',
+ path: 'C:/second',
+ projectGroupId: 'group-1',
+ displayName: 'second',
+ badgeColor: '',
+ addedAt: 0
+ }
+ ]
+ expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBeUndefined()
+ expect(
+ resolveAiVaultSessionLaunchTarget({
+ sessionFilePath: windowsFile,
+ sessionExecutionHostId: 'local',
+ activeWorktreeId: state.activeWorktreeId,
+ targetState: state
+ }).status
+ ).toBe('unsupported')
+ })
+})
diff --git a/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts b/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts
new file mode 100644
index 00000000000..ac1e33e7fea
--- /dev/null
+++ b/src/renderer/src/lib/ai-vault-antigravity-reference-startup.ts
@@ -0,0 +1,81 @@
+import { isAntigravityReferenceSession } from '../../../shared/antigravity-session-origin'
+import {
+ canResumeAiVaultSessionOnTarget,
+ getAiVaultResumeWorkspaceExecutionHostId,
+ getAiVaultResumeWorkspaceTargetStatus
+} from './ai-vault-resume-target'
+import { getAiVaultResumeWorkspaceWslDistro } from './ai-vault-resume-shell'
+import { buildAiVaultResumeCommand } from '../../../shared/ai-vault-resume-command'
+import { buildAgentStartupPlan } from '../../../shared/tui-agent-startup'
+import {
+ resolveTuiAgentLaunchArgs,
+ resolveTuiAgentLaunchEnv
+} from '../../../shared/tui-agent-launch-defaults'
+import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
+import type { GlobalSettings } from '../../../shared/global-settings-types'
+import type {
+ AiVaultResumeStartup,
+ buildAiVaultResumeStartupForWorktree
+} from './ai-vault-resume-command'
+
+/** References are fresh launches, so preserve normal model arguments and environment. */
+export function buildAntigravityReferenceStartup(args: {
+ session: { sessionId: string; filePath?: string }
+ cwd: string | null
+ platform: NodeJS.Platform
+ shell?: AgentStartupShell
+ commandOverride?: string | null
+ settings?: Partial<
+ Pick
+ > | null
+}): AiVaultResumeStartup | null {
+ const plan = buildAgentStartupPlan({
+ agent: 'antigravity',
+ prompt: '',
+ allowEmptyPromptLaunch: true,
+ platform: args.platform,
+ shell: args.shell,
+ cmdOverrides: {
+ ...args.settings?.agentCmdOverrides,
+ ...(args.commandOverride?.trim() ? { antigravity: args.commandOverride } : {})
+ },
+ agentArgs: resolveTuiAgentLaunchArgs('antigravity', args.settings?.agentDefaultArgs),
+ agentEnv: resolveTuiAgentLaunchEnv('antigravity', args.settings?.agentDefaultEnv)
+ })
+ if (!plan) {
+ return null
+ }
+ return {
+ command: buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: args.session.sessionId,
+ resumeFilePath: args.session.filePath,
+ cwd: args.cwd,
+ platform: args.platform,
+ shell: args.shell,
+ commandOverride: plan.launchCommand
+ }),
+ ...(plan.env ? { env: plan.env } : {}),
+ launchConfig: plan.launchConfig
+ }
+}
+
+export function assertAntigravityReferenceTarget(
+ args: Parameters[0]
+): void {
+ const workspaceId = args.worktreeId ?? args.state.activeWorktreeId
+ if (
+ isAntigravityReferenceSession(args.session) &&
+ !canResumeAiVaultSessionOnTarget({
+ sessionFilePath: args.session.filePath,
+ sessionExecutionHostId: args.session.executionHostId,
+ targetStatus: getAiVaultResumeWorkspaceTargetStatus(args.state, workspaceId),
+ targetExecutionHostId: getAiVaultResumeWorkspaceExecutionHostId(args.state, workspaceId),
+ targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.state, workspaceId)
+ })
+ ) {
+ throw new Error(
+ 'The Antigravity transcript belongs to a different execution host or WSL distro.'
+ )
+ }
+}
diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts
index dd4c6105abf..048a4daf3ef 100644
--- a/src/renderer/src/lib/ai-vault-resume-command.test.ts
+++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import {
+ getAiVaultAgentProviderSession,
buildAiVaultResumeCopyCommandForWorktree,
buildAiVaultResumeStartupForWorktree
} from './ai-vault-resume-command'
@@ -68,6 +69,47 @@ function buildQueuedAiVaultResumeCommand(
}
describe('ai vault resume command runtime', () => {
+ it.each(['local', 'ssh:reference-host'] as const)(
+ 'starts a fresh IDE reference on %s with model and environment preserved',
+ (executionHostId) => {
+ const state = makeState({
+ worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\example\\project',
+ localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
+ })
+ if (executionHostId !== 'local') {
+ state.repos = state.repos.map((repo) => ({ ...repo, executionHostId }))
+ }
+ const session = {
+ agent: 'antigravity' as const,
+ sessionId: 'ide-id',
+ cwd: '/home/example/project',
+ codexHome: null,
+ executionHostId,
+ executionHostPlatform: 'linux' as const,
+ resumeCommand: 'agy --conversation ide-id',
+ filePath:
+ executionHostId === 'local'
+ ? '\\\\wsl.localhost\\Ubuntu\\home\\example\\.gemini\\antigravity-ide\\brain\\ide-id\\.system_generated\\logs\\transcript_full.jsonl'
+ : '/home/example/.gemini/antigravity-ide/brain/ide-id/.system_generated/logs/transcript_full.jsonl'
+ }
+ if (!state.settings) {
+ throw new Error('Missing fixture settings')
+ }
+ state.settings.agentDefaultArgs = { antigravity: '--model claude-sonnet-4-6' }
+ state.settings.agentDefaultEnv = { antigravity: { AGY_CLI_HIDE_ACCOUNT_INFO: '1' } }
+ const startup = buildAiVaultResumeStartupForWorktree({ state, session })
+ expect(startup.command).toContain('--prompt-interactive')
+ expect(startup.command).not.toContain('--conversation')
+ expect(startup.command).not.toContain('wsl.localhost')
+ expect(startup.command).toContain('--model')
+ expect(startup.command).toContain('claude-sonnet-4-6')
+ expect(startup.env).toMatchObject({ AGY_CLI_HIDE_ACCOUNT_INFO: '1' })
+ expect(startup.providerSession).toBeUndefined()
+ expect(getAiVaultAgentProviderSession(session)).toBeNull()
+ expect(startup.cwd).toBe('/home/example/project')
+ }
+ )
+
it('repro: queues a host-runtime resume without configured-WSL shell syntax', () => {
const state = makeState({
worktreePath: 'C:\\Users\\alice\\repo',
diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts
index a0789c43b96..6e5b5ce86c4 100644
--- a/src/renderer/src/lib/ai-vault-resume-command.ts
+++ b/src/renderer/src/lib/ai-vault-resume-command.ts
@@ -1,3 +1,8 @@
+import {
+ assertAntigravityReferenceTarget,
+ buildAntigravityReferenceStartup
+} from './ai-vault-antigravity-reference-startup'
+import { isAntigravityReferenceSession } from '../../../shared/antigravity-session-origin'
import type { AiVaultSession } from '../../../shared/ai-vault-types'
import {
buildAiVaultResumeCommand,
@@ -125,11 +130,13 @@ function buildAiVaultResumeForWorktree(
* Spawned startups drop them through `envToDelete` instead. */
clearEnvNames?: readonly string[]
): AiVaultResumeStartup {
+ assertAntigravityReferenceTarget(args)
const providerSession = getAiVaultAgentProviderSession(args.session)
if (
args.session.executionHostId &&
args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID &&
args.session.resumeCommand &&
+ !isAntigravityReferenceSession(args.session) &&
args.session.agent !== 'omp' &&
!(args.session.agent === 'codex' && args.session.codexHome === null) &&
!args.commandOverride?.trim()
@@ -160,6 +167,19 @@ function buildAiVaultResumeForWorktree(
: undefined
const cwd = embedCwd ? args.session.cwd : null
const startupCwd = !embedCwd && args.session.cwd ? { cwd: args.session.cwd } : {}
+ if (isAntigravityReferenceSession(args.session)) {
+ const reference = buildAntigravityReferenceStartup({
+ session: { ...args.session, filePath: resumeFilePath },
+ cwd,
+ platform,
+ shell: liveShell,
+ commandOverride: args.commandOverride,
+ settings: args.state.settings
+ })
+ if (reference) {
+ return { ...reference, ...startupCwd }
+ }
+ }
if (providerSession && isResumableTuiAgent(args.session.agent)) {
const startupPlan = buildAgentResumeStartupPlan({
agent: args.session.agent,
@@ -256,6 +276,9 @@ export function getAiVaultAgentProviderSession(
if (!isResumableTuiAgent(session.agent)) {
return null
}
+ if (isAntigravityReferenceSession(session)) {
+ return null
+ }
if (session.agent === 'antigravity' || session.agent === 'cursor') {
return { key: 'conversation_id', id: session.sessionId }
}
diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts
index 3d36b28c07f..c51626a338e 100644
--- a/src/renderer/src/lib/ai-vault-resume-shell.ts
+++ b/src/renderer/src/lib/ai-vault-resume-shell.ts
@@ -1,5 +1,8 @@
import type { AppState } from '@/store/types'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
+import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
+import { getAiVaultResumeWorkspaceExecutionHostId } from './ai-vault-resume-target'
+import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime'
import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell'
@@ -74,3 +77,50 @@ export function getAiVaultResumeWorkspacePath(
.find((candidate) => candidate.id === targetWorktreeId)?.path ?? null
)
}
+
+export function getAiVaultResumeWorkspaceWslDistro(
+ state: Pick &
+ Partial<
+ Pick<
+ AppState,
+ 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'settings' | 'projectGroups'
+ >
+ >,
+ workspaceId: string | null | undefined
+): string | null | undefined {
+ const workspacePath = getAiVaultResumeWorkspacePath(state, workspaceId)
+ const workspaceKey = workspaceId ? parseWorkspaceKey(workspaceId) : null
+ const runtimeState = {
+ activeRepoId: state.activeRepoId ?? null,
+ activeWorktreeId: state.activeWorktreeId ?? null,
+ projects: state.projects ?? [],
+ settings: state.settings ?? null,
+ folderWorkspaces: state.folderWorkspaces,
+ projectGroups: state.projectGroups ?? [],
+ repos: state.repos,
+ worktreesByRepo: state.worktreesByRepo
+ }
+ if (workspaceKey?.type === 'folder') {
+ if (
+ getAiVaultResumeWorkspaceExecutionHostId(runtimeState, workspaceId ?? null) !==
+ LOCAL_EXECUTION_HOST_ID
+ ) {
+ return null
+ }
+ if (getFolderWorkspaceCandidateRepos(runtimeState, workspaceKey.folderWorkspaceId).length > 1) {
+ return undefined
+ }
+ }
+ const runtime = getLocalProjectExecutionRuntimeContext(
+ runtimeState,
+ workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId,
+ CLIENT_PLATFORM
+ )
+ if (runtime?.status === 'repair-required') {
+ return undefined
+ }
+ if (runtime?.status === 'resolved') {
+ return runtime.runtime.kind === 'wsl' ? runtime.runtime.distro : null
+ }
+ return workspacePath ? (parseWslUncPath(workspacePath)?.distro ?? null) : null
+}
diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts
index 874bc7ae7b2..17afeeb344c 100644
--- a/src/renderer/src/lib/ai-vault-resume-target.ts
+++ b/src/renderer/src/lib/ai-vault-resume-target.ts
@@ -9,7 +9,8 @@ import {
import type { Repo } from '../../../shared/repo-types'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
-import { isWslUncPath } from '../../../shared/wsl-paths'
+import { isWslUncPath, parseWslUncPath } from '../../../shared/wsl-paths'
+import { antigravitySessionOrigin } from '../../../shared/antigravity-session-origin'
import type { AppState } from '@/store/types'
import { getIndexedWorktreeMap } from '@/store/worktree-repo-index'
import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
@@ -42,9 +43,36 @@ export function canResumeAiVaultSessionOnTarget(args: {
sessionExecutionHostId?: ExecutionHostId | null
targetStatus: AiVaultResumeTargetStatus
targetExecutionHostId?: ExecutionHostId | null
+ targetWslDistro?: string | null
}): boolean {
const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId)
const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId)
+ const origin = args.sessionFilePath ? antigravitySessionOrigin(args.sessionFilePath) : null
+ if (origin && origin !== 'antigravity-cli') {
+ if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) {
+ return false
+ }
+ const sourceHost = sessionExecutionHostId ?? LOCAL_EXECUTION_HOST_ID
+ const targetHost =
+ targetExecutionHostId ?? (args.targetStatus === 'local' ? LOCAL_EXECUTION_HOST_ID : null)
+ if (sourceHost !== targetHost) {
+ // #6270's SSH/UNC labels do not prove this host owns the referenced file.
+ return false
+ }
+ if (args.targetStatus === 'local' && args.targetWslDistro === undefined) {
+ return false
+ }
+ const sourceWsl = args.sessionFilePath ? parseWslUncPath(args.sessionFilePath) : null
+ if (sourceWsl) {
+ return (
+ args.targetStatus === 'local' &&
+ Boolean(args.targetWslDistro) &&
+ sourceWsl.distro.toLowerCase() === args.targetWslDistro?.toLowerCase()
+ )
+ }
+ // File references require the original filesystem, unlike legacy ID resumes.
+ return args.targetStatus !== 'local' || !args.targetWslDistro
+ }
if (args.targetStatus === 'runtime') {
// Runtime session stores live on one paired server; only queue resumes back
// onto that exact server host.
diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts
index 7988e4656a7..c93efe80d83 100644
--- a/src/renderer/src/lib/local-preflight-context.ts
+++ b/src/renderer/src/lib/local-preflight-context.ts
@@ -9,7 +9,9 @@ import {
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { Repo } from '../../../shared/repo-types'
import type { Worktree } from '../../../shared/worktree/types'
-import { getIndexedRepoMap, getIndexedWorktreeById } from '@/store/worktree-repo-index'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+import { getIndexedRepoMap } from '@/store/worktree-repo-index'
+import { getLocalProjectRuntimeWorkspace } from './local-project-runtime-workspace'
import { getProviderRuntimeContextKey } from './provider-runtime-context'
import { getRendererAppPlatform } from './renderer-app-platform'
import {
@@ -35,11 +37,11 @@ export {
type LocalProjectRuntimeState = Pick<
AppState,
'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo'
->
+> &
+ Partial>
// Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}`
// or `[]` fallback would miss the cache on every read.
-const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {}
const EMPTY_REPOS: AppState['repos'] = []
type LocalProjectRuntimeWslContext = {
@@ -66,7 +68,13 @@ export function getLocalProjectExecutionRuntimeContext(
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
return undefined
}
- const worktree = getLocalWorktree(state, worktreeId)
+ const worktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
+ if (
+ !worktree &&
+ parseWorkspaceKey(worktreeId ?? state.activeWorktreeId ?? '')?.type === 'folder'
+ ) {
+ return undefined
+ }
const repo = getLocalRuntimeRepoForWorktree(state, worktree)
if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(worktree)) {
return undefined
@@ -262,7 +270,7 @@ function getCachedLocalProjectRuntimeWslContext(): LocalProjectRuntimeWslContext
}
function getLocalPreflightWslDistro(state: AppState, worktreeId?: string | null): string | null {
- const activeWorktree = getLocalWorktree(state, worktreeId)
+ const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree)
if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) {
return null
@@ -275,6 +283,9 @@ function getLocalRuntimeRepoForWorktree(
state: LocalProjectRuntimeState,
worktree?: Pick | null
): Pick | undefined {
+ if (!worktree && parseWorkspaceKey(state.activeWorktreeId ?? '')?.type === 'folder') {
+ return undefined
+ }
const repoId = worktree?.repoId ?? state.activeRepoId
return repoId ? getIndexedRepoMap(state.repos ?? EMPTY_REPOS).get(repoId) : undefined
}
@@ -303,25 +314,11 @@ function getLocalRuntimeProject(
)
}
-function getLocalWorktree(
- state: LocalProjectRuntimeState,
- worktreeId?: string | null
-): Pick | null {
- const targetWorktreeId = worktreeId ?? state.activeWorktreeId
- if (!targetWorktreeId) {
- return null
- }
- return (
- getIndexedWorktreeById(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO, targetWorktreeId) ??
- null
- )
-}
-
function getLocalPreflightProjectId(
state: LocalProjectRuntimeState,
worktreeId?: string | null
): string {
- const activeWorktree = getLocalWorktree(state, worktreeId)
+ const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
return (
activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project'
)
diff --git a/src/renderer/src/lib/local-project-runtime-workspace.ts b/src/renderer/src/lib/local-project-runtime-workspace.ts
new file mode 100644
index 00000000000..1eda3cb9f67
--- /dev/null
+++ b/src/renderer/src/lib/local-project-runtime-workspace.ts
@@ -0,0 +1,57 @@
+import type { AppState } from '@/store/types'
+import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
+import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
+import {
+ findFolderWorkspaceCandidateRepos,
+ resolveFolderWorkspaceHost
+} from '../../../shared/folder-workspace-execution-host'
+import type { Worktree } from '../../../shared/worktree/types'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+
+type RuntimeWorkspaceState = Pick &
+ Partial>
+
+const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {}
+
+export function getLocalProjectRuntimeWorkspace(
+ state: RuntimeWorkspaceState,
+ worktreeId?: string | null
+): Pick | null {
+ const targetWorktreeId = worktreeId ?? state.activeWorktreeId
+ if (!targetWorktreeId) {
+ return null
+ }
+ const scope = parseWorkspaceKey(targetWorktreeId)
+ if (scope?.type !== 'folder') {
+ return (
+ getIndexedWorktreeById(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO, targetWorktreeId) ??
+ null
+ )
+ }
+ const folderState = {
+ folderWorkspaces: state.folderWorkspaces ?? [],
+ projectGroups: state.projectGroups ?? [],
+ repos: state.repos
+ }
+ const folder = folderState.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId)
+ if (!folder || resolveFolderWorkspaceHost(folderState, folder.id).kind !== 'local') {
+ return null
+ }
+ const group = folderState.projectGroups.find((entry) => entry.id === folder.projectGroupId)
+ if (
+ (folder.executionHostId ?? group?.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !==
+ LOCAL_EXECUTION_HOST_ID
+ ) {
+ return null
+ }
+ const candidates = findFolderWorkspaceCandidateRepos(folderState, folder.id)
+ const repo = candidates.length === 1 ? candidates[0] : undefined
+ return repo && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
+ ? {
+ id: targetWorktreeId,
+ repoId: repo.id,
+ path: folder.folderPath,
+ hostId: LOCAL_EXECUTION_HOST_ID
+ }
+ : null
+}
diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts
index 5f257c5c12a..10e1ade04f6 100644
--- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts
+++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts
@@ -55,6 +55,7 @@ export function createWebAiVaultApi(): NonNullable['aiVault'
}
// Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host.
return callRuntimeResult('aiVault.listSessions', {
+ includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
limit: args?.limit,
force: args?.force,
scopePaths: args?.scopePaths,
diff --git a/src/shared/ai-vault-resume-command.ts b/src/shared/ai-vault-resume-command.ts
index 60f50023188..800efc56d60 100644
--- a/src/shared/ai-vault-resume-command.ts
+++ b/src/shared/ai-vault-resume-command.ts
@@ -1,6 +1,11 @@
// Resume-command construction for Agent Session History rows: turns a scanned
// session into the shell line that re-enters it, quoted for the target platform
// and (when known) the live tab's shell.
+import {
+ isAntigravityReferenceSession,
+ antigravityTranscriptReferencePrompt
+} from './antigravity-session-origin'
+import { normalizeAiVaultResumeFilePath } from './ai-vault-resume-path'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import {
clearEnvCommand,
@@ -41,7 +46,11 @@ export function buildAiVaultResumeCommand(args: {
: shell
? quoteStartupArg(resumeTarget, shell)
: quoteShellArg(resumeTarget, platform)
- const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg)
+ const referencePath = normalizeAiVaultResumeFilePath(resumeFilePath ?? undefined, platform)
+ const resumeCommand =
+ isAntigravityReferenceSession({ agent, filePath: referencePath }) && referencePath
+ ? `${baseCommand} --prompt-interactive ${quoteResumeArg(antigravityTranscriptReferencePrompt(referencePath), platform, shell)}`
+ : buildAgentResumeInvocation(agent, baseCommand, sessionArg)
return buildAiVaultResumeShellCommand({
resumeCommand,
diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts
index 26431f05810..fc650abdfc7 100644
--- a/src/shared/ai-vault-types.ts
+++ b/src/shared/ai-vault-types.ts
@@ -116,6 +116,8 @@ export type AiVaultSession = {
/** Older messages fell out of the newest-N window: the earliest preview turn
* is NOT the opening ask, so first-prompt consumers must not scan it. */
previewMessagesTruncated?: boolean
+ /** Bounded opening-prompt identity for Antigravity history joins; never a rolling preview. */
+ antigravityOpeningPrompt?: { hash: string; timestamp: string | null }
/**
* Full first non-injected user prompt. List scans omit this (payload/perf);
* populated only by on-demand `aiVault.getFirstUserPrompt` re-parses for copy.
@@ -212,6 +214,8 @@ export type AiVaultScanIssue = {
}
export type AiVaultListArgs = {
+ /** Opt-in promises this client starts IDE history in a new CLI conversation. */
+ includeAntigravityIdeSessions?: boolean
limit?: number
unlimited?: boolean
force?: boolean
diff --git a/src/shared/antigravity-session-origin.ts b/src/shared/antigravity-session-origin.ts
new file mode 100644
index 00000000000..de4b1d4b1b2
--- /dev/null
+++ b/src/shared/antigravity-session-origin.ts
@@ -0,0 +1,27 @@
+export const ANTIGRAVITY_HISTORY_ROOTS = [
+ 'antigravity-cli',
+ 'antigravity-ide',
+ 'antigravity'
+] as const
+export type AntigravitySessionOrigin = (typeof ANTIGRAVITY_HISTORY_ROOTS)[number]
+
+export function antigravitySessionOrigin(filePath: string): AntigravitySessionOrigin | null {
+ const segments = filePath.split(/[\\/]+/).filter(Boolean)
+ const brainIndex = segments.lastIndexOf('brain')
+ if (brainIndex < 2 || segments[brainIndex - 2] !== '.gemini') {
+ return null
+ }
+ return ANTIGRAVITY_HISTORY_ROOTS.find((origin) => origin === segments[brainIndex - 1]) ?? null
+}
+
+export function isAntigravityReferenceSession(session: {
+ agent: string
+ filePath?: string
+}): boolean {
+ const origin = session.filePath ? antigravitySessionOrigin(session.filePath) : null
+ return session.agent === 'antigravity' && origin !== null && origin !== 'antigravity-cli'
+}
+
+export function antigravityTranscriptReferencePrompt(filePath: string): string {
+ return `Review the chat transcript at ${JSON.stringify(filePath)}. Confirm your understanding of the previous conversation and continue from where it left off. This starts a new CLI conversation using the original transcript as a reference.`
+}
diff --git a/src/shared/antigravity-transcript-reference.test.ts b/src/shared/antigravity-transcript-reference.test.ts
new file mode 100644
index 00000000000..e5da85b3ed0
--- /dev/null
+++ b/src/shared/antigravity-transcript-reference.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest'
+import { buildAiVaultResumeCommand } from './ai-vault-resume-command'
+import { tokenizeCommandLine } from './agent-command-line-entrypoint'
+import { antigravityTranscriptReferencePrompt } from './antigravity-session-origin'
+import { normalizeAiVaultResumeFilePath } from './ai-vault-resume-path'
+
+describe('Antigravity transcript reference launch', () => {
+ it.each(['antigravity-ide', 'antigravity'])(
+ 'starts a new conversation for %s and preserves the explicit transcript argument',
+ (origin) => {
+ const path = `/home/example/.gemini/${origin}/brain/same-id/.system_generated/logs/transcript_full.jsonl`
+ const command = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'same-id',
+ cwd: null,
+ platform: 'linux',
+ resumeFilePath: path
+ })
+ expect(tokenizeCommandLine(command)).toEqual([
+ 'agy',
+ '--prompt-interactive',
+ antigravityTranscriptReferencePrompt(path)
+ ])
+ expect(command).not.toContain('--conversation')
+ }
+ )
+ it('quotes hostile path text as one prompt argument', () => {
+ const path = `/tmp/a'$(touch forbidden)/.gemini/antigravity-ide/brain/id/.system_generated/logs/transcript.jsonl`
+ const command = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'id',
+ cwd: null,
+ platform: 'linux',
+ resumeFilePath: path,
+ shell: 'posix'
+ })
+ expect(tokenizeCommandLine(command)).toEqual([
+ 'agy',
+ '--prompt-interactive',
+ antigravityTranscriptReferencePrompt(path)
+ ])
+ })
+ it('converts the selected WSL transcript into its owning Linux path', () => {
+ const path =
+ '\\\\wsl.localhost\\Ubuntu\\home\\example\\.gemini\\antigravity-ide\\brain\\id\\.system_generated\\logs\\transcript_full.jsonl'
+ const normalized = normalizeAiVaultResumeFilePath(path, 'linux')
+ const command = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'id',
+ cwd: null,
+ platform: 'linux',
+ resumeFilePath: path
+ })
+ expect(tokenizeCommandLine(command)).toEqual([
+ 'agy',
+ '--prompt-interactive',
+ antigravityTranscriptReferencePrompt(normalized!)
+ ])
+ expect(command).not.toContain('wsl.localhost')
+ })
+ it.each(['cmd', 'powershell', 'posix'] as const)(
+ 'uses the actual Windows %s shell and preserves CLI resume behavior',
+ (shell) => {
+ const path =
+ 'C:\\User data\\.gemini\\antigravity-ide\\brain\\id\\.system_generated\\logs\\transcript_full.jsonl'
+ const command = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'id',
+ cwd: null,
+ platform: 'win32',
+ resumeFilePath: path,
+ shell
+ })
+ expect(command).toContain('--prompt-interactive')
+ expect(command).not.toContain('--conversation')
+ const cli = buildAiVaultResumeCommand({
+ agent: 'antigravity',
+ sessionId: 'id',
+ cwd: null,
+ platform: 'win32',
+ resumeFilePath: path.replace('antigravity-ide', 'antigravity-cli'),
+ shell
+ })
+ expect(cli).toContain('--conversation')
+ expect(cli).not.toContain('--prompt-interactive')
+ }
+ )
+})
diff --git a/src/shared/node-bounded-file-reader.ts b/src/shared/node-bounded-file-reader.ts
index bc4d19233da..a8206b1a142 100644
--- a/src/shared/node-bounded-file-reader.ts
+++ b/src/shared/node-bounded-file-reader.ts
@@ -1,5 +1,5 @@
-import { closeSync, fstatSync, openSync, readSync, type Stats } from 'node:fs'
-import { open, type FileHandle } from 'node:fs/promises'
+import { closeSync, constants, fstatSync, openSync, readSync, type Stats } from 'node:fs'
+import { open, stat, type FileHandle } from 'node:fs/promises'
const MIN_GROWTH_BYTES = 64 * 1024
@@ -20,6 +20,8 @@ export type BoundedNodeFileRead = {
stats: Stats
}
+type NodeFileReadOptions = { regularFileOnly?: boolean; signal?: AbortSignal }
+
function validateSize(size: number, maxBytes: number): void {
if (!Number.isSafeInteger(size) || size < 0) {
throw new Error('File has an invalid byte size')
@@ -31,11 +33,21 @@ function validateSize(size: number, maxBytes: number): void {
export async function readNodeFileWithinLimit(
filePath: string,
- maxBytes: number
+ maxBytes: number,
+ options: NodeFileReadOptions = {}
): Promise {
- const handle = await open(filePath, 'r')
+ options.signal?.throwIfAborted()
+ if (options.regularFileOnly && !(await stat(filePath)).isFile()) {
+ throw new Error('Expected a regular file')
+ }
+ options.signal?.throwIfAborted()
+ // Nonblocking open fences replacement with a FIFO after the path check.
+ const flags = options.regularFileOnly
+ ? constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK)
+ : 'r'
+ const handle = await open(filePath, flags)
try {
- return await readNodeFileHandleWithinLimit(handle, maxBytes)
+ return await readNodeFileHandleWithinLimit(handle, maxBytes, options)
} finally {
await handle.close()
}
@@ -43,20 +55,28 @@ export async function readNodeFileWithinLimit(
export async function readNodeFileHandleWithinLimit(
handle: FileHandle,
- maxBytes: number
+ maxBytes: number,
+ options: NodeFileReadOptions = {}
): Promise {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
throw new RangeError('File read limit must be a non-negative safe integer')
}
+ options.signal?.throwIfAborted()
const stats = await handle.stat()
+ options.signal?.throwIfAborted()
+ if (options.regularFileOnly && !stats.isFile()) {
+ throw new Error('Expected a regular file')
+ }
validateSize(stats.size, maxBytes)
let buffer = Buffer.allocUnsafe(stats.size)
let offset = 0
while (true) {
+ options.signal?.throwIfAborted()
while (offset < buffer.length) {
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset)
+ options.signal?.throwIfAborted()
if (bytesRead === 0) {
return { buffer: buffer.subarray(0, offset), stats }
}
@@ -65,6 +85,7 @@ export async function readNodeFileHandleWithinLimit(
const probe = Buffer.allocUnsafe(1)
const { bytesRead } = await handle.read(probe, 0, 1, offset)
+ options.signal?.throwIfAborted()
if (bytesRead === 0) {
return { buffer: buffer.subarray(0, offset), stats }
}
diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts
index d036af9177f..f2f86da6a22 100644
--- a/src/shared/rpc-contract/ai-vault-params.ts
+++ b/src/shared/rpc-contract/ai-vault-params.ts
@@ -32,6 +32,7 @@ export const AiVaultListSessionsParams = z
)
.pipe(z.union([z.number().int(), z.undefined()]))
.optional(),
+ includeAntigravityIdeSessions: OptionalBoolean,
unlimited: OptionalBoolean,
force: OptionalBoolean,
scopePaths: z
diff --git a/src/shared/ssh-ai-vault-relay.ts b/src/shared/ssh-ai-vault-relay.ts
index 2672cfe7566..4a88d7b4558 100644
--- a/src/shared/ssh-ai-vault-relay.ts
+++ b/src/shared/ssh-ai-vault-relay.ts
@@ -6,6 +6,7 @@ export const SSH_AI_VAULT_LIST_LIMIT_MAX = 1000
export const SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
export type SshAiVaultRelayListParams = {
+ includeAntigravityIdeSessions?: boolean
limit?: number
unlimited?: boolean
force?: boolean