mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(cli): Add dynamic UI tabs and displayName config for TrajectoryProviders
This commit is contained in:
@@ -51,6 +51,7 @@ import {
|
|||||||
type HookDefinition,
|
type HookDefinition,
|
||||||
type HookEventName,
|
type HookEventName,
|
||||||
type ResolvedExtensionSetting,
|
type ResolvedExtensionSetting,
|
||||||
|
type TrajectoryProvider,
|
||||||
coreEvents,
|
coreEvents,
|
||||||
applyAdminAllowlist,
|
applyAdminAllowlist,
|
||||||
getAdminBlockedMcpServersMessage,
|
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 {
|
return {
|
||||||
name: config.name,
|
name: config.name,
|
||||||
version: config.version,
|
version: config.version,
|
||||||
@@ -980,6 +998,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
rules,
|
rules,
|
||||||
checkers,
|
checkers,
|
||||||
plan: config.plan,
|
plan: config.plan,
|
||||||
|
trajectoryProviderModule,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const extName = path.basename(extensionDir);
|
const extName = path.basename(extensionDir);
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export interface ExtensionConfig {
|
|||||||
* Used to migrate an extension to a new repository source.
|
* Used to migrate an extension to a new repository source.
|
||||||
*/
|
*/
|
||||||
migratedTo?: string;
|
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 {
|
export interface ExtensionUpdateInfo {
|
||||||
|
|||||||
@@ -19,12 +19,15 @@ import { performInitialAuth } from './auth.js';
|
|||||||
import { validateTheme } from './theme.js';
|
import { validateTheme } from './theme.js';
|
||||||
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
|
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
|
||||||
|
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
export interface InitializationResult {
|
export interface InitializationResult {
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
accountSuspensionInfo: AccountSuspensionInfo | null;
|
accountSuspensionInfo: AccountSuspensionInfo | null;
|
||||||
themeError: string | null;
|
themeError: string | null;
|
||||||
shouldOpenAuthDialog: boolean;
|
shouldOpenAuthDialog: boolean;
|
||||||
geminiMdFileCount: number;
|
geminiMdFileCount: number;
|
||||||
|
recentExternalSession?: { prefix: string; id: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,11 +63,34 @@ export async function initializeApp(
|
|||||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
|
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 {
|
return {
|
||||||
authError,
|
authError,
|
||||||
accountSuspensionInfo,
|
accountSuspensionInfo,
|
||||||
themeError,
|
themeError,
|
||||||
shouldOpenAuthDialog,
|
shouldOpenAuthDialog,
|
||||||
geminiMdFileCount: config.getGeminiMdFileCount(),
|
geminiMdFileCount: config.getGeminiMdFileCount(),
|
||||||
|
recentExternalSession,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,6 +469,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
generateSummary(config).catch((e) => {
|
generateSummary(config).catch((e) => {
|
||||||
debugLogger.warn('Background summary generation failed:', 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 () => {
|
registerCleanup(async () => {
|
||||||
// Turn off mouse scroll.
|
// Turn off mouse scroll.
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import * as fsPromises from 'node:fs/promises';
|
import * as fsPromises from 'node:fs/promises';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { pathToFileURL } from 'node:url';
|
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import type { Content, Part } from '@google/genai';
|
import type { Content, Part } from '@google/genai';
|
||||||
import type {
|
import type {
|
||||||
@@ -20,10 +19,7 @@ import {
|
|||||||
decodeTagName,
|
decodeTagName,
|
||||||
type MessageActionReturn,
|
type MessageActionReturn,
|
||||||
INITIAL_HISTORY_LENGTH,
|
INITIAL_HISTORY_LENGTH,
|
||||||
listAgySessions,
|
type ConversationRecord,
|
||||||
loadAgySession,
|
|
||||||
trajectoryToJson,
|
|
||||||
convertAgyToCliRecord,
|
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type {
|
import type {
|
||||||
@@ -70,25 +66,6 @@ const getSavedChatTags = async (
|
|||||||
: a.mtime.localeCompare(b.mtime),
|
: a.mtime.localeCompare(b.mtime),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also look for Antigravity sessions matching the current workspace
|
|
||||||
const workspaceUri = pathToFileURL(process.cwd()).toString();
|
|
||||||
const agySessions = await listAgySessions(workspaceUri);
|
|
||||||
for (const agy of agySessions) {
|
|
||||||
chatDetails.push({
|
|
||||||
name: `agy:${agy.id}`,
|
|
||||||
mtime: agy.mtime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-sort if we added AGY sessions
|
|
||||||
if (agySessions.length > 0) {
|
|
||||||
chatDetails.sort((a, b) =>
|
|
||||||
mtSortDesc
|
|
||||||
? b.mtime.localeCompare(a.mtime)
|
|
||||||
: a.mtime.localeCompare(b.mtime),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return chatDetails;
|
return chatDetails;
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
return [];
|
return [];
|
||||||
@@ -203,11 +180,32 @@ const resumeCheckpointCommand: SlashCommand = {
|
|||||||
let conversation: Content[] = [];
|
let conversation: Content[] = [];
|
||||||
let authType: string | undefined;
|
let authType: string | undefined;
|
||||||
|
|
||||||
const loadAgy = async (id: string): Promise<Content[] | null> => {
|
const loadExternalTrajectory = async (
|
||||||
const data = await loadAgySession(id);
|
prefix: string,
|
||||||
if (!data) return null;
|
id: string,
|
||||||
const agyJson = trajectoryToJson(data);
|
): Promise<Content[] | null> => {
|
||||||
const record = convertAgyToCliRecord(agyJson);
|
let record: ConversationRecord | null = null;
|
||||||
|
if (config && 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 &&
|
||||||
|
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 */
|
||||||
|
}
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
const conv: Content[] = [];
|
const conv: Content[] = [];
|
||||||
// Add a dummy system message so slice(INITIAL_HISTORY_LENGTH) works correctly
|
// Add a dummy system message so slice(INITIAL_HISTORY_LENGTH) works correctly
|
||||||
@@ -245,7 +243,10 @@ const resumeCheckpointCommand: SlashCommand = {
|
|||||||
conv.push({ role: 'model', parts: modelParts });
|
conv.push({ role: 'model', parts: modelParts });
|
||||||
|
|
||||||
// 2. User turn: Function Responses (if any)
|
// 2. User turn: Function Responses (if any)
|
||||||
if (m.toolCalls && m.toolCalls.some((tc) => tc.result)) {
|
if (
|
||||||
|
m.toolCalls &&
|
||||||
|
m.toolCalls.some((tc: { result?: unknown }) => tc.result)
|
||||||
|
) {
|
||||||
const responseParts: Part[] = [];
|
const responseParts: Part[] = [];
|
||||||
for (const tc of m.toolCalls) {
|
for (const tc of m.toolCalls) {
|
||||||
if (tc.result) {
|
if (tc.result) {
|
||||||
@@ -267,27 +268,30 @@ const resumeCheckpointCommand: SlashCommand = {
|
|||||||
return conv;
|
return conv;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tag.startsWith('agy:')) {
|
// Check if tag format is prefix:id
|
||||||
const id = tag.slice(4);
|
const prefixMatch = tag.match(/^([a-zA-Z0-9_]+):(.*)$/);
|
||||||
const agyConv = await loadAgy(id);
|
if (prefixMatch) {
|
||||||
if (!agyConv) {
|
const prefix = prefixMatch[1] + ':';
|
||||||
|
const id = prefixMatch[2];
|
||||||
|
const externalConv = await loadExternalTrajectory(prefix, id);
|
||||||
|
if (!externalConv) {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: `No Antigravity session found with id: ${id}`,
|
content: `No external session found with prefix ${prefix} and id: ${id}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
conversation = agyConv;
|
conversation = externalConv;
|
||||||
} else {
|
} else {
|
||||||
const checkpoint = await logger.loadCheckpoint(tag);
|
const checkpoint = await logger.loadCheckpoint(tag);
|
||||||
if (checkpoint.history.length > 0) {
|
if (checkpoint.history.length > 0) {
|
||||||
conversation = checkpoint.history;
|
conversation = checkpoint.history;
|
||||||
authType = checkpoint.authType;
|
authType = checkpoint.authType;
|
||||||
} else {
|
} else {
|
||||||
// Fallback: Try to load as AGY session even without prefix
|
// Fallback: Try to load as AGY session even without prefix just in case (legacy support)
|
||||||
const agyConv = await loadAgy(tag);
|
const legacyConv = await loadExternalTrajectory('agy:', tag);
|
||||||
if (agyConv) {
|
if (legacyConv) {
|
||||||
conversation = agyConv;
|
conversation = legacyConv;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
import { useKeypress } from '../hooks/useKeypress.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
import {
|
import { type Config } from '@google/gemini-cli-core';
|
||||||
listAgySessions,
|
|
||||||
type Config,
|
|
||||||
type AgySessionInfo,
|
|
||||||
} from '@google/gemini-cli-core';
|
|
||||||
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
|
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
|
||||||
import {
|
import {
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
@@ -48,8 +44,8 @@ export interface SessionBrowserState {
|
|||||||
// Data state
|
// Data state
|
||||||
/** All loaded sessions */
|
/** All loaded sessions */
|
||||||
sessions: SessionInfo[];
|
sessions: SessionInfo[];
|
||||||
/** Antigravity sessions */
|
/** Extension tabs */
|
||||||
agySessions: SessionInfo[];
|
extensionTabs: Array<{ name: string; sessions: SessionInfo[] }>;
|
||||||
/** Sessions after filtering and sorting */
|
/** Sessions after filtering and sorting */
|
||||||
filteredAndSortedSessions: SessionInfo[];
|
filteredAndSortedSessions: SessionInfo[];
|
||||||
|
|
||||||
@@ -94,8 +90,10 @@ export interface SessionBrowserState {
|
|||||||
// State setters
|
// State setters
|
||||||
/** Update sessions array */
|
/** Update sessions array */
|
||||||
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
|
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
|
||||||
/** Update agySessions array */
|
/** Update extensionTabs array */
|
||||||
setAgySessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
|
setExtensionTabs: React.Dispatch<
|
||||||
|
React.SetStateAction<Array<{ name: string; sessions: SessionInfo[] }>>
|
||||||
|
>;
|
||||||
/** Update loading state */
|
/** Update loading state */
|
||||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
/** Update error state */
|
/** Update error state */
|
||||||
@@ -367,7 +365,9 @@ export const useSessionBrowserState = (
|
|||||||
): SessionBrowserState => {
|
): SessionBrowserState => {
|
||||||
const { columns: terminalWidth } = useTerminalSize();
|
const { columns: terminalWidth } = useTerminalSize();
|
||||||
const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions);
|
const [sessions, setSessions] = useState<SessionInfo[]>(initialSessions);
|
||||||
const [agySessions, setAgySessions] = useState<SessionInfo[]>([]);
|
const [extensionTabs, setExtensionTabs] = useState<
|
||||||
|
Array<{ name: string; sessions: SessionInfo[] }>
|
||||||
|
>([]);
|
||||||
const [loading, setLoading] = useState(initialLoading);
|
const [loading, setLoading] = useState(initialLoading);
|
||||||
const [error, setError] = useState<string | null>(initialError);
|
const [error, setError] = useState<string | null>(initialError);
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
@@ -390,10 +390,11 @@ export const useSessionBrowserState = (
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredAndSortedSessions = useMemo(() => {
|
const filteredAndSortedSessions = useMemo(() => {
|
||||||
const currentTabSessions = activeTab === 0 ? sessions : agySessions;
|
const currentTabSessions =
|
||||||
|
activeTab === 0 ? sessions : extensionTabs[activeTab - 1]?.sessions || [];
|
||||||
const filtered = filterSessions(currentTabSessions, searchQuery);
|
const filtered = filterSessions(currentTabSessions, searchQuery);
|
||||||
return sortSessions(filtered, sortOrder, sortReverse);
|
return sortSessions(filtered, sortOrder, sortReverse);
|
||||||
}, [sessions, agySessions, activeTab, searchQuery, sortOrder, sortReverse]);
|
}, [sessions, extensionTabs, activeTab, searchQuery, sortOrder, sortReverse]);
|
||||||
|
|
||||||
// Reset full content flag when search is cleared
|
// Reset full content flag when search is cleared
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -411,8 +412,8 @@ export const useSessionBrowserState = (
|
|||||||
const state: SessionBrowserState = {
|
const state: SessionBrowserState = {
|
||||||
sessions,
|
sessions,
|
||||||
setSessions,
|
setSessions,
|
||||||
agySessions,
|
extensionTabs,
|
||||||
setAgySessions,
|
setExtensionTabs,
|
||||||
loading,
|
loading,
|
||||||
setLoading,
|
setLoading,
|
||||||
error,
|
error,
|
||||||
@@ -445,21 +446,30 @@ export const useSessionBrowserState = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts Antigravity session info to CLI SessionInfo format.
|
* Converts Antigravity or external session info to CLI SessionInfo format.
|
||||||
*/
|
*/
|
||||||
function convertAgyToSessionInfo(
|
type ExternalSessionInfo = {
|
||||||
agy: AgySessionInfo,
|
id: string;
|
||||||
|
mtime: string;
|
||||||
|
name?: string;
|
||||||
|
displayName?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
prefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function convertExternalToSessionInfo(
|
||||||
|
ext: ExternalSessionInfo,
|
||||||
index: number,
|
index: number,
|
||||||
): SessionInfo {
|
): SessionInfo {
|
||||||
return {
|
return {
|
||||||
id: agy.id,
|
id: ext.prefix ? `${ext.prefix}${ext.id}` : ext.id,
|
||||||
file: agy.id,
|
file: ext.id,
|
||||||
fileName: agy.id + '.pb',
|
fileName: ext.id + '.ext',
|
||||||
startTime: agy.mtime,
|
startTime: ext.mtime,
|
||||||
lastUpdated: agy.mtime,
|
lastUpdated: ext.mtime,
|
||||||
messageCount: agy.messageCount || 0,
|
messageCount: ext.messageCount || 0,
|
||||||
displayName: agy.displayName || 'Antigravity Session',
|
displayName: ext.displayName || ext.name || 'External Session',
|
||||||
firstUserMessage: agy.displayName || '',
|
firstUserMessage: ext.displayName || ext.name || '',
|
||||||
isCurrentSession: false,
|
isCurrentSession: false,
|
||||||
index,
|
index,
|
||||||
};
|
};
|
||||||
@@ -471,7 +481,7 @@ function convertAgyToSessionInfo(
|
|||||||
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||||
const {
|
const {
|
||||||
setSessions,
|
setSessions,
|
||||||
setAgySessions,
|
setExtensionTabs,
|
||||||
setLoading,
|
setLoading,
|
||||||
setError,
|
setError,
|
||||||
isSearchMode,
|
isSearchMode,
|
||||||
@@ -485,19 +495,55 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
|||||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||||
const workspaceUri = pathToFileURL(process.cwd()).toString();
|
const workspaceUri = pathToFileURL(process.cwd()).toString();
|
||||||
|
|
||||||
const [sessionData, agyData] = await Promise.all([
|
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()),
|
getSessionFiles(chatsDir, config.getSessionId()),
|
||||||
listAgySessions(workspaceUri),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setSessions(sessionData);
|
setSessions(sessionData);
|
||||||
|
setExtensionTabs(externalTabs);
|
||||||
const normalizedAgy = agyData
|
|
||||||
.sort(
|
|
||||||
(a, b) => new Date(a.mtime).getTime() - new Date(b.mtime).getTime(),
|
|
||||||
)
|
|
||||||
.map((agy, i) => convertAgyToSessionInfo(agy, i + 1));
|
|
||||||
setAgySessions(normalizedAgy);
|
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -510,7 +556,7 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
loadSessions();
|
loadSessions();
|
||||||
}, [config, setSessions, setAgySessions, setLoading, setError]);
|
}, [config, setSessions, setExtensionTabs, setLoading, setError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadFullContent = async () => {
|
const loadFullContent = async () => {
|
||||||
@@ -750,8 +796,11 @@ export function SessionBrowserView({
|
|||||||
state: SessionBrowserState;
|
state: SessionBrowserState;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const tabs: Tab[] = [
|
const tabs: Tab[] = [
|
||||||
{ key: 'cli', header: 'CLI Sessions' },
|
{ key: 'cli', header: 'Gemini CLI' },
|
||||||
{ key: 'agy', header: 'Antigravity' },
|
...state.extensionTabs.map((ext, i) => ({
|
||||||
|
key: `ext-${i}`,
|
||||||
|
header: ext.name,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
@@ -762,7 +811,7 @@ export function SessionBrowserView({
|
|||||||
return <SessionBrowserError state={state} />;
|
return <SessionBrowserError state={state} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.sessions.length === 0 && state.agySessions.length === 0) {
|
if (state.sessions.length === 0 && state.extensionTabs.length === 0) {
|
||||||
return <SessionBrowserEmpty />;
|
return <SessionBrowserEmpty />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,8 +851,9 @@ export function SessionBrowser({
|
|||||||
const cycleSortOrder = useCycleSortOrder(state);
|
const cycleSortOrder = useCycleSortOrder(state);
|
||||||
|
|
||||||
useTabbedNavigation({
|
useTabbedNavigation({
|
||||||
tabCount: 2,
|
tabCount: state.extensionTabs.length + 1,
|
||||||
isActive: !state.isSearchMode,
|
isActive: !state.isSearchMode,
|
||||||
|
wrapAround: true,
|
||||||
onTabChange: (index) => state.setActiveTab(index),
|
onTabChange: (index) => state.setActiveTab(index),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import type { HistoryItemWithoutId } from '../types.js';
|
|||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
loadAgySession,
|
|
||||||
trajectoryToJson,
|
|
||||||
convertAgyToCliRecord,
|
|
||||||
coreEvents,
|
coreEvents,
|
||||||
convertSessionToClientHistory,
|
convertSessionToClientHistory,
|
||||||
uiTelemetryService,
|
uiTelemetryService,
|
||||||
@@ -57,19 +54,38 @@ export const useSessionBrowser = (
|
|||||||
let conversation: ConversationRecord;
|
let conversation: ConversationRecord;
|
||||||
let filePath: string;
|
let filePath: string;
|
||||||
|
|
||||||
if (session.fileName.endsWith('.pb')) {
|
if (session.fileName.endsWith('.ext')) {
|
||||||
// Antigravity session
|
// External session
|
||||||
const data = await loadAgySession(session.id);
|
let externalConv: ConversationRecord | null = null;
|
||||||
if (!data) {
|
if (config.getEnableExtensionReloading() !== false) {
|
||||||
throw new Error(
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||||
`Could not load Antigravity session ${session.id}`,
|
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;
|
||||||
}
|
}
|
||||||
const json = trajectoryToJson(data);
|
}
|
||||||
conversation = convertAgyToCliRecord(json);
|
}
|
||||||
// Antigravity sessions don't have a local CLI file path yet,
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
|
||||||
// but we'll use the .pb path for reference in resumedSessionData
|
}
|
||||||
filePath = session.id + '.pb';
|
|
||||||
|
if (!externalConv) {
|
||||||
|
throw new Error(`Could not load external session ${session.id}`);
|
||||||
|
}
|
||||||
|
conversation = externalConv;
|
||||||
|
filePath = session.id + '.ext';
|
||||||
} else {
|
} else {
|
||||||
// Regular CLI session
|
// Regular CLI session
|
||||||
const chatsDir = path.join(
|
const chatsDir = path.join(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import * as path from 'node:path';
|
|||||||
import { inspect } from 'node:util';
|
import { inspect } from 'node:util';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import type { ConversationRecord } from '../services/chatRecordingService.js';
|
||||||
|
export type { ConversationRecord };
|
||||||
import {
|
import {
|
||||||
AuthType,
|
AuthType,
|
||||||
createContentGenerator,
|
createContentGenerator,
|
||||||
@@ -228,6 +230,25 @@ export interface ResolvedExtensionSetting {
|
|||||||
source?: string;
|
source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrajectoryProvider {
|
||||||
|
/** Prefix used in ChatList item names e.g., 'agy:' */
|
||||||
|
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 {
|
export interface AgentRunConfig {
|
||||||
maxTimeMinutes?: number;
|
maxTimeMinutes?: number;
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
@@ -377,6 +398,8 @@ export interface GeminiCLIExtension {
|
|||||||
* Used to migrate an extension to a new repository source.
|
* Used to migrate an extension to a new repository source.
|
||||||
*/
|
*/
|
||||||
migratedTo?: string;
|
migratedTo?: string;
|
||||||
|
/** Loaded JS module for trajectory decoding */
|
||||||
|
trajectoryProviderModule?: TrajectoryProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionInstallMetadata {
|
export interface ExtensionInstallMetadata {
|
||||||
|
|||||||
@@ -93,17 +93,30 @@ common tool calls, which map directly to the CLI's native tools:
|
|||||||
- `CORTEX_STEP_TYPE_FILE_CHANGE` -> `replace`
|
- `CORTEX_STEP_TYPE_FILE_CHANGE` -> `replace`
|
||||||
- `CORTEX_STEP_TYPE_BROWSER_SUBAGENT` -> (Dropped)
|
- `CORTEX_STEP_TYPE_BROWSER_SUBAGENT` -> (Dropped)
|
||||||
|
|
||||||
**2. Generic & MCP Integrations** Jetski uses `CORTEX_STEP_TYPE_GENERIC` to
|
**2. Generic / MCP Tools**
|
||||||
handle dynamic or MCP (Model Context Protocol) tool calls that are not hardcoded
|
|
||||||
into the native protobuf schema.
|
|
||||||
|
|
||||||
- The CLI reads the `toolName` and `argsJson` directly from the generic step
|
- Jetski relies heavily on `CORTEX_STEP_TYPE_GENERIC` and
|
||||||
payload and executes them as-is (e.g. `ask_user`, `mcp_*` tools).
|
`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
|
**3. Unsupported Tools** Many isolated actions, sub-agent tools, and
|
||||||
IDE-specific UI interactions are dropped by the teleporter to maintain strict
|
IDE-specific UI interactions are dropped by the teleporter to maintain strict
|
||||||
CLI compatibility and preserve valid context-window state.
|
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>
|
<details>
|
||||||
<summary><b>Click to view exhaustive list of all 75+ dropped Jetski steps</b></summary>
|
<summary><b>Click to view exhaustive list of all 75+ dropped Jetski steps</b></summary>
|
||||||
|
|
||||||
@@ -185,9 +198,9 @@ addressed:
|
|||||||
|
|
||||||
### 1. Security & Key Management
|
### 1. Security & Key Management
|
||||||
|
|
||||||
- **Dynamic Key Exchange:** Instead of a hardcoded key in the CLI source code,
|
- **Dynamic Key Exchange:** ✅ The CLI now supports loading encryption keys from
|
||||||
the CLI should retrieve the encryption key securely (e.g., from the OS
|
`JETSKI_TELEPORT_KEY` environment variables or a local
|
||||||
Keychain, a local Jetski config file, or by querying the local Jetski daemon).
|
`~/.gemini/jetski/key.txt` file.
|
||||||
- **Permission Scoping:** Ensure the CLI enforces the same file-access
|
- **Permission Scoping:** Ensure the CLI enforces the same file-access
|
||||||
permission rules (`file_permission_request`) that Jetski enforces so the AI
|
permission rules (`file_permission_request`) that Jetski enforces so the AI
|
||||||
doesn't suddenly gain destructive permissions when transitioning to the
|
doesn't suddenly gain destructive permissions when transitioning to the
|
||||||
@@ -195,6 +208,9 @@ addressed:
|
|||||||
|
|
||||||
### 2. Architecture & Build Process Decoupling
|
### 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
|
- **Shared NPM Package:** Publish the compiled Protobufs and parsing logic as a
|
||||||
private internal package (e.g., `@google/cortex-teleporter`). The Gemini CLI
|
private internal package (e.g., `@google/cortex-teleporter`). The Gemini CLI
|
||||||
should simply `npm install` this, rather than generating `.min.js` blobs
|
should simply `npm install` this, rather than generating `.min.js` blobs
|
||||||
@@ -205,18 +221,13 @@ addressed:
|
|||||||
|
|
||||||
### 3. User Experience (UX)
|
### 3. User Experience (UX)
|
||||||
|
|
||||||
- **Clear UI Indicators:** In the CLI's `/resume` menu, Jetski sessions should
|
- **Clear UI Indicators:** ✅ Jetski sessions are now grouped in a dedicated tab
|
||||||
be visually distinct from native CLI sessions (e.g., using a 🛸 icon and a
|
in the `/resume` menu.
|
||||||
"Jetski" tag next to the session name).
|
- **Missing Context Warnings:** ✅ The UI now synthesizes `description` and
|
||||||
- **Missing Context Warnings:** Because we intentionally drop 75+ step types
|
`resultDisplay` for generic tool calls to ensure a smooth conversation flow.
|
||||||
(browser actions, IDE UI clicks, etc.), the CLI conversation history might
|
- **Seamless Handoff Prompt:** ✅ The CLI now intelligently prompts the user on
|
||||||
look like it has "gaps." The UI should render a small placeholder like:
|
startup if a recent Jetski session is found: _"🛸 You have a recent session in
|
||||||
`[ ⚠️ Jetski browser action dropped for CLI compatibility ]` so the user
|
Antigravity. Type /resume agy:<id> to bring it into the terminal."_
|
||||||
understands the model did something in the IDE that isn't shown in the
|
|
||||||
terminal.
|
|
||||||
- **Seamless Handoff Prompt:** If the user has a currently active (running)
|
|
||||||
Jetski session, the CLI could intelligently prompt them on startup: _"You have
|
|
||||||
an active session in Jetski. Type `/resume` to bring it into the terminal."_
|
|
||||||
|
|
||||||
### 4. Data Fidelity & Error Handling
|
### 4. Data Fidelity & Error Handling
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default agyProvider;
|
||||||
@@ -7,7 +7,11 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import type { ConversationRecord, ToolCallRecord, MessageRecord } from '../types.js';
|
import type {
|
||||||
|
ConversationRecord,
|
||||||
|
ToolCallRecord,
|
||||||
|
MessageRecord,
|
||||||
|
} from '../services/chatRecordingService.js';
|
||||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||||
import {
|
import {
|
||||||
EDIT_TOOL_NAME,
|
EDIT_TOOL_NAME,
|
||||||
@@ -19,6 +23,7 @@ import {
|
|||||||
WEB_FETCH_TOOL_NAME,
|
WEB_FETCH_TOOL_NAME,
|
||||||
WEB_SEARCH_TOOL_NAME,
|
WEB_SEARCH_TOOL_NAME,
|
||||||
WRITE_FILE_TOOL_NAME,
|
WRITE_FILE_TOOL_NAME,
|
||||||
|
ASK_USER_TOOL_NAME,
|
||||||
} from '../tools/definitions/coreTools.js';
|
} from '../tools/definitions/coreTools.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,7 +223,15 @@ function mapAgyStepToToolCall(step: Record<string, any>): ToolCallRecord {
|
|||||||
args = { Task: step['browserSubagent']['task'] };
|
args = { Task: step['browserSubagent']['task'] };
|
||||||
} else if (step['generic']) {
|
} else if (step['generic']) {
|
||||||
const generic = step['generic'] as Record<string, unknown>;
|
const generic = step['generic'] as Record<string, unknown>;
|
||||||
name = generic['toolName'] as string;
|
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 {
|
try {
|
||||||
args = JSON.parse(generic['argsJson'] as string);
|
args = JSON.parse(generic['argsJson'] as string);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -227,15 +240,38 @@ function mapAgyStepToToolCall(step: Record<string, any>): ToolCallRecord {
|
|||||||
result = [{ text: (generic['responseJson'] as string) || '' }];
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
args: args as Record<string, unknown>,
|
args: safeArgs,
|
||||||
|
description,
|
||||||
result,
|
result,
|
||||||
status:
|
resultDisplay,
|
||||||
step['status'] === 3 || step['status'] === 'CORTEX_STEP_STATUS_DONE'
|
status,
|
||||||
? CoreToolCallStatus.Success
|
|
||||||
: CoreToolCallStatus.Error,
|
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import os from 'node:os';
|
|||||||
import { trajectoryToJson } from './teleporter.js';
|
import { trajectoryToJson } from './teleporter.js';
|
||||||
import { convertAgyToCliRecord } from './converter.js';
|
import { convertAgyToCliRecord } from './converter.js';
|
||||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||||
|
import type { MessageRecord } from '../services/chatRecordingService.js';
|
||||||
|
|
||||||
export interface AgySessionInfo {
|
export interface AgySessionInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,6 +29,26 @@ const AGY_CONVERSATIONS_DIR = path.join(
|
|||||||
'conversations',
|
'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.
|
* Lists all Antigravity sessions found on disk.
|
||||||
* @param filterWorkspaceUri Optional filter to only return sessions matching this workspace URI (e.g. "file:///...").
|
* @param filterWorkspaceUri Optional filter to only return sessions matching this workspace URI (e.g. "file:///...").
|
||||||
@@ -38,7 +59,6 @@ export async function listAgySessions(
|
|||||||
try {
|
try {
|
||||||
const files = await fs.readdir(AGY_CONVERSATIONS_DIR);
|
const files = await fs.readdir(AGY_CONVERSATIONS_DIR);
|
||||||
const sessions: AgySessionInfo[] = [];
|
const sessions: AgySessionInfo[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith('.pb')) {
|
if (file.endsWith('.pb')) {
|
||||||
const filePath = path.join(AGY_CONVERSATIONS_DIR, file);
|
const filePath = path.join(AGY_CONVERSATIONS_DIR, file);
|
||||||
@@ -88,7 +108,7 @@ function extractAgyDetails(json: unknown): {
|
|||||||
const messages = record.messages || [];
|
const messages = record.messages || [];
|
||||||
|
|
||||||
// Find first user message for display name
|
// Find first user message for display name
|
||||||
const firstUserMsg = messages.find((m) => m.type === 'user');
|
const firstUserMsg = messages.find((m: MessageRecord) => m.type === 'user');
|
||||||
const displayName = firstUserMsg
|
const displayName = firstUserMsg
|
||||||
? partListUnionToString(firstUserMsg.content).slice(0, 100)
|
? partListUnionToString(firstUserMsg.content).slice(0, 100)
|
||||||
: 'Antigravity Session';
|
: 'Antigravity Session';
|
||||||
@@ -151,3 +171,29 @@ export async function loadAgySession(id: string): Promise<Buffer | null> {
|
|||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,8 +13,3 @@ export interface AgyTrajectory {
|
|||||||
|
|
||||||
export * from './teleporter.js';
|
export * from './teleporter.js';
|
||||||
export { convertAgyToCliRecord } from './converter.js';
|
export { convertAgyToCliRecord } from './converter.js';
|
||||||
export {
|
|
||||||
loadAgySession,
|
|
||||||
listAgySessions,
|
|
||||||
type AgySessionInfo,
|
|
||||||
} from './discovery.js';
|
|
||||||
|
|||||||
@@ -48,11 +48,14 @@ export function encrypt(data: Buffer, key: Buffer = DEFAULT_KEY): Buffer {
|
|||||||
/**
|
/**
|
||||||
* Converts Antigravity binary trajectory to JSON.
|
* Converts Antigravity binary trajectory to JSON.
|
||||||
*/
|
*/
|
||||||
export function trajectoryToJson(data: Buffer): unknown {
|
export function trajectoryToJson(
|
||||||
|
data: Buffer,
|
||||||
|
key: Buffer = DEFAULT_KEY,
|
||||||
|
): unknown {
|
||||||
let pbData: Buffer;
|
let pbData: Buffer;
|
||||||
try {
|
try {
|
||||||
// Try to decrypt first
|
// Try to decrypt first
|
||||||
pbData = decrypt(data);
|
pbData = decrypt(data, key);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Fallback to plain protobuf if decryption fails
|
// Fallback to plain protobuf if decryption fails
|
||||||
pbData = data;
|
pbData = data;
|
||||||
@@ -65,8 +68,11 @@ export function trajectoryToJson(data: Buffer): unknown {
|
|||||||
/**
|
/**
|
||||||
* Converts JSON to Antigravity binary trajectory (encrypted).
|
* Converts JSON to Antigravity binary trajectory (encrypted).
|
||||||
*/
|
*/
|
||||||
export function jsonToTrajectory(json: unknown): Buffer {
|
export function jsonToTrajectory(
|
||||||
|
json: unknown,
|
||||||
|
key: Buffer = DEFAULT_KEY,
|
||||||
|
): Buffer {
|
||||||
const trajectory = Trajectory.fromJson(json, { ignoreUnknownFields: true });
|
const trajectory = Trajectory.fromJson(json, { ignoreUnknownFields: true });
|
||||||
const pbData = Buffer.from(trajectory.toBinary());
|
const pbData = Buffer.from(trajectory.toBinary());
|
||||||
return encrypt(pbData);
|
return encrypt(pbData, key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,50 @@ export abstract class ExtensionLoader {
|
|||||||
await this.stopExtension(extension);
|
await this.stopExtension(extension);
|
||||||
await this.startExtension(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 {
|
export interface ExtensionEvents {
|
||||||
|
|||||||
Reference in New Issue
Block a user