diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 2c46a845e6..9001ed8cb5 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -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); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 564c4fbb6f..2c8763c0bf 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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 { diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index f27e9a9511..065b2ab381 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -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, }; } diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b2402f9fe9..200d53c3d8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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. diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index e9c72708d4..4f39af178e 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -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 => { - 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 => { + 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; } } } diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx index 0ad13a14f1..c7be859048 100644 --- a/packages/cli/src/ui/components/SessionBrowser.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -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>; - /** Update agySessions array */ - setAgySessions: React.Dispatch>; + /** Update extensionTabs array */ + setExtensionTabs: React.Dispatch< + React.SetStateAction> + >; /** Update loading state */ setLoading: React.Dispatch>; /** Update error state */ @@ -367,7 +365,9 @@ export const useSessionBrowserState = ( ): SessionBrowserState => { const { columns: terminalWidth } = useTerminalSize(); const [sessions, setSessions] = useState(initialSessions); - const [agySessions, setAgySessions] = useState([]); + const [extensionTabs, setExtensionTabs] = useState< + Array<{ name: string; sessions: SessionInfo[] }> + >([]); const [loading, setLoading] = useState(initialLoading); const [error, setError] = useState(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 ; } - if (state.sessions.length === 0 && state.agySessions.length === 0) { + if (state.sessions.length === 0 && state.extensionTabs.length === 0) { return ; } @@ -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), }); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 3d1d376035..971feb396b 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -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( diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index aa3e9aa5b6..56ecaa417b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; +} + 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 { diff --git a/packages/core/src/teleportation/TELEPORTATION.md b/packages/core/src/teleportation/TELEPORTATION.md index ae3f5c350a..6bb1f87741 100644 --- a/packages/core/src/teleportation/TELEPORTATION.md +++ b/packages/core/src/teleportation/TELEPORTATION.md @@ -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`. +
Click to view exhaustive list of all 75+ dropped Jetski steps @@ -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: to bring it into the terminal."_ ### 4. Data Fidelity & Error Handling diff --git a/packages/core/src/teleportation/agyProvider.ts b/packages/core/src/teleportation/agyProvider.ts new file mode 100644 index 0000000000..1a146c40dd --- /dev/null +++ b/packages/core/src/teleportation/agyProvider.ts @@ -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 { + const data = await loadAgySession(id); + if (!data) return null; + const json = trajectoryToJson(data); + return convertAgyToCliRecord(json); + }, +}; + +export default agyProvider; diff --git a/packages/core/src/teleportation/converter.ts b/packages/core/src/teleportation/converter.ts index f52a2724d5..92ee99f520 100644 --- a/packages/core/src/teleportation/converter.ts +++ b/packages/core/src/teleportation/converter.ts @@ -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): ToolCallRecord { args = { Task: step['browserSubagent']['task'] }; } else if (step['generic']) { const generic = step['generic'] as Record; - 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): ToolCallRecord { result = [{ text: (generic['responseJson'] as string) || '' }]; } + const safeArgs = args as Record; + 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, + args: safeArgs, + description, result, - status: - step['status'] === 3 || step['status'] === 'CORTEX_STEP_STATUS_DONE' - ? CoreToolCallStatus.Success - : CoreToolCallStatus.Error, + resultDisplay, + status, timestamp, }; } diff --git a/packages/core/src/teleportation/discovery.ts b/packages/core/src/teleportation/discovery.ts index d844ca0bcd..7488a4dd17 100644 --- a/packages/core/src/teleportation/discovery.ts +++ b/packages/core/src/teleportation/discovery.ts @@ -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 { + 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 { return null; } } + +/** + * Returns the most recent session if it was updated within the last 10 minutes. + */ +export async function getRecentAgySession( + workspaceUri?: string, +): Promise { + 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; +} diff --git a/packages/core/src/teleportation/index.ts b/packages/core/src/teleportation/index.ts index d08d42b771..caebf0f5ef 100644 --- a/packages/core/src/teleportation/index.ts +++ b/packages/core/src/teleportation/index.ts @@ -13,8 +13,3 @@ export interface AgyTrajectory { export * from './teleporter.js'; export { convertAgyToCliRecord } from './converter.js'; -export { - loadAgySession, - listAgySessions, - type AgySessionInfo, -} from './discovery.js'; diff --git a/packages/core/src/teleportation/trajectory_teleporter.ts b/packages/core/src/teleportation/trajectory_teleporter.ts index 48d288b9d9..6276ff2c61 100644 --- a/packages/core/src/teleportation/trajectory_teleporter.ts +++ b/packages/core/src/teleportation/trajectory_teleporter.ts @@ -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); } diff --git a/packages/core/src/utils/extensionLoader.ts b/packages/core/src/utils/extensionLoader.ts index 053d4c2b13..05fad2f476 100644 --- a/packages/core/src/utils/extensionLoader.ts +++ b/packages/core/src/utils/extensionLoader.ts @@ -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 {