feat(cli): Add dynamic UI tabs and displayName config for TrajectoryProviders

This commit is contained in:
Sehoon Shon
2026-03-09 13:55:36 -04:00
parent 9b15e410a8
commit c69bf4a4b4
15 changed files with 467 additions and 133 deletions
@@ -51,6 +51,7 @@ import {
type HookDefinition,
type HookEventName,
type ResolvedExtensionSetting,
type TrajectoryProvider,
coreEvents,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
@@ -957,6 +958,23 @@ Would you like to attempt to install via "git clone" instead?`,
);
}
let trajectoryProviderModule: TrajectoryProvider | undefined;
if (config.trajectoryProvider) {
try {
const expectedPath = path.resolve(
effectiveExtensionPath,
config.trajectoryProvider,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
trajectoryProviderModule = (await import(expectedPath))
.default as TrajectoryProvider;
} catch (e) {
debugLogger.warn(
`Failed to import trajectoryProvider at ${config.trajectoryProvider} for extension ${config.name}: ${getErrorMessage(e)}`,
);
}
}
return {
name: config.name,
version: config.version,
@@ -980,6 +998,7 @@ Would you like to attempt to install via "git clone" instead?`,
rules,
checkers,
plan: config.plan,
trajectoryProviderModule,
};
} catch (e) {
const extName = path.basename(extensionDir);
+5
View File
@@ -46,6 +46,11 @@ export interface ExtensionConfig {
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
/**
* Path to a module that implements the TrajectoryProvider interface.
* Used for importing binary chat histories like Jetski Teleportation.
*/
trajectoryProvider?: string;
}
export interface ExtensionUpdateInfo {
+26
View File
@@ -19,12 +19,15 @@ import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import type { AccountSuspensionInfo } from '../ui/contexts/UIStateContext.js';
import { pathToFileURL } from 'node:url';
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 +63,34 @@ export async function initializeApp(
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
}
// Check for recent external sessions to prompt for resume
let recentExternalSession:
| { prefix: string; id: string; displayName?: string }
| undefined;
if (config.getEnableExtensionReloading() !== false) {
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
const extensionLoader = (config as any)._extensionLoader;
if (extensionLoader) {
const workspaceUri = pathToFileURL(process.cwd()).toString();
const recent =
await extensionLoader.getRecentExternalSession(workspaceUri);
if (recent) {
recentExternalSession = {
prefix: recent.prefix,
id: recent.id,
displayName: recent.displayName,
};
}
}
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any */
}
return {
authError,
accountSuspensionInfo,
themeError,
shouldOpenAuthDialog,
geminiMdFileCount: config.getGeminiMdFileCount(),
recentExternalSession,
};
}
+13
View File
@@ -469,6 +469,19 @@ export const AppContainer = (props: AppContainerProps) => {
generateSummary(config).catch((e) => {
debugLogger.warn('Background summary generation failed:', e);
});
// Show handoff prompt for recent external sessions
if (initializationResult.recentExternalSession) {
const { prefix, id, displayName } =
initializationResult.recentExternalSession;
historyManager.addItem(
{
type: MessageType.INFO,
text: `🛸 You have a recent session in ${displayName || 'an external tool'}. Type "/resume ${prefix}${id}" to bring it into the terminal.`,
},
Date.now(),
);
}
})();
registerCleanup(async () => {
// Turn off mouse scroll.
+44 -40
View File
@@ -7,7 +7,6 @@
import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { pathToFileURL } from 'node:url';
import { theme } from '../semantic-colors.js';
import type { Content, Part } from '@google/genai';
import type {
@@ -20,10 +19,7 @@ import {
decodeTagName,
type MessageActionReturn,
INITIAL_HISTORY_LENGTH,
listAgySessions,
loadAgySession,
trajectoryToJson,
convertAgyToCliRecord,
type ConversationRecord,
} from '@google/gemini-cli-core';
import path from 'node:path';
import type {
@@ -70,25 +66,6 @@ const getSavedChatTags = async (
: 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;
} catch (_err) {
return [];
@@ -203,11 +180,32 @@ const resumeCheckpointCommand: SlashCommand = {
let conversation: Content[] = [];
let authType: string | undefined;
const loadAgy = async (id: string): Promise<Content[] | null> => {
const data = await loadAgySession(id);
if (!data) return null;
const agyJson = trajectoryToJson(data);
const record = convertAgyToCliRecord(agyJson);
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 */
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[] = [];
// 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 });
// 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[] = [];
for (const tc of m.toolCalls) {
if (tc.result) {
@@ -267,27 +268,30 @@ const resumeCheckpointCommand: SlashCommand = {
return conv;
};
if (tag.startsWith('agy:')) {
const id = tag.slice(4);
const agyConv = await loadAgy(id);
if (!agyConv) {
// 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 Antigravity session found with id: ${id}`,
content: `No external session found with prefix ${prefix} and id: ${id}`,
};
}
conversation = agyConv;
conversation = externalConv;
} else {
const checkpoint = await logger.loadCheckpoint(tag);
if (checkpoint.history.length > 0) {
conversation = checkpoint.history;
authType = checkpoint.authType;
} else {
// Fallback: Try to load as AGY session even without prefix
const agyConv = await loadAgy(tag);
if (agyConv) {
conversation = agyConv;
// Fallback: Try to load as AGY session even without prefix just in case (legacy support)
const legacyConv = await loadExternalTrajectory('agy:', tag);
if (legacyConv) {
conversation = legacyConv;
}
}
}
@@ -13,11 +13,7 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { useKeypress } from '../hooks/useKeypress.js';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
listAgySessions,
type Config,
type AgySessionInfo,
} from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
import {
formatRelativeTime,
@@ -48,8 +44,8 @@ export interface SessionBrowserState {
// Data state
/** All loaded sessions */
sessions: SessionInfo[];
/** Antigravity sessions */
agySessions: SessionInfo[];
/** Extension tabs */
extensionTabs: Array<{ name: string; sessions: SessionInfo[] }>;
/** Sessions after filtering and sorting */
filteredAndSortedSessions: SessionInfo[];
@@ -94,8 +90,10 @@ export interface SessionBrowserState {
// State setters
/** Update sessions array */
setSessions: React.Dispatch<React.SetStateAction<SessionInfo[]>>;
/** Update agySessions array */
setAgySessions: 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 */
@@ -367,7 +365,9 @@ export const useSessionBrowserState = (
): SessionBrowserState => {
const { columns: terminalWidth } = useTerminalSize();
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 [error, setError] = useState<string | null>(initialError);
const [activeIndex, setActiveIndex] = useState(0);
@@ -390,10 +390,11 @@ export const useSessionBrowserState = (
}, []);
const filteredAndSortedSessions = useMemo(() => {
const currentTabSessions = activeTab === 0 ? sessions : agySessions;
const currentTabSessions =
activeTab === 0 ? sessions : extensionTabs[activeTab - 1]?.sessions || [];
const filtered = filterSessions(currentTabSessions, searchQuery);
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
useEffect(() => {
@@ -411,8 +412,8 @@ export const useSessionBrowserState = (
const state: SessionBrowserState = {
sessions,
setSessions,
agySessions,
setAgySessions,
extensionTabs,
setExtensionTabs,
loading,
setLoading,
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(
agy: AgySessionInfo,
type ExternalSessionInfo = {
id: string;
mtime: string;
name?: string;
displayName?: string;
messageCount?: number;
prefix?: string;
};
function convertExternalToSessionInfo(
ext: ExternalSessionInfo,
index: number,
): SessionInfo {
return {
id: agy.id,
file: agy.id,
fileName: agy.id + '.pb',
startTime: agy.mtime,
lastUpdated: agy.mtime,
messageCount: agy.messageCount || 0,
displayName: agy.displayName || 'Antigravity Session',
firstUserMessage: agy.displayName || '',
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,
};
@@ -471,7 +481,7 @@ function convertAgyToSessionInfo(
const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const {
setSessions,
setAgySessions,
setExtensionTabs,
setLoading,
setError,
isSearchMode,
@@ -485,19 +495,55 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
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()),
listAgySessions(workspaceUri),
]);
setSessions(sessionData);
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);
setExtensionTabs(externalTabs);
setLoading(false);
} catch (err) {
@@ -510,7 +556,7 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
loadSessions();
}, [config, setSessions, setAgySessions, setLoading, setError]);
}, [config, setSessions, setExtensionTabs, setLoading, setError]);
useEffect(() => {
const loadFullContent = async () => {
@@ -750,8 +796,11 @@ export function SessionBrowserView({
state: SessionBrowserState;
}): React.JSX.Element {
const tabs: Tab[] = [
{ key: 'cli', header: 'CLI Sessions' },
{ key: 'agy', header: 'Antigravity' },
{ key: 'cli', header: 'Gemini CLI' },
...state.extensionTabs.map((ext, i) => ({
key: `ext-${i}`,
header: ext.name,
})),
];
if (state.loading) {
@@ -762,7 +811,7 @@ export function SessionBrowserView({
return <SessionBrowserError state={state} />;
}
if (state.sessions.length === 0 && state.agySessions.length === 0) {
if (state.sessions.length === 0 && state.extensionTabs.length === 0) {
return <SessionBrowserEmpty />;
}
@@ -802,8 +851,9 @@ export function SessionBrowser({
const cycleSortOrder = useCycleSortOrder(state);
useTabbedNavigation({
tabCount: 2,
tabCount: state.extensionTabs.length + 1,
isActive: !state.isSearchMode,
wrapAround: true,
onTabChange: (index) => state.setActiveTab(index),
});
+31 -15
View File
@@ -9,9 +9,6 @@ import type { HistoryItemWithoutId } from '../types.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import {
loadAgySession,
trajectoryToJson,
convertAgyToCliRecord,
coreEvents,
convertSessionToClientHistory,
uiTelemetryService,
@@ -57,19 +54,38 @@ export const useSessionBrowser = (
let conversation: ConversationRecord;
let filePath: string;
if (session.fileName.endsWith('.pb')) {
// Antigravity session
const data = await loadAgySession(session.id);
if (!data) {
throw new Error(
`Could not load Antigravity session ${session.id}`,
);
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 json = trajectoryToJson(data);
conversation = convertAgyToCliRecord(json);
// Antigravity sessions don't have a local CLI file path yet,
// 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 {
// Regular CLI session
const chatsDir = path.join(
+23
View File
@@ -9,6 +9,8 @@ import * as path from 'node:path';
import { inspect } from 'node:util';
import process from 'node:process';
import { z } from 'zod';
import type { ConversationRecord } from '../services/chatRecordingService.js';
export type { ConversationRecord };
import {
AuthType,
createContentGenerator,
@@ -228,6 +230,25 @@ export interface ResolvedExtensionSetting {
source?: string;
}
export interface TrajectoryProvider {
/** Prefix used 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 {
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 {
@@ -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_BROWSER_SUBAGENT` -> (Dropped)
**2. Generic & MCP Integrations** Jetski uses `CORTEX_STEP_TYPE_GENERIC` to
handle dynamic or MCP (Model Context Protocol) tool calls that are not hardcoded
into the native protobuf schema.
**2. Generic / MCP Tools**
- The CLI reads the `toolName` and `argsJson` directly from the generic step
payload and executes them as-is (e.g. `ask_user`, `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>
@@ -185,9 +198,9 @@ addressed:
### 1. Security & Key Management
- **Dynamic Key Exchange:** Instead of a hardcoded key in the CLI source code,
the CLI should retrieve the encryption key securely (e.g., from the OS
Keychain, a local Jetski config file, or by querying the local Jetski daemon).
- **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
@@ -195,6 +208,9 @@ addressed:
### 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
@@ -205,18 +221,13 @@ addressed:
### 3. User Experience (UX)
- **Clear UI Indicators:** In the CLI's `/resume` menu, Jetski sessions should
be visually distinct from native CLI sessions (e.g., using a 🛸 icon and a
"Jetski" tag next to the session name).
- **Missing Context Warnings:** Because we intentionally drop 75+ step types
(browser actions, IDE UI clicks, etc.), the CLI conversation history might
look like it has "gaps." The UI should render a small placeholder like:
`[ ⚠️ Jetski browser action dropped for CLI compatibility ]` so the user
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."_
- **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
@@ -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;
+43 -7
View File
@@ -7,7 +7,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
/* 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 {
EDIT_TOOL_NAME,
@@ -19,6 +23,7 @@ import {
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
ASK_USER_TOOL_NAME,
} from '../tools/definitions/coreTools.js';
/**
@@ -218,7 +223,15 @@ function mapAgyStepToToolCall(step: Record<string, any>): ToolCallRecord {
args = { Task: step['browserSubagent']['task'] };
} else if (step['generic']) {
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 {
args = JSON.parse(generic['argsJson'] as string);
} catch {
@@ -227,15 +240,38 @@ function mapAgyStepToToolCall(step: Record<string, any>): ToolCallRecord {
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: args as Record<string, unknown>,
args: safeArgs,
description,
result,
status:
step['status'] === 3 || step['status'] === 'CORTEX_STEP_STATUS_DONE'
? CoreToolCallStatus.Success
: CoreToolCallStatus.Error,
resultDisplay,
status,
timestamp,
};
}
+48 -2
View File
@@ -11,6 +11,7 @@ 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;
@@ -28,6 +29,26 @@ const AGY_CONVERSATIONS_DIR = path.join(
'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:///...").
@@ -38,7 +59,6 @@ export async function listAgySessions(
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);
@@ -88,7 +108,7 @@ function extractAgyDetails(json: unknown): {
const messages = record.messages || [];
// 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
? partListUnionToString(firstUserMsg.content).slice(0, 100)
: 'Antigravity Session';
@@ -151,3 +171,29 @@ export async function loadAgySession(id: string): Promise<Buffer | 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;
}
-5
View File
@@ -13,8 +13,3 @@ export interface AgyTrajectory {
export * from './teleporter.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.
*/
export function trajectoryToJson(data: Buffer): unknown {
export function trajectoryToJson(
data: Buffer,
key: Buffer = DEFAULT_KEY,
): unknown {
let pbData: Buffer;
try {
// Try to decrypt first
pbData = decrypt(data);
pbData = decrypt(data, key);
} catch (_e) {
// Fallback to plain protobuf if decryption fails
pbData = data;
@@ -65,8 +68,11 @@ export function trajectoryToJson(data: Buffer): unknown {
/**
* 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 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.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 {