- Root cause: getDiagnostics.lastStepIndex is stale, SDK EventMonitor cannot detect real-time step changes
- Fix: Direct rawRPC('GetCascadeTrajectorySteps') polling every 5s
- Relay: PLANNER_RESPONSE, NOTIFY_USER, TASK_BOUNDARY, WAITING steps
- Added: docs/discord-bridge-analysis.md (full SDK architecture analysis)
- Added: docs/devlog/entries/20260308-003.md (trial-and-error history)
- Added: antigravity-sdk-main/ source reference
- Vikunja: #252 done, #253 created, #251 commented
528 lines
19 KiB
TypeScript
528 lines
19 KiB
TypeScript
/**
|
|
* 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<ITrajectoryEntry[]>());
|
|
/** Fires when the session list changes */
|
|
public readonly onSessionsChanged: Event<ITrajectoryEntry[]> = 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<void> {
|
|
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<ITrajectoryEntry[]> {
|
|
if (!this._initialized) {
|
|
await this._loadSessions();
|
|
}
|
|
return [...this._sessions];
|
|
}
|
|
|
|
/**
|
|
* Refresh the session list.
|
|
*
|
|
* @returns Updated session list
|
|
*/
|
|
async refreshSessions(): Promise<ITrajectoryEntry[]> {
|
|
await this._loadSessions();
|
|
this._onSessionsChanged.fire(this._sessions);
|
|
return [...this._sessions];
|
|
}
|
|
|
|
/**
|
|
* Get agent preferences (all 16 sentinel values).
|
|
*/
|
|
async getPreferences(): Promise<IAgentPreferences> {
|
|
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<IDiagnosticsInfo> {
|
|
const raw = await this._commands.execute<string>(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<string> {
|
|
const result = await this._commands.execute<string>('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<boolean> {
|
|
const result = await this._commands.execute<boolean | null>('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<string> {
|
|
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<string>(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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await this._commands.execute(AntigravityCommands.SEND_CHAT_ACTION, action);
|
|
}
|
|
|
|
/**
|
|
* Switch to a specific conversation.
|
|
*
|
|
* @param sessionId - Conversation UUID (googleAgentId)
|
|
*/
|
|
async focusSession(sessionId: string): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await this._commands.execute(AntigravityCommands.ACCEPT_AGENT_STEP);
|
|
}
|
|
|
|
/** Reject the current agent step. */
|
|
async rejectStep(): Promise<void> {
|
|
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<void> {
|
|
await this._commands.execute(AntigravityCommands.COMMAND_ACCEPT);
|
|
}
|
|
|
|
/** Reject a pending command (non-terminal). */
|
|
async rejectCommand(): Promise<void> {
|
|
await this._commands.execute(AntigravityCommands.COMMAND_REJECT);
|
|
}
|
|
|
|
// ─── Terminal Control ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Accept a pending terminal command.
|
|
*
|
|
* Uses `antigravity.terminalCommand.accept`.
|
|
*/
|
|
async acceptTerminalCommand(): Promise<void> {
|
|
await this._commands.execute(AntigravityCommands.TERMINAL_ACCEPT);
|
|
}
|
|
|
|
/** Reject a pending terminal command. */
|
|
async rejectTerminalCommand(): Promise<void> {
|
|
await this._commands.execute(AntigravityCommands.TERMINAL_REJECT);
|
|
}
|
|
|
|
/** Run a pending terminal command. */
|
|
async runTerminalCommand(): Promise<void> {
|
|
await this._commands.execute(AntigravityCommands.TERMINAL_RUN);
|
|
}
|
|
|
|
// ─── Panel Control ──────────────────────────────────────────────────────
|
|
|
|
/** Open the Cascade agent panel */
|
|
async openPanel(): Promise<void> {
|
|
await this._commands.execute(AntigravityCommands.OPEN_AGENT_PANEL);
|
|
}
|
|
|
|
/** Focus the Cascade agent panel */
|
|
async focusPanel(): Promise<void> {
|
|
await this._commands.execute(AntigravityCommands.FOCUS_AGENT_PANEL);
|
|
}
|
|
|
|
/** Open the agent side panel */
|
|
async openSidePanel(): Promise<void> {
|
|
await this._commands.execute(AntigravityCommands.OPEN_AGENT_SIDE_PANEL);
|
|
}
|
|
|
|
/** Focus the agent side panel */
|
|
async focusSidePanel(): Promise<void> {
|
|
await this._commands.execute(AntigravityCommands.FOCUS_AGENT_SIDE_PANEL);
|
|
}
|
|
|
|
/**
|
|
* Get the browser integration port (e.g., 57401).
|
|
*/
|
|
async getBrowserPort(): Promise<number> {
|
|
return this._commands.execute<number>(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<void> {
|
|
try {
|
|
// Primary: getDiagnostics.recentTrajectories (10 most recent, with titles)
|
|
const raw = await this._commands.execute<string>(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<void> {
|
|
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<string>, timeoutMs: number): Promise<string> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
const pollInterval = 500;
|
|
|
|
while (Date.now() < deadline) {
|
|
await this._delay(pollInterval);
|
|
|
|
try {
|
|
const raw = await this._commands.execute<string>(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<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
dispose(): void {
|
|
this._disposables.dispose();
|
|
}
|
|
}
|
|
|