Compare commits

...

5 Commits

Author SHA1 Message Date
Sehoon Shon a04e8cce43 docs: add documentation for teleportation feature 2026-03-19 01:19:56 -04:00
Sehoon Shon e806947c87 feat(core): implement core teleporter module 2026-03-19 01:19:53 -04:00
Sehoon Shon 6d3e4764cc feat(cli): support extension installation for teleportation 2026-03-19 01:19:49 -04:00
Sehoon Shon 4f8534b223 feat(cli): integrate TrajectoryProvider into /resume UI 2026-03-19 01:19:39 -04:00
Sehoon Shon b75ec12185 feat(core): define TrajectoryProvider interface 2026-03-19 01:19:34 -04:00
25 changed files with 68856 additions and 47 deletions
+96
View File
@@ -0,0 +1,96 @@
# One-Pager: Session Teleportation (External Tool Resumption)
## 1. Objective
Enable Gemini CLI users to seamlessly resume work sessions (trajectories) that
were originated and executed in external coding tools or IDE extensions. This
"teleportation" of sessions establishes Gemini CLI as a centralized hub for
AI-assisted development across various environments.
## 2. Background & Motivation
Developers frequently switch between multiple tools (e.g., IDE extensions,
web-based coding assistants, and the terminal). Currently, context is
fragmented; a session started in an external tool cannot be natively continued
in Gemini CLI. By allowing Gemini CLI to parse and ingest trajectories from
these external tools, we prevent context loss, avoid duplicate work, and
significantly enhance developer productivity.
## 3. Goals & Non-Goals
**Goals:**
- Provide a standardized abstraction (`TrajectoryProvider`) for fetching
external session histories.
- Implement a core translation layer (`teleporter` module) to map external tool
actions (e.g., `find`, `search_web`, `read_url`, `write_to_file`) to Gemini
CLI's native tool formats.
- Update the `/resume` CLI command to display and filter these external sessions
alongside native ones.
- Support dynamic loading of new trajectory providers via the extension system.
**Non-Goals:**
- Two-way sync: We do not intend to export Gemini CLI sessions back to the
originating external tool in this phase.
- Perfect fidelity for unsupported external tools: Only explicitly supported and
mapped tools will be translated. Others will be gracefully ignored or logged
as plain text.
## 4. Proposed Design / Architecture
The design revolves around a pluggable architecture that decouples the fetching
of external trajectories from the core CLI logic.
### 4.1. `TrajectoryProvider` Interface
A standard contract that external extensions implement. It defines methods for:
- Retrieving a list of available sessions.
- Fetching the full trajectory payload for a specific session ID.
- Providing metadata (display name, icon, source tool name) for the CLI UI.
### 4.2. Core `teleporter` Module
This module acts as the ingestion engine. It performs:
- **Deserialization:** Parsing the raw JSON/format of the external tool.
- **Conversion:** Mapping external tool calls (e.g., an Antigravity `search_web`
call) into the equivalent Gemini CLI tool call format, ensuring the LLM
understands the historical context correctly.
- **Validation:** Ensuring the resulting session history conforms to Gemini
CLI's internal session schema.
### 4.3. UI Integration (`/resume`)
The `SessionBrowser` UI will be updated to handle multiple sources.
- Introduce dynamic tabs or source filters in the TUI (Terminal UI).
- Display metadata (e.g., "Imported from Tool X", timestamps) to help users
distinguish between native and teleported sessions.
### 4.4. Extension Manager Updates
Enhance the existing `extensionLoader` to discover, load, and register plugins
that implement the `TrajectoryProvider` interface dynamically at runtime.
## 5. Rollout Plan
The implementation is broken down into incremental PRs tracked by Epic #22801:
1. **Define Core Interfaces:** Establish `TrajectoryProvider`.
2. **Implement Translation Logic:** Build the `teleporter` module with initial
conversion mappings.
3. **UI Updates:** Hook the providers into the `/resume` interface.
4. **Extension Support:** Enable dynamic loading of providers.
5. **Documentation:** Publish `TELEPORTATION.md` outlining how to build new
providers.
## 6. Security & Privacy
- **Local First:** Trajectories should be read from local filesystem artifacts
or secure local IPC mechanisms where possible, avoiding unnecessary network
calls.
- **Data Sanitization:** The conversion process must not execute any historical
commands; it strictly translates historical records into past-tense context
for the LLM.
+1
View File
@@ -52,6 +52,7 @@ export default tseslint.config(
'packages/test-utils/**',
'.gemini/skills/**',
'**/*.d.ts',
'packages/core/src/teleportation/trajectory_teleporter.min.js',
],
},
eslint.configs.recommended,
@@ -51,6 +51,7 @@ import {
type HookDefinition,
type HookEventName,
type ResolvedExtensionSetting,
type TrajectoryProvider,
coreEvents,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
@@ -957,6 +958,23 @@ Would you like to attempt to install via "git clone" instead?`,
);
}
let trajectoryProviderModule: TrajectoryProvider | undefined;
if (config.trajectoryProvider) {
try {
const expectedPath = path.resolve(
effectiveExtensionPath,
config.trajectoryProvider,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
trajectoryProviderModule = (await import(expectedPath))
.default as TrajectoryProvider;
} catch (e) {
debugLogger.warn(
`Failed to import trajectoryProvider at ${config.trajectoryProvider} for extension ${config.name}: ${getErrorMessage(e)}`,
);
}
}
return {
name: config.name,
version: config.version,
@@ -980,6 +998,7 @@ Would you like to attempt to install via "git clone" instead?`,
rules,
checkers,
plan: config.plan,
trajectoryProviderModule,
};
} catch (e) {
const extName = path.basename(extensionDir);
+5
View File
@@ -46,6 +46,11 @@ export interface ExtensionConfig {
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
/**
* Path to a module that implements the TrajectoryProvider interface.
* Used for importing binary chat histories like Jetski Teleportation.
*/
trajectoryProvider?: string;
}
export interface ExtensionUpdateInfo {
+27
View File
@@ -19,12 +19,16 @@ import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
import { pathToFileURL } from 'node:url';
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
export interface InitializationResult {
authError: string | null;
accountSuspensionInfo: AccountSuspensionInfo | null;
themeError: string | null;
shouldOpenAuthDialog: boolean;
geminiMdFileCount: number;
recentExternalSession?: { prefix: string; id: string; displayName?: string };
}
/**
@@ -60,11 +64,34 @@ export async function initializeApp(
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
}
// Check for recent external sessions to prompt for resume
let recentExternalSession:
| { prefix: string; id: string; displayName?: string }
| undefined;
if (config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensionLoader = (config as any)._extensionLoader;
if (extensionLoader) {
const workspaceUri = pathToFileURL(process.cwd()).toString();
const recent =
await extensionLoader.getRecentExternalSession(workspaceUri);
if (recent) {
recentExternalSession = {
prefix: recent.prefix,
id: recent.id,
displayName: recent.displayName,
};
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
return {
authError,
accountSuspensionInfo,
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
recentExternalSession,
};
}
+13
View File
@@ -469,6 +469,19 @@ export const AppContainer = (props: AppContainerProps) => {
generateSummary(config).catch((e) => {
debugLogger.warn('Background summary generation failed:', e);
});
// Show handoff prompt for recent external sessions
if (initializationResult.recentExternalSession) {
const { prefix, id, displayName } =
initializationResult.recentExternalSession;
historyManager.addItem(
{
type: MessageType.INFO,
text: `🛸 You have a recent session in ${displayName || 'an external tool'}. Type "/resume ${prefix}${id}" to bring it into the terminal.`,
},
Date.now(),
);
}
})();
registerCleanup(async () => {
// Turn off mouse scroll.
@@ -8,10 +8,14 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import { agentsCommand } from './agentsCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Config } from '@google/gemini-cli-core';
import { Storage } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
import { enableAgent, disableAgent } from '../../utils/agentSettings.js';
import { renderAgentActionFeedback } from '../../utils/agentUtils.js';
import * as fs from 'node:fs/promises';
vi.mock('node:fs/promises');
vi.mock('../../utils/agentSettings.js', () => ({
enableAgent: vi.fn(),
@@ -105,40 +109,34 @@ describe('agentsCommand', () => {
);
});
it('should reload the agent registry when reload subcommand is called', async () => {
it('should reload the agent registry when refresh subcommand is called', async () => {
const reloadSpy = vi.fn().mockResolvedValue(undefined);
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
reload: reloadSpy,
});
const reloadCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'reload',
const refreshCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
expect(reloadCommand).toBeDefined();
expect(refreshCommand).toBeDefined();
const result = await reloadCommand!.action!(mockContext, '');
const result = await refreshCommand!.action!(mockContext, '');
expect(reloadSpy).toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Reloading agent registry...',
}),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Agents reloaded successfully',
content: 'Agents refreshed successfully.',
});
});
it('should show an error if agent registry is not available during reload', async () => {
it('should show an error if agent registry is not available during refresh', async () => {
mockConfig.getAgentRegistry = vi.fn().mockReturnValue(undefined);
const reloadCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'reload',
const refreshCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
);
const result = await reloadCommand!.action!(mockContext, '');
const result = await refreshCommand!.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
@@ -462,4 +460,56 @@ describe('agentsCommand', () => {
expect(completions).toEqual(['agent1', 'agent2']);
});
});
describe('import sub-command', () => {
it('should import an agent with correct tool mapping', async () => {
vi.mocked(fs.readFile).mockResolvedValue(`---
name: test-claude-agent
tools: Read, Glob, Grep, Bash
model: sonnet
---
System prompt content`);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.spyOn(Storage, 'getUserAgentsDir').mockReturnValue('/mock/agents');
const importCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'import',
);
expect(importCommand).toBeDefined();
const result = await importCommand!.action!(
mockContext,
'/path/to/claude.md',
);
expect(fs.readFile).toHaveBeenCalledWith('/path/to/claude.md', 'utf-8');
expect(fs.writeFile).toHaveBeenCalledWith(
expect.stringMatching(/test-claude-agent\.md$/),
expect.stringContaining('tools:\n - read_file\n - run_command\n'),
'utf-8',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining(
"Successfully imported agent 'test-claude-agent'",
),
});
});
it('should show error if no file path provided', async () => {
const importCommand = agentsCommand.subCommands?.find(
(cmd) => cmd.name === 'import',
);
const result = await importCommand!.action!(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /agents import <file-path>',
});
});
});
});
+142 -10
View File
@@ -8,6 +8,7 @@ import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { Content, Part } from '@google/genai';
import type {
CommandContext,
SlashCommand,
@@ -18,6 +19,7 @@ import {
decodeTagName,
type MessageActionReturn,
INITIAL_HISTORY_LENGTH,
type ConversationRecord,
} from '@google/gemini-cli-core';
import path from 'node:path';
import type {
@@ -174,8 +176,119 @@ const resumeCheckpointCommand: SlashCommand = {
const { logger, config } = context.services;
await logger.initialize();
const checkpoint = await logger.loadCheckpoint(tag);
const conversation = checkpoint.history;
let conversation: readonly Content[] = [];
let authType: string | undefined;
const loadExternalTrajectory = async (
prefix: string,
id: string,
): Promise<Content[] | null> => {
let record: ConversationRecord | null = null;
if (config && config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
const extensions = (config as any)._extensionLoader?.getExtensions
? (config as any)._extensionLoader.getExtensions()
: [];
for (const extension of extensions) {
if (
extension.trajectoryProviderModule &&
extension.trajectoryProviderModule.prefix === prefix
) {
try {
record = await extension.trajectoryProviderModule.loadSession(id);
if (record) break;
} catch (_err) {
// Ignore failure
}
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */
}
if (!record) return null;
const conv: Content[] = [];
// Add a dummy system message so slice(INITIAL_HISTORY_LENGTH) works correctly
conv.push({ role: 'user', parts: [{ text: '' }] });
for (const m of record.messages) {
if (m.type === 'user') {
const parts = Array.isArray(m.content)
? m.content.map((c: string | Part) =>
typeof c === 'string' ? { text: c } : { text: c.text || '' },
)
: [{ text: '' }];
conv.push({ role: 'user', parts });
} else if (m.type === 'gemini') {
// 1. Model turn: Text + Function Calls
const modelParts: Part[] = [];
if (Array.isArray(m.content)) {
m.content.forEach((c: string | Part) => {
modelParts.push(
typeof c === 'string' ? { text: c } : { text: c.text || '' },
);
});
}
if (m.toolCalls) {
for (const tc of m.toolCalls) {
modelParts.push({
functionCall: {
name: tc.name,
args: tc.args,
},
});
}
}
conv.push({ role: 'model', parts: modelParts });
// 2. User turn: Function Responses (if any)
if (
m.toolCalls &&
m.toolCalls.some((tc: { result?: unknown }) => tc.result)
) {
const responseParts: Part[] = [];
for (const tc of m.toolCalls) {
if (tc.result) {
responseParts.push({
functionResponse: {
name: tc.name,
response: { result: tc.result },
id: tc.id,
},
});
}
}
if (responseParts.length > 0) {
conv.push({ role: 'user', parts: responseParts });
}
}
}
}
return conv;
};
// Check if tag format is prefix:id
const prefixMatch = tag.match(/^([a-zA-Z0-9_]+):(.*)$/);
if (prefixMatch) {
const prefix = prefixMatch[1] + ':';
const id = prefixMatch[2];
const externalConv = await loadExternalTrajectory(prefix, id);
if (!externalConv) {
return {
type: 'message',
messageType: 'error',
content: `No external session found with prefix ${prefix} and id: ${id}`,
};
}
conversation = externalConv;
} else {
const checkpoint = await logger.loadCheckpoint(tag);
if (checkpoint.history.length > 0) {
conversation = checkpoint.history;
authType = checkpoint.authType;
}
}
if (conversation.length === 0) {
return {
@@ -186,15 +299,11 @@ const resumeCheckpointCommand: SlashCommand = {
}
const currentAuthType = config?.getContentGeneratorConfig()?.authType;
if (
checkpoint.authType &&
currentAuthType &&
checkpoint.authType !== currentAuthType
) {
if (authType && currentAuthType && authType !== currentAuthType) {
return {
type: 'message',
messageType: 'error',
content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`,
content: `Cannot resume chat. It was saved with a different authentication method (${authType}) than the current one (${currentAuthType}).`,
};
}
@@ -206,11 +315,34 @@ const resumeCheckpointCommand: SlashCommand = {
const uiHistory: HistoryItemWithoutId[] = [];
for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) {
const text =
item.parts
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parts = item.parts as Array<{
text?: string;
functionCall?: { name: string };
functionResponse?: { name: string };
}>;
let text =
parts
?.filter((m) => !!m.text)
.map((m) => m.text)
.join('') || '';
const toolCalls = parts?.filter((p) => !!p.functionCall);
if (toolCalls?.length) {
const calls = toolCalls
.map((tc) => `[Tool Call: ${tc.functionCall?.name}]`)
.join('\n');
text = text ? `${text}\n${calls}` : calls;
}
const toolResponses = parts?.filter((p) => !!p.functionResponse);
if (toolResponses?.length) {
const responses = toolResponses
.map((tr) => `[Tool Result: ${tr.functionResponse?.name}]`)
.join('\n');
text = text ? `${text}\n${responses}` : responses;
}
if (!text) {
continue;
}
@@ -12,12 +12,16 @@ import { Colors } from '../colors.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
import type { Config } from '@google/gemini-cli-core';
import { pathToFileURL } from 'node:url';
import { type Config } from '@google/gemini-cli-core';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import {
formatRelativeTime,
getSessionFiles,
} from '../../utils/sessionUtils.js';
import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js';
import { TabHeader, type Tab } from './shared/TabHeader.js';
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return */
/**
* Props for the main SessionBrowser component.
@@ -41,6 +45,8 @@ export interface SessionBrowserState {
// Data state
/** All loaded sessions */
sessions: SessionInfo[];
/** Extension tabs */
extensionTabs: Array<{ name: string; sessions: SessionInfo[] }>;
/** Sessions after filtering and sorting */
filteredAndSortedSessions: SessionInfo[];
@@ -55,6 +61,8 @@ export interface SessionBrowserState {
scrollOffset: number;
/** Terminal width for layout calculations */
terminalWidth: number;
/** Current active tab (0: CLI, 1+: External) */
activeTab: number;
// Search state
/** Current search query string */
@@ -83,6 +91,10 @@ export interface SessionBrowserState {
// State setters
/** Update sessions array */
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
/** Update extensionTabs array */
setExtensionTabs: React.Dispatch<
React.SetStateAction<Array<{ name: string; sessions: SessionInfo[] }>>
>;
/** Update loading state */
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
/** Update error state */
@@ -91,6 +103,8 @@ export interface SessionBrowserState {
setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
/** Update scroll offset */
setScrollOffset: React.Dispatch<React.SetStateAction<number>>;
/** Update active tab */
setActiveTab: (index: number) => void;
/** Update search query */
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
/** Update search mode state */
@@ -352,6 +366,9 @@ export const useSessionBrowserState = (
): SessionBrowserState => {
const { columns: terminalWidth } = useTerminalSize();
const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions);
const [extensionTabs, setExtensionTabs] = useState<
Array<{ name: string; sessions: SessionInfo[] }>
>([]);
const [loading, setLoading] = useState(initialLoading);
const [error, setError] = useState<string | null>(initialError);
const [activeIndex, setActiveIndex] = useState(0);
@@ -365,10 +382,20 @@ export const useSessionBrowserState = (
const [hasLoadedFullContent, setHasLoadedFullContent] = useState(false);
const loadingFullContentRef = useRef(false);
const [activeTab, setActiveTabInternal] = useState(0);
const setActiveTab = useCallback((index: number) => {
setActiveTabInternal(index);
setActiveIndex(0);
setScrollOffset(0);
}, []);
const filteredAndSortedSessions = useMemo(() => {
const filtered = filterSessions(sessions, searchQuery);
const currentTabSessions =
activeTab === 0 ? sessions : extensionTabs[activeTab - 1]?.sessions || [];
const filtered = filterSessions(currentTabSessions, searchQuery);
return sortSessions(filtered, sortOrder, sortReverse);
}, [sessions, searchQuery, sortOrder, sortReverse]);
}, [sessions, extensionTabs, activeTab, searchQuery, sortOrder, sortReverse]);
// Reset full content flag when search is cleared
useEffect(() => {
@@ -386,6 +413,8 @@ export const useSessionBrowserState = (
const state: SessionBrowserState = {
sessions,
setSessions,
extensionTabs,
setExtensionTabs,
loading,
setLoading,
error,
@@ -394,6 +423,8 @@ export const useSessionBrowserState = (
setActiveIndex,
scrollOffset,
setScrollOffset,
activeTab,
setActiveTab,
searchQuery,
setSearchQuery,
isSearchMode,
@@ -415,12 +446,43 @@ export const useSessionBrowserState = (
return state;
};
/**
* Converts an external session info to CLI SessionInfo format.
*/
type ExternalSessionInfo = {
id: string;
mtime: string;
name?: string;
displayName?: string;
messageCount?: number;
prefix?: string;
};
function convertExternalToSessionInfo(
ext: ExternalSessionInfo,
index: number,
): SessionInfo {
return {
id: ext.prefix ? `${ext.prefix}${ext.id}` : ext.id,
file: ext.id,
fileName: ext.id + '.ext',
startTime: ext.mtime,
lastUpdated: ext.mtime,
messageCount: ext.messageCount || 0,
displayName: ext.displayName || ext.name || 'External Session',
firstUserMessage: ext.displayName || ext.name || '',
isCurrentSession: false,
index,
};
}
/**
* Hook to load sessions on mount.
*/
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const {
setSessions,
setExtensionTabs,
setLoading,
setError,
isSearchMode,
@@ -432,11 +494,58 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const loadSessions = async () => {
try {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
const sessionData = await getSessionFiles(
chatsDir,
config.getSessionId(),
);
const workspaceUri = pathToFileURL(process.cwd()).toString();
const externalTabs: Array<{ name: string; sessions: SessionInfo[] }> =
[];
if (config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensions = (config as any)._extensionLoader?.getExtensions
? (config as any)._extensionLoader.getExtensions()
: [];
for (const extension of extensions) {
if (extension.trajectoryProviderModule) {
try {
const sessions =
await extension.trajectoryProviderModule.listSessions(
workspaceUri,
);
const normalizedExt = sessions
.map((ext: any) => ({
...ext,
prefix: extension.trajectoryProviderModule.prefix,
}))
.sort(
(a: any, b: any) =>
new Date(a.mtime).getTime() - new Date(b.mtime).getTime(),
)
.map((ext: any, i: number) =>
convertExternalToSessionInfo(ext, i + 1),
);
if (normalizedExt.length > 0) {
externalTabs.push({
name:
extension.trajectoryProviderModule.displayName ||
extension.name,
sessions: normalizedExt,
});
}
} catch (_e) {
// Ignore loader errors
}
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
const [sessionData] = await Promise.all([
getSessionFiles(chatsDir, config.getSessionId()),
]);
setSessions(sessionData);
setExtensionTabs(externalTabs);
setLoading(false);
} catch (err) {
setError(
@@ -448,7 +557,7 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadSessions();
}, [config, setSessions, setLoading, setError]);
}, [config, setSessions, setExtensionTabs, setLoading, setError]);
useEffect(() => {
const loadFullContent = async () => {
@@ -610,6 +719,9 @@ export const useSessionBrowserInput = (
}
// Delete session control.
else if (key.sequence === 'x' || key.sequence === 'X') {
// Only allow deleting CLI sessions for now
if (state.activeTab !== 0) return true;
const selectedSession =
state.filteredAndSortedSessions[state.activeIndex];
if (selectedSession && !selectedSession.isCurrentSession) {
@@ -684,6 +796,14 @@ export function SessionBrowserView({
}: {
state: SessionBrowserState;
}): React.JSX.Element {
const tabs: Tab[] = [
{ key: 'cli', header: 'Gemini CLI' },
...state.extensionTabs.map((ext, i) => ({
key: `ext-${i}`,
header: ext.name,
})),
];
if (state.loading) {
return <SessionBrowserLoading />;
}
@@ -692,11 +812,20 @@ export function SessionBrowserView({
return <SessionBrowserError state={state} />;
}
if (state.sessions.length === 0) {
if (state.sessions.length === 0 && state.extensionTabs.length === 0) {
return <SessionBrowserEmpty />;
}
return (
<Box flexDirection="column" paddingX={1}>
<Box marginBottom={1}>
<TabHeader
tabs={tabs}
currentIndex={state.activeTab}
showStatusIcons={false}
/>
</Box>
<SessionListHeader state={state} />
{state.isSearchMode && <SearchModeDisplay state={state} />}
@@ -721,6 +850,14 @@ export function SessionBrowser({
useLoadSessions(config, state);
const moveSelection = useMoveSelection(state);
const cycleSortOrder = useCycleSortOrder(state);
useTabbedNavigation({
tabCount: state.extensionTabs.length + 1,
isActive: !state.isSearchMode,
wrapAround: true,
onTabChange: (index) => state.setActiveTab(index),
});
useSessionBrowserInput(
state,
moveSelection,
+47 -12
View File
@@ -21,6 +21,7 @@ import {
type SessionInfo,
} from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
export { convertSessionToHistoryFormats };
@@ -51,20 +52,54 @@ export const useSessionBrowser = (
handleResumeSession: useCallback(
async (session: SessionInfo) => {
try {
const chatsDir = path.join(
config.storage.getProjectTempDir(),
'chats',
);
let conversation: ConversationRecord;
let filePath: string;
const fileName = session.fileName;
if (session.fileName.endsWith('.ext')) {
// External session
let externalConv: ConversationRecord | null = null;
if (config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensions = (config as any)._extensionLoader?.getExtensions
? (config as any)._extensionLoader.getExtensions()
: [];
for (const extension of extensions) {
if (extension.trajectoryProviderModule) {
const prefix =
extension.trajectoryProviderModule.prefix || '';
if (session.id.startsWith(prefix)) {
const originalId = prefix
? session.id.slice(prefix.length)
: session.id;
externalConv =
await extension.trajectoryProviderModule.loadSession(
originalId,
);
if (externalConv) break;
}
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
const originalFilePath = path.join(chatsDir, fileName);
if (!externalConv) {
throw new Error(`Could not load external session ${session.id}`);
}
conversation = externalConv;
filePath = session.id + '.ext';
} else {
// Regular CLI session
const chatsDir = path.join(
config.storage.getProjectTempDir(),
'chats',
);
const fileName = session.fileName;
filePath = path.join(chatsDir, fileName);
// Load up the conversation.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const conversation: ConversationRecord = JSON.parse(
await fs.readFile(originalFilePath, 'utf8'),
);
// Load up the conversation.
conversation = JSON.parse(await fs.readFile(filePath, 'utf8'));
}
// Use the old session's ID to continue it.
const existingSessionId = conversation.sessionId;
@@ -73,7 +108,7 @@ export const useSessionBrowser = (
const resumedSessionData = {
conversation,
filePath: originalFilePath,
filePath,
};
// We've loaded it; tell the UI about it.
+1
View File
@@ -12,6 +12,7 @@
"scripts": {
"bundle:browser-mcp": "node scripts/bundle-browser-mcp.mjs",
"build": "node ../../scripts/build_package.js",
"build:teleporter": "./scripts/build-teleporter.sh",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write .",
"test": "vitest run",
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -e
# Define paths
CLI_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
EXA_ROOT="$(cd "$CLI_ROOT/../jetski/Exafunction" && pwd)"
TELEPORTER_TS="$CLI_ROOT/packages/core/src/teleportation/trajectory_teleporter.ts"
TELEPORTER_MIN_JS="$CLI_ROOT/packages/core/src/teleportation/trajectory_teleporter.min.js"
if [ ! -d "$EXA_ROOT" ]; then
echo "Error: Exafunction directory not found at $EXA_ROOT"
exit 1
fi
echo "Building Protobuf JS definitions in Exafunction..."
cd "$EXA_ROOT"
pnpm --dir exa/proto_ts build
echo "Bundling and minifying trajectory_teleporter.ts..."
# Because esbuild resolves relative imports from the source file's directory,
# and trajectory_teleporter.ts playfully imports './exa/...', we copy it to EXA_ROOT
# temporarily for the build step to succeed.
cp "$TELEPORTER_TS" "$EXA_ROOT/trajectory_teleporter_tmp.ts"
cd "$EXA_ROOT"
pnpm dlx esbuild "./trajectory_teleporter_tmp.ts" \
--bundle \
--format=esm \
--platform=node \
--outfile="$TELEPORTER_MIN_JS"
rm "$EXA_ROOT/trajectory_teleporter_tmp.ts"
echo "Done! Wrote bundle to $TELEPORTER_MIN_JS"
+23
View File
@@ -9,6 +9,8 @@ import * as path from 'node:path';
import { inspect } from 'node:util';
import process from 'node:process';
import { z } from 'zod';
import type { ConversationRecord } from '../services/chatRecordingService.js';
export type { ConversationRecord };
import {
AuthType,
createContentGenerator,
@@ -228,6 +230,25 @@ export interface ResolvedExtensionSetting {
source?: string;
}
export interface TrajectoryProvider {
/** Prefix used to identify sessions from this provider (e.g., 'ext:') */
prefix: string;
/** Optional display name for UI Tabs */
displayName?: string;
/** Return an array of conversational tags/ids */
listSessions(workspaceUri?: string): Promise<
Array<{
id: string;
mtime: string;
name?: string;
displayName?: string;
messageCount?: number;
}>
>;
/** Load a single conversation payload */
loadSession(id: string): Promise<ConversationRecord | null>;
}
export interface AgentRunConfig {
maxTimeMinutes?: number;
maxTurns?: number;
@@ -377,6 +398,8 @@ export interface GeminiCLIExtension {
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
/** Loaded JS module for trajectory decoding */
trajectoryProviderModule?: TrajectoryProvider;
}
export interface ExtensionInstallMetadata {
+2
View File
@@ -221,6 +221,8 @@ export * from './telemetry/constants.js';
export { sessionId, createSessionId } from './utils/session.js';
export * from './utils/compatibility.js';
export * from './utils/browser.js';
export * from './teleportation/index.js';
export { Storage } from './config/storage.js';
// Export hooks system
@@ -0,0 +1,251 @@
# Trajectory Teleportation: Antigravity to Gemini CLI
This document explains how the Gemini CLI discovers, reads, and converts
Antigravity (Jetski) trajectories to enable session resumption.
## Overview
The teleportation feature allows you to pick up a conversation in the Gemini CLI
that was started in the Antigravity (Jetski) IDE.
## 1. Discovery
The CLI identifies Antigravity sessions by scanning the local filesystem.
- **Storage Location**: `~/.antigravity/conversations`
- **File Format**: Binary Protobuf files with the `.pb` extension.
- **Session IDs**: The filenames (e.g., `f81d4fae-7dec.pb`) serve as the unique
identifiers for resumption.
## 2. Decryption & Parsing
Since Antigravity stores data in a specialized binary format, the CLI uses a
dedicated teleporter bundle:
- **Logic**: `trajectory_teleporter.min.js` (bundled in
`@google/gemini-cli-core`).
- **Process**: The binary `.pb` file is read into a Buffer and passed to the
teleporter's `trajectoryToJson` function, which outputs a standard JavaScript
object.
- **Generation**: To ensure the CLI stays in sync with Jetski's protobuf schema
(`cortex.proto`), this bundle isn't written entirely by hand. It is
auto-generated by compiling the latest schema via `esbuild`. You can rebuild
it anytime by running `npm run build:teleporter` from the CLI root. _(See
[How to Regenerate the Bundle](#how-to-regenerate-the-bundle) for exact
steps)._
## 3. Workspace Identification
To filter sessions by the user's active workspace, the CLI attempts to read the
target workspace from the trajectory.
1. **Primary**: It reads `metadata.workspaces[0].workspaceFolderAbsoluteUri`
from the top-level trajectory metadata. This is the authoritative data
populated by the Jetski Go backend.
2. **Fallback**: For older trajectories without top-level metadata, it attempts
to extract `workspaceUri` from the first user input's
`activeUserState.activeDocument` context.
## 4. Conversion Logic
The conversion layer
([converter.ts](file:///Users/sshon/developments/gemini-cli/packages/core/src/teleportation/converter.ts))
translates the technical "Steps" of an Antigravity trajectory into the CLI's
`ConversationRecord` format:
- **User Input**: Maps `CORTEX_STEP_TYPE_USER_INPUT` (type 14) to `user`
messages.
- **Model Responses**: Maps `CORTEX_STEP_TYPE_PLANNER_RESPONSE` (type 15) to
`gemini` messages.
- **Thoughts & Reasoning**: Extracts reasoning content from the Antigravity step
and populates the `thoughts` array in the CLI record, preserving the model's
logic.
- **Tool Calls**: Maps Antigravity tool execution steps to CLI `ToolCallRecord`
objects. The integration handles these in three main categories:
| Tool Capability | Gemini CLI Tool | Jetski Protobuf Step | Teleportation Status |
| :------------------- | :------------------------------------- | :---------------------------------------------- | :----------------------- |
| **Read File** | `read_file` | `CORTEX_STEP_TYPE_VIEW_FILE` | ✅ Native Mapping |
| **List Directory** | `ls` | `CORTEX_STEP_TYPE_LIST_DIRECTORY` | ✅ Native Mapping |
| **Search Code** | `grep_search` | `CORTEX_STEP_TYPE_GREP_SEARCH` | ✅ Native Mapping |
| **Edit File** | `replace` | `CORTEX_STEP_TYPE_FILE_CHANGE` | ✅ Native Mapping |
| **Search Web** | `google_web_search` | `CORTEX_STEP_TYPE_SEARCH_WEB` | ✅ Native Mapping |
| **Execute Shell** | `run_shell_command` | `CORTEX_STEP_TYPE_RUN_COMMAND` | ✅ Native Mapping |
| **Browser Agent** | _N/A_ \* | `CORTEX_STEP_TYPE_BROWSER_SUBAGENT` | ❌ Dropped |
| **User Input** | `ask_user` | `CORTEX_STEP_TYPE_GENERIC` | ⚠️ Generic / MCP |
| **Read URL Content** | `web_fetch` | `CORTEX_STEP_TYPE_READ_URL_CONTENT` | ❌ Dropped |
| **Find Files** | `glob` | `CORTEX_STEP_TYPE_FIND` | ❌ Dropped |
| **Write File** | `write_file` | `CORTEX_STEP_TYPE_WRITE_TO_FILE` | ❌ Dropped |
| **Read Many Files** | `read_many_files` | `CORTEX_STEP_TYPE_GENERIC` | ⚠️ Generic / MCP |
| **Write Todos** | `write_todos` | `CORTEX_STEP_TYPE_GENERIC` | ⚠️ Generic / MCP |
| **Save Memory** | `save_memory` | `CORTEX_STEP_TYPE_GENERIC` | ⚠️ Generic / MCP |
| **Docs/Skills** | `get_internal_docs` & `activate_skill` | `CORTEX_STEP_TYPE_GENERIC` | ⚠️ Generic / MCP |
| **Plan Mode** | `enter_plan_mode` & `exit_plan_mode` | `CORTEX_STEP_TYPE_GENERIC` | ⚠️ Generic / MCP |
| **IDE Actions** | _N/A_ | `VIEW_CODE_ITEM`, `LINT_DIFF`, `ADD_ANNOTATION` | 🚫 Jetski Only (Dropped) |
**1. Native Mappings** Antigravity has specific protobuf message types for
common tool calls, which map directly to the CLI's native tools:
- `CORTEX_STEP_TYPE_VIEW_FILE` -> `read_file`
- `CORTEX_STEP_TYPE_LIST_DIRECTORY` -> `ls`
- `CORTEX_STEP_TYPE_GREP_SEARCH` -> `grep_search`
- `CORTEX_STEP_TYPE_RUN_COMMAND` -> `run_shell_command`
- `CORTEX_STEP_TYPE_FILE_CHANGE` -> `replace`
- `CORTEX_STEP_TYPE_BROWSER_SUBAGENT` -> (Dropped)
**2. Generic / MCP Tools**
- Jetski relies heavily on `CORTEX_STEP_TYPE_GENERIC` and
`CORTEX_STEP_TYPE_MCP_TOOL` to route non-native or dynamic tools.
- This is fully supported! The CLI reads the `toolName` and `argsJson` directly
from the generic step payload. For instance, the Jetski `ask_user` tool
natively maps to the CLI's `ask_user` tool, and any custom MCP commands are
preserved as-is.
**3. Unsupported Tools** Many isolated actions, sub-agent tools, and
IDE-specific UI interactions are dropped by the teleporter to maintain strict
CLI compatibility and preserve valid context-window state.
**4. Tool UI & Context Representation** When importing dynamic/generic tools,
the CLI UI uses internal synthesis to properly reflect tool usage and outputs
that the user saw in JetSki without blank rendering:
- **Arguments:** Arguments extracted from `argsJson` are synthesized into the
`.description` field enabling the CLI UI to display the exact call arguments
(e.g., specific files or search strings) below the tool name.
- **Output (Result Display):** Tool outputs, like terminal output payloads or
file text, are iteratively extracted from the trajectory steps and rendered
explicitly using `resultDisplay`.
<details>
<summary><b>Click to view exhaustive list of all 75+ dropped Jetski steps</b></summary>
Any step mapped in `cortex.proto` that isn't functionally replicated by the CLI
is skipped. This includes:
- **Browser UI & Automation**: `CORTEX_STEP_TYPE_BROWSER_CLICK_ELEMENT`,
`CORTEX_STEP_TYPE_BROWSER_MOVE_MOUSE`, `CORTEX_STEP_TYPE_BROWSER_SUBAGENT`,
`CORTEX_STEP_TYPE_EXECUTE_BROWSER_JAVASCRIPT`,
`CORTEX_STEP_TYPE_CAPTURE_BROWSER_SCREENSHOT`,
`CORTEX_STEP_TYPE_READ_BROWSER_PAGE`, `CORTEX_STEP_TYPE_BROWSER_GET_DOM`,
`CORTEX_STEP_TYPE_BROWSER_LIST_NETWORK_REQUESTS`,
`CORTEX_STEP_TYPE_BROWSER_SCROLL_UP`, `CORTEX_STEP_TYPE_OPEN_BROWSER_URL`, and
15+ other browser controls.
- **IDE & UI Actions**: `CORTEX_STEP_TYPE_VIEW_CODE_ITEM`,
`CORTEX_STEP_TYPE_LINT_DIFF`, `CORTEX_STEP_TYPE_ADD_ANNOTATION`,
`CORTEX_STEP_TYPE_VIEW_FILE_OUTLINE`, `CORTEX_STEP_TYPE_CODE_SEARCH`,
`CORTEX_STEP_TYPE_FIND_ALL_REFERENCES`, `CORTEX_STEP_TYPE_CODE_ACTION`.
- **Agent Framework**: `CORTEX_STEP_TYPE_TASK_BOUNDARY`,
`CORTEX_STEP_TYPE_NOTIFY_USER`, `CORTEX_STEP_TYPE_INVOKE_SUBAGENT`,
`CORTEX_STEP_TYPE_CHECKPOINT`, `CORTEX_STEP_TYPE_EPHEMERAL_MESSAGE`,
`CORTEX_STEP_TYPE_COMMAND_STATUS`, `CORTEX_STEP_TYPE_SEND_COMMAND_INPUT`.
- **Knowledge & Workspace**: `CORTEX_STEP_TYPE_KNOWLEDGE_GENERATION`,
`CORTEX_STEP_TYPE_KI_INSERTION`, `CORTEX_STEP_TYPE_TRAJECTORY_SEARCH`,
`CORTEX_STEP_TYPE_MQUERY`, `CORTEX_STEP_TYPE_INTERNAL_SEARCH`,
`CORTEX_STEP_TYPE_LIST_RESOURCES`, `CORTEX_STEP_TYPE_WORKSPACE_API`.
- **Misc Commands**: `CORTEX_STEP_TYPE_GIT_COMMIT`,
`CORTEX_STEP_TYPE_GENERATE_IMAGE`, `CORTEX_STEP_TYPE_COMPILE`,
`CORTEX_STEP_TYPE_CLIPBOARD`, `CORTEX_STEP_TYPE_ERROR_MESSAGE`, etc.
</details>
## 5. Session Resumption
Once converted:
1. The record is injected into the CLI's `ChatRecordingService`.
2. Users can continue the conversation seamlessly via the `/resume` command.
## Maintenance & Updates
You are correct that if Antigravity's Protobuf definitions change, the
`trajectory_teleporter.min.js` bundle will need to be updated to maintain
compatibility.
### When to Update
- If new step types are added to Antigravity that the CLI should support in
`converter.ts`.
- If the binary format of the `.pb` files changes.
- If the encryption key or algorithm is rotated.
### How to Regenerate the Bundle
To keep the CLI up to date, you can run the automated build script:
```bash
npm run build:teleporter
```
This runs `packages/core/scripts/build-teleporter.sh`, which automatically:
1. Navigates to the neighboring `Exafunction` directory.
2. Generates the latest Protobuf JS schemas (`pnpm --dir exa/proto_ts build`).
3. Uses `esbuild` to re-bundle the CLI's teleporter script
(`trajectory_teleporter.ts`) against the fresh schemas.
4. Outputs the new `trajectory_teleporter.min.js` directly into the `gemini-cli`
source tree.
> [!TIP] In the long term, this logic could be moved to a shared NPM package
> published from the Antigravity repository, allowing the Gemini CLI to stay
> updated via a simple `npm update`.
## Productionization Roadmap
To safely and seamlessly bring the Jetski trajectory teleportation feature to a
fully production-ready state in the Gemini CLI, several key areas need to be
addressed:
### 1. Security & Key Management
- **Dynamic Key Exchange:** ✅ The CLI now supports loading encryption keys from
`JETSKI_TELEPORT_KEY` environment variables or a local
`~/.gemini/jetski/key.txt` file.
- **Permission Scoping:** Ensure the CLI enforces the same file-access
permission rules (`file_permission_request`) that Jetski enforces so the AI
doesn't suddenly gain destructive permissions when transitioning to the
terminal.
### 2. Architecture & Build Process Decoupling
- **Trajectory Provider Interface:** ✅ The CLI now uses a generic
`TrajectoryProvider` interface, allowing teleportation logic to be decoupled
into extensions.
- **Shared NPM Package:** Publish the compiled Protobufs and parsing logic as a
private internal package (e.g., `@google/cortex-teleporter`). The Gemini CLI
should simply `npm install` this, rather than generating `.min.js` blobs
manually.
- **Schema Versioning:** Add version checks. If the CLI encounters a trajectory
from a newer version of Jetski with breaking protobuf changes, it should
gracefully prompt the user to update the CLI.
### 3. User Experience (UX)
- **Clear UI Indicators:** ✅ Jetski sessions are now grouped in a dedicated tab
in the `/resume` menu.
- **Missing Context Warnings:** ✅ The UI now synthesizes `description` and
`resultDisplay` for generic tool calls to ensure a smooth conversation flow.
- **Seamless Handoff Prompt:** ✅ The CLI now intelligently prompts the user on
startup if a recent Jetski session is found: _"🛸 You have a recent session in
Antigravity. Type /resume agy:<id> to bring it into the terminal."_
### 4. Data Fidelity & Error Handling
- **Graceful Degradation:** If the CLI fails to parse a specific `generic` step
or misses a tool argument, it shouldn't crash the entire resume process. It
should skip the broken step and append a system message warning the model.
- **File State Synchronization:** If a user made uncommitted file edits in
Jetski (e.g., `fileChange` steps that haven't been saved to disk), the CLI
needs to ensure it doesn't accidentally overwrite or hallucinate disk state.
It might be worth requiring a file save before teleporting.
### 5. Performance
- **Lazy Loading / Streaming:** When populating the list of sessions for
`/resume`, the CLI shouldnt decrypt and parse the _entire_ history of every
file. It should only read the `metadata` headers to render the UI picker, and
only parse the full multi-megabyte `ConversationRecord` when a specific
session is selected.
- **Memory Limits:** Set an upper limit on how many historical tool turns the
CLI loads into memory when teleporting to avoid OOM (Out of Memory) crashes in
Node.js.
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { listAgySessions, loadAgySession } from './discovery.js';
import { trajectoryToJson } from './teleporter.js';
import { convertAgyToCliRecord } from './converter.js';
import type {
TrajectoryProvider,
ConversationRecord,
} from '../config/config.js';
/**
* Trajectory provider for Antigravity (Jetski) sessions.
*/
const agyProvider: TrajectoryProvider = {
prefix: 'agy:',
displayName: 'Antigravity',
async listSessions(workspaceUri?: string) {
const sessions = await listAgySessions(workspaceUri);
return sessions.map((s) => ({
id: s.id,
mtime: s.mtime,
displayName: s.displayName,
messageCount: s.messageCount,
}));
},
async loadSession(id: string): Promise<ConversationRecord | null> {
const data = await loadAgySession(id);
if (!data) return null;
const json = trajectoryToJson(data);
return convertAgyToCliRecord(json);
},
};
// eslint-disable-next-line import/no-default-export
export default agyProvider;
@@ -0,0 +1,278 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import type {
ConversationRecord,
ToolCallRecord,
MessageRecord,
} from '../services/chatRecordingService.js';
import { CoreToolCallStatus } from '../scheduler/types.js';
import {
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */
EDIT_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
ASK_USER_TOOL_NAME,
} from '../tools/definitions/coreTools.js';
/**
* Converts an Antigravity Trajectory JSON to a Gemini CLI ConversationRecord.
*/
export function convertAgyToCliRecord(agyJson: unknown): ConversationRecord {
if (typeof agyJson !== 'object' || agyJson === null) {
throw new Error('Invalid AGY JSON');
}
const json = agyJson as Record<string, unknown>;
const messages: MessageRecord[] = [];
const sessionId = (json['trajectoryId'] as string) || 'agy-session';
const startTime = new Date().toISOString(); // Default to now if not found
let currentGeminiMessage: (MessageRecord & { type: 'gemini' }) | null = null;
const steps = (json['steps'] as any[]) || [];
for (const step of steps) {
const s = step as Record<string, unknown>;
const metadata = s['metadata'] as Record<string, unknown> | undefined;
const timestamp =
(metadata?.['timestamp'] as string) || new Date().toISOString();
const stepId =
(metadata?.['stepId'] as string) ||
Math.random().toString(36).substring(7);
switch (s['type']) {
case 14: // CORTEX_STEP_TYPE_USER_INPUT
case 'CORTEX_STEP_TYPE_USER_INPUT': {
// Close current Gemini message if open
currentGeminiMessage = null;
const userInput = s['userInput'] as Record<string, unknown> | undefined;
messages.push({
id: stepId,
timestamp,
type: 'user',
content: [{ text: (userInput?.['userResponse'] as string) || '' }],
});
break;
}
case 15: // CORTEX_STEP_TYPE_PLANNER_RESPONSE
case 'CORTEX_STEP_TYPE_PLANNER_RESPONSE': {
const plannerResponse = s['plannerResponse'] as
| Record<string, unknown>
| undefined;
const response = plannerResponse?.['response'] || '';
const thinking = plannerResponse?.['thinking'] || '';
currentGeminiMessage = {
id: stepId,
timestamp,
type: 'gemini',
content: [{ text: response as string }],
thoughts: thinking
? [
{
subject: 'Thinking',
description: thinking as string,
timestamp,
},
]
: [],
toolCalls: [],
};
messages.push(currentGeminiMessage);
break;
}
case 7: // CORTEX_STEP_TYPE_GREP_SEARCH
case 'CORTEX_STEP_TYPE_GREP_SEARCH':
case 8: // CORTEX_STEP_TYPE_VIEW_FILE
case 'CORTEX_STEP_TYPE_VIEW_FILE':
case 9: // CORTEX_STEP_TYPE_LIST_DIRECTORY
case 'CORTEX_STEP_TYPE_LIST_DIRECTORY':
case 21: // CORTEX_STEP_TYPE_RUN_COMMAND
case 'CORTEX_STEP_TYPE_RUN_COMMAND':
case 23: // CORTEX_STEP_TYPE_WRITE_TO_FILE
case 'CORTEX_STEP_TYPE_WRITE_TO_FILE':
case 25: // CORTEX_STEP_TYPE_FIND
case 'CORTEX_STEP_TYPE_FIND':
case 31: // CORTEX_STEP_TYPE_READ_URL_CONTENT
case 'CORTEX_STEP_TYPE_READ_URL_CONTENT':
case 33: // CORTEX_STEP_TYPE_SEARCH_WEB
case 'CORTEX_STEP_TYPE_SEARCH_WEB':
case 38: // CORTEX_STEP_TYPE_MCP_TOOL
case 'CORTEX_STEP_TYPE_MCP_TOOL':
case 85: // CORTEX_STEP_TYPE_BROWSER_SUBAGENT
case 'CORTEX_STEP_TYPE_BROWSER_SUBAGENT':
case 86: // CORTEX_STEP_TYPE_FILE_CHANGE
case 'CORTEX_STEP_TYPE_FILE_CHANGE':
case 140: // CORTEX_STEP_TYPE_GENERIC
case 'CORTEX_STEP_TYPE_GENERIC': {
if (!currentGeminiMessage) {
// If no planner response preceded this, create a dummy one
const adjunctMessage: MessageRecord = {
id: `adjunct-${stepId}`,
timestamp,
type: 'gemini',
content: [],
toolCalls: [],
thoughts: [],
};
messages.push(adjunctMessage);
currentGeminiMessage = adjunctMessage as MessageRecord & {
type: 'gemini';
};
}
if (currentGeminiMessage) {
currentGeminiMessage.toolCalls?.push(mapAgyStepToToolCall(s));
}
break;
}
default:
// Skip unknown steps
break;
}
}
return {
sessionId,
projectHash: 'agy-imported',
startTime,
lastUpdated: new Date().toISOString(),
messages,
};
}
function mapAgyStepToToolCall(step: Record<string, any>): ToolCallRecord {
const timestamp =
(step['metadata']?.['timestamp'] as string) || new Date().toISOString();
const id =
(step['metadata']?.['stepId'] as string) ||
Math.random().toString(36).substring(7);
let name = 'unknown_tool';
let args: any = {};
let result: any = null;
if (step['viewFile']) {
name = READ_FILE_TOOL_NAME;
args = { AbsolutePath: step['viewFile']['absolutePathUri'] };
result = [{ text: step['viewFile']['content'] || '' }];
} else if (step['listDirectory']) {
name = LS_TOOL_NAME;
args = { DirectoryPath: step['listDirectory']['directoryPathUri'] };
} else if (step['grepSearch']) {
name = GREP_TOOL_NAME;
args = {
Query: step['grepSearch']['query'],
SearchPath: step['grepSearch']['searchPathUri'],
};
result = [{ text: step['grepSearch']['rawOutput'] || '' }];
} else if (step['runCommand']) {
name = SHELL_TOOL_NAME;
args = { CommandLine: step['runCommand']['commandLine'] };
result = [{ text: step['runCommand']['combinedOutput']?.['full'] || '' }];
} else if (step['fileChange']) {
name = EDIT_TOOL_NAME; // Or multi_replace_file_content
args = { TargetFile: step['fileChange']['absolutePathUri'] };
} else if (step['writeToFile']) {
name = WRITE_FILE_TOOL_NAME;
args = { TargetFile: step['writeToFile']['targetFileUri'] };
} else if (step['find']) {
name = GLOB_TOOL_NAME;
args = {
Pattern: step['find']['pattern'],
SearchDirectory: step['find']['searchDirectory'],
};
result = [{ text: step['find']['truncatedOutput'] || '' }];
} else if (step['readUrlContent']) {
name = WEB_FETCH_TOOL_NAME;
args = { Url: step['readUrlContent']['url'] };
// We intentionally don't try fully mapping the complex KnowledgeBaseItem struct into a string here
result = [{ text: 'successfully read url content' }];
} else if (step['searchWeb']) {
name = WEB_SEARCH_TOOL_NAME; // Usually mapped from 'searchWeb'
args = { query: step['searchWeb']['query'] };
if (step['searchWeb']['domain']) {
args['domain'] = step['searchWeb']['domain'];
}
result = [{ text: 'successfully searched web' }];
} else if (step['mcpTool']) {
const mcpStep = step['mcpTool'];
name = mcpStep['toolCall']?.['name'] || 'unknown_mcp_tool';
try {
if (mcpStep['toolCall']?.['arguments']) {
args = JSON.parse(mcpStep['toolCall']['arguments']);
}
} catch {
args = {};
}
result = [{ text: mcpStep['resultString'] || '' }];
} else if (step['browserSubagent']) {
name = 'browser_subagent';
args = { Task: step['browserSubagent']['task'] };
} else if (step['generic']) {
const generic = step['generic'] as Record<string, unknown>;
const rawName = generic['toolName'] as string;
// Map generic tools to official CLI constants where applicable
if (rawName === 'ask_user') {
name = ASK_USER_TOOL_NAME;
} else {
name = rawName;
}
try {
args = JSON.parse(generic['argsJson'] as string);
} catch {
args = {};
}
result = [{ text: (generic['responseJson'] as string) || '' }];
}
const safeArgs = args as Record<string, unknown>;
const status =
step['status'] === 3 || step['status'] === 'CORTEX_STEP_STATUS_DONE'
? CoreToolCallStatus.Success
: CoreToolCallStatus.Error;
// Synthesize a UI string from the args so it isn't blank in the terminal
const argValues = Object.values(safeArgs)
.filter((v) => typeof v === 'string' || typeof v === 'number')
.join(', ');
const description = argValues || '';
// Synthesize a UI string for the result output
let resultDisplay: string | undefined = undefined;
if (Array.isArray(result) && result.length > 0) {
const textParts = result
.map((part) => part?.text)
.filter((text) => typeof text === 'string' && text.length > 0);
if (textParts.length > 0) {
resultDisplay = textParts.join('\n');
}
}
return {
id,
name,
args: safeArgs,
description,
result,
resultDisplay,
status,
timestamp,
};
}
@@ -0,0 +1,199 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { trajectoryToJson } from './teleporter.js';
import { convertAgyToCliRecord } from './converter.js';
import { partListUnionToString } from '../core/geminiRequest.js';
import type { MessageRecord } from '../services/chatRecordingService.js';
export interface AgySessionInfo {
id: string;
path: string;
mtime: string;
displayName?: string;
messageCount?: number;
workspaceUri?: string;
}
const AGY_CONVERSATIONS_DIR = path.join(
os.homedir(),
'.gemini',
'jetski',
'conversations',
);
const AGY_KEY_PATH = path.join(os.homedir(), '.gemini', 'jetski', 'key.txt');
/**
* Loads the Antigravity encryption key.
* Priority: JETSKI_TELEPORT_KEY env var > ~/.gemini/jetski/key.txt
*/
export async function loadAgyKey(): Promise<Buffer | undefined> {
const envKey = process.env['JETSKI_TELEPORT_KEY'];
if (envKey) {
return Buffer.from(envKey);
}
try {
const keyContent = await fs.readFile(AGY_KEY_PATH, 'utf-8');
return Buffer.from(keyContent.trim());
} catch (_e) {
return undefined;
}
}
/**
* Lists all Antigravity sessions found on disk.
* @param filterWorkspaceUri Optional filter to only return sessions matching this workspace URI (e.g. "file:///...").
*/
export async function listAgySessions(
filterWorkspaceUri?: string,
): Promise<AgySessionInfo[]> {
try {
const files = await fs.readdir(AGY_CONVERSATIONS_DIR);
const sessions: AgySessionInfo[] = [];
for (const file of files) {
if (file.endsWith('.pb')) {
const filePath = path.join(AGY_CONVERSATIONS_DIR, file);
const stats = await fs.stat(filePath);
const id = path.basename(file, '.pb');
let details: ReturnType<typeof extractAgyDetails> = {};
try {
const data = await fs.readFile(filePath);
const json = trajectoryToJson(data);
details = extractAgyDetails(json);
} catch (_error) {
// Ignore errors during parsing
}
if (
filterWorkspaceUri &&
details.workspaceUri &&
details.workspaceUri !== filterWorkspaceUri
) {
continue; // Skip sessions from other workspaces if we have a filter
}
sessions.push({
id,
path: filePath,
mtime: stats.mtime.toISOString(),
...details,
});
}
}
return sessions;
} catch (_error) {
// If directory doesn't exist, just return empty list
return [];
}
}
function extractAgyDetails(json: unknown): {
displayName?: string;
messageCount?: number;
workspaceUri?: string;
} {
try {
const record = convertAgyToCliRecord(json);
const messages = record.messages || [];
// Find first user message for display name
const firstUserMsg = messages.find((m: MessageRecord) => m.type === 'user');
const displayName = firstUserMsg
? partListUnionToString(firstUserMsg.content).slice(0, 100)
: 'Antigravity Session';
// Attempt to extract authoritative workspace object from top-level metadata first
let workspaceUri: string | undefined;
const agyJson = json as Record<string, unknown>;
const metadata = agyJson['metadata'] as Record<string, unknown> | undefined;
if (metadata) {
const workspaces = metadata['workspaces'] as
| Array<Record<string, unknown>>
| undefined;
const firstWorkspace = workspaces?.[0];
if (firstWorkspace && firstWorkspace['workspaceFolderAbsoluteUri']) {
workspaceUri = firstWorkspace['workspaceFolderAbsoluteUri'] as string;
}
}
// Fallback: Attempt to extract workspace object from raw JSON steps (e.g. older offline trajectories)
if (!workspaceUri) {
const steps = (agyJson['steps'] as Array<Record<string, unknown>>) || [];
for (const step of steps) {
const userInput = step['userInput'] as
| Record<string, unknown>
| undefined;
if (userInput) {
const activeState = userInput['activeUserState'] as
| Record<string, unknown>
| undefined;
const activeDoc = activeState?.['activeDocument'] as
| Record<string, unknown>
| undefined;
if (activeDoc && activeDoc['workspaceUri']) {
workspaceUri = activeDoc['workspaceUri'] as string;
break;
}
}
}
}
return {
displayName,
messageCount: messages.length,
workspaceUri,
};
} catch (_error) {
return {};
}
}
/**
* Loads the raw binary data of an Antigravity session.
*/
export async function loadAgySession(id: string): Promise<Buffer | null> {
const filePath = path.join(AGY_CONVERSATIONS_DIR, `${id}.pb`);
try {
return await fs.readFile(filePath);
} catch (_error) {
return null;
}
}
/**
* Returns the most recent session if it was updated within the last 10 minutes.
*/
export async function getRecentAgySession(
workspaceUri?: string,
): Promise<AgySessionInfo | null> {
const sessions = await listAgySessions(workspaceUri);
if (sessions.length === 0) return null;
// Sort by mtime descending
const sorted = sessions.sort(
(a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime(),
);
const mostRecent = sorted[0];
const mtime = new Date(mostRecent.mtime).getTime();
const now = Date.now();
// 10 minutes threshold
if (now - mtime < 10 * 60 * 1000) {
return mostRecent;
}
return null;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface AgyTrajectory {
trajectoryId: string;
cascadeId: string;
trajectoryType: number;
steps: unknown[];
}
export * from './teleporter.js';
export { convertAgyToCliRecord } from './converter.js';
@@ -0,0 +1,154 @@
# Remote Teleportation Architecture Proposal
## Objective
Prevent leaking proprietary JetSki Protobuf schemas and decryption keys directly
within the public Gemini CLI bundle. When a user requests to resume a JetSki
trajectory, the CLI will interact with an external, secure, or isolated
converter to fetch the standard `ConversationRecord` JSON.
## Guarantees & Constraints
- No Protobuf definitions are packaged or visible in the Gemini CLI
distribution.
- Telemetry/Decryption keys do not need to be hardcoded in the CLI.
- Conversion remains effortless and instantaneous for the end-user.
- **Strict Logic Confidentiality**: Proprietary conversion logic and Protobuf
schemas cannot be readable or reverse-engineered by the user via local caches
or easily accessible client-side code.
---
## Option 1: Local MCP Server (JetSki Daemon Extension)
Since Gemini CLI already natively supports the **Model Context Protocol (MCP)**,
we can utilize JetSki directly as an MCP provider.
### How it works
The JetSki extension (or its already running background daemon) can expose an
MCP tool, for example, `get_trajectory_history`. When the user runs `/resume`,
Gemini CLI invokes this tool via the standard MCP pipeline.
1. **User runs `/resume`**: Gemini CLI lists available trajectories (it can
list .pb files from the local directory or query the MCP server for a
history list).
2. **Conversation Selection**: The user selects a session.
3. **MCP Tool Call**: Gemini calls the MCP tool
`get_trajectory(filePath: string)`.
4. **Local Conversion**: JetSki decrypts, parses, and returns the strictly
CLI-compliant `HistoryItem` JSON payload over stdio/SSE.
5. **No Overhead**: The CLI injects the payload directly into its state without
ever touching a Protobuf.
### Effort & Trade-offs
- **Effort**: Very Low. Gemini CLI already supports MCP out of the box. No new
endpoints, infrastructure, or routing is needed. We only need to write a small
MCP server module using `@modelcontextprotocol/sdk` inside JetSki.
- **Pros**: Zero remote network latency; highly secure (stays on local machine);
incredibly seamless.
- **Cons**: Requires the JetSki service to be installed and running in the
background.
---
## Option 2: Stateless Cloud Function (API/Microservice)
Host the trajectory parsing logic completely server-side.
### How it works
1. **User runs `/resume`**: The CLI scans local `.pb` trajectories.
2. **Cloud Request**: The CLI sends a
`POST https://api.exafunction.com/v1/teleport` request. The body contains
the binary payload (and maybe an authorization token/key).
3. **Cloud Conversion**: The cloud function decrypts, parses, and formats the
trajectory.
4. **Response**: The API responds with the `ConversationRecord` JSON.
### Effort & Trade-offs
- **Effort**: Medium. Requires setting up a secured API endpoint, likely using
Google Cloud Functions or AWS Lambda.
- **Pros**: Completely decouples schema and keys from the end-user's device
entirely. The conversion environment is completely under Exafunction's
control.
- **Cons**: Network roundtrip latency whenever the user converts a session.
Involves sending binary trajectory files (potentially containing source code
snippets) over the network, which could be a privacy concern for some users.
---
## Option 3: Remote "Plugin" Loader (WASM / Transient Javascript)
The CLI downloads the interpreter on-demand, or runs it inside an isolated
Sandbox (like WebAssembly).
### How it works
1. **On-Demand Download**: When `/resume` is first used, the CLI downloads a
private, versioned conversion payload (e.g., `teleporter_v1.2.3.wasm` or an
obfuscated JS bundle) from a secure URL and caches it locally.
2. **Local Execution**: It runs this script locally to perform decryption and
conversion.
3. It keeps the schema out of the open-source CLI bundle, though a determined
user could still reverse engineer the WASM/JS if they inspect their local
caches.
### Effort & Trade-offs
- **Effort**: Medium-High. Requires robust WebAssembly compilation from the
Protobuf definitions, or a dynamic code loading execution chain in Node.js
that doesn't trigger security flags.
- **Pros**: Speed of local processing, decoupled from the main CLI installation.
- **Cons**: Adds complexity to the bundle distribution system.
- **Cons (Security)**: A determined user could reverse engineer the cached
WebAssembly or obfuscated JS bundle to reconstruct the JetSki Protobuf schema,
breaking the logic confidentiality requirement.
---
## Option 4: Remote MCP Server (via SSE)
A remote MCP server would bridge the user's local trajectory files with
Exafunction's parser inside a totally separate host.
### How it works
- We run an SSE MCP service on the Exafunction side.
- Instead of using stdio transport from a local background process (Option 1),
the Gemini CLI establishes an SSE connection to
`https://mcp.exafunction.com/sse`.
- The local CLI still queries the filesystem for the `.pb` trajectories.
- It invokes a remote tool: `parse_trajectory(trajectoryPb: string)` and passes
the local protobuf string as the request argument.
- The remote server unmarshalls, decrypts, and maps the proprietary protobufs
into the standard JSON response, which the CLI renders natively.
### Effort & Trade-offs
- **Effort**: Medium. Gemini CLI already supports SSE network transports for
MCP. You would need to host an MCP SSE server remotely.
- **Pros**: Proprietary mapping logic, credentials, and Protobuf schemas are
hosted totally remote and are decoupled from JetSki OR the CLI. Since it uses
standard MCP, it requires absolutely no specialized HTTP routing or bespoke
protocol headers in the CLI because Gemini naturally maps arguments
dynamically already.
- **Cons**: High network latency (sending large Protobuf strings back and
forth), privacy concerns because user code trajectories are being transmitted
remotely.
---
## Recommendation
**Option 4 (Remote MCP)** or **Option 2 (Cloud Function Isolation)** is the
recommended production approach to ensure strict confidentiality.
By keeping the proprietary deserialization binary completely remote behind an
authentication layer, Gemini CLI ensures that end users cannot observe execution
state, trace unmarshalled arrays, or scrape proprietary JetSki Protobuf
primitives natively. If removing heavy network latency is the highest priority,
**Option 1 (JetSki Local MCP)** remains the most effortless and robust path
forward without modifying the open-source CLI distribution.
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as teleporter from './trajectory_teleporter.min.js';
/**
* Decrypts and parses an Antigravity trajectory file (.pb) into JSON.
*/
export function trajectoryToJson(data: Buffer): unknown {
return teleporter.trajectoryToJson(data);
}
/**
* Converts a JSON trajectory back to encrypted binary format.
*/
export function jsonToTrajectory(json: unknown): Buffer {
return teleporter.jsonToTrajectory(json);
}
@@ -0,0 +1,10 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export function trajectoryToJson(data: Buffer): unknown;
export function jsonToTrajectory(json: unknown): Buffer;
export function decrypt(data: Buffer): unknown;
export function encrypt(data: Buffer): Buffer;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import * as crypto from 'node:crypto';
import { Trajectory } from './exa/proto_ts/dist/exa/gemini_coder/proto/trajectory_pb.js';
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
const DEFAULT_KEY = Buffer.from('safeCodeiumworldKeYsecretBalloon');
const NONCE_SIZE = 12; // GCM default nonce size
const TAG_SIZE = 16; // GCM default tag size
/**
* Decrypts data using AES-256-GCM.
* The data is expected to be in the format: [nonce (12b)][ciphertext][tag (16b)]
*/
export function decrypt(data: Buffer, key: Buffer = DEFAULT_KEY): Buffer {
if (data.length < NONCE_SIZE + TAG_SIZE) {
throw new Error('Data too short');
}
const nonce = data.subarray(0, NONCE_SIZE);
const tag = data.subarray(data.length - TAG_SIZE);
const ciphertext = data.subarray(NONCE_SIZE, data.length - TAG_SIZE);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
/**
* Encrypts data using AES-256-GCM.
* Returns data in the format: [nonce (12b)][ciphertext][tag (16b)]
*/
export function encrypt(data: Buffer, key: Buffer = DEFAULT_KEY): Buffer {
const nonce = crypto.randomBytes(NONCE_SIZE);
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([nonce, ciphertext, tag]);
}
/**
* Converts Antigravity binary trajectory to JSON.
*/
export function trajectoryToJson(
data: Buffer,
key: Buffer = DEFAULT_KEY,
): unknown {
let pbData: Buffer;
try {
// Try to decrypt first
pbData = decrypt(data, key);
} catch (_e) {
// Fallback to plain protobuf if decryption fails
pbData = data;
}
const trajectory = Trajectory.fromBinary(pbData);
return trajectory.toJson();
}
/**
* Converts JSON to Antigravity binary trajectory (encrypted).
*/
export function jsonToTrajectory(
json: unknown,
key: Buffer = DEFAULT_KEY,
): Buffer {
const trajectory = Trajectory.fromJson(json, { ignoreUnknownFields: true });
const pbData = Buffer.from(trajectory.toBinary());
return encrypt(pbData, key);
}
@@ -242,6 +242,50 @@ export abstract class ExtensionLoader {
await this.stopExtension(extension);
await this.startExtension(extension);
}
/**
* Returns the most recent session from all extensions if it's within the threshold.
*/
async getRecentExternalSession(
workspaceUri?: string,
thresholdMs: number = 10 * 60 * 1000,
): Promise<{ prefix: string; id: string; displayName?: string } | null> {
const activeExtensions = this.getExtensions().filter((e) => e.isActive);
let mostRecent: {
prefix: string;
id: string;
displayName?: string;
mtime: number;
} | null = null;
for (const extension of activeExtensions) {
if (extension.trajectoryProviderModule) {
try {
const sessions =
await extension.trajectoryProviderModule.listSessions(workspaceUri);
for (const s of sessions) {
const mtime = new Date(s.mtime).getTime();
if (!mostRecent || mtime > mostRecent.mtime) {
mostRecent = {
prefix: extension.trajectoryProviderModule.prefix || '',
id: s.id,
displayName: s.displayName,
mtime,
};
}
}
} catch (_e) {
// Ignore extension errors
}
}
}
if (mostRecent && Date.now() - mostRecent.mtime < thresholdMs) {
return mostRecent;
}
return null;
}
}
export interface ExtensionEvents {