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