- vscode-file:// refuses custom .js files → inline script into HTML - Random port → deterministic port from project name hash (gravity_control=34332) - Hardcoded port in renderer script for immediate discovery - Auto-update product.json SHA256 checksums after HTML modification - Bump version 0.2.0 → 0.3.5
1448 lines
61 KiB
JavaScript
1448 lines
61 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:// protocol does NOT serve custom .js files,
|
||
// so we INLINE the script content 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 (from previous versions)
|
||
const oldScriptBasename = path.basename(scriptPath);
|
||
if (html.includes(`src="./${oldScriptBasename}"`)) {
|
||
html = html.replace(/\n?<!-- AG SDK \[variet-gravity-bridge\] -->\n?<script src="[^"]*"><\/script>\n?<!-- \/AG SDK \[variet-gravity-bridge\] -->\n?/, '');
|
||
logToFile('[OBSERVER] removed old external <script src> 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)) {
|
||
// Replace existing inline script with updated content
|
||
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 {
|
||
// First time: insert before </html>
|
||
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 (relative key in product.json → absolute path)
|
||
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'),
|
||
};
|
||
let updated = false;
|
||
for (const [key, filePath] of Object.entries(filesToCheck)) {
|
||
if (!product.checksums[key])
|
||
continue; // not in checksums, skip
|
||
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();
|
||
/** 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,
|
||
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 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: try hardcoded port FIRST, then JSON fallback ──
|
||
// vscode-file:// protocol blocks .json files, so XHR to ag-bridge-ports.json
|
||
// returns 404. The extension embeds the known port at script generation time.
|
||
var HARDCODED_PORT=${_port};
|
||
|
||
function tryPing(port,cb){
|
||
try{
|
||
var xhr=new XMLHttpRequest();
|
||
xhr.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
|
||
xhr.timeout=2000;
|
||
xhr.send();
|
||
if(xhr.status===200&&xhr.responseText==='pong'){cb(true);return;}
|
||
}catch(e){}
|
||
cb(false);
|
||
}
|
||
|
||
function discoverPort(cb){
|
||
// Strategy 1: Try hardcoded port immediately
|
||
log('Trying hardcoded port '+HARDCODED_PORT+'...');
|
||
tryPing(HARDCODED_PORT,function(ok){
|
||
if(ok){log('Port discovered (hardcoded): '+HARDCODED_PORT);cb(HARDCODED_PORT);return;}
|
||
log('Hardcoded port failed, falling back to JSON + port scan...');
|
||
|
||
// Strategy 2: JSON file fallback + port scan
|
||
var attempts=0;
|
||
var timer=setInterval(function(){
|
||
attempts++;
|
||
if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
|
||
// Try hardcoded port again (server may start late)
|
||
tryPing(HARDCODED_PORT,function(ok2){
|
||
if(ok2){clearInterval(timer);log('Port discovered (hardcoded retry): '+HARDCODED_PORT);cb(HARDCODED_PORT);return;}
|
||
});
|
||
// Try JSON file (may work in future Electron versions)
|
||
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{
|
||
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 (JSON): '+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
|