Decoded HandleCascadeUserInteractionRequest protobuf schema from AG's extension.js (message #162, base64 FileDescriptor 78KB). Working payload (variant PROTO-0): cascadeId + interaction.{trajectoryId, stepIndex, runCommand.confirm} Changes: - extension.ts: Added Strategy 0-PROTO with decoded proto RPC call - extension.ts: Fixed processResponseFile to call tryApprovalStrategies() instead of direct clickTrigger (was bypassing all strategies) - extension.ts: Fixed false positive Run detection (sessionStalled reset when step_probe confirms no WAITING) - extension.ts: Moved lastPendingStepIndex to module scope - extension.ts: Added activeTrajectoryId tracking from session init - bot.py: Added MERGE detection + Discord message edit for command updates - bot.py: Added _sent_commands tracking for merge detection Proto RE methodology: 1. Found schema exports in AG extension.js 2. Located fileDesc() with base64 protobuf descriptor 3. Decoded 58KB raw proto, found message names 4. Extracted CascadeRunCommandInteraction.confirm field 5. Tested camelCase JSON via ConnectRPC = SUCCESS
2413 lines
115 KiB
JavaScript
2413 lines
115 KiB
JavaScript
"use strict";
|
|
/**
|
|
* Gravity Bridge — VS Code Extension (SDK Edition)
|
|
*
|
|
* Uses antigravity-sdk for:
|
|
* - Real-time step/conversation monitoring via EventMonitor
|
|
* - Full conversation content via LSBridge.getConversation()
|
|
* - Message sending via CascadeManager.sendPrompt()
|
|
* - Accept/Reject via CascadeManager.acceptStep()/rejectStep()
|
|
*
|
|
* Communication with Discord via file-based bridge protocol.
|
|
*/
|
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
}
|
|
Object.defineProperty(o, k2, desc);
|
|
}) : (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
o[k2] = m[k];
|
|
}));
|
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
}) : function(o, v) {
|
|
o["default"] = v;
|
|
});
|
|
var __importStar = (this && this.__importStar) || (function () {
|
|
var ownKeys = function(o) {
|
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
var ar = [];
|
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
return ar;
|
|
};
|
|
return ownKeys(o);
|
|
};
|
|
return function (mod) {
|
|
if (mod && mod.__esModule) return mod;
|
|
var result = {};
|
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
__setModuleDefault(result, mod);
|
|
return result;
|
|
};
|
|
})();
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.activate = activate;
|
|
exports.deactivate = deactivate;
|
|
const vscode = __importStar(require("vscode"));
|
|
const fs = __importStar(require("fs"));
|
|
const path = __importStar(require("path"));
|
|
const os = __importStar(require("os"));
|
|
const cp = __importStar(require("child_process"));
|
|
const crypto = __importStar(require("crypto"));
|
|
// ─── File-based logging (AI can read directly) ───
|
|
function logToFile(msg) {
|
|
const ts = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
|
const line = `${ts} ${msg}`;
|
|
console.log(`Gravity Bridge: ${msg}`);
|
|
try {
|
|
if (!bridgePath)
|
|
return;
|
|
const logFile = path.join(bridgePath, 'extension.log');
|
|
fs.appendFileSync(logFile, line + '\n', 'utf-8');
|
|
}
|
|
catch (e) {
|
|
console.error(`Gravity Bridge LOG WRITE FAIL: ${e.message}`);
|
|
}
|
|
}
|
|
// antigravity-sdk embedded locally (src/sdk/)
|
|
let AntigravitySDK;
|
|
let sdk;
|
|
let statusBar;
|
|
let bridgePath;
|
|
let projectName;
|
|
let isActive = false;
|
|
let deterministicPort = 0; // derived from projectName, consistent across restarts
|
|
let watcher = null;
|
|
let commandsWatcher = null;
|
|
const sentPendingIds = new Set();
|
|
// ─── Project Detection ───
|
|
function detectProjectName() {
|
|
const config = vscode.workspace.getConfiguration('gravityBridge');
|
|
const configName = config.get('projectName');
|
|
if (configName) {
|
|
return configName;
|
|
}
|
|
const folders = vscode.workspace.workspaceFolders;
|
|
if (folders && folders.length > 0) {
|
|
const cwd = folders[0].uri.fsPath;
|
|
try {
|
|
const remoteUrl = cp.execSync('git remote get-url origin', {
|
|
cwd, encoding: 'utf-8', timeout: 3000
|
|
}).trim();
|
|
const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
|
|
if (match && match[1]) {
|
|
return match[1].toLowerCase().replace(/[\s\-]+/g, '_');
|
|
}
|
|
}
|
|
catch { }
|
|
return path.basename(cwd).toLowerCase().replace(/[\s\-]+/g, '_');
|
|
}
|
|
return 'default';
|
|
}
|
|
// ─── Bridge File I/O ───
|
|
function ensureBridgeDir() {
|
|
const dirs = ['', 'response', 'commands', 'chat_snapshots'];
|
|
for (const d of dirs) {
|
|
const p = path.join(bridgePath, d);
|
|
if (!fs.existsSync(p)) {
|
|
fs.mkdirSync(p, { recursive: true });
|
|
}
|
|
}
|
|
}
|
|
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
|
|
let activeSessionId = '';
|
|
let activeTrajectoryId = '';
|
|
function writeChatSnapshot(text) {
|
|
try {
|
|
// Write to chat_snapshots/*.json for Bot's chat_snapshot_scanner to pick up
|
|
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
|
|
if (!fs.existsSync(snapshotDir)) {
|
|
fs.mkdirSync(snapshotDir, { recursive: true });
|
|
}
|
|
const id = Date.now().toString();
|
|
const data = {
|
|
id,
|
|
project_name: projectName,
|
|
content: text,
|
|
timestamp: Date.now() / 1000,
|
|
};
|
|
const filePath = path.join(snapshotDir, `${id}.json`);
|
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`);
|
|
// Lazily register session → project mapping (correct because projectName is per-window)
|
|
if (activeSessionId) {
|
|
writeRegistration(activeSessionId);
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.log(`Gravity Bridge: snapshot write error: ${e.message}`);
|
|
}
|
|
}
|
|
// ─── Command File Watcher (Discord → Antigravity) ───
|
|
function processCommandFile(filePath) {
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const cmd = JSON.parse(content);
|
|
// Skip already consumed commands
|
|
if (cmd.consumed) {
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
catch { }
|
|
return;
|
|
}
|
|
// Ignore commands for other projects
|
|
if (cmd.project_name && cmd.project_name !== projectName) {
|
|
console.log(`Gravity Bridge: skipping command for "${cmd.project_name}" (we are "${projectName}")`);
|
|
return;
|
|
}
|
|
// Bot writes 'text' field, not 'message'
|
|
const text = cmd.text || cmd.message || '';
|
|
const action = cmd.action || '';
|
|
console.log(`Gravity Bridge: command — text="${text}" action="${action}"`);
|
|
if (action === 'approve' && sdk) {
|
|
sdk.cascade.acceptStep().catch((e) => console.log(`Gravity Bridge: approve error: ${e.message}`));
|
|
}
|
|
else if (action === 'reject' && sdk) {
|
|
sdk.cascade.rejectStep().catch((e) => console.log(`Gravity Bridge: reject error: ${e.message}`));
|
|
}
|
|
else if (action === 'approve_terminal' && sdk) {
|
|
sdk.cascade.acceptTerminalCommand().catch((e) => console.log(`Gravity Bridge: approve_terminal error: ${e.message}`));
|
|
}
|
|
else if (text === '!stop') {
|
|
// Cancel current operation
|
|
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
|
|
.then(() => console.log('Gravity Bridge: ✅ stop sent'), () => { });
|
|
}
|
|
else if (text.startsWith('!auto ')) {
|
|
// Auto-approve mode toggle
|
|
const mode = text.includes('on') ? 'true' : 'false';
|
|
console.log(`Gravity Bridge: auto-approve → ${mode}`);
|
|
}
|
|
else if (text) {
|
|
// Send message to Antigravity — use VS Code command (most reliable)
|
|
vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', text)
|
|
.then(() => console.log(`Gravity Bridge: ✅ sent "${text.substring(0, 50)}" via sendPromptToAgentPanel`), (e) => console.log(`Gravity Bridge: sendPrompt failed: ${e.message}`));
|
|
}
|
|
// Remove processed command file
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
catch { }
|
|
}
|
|
catch (e) {
|
|
console.log(`Gravity Bridge: command processing error: ${e.message}`);
|
|
}
|
|
}
|
|
function watchCommandsDir() {
|
|
const cmdDir = path.join(bridgePath, 'commands');
|
|
// Process existing files
|
|
try {
|
|
for (const f of fs.readdirSync(cmdDir)) {
|
|
if (f.endsWith('.json')) {
|
|
processCommandFile(path.join(cmdDir, f));
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
// Watch for new files
|
|
try {
|
|
commandsWatcher = fs.watch(cmdDir, (event, filename) => {
|
|
if (filename && filename.endsWith('.json') && event === 'rename') {
|
|
const fp = path.join(cmdDir, filename);
|
|
if (fs.existsSync(fp)) {
|
|
setTimeout(() => processCommandFile(fp), 200);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
catch { }
|
|
}
|
|
// ─── SDK Integration ───
|
|
async function initSDK(context) {
|
|
try {
|
|
const sdkModule = require('./sdk/index');
|
|
AntigravitySDK = sdkModule.AntigravitySDK;
|
|
}
|
|
catch (err) {
|
|
console.log(`Gravity Bridge: antigravity-sdk load failed: ${err.message}`);
|
|
return false;
|
|
}
|
|
try {
|
|
sdk = new AntigravitySDK(context);
|
|
await sdk.initialize();
|
|
console.log('Gravity Bridge: ✅ SDK initialized');
|
|
return true;
|
|
}
|
|
catch (err) {
|
|
console.log(`Gravity Bridge: SDK init failed: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
// ─── Approval Observer via SDK IntegrationManager ───
|
|
async function setupApprovalObserver() {
|
|
if (!sdk) {
|
|
logToFile('[OBSERVER] no SDK');
|
|
return;
|
|
}
|
|
try {
|
|
const integration = sdk.integration;
|
|
if (!integration) {
|
|
logToFile('[OBSERVER] sdk.integration unavailable');
|
|
return;
|
|
}
|
|
// 1. Start HTTP bridge server in Extension Host
|
|
const bridgePort = await startObserverHttpBridge();
|
|
if (!bridgePort) {
|
|
logToFile('[OBSERVER] HTTP bridge failed');
|
|
return;
|
|
}
|
|
// 2. Register a TOP_BAR button so build() works
|
|
try {
|
|
integration.register({
|
|
id: 'gravity_bridge_status',
|
|
point: 'topBar',
|
|
icon: '🌉',
|
|
tooltip: 'Gravity Bridge Active',
|
|
});
|
|
}
|
|
catch { /* already registered */ }
|
|
// 3. Write renderer script with HTTP fetch() approach
|
|
const observerJS = generateApprovalObserverScript(bridgePort);
|
|
const patcher = integration._patcher;
|
|
if (patcher && typeof patcher.getScriptPath === 'function') {
|
|
let baseScript = '';
|
|
try {
|
|
baseScript = integration.build();
|
|
}
|
|
catch {
|
|
baseScript = '';
|
|
}
|
|
const combinedScript = baseScript + '\n' + observerJS;
|
|
const scriptPath = patcher.getScriptPath();
|
|
fs.writeFileSync(scriptPath, combinedScript, 'utf8');
|
|
logToFile(`[OBSERVER] script written → ${scriptPath} (port=${bridgePort})`);
|
|
if (!integration.isInstalled()) {
|
|
patcher.install(combinedScript);
|
|
logToFile('[OBSERVER] patcher.install() called (needs reload)');
|
|
}
|
|
// Patch BOTH HTML files with inline script injection.
|
|
// CRITICAL: vscode-file:// does NOT serve custom .js files (silent 404),
|
|
// so we MUST inline the script directly into BOTH HTML files.
|
|
// workbench.html — loaded by DevTools/standard mode
|
|
// workbench-jetski-agent.html — loaded by AG agent mode
|
|
const scriptDir = path.dirname(scriptPath);
|
|
const htmlFiles = ['workbench.html', 'workbench-jetski-agent.html'];
|
|
for (const htmlFileName of htmlFiles) {
|
|
const htmlPath = path.join(scriptDir, htmlFileName);
|
|
try {
|
|
if (!fs.existsSync(htmlPath)) {
|
|
logToFile(`[OBSERVER] ${htmlFileName} not found — skipping`);
|
|
continue;
|
|
}
|
|
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
// CRITICAL: Patch CSP to allow inline scripts.
|
|
// Default CSP has script-src 'self' 'unsafe-eval' blob: — NO 'unsafe-inline'.
|
|
// Without 'unsafe-inline', all inline <script> tags are silently blocked.
|
|
if (html.includes('script-src') && !html.match(/script-src[^;]*'unsafe-inline'/)) {
|
|
html = html.replace(/(script-src\s[^;]*?)('self')/, "$1$2\n\t\t\t\t\t'unsafe-inline'");
|
|
logToFile(`[OBSERVER] ${htmlFileName} CSP patched: added 'unsafe-inline' to script-src`);
|
|
}
|
|
// Remove old external script tag if present (legacy, cannot be served)
|
|
const extMarkerStart = '<!-- AG SDK [variet-gravity-bridge] -->';
|
|
const extMarkerEnd = '<!-- /AG SDK [variet-gravity-bridge] -->';
|
|
if (html.includes(extMarkerStart)) {
|
|
const extRe = new RegExp('\\n?' + extMarkerStart.replace(/[[\]]/g, '\\$&') +
|
|
'[\\s\\S]*?' +
|
|
extMarkerEnd.replace(/[[\]]/g, '\\$&') + '\\n?');
|
|
html = html.replace(extRe, '');
|
|
logToFile(`[OBSERVER] removed external script tag from ${htmlFileName}`);
|
|
}
|
|
// Insert or update inline script
|
|
const inlineMarkerStart = '<!-- AG SDK INLINE [variet-gravity-bridge] -->';
|
|
const inlineMarkerEnd = '<!-- /AG SDK INLINE [variet-gravity-bridge] -->';
|
|
if (html.includes(inlineMarkerStart)) {
|
|
const re = new RegExp(inlineMarkerStart.replace(/[[\]]/g, '\\$&') +
|
|
'[\\s\\S]*?' +
|
|
inlineMarkerEnd.replace(/[[\]]/g, '\\$&'));
|
|
html = html.replace(re, `${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}`);
|
|
logToFile(`[OBSERVER] ${htmlFileName} inline script UPDATED`);
|
|
}
|
|
else {
|
|
html = html.replace('</html>', `\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
|
|
logToFile(`[OBSERVER] ${htmlFileName} inline script INSERTED`);
|
|
}
|
|
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
}
|
|
catch (e) {
|
|
logToFile(`[OBSERVER] ${htmlFileName} patch error: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
// 4. Update product.json checksums so vscode-file:// serves our patched files
|
|
updateProductChecksums();
|
|
try {
|
|
integration.enableAutoRepair();
|
|
}
|
|
catch { }
|
|
setInterval(() => { try {
|
|
integration.signalActive();
|
|
}
|
|
catch { } }, 30_000);
|
|
logToFile(`[OBSERVER] setup complete (HTTP bridge on port ${bridgePort})`);
|
|
console.log(`Gravity Bridge: ✅ Approval observer installed (port ${bridgePort})`);
|
|
}
|
|
catch (err) {
|
|
logToFile(`[OBSERVER] setup error: ${err.message}`);
|
|
}
|
|
}
|
|
// ─── Product.json Checksum Auto-Update ───
|
|
// vscode-file:// protocol validates SHA256 checksums in product.json.
|
|
// If a file's checksum doesn't match, Electron serves the ORIGINAL cached version.
|
|
// This function recalculates checksums for files we modify (HTML files with <script> tags).
|
|
function updateProductChecksums() {
|
|
try {
|
|
// Find product.json (2 levels up from workbench dir: resources/app/product.json)
|
|
const patcher = sdk?.integration?._patcher;
|
|
if (!patcher || typeof patcher.getWorkbenchDir !== 'function') {
|
|
logToFile('[CHECKSUM] no patcher/workbenchDir — skipping');
|
|
return;
|
|
}
|
|
const workbenchDir = patcher.getWorkbenchDir();
|
|
// workbenchDir = .../resources/app/out/vs/code/electron-browser/workbench
|
|
// product.json = .../resources/app/product.json (5 levels up from workbench)
|
|
const appDir = path.resolve(workbenchDir, '..', '..', '..', '..', '..');
|
|
const productJsonPath = path.join(appDir, 'product.json');
|
|
if (!fs.existsSync(productJsonPath)) {
|
|
logToFile(`[CHECKSUM] product.json not found at ${productJsonPath}`);
|
|
return;
|
|
}
|
|
// Read product.json (may have BOM)
|
|
let raw = fs.readFileSync(productJsonPath, 'utf8');
|
|
if (raw.charCodeAt(0) === 0xFEFF)
|
|
raw = raw.substring(1);
|
|
const product = JSON.parse(raw);
|
|
if (!product.checksums) {
|
|
logToFile('[CHECKSUM] no checksums section in product.json');
|
|
return;
|
|
}
|
|
// Files we may modify or create (relative key in product.json → absolute path)
|
|
// CRITICAL: vscode-file:// only serves files with valid checksums in product.json.
|
|
// Custom JS files MUST be added here or they'll silently 404.
|
|
const filesToCheck = {
|
|
'vs/code/electron-browser/workbench/workbench.html': path.join(workbenchDir, 'workbench.html'),
|
|
'vs/code/electron-browser/workbench/workbench-jetski-agent.html': path.join(workbenchDir, 'workbench-jetski-agent.html'),
|
|
'vs/code/electron-browser/workbench/ag-sdk-variet-gravity-bridge.js': path.join(workbenchDir, 'ag-sdk-variet-gravity-bridge.js'),
|
|
};
|
|
let updated = false;
|
|
for (const [key, filePath] of Object.entries(filesToCheck)) {
|
|
if (!fs.existsSync(filePath))
|
|
continue;
|
|
const fileBytes = fs.readFileSync(filePath);
|
|
const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, '');
|
|
if (product.checksums[key] !== hash) {
|
|
logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`);
|
|
product.checksums[key] = hash;
|
|
updated = true;
|
|
}
|
|
}
|
|
if (updated) {
|
|
fs.writeFileSync(productJsonPath, JSON.stringify(product, null, '\t'), 'utf8');
|
|
logToFile('[CHECKSUM] product.json updated ✅');
|
|
}
|
|
else {
|
|
logToFile('[CHECKSUM] all checksums already match ✅');
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[CHECKSUM] error: ${e.message}`);
|
|
}
|
|
}
|
|
// ─── HTTP Bridge Server (Extension Host → Renderer communication) ───
|
|
let observerHttpServer = null;
|
|
const pendingResponses = new Map();
|
|
// Click trigger: extension sets this, renderer polls and clicks button
|
|
let clickTrigger = null;
|
|
let sessionStalled = false; // true when session is stalled waiting for approval
|
|
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step (module-level for tryApprovalStrategies)
|
|
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
|
|
let deepInspectRequested = false;
|
|
let deepInspectResult = null;
|
|
let deepInspectWaiters = [];
|
|
/** Derive a deterministic port from project name (range 10000-60000) */
|
|
function getDeterministicPort(name) {
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) {
|
|
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
|
}
|
|
return 10000 + (Math.abs(hash) % 50000);
|
|
}
|
|
function startObserverHttpBridge() {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const http = require('http');
|
|
const server = http.createServer((req, res) => {
|
|
// CORS headers for renderer fetch()
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(200);
|
|
res.end();
|
|
return;
|
|
}
|
|
const url = new URL(req.url, `http://127.0.0.1`);
|
|
// POST /pending — renderer reports a detected approval button
|
|
if (req.method === 'POST' && url.pathname === '/pending') {
|
|
let body = '';
|
|
req.on('data', (c) => body += c);
|
|
req.on('end', () => {
|
|
try {
|
|
const data = JSON.parse(body);
|
|
// ── Server-side false positive filter ──
|
|
const cmd = (data.command || '').trim();
|
|
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it)$/i;
|
|
if (FALSE_POSITIVE_RE.test(cmd)) {
|
|
logToFile(`[HTTP] filtered false positive: "${cmd}"`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, filtered: true }));
|
|
return;
|
|
}
|
|
// "Run" button → only accept if session is actually stalled (waiting for approval)
|
|
if (/^Run/i.test(cmd) && !sessionStalled) {
|
|
logToFile(`[HTTP] filtered "Run" — session not stalled`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, filtered: true }));
|
|
return;
|
|
}
|
|
const rid = data.request_id || Date.now().toString();
|
|
// Write pending file for Discord bot
|
|
const pendingDir = path.join(bridgePath, 'pending');
|
|
if (!fs.existsSync(pendingDir))
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
const pending = {
|
|
...data,
|
|
request_id: rid,
|
|
conversation_id: activeSessionId || '',
|
|
timestamp: Date.now() / 1000,
|
|
status: 'pending',
|
|
project_name: projectName,
|
|
auto_detected: true,
|
|
source: 'dom_observer',
|
|
};
|
|
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
|
|
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, request_id: rid }));
|
|
}
|
|
catch (e) {
|
|
logToFile(`[HTTP] pending error: ${e.message}`);
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
// GET /response/:rid — renderer polls for Discord approval
|
|
if (req.method === 'GET' && url.pathname.startsWith('/response/')) {
|
|
const rid = url.pathname.split('/')[2];
|
|
const respFile = path.join(bridgePath, 'response', `${rid}.json`);
|
|
if (fs.existsSync(respFile)) {
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(respFile, 'utf8'));
|
|
logToFile(`[HTTP] response served to renderer: ${rid} approved=${data.approved}`);
|
|
// Delay deletion: processResponseFile (response watcher) may need to read it too.
|
|
// The watcher fires with 300ms delay, so 2s is safe.
|
|
setTimeout(() => {
|
|
try {
|
|
fs.unlinkSync(respFile);
|
|
}
|
|
catch { }
|
|
}, 2000);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
}
|
|
catch {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ waiting: true }));
|
|
}
|
|
}
|
|
else {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ waiting: true }));
|
|
}
|
|
return;
|
|
}
|
|
// GET /trigger-click — renderer polls to check if extension wants a click
|
|
if (req.method === 'GET' && url.pathname === '/trigger-click') {
|
|
if (clickTrigger && (Date.now() - clickTrigger.timestamp) < 30000) {
|
|
const trigger = clickTrigger;
|
|
clickTrigger = null; // consume once
|
|
logToFile(`[HTTP] trigger-click consumed: ${trigger.action}`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ action: trigger.action }));
|
|
}
|
|
else {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ action: null }));
|
|
}
|
|
return;
|
|
}
|
|
// GET /deep-inspect — trigger deep DOM inspection from renderer
|
|
if (req.method === 'GET' && url.pathname === '/deep-inspect') {
|
|
deepInspectRequested = true;
|
|
logToFile('[HTTP] deep-inspect triggered — waiting for renderer result...');
|
|
// Wait up to 10s for renderer to POST result
|
|
const timeout = setTimeout(() => {
|
|
deepInspectWaiters = deepInspectWaiters.filter(w => w !== waiter);
|
|
if (deepInspectResult) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(deepInspectResult));
|
|
}
|
|
else {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ status: 'timeout', message: 'Renderer did not respond in 10s. Is the v3 script loaded?' }));
|
|
}
|
|
}, 10000);
|
|
const waiter = (data) => {
|
|
clearTimeout(timeout);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
};
|
|
deepInspectWaiters.push(waiter);
|
|
return;
|
|
}
|
|
// GET /deep-inspect-trigger — renderer polls this
|
|
if (req.method === 'GET' && url.pathname === '/deep-inspect-trigger') {
|
|
const requested = deepInspectRequested;
|
|
deepInspectRequested = false;
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ inspect: requested }));
|
|
return;
|
|
}
|
|
// POST /deep-inspect-result — renderer posts inspection results here
|
|
if (req.method === 'POST' && url.pathname === '/deep-inspect-result') {
|
|
let body = '';
|
|
req.on('data', (c) => body += c);
|
|
req.on('end', () => {
|
|
try {
|
|
const data = JSON.parse(body);
|
|
deepInspectResult = data;
|
|
logToFile(`[HTTP] deep-inspect result received (${body.length} bytes)`);
|
|
// Write to file for reference
|
|
const inspectFile = path.join(bridgePath, 'deep-inspect-result.json');
|
|
fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2));
|
|
// Notify waiters
|
|
const waiters = [...deepInspectWaiters];
|
|
deepInspectWaiters = [];
|
|
waiters.forEach(w => w(data));
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
}
|
|
catch (e) {
|
|
logToFile(`[HTTP] deep-inspect-result parse error: ${e.message}`);
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
// GET /ping — health check
|
|
if (url.pathname === '/ping') {
|
|
res.writeHead(200);
|
|
res.end('pong');
|
|
return;
|
|
}
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
});
|
|
// Listen on deterministic port (derived from projectName), fallback to random
|
|
deterministicPort = getDeterministicPort(projectName);
|
|
const tryListen = (targetPort) => {
|
|
server.listen(targetPort, '127.0.0.1', () => {
|
|
const port = server.address().port;
|
|
observerHttpServer = server;
|
|
logToFile(`[HTTP] bridge server started on port ${port}`);
|
|
// Write port to shared ports JSON (multi-bridge support)
|
|
const patcher = sdk.integration?._patcher;
|
|
if (patcher && typeof patcher.getWorkbenchDir === 'function') {
|
|
const workbenchDir = patcher.getWorkbenchDir();
|
|
const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
|
|
let portsData = {};
|
|
try {
|
|
if (fs.existsSync(portsFile)) {
|
|
portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8'));
|
|
}
|
|
}
|
|
catch { }
|
|
portsData[projectName] = port;
|
|
fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8');
|
|
logToFile(`[HTTP] ports JSON updated → ${portsFile} (${projectName}=${port})`);
|
|
}
|
|
resolve(port);
|
|
});
|
|
};
|
|
server.on('error', (e) => {
|
|
if (e.code === 'EADDRINUSE' && deterministicPort > 0) {
|
|
logToFile(`[HTTP] deterministic port ${deterministicPort} in use, trying random...`);
|
|
deterministicPort = 0;
|
|
const server2 = require('http').createServer(server._events.request);
|
|
observerHttpServer = server2;
|
|
server2.on('error', (e2) => {
|
|
logToFile(`[HTTP] random port also failed: ${e2.message}`);
|
|
resolve(0);
|
|
});
|
|
server2.listen(0, '127.0.0.1', () => {
|
|
const port = server2.address().port;
|
|
logToFile(`[HTTP] bridge server started on RANDOM port ${port}`);
|
|
resolve(port);
|
|
});
|
|
return;
|
|
}
|
|
logToFile(`[HTTP] server error: ${e.message}`);
|
|
resolve(0);
|
|
});
|
|
tryListen(deterministicPort);
|
|
}
|
|
catch (e) {
|
|
logToFile(`[HTTP] server failed: ${e.message}`);
|
|
resolve(0);
|
|
}
|
|
});
|
|
}
|
|
// ─── Renderer Script (uses fetch() — no Node.js APIs) ───
|
|
function generateApprovalObserverScript(_port) {
|
|
// Port is hardcoded as fallback, but renderer also reads ag-bridge-ports.json for multi-bridge
|
|
return `
|
|
// ── Gravity Bridge v3: Approval Observer (deep DOM traversal — iframes, webviews, shadow DOMs) ──
|
|
(function(){
|
|
'use strict';
|
|
var BASE='',_obs=false,_sent={},_ready=false;
|
|
var _scanScheduled=false,_lastScanTs=0;
|
|
var THROTTLE_MS=100;
|
|
var CLEANUP_MS=300000;
|
|
var _domDumped=false;
|
|
|
|
function log(m){console.log('[GB Observer] '+m);}
|
|
log('v3 Script loaded — deep DOM traversal enabled');
|
|
|
|
// ── Deep DOM Traversal: find buttons across ALL boundaries ──
|
|
// Searches: main document → iframes (contentDocument) → webview elements → shadow DOMs
|
|
function deepFindButtons(patterns){
|
|
var results=[];
|
|
// 1. Main document buttons
|
|
collectButtons(document,results,patterns,'main');
|
|
// 2. Iframe traversal (try contentDocument — works if same-origin or webSecurity off)
|
|
var iframes=document.querySelectorAll('iframe');
|
|
for(var i=0;i<iframes.length;i++){
|
|
try{
|
|
var idoc=iframes[i].contentDocument||iframes[i].contentWindow.document;
|
|
if(idoc){collectButtons(idoc,results,patterns,'iframe#'+i+'('+iframes[i].className.substring(0,30)+')');}
|
|
}catch(e){
|
|
// Cross-origin — can't access. Log only on first dom dump
|
|
if(!_domDumped)log('iframe#'+i+' cross-origin: '+e.message.substring(0,60));
|
|
}
|
|
}
|
|
// 3. Webview elements (Electron <webview> tag — has executeJavaScript)
|
|
var webviews=document.querySelectorAll('webview');
|
|
for(var w=0;w<webviews.length;w++){
|
|
try{
|
|
var wvDoc=webviews[w].contentDocument;
|
|
if(wvDoc){collectButtons(wvDoc,results,patterns,'webview#'+w);}
|
|
}catch(e){
|
|
if(!_domDumped)log('webview#'+w+' access error: '+e.message.substring(0,60));
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function collectButtons(doc,results,patterns,source){
|
|
if(!doc||!doc.querySelectorAll)return;
|
|
var btns=doc.querySelectorAll('button');
|
|
for(var i=0;i<btns.length;i++){
|
|
var b=btns[i];
|
|
if(b.disabled||b.hidden)continue;
|
|
try{if(!b.offsetParent&&b.style.display!=='fixed')continue;}catch(e){}
|
|
var txt=(b.textContent||'').trim();
|
|
if(!txt)continue;
|
|
for(var p=0;p<patterns.length;p++){
|
|
if(patterns[p].test(txt)){
|
|
results.push({btn:b,text:txt,source:source});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// 4. Recurse into shadow DOMs
|
|
try{
|
|
var allEls=doc.querySelectorAll('*');
|
|
for(var j=0;j<allEls.length;j++){
|
|
var sr=allEls[j].shadowRoot;
|
|
if(sr)collectButtons(sr,results,patterns,source+'>shadow');
|
|
}
|
|
}catch(e){}
|
|
}
|
|
|
|
// ── Deep DOM Inspector (recursive, POSTs results to bridge) ──
|
|
function runDeepInspect(){
|
|
var result={timestamp:new Date().toISOString(),windowURL:window.location.href,windowOrigin:window.location.origin,windowProtocol:window.location.protocol,framesCount:window.frames.length,nodes:[]};
|
|
log('DEEP-INSPECT: starting recursive DOM analysis...');
|
|
|
|
function inspectDoc(doc,depth,label){
|
|
var node={label:label,depth:depth,accessible:true,url:'',buttons:[],roleBtns:[],iframes:[],webviews:[],shadowDOMs:0,totalElements:0};
|
|
if(!doc){node.accessible=false;node.error='null document';result.nodes.push(node);return;}
|
|
try{node.url=(doc.URL||doc.documentURI||'unknown').substring(0,200);}catch(e){node.url='blocked';}
|
|
try{node.title=(doc.title||'').substring(0,100);}catch(e){}
|
|
try{node.readyState=doc.readyState;}catch(e){}
|
|
|
|
// CSP
|
|
try{
|
|
var csp=doc.querySelectorAll('meta[http-equiv="Content-Security-Policy"]');
|
|
if(csp.length>0){node.csp=[];for(var c=0;c<csp.length;c++){node.csp.push((csp[c].content||'').substring(0,200));}}
|
|
}catch(e){}
|
|
|
|
try{
|
|
var allEls=doc.querySelectorAll('*');
|
|
node.totalElements=allEls.length;
|
|
// Buttons
|
|
var btns=doc.querySelectorAll('button');
|
|
for(var i=0;i<btns.length;i++){
|
|
var b=btns[i];
|
|
var txt=(b.textContent||'').trim().substring(0,80);
|
|
if(!txt)continue;
|
|
var cls=(b.className||'').substring(0,60);
|
|
var disabled=b.disabled;
|
|
var hidden=b.hidden||false;
|
|
try{if(!b.offsetParent&&b.style.display!=='fixed')hidden=true;}catch(e){}
|
|
var aria=b.getAttribute('aria-label')||'';
|
|
var ttl=b.getAttribute('title')||'';
|
|
node.buttons.push({text:txt,class:cls,disabled:disabled,hidden:hidden,aria:aria,title:ttl});
|
|
}
|
|
// role=button
|
|
var rbs=doc.querySelectorAll('[role="button"]');
|
|
for(var r=0;r<rbs.length;r++){
|
|
if(rbs[r].tagName==='BUTTON')continue;
|
|
var rtxt=(rbs[r].textContent||'').trim().substring(0,60);
|
|
node.roleBtns.push({tag:rbs[r].tagName.toLowerCase(),text:rtxt});
|
|
}
|
|
// Shadow DOMs
|
|
for(var s=0;s<allEls.length;s++){
|
|
var sr=allEls[s].shadowRoot;
|
|
if(sr){node.shadowDOMs++;inspectDoc(sr,depth+1,'shadow(<'+allEls[s].tagName.toLowerCase()+' class="'+(allEls[s].className||'').substring(0,30)+'">)');}
|
|
}
|
|
// Iframes
|
|
var ifs=doc.querySelectorAll('iframe');
|
|
for(var fi=0;fi<ifs.length;fi++){
|
|
var f=ifs[fi];
|
|
var finfo={index:fi,class:(f.className||'').substring(0,60),src:(f.src||'').substring(0,150),id:f.id||'',sandbox:f.getAttribute('sandbox')||'',allow:f.getAttribute('allow')||'',accessible:false,cwExists:false,cwFrames:0};
|
|
try{
|
|
var idoc=f.contentDocument||(f.contentWindow&&f.contentWindow.document);
|
|
if(idoc){finfo.accessible=true;inspectDoc(idoc,depth+1,'iframe#'+fi+'('+finfo.class.substring(0,30)+')');
|
|
}else{finfo.error='contentDocument=null';}
|
|
}catch(e){finfo.error=e.message.substring(0,80);}
|
|
try{var cw=f.contentWindow;if(cw){finfo.cwExists=true;finfo.cwFrames=cw.length;try{finfo.cwLocation=cw.location.href;}catch(e2){finfo.cwLocation='blocked: '+e2.message.substring(0,40);}}}
|
|
catch(e){}
|
|
node.iframes.push(finfo);
|
|
}
|
|
// Webviews
|
|
var wvs=doc.querySelectorAll('webview');
|
|
for(var wi=0;wi<wvs.length;wi++){
|
|
var wv=wvs[wi];
|
|
var winfo={index:wi,src:(wv.src||'').substring(0,150),class:(wv.className||'').substring(0,60),partition:wv.getAttribute('partition')||'',preload:wv.getAttribute('preload')||'',nodeintegration:wv.getAttribute('nodeintegration')||'',webpreferences:wv.getAttribute('webpreferences')||'',hasExecJS:typeof wv.executeJavaScript==='function',contentDocAccessible:false};
|
|
try{var wdoc=wv.contentDocument;if(wdoc){winfo.contentDocAccessible=true;inspectDoc(wdoc,depth+1,'webview#'+wi+'.contentDocument');}}catch(e){winfo.contentDocError=e.message.substring(0,60);}
|
|
node.webviews.push(winfo);
|
|
}
|
|
}catch(e){node.error=e.message;}
|
|
result.nodes.push(node);
|
|
return node;
|
|
}
|
|
|
|
inspectDoc(document,0,'MainDocument');
|
|
|
|
// Webview executeJavaScript probe (async)
|
|
var webviews=document.querySelectorAll('webview');
|
|
var probesPending=webviews.length;
|
|
result.webviewProbes=[];
|
|
if(probesPending===0)postResults();
|
|
for(var pw=0;pw<webviews.length;pw++){
|
|
(function(wv,idx){
|
|
if(typeof wv.executeJavaScript!=='function'){result.webviewProbes.push({index:idx,error:'executeJavaScript not available'});probesPending--;if(probesPending<=0)postResults();return;}
|
|
try{
|
|
wv.executeJavaScript('(function(){var btns=document.querySelectorAll("button");var allEls=document.querySelectorAll("*");var ifs=document.querySelectorAll("iframe");var wvs=document.querySelectorAll("webview");var btnArr=[];for(var i=0;i<btns.length;i++){var b=btns[i];var txt=(b.textContent||"").trim();var cls=(b.className||"").substring(0,50);var dis=b.disabled;var hid=b.hidden||!b.offsetParent;btnArr.push({text:txt.substring(0,60),class:cls,disabled:dis,hidden:hid,aria:b.getAttribute("aria-label")||"",title:b.getAttribute("title")||""});}var rbs=document.querySelectorAll("[role=button]");var rbArr=[];for(var j=0;j<rbs.length;j++){if(rbs[j].tagName!=="BUTTON")rbArr.push({tag:rbs[j].tagName.toLowerCase(),text:(rbs[j].textContent||"").trim().substring(0,40)});}var sc=0;for(var k=0;k<allEls.length;k++){if(allEls[k].shadowRoot)sc++;}return JSON.stringify({url:document.URL,title:document.title,totalElements:allEls.length,buttons:btnArr,roleBtns:rbArr,iframes:ifs.length,webviews:wvs.length,shadowDOMs:sc});})()')
|
|
.then(function(r){
|
|
try{var d=JSON.parse(r);result.webviewProbes.push({index:idx,success:true,data:d});log('DEEP-INSPECT: webview#'+idx+' probe OK: '+d.buttons.length+' buttons, '+d.totalElements+' elements');}catch(e){result.webviewProbes.push({index:idx,parseError:e.message,raw:r});}
|
|
probesPending--;if(probesPending<=0)postResults();
|
|
})
|
|
.catch(function(e){
|
|
result.webviewProbes.push({index:idx,execError:e.message});
|
|
log('DEEP-INSPECT: webview#'+idx+' execJS error: '+e.message);
|
|
probesPending--;if(probesPending<=0)postResults();
|
|
});
|
|
}catch(e){
|
|
result.webviewProbes.push({index:idx,callError:e.message});
|
|
probesPending--;if(probesPending<=0)postResults();
|
|
}
|
|
})(webviews[pw],pw);
|
|
}
|
|
|
|
function postResults(){
|
|
var summary='nodes='+result.nodes.length;
|
|
var totalBtns=0;for(var n=0;n<result.nodes.length;n++)totalBtns+=result.nodes[n].buttons.length;
|
|
summary+=' totalButtons='+totalBtns+' webviewProbes='+result.webviewProbes.length;
|
|
log('DEEP-INSPECT complete: '+summary);
|
|
// Also log buttons from each node
|
|
for(var n2=0;n2<result.nodes.length;n2++){
|
|
var nd=result.nodes[n2];
|
|
if(nd.buttons.length>0){
|
|
log(' '+nd.label+': '+nd.buttons.length+' buttons');
|
|
for(var bi=0;bi<Math.min(15,nd.buttons.length);bi++){
|
|
log(' ['+bi+'] "'+nd.buttons[bi].text+'"'+(nd.buttons[bi].disabled?' DISABLED':'')+(nd.buttons[bi].hidden?' HIDDEN':''));
|
|
}
|
|
}
|
|
}
|
|
// POST to bridge
|
|
fetch(BASE+'/deep-inspect-result',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(result)})
|
|
.then(function(){log('DEEP-INSPECT results posted to bridge');})
|
|
.catch(function(e){log('DEEP-INSPECT post error: '+e.message);});
|
|
}
|
|
}
|
|
|
|
// Auto-dump on startup (3s delay)
|
|
function dumpDOMStructure(){runDeepInspect();}
|
|
|
|
// ── Port Discovery: async fetch()-based (sync XHR blocked in Electron renderer) ──
|
|
var HARDCODED_PORT=${_port};
|
|
|
|
function tryPingAsync(port){
|
|
return fetch('http://127.0.0.1:'+port+'/ping?t='+Date.now(),{signal:AbortSignal.timeout(2000)})
|
|
.then(function(r){return r.text();})
|
|
.then(function(t){return t==='pong';})
|
|
.catch(function(){return false;});
|
|
}
|
|
|
|
function discoverPort(cb){
|
|
log('Trying hardcoded port '+HARDCODED_PORT+'...');
|
|
tryPingAsync(HARDCODED_PORT).then(function(ok){
|
|
if(ok){log('Port discovered (hardcoded): '+HARDCODED_PORT);cb(HARDCODED_PORT);return;}
|
|
log('Hardcoded port failed, retrying with backoff...');
|
|
|
|
var attempts=0;
|
|
var timer=setInterval(function(){
|
|
attempts++;
|
|
if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
|
|
tryPingAsync(HARDCODED_PORT).then(function(ok2){
|
|
if(ok2){clearInterval(timer);log('Port discovered (retry #'+attempts+'): '+HARDCODED_PORT);cb(HARDCODED_PORT);}
|
|
});
|
|
},2000);
|
|
});
|
|
}
|
|
|
|
discoverPort(function(port){
|
|
BASE='http://127.0.0.1:'+port;
|
|
fetch(BASE+'/ping').then(function(r){return r.text();}).then(function(t){
|
|
if(t==='pong'){log('Bridge connected on port '+port);_ready=true;startObserver();setTimeout(dumpDOMStructure,3000);}
|
|
else log('Bridge ping failed: '+t);
|
|
}).catch(function(e){log('Bridge unreachable: '+e.message);});
|
|
});
|
|
|
|
// ── Button patterns to detect (order matters: first match wins per scan) ──
|
|
var PATS=[
|
|
{re:/^Run/i, type:'terminal_command'},
|
|
{re:/^Accept all$/i, type:'diff_review'},
|
|
{re:/^Reject all$/i, type:'diff_review'},
|
|
{re:/^Accept$/i, type:'agent_step'},
|
|
{re:/^Allow/i, type:'permission'},
|
|
{re:/^Approve/i, type:'agent_step'},
|
|
{re:/^Deny$/i, type:'permission'},
|
|
{re:/^Retry$/i, type:'error_recovery'},
|
|
{re:/^Dismiss$/i, type:'error_recovery'},
|
|
];
|
|
|
|
// ALL actionable button patterns (for grouping siblings in same container)
|
|
var ALL_ACTION_RE=[/^Run/i,/^Accept/i,/^Reject/i,/^Allow/i,/^Deny/i,/^Approve/i,/^Cancel$/i,/^Retry$/i,/^Dismiss$/i,/^Stop$/i,/^Decline$/i];
|
|
|
|
// Reject button patterns for finding the counterpart
|
|
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i];
|
|
|
|
// ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
|
|
function btnId(b,type){
|
|
// Use: type + button text + parent's first 40 chars of text content
|
|
var txt=(b.textContent||'').trim();
|
|
var parent=b.parentElement;
|
|
var pctx=parent?(parent.textContent||'').substring(0,40).replace(/\\s+/g,' '):'';
|
|
// Also use DOM position: nth-child among sibling buttons
|
|
var idx=0;
|
|
if(parent){
|
|
var siblings=parent.querySelectorAll('button');
|
|
for(var i=0;i<siblings.length;i++){if(siblings[i]===b){idx=i;break;}}
|
|
}
|
|
return type+'|'+txt+'|'+idx+'|'+pctx.substring(0,20);
|
|
}
|
|
|
|
// ── Context extraction — walk up DOM to find command/code description ──
|
|
function extractContext(b){
|
|
// Strategy 1: Look for code/pre/terminal blocks near the button
|
|
var container=b.closest('[class*="step"]')
|
|
||b.closest('[class*="action"]')
|
|
||b.closest('[class*="tool"]')
|
|
||b.closest('[class*="cascade"]')
|
|
||b.closest('[class*="message"]');
|
|
if(!container)container=b.parentElement;
|
|
if(!container)return '';
|
|
|
|
// Look for code blocks
|
|
var codeEl=container.querySelector('pre,code,[class*="command"],[class*="terminal"],[class*="code-block"]');
|
|
if(codeEl){
|
|
var codeText=(codeEl.textContent||'').trim();
|
|
if(codeText.length>0)return codeText.substring(0,500);
|
|
}
|
|
|
|
// Strategy 2: Get surrounding text (exclude button text itself)
|
|
var full=(container.textContent||'');
|
|
var btnText=(b.textContent||'');
|
|
var desc=full.replace(btnText,'').trim();
|
|
// Trim to reasonable length
|
|
return desc.substring(0,500);
|
|
}
|
|
|
|
// ── Find common container of related buttons ──
|
|
function findButtonContainer(btn){
|
|
return btn.closest('[class*="step"]')
|
|
||btn.closest('[class*="action"]')
|
|
||btn.closest('[class*="tool"]')
|
|
||btn.closest('[class*="cascade"]')
|
|
||btn.closest('[class*="message"]')
|
|
||btn.closest('[class*="dialog"]')
|
|
||btn.closest('[class*="notification"]')
|
|
||btn.parentElement;
|
|
}
|
|
|
|
// ── Collect all actionable sibling buttons from a container ──
|
|
function collectSiblingButtons(container,triggerBtn){
|
|
if(!container)return [];
|
|
var siblings=container.querySelectorAll('button');
|
|
var result=[];
|
|
for(var i=0;i<siblings.length;i++){
|
|
var sb=siblings[i];
|
|
if(sb.disabled||sb.hidden)continue;
|
|
try{if(!sb.offsetParent&&sb.style.display!=='fixed')continue;}catch(e){}
|
|
var stxt=(sb.textContent||'').trim();
|
|
stxt=stxt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
|
|
if(!stxt)continue;
|
|
// Check if this button matches any actionable pattern
|
|
var isAction=false;
|
|
for(var a=0;a<ALL_ACTION_RE.length;a++){
|
|
if(ALL_ACTION_RE[a].test(stxt)){isAction=true;break;}
|
|
}
|
|
if(!isAction)continue;
|
|
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Find the React app container (Antigravity's main UI root) ──
|
|
function findPanel(){
|
|
// Priority order of panel selectors (most specific first)
|
|
var selectors=[
|
|
'.antigravity-agent-side-panel',
|
|
'#jetski-agent-panel',
|
|
'.react-app-container',
|
|
'[class*="agent-panel"]',
|
|
'[class*="agentPanel"]',
|
|
];
|
|
for(var i=0;i<selectors.length;i++){
|
|
var el=document.querySelector(selectors[i]);
|
|
if(el)return el;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Core scan — finds actionable buttons and reports to bridge ──
|
|
// Groups related buttons from same container into a single pending
|
|
function scan(){
|
|
if(!_ready)return;
|
|
var now=Date.now();
|
|
|
|
var panel=findPanel();
|
|
// Expand search: panel-scoped first, then full body for review bars
|
|
var searchRoots=[];
|
|
if(panel)searchRoots.push(panel);
|
|
// Always also scan body for diff review bar (Accept all/Reject all)
|
|
// which lives outside the agent panel in the editor notification area
|
|
if(document.body)searchRoots.push(document.body);
|
|
if(!searchRoots.length)return;
|
|
|
|
var seen={}; // dedupe buttons across search roots
|
|
for(var r=0;r<searchRoots.length;r++){
|
|
var allBtns=searchRoots[r].querySelectorAll('button');
|
|
if(!allBtns.length)continue;
|
|
|
|
for(var j=0;j<allBtns.length;j++){
|
|
var b=allBtns[j];
|
|
if(b.disabled||b.hidden)continue;
|
|
// Check visibility (offsetParent null = hidden via CSS)
|
|
if(!b.offsetParent&&b.style.display!=='fixed')continue;
|
|
|
|
var txt=(b.textContent||'').trim();
|
|
if(!txt)continue;
|
|
// Strip keyboard shortcut suffixes (e.g. "RunAlt+↵" → "Run")
|
|
txt=txt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
|
|
if(!txt)continue;
|
|
|
|
// Match against patterns
|
|
var matchedType=null;
|
|
for(var p=0;p<PATS.length;p++){
|
|
if(PATS[p].re.test(txt)){matchedType=PATS[p].type;break;}
|
|
}
|
|
if(!matchedType)continue;
|
|
|
|
// Generate stable ID for the GROUP (use container-based key)
|
|
var container=findButtonContainer(b);
|
|
var groupKey=matchedType+'|group|'+(container?(container.textContent||'').substring(0,40).replace(/\\s+/g,' '):'none');
|
|
if(_sent[groupKey])continue;
|
|
|
|
// Collect ALL related buttons from the same container
|
|
var siblings=collectSiblingButtons(container,b);
|
|
if(siblings.length===0)siblings=[{btn:b,text:txt,isPrimary:true}];
|
|
|
|
// Build buttons array for multi-choice support
|
|
var buttonsArr=[];
|
|
var btnRefs=[];
|
|
var bidList=[];
|
|
for(var si=0;si<siblings.length;si++){
|
|
var sb=siblings[si];
|
|
var sbid=btnId(sb.btn,matchedType);
|
|
buttonsArr.push({text:sb.text,index:si,is_primary:sb.isPrimary});
|
|
btnRefs.push(sb.btn);
|
|
bidList.push(sbid);
|
|
}
|
|
|
|
// Extract context from trigger button
|
|
var desc=extractContext(b);
|
|
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
|
|
|
|
// Mark entire group as sent
|
|
_sent[groupKey]={rid:rid,ts:now};
|
|
for(var mk=0;mk<bidList.length;mk++){_sent[bidList[mk]]={rid:rid,ts:now};}
|
|
|
|
log('DETECTED GROUP '+matchedType+': '+buttonsArr.map(function(x){return '"'+x.text+'"';}).join(', ')+' → pending to bridge');
|
|
|
|
// Send to bridge (closure to capture refs)
|
|
(function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
|
|
var payload={
|
|
request_id:rid2,
|
|
command:txt2,
|
|
description:desc2,
|
|
step_type:type2,
|
|
buttons:buttonsArr2
|
|
};
|
|
fetch(BASE+'/pending',{
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify(payload)
|
|
}).then(function(r){return r.json();}).then(function(d){
|
|
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
|
|
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
|
|
}).catch(function(e){
|
|
log('POST error: '+e.message);
|
|
delete _sent[groupKey2];
|
|
for(var di=0;di<bidList2.length;di++){delete _sent[bidList2[di]];}
|
|
});
|
|
})(rid,btnRefs,bidList,groupKey,txt,desc,matchedType,buttonsArr);
|
|
|
|
// Process ONE button GROUP per scan cycle (avoid flooding)
|
|
return;
|
|
}
|
|
} // end searchRoots loop
|
|
}
|
|
|
|
// ── Poll for Discord response (multi-button group aware) ──
|
|
function pollResponseGroup(rid,btnRefs,bidList,groupKey){
|
|
var polls=0;
|
|
var maxPolls=600; // 5 minutes at 500ms interval
|
|
var timer=setInterval(function(){
|
|
polls++;
|
|
// Check if ANY button in the group is still in DOM
|
|
var anyAlive=false;
|
|
for(var ai=0;ai<btnRefs.length;ai++){
|
|
if(document.body.contains(btnRefs[ai])){anyAlive=true;break;}
|
|
}
|
|
if(!anyAlive){
|
|
log('All buttons removed from DOM — stopping poll for '+rid);
|
|
clearInterval(timer);
|
|
delete _sent[groupKey];
|
|
for(var ci=0;ci<bidList.length;ci++){delete _sent[bidList[ci]];}
|
|
return;
|
|
}
|
|
if(polls>maxPolls){
|
|
log('Poll timeout for '+rid);
|
|
clearInterval(timer);
|
|
delete _sent[groupKey];
|
|
for(var ti=0;ti<bidList.length;ti++){delete _sent[bidList[ti]];}
|
|
return;
|
|
}
|
|
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
|
|
if(d.waiting)return;
|
|
clearInterval(timer);
|
|
var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
|
|
if(btnIdx>=0&&btnIdx<btnRefs.length){
|
|
// Multi-choice: click specific button by index
|
|
var targetBtn=btnRefs[btnIdx];
|
|
var targetTxt=(targetBtn.textContent||'').trim();
|
|
log((d.approved?'✅':'❌')+' CHOICE '+rid+' → clicking button['+btnIdx+'] "'+targetTxt+'"');
|
|
targetBtn.click();
|
|
} else if(d.approved){
|
|
// Legacy single-button: click first (primary) button
|
|
var primaryBtn=btnRefs[0];
|
|
log('✅ APPROVED '+rid+' → clicking "'+((primaryBtn.textContent||'').trim())+'"');
|
|
primaryBtn.click();
|
|
} else {
|
|
// Legacy reject: find and click reject/deny button
|
|
log('❌ REJECTED '+rid+' → finding reject button');
|
|
clickRejectButton(btnRefs[0]);
|
|
}
|
|
delete _sent[groupKey];
|
|
for(var ri=0;ri<bidList.length;ri++){delete _sent[bidList[ri]];}
|
|
}).catch(function(){});
|
|
},500);
|
|
}
|
|
|
|
// Legacy pollResponse for backward compatibility (single button)
|
|
function pollResponse(rid,btn,bid){
|
|
pollResponseGroup(rid,[btn],[bid],bid);
|
|
}
|
|
|
|
// ── Find and click the reject/cancel counterpart button ──
|
|
function clickRejectButton(approveBtn){
|
|
// Walk up to find the container, then search for reject buttons
|
|
var container=approveBtn.closest('[class*="step"]')
|
|
||approveBtn.closest('[class*="action"]')
|
|
||approveBtn.closest('[class*="tool"]')
|
|
||approveBtn.parentElement;
|
|
if(!container){log('No container for reject');return;}
|
|
|
|
var siblings=container.querySelectorAll('button');
|
|
for(var i=0;i<siblings.length;i++){
|
|
var t=(siblings[i].textContent||'').trim();
|
|
for(var r=0;r<REJECT_RE.length;r++){
|
|
if(REJECT_RE[r].test(t)){
|
|
log('Clicking reject: "'+t+'"');
|
|
siblings[i].click();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
log('No reject button found near approve button');
|
|
}
|
|
|
|
// ── Throttled scan — leading-edge: fires immediately, then locks ──
|
|
function scheduleScan(){
|
|
if(!_ready)return;
|
|
var now=Date.now();
|
|
if(now-_lastScanTs>=THROTTLE_MS){
|
|
_lastScanTs=now;
|
|
scan();
|
|
} else if(!_scanScheduled){
|
|
_scanScheduled=true;
|
|
setTimeout(function(){
|
|
_scanScheduled=false;
|
|
_lastScanTs=Date.now();
|
|
scan();
|
|
},THROTTLE_MS-(now-_lastScanTs));
|
|
}
|
|
}
|
|
|
|
// ── Periodic cleanup of stale _sent entries ──
|
|
setInterval(function(){
|
|
var now=Date.now();
|
|
var keys=Object.keys(_sent);
|
|
for(var i=0;i<keys.length;i++){
|
|
var entry=_sent[keys[i]];
|
|
if(entry&&entry.ts&&(now-entry.ts)>CLEANUP_MS){
|
|
log('Cleanup stale entry: '+keys[i]);
|
|
delete _sent[keys[i]];
|
|
}
|
|
}
|
|
},60000);
|
|
|
|
// ── Start observation ──
|
|
function startObserver(){
|
|
if(_obs)return;
|
|
// PRIMARY: MutationObserver — reacts instantly to DOM changes
|
|
new MutationObserver(function(mutations){
|
|
// Only scan if mutations contain added nodes (new buttons potentially)
|
|
for(var i=0;i<mutations.length;i++){
|
|
if(mutations[i].addedNodes.length>0){
|
|
scheduleScan();
|
|
return;
|
|
}
|
|
}
|
|
}).observe(document.body,{childList:true,subtree:true});
|
|
|
|
// FALLBACK: periodic scan every 3s for any missed mutations
|
|
setInterval(scheduleScan,3000);
|
|
|
|
// ── DEEP-INSPECT POLLING: curl→Bridge→Renderer→Results ──
|
|
setInterval(function(){
|
|
if(!_ready||!BASE)return;
|
|
fetch(BASE+'/deep-inspect-trigger?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
|
|
if(d.inspect){log('🔍 Deep inspect triggered via HTTP');runDeepInspect();}
|
|
}).catch(function(){});
|
|
},2000);
|
|
|
|
// ── TRIGGER-CLICK: Extension→Renderer bridge for programmatic button clicks ──
|
|
// Extension sets clickTrigger via tryApprovalStrategies → renderer polls and clicks
|
|
// v3: uses deepFindButtons() to traverse iframes, webviews, shadow DOMs
|
|
setInterval(function(){
|
|
if(!_ready||!BASE)return;
|
|
fetch(BASE+'/trigger-click?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
|
|
if(!d.action)return;
|
|
log('🔔 TRIGGER-CLICK received: action='+d.action);
|
|
|
|
var approveRe=[/^Run$/i,/^Run /i,/^Accept/i,/^Allow/i,/^Approve/i,/^Continue$/i,/^Proceed$/i,/^Retry$/i];
|
|
var rejectRe=[/^Reject/i,/^Cancel$/i,/^Deny$/i,/^Stop$/i,/^Decline$/i,/^Dismiss$/i];
|
|
var patterns=(d.action==='approve')?approveRe:rejectRe;
|
|
var emoji=(d.action==='approve')?'✅':'❌';
|
|
|
|
// Phase 1: deepFindButtons in main doc + accessible iframes + shadow DOMs
|
|
var found=deepFindButtons(patterns);
|
|
if(found.length>0){
|
|
log(emoji+' TRIGGER-CLICK: clicking "'+found[0].text+'" from '+found[0].source);
|
|
found[0].btn.click();
|
|
return;
|
|
}
|
|
|
|
// Phase 2: Try <webview>.executeJavaScript for inaccessible webviews
|
|
var webviews=document.querySelectorAll('webview');
|
|
if(webviews.length>0){
|
|
log('TRIGGER-CLICK: trying '+webviews.length+' webview(s) via executeJavaScript...');
|
|
var patternsStr=patterns.map(function(re){return re.source;}).join('|');
|
|
var clickScript='(function(){'+
|
|
'var re=new RegExp("'+patternsStr+'","i");'+
|
|
'var btns=document.querySelectorAll("button");'+
|
|
'for(var i=0;i<btns.length;i++){'+
|
|
'var b=btns[i];if(b.disabled||b.hidden)continue;'+
|
|
'var t=(b.textContent||"").trim();'+
|
|
'if(re.test(t)){b.click();return "CLICKED:"+t;}'+
|
|
'}'+
|
|
'return "NOT_FOUND:"+btns.length+"_buttons";'+
|
|
'})()';
|
|
for(var w=0;w<webviews.length;w++){
|
|
(function(wv,idx){
|
|
try{
|
|
if(typeof wv.executeJavaScript==='function'){
|
|
wv.executeJavaScript(clickScript).then(function(result){
|
|
log(emoji+' TRIGGER-CLICK webview#'+idx+': '+result);
|
|
}).catch(function(e){
|
|
log('TRIGGER-CLICK webview#'+idx+' execJS error: '+e.message);
|
|
});
|
|
}
|
|
}catch(e){
|
|
log('TRIGGER-CLICK webview#'+idx+' error: '+e.message);
|
|
}
|
|
})(webviews[w],w);
|
|
}
|
|
}
|
|
|
|
// Phase 3: Try iframes via postMessage (cross-origin fallback)
|
|
var iframes=document.querySelectorAll('iframe');
|
|
if(iframes.length>0){
|
|
log('TRIGGER-CLICK: trying '+iframes.length+' iframe(s) — checking accessibility...');
|
|
var clickedAny=false;
|
|
for(var fi=0;fi<iframes.length;fi++){
|
|
try{
|
|
var idoc=iframes[fi].contentDocument||iframes[fi].contentWindow.document;
|
|
if(!idoc)continue;
|
|
var ibtns=idoc.querySelectorAll('button');
|
|
for(var bi=0;bi<ibtns.length;bi++){
|
|
var ib=ibtns[bi];
|
|
if(ib.disabled||ib.hidden)continue;
|
|
var itxt=(ib.textContent||'').trim();
|
|
for(var pi=0;pi<patterns.length;pi++){
|
|
if(patterns[pi].test(itxt)){
|
|
log(emoji+' TRIGGER-CLICK iframe#'+fi+': clicking "'+itxt+'"');
|
|
ib.click();
|
|
clickedAny=true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}catch(e){}
|
|
}
|
|
}
|
|
|
|
if(!found.length){
|
|
// Log what we DID find for debugging
|
|
var allBtns=document.querySelectorAll('button');
|
|
var btnTexts=[];
|
|
for(var di=0;di<Math.min(10,allBtns.length);di++){
|
|
btnTexts.push('"'+((allBtns[di].textContent||'').trim()).substring(0,30)+'"');
|
|
}
|
|
log('⚠️ TRIGGER-CLICK: no '+d.action+' button found. Main DOM has '+allBtns.length+' btns: ['+btnTexts.join(',')+']');
|
|
log('⚠️ iframes='+document.querySelectorAll('iframe').length+' webviews='+document.querySelectorAll('webview').length);
|
|
}
|
|
}).catch(function(){});
|
|
},1000);
|
|
|
|
_obs=true;
|
|
log('v3 Observer active — deep DOM traversal + MutationObserver + trigger-click polling');
|
|
}
|
|
})();
|
|
`;
|
|
}
|
|
// Track last seen step per session to avoid re-fetching
|
|
const lastSeenStep = new Map();
|
|
const lastSnapshotText = new Map();
|
|
const registeredSessions = new Set(); // track which sessions have been registered
|
|
/**
|
|
* Write a registration file for the Bot to discover session → project mapping.
|
|
* Called automatically on first step event per session.
|
|
*/
|
|
function writeRegistration(sessionId) {
|
|
try {
|
|
const regDir = path.join(bridgePath, 'register');
|
|
if (!fs.existsSync(regDir)) {
|
|
fs.mkdirSync(regDir, { recursive: true });
|
|
}
|
|
const regFile = path.join(regDir, `${sessionId}.json`);
|
|
// Always overwrite — the window that actively writes snapshots/approvals is the correct owner
|
|
const data = {
|
|
conversation_id: sessionId,
|
|
project_name: projectName,
|
|
timestamp: Date.now() / 1000,
|
|
};
|
|
fs.writeFileSync(regFile, JSON.stringify(data, null, 2), 'utf-8');
|
|
console.log(`Gravity Bridge: registered session ${sessionId.substring(0, 8)} → ${projectName}`);
|
|
}
|
|
catch (e) {
|
|
console.log(`Gravity Bridge: registration write error: ${e.message}`);
|
|
}
|
|
}
|
|
function setupMonitor() {
|
|
if (!sdk) {
|
|
return;
|
|
}
|
|
// NOTE: SDK EventMonitor DISABLED to prevent ERR_CONNECTION_REFUSED spam.
|
|
// Root cause: EventMonitor polls GetCascadeTrajectorySteps every 2s via rawRPC,
|
|
// which has a 775-step hard limit and generates connection errors.
|
|
// ALL relay is now handled by the GetAllCascadeTrajectories POLL below.
|
|
console.log('Gravity Bridge: SDK monitor DISABLED (using GetAllCascadeTrajectories POLL instead)');
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// PRIMARY RELAY: GetAllCascadeTrajectories (THE CORRECT API!)
|
|
//
|
|
// PROVEN VIA DIRECT RPC TESTING:
|
|
// - GetCascadeTrajectorySteps: 775-step hard limit, startStepIndex IGNORED
|
|
// - getDiagnostics.lastStepIndex: stale (can lag behind)
|
|
// - GetAllCascadeTrajectories:
|
|
// stepCount: REAL-TIME (verified 1413→1429 live)
|
|
// latestNotifyUserStep: contains FULL notificationContent
|
|
// latestTaskBoundaryStep: contains FULL taskName/Status/Summary
|
|
// stepIndex on each → perfect for dedup
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
let pollCount = 0;
|
|
// activeSessionId is module-level (used by writeChatSnapshot for lazy registration)
|
|
let activeSessionTitle = '';
|
|
let lastKnownStepCount = 0;
|
|
let lastNotifyStepIndex = -1;
|
|
let lastTaskStepIndex = -1;
|
|
// lastPendingStepIndex is module-level (above sessionStalled)
|
|
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
|
|
let lastPendingTime = 0; // cooldown: minimum gap between pendings
|
|
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending
|
|
let lastModTime = ''; // track lastModifiedTime to distinguish thinking vs approval
|
|
let stallProbed = false; // prevent repeated step probes during same stall
|
|
let lastRelayedTaskText = ''; // dedup TASK_BOUNDARY relay
|
|
setInterval(async () => {
|
|
pollCount++;
|
|
if (pollCount <= 3 || pollCount % 12 === 0) {
|
|
logToFile(`[POLL#${pollCount}] alive`);
|
|
}
|
|
try {
|
|
const allTraj = await sdk.ls.rawRPC('GetAllCascadeTrajectories', {});
|
|
if (!allTraj?.trajectorySummaries) {
|
|
if (pollCount <= 3)
|
|
logToFile('[POLL] no trajectorySummaries');
|
|
return;
|
|
}
|
|
// ── Filter to sessions owned by THIS window ──
|
|
// Each window claims sessions it sees first via writeRegistration().
|
|
// Only process sessions registered to THIS projectName (or unclaimed ones).
|
|
let bestSession = null;
|
|
let bestSessionId = '';
|
|
let bestModTime = '';
|
|
const regDir = path.join(bridgePath, 'register');
|
|
for (const [sid, data] of Object.entries(allTraj.trajectorySummaries)) {
|
|
// Check if this session is claimed by another project
|
|
const regFile = path.join(regDir, `${sid}.json`);
|
|
if (fs.existsSync(regFile)) {
|
|
try {
|
|
const reg = JSON.parse(fs.readFileSync(regFile, 'utf-8'));
|
|
if (reg.project_name && reg.project_name !== projectName) {
|
|
// Session belongs to another window — skip
|
|
continue;
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
const modTime = data.lastModifiedTime || '';
|
|
if (!bestSession || modTime > bestModTime) {
|
|
bestSession = data;
|
|
bestSessionId = sid;
|
|
bestModTime = modTime;
|
|
}
|
|
}
|
|
if (!bestSession)
|
|
return;
|
|
const currentCount = bestSession.stepCount || 0;
|
|
const currentTitle = (bestSession.summary || 'Untitled').substring(0, 50);
|
|
const isRunning = String(bestSession.status || '').includes('RUNNING');
|
|
// Session changed?
|
|
if (bestSessionId !== activeSessionId) {
|
|
activeSessionId = bestSessionId;
|
|
activeTrajectoryId = bestSession.trajectoryId || '';
|
|
activeSessionTitle = currentTitle;
|
|
lastKnownStepCount = currentCount;
|
|
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
|
|
lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1;
|
|
lastPendingStepIndex = -1;
|
|
stallProbed = false;
|
|
// Don't register here — registration happens lazily in writeChatSnapshot/writePendingApproval
|
|
// to avoid race conditions between multiple extension instances
|
|
// Dump session keys + trajectoryMetadata on session change
|
|
const allKeys = Object.keys(bestSession);
|
|
logToFile(`[SESSION-INIT] id=${activeSessionId.substring(0, 8)} keys=[${allKeys.join(',')}]`);
|
|
const trajMeta = bestSession.trajectoryMetadata;
|
|
if (trajMeta) {
|
|
logToFile(`[SESSION-INIT] trajectoryMetadata=${JSON.stringify(trajMeta).substring(0, 500)}`);
|
|
}
|
|
console.log(`Gravity Bridge: [POLL#${pollCount}] session: ${activeSessionId.substring(0, 8)} "${currentTitle}" steps=${currentCount} ${isRunning ? 'RUNNING' : 'idle'}`);
|
|
return;
|
|
}
|
|
const delta = currentCount - lastKnownStepCount;
|
|
lastKnownStepCount = currentCount;
|
|
if (delta > 0) {
|
|
console.log(`Gravity Bridge: [POLL#${pollCount}] +${delta} steps (${currentCount}) "${currentTitle}"`);
|
|
}
|
|
// Log session state on EVERY poll for diagnostics
|
|
const statusStr = String(bestSession.status || 'UNKNOWN');
|
|
if (pollCount <= 10 || pollCount % 6 === 0 || delta > 0) {
|
|
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
|
|
}
|
|
// ── PRIMARY: Step-probe-based approval detection ──
|
|
// On stall (idle=1, ~5s), probe GetCascadeTrajectorySteps to check WAITING.
|
|
// 775-step limit: probe fails for long sessions → faster stall fallback.
|
|
// ── STALL-BASED approval detection with step probe ──
|
|
const currentModTime = bestSession.lastModifiedTime || bestSession.lastModifiedTimestamp || bestSession.modifiedTime || '';
|
|
const modTimeChanged = currentModTime !== lastModTime;
|
|
const isStall = isRunning && delta === 0;
|
|
// Log modTime on stalls for debugging
|
|
if (isStall && consecutiveIdleCount < 8) {
|
|
logToFile(`[STALL-DBG] idle=${consecutiveIdleCount} modTime='${currentModTime}' changed=${modTimeChanged}`);
|
|
}
|
|
if (delta > 0) {
|
|
sessionStalled = false;
|
|
// Steps progressed — if we had a pending approval, it was handled in AG directly
|
|
if (!sawRunningAfterPending && lastPendingStepIndex >= 0) {
|
|
// Mark pending as auto_resolved so bot can update Discord message
|
|
try {
|
|
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending'))
|
|
.filter((f) => f.endsWith('.json'));
|
|
for (const pf of pendingFiles) {
|
|
const pfPath = path.join(bridgePath, 'pending', pf);
|
|
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
|
|
if (pd.status === 'pending' && pd.step_index === lastPendingStepIndex) {
|
|
pd.status = 'auto_resolved';
|
|
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
|
|
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${pf}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[AUTO-RESOLVE] error: ${e.message}`);
|
|
}
|
|
lastPendingStepIndex = -1;
|
|
}
|
|
consecutiveIdleCount = 0;
|
|
sawRunningAfterPending = true;
|
|
stallProbed = false; // allow re-probe on next stall
|
|
lastModTime = currentModTime;
|
|
}
|
|
else if (isStall) {
|
|
if (modTimeChanged) {
|
|
// lastModifiedTime is still changing = AI is thinking, NOT approval
|
|
consecutiveIdleCount = 0; // Reset!
|
|
stallProbed = false;
|
|
if (pollCount <= 10 || pollCount % 12 === 0) {
|
|
logToFile(`[THINK] step=${currentCount} modTime changing → not stall`);
|
|
}
|
|
}
|
|
else {
|
|
// lastModifiedTime frozen = real stall (approval waiting)
|
|
consecutiveIdleCount++;
|
|
if (consecutiveIdleCount >= 1)
|
|
sessionStalled = true;
|
|
}
|
|
lastModTime = currentModTime;
|
|
// ── Step probe: on stall, fetch latest step via cascadeId (retry until WAITING found) ──
|
|
// CONFIRMED: param='cascadeId', id=sessionId (map key from trajectorySummaries)
|
|
// Retries every 2 polls (~10s) because RUN_COMMAND step may not be created yet
|
|
if (consecutiveIdleCount >= 1 && !stallProbed) {
|
|
try {
|
|
const stepsResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
|
cascadeId: bestSessionId,
|
|
});
|
|
if (stepsResp?.steps?.length > 0) {
|
|
const steps = stepsResp.steps;
|
|
// Diagnostic: compare returned steps vs trajectory stepCount
|
|
logToFile(`[STEP-PROBE] returned=${steps.length} vs trajectory.stepCount=${currentCount}`);
|
|
if (steps.length < currentCount) {
|
|
logToFile(`[STEP-PROBE] ⚠️ 775-limit hit! steps=${steps.length} < stepCount=${currentCount}`);
|
|
}
|
|
// Scan last 5 steps backwards to find WAITING (RUN_COMMAND may not be last)
|
|
let foundWaiting = false;
|
|
for (let si = steps.length - 1; si >= Math.max(0, steps.length - 5); si--) {
|
|
const step = steps[si];
|
|
const stepStatus = step?.status || '';
|
|
const stepType = step?.type || '';
|
|
if (stepStatus === 'CORTEX_STEP_STATUS_WAITING') {
|
|
foundWaiting = true;
|
|
// Extract command from metadata.toolCall or direct fields
|
|
const toolCall = step?.metadata?.toolCall;
|
|
const toolName = toolCall?.name || stepType.replace('CORTEX_STEP_TYPE_', '').toLowerCase();
|
|
let command = toolName;
|
|
// Parse argumentsJson for command details
|
|
if (toolCall?.argumentsJson) {
|
|
try {
|
|
const args = JSON.parse(toolCall.argumentsJson);
|
|
if (args.CommandLine) {
|
|
command = `${toolName}: ${args.CommandLine.substring(0, 150)}`;
|
|
}
|
|
else if (args.TargetFile) {
|
|
command = `${toolName}: ${args.TargetFile.split(/[\\/]/).pop()}`;
|
|
}
|
|
else {
|
|
command = `${toolName}: ${Object.keys(args).join(', ')}`;
|
|
}
|
|
}
|
|
catch {
|
|
command = toolName;
|
|
}
|
|
}
|
|
const description = `Step #${si} (${stepType.replace('CORTEX_STEP_TYPE_', '')})`;
|
|
logToFile(`[STEP-PROBE] ★ WAITING! step=${si} type=${stepType} cmd='${command}'`);
|
|
if (si !== lastPendingStepIndex) {
|
|
stallProbed = true; // found WAITING — stop retrying
|
|
lastPendingStepIndex = si;
|
|
lastPendingTime = Date.now();
|
|
sawRunningAfterPending = false;
|
|
writePendingApproval({
|
|
conversation_id: activeSessionId,
|
|
command,
|
|
description,
|
|
step_type: toolName,
|
|
step_index: si,
|
|
source: 'step_probe',
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (!foundWaiting) {
|
|
const lastStep = steps[steps.length - 1];
|
|
logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`);
|
|
// CRITICAL: Reset sessionStalled when step_probe confirms no WAITING.
|
|
// Without this, sessionStalled stays true during long AI generations
|
|
// (PLANNER_RESPONSE with delta=0), causing false positive "Run" detections.
|
|
sessionStalled = false;
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[STEP-PROBE] error: ${e.message}`);
|
|
}
|
|
}
|
|
// Stall fallback REMOVED — step probe is sole fallback source
|
|
// (stall fallback was generating false positives and is now redundant)
|
|
}
|
|
else if (!isRunning) {
|
|
// ── Error detection: probe when session transitions from RUNNING→idle ──
|
|
if (consecutiveIdleCount > 0 && !stallProbed) {
|
|
// Was running, now idle — possible error. Probe once.
|
|
try {
|
|
const stepsResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
|
cascadeId: bestSessionId,
|
|
});
|
|
if (stepsResp?.steps?.length > 0) {
|
|
const steps = stepsResp.steps;
|
|
// Check last 3 steps for error/failed status
|
|
for (let si = steps.length - 1; si >= Math.max(0, steps.length - 3); si--) {
|
|
const step = steps[si];
|
|
const stepStatus = step?.status || '';
|
|
const stepType = step?.type || '';
|
|
if (stepStatus.includes('ERROR') || stepStatus.includes('FAILED')) {
|
|
const toolCall = step?.metadata?.toolCall;
|
|
const toolName = toolCall?.name || stepType.replace('CORTEX_STEP_TYPE_', '').toLowerCase();
|
|
let command = `⚠️ Error: ${toolName}`;
|
|
if (toolCall?.argumentsJson) {
|
|
try {
|
|
const args = JSON.parse(toolCall.argumentsJson);
|
|
if (args.CommandLine)
|
|
command = `⚠️ Error: ${args.CommandLine.substring(0, 100)}`;
|
|
else if (args.TargetFile)
|
|
command = `⚠️ Error: ${args.TargetFile.split(/[\\/]/).pop()}`;
|
|
}
|
|
catch { }
|
|
}
|
|
const description = `Step #${si} ${stepStatus} — Retry?`;
|
|
logToFile(`[STEP-PROBE] ★ ERROR! step=${si} status=${stepStatus} type=${stepType}`);
|
|
if (si !== lastPendingStepIndex) {
|
|
stallProbed = true;
|
|
lastPendingStepIndex = si;
|
|
writePendingApproval({
|
|
conversation_id: activeSessionId,
|
|
command,
|
|
description,
|
|
step_type: 'error_recovery',
|
|
step_index: si,
|
|
source: 'step_probe_error',
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[STEP-PROBE-ERR] error check: ${e.message}`);
|
|
}
|
|
}
|
|
consecutiveIdleCount = 0;
|
|
lastModTime = currentModTime;
|
|
}
|
|
// ── Process latestNotifyUserStep ──
|
|
const notifyStep = bestSession.latestNotifyUserStep;
|
|
if (notifyStep && notifyStep.stepIndex > lastNotifyStepIndex) {
|
|
lastNotifyStepIndex = notifyStep.stepIndex;
|
|
const content = notifyStep.step?.notifyUser?.notificationContent || '';
|
|
// Filter: only relay meaningful notifications (skip trivial ones)
|
|
if (content.length > 50) {
|
|
writeChatSnapshot(`📣 **알림** (step ${notifyStep.stepIndex})\n\n${content}`);
|
|
console.log(`Gravity Bridge: [POLL#${pollCount}] NOTIFY step=${notifyStep.stepIndex} ${content.length} chars`);
|
|
}
|
|
else if (content.length > 0) {
|
|
logToFile(`[POLL] NOTIFY skipped (too short: ${content.length} chars): ${content.substring(0, 80)}`);
|
|
}
|
|
}
|
|
// ── Process latestTaskBoundaryStep ──
|
|
const taskStep = bestSession.latestTaskBoundaryStep;
|
|
if (taskStep && taskStep.stepIndex > lastTaskStepIndex) {
|
|
lastTaskStepIndex = taskStep.stepIndex;
|
|
const tb = taskStep.step?.taskBoundary;
|
|
if (tb?.taskName) {
|
|
const mode = tb.mode ? tb.mode.replace('AGENT_MODE_', '') : '';
|
|
// Filter: skip status-only updates with same task name (noise)
|
|
const taskText = `${tb.taskName}|${tb.taskStatus || ''}`;
|
|
if (taskText !== lastRelayedTaskText) {
|
|
lastRelayedTaskText = taskText;
|
|
writeChatSnapshot(`📋 **[${mode}] ${tb.taskName}**\n${tb.taskStatus || ''}\n\n${tb.taskSummary || ''}`);
|
|
console.log(`Gravity Bridge: [POLL#${pollCount}] TASK step=${taskStep.stepIndex} "${tb.taskName}"`);
|
|
}
|
|
else {
|
|
logToFile(`[POLL] TASK skipped (duplicate): "${tb.taskName}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
if (pollCount <= 5 || pollCount % 20 === 0) {
|
|
console.log(`Gravity Bridge: [POLL#${pollCount}] error: ${e.message}`);
|
|
}
|
|
}
|
|
}, 5000);
|
|
}
|
|
// ─── Response Watcher (Discord approval → Antigravity RPC) ───
|
|
let responseWatcher = null;
|
|
function setupResponseWatcher() {
|
|
const responseDir = path.join(bridgePath, 'response');
|
|
if (!fs.existsSync(responseDir)) {
|
|
fs.mkdirSync(responseDir, { recursive: true });
|
|
}
|
|
try {
|
|
responseWatcher = fs.watch(responseDir, (event, filename) => {
|
|
if (filename && filename.endsWith('.json') && event === 'rename') {
|
|
const fp = path.join(responseDir, filename);
|
|
if (fs.existsSync(fp)) {
|
|
// Check if this response belongs to our project
|
|
const rid = filename.replace('.json', '');
|
|
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
|
|
if (fs.existsSync(pendingFile)) {
|
|
try {
|
|
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
|
|
if (pending.project_name && pending.project_name !== projectName) {
|
|
logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
|
|
return; // Not our project
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
setTimeout(() => processResponseFile(fp), 300);
|
|
}
|
|
}
|
|
});
|
|
console.log('Gravity Bridge: response watcher started');
|
|
}
|
|
catch (e) {
|
|
console.log(`Gravity Bridge: response watcher failed: ${e.message}`);
|
|
}
|
|
}
|
|
async function processResponseFile(filePath) {
|
|
try {
|
|
// Gracefully handle files already consumed by HTTP handler
|
|
if (!fs.existsSync(filePath)) {
|
|
// HTTP GET /response/:rid already served and deleted this file — skip silently
|
|
return;
|
|
}
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const resp = JSON.parse(content);
|
|
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved}`;
|
|
console.log(`Gravity Bridge: ${msg}`);
|
|
logToFile(msg);
|
|
// Find matching pending request
|
|
const pendingDir = path.join(bridgePath, 'pending');
|
|
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
|
|
let sessionId = '';
|
|
let isDomObserver = false;
|
|
if (fs.existsSync(pendingFile)) {
|
|
try {
|
|
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
|
|
sessionId = pending.conversation_id || '';
|
|
isDomObserver = pending.auto_detected === true
|
|
|| pending.source === 'dom_observer';
|
|
}
|
|
catch { }
|
|
}
|
|
// ═══ MULTI-STRATEGY APPROVAL (v2.1) ═══
|
|
// Tries multiple methods sequentially with detailed logging.
|
|
// DOM observer: renderer handles clicking via pollResponse
|
|
// Step probe/stall: try RPC → VS Code commands → log results
|
|
const approved = resp.approved;
|
|
if (isDomObserver) {
|
|
// DOM observer path: renderer polls /response/:rid and clicks directly
|
|
logToFile(`[RESPONSE] renderer-handled approval (rid=${resp.request_id})`);
|
|
}
|
|
else {
|
|
// Step probe path: run ALL approval strategies (5 vectors → 30+ methods)
|
|
logToFile(`[RESPONSE] step_probe → running tryApprovalStrategies(${approved}, ${activeSessionId.substring(0, 8)})`);
|
|
const strategyResult = await tryApprovalStrategies(approved, activeSessionId);
|
|
logToFile(`[RESPONSE] strategy result: ${strategyResult}`);
|
|
}
|
|
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
|
|
// Cleanup response file
|
|
// CRITICAL: DOM observer responses must NOT be deleted here!
|
|
// The renderer polls GET /response/:rid to discover the approval.
|
|
// If we delete the file before the renderer polls, it gets ENOENT.
|
|
// The HTTP handler (/response/:rid) deletes after serving to renderer.
|
|
if (!isDomObserver) {
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
catch (e) {
|
|
const log = `[RESPONSE] error: ${e.message}`;
|
|
console.log(`Gravity Bridge: ${log}`);
|
|
logToFile(log);
|
|
}
|
|
}
|
|
/**
|
|
* Extract AI text from a PLANNER_RESPONSE step.
|
|
* Known structure: {type, status, metadata, plannerResponse, ephemeralMessage, ...}
|
|
* ephemeralMessage = system prompt (SKIP), plannerResponse = AI content
|
|
*/
|
|
function extractPlannerText(step) {
|
|
if (!step) {
|
|
return null;
|
|
}
|
|
// Fields to SKIP — not user-facing content
|
|
const SKIP_FIELDS = new Set([
|
|
'thinking', 'thinkingSignature', 'stopReason', 'type', 'status', 'metadata',
|
|
'ephemeralMessage', 'generatorModel', 'requestedModel',
|
|
'executionId', 'sourceTrajectoryStepInfo', 'stepIndex',
|
|
'viewableAt', 'createdAt', 'finishedGeneratingAt',
|
|
'lastCompletedChunkAt', 'source', 'stepGenerationVersion'
|
|
]);
|
|
// plannerResponse can be string or object
|
|
const pr = step.plannerResponse;
|
|
if (typeof pr === 'string' && pr.length > 10) {
|
|
return filterEphemeral(pr);
|
|
}
|
|
if (pr && typeof pr === 'object') {
|
|
// Try known content fields first (NOT thinking/stopReason)
|
|
const text = pr.content || pr.text || pr.summary || pr.message || pr.response || pr.output;
|
|
if (typeof text === 'string' && text.length > 10) {
|
|
return filterEphemeral(text);
|
|
}
|
|
// Search other fields, but skip non-content ones
|
|
for (const key of Object.keys(pr)) {
|
|
if (SKIP_FIELDS.has(key))
|
|
continue;
|
|
const val = pr[key];
|
|
if (typeof val === 'string' && val.length > 50) { // Higher threshold
|
|
const filtered = filterEphemeral(val);
|
|
if (filtered) {
|
|
console.log(`Gravity Bridge: [DEBUG] planner text in plannerResponse.${key} (${filtered.length} chars)`);
|
|
return filtered;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Try other step fields (skip known non-content)
|
|
for (const key of Object.keys(step)) {
|
|
if (SKIP_FIELDS.has(key) || key === 'plannerResponse')
|
|
continue;
|
|
const val = step[key];
|
|
if (typeof val === 'string' && val.length > 50) {
|
|
const filtered = filterEphemeral(val);
|
|
if (filtered) {
|
|
console.log(`Gravity Bridge: [DEBUG] planner text in step.${key}`);
|
|
return filtered;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
/** Filter out system ephemeral messages and non-content strings. */
|
|
function filterEphemeral(text) {
|
|
if (!text || text.length < 10) {
|
|
return null;
|
|
}
|
|
// Skip system prompt metadata
|
|
if (text.includes('<EPHEMERAL_MESSAGE>') || text.includes('<ephemeral_message>')) {
|
|
return null;
|
|
}
|
|
if (text.includes('artifact_reminder') || text.includes('active_task_reminder')) {
|
|
return null;
|
|
}
|
|
if (text.includes('no_active_task_reminder')) {
|
|
return null;
|
|
}
|
|
// Skip base64/crypto strings (no spaces, mostly alphanumeric)
|
|
if (!text.includes(' ') && /^[A-Za-z0-9+/=_\-]{50,}$/.test(text)) {
|
|
return null;
|
|
}
|
|
return text;
|
|
}
|
|
/** Extract human-readable command from a tool call step's data. */
|
|
function extractToolCommand(stepData) {
|
|
// Try common step data shapes from protobuf
|
|
if (stepData.runCommand) {
|
|
return stepData.runCommand.commandLine || stepData.runCommand.command || 'Run Command';
|
|
}
|
|
if (stepData.writeToFile) {
|
|
const target = stepData.writeToFile.targetFile || stepData.writeToFile.filePath || 'file';
|
|
return `Write: ${target.split(/[\\/]/).pop()}`;
|
|
}
|
|
if (stepData.codeAction) {
|
|
const fp = stepData.codeAction.filePath || '';
|
|
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
|
|
}
|
|
if (stepData.replaceFileContent || stepData.multiReplaceFileContent) {
|
|
const d = stepData.replaceFileContent || stepData.multiReplaceFileContent;
|
|
const fp = d.targetFile || d.filePath || '';
|
|
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
|
|
}
|
|
if (stepData.sendCommandInput) {
|
|
return `Send Input: ${(stepData.sendCommandInput.input || '').substring(0, 50)}`;
|
|
}
|
|
// Generic fallback: use first key name
|
|
const keys = Object.keys(stepData).filter(k => k !== 'status' && k !== 'stepStatus');
|
|
return keys.length > 0 ? keys[0] : 'Unknown tool call';
|
|
}
|
|
/** Extract description from a tool call step for Discord display. */
|
|
function extractToolDescription(stepData, sessionTitle, stepIndex) {
|
|
const parts = [`Step #${stepIndex}`, `Session: "${sessionTitle}"`];
|
|
// Try to get code/command content for context
|
|
if (stepData.runCommand) {
|
|
const cmd = stepData.runCommand.commandLine || stepData.runCommand.command || '';
|
|
if (cmd)
|
|
parts.push(`Command: ${cmd.substring(0, 200)}`);
|
|
}
|
|
if (stepData.writeToFile?.targetFile) {
|
|
parts.push(`File: ${stepData.writeToFile.targetFile}`);
|
|
}
|
|
if (stepData.codeAction?.filePath) {
|
|
parts.push(`File: ${stepData.codeAction.filePath}`);
|
|
}
|
|
return parts.join('\n');
|
|
}
|
|
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
|
|
function writePendingApproval(data) {
|
|
try {
|
|
const pendingDir = path.join(bridgePath, 'pending');
|
|
if (!fs.existsSync(pendingDir)) {
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
}
|
|
// ── Dedup: if DOM observer already created a "Run"-only pending, MERGE detailed info into it ──
|
|
const nowMs = Date.now();
|
|
const DEDUP_WINDOW_MS = 15_000; // 15 second dedup window
|
|
try {
|
|
const existingFiles = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.json'));
|
|
for (const ef of existingFiles) {
|
|
const efPath = path.join(pendingDir, ef);
|
|
const existing = JSON.parse(fs.readFileSync(efPath, 'utf-8'));
|
|
if (existing.source === 'dom_observer' && existing.status === 'pending') {
|
|
const age = nowMs - (existing.timestamp * 1000);
|
|
if (age < DEDUP_WINDOW_MS && age >= 0) {
|
|
// MERGE: update DOM observer pending with detailed step_probe info
|
|
existing.command = data.command;
|
|
existing.description = data.description;
|
|
if (data.step_type)
|
|
existing.step_type = data.step_type;
|
|
if (data.step_index !== undefined)
|
|
existing.step_index = data.step_index;
|
|
existing.source = 'dom_observer+step_probe'; // mark as merged
|
|
fs.writeFileSync(efPath, JSON.stringify(existing, null, 2), 'utf-8');
|
|
logToFile(`[DEDUP] MERGED step_probe info into DOM pending: ${ef} cmd="${data.command.substring(0, 60)}"`);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (dedupErr) {
|
|
logToFile(`[DEDUP] check error (non-fatal): ${dedupErr.message}`);
|
|
}
|
|
const id = nowMs.toString();
|
|
const payload = {
|
|
request_id: id,
|
|
conversation_id: data.conversation_id,
|
|
command: data.command,
|
|
description: data.description,
|
|
timestamp: nowMs / 1000,
|
|
status: 'pending',
|
|
discord_message_id: 0,
|
|
project_name: projectName,
|
|
...(data.step_type ? { step_type: data.step_type } : {}),
|
|
...(data.step_index !== undefined ? { step_index: data.step_index } : {}),
|
|
...(data.source ? { source: data.source } : {}),
|
|
};
|
|
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
|
|
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
|
|
// Register session → project mapping (correct because projectName is per-window)
|
|
if (data.conversation_id) {
|
|
writeRegistration(data.conversation_id);
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.log(`Gravity Bridge: pending write error: ${e.message}`);
|
|
}
|
|
}
|
|
// ─── Multi-Strategy Approval Execution ───
|
|
/**
|
|
* Try multiple approval methods sequentially.
|
|
* Returns a string describing which method succeeded (or all failed).
|
|
*
|
|
* Strategy order (most reliable first):
|
|
* 1. HandleCascadeUserInteraction RPC (cross-platform, no focus)
|
|
* 2. VS Code accept/reject commands (focus-dependent)
|
|
* 3. Log failure for manual intervention
|
|
*/
|
|
async function tryApprovalStrategies(approved, sessionId) {
|
|
const action = approved ? 'APPROVE' : 'REJECT';
|
|
logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)}`);
|
|
// ── Dynamic Command Discovery (log what's available during WAITING state) ──
|
|
let approvalCmdList = [];
|
|
try {
|
|
const allCmds = await vscode.commands.getCommands(true);
|
|
const agCmds = allCmds.filter((c) => c.startsWith('antigravity.'));
|
|
approvalCmdList = agCmds.filter((c) => {
|
|
const lower = c.toLowerCase();
|
|
return lower.includes('accept') || lower.includes('reject') || lower.includes('approve')
|
|
|| lower.includes('terminal') || lower.includes('run') || lower.includes('step')
|
|
|| lower.includes('cascade') || lower.includes('action');
|
|
});
|
|
logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`);
|
|
for (const c of approvalCmdList) {
|
|
logToFile(`[APPROVAL-CMD-CHECK] → ${c}`);
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`);
|
|
}
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source)
|
|
// HandleCascadeUserInteractionRequest:
|
|
// cascade_id: string
|
|
// interaction: CascadeUserInteraction {
|
|
// trajectory_id, step_index,
|
|
// oneof: { run_command: CascadeRunCommandInteraction { confirm: bool } }
|
|
// }
|
|
// ConnectRPC uses camelCase for JSON encoding of snake_case proto fields
|
|
// ══════════════════════════════════════════════════════════
|
|
if (sdk && approved) {
|
|
const protoVariants = [
|
|
// Variant A: camelCase JSON (ConnectRPC default)
|
|
{
|
|
cascadeId: sessionId,
|
|
interaction: {
|
|
trajectoryId: activeTrajectoryId || sessionId,
|
|
stepIndex: lastPendingStepIndex,
|
|
runCommand: { confirm: true },
|
|
},
|
|
},
|
|
// Variant B: snake_case JSON (proto native)
|
|
{
|
|
cascade_id: sessionId,
|
|
interaction: {
|
|
trajectory_id: activeTrajectoryId || sessionId,
|
|
step_index: lastPendingStepIndex,
|
|
run_command: { confirm: true },
|
|
},
|
|
},
|
|
// Variant C: camelCase, without trajectoryId (maybe optional)
|
|
{
|
|
cascadeId: sessionId,
|
|
interaction: {
|
|
stepIndex: lastPendingStepIndex,
|
|
runCommand: { confirm: true },
|
|
},
|
|
},
|
|
// Variant D: camelCase, confirm only (minimal)
|
|
{
|
|
cascadeId: sessionId,
|
|
interaction: {
|
|
runCommand: { confirm: true },
|
|
},
|
|
},
|
|
// Variant E: snake_case minimal
|
|
{
|
|
cascade_id: sessionId,
|
|
interaction: {
|
|
run_command: { confirm: true },
|
|
},
|
|
},
|
|
];
|
|
for (let i = 0; i < protoVariants.length; i++) {
|
|
try {
|
|
const payload = protoVariants[i];
|
|
logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 200)})`);
|
|
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', payload);
|
|
logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
|
|
return `RPC-PROTO-${i}:HandleCascadeUserInteraction`;
|
|
}
|
|
catch (e) {
|
|
// Capture FULL error message (critical for diagnostics)
|
|
logToFile(`[APPROVAL-PROTO-${i}] ❌ ${e.message.substring(0, 300)}`);
|
|
}
|
|
}
|
|
}
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 0A: executeCascadeAction
|
|
// ══════════════════════════════════════════════════════════
|
|
const cascadeActionVariants = [
|
|
{ action: approved ? 'accept' : 'reject' },
|
|
{ action: approved ? 'ACCEPT' : 'REJECT' },
|
|
{ action: approved ? 'approve' : 'deny' },
|
|
{ action: approved ? 'run' : 'reject' },
|
|
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
|
|
{ cascadeId: sessionId, type: approved ? 'accept' : 'reject' },
|
|
approved ? 'accept' : 'reject', // plain string arg
|
|
approved ? 1 : 0, // numeric arg
|
|
];
|
|
for (let i = 0; i < cascadeActionVariants.length; i++) {
|
|
try {
|
|
const arg = cascadeActionVariants[i];
|
|
logToFile(`[APPROVAL-0A-${i}] executeCascadeAction(${JSON.stringify(arg)})`);
|
|
const result = await vscode.commands.executeCommand('antigravity.executeCascadeAction', arg);
|
|
logToFile(`[APPROVAL-0A-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
|
|
return `CMD-0A:executeCascadeAction(variant=${i})`;
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-0A-${i}] ❌ ${e.message.substring(0, 100)}`);
|
|
}
|
|
}
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 0B: Direct approval commands (brute-force try all 7 SDK commands)
|
|
// ══════════════════════════════════════════════════════════
|
|
const commandsToTry = approved
|
|
? [
|
|
'antigravity.terminalCommand.run',
|
|
'antigravity.terminalCommand.accept',
|
|
'antigravity.agent.acceptAgentStep',
|
|
'antigravity.command.accept',
|
|
]
|
|
: [
|
|
'antigravity.terminalCommand.reject',
|
|
'antigravity.agent.rejectAgentStep',
|
|
'antigravity.command.reject',
|
|
];
|
|
for (const cmd of commandsToTry) {
|
|
// Try with no args, then with sessionId
|
|
for (const args of [[], [sessionId], [{ cascadeId: sessionId }]]) {
|
|
try {
|
|
logToFile(`[APPROVAL-0B] ${cmd}(${JSON.stringify(args)})`);
|
|
const result = await vscode.commands.executeCommand(cmd, ...args);
|
|
logToFile(`[APPROVAL-0B] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
|
|
return `CMD-0B:${cmd}`;
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-0B] ❌ ${e.message.substring(0, 80)}`);
|
|
}
|
|
}
|
|
}
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 0C: Electron webContents access (pierce webview isolation)
|
|
// ══════════════════════════════════════════════════════════
|
|
try {
|
|
logToFile(`[APPROVAL-0C] Attempting Electron webContents access...`);
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
const electron = require('electron');
|
|
const remote = electron.remote;
|
|
if (remote) {
|
|
logToFile(`[APPROVAL-0C] electron.remote available!`);
|
|
const allWC = remote.webContents.getAllWebContents();
|
|
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
|
|
for (const wc of allWC) {
|
|
const wcUrl = wc.getURL() || '';
|
|
logToFile(`[APPROVAL-0C] wc: ${wcUrl.substring(0, 100)}`);
|
|
if (wcUrl.includes('vscode-webview://') || wcUrl.includes('webview')) {
|
|
const clickScript = approved
|
|
? `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Run|Accept|Allow/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`
|
|
: `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Reject|Deny|Cancel/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`;
|
|
const clickResult = await wc.executeJavaScript(clickScript);
|
|
logToFile(`[APPROVAL-0C] executeJavaScript result: ${clickResult}`);
|
|
if (clickResult && clickResult.startsWith('clicked:')) {
|
|
return `ELECTRON-0C:webContents(${clickResult})`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
logToFile(`[APPROVAL-0C] electron.remote NOT available`);
|
|
// Try without remote (main process context)
|
|
const wc = electron.webContents;
|
|
if (wc && typeof wc.getAllWebContents === 'function') {
|
|
logToFile(`[APPROVAL-0C] Direct electron.webContents available!`);
|
|
const allWC = wc.getAllWebContents();
|
|
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
|
|
}
|
|
else {
|
|
logToFile(`[APPROVAL-0C] No webContents access from Extension Host`);
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-0C] ❌ ${e.message.substring(0, 150)}`);
|
|
}
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 0D: sendChatActionMessage (may route to agent)
|
|
// ══════════════════════════════════════════════════════════
|
|
const chatActionVariants = [
|
|
{ action: approved ? 'accept' : 'reject', cascadeId: sessionId },
|
|
{ message: approved ? 'accept' : 'reject', conversationId: sessionId },
|
|
approved ? 'accept' : 'reject',
|
|
];
|
|
for (let i = 0; i < chatActionVariants.length; i++) {
|
|
try {
|
|
logToFile(`[APPROVAL-0D-${i}] sendChatActionMessage(${JSON.stringify(chatActionVariants[i])})`);
|
|
const result = await vscode.commands.executeCommand('antigravity.sendChatActionMessage', chatActionVariants[i]);
|
|
logToFile(`[APPROVAL-0D-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
|
|
return `CMD-0D:sendChatActionMessage(variant=${i})`;
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-0D-${i}] ❌ ${e.message.substring(0, 80)}`);
|
|
}
|
|
}
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 1: HandleCascadeUserInteraction RPC (expanded variants)
|
|
// ══════════════════════════════════════════════════════════
|
|
if (sdk) {
|
|
const rpcVariants = [
|
|
// Original variants
|
|
{ cascadeId: sessionId, approved: approved },
|
|
{ cascadeId: sessionId, stepAction: approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT' },
|
|
{ cascadeId: sessionId, userAction: approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED' },
|
|
// New variants — ConnectRPC protobuf field naming conventions
|
|
{ cascade_id: sessionId, accepted: approved },
|
|
{ cascadeId: sessionId, accepted: approved },
|
|
{ cascade_id: sessionId, user_action: approved ? 1 : 2 }, // enum as int
|
|
{ cascadeId: sessionId, interaction: approved ? 'ACCEPT' : 'REJECT' },
|
|
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
|
|
// With step index from last known waiting step
|
|
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, accepted: approved },
|
|
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, approved: approved },
|
|
];
|
|
for (let i = 0; i < rpcVariants.length; i++) {
|
|
try {
|
|
logToFile(`[APPROVAL-1-${i}] HandleCascadeUserInteraction(${JSON.stringify(rpcVariants[i]).substring(0, 120)})`);
|
|
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', rpcVariants[i]);
|
|
logToFile(`[APPROVAL-1-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
|
|
return `RPC-1-${i}:HandleCascadeUserInteraction`;
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-1-${i}] ❌ ${e.message.substring(0, 80)}`);
|
|
}
|
|
}
|
|
}
|
|
// ── Strategy 2: Renderer DOM Click via HTTP Bridge (kept as fallback) ──
|
|
try {
|
|
const triggerAction = approved ? 'approve' : 'reject';
|
|
logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`);
|
|
clickTrigger = { action: triggerAction, timestamp: Date.now() };
|
|
logToFile(`[APPROVAL-2] ✅ clickTrigger set — renderer will poll and click within 2s`);
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`);
|
|
}
|
|
// ── Strategy 3: ResolveOutstandingSteps (REJECT ONLY — this CANCELS!) ──
|
|
if (!approved && sdk) {
|
|
try {
|
|
logToFile(`[APPROVAL-3] ResolveOutstandingSteps (REJECT/CANCEL only!)`);
|
|
const rpcResult = await sdk.ls.rawRPC('ResolveOutstandingSteps', {
|
|
cascadeId: sessionId,
|
|
});
|
|
logToFile(`[APPROVAL-3] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
|
|
return `RPC-3:ResolveOutstandingSteps(cancel)`;
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-3] ❌ FAIL: ${e.message}`);
|
|
}
|
|
}
|
|
logToFile(`[APPROVAL] ⚠️ ALL strategies attempted — check logs for results`);
|
|
return `ALL_ATTEMPTED:${action}`;
|
|
}
|
|
// ─── Activation ───
|
|
async function activate(context) {
|
|
console.log('Gravity Bridge: activating...');
|
|
// Project detection
|
|
projectName = detectProjectName();
|
|
console.log(`Gravity Bridge: project "${projectName}"`);
|
|
// Bridge path
|
|
const config = vscode.workspace.getConfiguration('gravityBridge');
|
|
const configPath = config.get('bridgePath');
|
|
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
|
|
ensureBridgeDir();
|
|
console.log(`Gravity Bridge: bridge path: ${bridgePath}`);
|
|
// Status bar
|
|
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
|
statusBar.text = '$(sync~spin) Bridge';
|
|
statusBar.tooltip = `Gravity Bridge: ${projectName}`;
|
|
statusBar.show();
|
|
context.subscriptions.push(statusBar);
|
|
// Initialize SDK
|
|
const sdkReady = await initSDK(context);
|
|
if (sdkReady) {
|
|
// ── Command Discovery Diagnostic ──
|
|
// Enumerate ALL antigravity.* commands to find correct approval command names
|
|
try {
|
|
const allCmds = await vscode.commands.getCommands(true);
|
|
const agCmds = allCmds.filter((c) => c.startsWith('antigravity.'));
|
|
logToFile(`[CMD-DISCOVERY] Total antigravity.* commands: ${agCmds.length}`);
|
|
// Log approval-related commands specifically
|
|
const approvalKeywords = ['accept', 'reject', 'approve', 'terminal', 'agent', 'cascade', 'step', 'run', 'command.'];
|
|
const relevantCmds = agCmds.filter((c) => approvalKeywords.some(kw => c.toLowerCase().includes(kw)));
|
|
logToFile(`[CMD-DISCOVERY] Approval-related commands (${relevantCmds.length}):`);
|
|
for (const cmd of relevantCmds) {
|
|
logToFile(`[CMD-DISCOVERY] → ${cmd}`);
|
|
}
|
|
// Also dump ALL commands for full reference
|
|
logToFile(`[CMD-DISCOVERY] ALL antigravity.* commands:`);
|
|
for (const cmd of agCmds) {
|
|
logToFile(`[CMD-DISCOVERY] ${cmd}`);
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[CMD-DISCOVERY] error: ${e.message}`);
|
|
}
|
|
setupMonitor(); // Now just logs that monitor is disabled
|
|
setupApprovalObserver(); // DOM observer via SDK IntegrationManager
|
|
statusBar.text = '$(check) Bridge';
|
|
statusBar.tooltip = `Gravity Bridge: ${projectName} (POLL + Observer active)`;
|
|
// Register SDK-powered commands
|
|
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.approve', async () => {
|
|
try {
|
|
await sdk.cascade.acceptStep();
|
|
vscode.window.showInformationMessage('Gravity Bridge: Step approved');
|
|
}
|
|
catch (e) {
|
|
vscode.window.showErrorMessage(`Approve failed: ${e.message}`);
|
|
}
|
|
}), vscode.commands.registerCommand('gravityBridge.reject', async () => {
|
|
try {
|
|
await sdk.cascade.rejectStep();
|
|
vscode.window.showInformationMessage('Gravity Bridge: Step rejected');
|
|
}
|
|
catch (e) {
|
|
vscode.window.showErrorMessage(`Reject failed: ${e.message}`);
|
|
}
|
|
}));
|
|
}
|
|
else {
|
|
statusBar.text = '$(warning) Bridge (no SDK)';
|
|
console.log('Gravity Bridge: SDK not available, file-based mode only');
|
|
}
|
|
// Watch commands directory
|
|
watchCommandsDir();
|
|
// Watch response directory for approval interactions
|
|
setupResponseWatcher();
|
|
// Register basic commands
|
|
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.start', () => {
|
|
isActive = true;
|
|
statusBar.text = sdkReady ? '$(check) Bridge SDK' : '$(sync~spin) Bridge';
|
|
vscode.window.showInformationMessage(`Gravity Bridge started for "${projectName}"`);
|
|
}), vscode.commands.registerCommand('gravityBridge.stop', () => {
|
|
isActive = false;
|
|
// SDK monitor is disabled, no need to stop
|
|
statusBar.text = '$(circle-slash) Bridge OFF';
|
|
vscode.window.showInformationMessage('Gravity Bridge stopped');
|
|
}), vscode.commands.registerCommand('gravityBridge.connect', async () => {
|
|
if (!sdk) {
|
|
vscode.window.showErrorMessage('SDK not initialized');
|
|
return;
|
|
}
|
|
try {
|
|
const sessions = await sdk.cascade.getSessions();
|
|
const items = sessions.map((s) => ({
|
|
label: s.title || 'Untitled',
|
|
description: `step ${s.stepCount} • ${s.id?.substring(0, 8)}`,
|
|
sessionId: s.id,
|
|
}));
|
|
const pick = await vscode.window.showQuickPick(items, {
|
|
placeHolder: 'Select a conversation to connect'
|
|
});
|
|
if (pick) {
|
|
await sdk.cascade.focusSession(pick.sessionId);
|
|
vscode.window.showInformationMessage(`Connected to: ${pick.label}`);
|
|
}
|
|
}
|
|
catch (e) {
|
|
vscode.window.showErrorMessage(`Connect failed: ${e.message}`);
|
|
}
|
|
}));
|
|
// Cleanup
|
|
context.subscriptions.push({
|
|
dispose: () => {
|
|
if (sdk) {
|
|
try {
|
|
sdk.dispose();
|
|
}
|
|
catch { }
|
|
}
|
|
if (watcher) {
|
|
watcher.close();
|
|
}
|
|
if (commandsWatcher) {
|
|
commandsWatcher.close();
|
|
}
|
|
}
|
|
});
|
|
console.log('Gravity Bridge: ✅ activated');
|
|
isActive = true;
|
|
}
|
|
function deactivate() {
|
|
if (sdk) {
|
|
try {
|
|
sdk.dispose();
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
//# sourceMappingURL=extension.js.map
|