/** * Cascade Manager — Session listing, creation, and monitoring. * * Provides high-level API to interact with Cascade conversations * using verified transport layer (CommandBridge + StateBridge). * * VERIFIED 2026-02-28: getDiagnostics.recentTrajectories returns clean JSON * with { googleAgentId, trajectoryId, summary, lastStepIndex, lastModifiedTime }. * * @module cascade/cascade-manager */ import { IDisposable, DisposableStore } from '../core/disposable'; import { EventEmitter, Event } from '../core/events'; import { Logger } from '../core/logger'; import type { ITrajectoryEntry, IAgentPreferences, IDiagnosticsInfo, ICreateSessionOptions, } from '../core/types'; import { CommandBridge, AntigravityCommands } from '../transport/command-bridge'; import { StateBridge } from '../transport/state-bridge'; const log = new Logger('CascadeManager'); /** * Manages Cascade conversations. * * Primary data source: `antigravity.getDiagnostics` → `recentTrajectories` * Fallback: `antigravityUnifiedStateSync.trajectorySummaries` protobuf parsing * * @example * ```typescript * const manager = new CascadeManager(commands, state); * await manager.initialize(); * * // List sessions (real titles from getDiagnostics) * const sessions = await manager.getSessions(); * sessions.forEach(s => console.log(`${s.title} (step ${s.stepCount})`)); * * // Read preferences (all 16 sentinel values) * const prefs = await manager.getPreferences(); * * // Create & send * await manager.createSession({ task: 'Analyze coverage', background: true }); * ``` */ export class CascadeManager implements IDisposable { private readonly _disposables = new DisposableStore(); private _sessions: ITrajectoryEntry[] = []; private _initialized = false; // Events private readonly _onSessionsChanged = this._disposables.add(new EventEmitter()); /** Fires when the session list changes */ public readonly onSessionsChanged: Event = this._onSessionsChanged.event; constructor( private readonly _commands: CommandBridge, private readonly _state: StateBridge, ) { } /** * Initialize the cascade manager. * Loads the initial session list from getDiagnostics. */ async initialize(): Promise { if (this._initialized) return; await this._loadSessions(); this._initialized = true; log.info(`Initialized with ${this._sessions.length} sessions`); } // ─── Read API ─────────────────────────────────────────────────────────── /** * Get all known Cascade sessions. * * Uses `getDiagnostics.recentTrajectories` (clean JSON with titles). * * @returns List of trajectory entries sorted by recency */ async getSessions(): Promise { if (!this._initialized) { await this._loadSessions(); } return [...this._sessions]; } /** * Refresh the session list. * * @returns Updated session list */ async refreshSessions(): Promise { await this._loadSessions(); this._onSessionsChanged.fire(this._sessions); return [...this._sessions]; } /** * Get agent preferences (all 16 sentinel values). */ async getPreferences(): Promise { return this._state.getAgentPreferences(); } /** * Get IDE diagnostics (176KB JSON with system info, logs, trajectories). * * Structure (verified): * - isRemote, systemInfo (OS, user, email) * - extensionLogs (Array[375]) * - rendererLogs, mainThreadLogs, agentWindowConsoleLogs * - languageServerLogs * - recentTrajectories (Array[10]) * * @returns Parsed diagnostics information */ async getDiagnostics(): Promise { const raw = await this._commands.execute(AntigravityCommands.GET_DIAGNOSTICS); if (!raw || typeof raw !== 'string') { throw new Error('getDiagnostics returned unexpected type'); } const parsed = JSON.parse(raw); return { isRemote: parsed.isRemote ?? false, systemInfo: { operatingSystem: parsed.systemInfo?.operatingSystem ?? 'unknown', timestamp: parsed.systemInfo?.timestamp ?? '', userEmail: parsed.systemInfo?.userEmail ?? '', userName: parsed.systemInfo?.userName ?? '', }, raw: parsed, }; } /** * Get the Chrome DevTools MCP URL. * * Verified: returns `http://127.0.0.1:{port}/mcp` * * @returns MCP URL string */ async getMcpUrl(): Promise { const result = await this._commands.execute('antigravity.getChromeDevtoolsMcpUrl'); return result ?? ''; } /** * Check if a file is gitignored. * * @param filePath - Relative or absolute file path * @returns true if gitignored, false/null otherwise */ async isFileGitIgnored(filePath: string): Promise { const result = await this._commands.execute('antigravity.isFileGitIgnored', filePath); return result === true; } // ─── Write API ────────────────────────────────────────────────────────── // // Two-layer architecture (VERIFIED 2026-02-28): // // Layer 1 -- HEADLESS LS API (RECOMMENDED): // Access: sdk.ls (LSBridge from antigravity-sdk) // Method: Preact VNode tree -> component.props.lsClient -> 148 LS methods // Creates cascade WITHOUT opening panel or switching UI. // Usage: await sdk.ls.createCascade({ text: 'prompt' }) // // Layer 2 — COMMAND API (FALLBACK, this file): // Access: vscode.commands.executeCommand (extension host) // Method: startNewConversation → sendPromptToAgentPanel → restore // PROBLEM: Always switches UI, causes flickering, race conditions. // Use only when renderer integration is not available. // // ──────────────────────────────────────────────────────────────────────── /** * Create a new Cascade conversation via VS Code commands. * * ⚠️ **FALLBACK APPROACH** — causes UI flickering. * For true headless creation, use `sdk.ls.createCascade()` * from the SDK's LS bridge (see LSBridge module). * * VERIFIED 2026-02-28: * - `startNewConversation` ✅ creates new chat (but switches UI) * - `prioritized.chat.openNewConversation` ❌ does NOT create new * - `sendPromptToAgentPanel` ✅ sends to currently visible chat (always opens panel) * - `sendTextToChat` ❌ does not visibly work * * @param options - Session creation options * @returns Session ID (googleAgentId) or empty string if not detected */ async createSession(options: ICreateSessionOptions): Promise { log.info(`Creating session (command fallback): "${options.task.substring(0, 50)}..."`); // Snapshot current sessions to detect the new one const beforeIds = new Set(this._sessions.map(s => s.id)); // Remember current active session (for background restore) let previousActiveId = ''; if (options.background) { try { const raw = await this._commands.execute(AntigravityCommands.GET_DIAGNOSTICS); if (raw && typeof raw === 'string') { const diag = JSON.parse(raw); if (Array.isArray(diag.recentTrajectories) && diag.recentTrajectories.length > 0) { previousActiveId = diag.recentTrajectories[0].googleAgentId ?? ''; } } } catch { } } // Create new conversation (VERIFIED: startNewConversation works) await this._commands.execute(AntigravityCommands.START_NEW_CONVERSATION); await this._delay(1500); // Wait for UI to initialize // Send initial prompt if (options.task) { await this._commands.execute(AntigravityCommands.SEND_PROMPT_TO_AGENT, options.task); } // Mark as background if requested if (options.background) { await this._commands.execute(AntigravityCommands.TRACK_BACKGROUND_CONVERSATION); } // Wait for new session to appear in getDiagnostics const newId = await this._waitForNewSession(beforeIds, 8000); // If background: switch back to original conversation if (options.background && previousActiveId) { await this._delay(500); await this._commands.execute(AntigravityCommands.SET_VISIBLE_CONVERSATION, previousActiveId); log.info(`Background session created, restored to ${previousActiveId}`); } if (newId) { log.info(`Session created: ${newId}`); } else { log.warn('Session created but ID not detected within timeout'); } return newId; } /** * Create a background Cascade conversation via commands. * * ⚠️ **FALLBACK** — Uses quick-switch approach (UI flickers briefly). * For true headless background sessions, use the SDK's LS bridge: * ```typescript * // Using LSBridge: * const cascadeId = await sdk.ls.createCascade({ text: 'task', modelId: 1018 }); * ``` * * @param task - Initial task/prompt to send * @returns Session ID or empty string */ async createBackgroundSession(task: string): Promise { return this.createSession({ task, background: true }); } /** * Send a message to the active Cascade conversation. * * Uses `antigravity.sendTextToChat` — the primary text sending command. */ async sendMessage(text: string): Promise { await this._commands.execute(AntigravityCommands.SEND_TEXT_TO_CHAT, text); } /** * Send a prompt directly to the agent panel. * * Uses `antigravity.sendPromptToAgentPanel` — focuses the agent panel. */ async sendPrompt(text: string): Promise { await this._commands.execute(AntigravityCommands.SEND_PROMPT_TO_AGENT, text); } /** * Send a chat action message (e.g., typing indicator, feedback). * * Uses `antigravity.sendChatActionMessage`. */ async sendChatAction(action: string): Promise { await this._commands.execute(AntigravityCommands.SEND_CHAT_ACTION, action); } /** * Switch to a specific conversation. * * @param sessionId - Conversation UUID (googleAgentId) */ async focusSession(sessionId: string): Promise { await this._commands.execute(AntigravityCommands.SET_VISIBLE_CONVERSATION, sessionId); } /** * Open a new conversation in the agent panel (prioritized command). * * Uses `antigravity.prioritized.chat.openNewConversation` which both * opens the panel AND creates a fresh conversation. */ async openNewConversation(): Promise { await this._commands.execute(AntigravityCommands.OPEN_NEW_CONVERSATION); } /** * Execute a Cascade action. * * Uses `antigravity.executeCascadeAction`. * * @param action - Action data to execute */ async executeCascadeAction(action: unknown): Promise { await this._commands.execute(AntigravityCommands.EXECUTE_CASCADE_ACTION, action); } // ─── Step Control ─────────────────────────────────────────────────────── /** * Accept the current agent step (code edit, file write, etc.). * * Uses `antigravity.agent.acceptAgentStep`. */ async acceptStep(): Promise { await this._commands.execute(AntigravityCommands.ACCEPT_AGENT_STEP); } /** Reject the current agent step. */ async rejectStep(): Promise { await this._commands.execute(AntigravityCommands.REJECT_AGENT_STEP); } /** * Accept a pending command (non-terminal, e.g. file edit confirmation). * * Uses `antigravity.command.accept`. * This is DIFFERENT from terminalCommand.accept. */ async acceptCommand(): Promise { await this._commands.execute(AntigravityCommands.COMMAND_ACCEPT); } /** Reject a pending command (non-terminal). */ async rejectCommand(): Promise { await this._commands.execute(AntigravityCommands.COMMAND_REJECT); } // ─── Terminal Control ─────────────────────────────────────────────────── /** * Accept a pending terminal command. * * Uses `antigravity.terminalCommand.accept`. */ async acceptTerminalCommand(): Promise { await this._commands.execute(AntigravityCommands.TERMINAL_ACCEPT); } /** Reject a pending terminal command. */ async rejectTerminalCommand(): Promise { await this._commands.execute(AntigravityCommands.TERMINAL_REJECT); } /** Run a pending terminal command. */ async runTerminalCommand(): Promise { await this._commands.execute(AntigravityCommands.TERMINAL_RUN); } // ─── Panel Control ────────────────────────────────────────────────────── /** Open the Cascade agent panel */ async openPanel(): Promise { await this._commands.execute(AntigravityCommands.OPEN_AGENT_PANEL); } /** Focus the Cascade agent panel */ async focusPanel(): Promise { await this._commands.execute(AntigravityCommands.FOCUS_AGENT_PANEL); } /** Open the agent side panel */ async openSidePanel(): Promise { await this._commands.execute(AntigravityCommands.OPEN_AGENT_SIDE_PANEL); } /** Focus the agent side panel */ async focusSidePanel(): Promise { await this._commands.execute(AntigravityCommands.FOCUS_AGENT_SIDE_PANEL); } /** * Get the browser integration port (e.g., 57401). */ async getBrowserPort(): Promise { return this._commands.execute(AntigravityCommands.GET_BROWSER_PORT); } // ─── Private ──────────────────────────────────────────────────────────── /** * Load sessions from getDiagnostics.recentTrajectories (clean JSON). * * VERIFIED structure per entry: * { * googleAgentId: "uuid", ← conversation ID * trajectoryId: "uuid", ← internal trajectory ID * summary: "title", ← human-readable title * lastStepIndex: 992, ← step count * lastModifiedTime: "ISO" ← last activity * } */ private async _loadSessions(): Promise { try { // Primary: getDiagnostics.recentTrajectories (10 most recent, with titles) const raw = await this._commands.execute(AntigravityCommands.GET_DIAGNOSTICS); if (raw && typeof raw === 'string') { const diag = JSON.parse(raw); if (Array.isArray(diag.recentTrajectories)) { this._sessions = diag.recentTrajectories.map((entry: any) => ({ id: entry.googleAgentId ?? '', title: entry.summary ?? 'Untitled', stepCount: entry.lastStepIndex ?? 0, workspaceUri: '', lastModifiedTime: entry.lastModifiedTime ?? '', trajectoryId: entry.trajectoryId ?? '', })); log.debug(`Loaded ${this._sessions.length} sessions from getDiagnostics`); return; } } } catch (error) { log.warn('getDiagnostics failed, falling back to USS', error); } // Fallback: parse trajectory summaries protobuf try { await this._loadSessionsFromUSS(); } catch (error) { log.error('Failed to load sessions from USS', error); this._sessions = []; } } /** * Fallback: extract sessions from USS trajectory summaries protobuf. */ private async _loadSessionsFromUSS(): Promise { const raw = await this._state.getRawValue('antigravityUnifiedStateSync.trajectorySummaries'); if (!raw) { this._sessions = []; return; } const buffer = Buffer.from(raw, 'base64'); const text = buffer.toString('utf8'); // Extract UUIDs const uuids = [...new Set(text.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g) || [])]; this._sessions = uuids.map((id, i) => ({ id, title: `Conversation ${i + 1}`, stepCount: 0, workspaceUri: '', })); log.debug(`Loaded ${this._sessions.length} sessions from USS (fallback)`); } /** * Wait for a new session to appear in getDiagnostics. * Polls every 500ms up to timeoutMs. * * @returns New session ID or empty string if timeout */ private async _waitForNewSession(beforeIds: Set, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; const pollInterval = 500; while (Date.now() < deadline) { await this._delay(pollInterval); try { const raw = await this._commands.execute(AntigravityCommands.GET_DIAGNOSTICS); if (!raw || typeof raw !== 'string') continue; const diag = JSON.parse(raw); if (!Array.isArray(diag.recentTrajectories)) continue; for (const entry of diag.recentTrajectories) { const id = entry.googleAgentId; if (id && !beforeIds.has(id)) { // Update local session list await this._loadSessions(); return id; } } } catch { // ignore, retry } } return ''; } /** * Simple delay utility. */ private _delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } dispose(): void { this._disposables.dispose(); } }