mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 01:00:59 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a04e8cce43 | |||
| e806947c87 | |||
| 6d3e4764cc | |||
| 4f8534b223 | |||
| b75ec12185 |
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Executable
+34
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 shouldn’t 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user