fix(bridge): rawRPC direct polling + SDK analysis docs + trial-and-error log
- 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
This commit is contained in:
342
antigravity-sdk-main/src/transport/command-bridge.ts
Normal file
342
antigravity-sdk-main/src/transport/command-bridge.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Command Bridge — executes Antigravity internal commands via VS Code API.
|
||||
*
|
||||
* All commands go through `vscode.commands.executeCommand()` which is the
|
||||
* safe, official way to interact with Antigravity from extensions.
|
||||
*
|
||||
* VERIFIED: All commands listed below were confirmed to exist in
|
||||
* Antigravity v1.107.0 workbench.desktop.main.js and extension.js
|
||||
* on 2026-02-28.
|
||||
*
|
||||
* @module transport/command-bridge
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { IDisposable } from '../core/disposable';
|
||||
import { CommandExecutionError } from '../core/errors';
|
||||
import { Logger } from '../core/logger';
|
||||
|
||||
const log = new Logger('CommandBridge');
|
||||
|
||||
/**
|
||||
* All known Antigravity commands, organized by category.
|
||||
*
|
||||
* Sources: workbench.desktop.main.js (160+ commands) + extension.js (45 commands)
|
||||
*/
|
||||
export const AntigravityCommands = {
|
||||
|
||||
// ─── Agent Panel & UI (VERIFIED: .open/.focus suffix required) ────────
|
||||
|
||||
/** Open the Cascade agent panel */
|
||||
OPEN_AGENT_PANEL: 'antigravity.agentPanel.open',
|
||||
/** Focus the Cascade agent panel */
|
||||
FOCUS_AGENT_PANEL: 'antigravity.agentPanel.focus',
|
||||
/** Open the agent side panel */
|
||||
OPEN_AGENT_SIDE_PANEL: 'antigravity.agentSidePanel.open',
|
||||
/** Focus the agent side panel */
|
||||
FOCUS_AGENT_SIDE_PANEL: 'antigravity.agentSidePanel.focus',
|
||||
/** Toggle side panel visibility */
|
||||
TOGGLE_SIDE_PANEL: 'antigravity.agentSidePanel.toggleVisibility',
|
||||
/** Open agent (generic) */
|
||||
OPEN_AGENT: 'antigravity.openAgent',
|
||||
/** Toggle chat focus */
|
||||
TOGGLE_CHAT_FOCUS: 'antigravity.toggleChatFocus',
|
||||
/** Switch between workspace editor and agent view */
|
||||
SWITCH_WORKSPACE_AGENT: 'antigravity.switchBetweenWorkspaceAndAgent',
|
||||
|
||||
// ─── Conversation Management (Critical for SDK) ──────────────────────
|
||||
|
||||
/** Start a new conversation */
|
||||
START_NEW_CONVERSATION: 'antigravity.startNewConversation',
|
||||
/** Send a prompt to the agent panel */
|
||||
SEND_PROMPT_TO_AGENT: 'antigravity.sendPromptToAgentPanel',
|
||||
/** Send text to chat */
|
||||
SEND_TEXT_TO_CHAT: 'antigravity.sendTextToChat',
|
||||
/** Send a chat action message */
|
||||
SEND_CHAT_ACTION: 'antigravity.sendChatActionMessage',
|
||||
/** Set which conversation is visible */
|
||||
SET_VISIBLE_CONVERSATION: 'antigravity.setVisibleConversation',
|
||||
/** Execute a cascade action */
|
||||
EXECUTE_CASCADE_ACTION: 'antigravity.executeCascadeAction',
|
||||
/** Broadcast conversation deletion to all windows */
|
||||
BROADCAST_CONVERSATION_DELETION: 'antigravity.broadcastConversationDeletion',
|
||||
/** Track that a background conversation was created */
|
||||
TRACK_BACKGROUND_CONVERSATION: 'antigravity.trackBackgroundConversationCreated',
|
||||
|
||||
// ─── Agent Step Control (VERIFIED) ────────────────────────────────────
|
||||
|
||||
/** Accept the current agent step */
|
||||
ACCEPT_AGENT_STEP: 'antigravity.agent.acceptAgentStep',
|
||||
/** Reject the current agent step */
|
||||
REJECT_AGENT_STEP: 'antigravity.agent.rejectAgentStep',
|
||||
/** Accept a pending command */
|
||||
COMMAND_ACCEPT: 'antigravity.command.accept',
|
||||
/** Reject a pending command */
|
||||
COMMAND_REJECT: 'antigravity.command.reject',
|
||||
/** Accept a terminal command */
|
||||
TERMINAL_ACCEPT: 'antigravity.terminalCommand.accept',
|
||||
/** Reject a terminal command */
|
||||
TERMINAL_REJECT: 'antigravity.terminalCommand.reject',
|
||||
/** Run a terminal command */
|
||||
TERMINAL_RUN: 'antigravity.terminalCommand.run',
|
||||
/** Open new conversation (prioritized) */
|
||||
OPEN_NEW_CONVERSATION: 'antigravity.prioritized.chat.openNewConversation',
|
||||
|
||||
// ─── Terminal Integration ─────────────────────────────────────────────
|
||||
|
||||
/** Notify terminal command started */
|
||||
TERMINAL_COMMAND_START: 'antigravity.onManagerTerminalCommandStart',
|
||||
/** Notify terminal command data */
|
||||
TERMINAL_COMMAND_DATA: 'antigravity.onManagerTerminalCommandData',
|
||||
/** Notify terminal command finished */
|
||||
TERMINAL_COMMAND_FINISH: 'antigravity.onManagerTerminalCommandFinish',
|
||||
/** Update last terminal command */
|
||||
UPDATE_TERMINAL_LAST_COMMAND: 'antigravity.updateTerminalLastCommand',
|
||||
/** Notify shell command completion */
|
||||
ON_SHELL_COMPLETION: 'antigravity.onShellCommandCompletion',
|
||||
/** Show managed terminal */
|
||||
SHOW_MANAGED_TERMINAL: 'antigravity.showManagedTerminal',
|
||||
/** Send terminal output to chat */
|
||||
SEND_TERMINAL_TO_CHAT: 'antigravity.sendTerminalToChat',
|
||||
/** Send terminal output to side panel */
|
||||
SEND_TERMINAL_TO_SIDE_PANEL: 'antigravity.sendTerminalToSidePanel',
|
||||
|
||||
// ─── Agent & Mode ─────────────────────────────────────────────────────
|
||||
|
||||
/** Initialize the agent */
|
||||
INITIALIZE_AGENT: 'antigravity.initializeAgent',
|
||||
|
||||
// ─── Conversation Picker & Workspace ──────────────────────────────────
|
||||
|
||||
/** Open conversation workspace picker */
|
||||
OPEN_CONVERSATION_PICKER: 'antigravity.openConversationWorkspaceQuickPick',
|
||||
/** Open conversation picker (alternative) */
|
||||
OPEN_CONV_PICKER_ALT: 'antigravity.openConversationPicker',
|
||||
/** Set working directories */
|
||||
SET_WORKING_DIRS: 'antigravity.setWorkingDirectories',
|
||||
|
||||
// ─── Review & Diff ────────────────────────────────────────────────────
|
||||
|
||||
/** Open review changes view */
|
||||
OPEN_REVIEW_CHANGES: 'antigravity.openReviewChanges',
|
||||
/** Open diff view */
|
||||
OPEN_DIFF_VIEW: 'antigravity.openDiffView',
|
||||
/** Open diff zones */
|
||||
OPEN_DIFF_ZONES: 'antigravity.openDiffZones',
|
||||
/** Close all diff zones */
|
||||
CLOSE_ALL_DIFF_ZONES: 'antigravity.closeAllDiffZones',
|
||||
|
||||
// ─── Rules & Workflows ────────────────────────────────────────────────
|
||||
|
||||
/** Create a new rule */
|
||||
CREATE_RULE: 'antigravity.createRule',
|
||||
/** Create a new workflow */
|
||||
CREATE_WORKFLOW: 'antigravity.createWorkflow',
|
||||
/** Create a global workflow */
|
||||
CREATE_GLOBAL_WORKFLOW: 'antigravity.createGlobalWorkflow',
|
||||
/** Open global rules */
|
||||
OPEN_GLOBAL_RULES: 'antigravity.openGlobalRules',
|
||||
/** Open workspace rules */
|
||||
OPEN_WORKSPACE_RULES: 'antigravity.openWorkspaceRules',
|
||||
|
||||
// ─── Plugins & MCP ────────────────────────────────────────────────────
|
||||
|
||||
/** Open configure plugins page */
|
||||
OPEN_CONFIGURE_PLUGINS: 'antigravity.openConfigurePluginsPage',
|
||||
/** Get Cascade plugin template */
|
||||
GET_PLUGIN_TEMPLATE: 'antigravity.getCascadePluginTemplate',
|
||||
/** Poll MCP server states */
|
||||
POLL_MCP_SERVERS: 'antigravity.pollMcpServerStates',
|
||||
/** Open MCP config file */
|
||||
OPEN_MCP_CONFIG: 'antigravity.openMcpConfigFile',
|
||||
/** Open MCP docs page */
|
||||
OPEN_MCP_DOCS: 'antigravity.openMcpDocsPage',
|
||||
/** Update plugin installation count */
|
||||
UPDATE_PLUGIN_COUNT: 'antigravity.updatePluginInstallationCount',
|
||||
|
||||
// ─── Autocomplete ─────────────────────────────────────────────────────
|
||||
|
||||
/** Enable autocomplete */
|
||||
ENABLE_AUTOCOMPLETE: 'antigravity.enableAutocomplete',
|
||||
/** Disable autocomplete */
|
||||
DISABLE_AUTOCOMPLETE: 'antigravity.disableAutocomplete',
|
||||
/** Accept completion */
|
||||
ACCEPT_COMPLETION: 'antigravity.acceptCompletion',
|
||||
/** Force supercomplete */
|
||||
FORCE_SUPERCOMPLETE: 'antigravity.forceSupercomplete',
|
||||
/** Snooze autocomplete temporarily */
|
||||
SNOOZE_AUTOCOMPLETE: 'antigravity.snoozeAutocomplete',
|
||||
/** Cancel snooze */
|
||||
CANCEL_SNOOZE: 'antigravity.cancelSnoozeAutocomplete',
|
||||
|
||||
// ─── Auth & Account ───────────────────────────────────────────────────
|
||||
|
||||
/** Login to Antigravity */
|
||||
LOGIN: 'antigravity.login',
|
||||
/** Cancel login */
|
||||
CANCEL_LOGIN: 'antigravity.cancelLogin',
|
||||
/** Handle auth refresh */
|
||||
HANDLE_AUTH_REFRESH: 'antigravity.handleAuthRefresh',
|
||||
/** Sign in to Antigravity */
|
||||
SIGN_IN: 'antigravity.SignInToAntigravity',
|
||||
|
||||
// ─── Diagnostics & Debug ──────────────────────────────────────────────
|
||||
|
||||
/** Get diagnostics info */
|
||||
GET_DIAGNOSTICS: 'antigravity.getDiagnostics',
|
||||
/** Download diagnostics bundle */
|
||||
DOWNLOAD_DIAGNOSTICS: 'antigravity.downloadDiagnostics',
|
||||
/** Capture traces */
|
||||
CAPTURE_TRACES: 'antigravity.captureTraces',
|
||||
/** Enable tracing */
|
||||
ENABLE_TRACING: 'antigravity.enableTracing',
|
||||
/** Clear and disable tracing */
|
||||
CLEAR_TRACING: 'antigravity.clearAndDisableTracing',
|
||||
/** Get manager trace */
|
||||
GET_MANAGER_TRACE: 'antigravity.getManagerTrace',
|
||||
/** Get workbench trace */
|
||||
GET_WORKBENCH_TRACE: 'antigravity.getWorkbenchTrace',
|
||||
/** Toggle debug info widget */
|
||||
TOGGLE_DEBUG_INFO: 'antigravity.toggleDebugInfoWidget',
|
||||
/** Open troubleshooting */
|
||||
OPEN_TROUBLESHOOTING: 'antigravity.openTroubleshooting',
|
||||
/** Open issue reporter */
|
||||
OPEN_ISSUE_REPORTER: 'antigravity.openIssueReporter',
|
||||
|
||||
// ─── Language Server ──────────────────────────────────────────────────
|
||||
|
||||
/** Restart the language server */
|
||||
RESTART_LANGUAGE_SERVER: 'antigravity.restartLanguageServer',
|
||||
/** Kill language server and reload window */
|
||||
KILL_LS_AND_RELOAD: 'antigravity.killLanguageServerAndReloadWindow',
|
||||
|
||||
// ─── Git & Commit ─────────────────────────────────────────────────────
|
||||
|
||||
/** Generate commit message via AI */
|
||||
GENERATE_COMMIT_MESSAGE: 'antigravity.generateCommitMessage',
|
||||
/** Cancel commit message generation */
|
||||
CANCEL_COMMIT_MESSAGE: 'antigravity.cancelGenerateCommitMessage',
|
||||
|
||||
// ─── Browser ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Open browser */
|
||||
OPEN_BROWSER: 'antigravity.openBrowser',
|
||||
/** Get browser onboarding port (returns number, e.g. 57401) */
|
||||
GET_BROWSER_PORT: 'antigravity.getBrowserOnboardingPort',
|
||||
|
||||
// ─── Settings & Import ────────────────────────────────────────────────
|
||||
|
||||
/** Open quick settings panel */
|
||||
OPEN_QUICK_SETTINGS: 'antigravity.openQuickSettingsPanel',
|
||||
/** Open customizations tab */
|
||||
OPEN_CUSTOMIZATIONS: 'antigravity.openCustomizationsTab',
|
||||
/** Import VS Code settings */
|
||||
IMPORT_VSCODE_SETTINGS: 'antigravity.importVSCodeSettings',
|
||||
/** Import VS Code extensions */
|
||||
IMPORT_VSCODE_EXTENSIONS: 'antigravity.importVSCodeExtensions',
|
||||
/** Import Cursor settings */
|
||||
IMPORT_CURSOR_SETTINGS: 'antigravity.importCursorSettings',
|
||||
/** Import Cursor extensions */
|
||||
IMPORT_CURSOR_EXTENSIONS: 'antigravity.importCursorExtensions',
|
||||
|
||||
// ─── Misc ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Reload window */
|
||||
RELOAD_WINDOW: 'antigravity.reloadWindow',
|
||||
/** Open documentation */
|
||||
OPEN_DOCS: 'antigravity.openDocs',
|
||||
/** Open changelog */
|
||||
OPEN_CHANGELOG: 'antigravity.openChangeLog',
|
||||
/** Explain and fix problem (from diagnostics) */
|
||||
EXPLAIN_AND_FIX: 'antigravity.explainAndFixProblem',
|
||||
/** Open a URL */
|
||||
OPEN_URL: 'antigravity.openGenericUrl',
|
||||
/** Editor mode settings */
|
||||
EDITOR_MODE_SETTINGS: 'antigravity.editorModeSettings',
|
||||
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Bridges between the SDK and Antigravity's command system.
|
||||
*
|
||||
* All interactions with Antigravity go through registered VS Code commands,
|
||||
* ensuring we never bypass the official extension API.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const bridge = new CommandBridge();
|
||||
*
|
||||
* // Open the agent panel
|
||||
* await bridge.execute(AntigravityCommands.OPEN_AGENT_PANEL);
|
||||
*
|
||||
* // Start a new conversation
|
||||
* await bridge.execute(AntigravityCommands.START_NEW_CONVERSATION);
|
||||
*
|
||||
* // Send a prompt
|
||||
* await bridge.execute(AntigravityCommands.SEND_PROMPT_TO_AGENT, 'Hello!');
|
||||
* ```
|
||||
*/
|
||||
export class CommandBridge implements IDisposable {
|
||||
private _disposed = false;
|
||||
|
||||
/**
|
||||
* Execute an Antigravity command.
|
||||
*
|
||||
* @param command - The command ID to execute
|
||||
* @param args - Arguments to pass to the command
|
||||
* @returns The command's return value
|
||||
* @throws {CommandExecutionError} If the command fails
|
||||
*/
|
||||
async execute<T = unknown>(command: string, ...args: unknown[]): Promise<T> {
|
||||
if (this._disposed) {
|
||||
throw new CommandExecutionError(command, 'CommandBridge has been disposed');
|
||||
}
|
||||
|
||||
log.debug(`Executing: ${command}`, args.length > 0 ? args : '');
|
||||
|
||||
try {
|
||||
const result = await vscode.commands.executeCommand<T>(command, ...args);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Command failed: ${command}`, message);
|
||||
throw new CommandExecutionError(command, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is registered and available.
|
||||
*
|
||||
* @param command - Command ID to check
|
||||
* @returns true if the command exists
|
||||
*/
|
||||
async isAvailable(command: string): Promise<boolean> {
|
||||
const commands = await vscode.commands.getCommands(true);
|
||||
return commands.includes(command);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered Antigravity commands.
|
||||
*
|
||||
* @returns List of command IDs starting with 'antigravity.'
|
||||
*/
|
||||
async getAntigravityCommands(): Promise<string[]> {
|
||||
const commands = await vscode.commands.getCommands(true);
|
||||
return commands.filter((cmd) => cmd.startsWith('antigravity.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a command handler.
|
||||
*
|
||||
* @param command - Command ID to register
|
||||
* @param handler - Function to handle the command
|
||||
* @returns Disposable to unregister the command
|
||||
*/
|
||||
register(command: string, handler: (...args: unknown[]) => unknown): IDisposable {
|
||||
return vscode.commands.registerCommand(command, handler);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
307
antigravity-sdk-main/src/transport/event-monitor.ts
Normal file
307
antigravity-sdk-main/src/transport/event-monitor.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Event Monitor — polls state.vscdb and getDiagnostics for changes.
|
||||
*
|
||||
* Detects:
|
||||
* - USS key changes (trajectory summaries, preferences, etc.)
|
||||
* - Step count changes per session (via getDiagnostics.recentTrajectories)
|
||||
* - Active session switches
|
||||
* - New conversations
|
||||
*
|
||||
* @module transport/event-monitor
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { IDisposable, DisposableStore } from '../core/disposable';
|
||||
import { EventEmitter, Event } from '../core/events';
|
||||
import { Logger } from '../core/logger';
|
||||
import { StateBridge, USSKeys } from './state-bridge';
|
||||
|
||||
const log = new Logger('EventMonitor');
|
||||
|
||||
/**
|
||||
* USS key change event.
|
||||
*/
|
||||
export interface IStateChange {
|
||||
/** Which USS key changed */
|
||||
readonly key: string;
|
||||
/** New data size */
|
||||
readonly newSize: number;
|
||||
/** Previous data size */
|
||||
readonly previousSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step count change event — fired when the agent adds/processes steps.
|
||||
*/
|
||||
export interface IStepCountChange {
|
||||
/** Conversation UUID (googleAgentId) */
|
||||
readonly sessionId: string;
|
||||
/** Conversation title */
|
||||
readonly title: string;
|
||||
/** Previous step count */
|
||||
readonly previousCount: number;
|
||||
/** New step count */
|
||||
readonly newCount: number;
|
||||
/** Number of new steps added */
|
||||
readonly delta: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active session change event.
|
||||
*/
|
||||
export interface IActiveSessionChange {
|
||||
/** New active session ID */
|
||||
readonly sessionId: string;
|
||||
/** New active session title */
|
||||
readonly title: string;
|
||||
/** Previous active session ID (empty if first detection) */
|
||||
readonly previousSessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of a trajectory from getDiagnostics.
|
||||
*/
|
||||
interface ITrajectorySnapshot {
|
||||
id: string;
|
||||
title: string;
|
||||
stepCount: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors Antigravity state for changes.
|
||||
*
|
||||
* Two polling modes:
|
||||
* 1. **USS polling** — watches state.vscdb keys for size changes (lightweight)
|
||||
* 2. **Trajectory polling** — watches getDiagnostics for step count changes (heavier, optional)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const monitor = new EventMonitor(stateBridge);
|
||||
*
|
||||
* // React to step changes (agent is working)
|
||||
* monitor.onStepCountChanged((e) => {
|
||||
* console.log(`${e.title}: +${e.delta} steps (now ${e.newCount})`);
|
||||
* });
|
||||
*
|
||||
* // React to conversation switches
|
||||
* monitor.onActiveSessionChanged((e) => {
|
||||
* console.log(`Switched to: ${e.title}`);
|
||||
* });
|
||||
*
|
||||
* monitor.start(3000);
|
||||
* ```
|
||||
*/
|
||||
export class EventMonitor implements IDisposable {
|
||||
private readonly _disposables = new DisposableStore();
|
||||
private _ussTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _trajTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _ussSnapshots = new Map<string, number>();
|
||||
private _trajSnapshots = new Map<string, ITrajectorySnapshot>();
|
||||
private _activeSessionId = '';
|
||||
private _running = false;
|
||||
|
||||
// ─── USS Events ─────────────────────────────────────────────────────
|
||||
|
||||
private readonly _onStateChanged = this._disposables.add(new EventEmitter<IStateChange>());
|
||||
/** Fires when any monitored USS key changes size */
|
||||
public readonly onStateChanged: Event<IStateChange> = this._onStateChanged.event;
|
||||
|
||||
private readonly _onNewConversation = this._disposables.add(new EventEmitter<void>());
|
||||
/** Fires when trajectory summaries grow (new conversation likely) */
|
||||
public readonly onNewConversation: Event<void> = this._onNewConversation.event;
|
||||
|
||||
// ─── Trajectory Events ──────────────────────────────────────────────
|
||||
|
||||
private readonly _onStepCountChanged = this._disposables.add(new EventEmitter<IStepCountChange>());
|
||||
/** Fires when a session's step count changes (agent made progress) */
|
||||
public readonly onStepCountChanged: Event<IStepCountChange> = this._onStepCountChanged.event;
|
||||
|
||||
private readonly _onActiveSessionChanged = this._disposables.add(new EventEmitter<IActiveSessionChange>());
|
||||
/** Fires when the active (most recent) session changes */
|
||||
public readonly onActiveSessionChanged: Event<IActiveSessionChange> = this._onActiveSessionChanged.event;
|
||||
|
||||
/** Keys we monitor for USS changes */
|
||||
private readonly _watchedKeys = [
|
||||
USSKeys.TRAJECTORY_SUMMARIES,
|
||||
USSKeys.AGENT_PREFERENCES,
|
||||
USSKeys.USER_STATUS,
|
||||
];
|
||||
|
||||
constructor(private readonly _state: StateBridge) { }
|
||||
|
||||
/**
|
||||
* Start polling for state changes.
|
||||
*
|
||||
* @param intervalMs - USS polling interval (default: 3000ms)
|
||||
* @param trajectoryIntervalMs - Trajectory polling interval (default: 5000ms).
|
||||
* Set to 0 to disable trajectory polling (saves CPU).
|
||||
*/
|
||||
start(intervalMs: number = 3000, trajectoryIntervalMs: number = 5000): void {
|
||||
if (this._running) return;
|
||||
|
||||
this._running = true;
|
||||
log.info(`Starting event monitor (USS: ${intervalMs}ms, Traj: ${trajectoryIntervalMs}ms)`);
|
||||
|
||||
// Initial USS snapshot
|
||||
this._takeUSSSnapshot().catch(() => { });
|
||||
|
||||
// USS polling
|
||||
this._ussTimer = setInterval(async () => {
|
||||
try {
|
||||
await this._pollUSS();
|
||||
} catch (error) {
|
||||
log.error('USS poll error', error);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
// Trajectory polling (optional, heavier)
|
||||
if (trajectoryIntervalMs > 0) {
|
||||
this._pollTrajectories().catch(() => { });
|
||||
|
||||
this._trajTimer = setInterval(async () => {
|
||||
try {
|
||||
await this._pollTrajectories();
|
||||
} catch (error) {
|
||||
log.error('Trajectory poll error', error);
|
||||
}
|
||||
}, trajectoryIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this._ussTimer) {
|
||||
clearInterval(this._ussTimer);
|
||||
this._ussTimer = null;
|
||||
}
|
||||
if (this._trajTimer) {
|
||||
clearInterval(this._trajTimer);
|
||||
this._trajTimer = null;
|
||||
}
|
||||
this._running = false;
|
||||
log.info('Event monitor stopped');
|
||||
}
|
||||
|
||||
/** Check if the monitor is currently running. */
|
||||
get isRunning(): boolean {
|
||||
return this._running;
|
||||
}
|
||||
|
||||
/** Get the currently active session ID. */
|
||||
get activeSessionId(): string {
|
||||
return this._activeSessionId;
|
||||
}
|
||||
|
||||
// ─── USS Polling ────────────────────────────────────────────────────
|
||||
|
||||
private async _takeUSSSnapshot(): Promise<void> {
|
||||
for (const key of this._watchedKeys) {
|
||||
try {
|
||||
const value = await this._state.getRawValue(key);
|
||||
this._ussSnapshots.set(key, value ? value.length : 0);
|
||||
} catch {
|
||||
this._ussSnapshots.set(key, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _pollUSS(): Promise<void> {
|
||||
for (const key of this._watchedKeys) {
|
||||
try {
|
||||
const value = await this._state.getRawValue(key);
|
||||
const newSize = value ? value.length : 0;
|
||||
const previousSize = this._ussSnapshots.get(key) ?? 0;
|
||||
|
||||
if (newSize !== previousSize) {
|
||||
log.debug(`USS change: ${key} (${previousSize} -> ${newSize})`);
|
||||
this._ussSnapshots.set(key, newSize);
|
||||
this._onStateChanged.fire({ key, newSize, previousSize });
|
||||
|
||||
if (key === USSKeys.TRAJECTORY_SUMMARIES && newSize > previousSize) {
|
||||
this._onNewConversation.fire();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip errors during polling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Trajectory Polling ─────────────────────────────────────────────
|
||||
|
||||
private async _pollTrajectories(): Promise<void> {
|
||||
let trajectories: Array<{
|
||||
googleAgentId: string;
|
||||
trajectoryId: string;
|
||||
summary: string;
|
||||
lastStepIndex: number;
|
||||
lastModifiedTime: string;
|
||||
}>;
|
||||
|
||||
try {
|
||||
const raw = await vscode.commands.executeCommand<string>('antigravity.getDiagnostics');
|
||||
if (!raw || typeof raw !== 'string') return;
|
||||
const diag = JSON.parse(raw);
|
||||
if (!Array.isArray(diag.recentTrajectories)) return;
|
||||
trajectories = diag.recentTrajectories;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for step count changes in each trajectory
|
||||
for (const traj of trajectories) {
|
||||
const id = traj.googleAgentId;
|
||||
if (!id) continue;
|
||||
|
||||
const prev = this._trajSnapshots.get(id);
|
||||
const newCount = traj.lastStepIndex ?? 0;
|
||||
|
||||
if (prev && prev.stepCount !== newCount) {
|
||||
const delta = newCount - prev.stepCount;
|
||||
log.debug(`Step change: "${traj.summary}" ${prev.stepCount} -> ${newCount} (+${delta})`);
|
||||
|
||||
this._onStepCountChanged.fire({
|
||||
sessionId: id,
|
||||
title: traj.summary ?? 'Untitled',
|
||||
previousCount: prev.stepCount,
|
||||
newCount,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
|
||||
this._trajSnapshots.set(id, {
|
||||
id,
|
||||
title: traj.summary ?? 'Untitled',
|
||||
stepCount: newCount,
|
||||
lastModified: traj.lastModifiedTime ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for active session change (first entry = most recent)
|
||||
if (trajectories.length > 0) {
|
||||
const newActiveId = trajectories[0].googleAgentId;
|
||||
if (newActiveId && newActiveId !== this._activeSessionId) {
|
||||
const previousId = this._activeSessionId;
|
||||
this._activeSessionId = newActiveId;
|
||||
|
||||
// Only fire event after initial snapshot (not on first detection)
|
||||
if (previousId !== '') {
|
||||
log.debug(`Active session changed: "${trajectories[0].summary}"`);
|
||||
this._onActiveSessionChanged.fire({
|
||||
sessionId: newActiveId,
|
||||
title: trajectories[0].summary ?? 'Untitled',
|
||||
previousSessionId: previousId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
9
antigravity-sdk-main/src/transport/index.ts
Normal file
9
antigravity-sdk-main/src/transport/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Transport module re-exports.
|
||||
* @module transport
|
||||
*/
|
||||
|
||||
export { CommandBridge, AntigravityCommands } from './command-bridge';
|
||||
export { StateBridge, USSKeys } from './state-bridge';
|
||||
export { EventMonitor, type IStateChange } from './event-monitor';
|
||||
export { LSBridge, Models, type ModelId, type IHeadlessCascadeOptions, type ISendMessageOptions } from './ls-bridge';
|
||||
725
antigravity-sdk-main/src/transport/ls-bridge.ts
Normal file
725
antigravity-sdk-main/src/transport/ls-bridge.ts
Normal file
@@ -0,0 +1,725 @@
|
||||
/**
|
||||
* Language Server Bridge — Direct ConnectRPC calls to the local LS.
|
||||
*
|
||||
* UPDATED 2026-03-01 (v1.3.0):
|
||||
* Fixed CSRF token authentication (Issue #1).
|
||||
* The LS binary is launched with --csrf_token as a CLI argument.
|
||||
* Previous versions did not send this token, causing 401 "missing CSRF token".
|
||||
*
|
||||
* Discovery strategy (multi-layer):
|
||||
* 1. Process CLI args — extract --port and --csrf_token from LS process
|
||||
* 2. getDiagnostics console logs — fallback for port discovery
|
||||
* 3. Manual override — setConnection(port, csrfToken)
|
||||
*
|
||||
* Service: exa.language_server_pb.LanguageServerService
|
||||
* Protocol: HTTPS POST with JSON body + x-csrf-token header
|
||||
*
|
||||
* @module transport/ls-bridge
|
||||
*/
|
||||
|
||||
import { Logger } from '../core/logger';
|
||||
|
||||
const log = new Logger('LSBridge');
|
||||
|
||||
/** Known model IDs (verified 2026-02-28) */
|
||||
export const Models = {
|
||||
GEMINI_FLASH: 1018,
|
||||
GEMINI_PRO_LOW: 1164,
|
||||
GEMINI_PRO_HIGH: 1165,
|
||||
CLAUDE_SONNET: 1163,
|
||||
CLAUDE_OPUS: 1154,
|
||||
GPT_OSS: 342,
|
||||
} as const;
|
||||
|
||||
export type ModelId = typeof Models[keyof typeof Models] | number;
|
||||
|
||||
/** Options for creating a headless cascade */
|
||||
export interface IHeadlessCascadeOptions {
|
||||
/** Text prompt to send */
|
||||
text: string;
|
||||
/** Model ID (default: Gemini 3 Flash = 1018) */
|
||||
model?: ModelId;
|
||||
/** Planner type: 'conversational' (default) or 'normal' */
|
||||
plannerType?: 'conversational' | 'normal';
|
||||
}
|
||||
|
||||
/** Options for sending a message to existing cascade */
|
||||
export interface ISendMessageOptions {
|
||||
/** Target cascade ID */
|
||||
cascadeId: string;
|
||||
/** Text to send */
|
||||
text: string;
|
||||
/** Model ID (default: Gemini 3 Flash = 1018) */
|
||||
model?: ModelId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation annotation fields (from jetski_cortex.proto ConversationAnnotations).
|
||||
*
|
||||
* These are metadata annotations on a conversation that the user can set.
|
||||
* The LS stores these natively and they persist across sessions.
|
||||
*/
|
||||
export interface IConversationAnnotations {
|
||||
/** Custom user title -- overrides the auto-generated summary */
|
||||
title?: string;
|
||||
/** Tags/labels for organization */
|
||||
tags?: string[];
|
||||
/** Whether this conversation is archived */
|
||||
archived?: boolean;
|
||||
/** Whether this conversation is starred (pinned) */
|
||||
starred?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct bridge to the Language Server via ConnectRPC.
|
||||
*
|
||||
* Discovers the LS port and CSRF token from the LS process CLI args,
|
||||
* then makes authenticated HTTPS POST calls to the LS endpoints.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ls = new LSBridge(commandBridge);
|
||||
* await ls.initialize();
|
||||
*
|
||||
* // Create a headless cascade
|
||||
* const cascadeId = await ls.createCascade({
|
||||
* text: 'Analyze test coverage',
|
||||
* model: Models.GEMINI_FLASH,
|
||||
* });
|
||||
*
|
||||
* // Send follow-up
|
||||
* await ls.sendMessage({ cascadeId, text: 'Focus on edge cases' });
|
||||
*
|
||||
* // Switch UI to it
|
||||
* await ls.focusCascade(cascadeId);
|
||||
* ```
|
||||
*/
|
||||
export class LSBridge {
|
||||
private _port: number | null = null;
|
||||
private _csrfToken: string | null = null;
|
||||
private _useTls: boolean = false;
|
||||
private _executeCommand: <T = any>(command: string, ...args: any[]) => Promise<T>;
|
||||
|
||||
constructor(executeCommand: <T = any>(command: string, ...args: any[]) => Promise<T>) {
|
||||
this._executeCommand = executeCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the Language Server port and CSRF token.
|
||||
* Must be called before other methods.
|
||||
*
|
||||
* Discovery chain:
|
||||
* 1. Parse LS process CLI arguments (--port, --csrf_token)
|
||||
* 2. Fallback: getDiagnostics console logs (port only)
|
||||
* 3. Manual: call setConnection() after initialize() returns false
|
||||
*/
|
||||
async initialize(): Promise<boolean> {
|
||||
// Strategy 1: discover from LS process CLI args (port + CSRF)
|
||||
const fromProcess = await this._discoverFromProcess();
|
||||
if (fromProcess) {
|
||||
this._port = fromProcess.port;
|
||||
this._csrfToken = fromProcess.csrfToken;
|
||||
this._useTls = fromProcess.useTls;
|
||||
log.info(`LS discovered from process: port=${this._port}, tls=${this._useTls}, csrf=${this._csrfToken ? 'found' : 'missing'}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strategy 2: fallback to getDiagnostics logs (port only, no CSRF)
|
||||
this._port = await this._discoverPortFromDiagnostics();
|
||||
if (this._port) {
|
||||
log.warn(`LS port from diagnostics: ${this._port}, but CSRF token not found — RPC calls may fail with 401`);
|
||||
return true;
|
||||
}
|
||||
|
||||
log.warn('Could not discover LS connection. Use setConnection(port, csrfToken) manually.');
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether the bridge is ready (port discovered) */
|
||||
get isReady(): boolean {
|
||||
return this._port !== null;
|
||||
}
|
||||
|
||||
/** The discovered LS port */
|
||||
get port(): number | null {
|
||||
return this._port;
|
||||
}
|
||||
|
||||
/** Whether CSRF token is available */
|
||||
get hasCsrfToken(): boolean {
|
||||
return this._csrfToken !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually set the LS connection parameters.
|
||||
*
|
||||
* Use this when auto-discovery fails (e.g., non-standard install,
|
||||
* or you've discovered the port/token through other means like `lsof`).
|
||||
*
|
||||
* @param port - LS port number
|
||||
* @param csrfToken - CSRF token from LS process CLI args
|
||||
* @param useTls - Whether to use HTTPS (default: false, extension_server uses HTTP)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ls = new LSBridge(commandBridge);
|
||||
* const ok = await ls.initialize();
|
||||
* if (!ok) {
|
||||
* // Manual fallback: get port and csrf from your own discovery
|
||||
* ls.setConnection(54321, 'abc123-csrf-token');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setConnection(port: number, csrfToken: string, useTls: boolean = false): void {
|
||||
this._port = port;
|
||||
this._csrfToken = csrfToken;
|
||||
this._useTls = useTls;
|
||||
log.info(`LS connection set manually: port=${port}, tls=${useTls}, csrf=${csrfToken ? 'provided' : 'empty'}`);
|
||||
}
|
||||
|
||||
// ─── Headless Cascade API ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new cascade and optionally send a message.
|
||||
* Fully headless — no UI panel opened, no conversation switched.
|
||||
*
|
||||
* @returns cascadeId or null on failure
|
||||
*/
|
||||
async createCascade(options: IHeadlessCascadeOptions): Promise<string | null> {
|
||||
this._ensureReady();
|
||||
|
||||
// Step 1: StartCascade
|
||||
const startResp = await this._rpc('StartCascade', { source: 0 });
|
||||
const cascadeId = startResp?.cascadeId;
|
||||
if (!cascadeId) {
|
||||
log.error('StartCascade returned no cascadeId');
|
||||
return null;
|
||||
}
|
||||
log.info(`Cascade created: ${cascadeId}`);
|
||||
|
||||
// Step 2: SendUserCascadeMessage
|
||||
if (options.text) {
|
||||
await this._sendMessage(cascadeId, options.text, options.model, options.plannerType);
|
||||
log.info(`Message sent to: ${cascadeId}`);
|
||||
}
|
||||
|
||||
return cascadeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to an existing cascade.
|
||||
*
|
||||
* @returns true if sent successfully
|
||||
*/
|
||||
async sendMessage(options: ISendMessageOptions): Promise<boolean> {
|
||||
this._ensureReady();
|
||||
await this._sendMessage(options.cascadeId, options.text, options.model);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the UI to show a specific cascade conversation.
|
||||
*/
|
||||
async focusCascade(cascadeId: string): Promise<void> {
|
||||
this._ensureReady();
|
||||
await this._rpc('SmartFocusConversation', { cascadeId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running cascade invocation.
|
||||
*/
|
||||
async cancelCascade(cascadeId: string): Promise<void> {
|
||||
this._ensureReady();
|
||||
await this._rpc('CancelCascadeInvocation', { cascadeId });
|
||||
}
|
||||
|
||||
// ─── Conversation Annotations API ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Native conversation annotations (verified from jetski_cortex.proto).
|
||||
*
|
||||
* ConversationAnnotations protobuf fields:
|
||||
* - title (string) — custom user title, overrides auto-summary
|
||||
* - tags (string[]) — tags/labels
|
||||
* - archived (bool) — archive status
|
||||
* - starred (bool) — pinned/starred
|
||||
* - last_user_view_time (Timestamp)
|
||||
*
|
||||
* @param cascadeId - Conversation ID
|
||||
* @param annotations - Partial annotation fields to set
|
||||
* @param merge - If true, merge with existing annotations (default: true)
|
||||
*/
|
||||
async updateAnnotations(
|
||||
cascadeId: string,
|
||||
annotations: IConversationAnnotations,
|
||||
merge: boolean = true,
|
||||
): Promise<void> {
|
||||
this._ensureReady();
|
||||
|
||||
// Convert camelCase to snake_case for protobuf
|
||||
const proto: Record<string, any> = {};
|
||||
if (annotations.title !== undefined) proto.title = annotations.title;
|
||||
if (annotations.starred !== undefined) proto.starred = annotations.starred;
|
||||
if (annotations.archived !== undefined) proto.archived = annotations.archived;
|
||||
if (annotations.tags !== undefined) proto.tags = annotations.tags;
|
||||
|
||||
await this._rpc('UpdateConversationAnnotations', {
|
||||
cascadeId,
|
||||
annotations: proto,
|
||||
mergeAnnotations: merge,
|
||||
});
|
||||
log.info(`Annotations updated for ${cascadeId.substring(0, 8)}...`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom title for a conversation.
|
||||
*
|
||||
* This sets the `title` field in ConversationAnnotations.
|
||||
* When set, this title should be displayed instead of the
|
||||
* auto-generated `summary` from the LLM.
|
||||
*
|
||||
* @param cascadeId - Conversation ID
|
||||
* @param title - Custom title to set
|
||||
*/
|
||||
async setTitle(cascadeId: string, title: string): Promise<void> {
|
||||
await this.updateAnnotations(cascadeId, { title });
|
||||
}
|
||||
|
||||
/**
|
||||
* Star (pin) or unstar a conversation.
|
||||
*
|
||||
* This sets the `starred` field in ConversationAnnotations.
|
||||
*
|
||||
* @param cascadeId - Conversation ID
|
||||
* @param starred - true to star, false to unstar
|
||||
*/
|
||||
async setStar(cascadeId: string, starred: boolean): Promise<void> {
|
||||
await this.updateAnnotations(cascadeId, { starred });
|
||||
}
|
||||
|
||||
// ─── Conversation Read API ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get details of a specific conversation.
|
||||
*/
|
||||
async getConversation(cascadeId: string): Promise<any> {
|
||||
this._ensureReady();
|
||||
return this._rpc('GetConversation', { cascadeId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cascade trajectories (conversation list).
|
||||
*/
|
||||
async listCascades(): Promise<any> {
|
||||
this._ensureReady();
|
||||
const resp = await this._rpc('GetAllCascadeTrajectories', {});
|
||||
return resp?.trajectorySummaries ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trajectory descriptions (lighter than full trajectories).
|
||||
* Returns { trajectories: [...] }.
|
||||
*/
|
||||
async getTrajectoryDescriptions(): Promise<any> {
|
||||
this._ensureReady();
|
||||
return this._rpc('GetUserTrajectoryDescriptions', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user status (tier, models, etc.)
|
||||
*/
|
||||
async getUserStatus(): Promise<any> {
|
||||
this._ensureReady();
|
||||
return this._rpc('GetUserStatus', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a raw RPC call to any LS method.
|
||||
* @param method - RPC method name (e.g. 'StartCascade')
|
||||
* @param payload - JSON payload
|
||||
*/
|
||||
async rawRPC(method: string, payload: any): Promise<any> {
|
||||
this._ensureReady();
|
||||
return this._rpc(method, payload);
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────
|
||||
|
||||
private _ensureReady(): void {
|
||||
if (!this._port) {
|
||||
throw new Error('LSBridge not initialized. Call initialize() first.');
|
||||
}
|
||||
}
|
||||
|
||||
private async _sendMessage(
|
||||
cascadeId: string,
|
||||
text: string,
|
||||
model?: ModelId,
|
||||
plannerType?: string,
|
||||
): Promise<void> {
|
||||
const payload: any = {
|
||||
cascadeId,
|
||||
items: [{ chunk: { case: 'text', value: text } }],
|
||||
cascadeConfig: {
|
||||
plannerConfig: {
|
||||
plannerTypeConfig: {
|
||||
case: plannerType || 'conversational',
|
||||
value: {},
|
||||
},
|
||||
requestedModel: {
|
||||
choice: { case: 'model', value: model || Models.GEMINI_FLASH },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await this._rpc('SendUserCascadeMessage', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover LS port and CSRF token from the Language Server process.
|
||||
*
|
||||
* VERIFIED 2026-03-01 from Antigravity extension.js source:
|
||||
*
|
||||
* 1. CSRF header is "x-codeium-csrf-token" (NOT x-csrf-token)
|
||||
* 2. CSRF value is --csrf_token from CLI (NOT --extension_server_csrf_token)
|
||||
* 3. ConnectRPC endpoint is on httpsPort (HTTPS) or httpPort (HTTP)
|
||||
* These ports are NOT in CLI args (--random_port flag means random).
|
||||
* We discover them via netstat/PID, excluding extension_server_port.
|
||||
*
|
||||
* Source code proof:
|
||||
* n.header.set("x-codeium-csrf-token", e) // header name
|
||||
* address = `127.0.0.1:${te.httpsPort}` // ConnectRPC address
|
||||
* csrfToken = a = d.randomUUID() → --csrf_token // token source
|
||||
* t.headers["x-codeium-csrf-token"] === this.csrfToken ? ... : 403
|
||||
*
|
||||
* Discovery: 2 phases
|
||||
* Phase 1: Get-CimInstance/ps → PID, --csrf_token, --extension_server_port
|
||||
* Phase 2: netstat → find LISTENING ports for PID, exclude ext_server_port
|
||||
*/
|
||||
private async _discoverFromProcess(): Promise<{ port: number; csrfToken: string; useTls: boolean } | null> {
|
||||
try {
|
||||
const platform = process.platform;
|
||||
|
||||
// Phase 1: find LS process, extract PID, csrf_token, extension_server_port
|
||||
let processInfo = await this._findLSProcess(platform);
|
||||
if (!processInfo) {
|
||||
log.debug('No LS processes found');
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug(`LS process found: PID=${processInfo.pid}, csrf=present, ext_port=${processInfo.extPort}`);
|
||||
|
||||
// Phase 2: find actual ConnectRPC port via netstat
|
||||
const connectPort = await this._findConnectPort(platform, processInfo.pid, processInfo.extPort);
|
||||
if (!connectPort) {
|
||||
log.debug('Could not find ConnectRPC port via netstat, trying extension_server_port as fallback');
|
||||
// Fallback: try extension_server_port with HTTP
|
||||
if (processInfo.extPort) {
|
||||
return { port: processInfo.extPort, csrfToken: processInfo.csrfToken, useTls: false };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
port: connectPort.port,
|
||||
csrfToken: processInfo.csrfToken,
|
||||
useTls: connectPort.tls,
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
log.debug('Process discovery failed', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: Find the LS process for this workspace.
|
||||
*/
|
||||
private async _findLSProcess(
|
||||
platform: string,
|
||||
): Promise<{ pid: number; csrfToken: string; extPort: number } | null> {
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
let output: string;
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Use -EncodedCommand to avoid all PowerShell escaping issues with $_ and quotes
|
||||
const psScript = "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'language_server' -and $_.CommandLine -match 'csrf_token' } | ForEach-Object { $_.ProcessId.ToString() + '|' + $_.CommandLine }";
|
||||
const encoded = Buffer.from(psScript, 'utf16le').toString('base64');
|
||||
const result = await execAsync(
|
||||
`powershell.exe -NoProfile -EncodedCommand ${encoded}`,
|
||||
{ encoding: 'utf8', timeout: 10000, windowsHide: true },
|
||||
);
|
||||
output = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(
|
||||
'ps -eo pid,args 2>/dev/null | grep language_server | grep csrf_token | grep -v grep',
|
||||
{ encoding: 'utf8', timeout: 5000 },
|
||||
);
|
||||
output = result.stdout;
|
||||
}
|
||||
|
||||
const lines = output.split('\n').filter((l: string) => l.trim().length > 0);
|
||||
if (lines.length === 0) return null;
|
||||
|
||||
const workspaceHint = this._getWorkspaceHint();
|
||||
let bestLine: string | null = null;
|
||||
|
||||
if (workspaceHint) {
|
||||
for (const line of lines) {
|
||||
if (line.includes(workspaceHint)) {
|
||||
bestLine = line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bestLine) bestLine = lines[0];
|
||||
|
||||
// Extract PID (first field before | on Windows, first token on Unix)
|
||||
let pid: number;
|
||||
if (platform === 'win32') {
|
||||
pid = parseInt(bestLine.split('|')[0].trim(), 10);
|
||||
} else {
|
||||
pid = parseInt(bestLine.trim().split(/\s+/)[0], 10);
|
||||
}
|
||||
|
||||
const csrfToken = this._extractArg(bestLine, 'csrf_token');
|
||||
const extPortStr = this._extractArg(bestLine, 'extension_server_port');
|
||||
const extPort = extPortStr ? parseInt(extPortStr, 10) : 0;
|
||||
|
||||
if (!csrfToken || isNaN(pid)) return null;
|
||||
|
||||
return { pid, csrfToken, extPort };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: Find ConnectRPC port via netstat.
|
||||
*
|
||||
* The LS process listens on multiple ports:
|
||||
* - httpsPort (HTTPS, ConnectRPC) ← this is what we want
|
||||
* - httpPort (HTTP, ConnectRPC) ← also works
|
||||
* - lspPort (LSP JSON-RPC)
|
||||
* - extension_server_port is separate (for Extension Host IPC)
|
||||
*
|
||||
* We find all LISTENING ports for the LS PID, exclude ext_server_port,
|
||||
* then try HTTPS first (preferred), fall back to HTTP.
|
||||
*/
|
||||
private async _findConnectPort(
|
||||
platform: string,
|
||||
pid: number,
|
||||
extPort: number,
|
||||
): Promise<{ port: number; tls: boolean } | null> {
|
||||
try {
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
let output: string;
|
||||
|
||||
if (platform === 'win32') {
|
||||
const result = await execAsync(
|
||||
`netstat -aon | findstr "LISTENING" | findstr "${pid}"`,
|
||||
{ encoding: 'utf8', timeout: 5000, windowsHide: true },
|
||||
);
|
||||
output = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(
|
||||
`ss -tlnp 2>/dev/null | grep "pid=${pid}" || netstat -tlnp 2>/dev/null | grep "${pid}"`,
|
||||
{ encoding: 'utf8', timeout: 5000 },
|
||||
);
|
||||
output = result.stdout;
|
||||
}
|
||||
|
||||
// Extract all listening ports for this PID
|
||||
const portMatches = output.matchAll(/127\.0\.0\.1:(\d+)/g);
|
||||
const ports: number[] = [];
|
||||
for (const m of portMatches) {
|
||||
const p = parseInt(m[1], 10);
|
||||
// Exclude extension_server_port
|
||||
if (p !== extPort && !ports.includes(p)) {
|
||||
ports.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (ports.length === 0) return null;
|
||||
|
||||
log.debug(`LS ports (excl ext ${extPort}): ${ports.join(', ')}`);
|
||||
|
||||
// Try to identify httpsPort vs httpPort by probing
|
||||
// Strategy: try HTTPS first on each port (httpsPort is preferred)
|
||||
for (const port of ports) {
|
||||
const tls = await this._probePort(port, true);
|
||||
if (tls) return { port, tls: true };
|
||||
}
|
||||
|
||||
// Fallback: try HTTP
|
||||
for (const port of ports) {
|
||||
const http = await this._probePort(port, false);
|
||||
if (http) return { port, tls: false };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
log.debug('netstat port discovery failed', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick probe: check if a port accepts ConnectRPC requests.
|
||||
* Returns true if the port responds (even with error) on the given protocol.
|
||||
*/
|
||||
private _probePort(port: number, useTls: boolean): Promise<boolean> {
|
||||
const mod = useTls ? require('https') : require('http');
|
||||
const proto = useTls ? 'https' : 'http';
|
||||
return new Promise((resolve) => {
|
||||
const req = mod.request(`${proto}://127.0.0.1:${port}/exa.language_server_pb.LanguageServerService/GetUserStatus`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
|
||||
rejectUnauthorized: false,
|
||||
timeout: 2000,
|
||||
}, (res: any) => {
|
||||
// 401 = correct endpoint, just missing CSRF (expected)
|
||||
// 200 = also correct (unlikely without CSRF but possible)
|
||||
resolve(res.statusCode === 401 || res.statusCode === 200);
|
||||
});
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => { req.destroy(); resolve(false); });
|
||||
req.write('{}');
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a workspace hint string used to match the correct LS process.
|
||||
*
|
||||
* The LS process has --workspace_id like:
|
||||
* file_d_3A_programming_better_antigravity
|
||||
* which is an encoded version of the workspace URI.
|
||||
*/
|
||||
private _getWorkspaceHint(): string {
|
||||
try {
|
||||
const vscode = require('vscode');
|
||||
const folders = vscode.workspace?.workspaceFolders;
|
||||
if (folders && folders.length > 0) {
|
||||
// Convert workspace path to LS workspace_id format
|
||||
// e.g., "d:\programming\better-antigravity" -> "better_antigravity"
|
||||
// (LS uses underscored path segments)
|
||||
const folder = folders[0].uri.fsPath;
|
||||
const parts = folder.replace(/\\/g, '/').split('/');
|
||||
// Use last 2-3 segments for matching
|
||||
return parts.slice(-2).join('_').replace(/[-.\s]/g, '_').toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
// vscode not available (e.g., testing)
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a CLI argument value from a command-line string.
|
||||
* Supports both --key=value and --key value formats.
|
||||
*/
|
||||
private _extractArg(cmdLine: string, argName: string): string | null {
|
||||
// --argName=value
|
||||
const eqMatch = cmdLine.match(new RegExp(`--${argName}=([^\\s"]+)`));
|
||||
if (eqMatch) return eqMatch[1];
|
||||
|
||||
// --argName value
|
||||
const spaceMatch = cmdLine.match(new RegExp(`--${argName}\\s+([^\\s"]+)`));
|
||||
if (spaceMatch) return spaceMatch[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: discover port from getDiagnostics console logs.
|
||||
* NOTE: This does NOT discover the CSRF token.
|
||||
* In recent Antigravity versions, the port URL may no longer appear in logs.
|
||||
*/
|
||||
private async _discoverPortFromDiagnostics(): Promise<number | null> {
|
||||
try {
|
||||
const raw = await this._executeCommand<string>('antigravity.getDiagnostics');
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
const diag = JSON.parse(raw);
|
||||
|
||||
const logs: string = diag.agentWindowConsoleLogs || '';
|
||||
|
||||
// Pattern: 127.0.0.1:{port}/exa.language_server_pb
|
||||
const m1 = logs.match(/127\.0\.0\.1:(\d+)\/exa\.language_server_pb/);
|
||||
if (m1) return parseInt(m1[1], 10);
|
||||
|
||||
// Fallback: any 127.0.0.1:{port} in HTTPS context
|
||||
const m2 = logs.match(/https?:\/\/127\.0\.0\.1:(\d+)/);
|
||||
if (m2) return parseInt(m2[1], 10);
|
||||
|
||||
// Check mainThreadLogs for port info
|
||||
if (diag.mainThreadLogs) {
|
||||
const mainLogs = typeof diag.mainThreadLogs === 'string'
|
||||
? diag.mainThreadLogs
|
||||
: JSON.stringify(diag.mainThreadLogs);
|
||||
const m3 = mainLogs.match(/127\.0\.0\.1:(\d+)/);
|
||||
if (m3) return parseInt(m3[1], 10);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Failed to discover LS port from diagnostics', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated RPC call to the Language Server.
|
||||
* Sends x-csrf-token header when available.
|
||||
*
|
||||
* VERIFIED 2026-03-01:
|
||||
* - extension_server_port uses plain HTTP (no TLS)
|
||||
* - Main LS port (--random_port) uses HTTPS with self-signed cert
|
||||
*/
|
||||
private async _rpc(method: string, payload: any): Promise<any> {
|
||||
const httpModule = this._useTls ? require('https') : require('http');
|
||||
const protocol = this._useTls ? 'https' : 'http';
|
||||
const url = `${protocol}://127.0.0.1:${this._port}/exa.language_server_pb.LanguageServerService/${method}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify(payload);
|
||||
const headers: Record<string, string | number> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
|
||||
// CSRF header: "x-codeium-csrf-token" (verified from extension.js source)
|
||||
if (this._csrfToken) {
|
||||
headers['x-codeium-csrf-token'] = this._csrfToken;
|
||||
}
|
||||
|
||||
const reqOptions: any = {
|
||||
method: 'POST',
|
||||
headers,
|
||||
};
|
||||
|
||||
// Self-signed TLS when using HTTPS
|
||||
if (this._useTls) {
|
||||
reqOptions.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
const req = httpModule.request(url, reqOptions, (res: any) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk: string) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
try { resolve(JSON.parse(data)); }
|
||||
catch { resolve(data); }
|
||||
} else {
|
||||
const hint = res.statusCode === 401
|
||||
? ' (CSRF token may be invalid or missing -- try setConnection() with the correct token)'
|
||||
: '';
|
||||
reject(new Error(`LS ${method}: ${res.statusCode} -- ${data.substring(0, 200)}${hint}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (err: Error) => reject(err));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
518
antigravity-sdk-main/src/transport/state-bridge.ts
Normal file
518
antigravity-sdk-main/src/transport/state-bridge.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* State Bridge — reads Antigravity's USS state from the SQLite database.
|
||||
*
|
||||
* Antigravity stores settings, conversation metadata, and agent preferences
|
||||
* in `state.vscdb` (SQLite). This bridge provides read-only access to that data.
|
||||
*
|
||||
* VERIFIED against live state.vscdb on 2026-02-28.
|
||||
*
|
||||
* @module transport/state-bridge
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { IDisposable } from '../core/disposable';
|
||||
import { StateReadError } from '../core/errors';
|
||||
import { Logger } from '../core/logger';
|
||||
import type {
|
||||
IAgentPreferences,
|
||||
TerminalExecutionPolicy,
|
||||
ArtifactReviewPolicy,
|
||||
} from '../core/types';
|
||||
|
||||
const log = new Logger('StateBridge');
|
||||
|
||||
/**
|
||||
* USS (Unified State Sync) keys in state.vscdb.
|
||||
*
|
||||
* VERIFIED: All keys listed below were confirmed to exist
|
||||
* in a live Antigravity v1.107.0 installation on 2026-02-28.
|
||||
* Values are Base64-encoded protobuf unless noted otherwise.
|
||||
*/
|
||||
export const USSKeys = {
|
||||
/** Agent preferences — terminal policy, review policy, secure mode, etc. (1020 bytes) */
|
||||
AGENT_PREFERENCES: 'antigravityUnifiedStateSync.agentPreferences',
|
||||
|
||||
/** Conversation/trajectory summaries — titles, timestamps, workspace URIs (74KB+) */
|
||||
TRAJECTORY_SUMMARIES: 'antigravityUnifiedStateSync.trajectorySummaries',
|
||||
|
||||
/** Agent manager window state (192 bytes) */
|
||||
AGENT_MANAGER_WINDOW: 'antigravityUnifiedStateSync.agentManagerWindow',
|
||||
|
||||
/** Enterprise override store (56 bytes) */
|
||||
OVERRIDE_STORE: 'antigravityUnifiedStateSync.overrideStore',
|
||||
|
||||
/** Model preferences — selected model, sentinel key */
|
||||
MODEL_PREFERENCES: 'antigravityUnifiedStateSync.modelPreferences',
|
||||
|
||||
/** Artifact review state (1204 bytes) */
|
||||
ARTIFACT_REVIEW: 'antigravityUnifiedStateSync.artifactReview',
|
||||
|
||||
/** Browser preferences (380 bytes) */
|
||||
BROWSER_PREFERENCES: 'antigravityUnifiedStateSync.browserPreferences',
|
||||
|
||||
/** Editor preferences (108 bytes) */
|
||||
EDITOR_PREFERENCES: 'antigravityUnifiedStateSync.editorPreferences',
|
||||
|
||||
/** Tab preferences (404 bytes) */
|
||||
TAB_PREFERENCES: 'antigravityUnifiedStateSync.tabPreferences',
|
||||
|
||||
/** Window preferences (44 bytes) */
|
||||
WINDOW_PREFERENCES: 'antigravityUnifiedStateSync.windowPreferences',
|
||||
|
||||
/** Scratch/playground workspaces (268 bytes) */
|
||||
SCRATCH_WORKSPACES: 'antigravityUnifiedStateSync.scratchWorkspaces',
|
||||
|
||||
/** Sidebar workspaces — recent workspace list (5604 bytes) */
|
||||
SIDEBAR_WORKSPACES: 'antigravityUnifiedStateSync.sidebarWorkspaces',
|
||||
|
||||
/** User status info (5196 bytes) */
|
||||
USER_STATUS: 'antigravityUnifiedStateSync.userStatus',
|
||||
|
||||
/** Model credits/usage info */
|
||||
MODEL_CREDITS: 'antigravityUnifiedStateSync.modelCredits',
|
||||
|
||||
/** Onboarding state (140 bytes) */
|
||||
ONBOARDING: 'antigravityUnifiedStateSync.onboarding',
|
||||
|
||||
/** Seen NUX (new user experience) IDs (76 bytes) */
|
||||
SEEN_NUX_IDS: 'antigravityUnifiedStateSync.seenNuxIds',
|
||||
|
||||
// ⚠️ Jetski-specific state (separate sync namespace)
|
||||
/** Agent manager initialization state — contains auth tokens, workspace map (5144 bytes) */
|
||||
AGENT_MANAGER_INIT: 'jetskiStateSync.agentManagerInitState',
|
||||
|
||||
// ⚠️ Non-USS but relevant keys
|
||||
/** All user settings — JSON format */
|
||||
ALL_USER_SETTINGS: 'antigravityUserSettings.allUserSettings',
|
||||
|
||||
/** Allowed model configs for commands */
|
||||
ALLOWED_COMMAND_MODEL_CONFIGS: 'antigravity_allowed_command_model_configs',
|
||||
|
||||
/** Chat session store index (JSON: {"version":1,"entries":{}}) */
|
||||
CHAT_SESSION_INDEX: 'chat.ChatSessionStore.index',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Keys that contain sensitive data and MUST NOT be exposed through the SDK.
|
||||
*
|
||||
* VERIFIED 2026-02-28:
|
||||
* - oauthToken: OAuth access token (732 bytes)
|
||||
* - agentManagerInitState: Contains LIVE ya29.* access token + g1//* refresh token!
|
||||
* - antigravityAuthStatus: Auth status
|
||||
*/
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'antigravityUnifiedStateSync.oauthToken',
|
||||
'jetskiStateSync.agentManagerInitState',
|
||||
'antigravityAuthStatus',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Protobuf sentinel keys found in agentPreferences.
|
||||
*
|
||||
* ALL 16 sentinel keys verified from live state.vscdb on 2026-02-28.
|
||||
* Each sentinel key string is followed by a small Base64 value encoding
|
||||
* a protobuf varint (the actual preference value).
|
||||
*/
|
||||
const SENTINEL_KEYS = {
|
||||
PLANNING_MODE: 'planningModeSentinelKey',
|
||||
ARTIFACT_REVIEW_POLICY: 'artifactReviewPolicySentinelKey',
|
||||
TERMINAL_AUTO_EXECUTION_POLICY: 'terminalAutoExecutionPolicySentinelKey',
|
||||
TERMINAL_ALLOWED_COMMANDS: 'terminalAllowedCommandsSentinelKey',
|
||||
TERMINAL_DENIED_COMMANDS: 'terminalDeniedCommandsSentinelKey',
|
||||
ALLOW_NON_WORKSPACE_FILES: 'allowAgentAccessNonWorkspaceFilesSentinelKey',
|
||||
ALLOW_GITIGNORE_ACCESS: 'allowCascadeAccessGitignoreFilesSentinelKey',
|
||||
SECURE_MODE: 'secureModeSentinelKey',
|
||||
EXPLAIN_FIX_IN_CONVO: 'explainAndFixInCurrentConversationSentinelKey',
|
||||
AUTO_CONTINUE_ON_MAX: 'autoContinueOnMaxGeneratorInvocationsSentinelKey',
|
||||
DISABLE_AUTO_OPEN_EDITED: 'disableAutoOpenEditedFilesSentinelKey',
|
||||
ENABLE_SOUNDS: 'enableSoundsForSpecialEventsSentinelKey',
|
||||
DISABLE_AUTO_FIX_LINTS: 'disableCascadeAutoFixLintsSentinelKey',
|
||||
ENABLE_SHELL_INTEGRATION: 'enableShellIntegrationSentinelKey',
|
||||
SANDBOX_ALLOW_NETWORK: 'sandboxAllowNetworkSentinelKey',
|
||||
ENABLE_TERMINAL_SANDBOX: 'enableTerminalSandboxSentinelKey',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Reads Antigravity's internal state from the SQLite database.
|
||||
*
|
||||
* Uses **sql.js** (pure JavaScript SQLite, compiled to WASM) which is
|
||||
* verified to work in Antigravity's Extension Host (unlike better-sqlite3
|
||||
* which fails due to ABI mismatch with Electron v22.21.1 / ABI v140).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const bridge = new StateBridge();
|
||||
* await bridge.initialize();
|
||||
*
|
||||
* const prefs = await bridge.getAgentPreferences();
|
||||
* console.log(prefs.terminalExecutionPolicy);
|
||||
* ```
|
||||
*/
|
||||
export class StateBridge implements IDisposable {
|
||||
private _dbPath: string | null = null;
|
||||
private _db: any = null; // sql.js Database instance
|
||||
private _disposed = false;
|
||||
|
||||
/**
|
||||
* Initialize the state bridge by locating and opening state database.
|
||||
*
|
||||
* @throws {StateReadError} If the database cannot be found
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const dbPath = this._findStateDb();
|
||||
|
||||
if (!dbPath) {
|
||||
throw new StateReadError('state.vscdb', 'Could not locate Antigravity state database');
|
||||
}
|
||||
|
||||
this._dbPath = dbPath;
|
||||
|
||||
// Open with sql.js (pure JS — verified working in Extension Host)
|
||||
try {
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Try to load sql.js from multiple locations:
|
||||
// 1. Adjacent sql-wasm.js (for VSIX bundles where consumer copies it to dist/)
|
||||
// 2. Standard require('sql.js') (for npm install / dev setups)
|
||||
let initSqlJs: any;
|
||||
const localSqlJs = path.join(__dirname, 'sql-wasm.js');
|
||||
if (fs.existsSync(localSqlJs)) {
|
||||
initSqlJs = require(localSqlJs);
|
||||
} else {
|
||||
initSqlJs = require('sql.js');
|
||||
}
|
||||
|
||||
// Auto-locate sql-wasm.wasm — try multiple paths so devs
|
||||
// don't need to manually copy anything after `npm install`
|
||||
const candidates = [
|
||||
// 1. Adjacent to this file (if wasm was bundled/copied to dist/)
|
||||
path.join(__dirname, 'sql-wasm.wasm'),
|
||||
// 2. sql.js package dist/ (standard npm install)
|
||||
path.resolve(__dirname, '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
|
||||
// 3. Hoisted node_modules (monorepo / npm workspaces)
|
||||
path.resolve(__dirname, '..', '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
|
||||
// 4. Walk up to find it (deep hoisting)
|
||||
path.resolve(__dirname, '..', '..', '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
|
||||
];
|
||||
|
||||
// Try require.resolve — works in all layouts
|
||||
try {
|
||||
const sqlJsMain = require.resolve('sql.js');
|
||||
candidates.unshift(path.join(path.dirname(sqlJsMain), 'sql-wasm.wasm'));
|
||||
} catch {
|
||||
// sql.js might not have a resolvable main in all setups
|
||||
}
|
||||
|
||||
let wasmPath: string | null = null;
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
wasmPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasmPath) {
|
||||
throw new Error('sql-wasm.wasm not found in any expected location');
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs({
|
||||
locateFile: () => wasmPath!,
|
||||
});
|
||||
const fileBuffer = fs.readFileSync(dbPath);
|
||||
this._db = new SQL.Database(fileBuffer);
|
||||
log.info(`State database opened via sql.js: ${dbPath}`);
|
||||
} catch (error) {
|
||||
log.warn('sql.js not available, will use child_process fallback', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a raw value from the state database.
|
||||
*
|
||||
* @param key - The SQLite key to read
|
||||
* @returns The raw string value, or null if not found
|
||||
* @throws {StateReadError} If the key is sensitive or read fails
|
||||
*/
|
||||
async getRawValue(key: string): Promise<string | null> {
|
||||
if (this._disposed) {
|
||||
throw new StateReadError(key, 'StateBridge has been disposed');
|
||||
}
|
||||
|
||||
if (!this._dbPath) {
|
||||
throw new StateReadError(key, 'StateBridge not initialized');
|
||||
}
|
||||
|
||||
// Block access to sensitive keys
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
throw new StateReadError(key, 'Access to sensitive keys is blocked by the SDK for security');
|
||||
}
|
||||
|
||||
try {
|
||||
if (this._db) {
|
||||
return this._querySqlJs(key);
|
||||
}
|
||||
return await this._queryChildProcess(key);
|
||||
} catch (error) {
|
||||
if (error instanceof StateReadError) throw error;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
throw new StateReadError(key, msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent preferences from USS.
|
||||
*
|
||||
* @returns Parsed agent preferences
|
||||
*/
|
||||
async getAgentPreferences(): Promise<IAgentPreferences> {
|
||||
const raw = await this.getRawValue(USSKeys.AGENT_PREFERENCES);
|
||||
|
||||
if (!raw) {
|
||||
log.warn('No agent preferences found, returning defaults');
|
||||
return this._defaultPreferences();
|
||||
}
|
||||
|
||||
try {
|
||||
return this._parseAgentPreferences(raw);
|
||||
} catch (error) {
|
||||
log.error('Failed to parse preferences, returning defaults', error);
|
||||
return this._defaultPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored USS keys from the state database.
|
||||
*
|
||||
* @returns List of key names related to Antigravity (excludes sensitive keys)
|
||||
*/
|
||||
async getAntigravityKeys(): Promise<string[]> {
|
||||
if (!this._dbPath) {
|
||||
throw new StateReadError('*', 'StateBridge not initialized');
|
||||
}
|
||||
|
||||
let keys: string[];
|
||||
|
||||
if (this._db) {
|
||||
const result = this._db.exec(
|
||||
"SELECT key FROM ItemTable WHERE key LIKE '%antigravity%' OR key LIKE '%jetskiStateSync%' OR key LIKE 'chat.%'",
|
||||
);
|
||||
keys = result.length > 0 ? result[0].values.map((r: any[]) => r[0] as string) : [];
|
||||
} else {
|
||||
const result = await this._queryChildProcess('*');
|
||||
keys = result ? result.split('\n').map((l: string) => l.trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
// Filter out sensitive keys
|
||||
return keys.filter((k) => !SENSITIVE_KEYS.has(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query using sql.js (in-process, pure JS).
|
||||
*/
|
||||
private _querySqlJs(key: string): string | null {
|
||||
const stmt = this._db.prepare('SELECT value FROM ItemTable WHERE key = $key');
|
||||
stmt.bind({ $key: key });
|
||||
if (stmt.step()) {
|
||||
const row = stmt.getAsObject();
|
||||
stmt.free();
|
||||
return (row.value as string) ?? null;
|
||||
}
|
||||
stmt.free();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query using child_process sqlite3 CLI (fallback).
|
||||
*/
|
||||
private async _queryChildProcess(key: string): Promise<string | null> {
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
const sql =
|
||||
key === '*'
|
||||
? "SELECT key FROM ItemTable WHERE key LIKE '%antigravity%' OR key LIKE '%jetskiStateSync%'"
|
||||
: `SELECT value FROM ItemTable WHERE key = '${key.replace(/'/g, "''")}'`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(`sqlite3 "${this._dbPath}" "${sql}"`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the state.vscdb file across platforms.
|
||||
*/
|
||||
private _findStateDb(): string | null {
|
||||
const candidates: string[] = [];
|
||||
|
||||
// Windows (VERIFIED: this is the correct path)
|
||||
const appData = process.env.APPDATA;
|
||||
if (appData) {
|
||||
candidates.push(path.join(appData, 'Antigravity', 'User', 'globalStorage', 'state.vscdb'));
|
||||
}
|
||||
|
||||
// macOS
|
||||
const home = process.env.HOME;
|
||||
if (home) {
|
||||
candidates.push(
|
||||
path.join(
|
||||
home,
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Antigravity',
|
||||
'User',
|
||||
'globalStorage',
|
||||
'state.vscdb',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Linux
|
||||
if (home) {
|
||||
candidates.push(
|
||||
path.join(home, '.config', 'Antigravity', 'User', 'globalStorage', 'state.vscdb'),
|
||||
);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse agent preferences from Base64(Protobuf).
|
||||
*
|
||||
* The protobuf structure uses "sentinel keys" as string fields:
|
||||
* - `planningModeSentinelKey` → nested message with Base64(varint)
|
||||
* - `terminalAutoExecutionPolicySentinelKey` → nested message with Base64(varint)
|
||||
* - `artifactReviewPolicySentinelKey` → nested message with Base64(varint)
|
||||
*
|
||||
* Each sentinel value is itself a small Base64 string (e.g., "EAM=" = varint 3 = EAGER).
|
||||
*/
|
||||
private _parseAgentPreferences(raw: string): IAgentPreferences {
|
||||
const buffer = Buffer.from(raw, 'base64');
|
||||
const text = buffer.toString('utf8');
|
||||
|
||||
// Extract all sentinel values
|
||||
const terminalPolicy = this._extractSentinelValue(text, SENTINEL_KEYS.TERMINAL_AUTO_EXECUTION_POLICY);
|
||||
const artifactPolicy = this._extractSentinelValue(text, SENTINEL_KEYS.ARTIFACT_REVIEW_POLICY);
|
||||
const planningMode = this._extractSentinelValue(text, SENTINEL_KEYS.PLANNING_MODE);
|
||||
const secureMode = this._extractSentinelValue(text, SENTINEL_KEYS.SECURE_MODE);
|
||||
const terminalSandbox = this._extractSentinelValue(text, SENTINEL_KEYS.ENABLE_TERMINAL_SANDBOX);
|
||||
const sandboxNetwork = this._extractSentinelValue(text, SENTINEL_KEYS.SANDBOX_ALLOW_NETWORK);
|
||||
const shellIntegration = this._extractSentinelValue(text, SENTINEL_KEYS.ENABLE_SHELL_INTEGRATION);
|
||||
const nonWorkspaceFiles = this._extractSentinelValue(text, SENTINEL_KEYS.ALLOW_NON_WORKSPACE_FILES);
|
||||
const gitignoreAccess = this._extractSentinelValue(text, SENTINEL_KEYS.ALLOW_GITIGNORE_ACCESS);
|
||||
const explainFix = this._extractSentinelValue(text, SENTINEL_KEYS.EXPLAIN_FIX_IN_CONVO);
|
||||
const autoContinue = this._extractSentinelValue(text, SENTINEL_KEYS.AUTO_CONTINUE_ON_MAX);
|
||||
const disableAutoOpen = this._extractSentinelValue(text, SENTINEL_KEYS.DISABLE_AUTO_OPEN_EDITED);
|
||||
const enableSounds = this._extractSentinelValue(text, SENTINEL_KEYS.ENABLE_SOUNDS);
|
||||
const disableAutoFix = this._extractSentinelValue(text, SENTINEL_KEYS.DISABLE_AUTO_FIX_LINTS);
|
||||
|
||||
return {
|
||||
terminalExecutionPolicy: (terminalPolicy ?? 1) as TerminalExecutionPolicy,
|
||||
artifactReviewPolicy: (artifactPolicy ?? 1) as ArtifactReviewPolicy,
|
||||
planningMode: planningMode ?? 0,
|
||||
secureModeEnabled: (secureMode ?? 0) === 1,
|
||||
terminalSandboxEnabled: (terminalSandbox ?? 0) === 1,
|
||||
sandboxAllowNetwork: (sandboxNetwork ?? 0) === 1,
|
||||
shellIntegrationEnabled: (shellIntegration ?? 1) === 1,
|
||||
allowNonWorkspaceFiles: (nonWorkspaceFiles ?? 0) === 1,
|
||||
allowGitignoreAccess: (gitignoreAccess ?? 0) === 1,
|
||||
explainFixInCurrentConvo: (explainFix ?? 0) === 1,
|
||||
autoContinueOnMax: autoContinue ?? 0,
|
||||
disableAutoOpenEdited: (disableAutoOpen ?? 0) === 1,
|
||||
enableSounds: (enableSounds ?? 0) === 1,
|
||||
disableAutoFixLints: (disableAutoFix ?? 0) === 1,
|
||||
allowedCommands: [],
|
||||
deniedCommands: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a varint value from a protobuf sentinel key.
|
||||
*
|
||||
* The structure is: sentinel_key_string followed by a small
|
||||
* Base64 value like "EAM=" (which decodes to a protobuf varint).
|
||||
*
|
||||
* Known mappings:
|
||||
* - "CAE=" → field 1, value 1 (OFF / ALWAYS)
|
||||
* - "EAI=" → field 2, value 2 (AUTO / TURBO)
|
||||
* - "EAM=" → field 2, value 3 (EAGER / AUTO)
|
||||
*/
|
||||
private _extractSentinelValue(text: string, sentinelKey: string): number | null {
|
||||
const idx = text.indexOf(sentinelKey);
|
||||
if (idx === -1) return null;
|
||||
|
||||
// After the sentinel key, look for a small Base64 fragment
|
||||
const after = text.substring(idx + sentinelKey.length, idx + sentinelKey.length + 30);
|
||||
|
||||
// Match a Base64 chunk (typically 4-8 chars ending with =)
|
||||
const b64Match = after.match(/([A-Za-z0-9+/]{2,8}={0,2})/);
|
||||
if (!b64Match) return null;
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(b64Match[1], 'base64');
|
||||
// Protobuf varint: last byte of the value
|
||||
// For simple single-byte varints, the value is in the lower 7 bits
|
||||
if (decoded.length >= 2) {
|
||||
// The first byte is (field_number << 3 | wire_type)
|
||||
// The second byte is the actual value
|
||||
return decoded[1];
|
||||
} else if (decoded.length === 1) {
|
||||
return decoded[0];
|
||||
}
|
||||
} catch {
|
||||
// Not valid base64
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private _defaultPreferences(): IAgentPreferences {
|
||||
return {
|
||||
terminalExecutionPolicy: 1 as TerminalExecutionPolicy, // OFF
|
||||
artifactReviewPolicy: 1 as ArtifactReviewPolicy, // ALWAYS
|
||||
planningMode: 0,
|
||||
secureModeEnabled: false,
|
||||
terminalSandboxEnabled: false,
|
||||
sandboxAllowNetwork: false,
|
||||
shellIntegrationEnabled: true,
|
||||
allowNonWorkspaceFiles: false,
|
||||
allowGitignoreAccess: false,
|
||||
explainFixInCurrentConvo: false,
|
||||
autoContinueOnMax: 0,
|
||||
disableAutoOpenEdited: false,
|
||||
enableSounds: false,
|
||||
disableAutoFixLints: false,
|
||||
allowedCommands: [],
|
||||
deniedCommands: [],
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._disposed = true;
|
||||
|
||||
if (this._db) {
|
||||
try {
|
||||
this._db.close();
|
||||
} catch {
|
||||
// Ignore close errors
|
||||
}
|
||||
this._db = null;
|
||||
}
|
||||
|
||||
this._dbPath = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user