Files
gravity_control/extension/out/extension.js
CD 4497e966b9 feat(bridge): renderer DOM click approval + command discovery diagnostic
- CMD-DISCOVERY: enumerate all antigravity.* commands at activation (72) and during WAITING state (119)
- APPROVAL-CMD-CHECK: re-check commands inside tryApprovalStrategies for dynamic registration
- Confirmed: ALL 7 SDK approval commands NOT REGISTERED in current AG build
- Confirmed: sendChatActionMessage, executeCascadeAction also NOT REGISTERED
- Replaced failed keyboard simulation (Strategy 2) with renderer DOM click approach:
  - Added clickTrigger variable + GET /trigger-click HTTP endpoint
  - Renderer polls /trigger-click every 1s, clicks Run/Accept button via DOM
- Updated known-issues.md with comprehensive findings
- Added devlog entry 20260309-002
2026-03-09 15:09:13 +09:00

1715 lines
76 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 = '';
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] workbench.html patched (needs reload)');
}
// Also patch workbench-jetski-agent.html (Antigravity's actual entry point!)
// IMPORTANT: vscode-file:// does NOT serve custom .js files (silent 404),
// so we MUST inline the script directly into the HTML.
const scriptDir = path.dirname(scriptPath);
const jetskiHtml = path.join(scriptDir, 'workbench-jetski-agent.html');
try {
if (fs.existsSync(jetskiHtml)) {
let html = fs.readFileSync(jetskiHtml, 'utf8');
// Remove old external script tag if present
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 jetski HTML');
}
// 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] jetski HTML inline script UPDATED');
}
else {
html = html.replace('</html>', `\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
logToFile('[OBSERVER] jetski HTML inline script INSERTED');
}
fs.writeFileSync(jetskiHtml, html, 'utf8');
}
}
catch (e) {
logToFile(`[OBSERVER] jetski 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;
/** 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);
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}" 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'));
fs.unlinkSync(respFile);
logToFile(`[HTTP] response sent: ${rid} approved=${data.approved}`);
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 /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 v2: Approval Observer (MutationObserver-first, throttled) ──
(function(){
'use strict';
var BASE='',_obs=false,_sent={},_ready=false;
var _scanScheduled=false,_lastScanTs=0;
var THROTTLE_MS=100;
var CLEANUP_MS=300000;
function log(m){console.log('[GB Observer] '+m);}
log('v2 Script loaded — discovering bridge port...');
// ── 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();}
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:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'},
{re:/^Continue$/i, type:'continue'},
{re:/^Proceed$/i, type:'continue'},
];
// Reject button patterns for finding the counterpart
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/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 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 ──
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;
// 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
var bid=btnId(b,matchedType);
if(_sent[bid])continue;
// Extract context
var desc=extractContext(b);
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
// Mark as sent
_sent[bid]={rid:rid,ts:now};
log('DETECTED '+matchedType+': "'+txt+'" → pending to bridge');
// Send to bridge (closure to capture refs)
(function(rid2,b2,bid2,txt2,desc2,type2){
fetch(BASE+'/pending',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
request_id:rid2,
command:txt2,
description:desc2,
step_type:type2
})
}).then(function(r){return r.json();}).then(function(d){
log('Pending created: '+d.request_id+' for "'+txt2+'"');
pollResponse(d.request_id,b2,bid2);
}).catch(function(e){
log('POST error: '+e.message);
delete _sent[bid2];
});
})(rid,b,bid,txt,desc,matchedType);
// Process ONE button per scan cycle (avoid flooding)
return;
}
} // end searchRoots loop
}
// ── Poll for Discord response ──
function pollResponse(rid,btn,bid){
var polls=0;
var maxPolls=600; // 5 minutes at 500ms interval
var timer=setInterval(function(){
polls++;
// Check if button is still in DOM (step may have been resolved by other means)
if(!document.body.contains(btn)){
log('Button removed from DOM — stopping poll for '+rid);
clearInterval(timer);
delete _sent[bid];
return;
}
if(polls>maxPolls){
log('Poll timeout for '+rid);
clearInterval(timer);
delete _sent[bid];
return;
}
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
if(d.waiting)return;
clearInterval(timer);
if(d.approved){
log('✅ APPROVED '+rid+' → clicking "'+((btn.textContent||'').trim())+'"');
btn.click();
} else {
log('❌ REJECTED '+rid+' → finding reject button');
clickRejectButton(btn);
}
delete _sent[bid];
}).catch(function(){});
},500);
}
// ── 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);
// ── TRIGGER-CLICK: Extension→Renderer bridge for programmatic button clicks ──
// Extension sets clickTrigger via tryApprovalStrategies → renderer polls and clicks
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);
// Find first visible approve or reject button
var allBtns=document.querySelectorAll('button');
if(d.action==='approve'){
// Click first Run/Accept/Allow/Continue button
var approveRe=[/^Run/i,/^Accept/i,/^Allow/i,/^Approve/i,/^Continue$/i,/^Proceed$/i];
for(var i=0;i<allBtns.length;i++){
var b=allBtns[i];
if(b.disabled||b.hidden||!b.offsetParent)continue;
var txt=(b.textContent||'').trim();
for(var p=0;p<approveRe.length;p++){
if(approveRe[p].test(txt)){
log('✅ TRIGGER-CLICK: clicking "'+txt+'"');
b.click();
return;
}
}
}
log('⚠️ TRIGGER-CLICK: no approve button found in DOM');
} else if(d.action==='reject'){
for(var j=0;j<allBtns.length;j++){
var b2=allBtns[j];
if(b2.disabled||b2.hidden||!b2.offsetParent)continue;
var txt2=(b2.textContent||'').trim();
for(var r=0;r<REJECT_RE.length;r++){
if(REJECT_RE[r].test(txt2)){
log('❌ TRIGGER-CLICK: clicking "'+txt2+'"');
b2.click();
return;
}
}
}
log('⚠️ TRIGGER-CLICK: no reject button found in DOM');
}
}).catch(function(){});
},1000);
_obs=true;
log('v2 Observer active — MutationObserver + 3s fallback + 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;
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step
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;
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) {
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++;
}
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)`);
}
}
}
catch (e) {
logToFile(`[STEP-PROBE] error: ${e.message}`);
}
}
const now = Date.now();
const cooldownOk = (now - lastPendingTime) > 60_000;
const fallbackThreshold = (currentCount > 770) ? 4 : 8; // 775-limit: faster fallback
if (consecutiveIdleCount >= fallbackThreshold && sawRunningAfterPending && cooldownOk) {
// Dynamic fallback: 20s (>770 steps) or 40s (normal)
lastPendingStepIndex = currentCount;
lastPendingTime = now;
sawRunningAfterPending = false;
const command = `Stall at step ${currentCount} (fallback)`;
const description = `승인 대기 감지 — fallback (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
logToFile(`[STALL-FALLBACK] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
writePendingApproval({ conversation_id: activeSessionId, command, description, source: 'stall_fallback' });
}
else if (consecutiveIdleCount === fallbackThreshold) {
const reasons = [];
if (!sawRunningAfterPending)
reasons.push('needDelta>0');
if (!cooldownOk)
reasons.push(`cooldown(${Math.round((60000 - (now - lastPendingTime)) / 1000)}s)`);
if (reasons.length > 0)
logToFile(`[STALL] SKIP: ${reasons.join(', ')}`);
}
}
else if (!isRunning) {
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 {
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 / stall path: try all approval strategies
logToFile(`[RESPONSE] step_probe/stall → trying multi-strategy approval`);
const approvalResult = await tryApprovalStrategies(approved, sessionId);
logToFile(`[RESPONSE] approval result: ${approvalResult}`);
}
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
// Cleanup response file — BUT NOT for DOM observer!
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 });
}
const id = Date.now().toString();
const payload = {
request_id: id,
conversation_id: data.conversation_id,
command: data.command,
description: data.description,
timestamp: Date.now() / 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 (check if approval commands appear when step is WAITING) ──
try {
const allCmds = await vscode.commands.getCommands(true);
const agCmds = allCmds.filter((c) => c.startsWith('antigravity.'));
const approvalCmds = 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');
});
logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmds.length} approval-related:`);
for (const c of approvalCmds) {
logToFile(`[APPROVAL-CMD-CHECK] → ${c}`);
}
// Dump ALL antigravity.* commands for full comparison
logToFile(`[APPROVAL-CMD-CHECK] FULL LIST (${agCmds.length}):`);
for (const c of agCmds) {
logToFile(`[APPROVAL-CMD-FULL] ${c}`);
}
}
catch (e) {
logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`);
}
// ── Strategy 1: HandleCascadeUserInteraction RPC ──
if (sdk) {
// Try variant A: { cascadeId, approved }
try {
logToFile(`[APPROVAL-1A] HandleCascadeUserInteraction { cascadeId, approved: ${approved} }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
approved: approved,
});
logToFile(`[APPROVAL-1A] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1A:HandleCascadeUserInteraction(approved=${approved})`;
}
catch (e) {
logToFile(`[APPROVAL-1A] ❌ FAIL: ${e.message}`);
}
// Try variant B: { cascadeId, stepAction }
try {
const stepAction = approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT';
logToFile(`[APPROVAL-1B] HandleCascadeUserInteraction { cascadeId, stepAction: '${stepAction}' }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
stepAction: stepAction,
});
logToFile(`[APPROVAL-1B] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1B:HandleCascadeUserInteraction(stepAction=${stepAction})`;
}
catch (e) {
logToFile(`[APPROVAL-1B] ❌ FAIL: ${e.message}`);
}
// Try variant C: { cascadeId, userAction } (experimental)
try {
const userAction = approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED';
logToFile(`[APPROVAL-1C] HandleCascadeUserInteraction { cascadeId, userAction: '${userAction}' }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
userAction: userAction,
});
logToFile(`[APPROVAL-1C] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1C:HandleCascadeUserInteraction(userAction=${userAction})`;
}
catch (e) {
logToFile(`[APPROVAL-1C] ❌ FAIL: ${e.message}`);
}
}
// ── Strategy 2: Renderer DOM Click via HTTP Bridge ──
// 2026-03-09: All SDK approval commands NOT REGISTERED (119 cmds tested).
// Keyboard simulation sends Enter to chat input (sends empty message — WRONG).
// New approach: set clickTrigger flag → renderer polls /trigger-click → clicks button.
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