Files
gravity_control/extension/out/extension.js

1312 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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"));
// ─── 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 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!)
const scriptDir = path.dirname(scriptPath);
const jetskiHtml = path.join(scriptDir, 'workbench-jetski-agent.html');
const scriptBasename = path.basename(scriptPath);
try {
if (fs.existsSync(jetskiHtml)) {
let html = fs.readFileSync(jetskiHtml, 'utf8');
if (!html.includes(scriptBasename)) {
html = html.replace('</html>', `\n<!-- AG SDK [variet-gravity-bridge] -->\n<script src="./${scriptBasename}"></script>\n<!-- /AG SDK [variet-gravity-bridge] -->\n</html>`);
fs.writeFileSync(jetskiHtml, html, 'utf8');
logToFile('[OBSERVER] workbench-jetski-agent.html PATCHED');
}
else {
logToFile('[OBSERVER] workbench-jetski-agent.html already has script tag');
}
}
}
catch (e) {
logToFile(`[OBSERVER] jetski patch error: ${e.message}`);
}
}
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}`);
}
}
// ─── HTTP Bridge Server (Extension Host → Renderer communication) ───
let observerHttpServer = null;
const pendingResponses = new Map();
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,
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 /ping — health check
if (url.pathname === '/ping') {
res.writeHead(200);
res.end('pong');
return;
}
res.writeHead(404);
res.end('not found');
});
// Listen on random port
server.listen(0, '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) => {
logToFile(`[HTTP] server error: ${e.message}`);
resolve(0);
});
}
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...');
// ── Multi-Port Discovery: reads ag-bridge-ports.json, tries ALL bridges ──
function discoverPort(cb){
var attempts=0;
var timer=setInterval(function(){
attempts++;
if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
try{
var xhr=new XMLHttpRequest();
xhr.open('GET','./ag-bridge-ports.json?t='+Date.now(),false);
xhr.send();
if(xhr.status===200){
var ports=JSON.parse(xhr.responseText);
var keys=Object.keys(ports);
for(var i=0;i<keys.length;i++){
var port=ports[keys[i]];
if(port>0&&port<65536){
// Try ping on each port
try{
var xhr2=new XMLHttpRequest();
xhr2.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
xhr2.timeout=1000;
xhr2.send();
if(xhr2.status===200&&xhr2.responseText==='pong'){
clearInterval(timer);
log('Port discovered: '+port+' (project='+keys[i]+')');
cb(port);
return;
}
}catch(e2){}
}
}
}
}catch(e){}
},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/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,/^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();
if(!panel)return;
// Find ALL buttons in the panel
var allBtns=panel.querySelectorAll('button');
if(!allBtns.length)return;
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;
}
}
// ── 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);
_obs=true;
log('v2 Observer active — MutationObserver + 3s fallback');
}
})();
`;
}
// 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 (for writeChatSnapshot 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
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;
// Don't register here — registration happens lazily in writeChatSnapshot/writePendingApproval
// to avoid race conditions between multiple extension instances
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}`);
}
// ── Stall-based approval detection ──
// INSIGHT: Both thinking and approval show RUNNING+delta=0.
// DIFFERENTIATOR: lastModifiedTime
// - Thinking: lastModifiedTime KEEPS CHANGING (server actively processing)
// - Approval wait: lastModifiedTime FROZEN (server idle, waiting for user)
// DEBUG: dump session keys on first poll to find modTime field
if (pollCount === 1) {
const keys = Object.keys(bestSession).filter(k => !['latestNotifyUserStep', 'latestTaskBoundaryStep', 'latestToolCallStep'].includes(k));
logToFile(`[DEBUG] session keys: ${keys.join(', ')}`);
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}, lastModifiedTimestamp=${bestSession.lastModifiedTimestamp}, modifiedTime=${bestSession.modifiedTime}`);
}
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;
lastModTime = currentModTime;
}
else if (isStall) {
if (modTimeChanged) {
// lastModifiedTime is still changing = AI is thinking, NOT approval
consecutiveIdleCount = 0; // Reset!
if (pollCount <= 10 || pollCount % 12 === 0) {
logToFile(`[THINK] step=${currentCount} modTime changing → not stall`);
}
}
else {
// lastModifiedTime frozen = real stall (approval waiting)
consecutiveIdleCount++;
}
lastModTime = currentModTime;
const now = Date.now();
const cooldownOk = (now - lastPendingTime) > 60_000;
if (consecutiveIdleCount >= 6 && sawRunningAfterPending && cooldownOk) {
// 6 polls × 5s = 30 seconds of FROZEN stall = approval waiting
lastPendingStepIndex = currentCount;
lastPendingTime = now;
sawRunningAfterPending = false;
const command = `Stall at step ${currentCount}`;
const description = `승인 대기 감지 (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
logToFile(`[STALL] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
writePendingApproval({ conversation_id: activeSessionId, command, description });
}
else if (consecutiveIdleCount === 6) {
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 || '';
if (content.length > 10) {
writeChatSnapshot(`📣 **알림** (step ${notifyStep.stepIndex})\n\n${content}`);
console.log(`Gravity Bridge: [POLL#${pollCount}] NOTIFY step=${notifyStep.stepIndex} ${content.length} chars`);
}
}
// ── 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_', '') : '';
writeChatSnapshot(`📋 **[${mode}] ${tb.taskName}**\n${tb.taskStatus || ''}\n\n${tb.taskSummary || ''}`);
console.log(`Gravity Bridge: [POLL#${pollCount}] TASK step=${taskStep.stepIndex} "${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 { }
}
// ═══ APPROVAL STRATEGY ═══
// DOM observer approvals: renderer handles clicking via pollResponse — skip VS Code commands
// Stall-detection approvals: use VS Code commands as fallback (focus-dependent)
const approved = resp.approved;
if (isDomObserver) {
// DOM observer path: renderer polls /response/:rid and clicks the button directly
logToFile(`[RESPONSE] DOM observer approval — renderer will handle click (rid=${resp.request_id})`);
}
else {
// Stall-detection path: use VS Code commands (legacy, focus-dependent)
logToFile(`[RESPONSE] Stall-detection approval — using VS Code commands`);
// Focus panel
for (let i = 0; i < 2; i++) {
try {
await vscode.commands.executeCommand('antigravity.agentPanel.focus');
if (i === 0)
logToFile('[RESPONSE] panel focus attempt 1');
}
catch (e) {
logToFile(`[RESPONSE] panel focus attempt ${i + 1} failed: ${e.message}`);
}
await new Promise(r => setTimeout(r, 500));
}
if (approved) {
const approveCommands = [
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.command.accept',
'antigravity.agent.acceptAgentStep',
];
for (const cmd of approveCommands) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
}
catch (e) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
}
}
else {
const rejectCommands = [
'antigravity.terminalCommand.reject',
'antigravity.command.reject',
'antigravity.agent.rejectAgentStep',
];
for (const cmd of rejectCommands) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
}
catch (e) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
}
}
}
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'stall'})`);
// Cleanup response file (but NOT pending — renderer still polls it)
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;
}
/** 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,
};
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}`);
}
}
// ─── 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) {
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