step_probe break statement caused only one WAITING step to get a pending file when AG runs multiple parallel tool calls. Now iterates all WAITING steps and creates pending for each.
3325 lines
171 KiB
JavaScript
3325 lines
171 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);
|
|
// Include projectName prefix so shared log can distinguish which extension instance logged
|
|
const prefix = projectName ? `[${projectName}]` : '';
|
|
const line = `${ts} ${prefix} ${msg}`;
|
|
console.log(`Gravity Bridge: ${prefix} ${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 workspaceUri = ''; // filesystem path of the workspace folder (for session filtering)
|
|
let isActive = false;
|
|
let autoApproveEnabled = false; // toggled via !auto from Discord
|
|
let deterministicPort = 0; // derived from projectName, consistent across restarts
|
|
let watcher = null;
|
|
let commandsWatcher = null;
|
|
const sentPendingIds = new Set();
|
|
// Memory-based dedup: tracks recently created pending step_indexes to prevent
|
|
// regeneration after pending file deletion (by Collector/Bot response cycle).
|
|
// Map<string, number> = `${conversationId}:${stepIndex}` → creation timestamp
|
|
const recentPendingSteps = new Map();
|
|
const PENDING_MEMORY_TTL_MS = 60_000; // 60 seconds memory retention
|
|
// In-memory cache for diff_review metadata (survives pending file deletion by Collector).
|
|
// Map<request_id, { edit_step_indices, modified_files }>
|
|
const diffReviewMetadata = new Map();
|
|
// ─── Project Detection ───
|
|
function detectProjectName() {
|
|
const config = vscode.workspace.getConfiguration('gravityBridge');
|
|
const configName = config.get('projectName');
|
|
if (configName) {
|
|
return configName;
|
|
}
|
|
const folders = vscode.workspace.workspaceFolders;
|
|
if (folders && folders.length > 0) {
|
|
const cwd = folders[0].uri.fsPath;
|
|
try {
|
|
const remoteUrl = cp.execSync('git remote get-url origin', {
|
|
cwd, encoding: 'utf-8', timeout: 3000
|
|
}).trim();
|
|
const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
|
|
if (match && match[1]) {
|
|
return match[1].toLowerCase().replace(/[\s\-]+/g, '_');
|
|
}
|
|
}
|
|
catch { }
|
|
return path.basename(cwd).toLowerCase().replace(/[\s\-]+/g, '_');
|
|
}
|
|
return 'default';
|
|
}
|
|
// ─── Bridge File I/O ───
|
|
function ensureBridgeDir() {
|
|
const dirs = ['', 'response', 'commands', 'chat_snapshots'];
|
|
for (const d of dirs) {
|
|
const p = path.join(bridgePath, d);
|
|
if (!fs.existsSync(p)) {
|
|
fs.mkdirSync(p, { recursive: true });
|
|
}
|
|
}
|
|
}
|
|
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
|
|
let activeSessionId = '';
|
|
let activeTrajectoryId = '';
|
|
// Track recently sent Discord→AG texts to avoid echo relay
|
|
const recentDiscordSentTexts = new Map();
|
|
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`);
|
|
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars)`);
|
|
logToFile(`[SNAPSHOT] content: ${text.substring(0, 200)}`);
|
|
// 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}`);
|
|
}
|
|
}
|
|
function writeChatSnapshotWithFiles(text, files) {
|
|
try {
|
|
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,
|
|
attached_files: files,
|
|
timestamp: Date.now() / 1000,
|
|
};
|
|
const filePath = path.join(snapshotDir, `${id}.json`);
|
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars, ${files.length} files)`);
|
|
if (activeSessionId) {
|
|
writeRegistration(activeSessionId);
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.log(`Gravity Bridge: snapshot+files 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
|
|
if (text === '!auto on') {
|
|
autoApproveEnabled = true;
|
|
}
|
|
else if (text === '!auto off') {
|
|
autoApproveEnabled = false;
|
|
}
|
|
else {
|
|
// Toggle if no explicit on/off
|
|
autoApproveEnabled = !autoApproveEnabled;
|
|
}
|
|
logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`);
|
|
}
|
|
else if (text) {
|
|
// Send message to Antigravity — use VS Code command (most reliable)
|
|
recentDiscordSentTexts.set(text.trim(), Date.now());
|
|
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
|
|
const processAllCommands = () => {
|
|
try {
|
|
for (const f of fs.readdirSync(cmdDir)) {
|
|
if (f.endsWith('.json')) {
|
|
processCommandFile(path.join(cmdDir, f));
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
};
|
|
processAllCommands();
|
|
// Watch for new files (may not fire reliably on Windows)
|
|
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 { }
|
|
// Polling fallback: fs.watch on Windows can silently fail
|
|
setInterval(() => {
|
|
processAllCommands();
|
|
}, 3000);
|
|
}
|
|
// ─── 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');
|
|
// ── FIX: SDK's _findLSProcess() uses case-sensitive String.includes() ──
|
|
// workspace_id in LS process has 'Desktop' (capital D), but SDK hint
|
|
// generates 'desktop' (lowercase) → match fails → connects to WRONG LS.
|
|
// Re-discover the correct LS using case-insensitive workspace_id matching.
|
|
await fixLSConnection();
|
|
return true;
|
|
}
|
|
catch (err) {
|
|
console.log(`Gravity Bridge: SDK init failed: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Fix SDK's LS connection by finding the correct language_server process
|
|
* for this workspace using case-insensitive matching.
|
|
*
|
|
* SDK bug: _findLSProcess() compares workspaceHint via JS String.includes()
|
|
* which is case-sensitive. workspace_id in process args has original casing
|
|
* (e.g., file_c_3A_Users_Certes_Desktop_variet_agent) but SDK hint is
|
|
* lowercased (desktop_variet_agent) → no match → falls back to first LS
|
|
* found (wrong workspace).
|
|
*/
|
|
async function fixLSConnection() {
|
|
if (!sdk?.ls)
|
|
return;
|
|
try {
|
|
const folders = vscode.workspace.workspaceFolders;
|
|
if (!folders || folders.length === 0)
|
|
return;
|
|
// Generate the workspace hint the same way SDK does, but we'll match case-insensitively
|
|
const folder = folders[0].uri.fsPath;
|
|
const parts = folder.replace(/\\/g, '/').split('/');
|
|
const hint = parts.slice(-2).join('_').replace(/[-.\s]/g, '_').toLowerCase();
|
|
if (!hint)
|
|
return;
|
|
// Find all language_server processes with csrf_token
|
|
const { exec } = cp;
|
|
const { promisify } = require('util');
|
|
const execAsync = promisify(exec);
|
|
let output;
|
|
try {
|
|
const psScript = `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'language_server' -and $_.CommandLine -match 'csrf_token' } | ForEach-Object { $_.ProcessId.ToString() + '|' + $_.CommandLine }`;
|
|
const encoded = Buffer.from(psScript, 'utf16le').toString('base64');
|
|
const result = await execAsync(`powershell.exe -NoProfile -EncodedCommand ${encoded}`, { encoding: 'utf8', timeout: 15000, windowsHide: true });
|
|
output = result.stdout;
|
|
}
|
|
catch {
|
|
return; // Can't discover processes — leave SDK's choice
|
|
}
|
|
const lines = output.split('\n').filter((l) => l.trim().length > 0);
|
|
if (lines.length <= 1)
|
|
return; // Only one LS — no ambiguity
|
|
// Find the line whose workspace_id matches our workspace (case-insensitive)
|
|
let matchedLine = null;
|
|
for (const line of lines) {
|
|
const lower = line.toLowerCase();
|
|
// Match workspace_id arg against our hint
|
|
const wsMatch = line.match(/--workspace_id[= ](\S+)/i);
|
|
if (wsMatch) {
|
|
const wsid = wsMatch[1].toLowerCase();
|
|
if (wsid.includes(hint)) {
|
|
matchedLine = line;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!matchedLine) {
|
|
logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`);
|
|
return;
|
|
}
|
|
// Extract port and csrf_token from matched line
|
|
const csrfMatch = matchedLine.match(/--csrf_token[= ](\S+)/);
|
|
const extPortMatch = matchedLine.match(/--extension_server_port[= ](\d+)/);
|
|
const pidMatch = matchedLine.split('|')[0]?.trim();
|
|
if (!csrfMatch || !extPortMatch) {
|
|
logToFile(`[LS-FIX] Matched LS but missing csrf/port args`);
|
|
return;
|
|
}
|
|
const csrfToken = csrfMatch[1];
|
|
const extPort = parseInt(extPortMatch[1], 10);
|
|
const pid = parseInt(pidMatch || '0', 10);
|
|
// Check if SDK already connected to this LS
|
|
if (sdk.ls.port === extPort) {
|
|
logToFile(`[LS-FIX] SDK already on correct LS port=${extPort}`);
|
|
return;
|
|
}
|
|
// Find ConnectRPC port via netstat (same as SDK logic)
|
|
let netstatOutput;
|
|
try {
|
|
const result = await execAsync(`netstat -aon | findstr "LISTENING" | findstr "${pid}"`, { encoding: 'utf8', timeout: 5000, windowsHide: true });
|
|
netstatOutput = result.stdout;
|
|
}
|
|
catch {
|
|
// Netstat failed — try extension_server_port as fallback
|
|
logToFile(`[LS-FIX] netstat failed, using ext_port=${extPort} for PID=${pid}`);
|
|
sdk.ls.setConnection(extPort, csrfToken, false);
|
|
logToFile(`[LS-FIX] ✅ Reconnected to correct LS: port=${extPort} hint="${hint}" PID=${pid}`);
|
|
return;
|
|
}
|
|
const portMatches = netstatOutput.matchAll(/127\.0\.0\.1:(\d+)/g);
|
|
const ports = [];
|
|
for (const m of portMatches) {
|
|
const p = parseInt(m[1], 10);
|
|
if (p !== extPort && !ports.includes(p)) {
|
|
ports.push(p);
|
|
}
|
|
}
|
|
// Try each port — prefer HTTPS, fall back to HTTP
|
|
const httpModule = require('http');
|
|
const httpsModule = require('https');
|
|
for (const useTls of [true, false]) {
|
|
const mod = useTls ? httpsModule : httpModule;
|
|
const proto = useTls ? 'https' : 'http';
|
|
for (const port of ports) {
|
|
try {
|
|
const ok = await new Promise((resolve) => {
|
|
const req = mod.request(`${proto}://127.0.0.1:${port}/exa.language_server_pb.LanguageServerService/GetUserStatus`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
|
|
rejectUnauthorized: false,
|
|
timeout: 2000,
|
|
}, (res) => resolve(res.statusCode === 200 || res.statusCode === 401));
|
|
req.on('error', () => resolve(false));
|
|
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
req.write('{}');
|
|
req.end();
|
|
});
|
|
if (ok) {
|
|
sdk.ls.setConnection(port, csrfToken, useTls);
|
|
logToFile(`[LS-FIX] ✅ Reconnected to correct LS: port=${port} ${proto} hint="${hint}" PID=${pid}`);
|
|
return;
|
|
}
|
|
}
|
|
catch { /* try next */ }
|
|
}
|
|
}
|
|
// Last resort: use extension_server_port
|
|
sdk.ls.setConnection(extPort, csrfToken, false);
|
|
logToFile(`[LS-FIX] ✅ Reconnected via ext_port=${extPort} hint="${hint}" PID=${pid}`);
|
|
}
|
|
catch (err) {
|
|
logToFile(`[LS-FIX] error: ${err.message}`);
|
|
}
|
|
}
|
|
// ─── Approval Observer via SDK IntegrationManager ───
|
|
async function setupApprovalObserver() {
|
|
if (!sdk) {
|
|
logToFile('[OBSERVER] no SDK');
|
|
return;
|
|
}
|
|
try {
|
|
const integration = sdk.integration;
|
|
if (!integration) {
|
|
logToFile('[OBSERVER] sdk.integration unavailable');
|
|
return;
|
|
}
|
|
// 1. Start HTTP bridge server in Extension Host
|
|
const bridgePort = await startObserverHttpBridge();
|
|
if (!bridgePort) {
|
|
logToFile('[OBSERVER] HTTP bridge failed');
|
|
return;
|
|
}
|
|
// 2. Register a TOP_BAR button so build() works
|
|
try {
|
|
integration.register({
|
|
id: 'gravity_bridge_status',
|
|
point: 'topBar',
|
|
icon: '🌉',
|
|
tooltip: 'Gravity Bridge Active',
|
|
});
|
|
}
|
|
catch { /* already registered */ }
|
|
// 3. Write renderer script with HTTP fetch() approach
|
|
const observerJS = generateApprovalObserverScript(bridgePort);
|
|
const patcher = integration._patcher;
|
|
if (patcher && typeof patcher.getScriptPath === 'function') {
|
|
let baseScript = '';
|
|
try {
|
|
baseScript = integration.build();
|
|
}
|
|
catch {
|
|
baseScript = '';
|
|
}
|
|
const combinedScript = baseScript + '\n' + observerJS;
|
|
const scriptPath = patcher.getScriptPath();
|
|
fs.writeFileSync(scriptPath, combinedScript, 'utf8');
|
|
logToFile(`[OBSERVER] script written → ${scriptPath} (port=${bridgePort})`);
|
|
if (!integration.isInstalled()) {
|
|
patcher.install(combinedScript);
|
|
logToFile('[OBSERVER] patcher.install() called (needs reload)');
|
|
}
|
|
// Patch BOTH HTML files with inline script injection.
|
|
// CRITICAL: vscode-file:// does NOT serve custom .js files (silent 404),
|
|
// so we MUST inline the script directly into BOTH HTML files.
|
|
// workbench.html — loaded by DevTools/standard mode
|
|
// workbench-jetski-agent.html — loaded by AG agent mode
|
|
const scriptDir = path.dirname(scriptPath);
|
|
// Each HTML file has DIFFERENT CSS/JS entry points — they are NOT interchangeable:
|
|
// workbench.html → workbench.desktop.main.css + workbench.js
|
|
// workbench-jetski-agent.html → tw-base.tailwind.css + jetskiMain.tailwind.css + jetskiAgent.js
|
|
// Cross-restoring between them causes CSS to not load → layout broken (elements visible but all shifted left).
|
|
const htmlFileSpecs = [
|
|
{
|
|
name: 'workbench.html',
|
|
requiredMarker: 'workbench.desktop.main.css', // CSS unique to this file
|
|
requiredScript: 'workbench.js', // JS entry point
|
|
},
|
|
{
|
|
name: 'workbench-jetski-agent.html',
|
|
requiredMarker: 'jetskiMain.tailwind.css', // CSS unique to this file
|
|
requiredScript: 'jetskiAgent.js', // JS entry point
|
|
},
|
|
];
|
|
// ── FIX #1: File lock to prevent multi-instance HTML patching race ──
|
|
const lockFile = path.join(scriptDir, '.patch-lock');
|
|
let lockAcquired = false;
|
|
try {
|
|
if (fs.existsSync(lockFile)) {
|
|
const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs;
|
|
if (lockAge < 30_000) {
|
|
logToFile(`[OBSERVER] another instance is patching (lock age=${Math.round(lockAge / 1000)}s) — skipping`);
|
|
return; // Exit setupApprovalObserver entirely
|
|
}
|
|
logToFile(`[OBSERVER] stale lock (age=${Math.round(lockAge / 1000)}s) — force-acquiring`);
|
|
}
|
|
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, ts: Date.now() }), 'utf-8');
|
|
lockAcquired = true;
|
|
}
|
|
catch (lockErr) {
|
|
logToFile(`[OBSERVER] lock acquire error: ${lockErr.message} — proceeding anyway`);
|
|
}
|
|
for (const spec of htmlFileSpecs) {
|
|
const htmlPath = path.join(scriptDir, spec.name);
|
|
const backupPath = htmlPath + '.orig';
|
|
try {
|
|
if (!fs.existsSync(htmlPath)) {
|
|
logToFile(`[OBSERVER] ${spec.name} not found — skipping`);
|
|
continue;
|
|
}
|
|
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
// ── BACKUP: Save original before first-ever patch ──
|
|
// Only backup if the file looks valid AND hasn't been backed up yet.
|
|
if (!fs.existsSync(backupPath)
|
|
&& html.length >= 500
|
|
&& html.includes('<!DOCTYPE html>')
|
|
&& html.includes(spec.requiredMarker)) {
|
|
fs.writeFileSync(backupPath, html, 'utf8');
|
|
logToFile(`[OBSERVER] ${spec.name} backed up to .orig (${html.length} bytes)`);
|
|
}
|
|
// ── SAFETY: Refuse to patch if file is corrupt, empty, or wrong type ──
|
|
// Race condition: another extension instance may be mid-write (0-byte).
|
|
// Wrong type: restored from the other HTML file (different CSS/JS refs).
|
|
const isCorrupt = html.length < 500 || !html.includes('<!DOCTYPE html>');
|
|
const isWrongType = !isCorrupt && !html.includes(spec.requiredMarker);
|
|
if (isCorrupt || isWrongType) {
|
|
const reason = isCorrupt
|
|
? `corrupt/empty (${html.length} bytes)`
|
|
: `wrong type (missing ${spec.requiredMarker})`;
|
|
logToFile(`[OBSERVER] ${spec.name} detected ${reason}`);
|
|
// Try to restore from backup
|
|
if (fs.existsSync(backupPath)) {
|
|
const backup = fs.readFileSync(backupPath, 'utf8');
|
|
if (backup.length >= 500
|
|
&& backup.includes('<!DOCTYPE html>')
|
|
&& backup.includes(spec.requiredMarker)) {
|
|
fs.writeFileSync(htmlPath, backup, 'utf8');
|
|
html = backup;
|
|
logToFile(`[OBSERVER] ${spec.name} RESTORED from .orig backup (${backup.length} bytes) ✅`);
|
|
}
|
|
else {
|
|
logToFile(`[OBSERVER] ${spec.name} .orig backup also invalid — SKIPPING`);
|
|
continue;
|
|
}
|
|
}
|
|
else {
|
|
logToFile(`[OBSERVER] ${spec.name} no .orig backup available — SKIPPING to prevent further damage`);
|
|
continue;
|
|
}
|
|
}
|
|
// CRITICAL: Patch CSP to allow inline scripts.
|
|
// Default CSP has script-src 'self' 'unsafe-eval' blob: — NO 'unsafe-inline'.
|
|
// Without 'unsafe-inline', all inline <script> tags are silently blocked.
|
|
if (html.includes('script-src') && !html.match(/script-src[^;]*'unsafe-inline'/)) {
|
|
html = html.replace(/(script-src\s[^;]*?)('self')/, "$1$2\n\t\t\t\t\t'unsafe-inline'");
|
|
logToFile(`[OBSERVER] ${spec.name} CSP patched: added 'unsafe-inline' to script-src`);
|
|
}
|
|
// Remove old external script tag if present (legacy, cannot be served)
|
|
const extMarkerStart = '<!-- AG SDK [variet-gravity-bridge] -->';
|
|
const extMarkerEnd = '<!-- /AG SDK [variet-gravity-bridge] -->';
|
|
if (html.includes(extMarkerStart)) {
|
|
const extRe = new RegExp('\\n?' + extMarkerStart.replace(/[[\]]/g, '\\$&') +
|
|
'[\\s\\S]*?' +
|
|
extMarkerEnd.replace(/[[\]]/g, '\\$&') + '\\n?');
|
|
html = html.replace(extRe, '');
|
|
logToFile(`[OBSERVER] removed external script tag from ${spec.name}`);
|
|
}
|
|
// Insert or update inline script
|
|
const inlineMarkerStart = '<!-- AG SDK INLINE [variet-gravity-bridge] -->';
|
|
const inlineMarkerEnd = '<!-- /AG SDK INLINE [variet-gravity-bridge] -->';
|
|
if (html.includes(inlineMarkerStart)) {
|
|
const re = new RegExp(inlineMarkerStart.replace(/[[\]]/g, '\\$&') +
|
|
'[\\s\\S]*?' +
|
|
inlineMarkerEnd.replace(/[[\]]/g, '\\$&'));
|
|
html = html.replace(re, `${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}`);
|
|
logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`);
|
|
}
|
|
else {
|
|
html = html.replace('</html>', `\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
|
|
logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`);
|
|
}
|
|
// SAFETY: Final validation before write
|
|
if (html.length < 500 || !html.includes('<!DOCTYPE html>') || !html.includes(spec.requiredMarker)) {
|
|
logToFile(`[OBSERVER] ${spec.name} WOULD BE CORRUPT after patching (${html.length} bytes) — ABORTING write`);
|
|
continue;
|
|
}
|
|
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
}
|
|
catch (e) {
|
|
logToFile(`[OBSERVER] ${spec.name} patch error: ${e.message}`);
|
|
}
|
|
}
|
|
// Release patch lock
|
|
if (lockAcquired) {
|
|
try {
|
|
fs.unlinkSync(lockFile);
|
|
}
|
|
catch { }
|
|
logToFile('[OBSERVER] patch lock released');
|
|
}
|
|
}
|
|
// 4. Update product.json checksums so vscode-file:// serves our patched files
|
|
updateProductChecksums();
|
|
try {
|
|
integration.enableAutoRepair();
|
|
}
|
|
catch { }
|
|
setInterval(() => { try {
|
|
integration.signalActive();
|
|
}
|
|
catch { } }, 30_000);
|
|
logToFile(`[OBSERVER] setup complete (HTTP bridge on port ${bridgePort})`);
|
|
console.log(`Gravity Bridge: ✅ Approval observer installed (port ${bridgePort})`);
|
|
}
|
|
catch (err) {
|
|
logToFile(`[OBSERVER] setup error: ${err.message}`);
|
|
}
|
|
}
|
|
// ─── Product.json Checksum Auto-Update ───
|
|
// vscode-file:// protocol validates SHA256 checksums in product.json.
|
|
// If a file's checksum doesn't match, Electron serves the ORIGINAL cached version.
|
|
// This function recalculates checksums for files we modify (HTML files with <script> tags).
|
|
function updateProductChecksums() {
|
|
try {
|
|
// Find product.json (2 levels up from workbench dir: resources/app/product.json)
|
|
const patcher = sdk?.integration?._patcher;
|
|
if (!patcher || typeof patcher.getWorkbenchDir !== 'function') {
|
|
logToFile('[CHECKSUM] no patcher/workbenchDir — skipping');
|
|
return;
|
|
}
|
|
const workbenchDir = patcher.getWorkbenchDir();
|
|
// workbenchDir = .../resources/app/out/vs/code/electron-browser/workbench
|
|
// product.json = .../resources/app/product.json (5 levels up from workbench)
|
|
const appDir = path.resolve(workbenchDir, '..', '..', '..', '..', '..');
|
|
const productJsonPath = path.join(appDir, 'product.json');
|
|
if (!fs.existsSync(productJsonPath)) {
|
|
logToFile(`[CHECKSUM] product.json not found at ${productJsonPath}`);
|
|
return;
|
|
}
|
|
// Read product.json (may have BOM)
|
|
let raw = fs.readFileSync(productJsonPath, 'utf8');
|
|
if (raw.charCodeAt(0) === 0xFEFF)
|
|
raw = raw.substring(1);
|
|
const product = JSON.parse(raw);
|
|
if (!product.checksums) {
|
|
logToFile('[CHECKSUM] no checksums section in product.json');
|
|
return;
|
|
}
|
|
// Files we may modify or create (relative key in product.json → absolute path)
|
|
// CRITICAL: vscode-file:// only serves files with valid checksums in product.json.
|
|
// Custom JS files MUST be added here or they'll silently 404.
|
|
const filesToCheck = {
|
|
'vs/code/electron-browser/workbench/workbench.html': path.join(workbenchDir, 'workbench.html'),
|
|
'vs/code/electron-browser/workbench/workbench-jetski-agent.html': path.join(workbenchDir, 'workbench-jetski-agent.html'),
|
|
'vs/code/electron-browser/workbench/ag-sdk-variet-gravity-bridge.js': path.join(workbenchDir, 'ag-sdk-variet-gravity-bridge.js'),
|
|
};
|
|
let updated = false;
|
|
for (const [key, filePath] of Object.entries(filesToCheck)) {
|
|
if (!fs.existsSync(filePath))
|
|
continue;
|
|
const fileBytes = fs.readFileSync(filePath);
|
|
const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, '');
|
|
if (product.checksums[key] !== hash) {
|
|
logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`);
|
|
product.checksums[key] = hash;
|
|
updated = true;
|
|
}
|
|
}
|
|
if (updated) {
|
|
fs.writeFileSync(productJsonPath, JSON.stringify(product, null, '\t'), 'utf8');
|
|
logToFile('[CHECKSUM] product.json updated ✅');
|
|
}
|
|
else {
|
|
logToFile('[CHECKSUM] all checksums already match ✅');
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[CHECKSUM] error: ${e.message}`);
|
|
}
|
|
}
|
|
// ─── HTTP Bridge Server (Extension Host → Renderer communication) ───
|
|
let observerHttpServer = null;
|
|
const pendingResponses = new Map();
|
|
// Click trigger: extension sets this, renderer polls and clicks button
|
|
let clickTrigger = null;
|
|
let sessionStalled = false; // true when session is stalled waiting for approval
|
|
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step (module-level for tryApprovalStrategies)
|
|
let stallProbed = false; // prevent repeated step probes during same stall (module-level for processResponseFile reset)
|
|
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending (module-level for processResponseFile)
|
|
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
|
|
let deepInspectRequested = false;
|
|
let deepInspectResult = null;
|
|
let deepInspectWaiters = [];
|
|
/** Derive a deterministic port from project name (range 10000-60000) */
|
|
function getDeterministicPort(name) {
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) {
|
|
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
|
}
|
|
return 10000 + (Math.abs(hash) % 50000);
|
|
}
|
|
function startObserverHttpBridge() {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const http = require('http');
|
|
const server = http.createServer((req, res) => {
|
|
// CORS headers for renderer fetch()
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(200);
|
|
res.end();
|
|
return;
|
|
}
|
|
const url = new URL(req.url, `http://127.0.0.1`);
|
|
// POST /pending — renderer reports a detected approval button
|
|
if (req.method === 'POST' && url.pathname === '/pending') {
|
|
let body = '';
|
|
req.on('data', (c) => body += c);
|
|
req.on('end', () => {
|
|
try {
|
|
const data = JSON.parse(body);
|
|
// ── Server-side false positive filter ──
|
|
const cmd = (data.command || '').trim();
|
|
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Deny|Allow Once|Allow This Conversation|Dismiss|Decline|Accept|Reject|Accept all|Reject all)$/i;
|
|
if (FALSE_POSITIVE_RE.test(cmd)) {
|
|
logToFile(`[HTTP] filtered false positive: "${cmd}"`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, filtered: true }));
|
|
return;
|
|
}
|
|
// "Run" button → step_probe handles these with full command detail
|
|
// Only let through if session is stalled AND step_probe hasn't created a pending yet
|
|
if (/^Run$/i.test(cmd)) {
|
|
if (!sessionStalled || lastPendingStepIndex >= 0) {
|
|
logToFile(`[HTTP] filtered "Run" — ${!sessionStalled ? 'not stalled' : 'step_probe pending exists'}`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, filtered: true }));
|
|
return;
|
|
}
|
|
}
|
|
const rid = data.request_id || Date.now().toString();
|
|
// Write pending file for Discord bot
|
|
const pendingDir = path.join(bridgePath, 'pending');
|
|
if (!fs.existsSync(pendingDir))
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
const pending = {
|
|
...data,
|
|
request_id: rid,
|
|
conversation_id: activeSessionId || '',
|
|
timestamp: Date.now() / 1000,
|
|
status: 'pending',
|
|
project_name: projectName,
|
|
auto_detected: true,
|
|
source: 'dom_observer',
|
|
step_index: lastPendingStepIndex >= 0 ? lastPendingStepIndex : undefined,
|
|
};
|
|
// File permission: inject multi-choice buttons
|
|
const cmdLower = (data.command || '').toLowerCase();
|
|
if (cmdLower.includes('allow') && !pending.buttons) {
|
|
// Dedup: skip if another file_permission pending was created within 10s
|
|
const nowMs = Date.now();
|
|
try {
|
|
const existingFiles = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.json'));
|
|
for (const ef of existingFiles) {
|
|
const existing = JSON.parse(fs.readFileSync(path.join(pendingDir, ef), 'utf-8'));
|
|
if (existing.step_type === 'file_permission' && existing.status === 'pending'
|
|
&& existing.project_name === projectName) {
|
|
const age = nowMs - (existing.timestamp * 1000);
|
|
if (age < 10_000 && age >= 0) {
|
|
logToFile(`[HTTP] filtered duplicate file_permission (${age}ms old): ${ef}`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' }));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
pending.buttons = [
|
|
{ text: 'Allow Once', index: 0 },
|
|
{ text: 'Allow This Conversation', index: 1 },
|
|
{ text: 'Deny', index: 2 },
|
|
];
|
|
pending.step_type = 'file_permission';
|
|
// Clean description: remove button labels from text
|
|
const rawDesc = (data.description || data.command || '').replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim();
|
|
pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`;
|
|
}
|
|
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
|
|
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true, request_id: rid }));
|
|
}
|
|
catch (e) {
|
|
logToFile(`[HTTP] pending error: ${e.message}`);
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
// GET /response/:rid — renderer polls for Discord approval
|
|
if (req.method === 'GET' && url.pathname.startsWith('/response/')) {
|
|
const rid = url.pathname.split('/')[2];
|
|
const respFile = path.join(bridgePath, 'response', `${rid}.json`);
|
|
if (fs.existsSync(respFile)) {
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(respFile, 'utf8'));
|
|
logToFile(`[HTTP] response served to renderer: ${rid} approved=${data.approved}`);
|
|
// Delay deletion: processResponseFile (response watcher) may need to read it too.
|
|
// The watcher fires with 300ms delay, so 2s is safe.
|
|
setTimeout(() => {
|
|
try {
|
|
fs.unlinkSync(respFile);
|
|
}
|
|
catch { }
|
|
}, 2000);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
}
|
|
catch {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ waiting: true }));
|
|
}
|
|
}
|
|
else {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ waiting: true }));
|
|
}
|
|
return;
|
|
}
|
|
// GET /trigger-click — renderer polls to check if extension wants a click
|
|
if (req.method === 'GET' && url.pathname === '/trigger-click') {
|
|
if (clickTrigger && (Date.now() - clickTrigger.timestamp) < 30000) {
|
|
const trigger = clickTrigger;
|
|
clickTrigger = null; // consume once
|
|
logToFile(`[HTTP] trigger-click consumed: ${trigger.action}`);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ action: trigger.action }));
|
|
}
|
|
else {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ action: null }));
|
|
}
|
|
return;
|
|
}
|
|
// GET /deep-inspect — trigger deep DOM inspection from renderer
|
|
if (req.method === 'GET' && url.pathname === '/deep-inspect') {
|
|
deepInspectRequested = true;
|
|
logToFile('[HTTP] deep-inspect triggered — waiting for renderer result...');
|
|
// Wait up to 10s for renderer to POST result
|
|
const timeout = setTimeout(() => {
|
|
deepInspectWaiters = deepInspectWaiters.filter(w => w !== waiter);
|
|
if (deepInspectResult) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(deepInspectResult));
|
|
}
|
|
else {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ status: 'timeout', message: 'Renderer did not respond in 10s. Is the v3 script loaded?' }));
|
|
}
|
|
}, 10000);
|
|
const waiter = (data) => {
|
|
clearTimeout(timeout);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
};
|
|
deepInspectWaiters.push(waiter);
|
|
return;
|
|
}
|
|
// GET /deep-inspect-trigger — renderer polls this
|
|
if (req.method === 'GET' && url.pathname === '/deep-inspect-trigger') {
|
|
const requested = deepInspectRequested;
|
|
deepInspectRequested = false;
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ inspect: requested }));
|
|
return;
|
|
}
|
|
// POST /deep-inspect-result — renderer posts inspection results here
|
|
if (req.method === 'POST' && url.pathname === '/deep-inspect-result') {
|
|
let body = '';
|
|
req.on('data', (c) => body += c);
|
|
req.on('end', () => {
|
|
try {
|
|
const data = JSON.parse(body);
|
|
deepInspectResult = data;
|
|
logToFile(`[HTTP] deep-inspect result received (${body.length} bytes)`);
|
|
// Write to file for reference
|
|
const inspectFile = path.join(bridgePath, 'deep-inspect-result.json');
|
|
fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2));
|
|
// Notify waiters
|
|
const waiters = [...deepInspectWaiters];
|
|
deepInspectWaiters = [];
|
|
waiters.forEach(w => w(data));
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: true }));
|
|
}
|
|
catch (e) {
|
|
logToFile(`[HTTP] deep-inspect-result parse error: ${e.message}`);
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: e.message }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
// GET /ping — health check
|
|
if (url.pathname === '/ping') {
|
|
res.writeHead(200);
|
|
res.end('pong');
|
|
return;
|
|
}
|
|
res.writeHead(404);
|
|
res.end('not found');
|
|
});
|
|
// Listen on deterministic port (derived from projectName), fallback to random
|
|
deterministicPort = getDeterministicPort(projectName);
|
|
const tryListen = (targetPort) => {
|
|
server.listen(targetPort, '127.0.0.1', () => {
|
|
const port = server.address().port;
|
|
observerHttpServer = server;
|
|
logToFile(`[HTTP] bridge server started on port ${port}`);
|
|
// Write port to shared ports JSON (multi-bridge support)
|
|
const patcher = sdk.integration?._patcher;
|
|
if (patcher && typeof patcher.getWorkbenchDir === 'function') {
|
|
const workbenchDir = patcher.getWorkbenchDir();
|
|
const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
|
|
let portsData = {};
|
|
try {
|
|
if (fs.existsSync(portsFile)) {
|
|
portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8'));
|
|
}
|
|
}
|
|
catch { }
|
|
portsData[projectName] = port;
|
|
fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8');
|
|
logToFile(`[HTTP] ports JSON updated → ${portsFile} (${projectName}=${port})`);
|
|
}
|
|
resolve(port);
|
|
});
|
|
};
|
|
server.on('error', (e) => {
|
|
if (e.code === 'EADDRINUSE' && deterministicPort > 0) {
|
|
logToFile(`[HTTP] deterministic port ${deterministicPort} in use, trying random...`);
|
|
deterministicPort = 0;
|
|
const server2 = require('http').createServer(server._events.request);
|
|
observerHttpServer = server2;
|
|
server2.on('error', (e2) => {
|
|
logToFile(`[HTTP] random port also failed: ${e2.message}`);
|
|
resolve(0);
|
|
});
|
|
server2.listen(0, '127.0.0.1', () => {
|
|
const port = server2.address().port;
|
|
logToFile(`[HTTP] bridge server started on RANDOM port ${port}`);
|
|
resolve(port);
|
|
});
|
|
return;
|
|
}
|
|
logToFile(`[HTTP] server error: ${e.message}`);
|
|
resolve(0);
|
|
});
|
|
tryListen(deterministicPort);
|
|
}
|
|
catch (e) {
|
|
logToFile(`[HTTP] server failed: ${e.message}`);
|
|
resolve(0);
|
|
}
|
|
});
|
|
}
|
|
// ─── Renderer Script (uses fetch() — no Node.js APIs) ───
|
|
function generateApprovalObserverScript(_port) {
|
|
// Port is hardcoded as fallback, but renderer also reads ag-bridge-ports.json for multi-bridge
|
|
return `
|
|
// ── Gravity Bridge v3: Approval Observer (deep DOM traversal — iframes, webviews, shadow DOMs) ──
|
|
(function(){
|
|
'use strict';
|
|
var BASE='',_obs=false,_sent={},_ready=false;
|
|
var _scanScheduled=false,_lastScanTs=0;
|
|
var THROTTLE_MS=100;
|
|
var CLEANUP_MS=300000;
|
|
var _domDumped=false;
|
|
|
|
function log(m){console.log('[GB Observer] '+m);}
|
|
log('v3 Script loaded — deep DOM traversal enabled');
|
|
|
|
// ── Deep DOM Traversal: find buttons across ALL boundaries ──
|
|
// Searches: main document → iframes (contentDocument) → webview elements → shadow DOMs
|
|
function deepFindButtons(patterns){
|
|
var results=[];
|
|
// 1. Main document buttons
|
|
collectButtons(document,results,patterns,'main');
|
|
// 2. Iframe traversal (try contentDocument — works if same-origin or webSecurity off)
|
|
var iframes=document.querySelectorAll('iframe');
|
|
for(var i=0;i<iframes.length;i++){
|
|
try{
|
|
var idoc=iframes[i].contentDocument||iframes[i].contentWindow.document;
|
|
if(idoc){collectButtons(idoc,results,patterns,'iframe#'+i+'('+iframes[i].className.substring(0,30)+')');}
|
|
}catch(e){
|
|
// Cross-origin — can't access. Log only on first dom dump
|
|
if(!_domDumped)log('iframe#'+i+' cross-origin: '+e.message.substring(0,60));
|
|
}
|
|
}
|
|
// 3. Webview elements (Electron <webview> tag — has executeJavaScript)
|
|
var webviews=document.querySelectorAll('webview');
|
|
for(var w=0;w<webviews.length;w++){
|
|
try{
|
|
var wvDoc=webviews[w].contentDocument;
|
|
if(wvDoc){collectButtons(wvDoc,results,patterns,'webview#'+w);}
|
|
}catch(e){
|
|
if(!_domDumped)log('webview#'+w+' access error: '+e.message.substring(0,60));
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function collectButtons(doc,results,patterns,source){
|
|
if(!doc||!doc.querySelectorAll)return;
|
|
var btns=doc.querySelectorAll('button');
|
|
for(var i=0;i<btns.length;i++){
|
|
var b=btns[i];
|
|
if(b.disabled||b.hidden)continue;
|
|
try{if(!b.offsetParent&&b.style.display!=='fixed')continue;}catch(e){}
|
|
var txt=(b.textContent||'').trim();
|
|
if(!txt)continue;
|
|
for(var p=0;p<patterns.length;p++){
|
|
if(patterns[p].test(txt)){
|
|
results.push({btn:b,text:txt,source:source});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// 4. Recurse into shadow DOMs
|
|
try{
|
|
var allEls=doc.querySelectorAll('*');
|
|
for(var j=0;j<allEls.length;j++){
|
|
var sr=allEls[j].shadowRoot;
|
|
if(sr)collectButtons(sr,results,patterns,source+'>shadow');
|
|
}
|
|
}catch(e){}
|
|
}
|
|
|
|
// ── Deep DOM Inspector (recursive, POSTs results to bridge) ──
|
|
function runDeepInspect(){
|
|
var result={timestamp:new Date().toISOString(),windowURL:window.location.href,windowOrigin:window.location.origin,windowProtocol:window.location.protocol,framesCount:window.frames.length,nodes:[]};
|
|
log('DEEP-INSPECT: starting recursive DOM analysis...');
|
|
|
|
function inspectDoc(doc,depth,label){
|
|
var node={label:label,depth:depth,accessible:true,url:'',buttons:[],roleBtns:[],iframes:[],webviews:[],shadowDOMs:0,totalElements:0};
|
|
if(!doc){node.accessible=false;node.error='null document';result.nodes.push(node);return;}
|
|
try{node.url=(doc.URL||doc.documentURI||'unknown').substring(0,200);}catch(e){node.url='blocked';}
|
|
try{node.title=(doc.title||'').substring(0,100);}catch(e){}
|
|
try{node.readyState=doc.readyState;}catch(e){}
|
|
|
|
// CSP
|
|
try{
|
|
var csp=doc.querySelectorAll('meta[http-equiv="Content-Security-Policy"]');
|
|
if(csp.length>0){node.csp=[];for(var c=0;c<csp.length;c++){node.csp.push((csp[c].content||'').substring(0,200));}}
|
|
}catch(e){}
|
|
|
|
try{
|
|
var allEls=doc.querySelectorAll('*');
|
|
node.totalElements=allEls.length;
|
|
// Buttons
|
|
var btns=doc.querySelectorAll('button');
|
|
for(var i=0;i<btns.length;i++){
|
|
var b=btns[i];
|
|
var txt=(b.textContent||'').trim().substring(0,80);
|
|
if(!txt)continue;
|
|
var cls=(b.className||'').substring(0,60);
|
|
var disabled=b.disabled;
|
|
var hidden=b.hidden||false;
|
|
try{if(!b.offsetParent&&b.style.display!=='fixed')hidden=true;}catch(e){}
|
|
var aria=b.getAttribute('aria-label')||'';
|
|
var ttl=b.getAttribute('title')||'';
|
|
node.buttons.push({text:txt,class:cls,disabled:disabled,hidden:hidden,aria:aria,title:ttl});
|
|
}
|
|
// role=button
|
|
var rbs=doc.querySelectorAll('[role="button"]');
|
|
for(var r=0;r<rbs.length;r++){
|
|
if(rbs[r].tagName==='BUTTON')continue;
|
|
var rtxt=(rbs[r].textContent||'').trim().substring(0,60);
|
|
node.roleBtns.push({tag:rbs[r].tagName.toLowerCase(),text:rtxt});
|
|
}
|
|
// Shadow DOMs
|
|
for(var s=0;s<allEls.length;s++){
|
|
var sr=allEls[s].shadowRoot;
|
|
if(sr){node.shadowDOMs++;inspectDoc(sr,depth+1,'shadow(<'+allEls[s].tagName.toLowerCase()+' class="'+(allEls[s].className||'').substring(0,30)+'">)');}
|
|
}
|
|
// Iframes
|
|
var ifs=doc.querySelectorAll('iframe');
|
|
for(var fi=0;fi<ifs.length;fi++){
|
|
var f=ifs[fi];
|
|
var finfo={index:fi,class:(f.className||'').substring(0,60),src:(f.src||'').substring(0,150),id:f.id||'',sandbox:f.getAttribute('sandbox')||'',allow:f.getAttribute('allow')||'',accessible:false,cwExists:false,cwFrames:0};
|
|
try{
|
|
var idoc=f.contentDocument||(f.contentWindow&&f.contentWindow.document);
|
|
if(idoc){finfo.accessible=true;inspectDoc(idoc,depth+1,'iframe#'+fi+'('+finfo.class.substring(0,30)+')');
|
|
}else{finfo.error='contentDocument=null';}
|
|
}catch(e){finfo.error=e.message.substring(0,80);}
|
|
try{var cw=f.contentWindow;if(cw){finfo.cwExists=true;finfo.cwFrames=cw.length;try{finfo.cwLocation=cw.location.href;}catch(e2){finfo.cwLocation='blocked: '+e2.message.substring(0,40);}}}
|
|
catch(e){}
|
|
node.iframes.push(finfo);
|
|
}
|
|
// Webviews
|
|
var wvs=doc.querySelectorAll('webview');
|
|
for(var wi=0;wi<wvs.length;wi++){
|
|
var wv=wvs[wi];
|
|
var winfo={index:wi,src:(wv.src||'').substring(0,150),class:(wv.className||'').substring(0,60),partition:wv.getAttribute('partition')||'',preload:wv.getAttribute('preload')||'',nodeintegration:wv.getAttribute('nodeintegration')||'',webpreferences:wv.getAttribute('webpreferences')||'',hasExecJS:typeof wv.executeJavaScript==='function',contentDocAccessible:false};
|
|
try{var wdoc=wv.contentDocument;if(wdoc){winfo.contentDocAccessible=true;inspectDoc(wdoc,depth+1,'webview#'+wi+'.contentDocument');}}catch(e){winfo.contentDocError=e.message.substring(0,60);}
|
|
node.webviews.push(winfo);
|
|
}
|
|
}catch(e){node.error=e.message;}
|
|
result.nodes.push(node);
|
|
return node;
|
|
}
|
|
|
|
inspectDoc(document,0,'MainDocument');
|
|
|
|
// Webview executeJavaScript probe (async)
|
|
var webviews=document.querySelectorAll('webview');
|
|
var probesPending=webviews.length;
|
|
result.webviewProbes=[];
|
|
if(probesPending===0)postResults();
|
|
for(var pw=0;pw<webviews.length;pw++){
|
|
(function(wv,idx){
|
|
if(typeof wv.executeJavaScript!=='function'){result.webviewProbes.push({index:idx,error:'executeJavaScript not available'});probesPending--;if(probesPending<=0)postResults();return;}
|
|
try{
|
|
wv.executeJavaScript('(function(){var btns=document.querySelectorAll("button");var allEls=document.querySelectorAll("*");var ifs=document.querySelectorAll("iframe");var wvs=document.querySelectorAll("webview");var btnArr=[];for(var i=0;i<btns.length;i++){var b=btns[i];var txt=(b.textContent||"").trim();var cls=(b.className||"").substring(0,50);var dis=b.disabled;var hid=b.hidden||!b.offsetParent;btnArr.push({text:txt.substring(0,60),class:cls,disabled:dis,hidden:hid,aria:b.getAttribute("aria-label")||"",title:b.getAttribute("title")||""});}var rbs=document.querySelectorAll("[role=button]");var rbArr=[];for(var j=0;j<rbs.length;j++){if(rbs[j].tagName!=="BUTTON")rbArr.push({tag:rbs[j].tagName.toLowerCase(),text:(rbs[j].textContent||"").trim().substring(0,40)});}var sc=0;for(var k=0;k<allEls.length;k++){if(allEls[k].shadowRoot)sc++;}return JSON.stringify({url:document.URL,title:document.title,totalElements:allEls.length,buttons:btnArr,roleBtns:rbArr,iframes:ifs.length,webviews:wvs.length,shadowDOMs:sc});})()')
|
|
.then(function(r){
|
|
try{var d=JSON.parse(r);result.webviewProbes.push({index:idx,success:true,data:d});log('DEEP-INSPECT: webview#'+idx+' probe OK: '+d.buttons.length+' buttons, '+d.totalElements+' elements');}catch(e){result.webviewProbes.push({index:idx,parseError:e.message,raw:r});}
|
|
probesPending--;if(probesPending<=0)postResults();
|
|
})
|
|
.catch(function(e){
|
|
result.webviewProbes.push({index:idx,execError:e.message});
|
|
log('DEEP-INSPECT: webview#'+idx+' execJS error: '+e.message);
|
|
probesPending--;if(probesPending<=0)postResults();
|
|
});
|
|
}catch(e){
|
|
result.webviewProbes.push({index:idx,callError:e.message});
|
|
probesPending--;if(probesPending<=0)postResults();
|
|
}
|
|
})(webviews[pw],pw);
|
|
}
|
|
|
|
function postResults(){
|
|
var summary='nodes='+result.nodes.length;
|
|
var totalBtns=0;for(var n=0;n<result.nodes.length;n++)totalBtns+=result.nodes[n].buttons.length;
|
|
summary+=' totalButtons='+totalBtns+' webviewProbes='+result.webviewProbes.length;
|
|
log('DEEP-INSPECT complete: '+summary);
|
|
// Also log buttons from each node
|
|
for(var n2=0;n2<result.nodes.length;n2++){
|
|
var nd=result.nodes[n2];
|
|
if(nd.buttons.length>0){
|
|
log(' '+nd.label+': '+nd.buttons.length+' buttons');
|
|
for(var bi=0;bi<Math.min(15,nd.buttons.length);bi++){
|
|
log(' ['+bi+'] "'+nd.buttons[bi].text+'"'+(nd.buttons[bi].disabled?' DISABLED':'')+(nd.buttons[bi].hidden?' HIDDEN':''));
|
|
}
|
|
}
|
|
}
|
|
// POST to bridge
|
|
fetch(BASE+'/deep-inspect-result',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(result)})
|
|
.then(function(){log('DEEP-INSPECT results posted to bridge');})
|
|
.catch(function(e){log('DEEP-INSPECT post error: '+e.message);});
|
|
}
|
|
}
|
|
|
|
// Auto-dump on startup (3s delay)
|
|
function dumpDOMStructure(){runDeepInspect();}
|
|
|
|
// ── Port Discovery: async fetch()-based (sync XHR blocked in Electron renderer) ──
|
|
var HARDCODED_PORT=${_port};
|
|
|
|
function tryPingAsync(port){
|
|
return fetch('http://127.0.0.1:'+port+'/ping?t='+Date.now(),{signal:AbortSignal.timeout(2000)})
|
|
.then(function(r){return r.text();})
|
|
.then(function(t){return t==='pong';})
|
|
.catch(function(){return false;});
|
|
}
|
|
|
|
function discoverPort(cb){
|
|
log('Trying hardcoded port '+HARDCODED_PORT+'...');
|
|
tryPingAsync(HARDCODED_PORT).then(function(ok){
|
|
if(ok){log('Port discovered (hardcoded): '+HARDCODED_PORT);cb(HARDCODED_PORT);return;}
|
|
log('Hardcoded port failed, retrying with backoff...');
|
|
|
|
var attempts=0;
|
|
var timer=setInterval(function(){
|
|
attempts++;
|
|
if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
|
|
tryPingAsync(HARDCODED_PORT).then(function(ok2){
|
|
if(ok2){clearInterval(timer);log('Port discovered (retry #'+attempts+'): '+HARDCODED_PORT);cb(HARDCODED_PORT);}
|
|
});
|
|
},2000);
|
|
});
|
|
}
|
|
|
|
discoverPort(function(port){
|
|
BASE='http://127.0.0.1:'+port;
|
|
fetch(BASE+'/ping').then(function(r){return r.text();}).then(function(t){
|
|
if(t==='pong'){log('Bridge connected on port '+port);_ready=true;startObserver();setTimeout(dumpDOMStructure,3000);}
|
|
else log('Bridge ping failed: '+t);
|
|
}).catch(function(e){log('Bridge unreachable: '+e.message);});
|
|
});
|
|
|
|
// ── Button patterns to detect (order matters: first match wins per scan) ──
|
|
// ONLY positive triggers should initiate a pending request group.
|
|
// Negative/secondary buttons (Deny, Reject, Dismiss) will be collected as siblings.
|
|
var PATS=[
|
|
{re:/^Run/i, type:'terminal_command'},
|
|
{re:/^Accept all$/i, type:'diff_review'},
|
|
{re:/^Accept$/i, type:'agent_step'},
|
|
{re:/^Allow/i, type:'permission'},
|
|
{re:/^Approve/i, type:'agent_step'},
|
|
{re:/^Retry$/i, type:'error_recovery'},
|
|
];
|
|
|
|
// ALL actionable button patterns (for grouping siblings in same container)
|
|
var ALL_ACTION_RE=[/^Run/i,/^Accept/i,/^Reject/i,/^Allow/i,/^Deny/i,/^Approve/i,/^Cancel$/i,/^Retry$/i,/^Dismiss$/i,/^Stop$/i,/^Decline$/i];
|
|
|
|
// Reject button patterns for finding the counterpart
|
|
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i];
|
|
|
|
// ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
|
|
function btnId(b,type){
|
|
// Use: type + button text + parent's first 40 chars of text content
|
|
var txt=(b.textContent||'').trim();
|
|
var parent=b.parentElement;
|
|
var pctx=parent?(parent.textContent||'').substring(0,40).replace(/\\s+/g,' '):'';
|
|
// Also use DOM position: nth-child among sibling buttons
|
|
var idx=0;
|
|
if(parent){
|
|
var siblings=parent.querySelectorAll('button');
|
|
for(var i=0;i<siblings.length;i++){if(siblings[i]===b){idx=i;break;}}
|
|
}
|
|
return type+'|'+txt+'|'+idx+'|'+pctx.substring(0,20);
|
|
}
|
|
|
|
// ── Context extraction — walk up DOM to find command/code description ──
|
|
function extractContext(b){
|
|
// Strategy 1: Look for code/pre/terminal blocks near the button
|
|
var container=b.closest('[class*="step"]')
|
|
||b.closest('[class*="action"]')
|
|
||b.closest('[class*="tool"]')
|
|
||b.closest('[class*="cascade"]')
|
|
||b.closest('[class*="message"]');
|
|
if(!container)container=b.parentElement;
|
|
if(!container)return '';
|
|
|
|
// Look for code blocks
|
|
var codeEl=container.querySelector('pre,code,[class*="command"],[class*="terminal"],[class*="code-block"]');
|
|
if(codeEl){
|
|
var codeText=(codeEl.textContent||'').trim();
|
|
if(codeText.length>0)return codeText.substring(0,500);
|
|
}
|
|
|
|
// Strategy 2: Get surrounding text (exclude button text itself)
|
|
var full=(container.textContent||'');
|
|
var btnText=(b.textContent||'');
|
|
var desc=full.replace(btnText,'').trim();
|
|
// Trim to reasonable length
|
|
return desc.substring(0,500);
|
|
}
|
|
|
|
// ── Find common container of related buttons ──
|
|
function findButtonContainer(btn){
|
|
return btn.closest('[class*="step"]')
|
|
||btn.closest('[class*="action"]')
|
|
||btn.closest('[class*="tool"]')
|
|
||btn.closest('[class*="cascade"]')
|
|
||btn.closest('[class*="message"]')
|
|
||btn.closest('[class*="dialog"]')
|
|
||btn.closest('[class*="notification"]')
|
|
||btn.parentElement;
|
|
}
|
|
|
|
// ── Collect all actionable sibling buttons from a container ──
|
|
function collectSiblingButtons(container,triggerBtn){
|
|
if(!container)return [];
|
|
var siblings=container.querySelectorAll('button');
|
|
var result=[];
|
|
for(var i=0;i<siblings.length;i++){
|
|
var sb=siblings[i];
|
|
if(sb.disabled||sb.hidden)continue;
|
|
try{if(!sb.offsetParent&&sb.style.display!=='fixed')continue;}catch(e){}
|
|
var stxt=(sb.textContent||'').trim();
|
|
stxt=stxt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
|
|
if(!stxt)continue;
|
|
// Check if this button matches any actionable pattern
|
|
var isAction=false;
|
|
for(var a=0;a<ALL_ACTION_RE.length;a++){
|
|
if(ALL_ACTION_RE[a].test(stxt)){isAction=true;break;}
|
|
}
|
|
if(!isAction)continue;
|
|
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ── Find the React app container (Antigravity's main UI root) ──
|
|
function findPanel(){
|
|
// Priority order of panel selectors (most specific first)
|
|
var selectors=[
|
|
'.antigravity-agent-side-panel',
|
|
'#jetski-agent-panel',
|
|
'.react-app-container',
|
|
'[class*="agent-panel"]',
|
|
'[class*="agentPanel"]',
|
|
];
|
|
for(var i=0;i<selectors.length;i++){
|
|
var el=document.querySelector(selectors[i]);
|
|
if(el)return el;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Core scan — finds actionable buttons and reports to bridge ──
|
|
// Groups related buttons from same container into a single pending
|
|
function scan(){
|
|
if(!_ready)return;
|
|
var now=Date.now();
|
|
|
|
var panel=findPanel();
|
|
// Expand search: panel-scoped first, then full body for review bars
|
|
var searchRoots=[];
|
|
if(panel)searchRoots.push(panel);
|
|
// Always also scan body for diff review bar (Accept all/Reject all)
|
|
// which lives outside the agent panel in the editor notification area
|
|
if(document.body)searchRoots.push(document.body);
|
|
if(!searchRoots.length)return;
|
|
|
|
var seen={}; // dedupe buttons across search roots
|
|
for(var r=0;r<searchRoots.length;r++){
|
|
var allBtns=searchRoots[r].querySelectorAll('button');
|
|
if(!allBtns.length)continue;
|
|
|
|
for(var j=0;j<allBtns.length;j++){
|
|
var b=allBtns[j];
|
|
if(b.disabled||b.hidden)continue;
|
|
// Check visibility (offsetParent null = hidden via CSS)
|
|
if(!b.offsetParent&&b.style.display!=='fixed')continue;
|
|
|
|
var txt=(b.textContent||'').trim();
|
|
if(!txt)continue;
|
|
// Strip keyboard shortcut suffixes (e.g. "RunAlt+↵" → "Run")
|
|
txt=txt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
|
|
if(!txt)continue;
|
|
|
|
// Match against patterns
|
|
var matchedType=null;
|
|
for(var p=0;p<PATS.length;p++){
|
|
if(PATS[p].re.test(txt)){matchedType=PATS[p].type;break;}
|
|
}
|
|
if(!matchedType)continue;
|
|
|
|
// Generate stable ID for the GROUP (use container-based key)
|
|
var container=findButtonContainer(b);
|
|
var groupKey=matchedType+'|group|'+(container?(container.textContent||'').substring(0,40).replace(/\\s+/g,' '):'none');
|
|
if(_sent[groupKey])continue;
|
|
|
|
// Collect ALL related buttons from the same container
|
|
var siblings=collectSiblingButtons(container,b);
|
|
if(siblings.length===0)siblings=[{btn:b,text:txt,isPrimary:true}];
|
|
|
|
// Build buttons array for multi-choice support
|
|
var buttonsArr=[];
|
|
var btnRefs=[];
|
|
var bidList=[];
|
|
for(var si=0;si<siblings.length;si++){
|
|
var sb=siblings[si];
|
|
var sbid=btnId(sb.btn,matchedType);
|
|
buttonsArr.push({text:sb.text,index:si,is_primary:sb.isPrimary});
|
|
btnRefs.push(sb.btn);
|
|
bidList.push(sbid);
|
|
}
|
|
|
|
// Extract context from trigger button
|
|
var desc=extractContext(b);
|
|
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
|
|
|
|
// Mark entire group as sent
|
|
_sent[groupKey]={rid:rid,ts:now};
|
|
for(var mk=0;mk<bidList.length;mk++){_sent[bidList[mk]]={rid:rid,ts:now};}
|
|
|
|
log('DETECTED GROUP '+matchedType+': '+buttonsArr.map(function(x){return '"'+x.text+'"';}).join(', ')+' → pending to bridge');
|
|
|
|
// Send to bridge (closure to capture refs)
|
|
(function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
|
|
var payload={
|
|
request_id:rid2,
|
|
command:txt2,
|
|
description:desc2,
|
|
step_type:type2,
|
|
buttons:buttonsArr2
|
|
};
|
|
fetch(BASE+'/pending',{
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify(payload)
|
|
}).then(function(r){return r.json();}).then(function(d){
|
|
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
|
|
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
|
|
}).catch(function(e){
|
|
log('POST error: '+e.message);
|
|
delete _sent[groupKey2];
|
|
for(var di=0;di<bidList2.length;di++){delete _sent[bidList2[di]];}
|
|
});
|
|
})(rid,btnRefs,bidList,groupKey,txt,desc,matchedType,buttonsArr);
|
|
|
|
// Process ONE button GROUP per scan cycle (avoid flooding)
|
|
return;
|
|
}
|
|
} // end searchRoots loop
|
|
}
|
|
|
|
// ── Poll for Discord response (multi-button group aware) ──
|
|
function pollResponseGroup(rid,btnRefs,bidList,groupKey){
|
|
var polls=0;
|
|
var maxPolls=200; // 5 minutes at 1500ms interval
|
|
var timer=setInterval(function(){
|
|
polls++;
|
|
// Check if ANY button in the group is still in DOM
|
|
var anyAlive=false;
|
|
for(var ai=0;ai<btnRefs.length;ai++){
|
|
if(document.body.contains(btnRefs[ai])){anyAlive=true;break;}
|
|
}
|
|
if(!anyAlive){
|
|
log('All buttons removed from DOM — stopping poll for '+rid);
|
|
clearInterval(timer);
|
|
delete _sent[groupKey];
|
|
for(var ci=0;ci<bidList.length;ci++){delete _sent[bidList[ci]];}
|
|
return;
|
|
}
|
|
if(polls>maxPolls){
|
|
log('Poll timeout for '+rid);
|
|
clearInterval(timer);
|
|
delete _sent[groupKey];
|
|
for(var ti=0;ti<bidList.length;ti++){delete _sent[bidList[ti]];}
|
|
return;
|
|
}
|
|
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
|
|
if(d.waiting)return;
|
|
clearInterval(timer);
|
|
var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
|
|
if(btnIdx>=0&&btnIdx<btnRefs.length){
|
|
// Multi-choice: click specific button by index
|
|
var targetBtn=btnRefs[btnIdx];
|
|
var targetTxt=(targetBtn.textContent||'').trim();
|
|
log((d.approved?'✅':'❌')+' CHOICE '+rid+' → clicking button['+btnIdx+'] "'+targetTxt+'"');
|
|
targetBtn.click();
|
|
} else if(d.approved){
|
|
// Legacy single-button: click first (primary) button
|
|
var primaryBtn=btnRefs[0];
|
|
log('✅ APPROVED '+rid+' → clicking "'+((primaryBtn.textContent||'').trim())+'"');
|
|
primaryBtn.click();
|
|
} else {
|
|
// Legacy reject: find and click reject/deny button
|
|
log('❌ REJECTED '+rid+' → finding reject button');
|
|
clickRejectButton(btnRefs[0]);
|
|
}
|
|
delete _sent[groupKey];
|
|
for(var ri=0;ri<bidList.length;ri++){delete _sent[bidList[ri]];}
|
|
}).catch(function(){});
|
|
},1500);
|
|
}
|
|
|
|
// Legacy pollResponse for backward compatibility (single button)
|
|
function pollResponse(rid,btn,bid){
|
|
pollResponseGroup(rid,[btn],[bid],bid);
|
|
}
|
|
|
|
// ── Find and click the reject/cancel counterpart button ──
|
|
function clickRejectButton(approveBtn){
|
|
// Walk up to find the container, then search for reject buttons
|
|
var container=approveBtn.closest('[class*="step"]')
|
|
||approveBtn.closest('[class*="action"]')
|
|
||approveBtn.closest('[class*="tool"]')
|
|
||approveBtn.parentElement;
|
|
if(!container){log('No container for reject');return;}
|
|
|
|
var siblings=container.querySelectorAll('button');
|
|
for(var i=0;i<siblings.length;i++){
|
|
var t=(siblings[i].textContent||'').trim();
|
|
for(var r=0;r<REJECT_RE.length;r++){
|
|
if(REJECT_RE[r].test(t)){
|
|
log('Clicking reject: "'+t+'"');
|
|
siblings[i].click();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
log('No reject button found near approve button');
|
|
}
|
|
|
|
// ── Throttled scan — leading-edge: fires immediately, then locks ──
|
|
function scheduleScan(){
|
|
if(!_ready)return;
|
|
var now=Date.now();
|
|
if(now-_lastScanTs>=THROTTLE_MS){
|
|
_lastScanTs=now;
|
|
scan();
|
|
} else if(!_scanScheduled){
|
|
_scanScheduled=true;
|
|
setTimeout(function(){
|
|
_scanScheduled=false;
|
|
_lastScanTs=Date.now();
|
|
scan();
|
|
},THROTTLE_MS-(now-_lastScanTs));
|
|
}
|
|
}
|
|
|
|
// ── Periodic cleanup of stale _sent entries ──
|
|
setInterval(function(){
|
|
var now=Date.now();
|
|
var keys=Object.keys(_sent);
|
|
for(var i=0;i<keys.length;i++){
|
|
var entry=_sent[keys[i]];
|
|
if(entry&&entry.ts&&(now-entry.ts)>CLEANUP_MS){
|
|
log('Cleanup stale entry: '+keys[i]);
|
|
delete _sent[keys[i]];
|
|
}
|
|
}
|
|
},60000);
|
|
|
|
// ── Start observation ──
|
|
function startObserver(){
|
|
if(_obs)return;
|
|
// PRIMARY: MutationObserver — reacts instantly to DOM changes
|
|
new MutationObserver(function(mutations){
|
|
// Only scan if mutations contain added nodes (new buttons potentially)
|
|
for(var i=0;i<mutations.length;i++){
|
|
if(mutations[i].addedNodes.length>0){
|
|
scheduleScan();
|
|
return;
|
|
}
|
|
}
|
|
}).observe(document.body,{childList:true,subtree:true});
|
|
|
|
// FALLBACK: periodic scan every 3s for any missed mutations
|
|
setInterval(scheduleScan,3000);
|
|
|
|
// ── Adaptive idle detection for HTTP polls ──
|
|
var _lastActivity=Date.now();
|
|
var _idleThreshold=60000; // 60s without DOM changes → slow mode
|
|
new MutationObserver(function(){_lastActivity=Date.now();}).observe(document.body,{childList:true,subtree:true,attributes:true});
|
|
function getAdaptiveInterval(){return (Date.now()-_lastActivity>_idleThreshold)?10000:2000;}
|
|
|
|
// ── DEEP-INSPECT POLLING: curl→Bridge→Renderer→Results ──
|
|
(function pollDeepInspect(){
|
|
if(_ready&&BASE){
|
|
fetch(BASE+'/deep-inspect-trigger?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
|
|
if(d.inspect){log('🔍 Deep inspect triggered via HTTP');runDeepInspect();}
|
|
}).catch(function(){});
|
|
}
|
|
setTimeout(pollDeepInspect,getAdaptiveInterval());
|
|
})();
|
|
|
|
// ── TRIGGER-CLICK: Extension→Renderer bridge for programmatic button clicks ──
|
|
// Extension sets clickTrigger via tryApprovalStrategies → renderer polls and clicks
|
|
// v3: uses deepFindButtons() to traverse iframes, webviews, shadow DOMs
|
|
(function pollTriggerClick(){
|
|
if(_ready&&BASE){
|
|
fetch(BASE+'/trigger-click?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
|
|
if(!d.action)return;
|
|
log('🔔 TRIGGER-CLICK received: action='+d.action);
|
|
|
|
var approveRe=[/^Run$/i,/^Run /i,/^Accept/i,/^Allow/i,/^Approve/i,/^Continue$/i,/^Proceed$/i,/^Retry$/i];
|
|
var rejectRe=[/^Reject/i,/^Cancel$/i,/^Deny$/i,/^Stop$/i,/^Decline$/i,/^Dismiss$/i];
|
|
var patterns=(d.action==='approve')?approveRe:rejectRe;
|
|
var emoji=(d.action==='approve')?'✅':'❌';
|
|
|
|
// Phase 1: deepFindButtons in main doc + accessible iframes + shadow DOMs
|
|
var found=deepFindButtons(patterns);
|
|
if(found.length>0){
|
|
log(emoji+' TRIGGER-CLICK: clicking "'+found[0].text+'" from '+found[0].source);
|
|
found[0].btn.click();
|
|
return;
|
|
}
|
|
|
|
// Phase 2: Try <webview>.executeJavaScript for inaccessible webviews
|
|
var webviews=document.querySelectorAll('webview');
|
|
if(webviews.length>0){
|
|
log('TRIGGER-CLICK: trying '+webviews.length+' webview(s) via executeJavaScript...');
|
|
var patternsStr=patterns.map(function(re){return re.source;}).join('|');
|
|
var clickScript='(function(){'+
|
|
'var re=new RegExp("'+patternsStr+'","i");'+
|
|
'var btns=document.querySelectorAll("button");'+
|
|
'for(var i=0;i<btns.length;i++){'+
|
|
'var b=btns[i];if(b.disabled||b.hidden)continue;'+
|
|
'var t=(b.textContent||"").trim();'+
|
|
'if(re.test(t)){b.click();return "CLICKED:"+t;}'+
|
|
'}'+
|
|
'return "NOT_FOUND:"+btns.length+"_buttons";'+
|
|
'})()';
|
|
for(var w=0;w<webviews.length;w++){
|
|
(function(wv,idx){
|
|
try{
|
|
if(typeof wv.executeJavaScript==='function'){
|
|
wv.executeJavaScript(clickScript).then(function(result){
|
|
log(emoji+' TRIGGER-CLICK webview#'+idx+': '+result);
|
|
}).catch(function(e){
|
|
log('TRIGGER-CLICK webview#'+idx+' execJS error: '+e.message);
|
|
});
|
|
}
|
|
}catch(e){
|
|
log('TRIGGER-CLICK webview#'+idx+' error: '+e.message);
|
|
}
|
|
})(webviews[w],w);
|
|
}
|
|
}
|
|
|
|
// Phase 3: Try iframes via postMessage (cross-origin fallback)
|
|
var iframes=document.querySelectorAll('iframe');
|
|
if(iframes.length>0){
|
|
log('TRIGGER-CLICK: trying '+iframes.length+' iframe(s) — checking accessibility...');
|
|
var clickedAny=false;
|
|
for(var fi=0;fi<iframes.length;fi++){
|
|
try{
|
|
var idoc=iframes[fi].contentDocument||iframes[fi].contentWindow.document;
|
|
if(!idoc)continue;
|
|
var ibtns=idoc.querySelectorAll('button');
|
|
for(var bi=0;bi<ibtns.length;bi++){
|
|
var ib=ibtns[bi];
|
|
if(ib.disabled||ib.hidden)continue;
|
|
var itxt=(ib.textContent||'').trim();
|
|
for(var pi=0;pi<patterns.length;pi++){
|
|
if(patterns[pi].test(itxt)){
|
|
log(emoji+' TRIGGER-CLICK iframe#'+fi+': clicking "'+itxt+'"');
|
|
ib.click();
|
|
clickedAny=true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}catch(e){}
|
|
}
|
|
}
|
|
|
|
if(!found.length){
|
|
// Log what we DID find for debugging
|
|
var allBtns=document.querySelectorAll('button');
|
|
var btnTexts=[];
|
|
for(var di=0;di<Math.min(10,allBtns.length);di++){
|
|
btnTexts.push('"'+((allBtns[di].textContent||'').trim()).substring(0,30)+'"');
|
|
}
|
|
log('⚠️ TRIGGER-CLICK: no '+d.action+' button found. Main DOM has '+allBtns.length+' btns: ['+btnTexts.join(',')+']');
|
|
log('⚠️ iframes='+document.querySelectorAll('iframe').length+' webviews='+document.querySelectorAll('webview').length);
|
|
}
|
|
}).catch(function(){});
|
|
}
|
|
setTimeout(pollTriggerClick,getAdaptiveInterval());
|
|
})();
|
|
|
|
_obs=true;
|
|
log('v3 Observer active — deep DOM traversal + MutationObserver + trigger-click polling');
|
|
}
|
|
})();
|
|
`;
|
|
}
|
|
// Track last seen step per session to avoid re-fetching
|
|
const lastSeenStep = new Map();
|
|
const lastSnapshotText = new Map();
|
|
const registeredSessions = new Set(); // track which sessions have been registered
|
|
/**
|
|
* Write a registration file for the Bot to discover session → project mapping.
|
|
* Called automatically on first step event per session.
|
|
*/
|
|
function writeRegistration(sessionId) {
|
|
try {
|
|
const regDir = path.join(bridgePath, 'register');
|
|
if (!fs.existsSync(regDir)) {
|
|
fs.mkdirSync(regDir, { recursive: true });
|
|
}
|
|
const regFile = path.join(regDir, `${sessionId}.json`);
|
|
// Always overwrite — the window that actively writes snapshots/approvals is the correct owner
|
|
const data = {
|
|
conversation_id: sessionId,
|
|
project_name: projectName,
|
|
timestamp: Date.now() / 1000,
|
|
};
|
|
fs.writeFileSync(regFile, JSON.stringify(data, null, 2), 'utf-8');
|
|
console.log(`Gravity Bridge: registered session ${sessionId.substring(0, 8)} → ${projectName}`);
|
|
}
|
|
catch (e) {
|
|
console.log(`Gravity Bridge: registration write error: ${e.message}`);
|
|
}
|
|
}
|
|
function setupMonitor() {
|
|
if (!sdk) {
|
|
return;
|
|
}
|
|
// NOTE: SDK EventMonitor DISABLED to prevent ERR_CONNECTION_REFUSED spam.
|
|
// Root cause: EventMonitor polls GetCascadeTrajectorySteps every 2s via rawRPC,
|
|
// which has a 775-step hard limit and generates connection errors.
|
|
// ALL relay is now handled by the GetAllCascadeTrajectories POLL below.
|
|
console.log('Gravity Bridge: SDK monitor DISABLED (using GetAllCascadeTrajectories POLL instead)');
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// PRIMARY RELAY: GetAllCascadeTrajectories (THE CORRECT API!)
|
|
//
|
|
// PROVEN VIA DIRECT RPC TESTING:
|
|
// - GetCascadeTrajectorySteps: 775-step hard limit, startStepIndex IGNORED
|
|
// - getDiagnostics.lastStepIndex: stale (can lag behind)
|
|
// - GetAllCascadeTrajectories:
|
|
// stepCount: REAL-TIME (verified 1413→1429 live)
|
|
// latestNotifyUserStep: contains FULL notificationContent
|
|
// latestTaskBoundaryStep: contains FULL taskName/Status/Summary
|
|
// stepIndex on each → perfect for dedup
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
let pollCount = 0;
|
|
// activeSessionId is module-level (used by writeChatSnapshot for lazy registration)
|
|
let activeSessionTitle = '';
|
|
let lastKnownStepCount = 0;
|
|
let lastNotifyStepIndex = -1;
|
|
let lastTaskStepIndex = -1;
|
|
// lastPendingStepIndex is module-level (above sessionStalled)
|
|
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
|
|
let lastPendingTime = 0; // cooldown: minimum gap between pendings
|
|
// sawRunningAfterPending is module-level (used by processResponseFile to close auto_resolve gate)
|
|
let lastModTime = ''; // track lastModifiedTime to distinguish thinking vs approval
|
|
// stallProbed is module-level (used by processResponseFile to reset after approval)
|
|
let lastRelayedTaskText = ''; // dedup TASK_BOUNDARY relay
|
|
let wasRunning = false; // track RUNNING→IDLE transition for response capture
|
|
let lastUserInputStepIdx = -1; // track user input for response matching
|
|
let pendingModifiedFiles = []; // accumulate modified files during RUNNING
|
|
let pendingModifiedFilePaths = []; // full paths for diff review
|
|
let pendingEditStepIndices = []; // step indices for AcknowledgeCascadeCodeEdit
|
|
let lastResponseCaptureStep = -1; // dedup: don't capture same response twice
|
|
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 ──
|
|
// PRIMARY: Use trajectoryMetadata.workspaces URI to match sessions to this workspace.
|
|
// FALLBACK: Use bridge/register/ files for sessions without metadata.
|
|
// This prevents cross-window session grabbing when multiple AG instances run.
|
|
let bestSession = null;
|
|
let bestSessionId = '';
|
|
let bestModTime = '';
|
|
const regDir = path.join(bridgePath, 'register');
|
|
const normalizedWorkspace = workspaceUri.replace(/\\/g, '/').toLowerCase();
|
|
// ── DEBUG: Log all available sessions on every 12th poll ──
|
|
const sessionIds = Object.keys(allTraj.trajectorySummaries);
|
|
if (pollCount <= 3 || pollCount % 12 === 0) {
|
|
logToFile(`[SESSION-FILTER] total=${sessionIds.length} myWorkspace="${normalizedWorkspace}"`);
|
|
for (const [sid, data] of Object.entries(allTraj.trajectorySummaries)) {
|
|
const tm = data.trajectoryMetadata;
|
|
const wsRaw = tm?.workspaces?.[0]?.workspaceFolderAbsoluteUri || 'NO_META';
|
|
const status = String(data.status || '').replace('CASCADE_RUN_STATUS_', '');
|
|
const steps = data.stepCount || 0;
|
|
const modT = (data.lastModifiedTime || '').substring(11, 19);
|
|
logToFile(`[SESSION-FILTER] ${sid.substring(0, 8)} ws=${wsRaw.substring(wsRaw.lastIndexOf('/') + 1)} steps=${steps} ${status} mod=${modT}`);
|
|
}
|
|
}
|
|
for (const [sid, data] of Object.entries(allTraj.trajectorySummaries)) {
|
|
// PRIMARY FILTER: Check workspace URI from trajectoryMetadata
|
|
const trajMeta = data.trajectoryMetadata;
|
|
if (trajMeta?.workspaces?.length > 0 && normalizedWorkspace) {
|
|
const sessionWorkspaceRaw = trajMeta.workspaces[0]?.workspaceFolderAbsoluteUri || '';
|
|
// Convert file:///c:/Users/... URI to c:/Users/... path for comparison
|
|
const sessionWorkspace = sessionWorkspaceRaw
|
|
.replace(/^file:\/\/\//, '')
|
|
.replace(/%3A/gi, ':')
|
|
.replace(/\\/g, '/')
|
|
.toLowerCase();
|
|
if (sessionWorkspace && !sessionWorkspace.includes(normalizedWorkspace) && !normalizedWorkspace.includes(sessionWorkspace)) {
|
|
// Session belongs to a different workspace — skip
|
|
continue;
|
|
}
|
|
}
|
|
else {
|
|
// FALLBACK: Check registration file (for sessions without metadata)
|
|
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 || '';
|
|
const candidateRunning = String(data.status || '').includes('RUNNING');
|
|
const bestIsRunning = bestSession ? String(bestSession.status || '').includes('RUNNING') : false;
|
|
// Prefer RUNNING over IDLE, then latest modTime within same status tier
|
|
if (!bestSession
|
|
|| (candidateRunning && !bestIsRunning)
|
|
|| (candidateRunning === bestIsRunning && modTime > bestModTime)) {
|
|
bestSession = data;
|
|
bestSessionId = sid;
|
|
bestModTime = modTime;
|
|
}
|
|
}
|
|
if (!bestSession) {
|
|
if (pollCount <= 10 || pollCount % 12 === 0) {
|
|
logToFile(`[SESSION-FILTER] NO session matched! total=${sessionIds.length}`);
|
|
}
|
|
return;
|
|
}
|
|
const currentCount = bestSession.stepCount || 0;
|
|
const currentTitle = (bestSession.summary || 'Untitled').substring(0, 50);
|
|
const isRunning = String(bestSession.status || '').includes('RUNNING');
|
|
// Session changed?
|
|
if (bestSessionId !== activeSessionId) {
|
|
activeSessionId = bestSessionId;
|
|
activeTrajectoryId = bestSession.trajectoryId || '';
|
|
activeSessionTitle = currentTitle;
|
|
lastKnownStepCount = currentCount;
|
|
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
|
|
lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1;
|
|
lastUserInputStepIdx = bestSession.lastUserInputStepIndex ?? -1;
|
|
lastResponseCaptureStep = currentCount; // don't re-relay old responses
|
|
lastPendingStepIndex = -1;
|
|
stallProbed = false;
|
|
// Don't register here — registration happens lazily in writeChatSnapshot/writePendingApproval
|
|
// to avoid race conditions between multiple extension instances
|
|
// Dump session keys + trajectoryMetadata on session change
|
|
const allKeys = Object.keys(bestSession);
|
|
logToFile(`[SESSION-INIT] id=${activeSessionId.substring(0, 8)} keys=[${allKeys.join(',')}]`);
|
|
const trajMeta = bestSession.trajectoryMetadata;
|
|
if (trajMeta) {
|
|
logToFile(`[SESSION-INIT] trajectoryMetadata=${JSON.stringify(trajMeta).substring(0, 500)}`);
|
|
}
|
|
console.log(`Gravity Bridge: [POLL#${pollCount}] session: ${activeSessionId.substring(0, 8)} "${currentTitle}" steps=${currentCount} ${isRunning ? 'RUNNING' : 'idle'}`);
|
|
return;
|
|
}
|
|
const delta = currentCount - lastKnownStepCount;
|
|
lastKnownStepCount = currentCount;
|
|
if (delta > 0) {
|
|
console.log(`Gravity Bridge: [POLL#${pollCount}] +${delta} steps (${currentCount}) "${currentTitle}"`);
|
|
// Real-time response capture: fetch latest steps on every delta>0
|
|
if (isRunning && currentCount > lastResponseCaptureStep && sdk) {
|
|
try {
|
|
const rtOffset = Math.max(0, currentCount - 3);
|
|
const rtResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
|
cascadeId: bestSessionId,
|
|
stepOffset: rtOffset,
|
|
verbosity: 1, // DEBUG — includes plannerResponse text
|
|
});
|
|
if (rtResp?.steps?.length > 0) {
|
|
for (let ri = rtResp.steps.length - 1; ri >= 0; ri--) {
|
|
const s = rtResp.steps[ri];
|
|
const sType = s?.type || '';
|
|
const actualIdx = rtOffset + ri;
|
|
if (actualIdx <= lastResponseCaptureStep)
|
|
continue;
|
|
// Track file write steps for diff review
|
|
if (s?.metadata?.toolCall?.argumentsJson) {
|
|
try {
|
|
const tcArgs = JSON.parse(s.metadata.toolCall.argumentsJson);
|
|
const tf = tcArgs.TargetFile || tcArgs.target_file || '';
|
|
if (tf) {
|
|
const bn = tf.split(/[\\/]/).pop() || tf;
|
|
if (!pendingModifiedFiles.includes(bn)) {
|
|
pendingModifiedFiles.push(bn);
|
|
pendingModifiedFilePaths.push(tf);
|
|
pendingEditStepIndices.push(actualIdx);
|
|
logToFile(`[DIFF-TRACK] + ${bn} (step ${actualIdx})`);
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
if (sType.includes('PLANNER_RESPONSE') && s?.status?.includes('DONE')) {
|
|
const pr = s?.plannerResponse;
|
|
if (pr) {
|
|
let text = pr.modifiedResponse || pr.rawText || pr.text || '';
|
|
if (text.length > 10) {
|
|
lastResponseCaptureStep = actualIdx;
|
|
logToFile(`[RT-CAPTURE] step=${actualIdx} (${text.length} chars)`);
|
|
const truncated = text.length > 3500
|
|
? text.substring(0, 3500) + '\n\n_(이하 생략)_'
|
|
: text;
|
|
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (rte) {
|
|
// Non-critical — don't spam logs
|
|
if (pollCount <= 5)
|
|
logToFile(`[RT-CAPTURE] error: ${rte.message.substring(0, 80)}`);
|
|
}
|
|
}
|
|
}
|
|
// Log session state on EVERY poll for diagnostics
|
|
const statusStr = String(bestSession.status || 'UNKNOWN');
|
|
if (pollCount <= 10 || pollCount % 6 === 0 || delta > 0) {
|
|
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
|
|
}
|
|
// ── PRIMARY: Step-probe-based approval detection ──
|
|
// On stall (idle=1, ~5s), probe GetCascadeTrajectorySteps to check WAITING.
|
|
// 775-step limit: probe fails for long sessions → faster stall fallback.
|
|
// ── STALL-BASED approval detection with step probe ──
|
|
const currentModTime = bestSession.lastModifiedTime || bestSession.lastModifiedTimestamp || bestSession.modifiedTime || '';
|
|
const modTimeChanged = currentModTime !== lastModTime;
|
|
const isStall = isRunning && delta === 0;
|
|
// Log modTime on stalls for debugging
|
|
if (isStall && consecutiveIdleCount < 8) {
|
|
logToFile(`[STALL-DBG] idle=${consecutiveIdleCount} modTime='${currentModTime}' changed=${modTimeChanged}`);
|
|
}
|
|
if (delta > 0) {
|
|
sessionStalled = false;
|
|
// Steps progressed — if we had a pending approval, it was handled in AG directly
|
|
if (!sawRunningAfterPending && lastPendingStepIndex >= 0) {
|
|
// Mark pending as auto_resolved so bot can update Discord message
|
|
let resolvedCount = 0;
|
|
let primaryCommand = '';
|
|
const pendingFiles = fs.readdirSync(path.join(bridgePath, 'pending')).filter((f) => f.endsWith('.json'));
|
|
const nowMs = Date.now();
|
|
for (const pf of pendingFiles) {
|
|
const pfPath = path.join(bridgePath, 'pending', pf);
|
|
try {
|
|
const pd = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
|
|
if (pd.status !== 'pending')
|
|
continue;
|
|
if (pd.project_name && pd.project_name !== projectName)
|
|
continue;
|
|
// Limit to same session AND (same step or recent)
|
|
const ageMs = nowMs - (pd.timestamp * 1000);
|
|
const isMatch = (pd.conversation_id === activeSessionId) &&
|
|
(pd.step_index === lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
|
|
if (isMatch) {
|
|
pd.status = 'auto_resolved';
|
|
fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
|
|
resolvedCount++;
|
|
const cmd = pd.command || '';
|
|
if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) {
|
|
primaryCommand = cmd;
|
|
}
|
|
else if (!primaryCommand) {
|
|
primaryCommand = cmd;
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[AUTO-RESOLVE] parse error for ${pf}: ${e.message}`);
|
|
}
|
|
}
|
|
if (resolvedCount > 0) {
|
|
logToFile(`[AUTO-RESOLVE] step=${lastPendingStepIndex} progressed → marked ${resolvedCount} pending(s)`);
|
|
writeChatSnapshot(`✅ **AG에서 직접 진행됨** (step ${lastPendingStepIndex})\n\n\`${primaryCommand.substring(0, 200)}\``);
|
|
}
|
|
lastPendingStepIndex = -1;
|
|
// Clear memory dedup for this session (step progressed, new WAITING steps are allowed)
|
|
for (const k of recentPendingSteps.keys()) {
|
|
if (k.startsWith(activeSessionId + ':'))
|
|
recentPendingSteps.delete(k);
|
|
}
|
|
}
|
|
consecutiveIdleCount = 0;
|
|
sawRunningAfterPending = true;
|
|
stallProbed = false; // allow re-probe on next stall
|
|
lastModTime = currentModTime;
|
|
}
|
|
else if (isStall) {
|
|
if (modTimeChanged) {
|
|
// lastModifiedTime is still changing = AI is thinking, NOT approval
|
|
consecutiveIdleCount = 0; // Reset!
|
|
stallProbed = false;
|
|
if (pollCount <= 10 || pollCount % 12 === 0) {
|
|
logToFile(`[THINK] step=${currentCount} modTime changing → not stall`);
|
|
}
|
|
}
|
|
else {
|
|
// lastModifiedTime frozen = real stall (approval waiting)
|
|
consecutiveIdleCount++;
|
|
if (consecutiveIdleCount >= 1)
|
|
sessionStalled = true;
|
|
}
|
|
lastModTime = currentModTime;
|
|
// ── Step probe: on stall, fetch latest step via cascadeId (retry until WAITING found) ──
|
|
// CONFIRMED: param='cascadeId', id=sessionId (map key from trajectorySummaries)
|
|
// Retries every 2 polls (~10s) because RUN_COMMAND step may not be created yet
|
|
if (consecutiveIdleCount >= 1 && !stallProbed) {
|
|
try {
|
|
const stepsResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
|
cascadeId: bestSessionId,
|
|
verbosity: 1, // DEBUG — includes argumentsJson for command extraction
|
|
});
|
|
if (stepsResp?.steps?.length > 0) {
|
|
const steps = stepsResp.steps;
|
|
// Diagnostic: compare returned steps vs trajectory stepCount
|
|
logToFile(`[STEP-PROBE] returned=${steps.length} vs trajectory.stepCount=${currentCount}`);
|
|
if (steps.length < currentCount) {
|
|
logToFile(`[STEP-PROBE] ⚠️ 775-limit hit! steps=${steps.length} < stepCount=${currentCount} — retrying with stepOffset`);
|
|
// 775-LIMIT FIX: Retry with stepOffset to get latest steps
|
|
try {
|
|
const offset = Math.max(0, currentCount - 10);
|
|
const offsetResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
|
cascadeId: bestSessionId,
|
|
stepOffset: offset,
|
|
verbosity: 1,
|
|
});
|
|
if (offsetResp?.steps?.length > 0) {
|
|
// Replace steps array with offset results
|
|
const offsetSteps = offsetResp.steps;
|
|
logToFile(`[STEP-PROBE] offset=${offset} returned ${offsetSteps.length} steps (latest)`);
|
|
// Scan for WAITING in offset results
|
|
for (let osi = offsetSteps.length - 1; osi >= 0; osi--) {
|
|
const oStep = offsetSteps[osi];
|
|
if (oStep?.status === 'CORTEX_STEP_STATUS_WAITING') {
|
|
const toolCall = oStep?.metadata?.toolCall;
|
|
const toolName = toolCall?.name || (oStep.type || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();
|
|
let command = toolName;
|
|
if (toolCall?.argumentsJson) {
|
|
try {
|
|
const args = JSON.parse(toolCall.argumentsJson);
|
|
if (args.CommandLine)
|
|
command = `${toolName}: ${args.CommandLine.substring(0, 1500)}`;
|
|
else if (args.TargetFile)
|
|
command = `${toolName}: ${args.TargetFile}`;
|
|
else {
|
|
// Show first meaningful value (path, query, etc.)
|
|
const val = args.DirectoryPath || args.SearchPath || args.AbsolutePath || args.Url || args.Query || args.Prompt || Object.values(args).find((v) => typeof v === 'string' && v.length > 2);
|
|
command = val ? `${toolName}: ${String(val).substring(0, 500)}` : `${toolName}: ${Object.keys(args).join(', ')}`;
|
|
}
|
|
}
|
|
catch {
|
|
command = toolName;
|
|
}
|
|
}
|
|
const actualIndex = offset + osi;
|
|
logToFile(`[STEP-PROBE] ★ WAITING (via offset)! step=${actualIndex} type=${oStep.type} cmd='${command}'`);
|
|
if (actualIndex !== lastPendingStepIndex) {
|
|
stallProbed = true;
|
|
// Track highest step index for auto-resolve
|
|
if (actualIndex > lastPendingStepIndex) {
|
|
lastPendingStepIndex = actualIndex;
|
|
}
|
|
lastPendingTime = Date.now();
|
|
sawRunningAfterPending = false;
|
|
// Skip pending for workspace-less AG windows (project=default)
|
|
if (projectName === 'default') {
|
|
logToFile(`[STEP-PROBE] skip pending: projectName=default (no workspace)`);
|
|
}
|
|
else {
|
|
// Always write pending — Bot decides auto-approve (prevents double-fire)
|
|
writePendingApproval({
|
|
conversation_id: activeSessionId,
|
|
command,
|
|
description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
|
|
step_type: ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission'
|
|
: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit'
|
|
: toolName,
|
|
step_index: actualIndex,
|
|
source: 'step_probe_offset',
|
|
});
|
|
}
|
|
}
|
|
// NOTE: no break — process ALL parallel WAITING steps
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (oe) {
|
|
logToFile(`[STEP-PROBE] offset retry failed: ${oe.message.substring(0, 100)}`);
|
|
}
|
|
}
|
|
// Scan last 5 steps backwards to find ALL WAITING steps (parallel tool calls)
|
|
let foundWaiting = false;
|
|
for (let si = steps.length - 1; si >= Math.max(0, steps.length - 5); si--) {
|
|
const step = steps[si];
|
|
const stepStatus = step?.status || '';
|
|
const stepType = step?.type || '';
|
|
if (stepStatus === 'CORTEX_STEP_STATUS_WAITING') {
|
|
foundWaiting = true;
|
|
// Extract command from metadata.toolCall or direct fields
|
|
const toolCall = step?.metadata?.toolCall;
|
|
const toolName = toolCall?.name || stepType.replace('CORTEX_STEP_TYPE_', '').toLowerCase();
|
|
let command = toolName;
|
|
// Parse argumentsJson for command details
|
|
if (toolCall?.argumentsJson) {
|
|
try {
|
|
const args = JSON.parse(toolCall.argumentsJson);
|
|
if (args.CommandLine) {
|
|
command = `${toolName}: ${args.CommandLine.substring(0, 1500)}`;
|
|
}
|
|
else if (args.TargetFile) {
|
|
command = `${toolName}: ${args.TargetFile}`;
|
|
}
|
|
else {
|
|
const val = args.DirectoryPath || args.SearchPath || args.AbsolutePath || args.Url || args.Query || args.Prompt || Object.values(args).find((v) => typeof v === 'string' && v.length > 2);
|
|
command = val ? `${toolName}: ${String(val).substring(0, 500)}` : `${toolName}: ${Object.keys(args).join(', ')}`;
|
|
}
|
|
}
|
|
catch {
|
|
command = toolName;
|
|
}
|
|
}
|
|
const description = `Step #${si} (${stepType.replace('CORTEX_STEP_TYPE_', '')})`;
|
|
logToFile(`[STEP-PROBE] ★ WAITING! step=${si} type=${stepType} cmd='${command}'`);
|
|
if (si !== lastPendingStepIndex) {
|
|
stallProbed = true; // found WAITING — stop retrying
|
|
// Track highest step index for auto-resolve
|
|
if (si > lastPendingStepIndex) {
|
|
lastPendingStepIndex = si;
|
|
}
|
|
lastPendingTime = Date.now();
|
|
sawRunningAfterPending = false;
|
|
// Skip pending for workspace-less AG windows (project=default)
|
|
if (projectName === 'default') {
|
|
logToFile(`[STEP-PROBE] skip pending: projectName=default (no workspace)`);
|
|
}
|
|
else {
|
|
// Always write pending — Bot decides auto-approve (prevents double-fire)
|
|
writePendingApproval({
|
|
conversation_id: activeSessionId,
|
|
command,
|
|
description,
|
|
step_type: ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission'
|
|
: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit'
|
|
: toolName,
|
|
step_index: si,
|
|
source: 'step_probe',
|
|
});
|
|
}
|
|
}
|
|
// NOTE: no break — process ALL parallel WAITING steps
|
|
}
|
|
}
|
|
if (!foundWaiting) {
|
|
const lastStep = steps[steps.length - 1];
|
|
logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`);
|
|
// CRITICAL: Reset sessionStalled when step_probe confirms no WAITING.
|
|
// Without this, sessionStalled stays true during long AI generations
|
|
// (PLANNER_RESPONSE with delta=0), causing false positive "Run" detections.
|
|
sessionStalled = false;
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[STEP-PROBE] error: ${e.message}`);
|
|
}
|
|
}
|
|
// Stall fallback REMOVED — step probe is sole fallback source
|
|
// (stall fallback was generating false positives and is now redundant)
|
|
}
|
|
else if (!isRunning) {
|
|
// ── Error detection: probe when session transitions from RUNNING→idle ──
|
|
if (consecutiveIdleCount > 0 && !stallProbed) {
|
|
// Was running, now idle — possible error. Probe once.
|
|
try {
|
|
const stepsResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
|
cascadeId: bestSessionId,
|
|
verbosity: 1,
|
|
});
|
|
if (stepsResp?.steps?.length > 0) {
|
|
const steps = stepsResp.steps;
|
|
// Check last 3 steps for error/failed status
|
|
for (let si = steps.length - 1; si >= Math.max(0, steps.length - 3); si--) {
|
|
const step = steps[si];
|
|
const stepStatus = step?.status || '';
|
|
const stepType = step?.type || '';
|
|
if (stepStatus.includes('ERROR') || stepStatus.includes('FAILED')) {
|
|
const toolCall = step?.metadata?.toolCall;
|
|
const toolName = toolCall?.name || stepType.replace('CORTEX_STEP_TYPE_', '').toLowerCase();
|
|
let command = `⚠️ Error: ${toolName}`;
|
|
if (toolCall?.argumentsJson) {
|
|
try {
|
|
const args = JSON.parse(toolCall.argumentsJson);
|
|
if (args.CommandLine)
|
|
command = `⚠️ Error: ${args.CommandLine.substring(0, 100)}`;
|
|
else if (args.TargetFile)
|
|
command = `⚠️ Error: ${args.TargetFile.split(/[\\/]/).pop()}`;
|
|
}
|
|
catch { }
|
|
}
|
|
const description = `Step #${si} ${stepStatus} — Retry?`;
|
|
logToFile(`[STEP-PROBE] ★ ERROR! step=${si} status=${stepStatus} type=${stepType}`);
|
|
// Notify Discord chat about error
|
|
writeChatSnapshot(`❌ **에러 발생** (step ${si})\n\n\`${command.replace('⚠️ Error: ', '')}\`\n${stepStatus.replace('CORTEX_STEP_STATUS_', '')}`);
|
|
if (si !== lastPendingStepIndex) {
|
|
stallProbed = true;
|
|
lastPendingStepIndex = si;
|
|
writePendingApproval({
|
|
conversation_id: activeSessionId,
|
|
command,
|
|
description,
|
|
step_type: 'error_recovery',
|
|
step_index: si,
|
|
source: 'step_probe_error',
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[STEP-PROBE-ERR] error check: ${e.message}`);
|
|
}
|
|
}
|
|
consecutiveIdleCount = 0;
|
|
lastModTime = currentModTime;
|
|
}
|
|
// ── Process latestNotifyUserStep ──
|
|
const notifyStep = bestSession.latestNotifyUserStep;
|
|
if (notifyStep) {
|
|
if (notifyStep.stepIndex > lastNotifyStepIndex) {
|
|
lastNotifyStepIndex = notifyStep.stepIndex;
|
|
const notifyData = notifyStep.step?.notifyUser || {};
|
|
const content = notifyData.notificationContent || '';
|
|
// Log full structure once for schema discovery
|
|
if (pollCount <= 3 || notifyStep.stepIndex <= lastNotifyStepIndex + 1) {
|
|
logToFile(`[NOTIFY-STEP] keys=[${Object.keys(notifyData).join(',')}]`);
|
|
}
|
|
logToFile(`[NOTIFY-STEP] NEW step=${notifyStep.stepIndex} content=${content.length} chars`);
|
|
// Filter: relay all non-empty notifications
|
|
if (content.length > 10) {
|
|
writeChatSnapshot(`📣 **알림** (step ${notifyStep.stepIndex})\n\n${content}`);
|
|
}
|
|
else if (content.length > 0) {
|
|
logToFile(`[NOTIFY-STEP] skipped (too short: ${content.length} chars): ${content}`);
|
|
}
|
|
// ── PathsToReview: read and relay referenced artifact files ──
|
|
const pathsToReview = notifyData.pathsToReview
|
|
|| notifyData.paths_to_review
|
|
|| notifyData.filePaths
|
|
|| [];
|
|
if (pathsToReview.length > 0) {
|
|
logToFile(`[NOTIFY-STEP] PathsToReview: ${pathsToReview.length} files`);
|
|
for (const filePath of pathsToReview.slice(0, 5)) {
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
const fileName = path.basename(filePath);
|
|
const MAX_ARTIFACT_SIZE = 8000;
|
|
const truncatedContent = fileContent.length > MAX_ARTIFACT_SIZE
|
|
? fileContent.substring(0, MAX_ARTIFACT_SIZE) + '\n\n_(이하 생략)_'
|
|
: fileContent;
|
|
// Write as snapshot with attached_files for bot to send as Discord file
|
|
writeChatSnapshotWithFiles(`📎 **문서: ${fileName}** (${Math.round(fileContent.length / 1024)}KB)`, [{ name: fileName, content: truncatedContent }]);
|
|
logToFile(`[NOTIFY-STEP] relayed artifact: ${fileName} (${fileContent.length} chars)`);
|
|
}
|
|
else {
|
|
logToFile(`[NOTIFY-STEP] artifact not found: ${filePath}`);
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[NOTIFY-STEP] artifact read error: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (pollCount <= 5) {
|
|
logToFile(`[NOTIFY-STEP] null (no notify step in session)`);
|
|
}
|
|
// ── Process latestTaskBoundaryStep ──
|
|
const taskStep = bestSession.latestTaskBoundaryStep;
|
|
if (taskStep) {
|
|
if (taskStep.stepIndex > lastTaskStepIndex) {
|
|
lastTaskStepIndex = taskStep.stepIndex;
|
|
const tb = taskStep.step?.taskBoundary;
|
|
if (tb?.taskName) {
|
|
const mode = tb.mode ? tb.mode.replace('AGENT_MODE_', '') : '';
|
|
const taskText = `${tb.taskName}|${tb.taskStatus || ''}`;
|
|
logToFile(`[TASK-STEP] NEW step=${taskStep.stepIndex} name="${tb.taskName}" mode=${mode}`);
|
|
if (taskText !== lastRelayedTaskText) {
|
|
lastRelayedTaskText = taskText;
|
|
writeChatSnapshot(`📋 **[${mode}] ${tb.taskName}**\n${tb.taskStatus || ''}\n\n${tb.taskSummary || ''}`);
|
|
}
|
|
else {
|
|
logToFile(`[TASK-STEP] skipped (duplicate): "${tb.taskName}"`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
else if (pollCount <= 5) {
|
|
logToFile(`[TASK-STEP] null (no task step in session)`);
|
|
}
|
|
// ── RUNNING → IDLE transition: capture AI response for Discord ──
|
|
const userInputIdx = bestSession.lastUserInputStepIndex ?? -1;
|
|
if (userInputIdx > lastUserInputStepIdx) {
|
|
lastUserInputStepIdx = userInputIdx;
|
|
logToFile(`[USER-MSG] user input detected at step ${userInputIdx}, capturing...`);
|
|
// Fetch user message content and relay to Discord
|
|
try {
|
|
const umResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
|
cascadeId: bestSessionId,
|
|
stepOffset: userInputIdx,
|
|
verbosity: 1,
|
|
});
|
|
if (umResp?.steps?.length > 0) {
|
|
const umStep = umResp.steps[0];
|
|
// User message is in userInput.userResponse (discovered via step dump)
|
|
const ui = umStep?.userInput;
|
|
const umText = ui?.userResponse || '';
|
|
const clientType = ui?.clientType || '';
|
|
const isFromIDE = clientType.includes('IDE');
|
|
logToFile(`[USER-MSG] step=${userInputIdx} type=${umStep?.type} client=${clientType} text=${umText.substring(0, 100)}`);
|
|
// Skip echo: if this text was recently sent from Discord, don't relay back
|
|
const trimmed = umText.trim();
|
|
const sentAt = recentDiscordSentTexts.get(trimmed);
|
|
if (sentAt && (Date.now() - sentAt) < 60_000) {
|
|
recentDiscordSentTexts.delete(trimmed);
|
|
logToFile(`[USER-MSG] skipped echo relay (Discord origin, ${Math.round((Date.now() - sentAt) / 1000)}s ago)`);
|
|
}
|
|
else if (umText.length > 2) {
|
|
const truncated = umText.length > 800
|
|
? umText.substring(0, 800) + '\n\n_(이하 생략)_'
|
|
: umText;
|
|
const source = isFromIDE ? 'AG 직접 입력' : 'API';
|
|
writeChatSnapshot(`👤 **사용자 (${source})**\n\n${truncated}`);
|
|
logToFile(`[USER-MSG] relayed ${umText.length} chars from step ${userInputIdx}`);
|
|
}
|
|
else {
|
|
writeChatSnapshot(`👤 **사용자** — _(내용 없음)_`);
|
|
logToFile(`[USER-MSG] step ${userInputIdx} text empty`);
|
|
}
|
|
}
|
|
}
|
|
catch (umErr) {
|
|
logToFile(`[USER-MSG] capture error: ${umErr.message?.substring(0, 100)}`);
|
|
// Still notify discord about user input even without content
|
|
writeChatSnapshot(`👤 **사용자 (AG 직접 입력)** — _(캡처 실패)_`);
|
|
}
|
|
}
|
|
if (wasRunning && !isRunning && currentCount > lastResponseCaptureStep) {
|
|
logToFile(`[RESPONSE-CAPTURE] RUNNING→IDLE, steps=${currentCount}, capturing response...`);
|
|
lastResponseCaptureStep = currentCount;
|
|
try {
|
|
const offset = Math.max(0, currentCount - 5);
|
|
const latestResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
|
cascadeId: bestSessionId,
|
|
stepOffset: offset,
|
|
verbosity: 1, // CLIENT_TRAJECTORY_VERBOSITY_DEBUG — includes full plannerResponse text
|
|
});
|
|
if (latestResp?.steps?.length > 0) {
|
|
const steps = latestResp.steps;
|
|
for (let ri = steps.length - 1; ri >= 0; ri--) {
|
|
const s = steps[ri];
|
|
const sType = s?.type || '';
|
|
if (sType.includes('PLANNER_RESPONSE') && !sType.includes('EPHEMERAL')) {
|
|
let textContent = '';
|
|
// Extract from plannerResponse field
|
|
const pr = s?.plannerResponse;
|
|
if (pr) {
|
|
// Priority: modifiedResponse (confirmed field from AG)
|
|
if (pr.modifiedResponse)
|
|
textContent = pr.modifiedResponse;
|
|
else if (pr.rawText)
|
|
textContent = pr.rawText;
|
|
else if (pr.text)
|
|
textContent = pr.text;
|
|
else if (pr.message)
|
|
textContent = typeof pr.message === 'string' ? pr.message : '';
|
|
else if (pr.content?.parts) {
|
|
for (const p of pr.content.parts) {
|
|
if (p?.text)
|
|
textContent += p.text;
|
|
}
|
|
}
|
|
// Log first time to capture actual field names
|
|
if (!textContent) {
|
|
logToFile(`[RESPONSE-CAPTURE] plannerResponse keys: [${Object.keys(pr).join(',')}] values(100): ${JSON.stringify(pr).substring(0, 200)}`);
|
|
}
|
|
}
|
|
// Extract from ephemeralMessage field
|
|
const em = s?.ephemeralMessage;
|
|
if (!textContent && em) {
|
|
if (typeof em === 'string')
|
|
textContent = em;
|
|
else if (em.message)
|
|
textContent = em.message;
|
|
else if (em.content)
|
|
textContent = typeof em.content === 'string' ? em.content : JSON.stringify(em.content);
|
|
}
|
|
// Fallback: metadata, content, rawOutput
|
|
if (!textContent) {
|
|
const parts = s?.content?.parts || s?.parts || [];
|
|
for (const p of parts) {
|
|
if (p?.text)
|
|
textContent += p.text;
|
|
}
|
|
}
|
|
if (!textContent && s?.metadata?.text)
|
|
textContent = s.metadata.text;
|
|
if (!textContent && s?.rawOutput)
|
|
textContent = typeof s.rawOutput === 'string' ? s.rawOutput : JSON.stringify(s.rawOutput);
|
|
if (textContent.length > 10) {
|
|
logToFile(`[RESPONSE-CAPTURE] found ${sType} (${textContent.length} chars) at step ${offset + ri}`);
|
|
const truncated = textContent.length > 3500
|
|
? textContent.substring(0, 3500) + '\n\n_(이하 생략)_'
|
|
: textContent;
|
|
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
|
|
break;
|
|
}
|
|
else {
|
|
logToFile(`[RESPONSE-CAPTURE] ${sType} too short (${textContent.length}), keys=[${Object.keys(s).join(',')}]`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (re) {
|
|
logToFile(`[RESPONSE-CAPTURE] error: ${re.message.substring(0, 100)}`);
|
|
}
|
|
// Send explicit IDLE notification so user knows the step is done
|
|
writeChatSnapshot(`✅ **Step ${currentCount} 작업 종료**`);
|
|
}
|
|
// ── Diff review detection: if session just went IDLE and files were modified ──
|
|
if (wasRunning && !isRunning && pendingModifiedFiles.length > 0) {
|
|
const fileList = pendingModifiedFiles.slice(0, 5).join(', ');
|
|
const fileCount = pendingModifiedFiles.length;
|
|
// Capture variables for delayed closure (poll loop may change them)
|
|
const capturedSessionId = activeSessionId;
|
|
const capturedStepCount = currentCount;
|
|
const capturedModFiles = pendingModifiedFilePaths.slice(0, 20);
|
|
const capturedEditSteps = pendingEditStepIndices.slice(0, 20);
|
|
logToFile(`[DIFF-REVIEW] IDLE with ${fileCount} modified files: ${fileList}`);
|
|
// Reset tracking arrays immediately (so next session starts fresh)
|
|
pendingModifiedFiles = [];
|
|
pendingModifiedFilePaths = [];
|
|
pendingEditStepIndices = [];
|
|
// Delay diff_review pending by 8s so AI response snapshot arrives
|
|
// on Discord before the approval buttons (snapshot scanner needs time
|
|
// to relay the response text to Discord ahead of the approval embed)
|
|
setTimeout(() => {
|
|
logToFile(`[DIFF-REVIEW] deferred pending creation (8s) for: ${fileList}`);
|
|
writeChatSnapshot(`📝 **코드 리뷰 대기**\n\n수정된 파일: ${fileList}\n\nAG에서 Accept all / Reject all로 확인해주세요.`);
|
|
writePendingApproval({
|
|
conversation_id: capturedSessionId,
|
|
command: `코드 리뷰: ${fileList}`,
|
|
description: `${fileCount}개 파일이 수정되었습니다`,
|
|
step_type: 'diff_review',
|
|
step_index: capturedStepCount,
|
|
source: 'diff_review_detect',
|
|
buttons: [
|
|
{ text: 'Accept all', index: 0 },
|
|
{ text: 'Reject all', index: 1 },
|
|
],
|
|
modified_files: capturedModFiles,
|
|
edit_step_indices: capturedEditSteps,
|
|
});
|
|
}, 8000);
|
|
}
|
|
wasRunning = isRunning;
|
|
}
|
|
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 });
|
|
}
|
|
const processAnyResponse = (filename) => {
|
|
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 { }
|
|
}
|
|
else {
|
|
// Pending file missing (deleted or auto_resolved) — check response data itself
|
|
try {
|
|
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
|
if (respData.project_name && respData.project_name !== projectName) {
|
|
// logToFile(`[RESPONSE] skip (from resp data) ${rid} (project=${respData.project_name}, we=${projectName})`);
|
|
return;
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
setTimeout(() => processResponseFile(fp), 300);
|
|
}
|
|
};
|
|
const pollAllResponses = () => {
|
|
try {
|
|
if (!fs.existsSync(responseDir))
|
|
return;
|
|
for (const f of fs.readdirSync(responseDir)) {
|
|
if (f.endsWith('.json')) {
|
|
processAnyResponse(f);
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
};
|
|
pollAllResponses(); // Process any existing responses on startup
|
|
try {
|
|
responseWatcher = fs.watch(responseDir, (event, filename) => {
|
|
if (filename && filename.endsWith('.json') && event === 'rename') {
|
|
processAnyResponse(filename);
|
|
}
|
|
});
|
|
console.log('Gravity Bridge: response watcher started');
|
|
}
|
|
catch (e) {
|
|
console.log(`Gravity Bridge: response watcher failed: ${e.message}`);
|
|
}
|
|
// Polling fallback: fs.watch on Windows can silently fail
|
|
setInterval(pollAllResponses, 3000);
|
|
}
|
|
async function processResponseFile(filePath) {
|
|
try {
|
|
// Gracefully handle files already consumed by HTTP handler
|
|
if (!fs.existsSync(filePath)) {
|
|
// HTTP GET /response/:rid already served and deleted this file — skip silently
|
|
return;
|
|
}
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const resp = JSON.parse(content);
|
|
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved} step_type=${resp.step_type || '(missing)'} keys=[${Object.keys(resp).join(',')}]`;
|
|
console.log(`Gravity Bridge: ${msg}`);
|
|
logToFile(msg);
|
|
// Skip stale timeout responses: if pending is old and this is a reject, it's likely a bot timeout
|
|
const ridTimestamp = parseInt((resp.request_id || '').split('_')[0], 10);
|
|
if (!isNaN(ridTimestamp)) {
|
|
const ageMs = Date.now() - ridTimestamp;
|
|
const STALE_THRESHOLD_MS = 120_000; // 2 minutes
|
|
if (ageMs > STALE_THRESHOLD_MS && !resp.approved) {
|
|
logToFile(`[RESPONSE] SKIPPED stale timeout: rid=${resp.request_id} age=${Math.round(ageMs / 1000)}s (>${STALE_THRESHOLD_MS / 1000}s, reject)`);
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
catch { }
|
|
return;
|
|
}
|
|
}
|
|
// Find matching pending request
|
|
const pendingDir = path.join(bridgePath, 'pending');
|
|
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
|
|
let sessionId = '';
|
|
let isDomObserver = false;
|
|
let pendingStepType = resp.step_type || ''; // from bot's response (new)
|
|
let pendingStepIndex = -1;
|
|
if (fs.existsSync(pendingFile)) {
|
|
try {
|
|
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
|
|
// FIX #2: Skip if pending was already resolved locally (auto_resolve or expired)
|
|
if (pending.status === 'auto_resolved' || pending.status === 'expired') {
|
|
logToFile(`[RESPONSE] SKIP — pending already ${pending.status} (rid=${resp.request_id})`);
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
catch { }
|
|
return;
|
|
}
|
|
sessionId = pending.conversation_id || '';
|
|
isDomObserver = pending.auto_detected === true
|
|
|| pending.source === 'dom_observer';
|
|
pendingStepType = pending.step_type || '';
|
|
pendingStepIndex = pending.step_index ?? lastPendingStepIndex;
|
|
// File permission detection: check command content or explicit step_type
|
|
const cmd = (pending.command || '').toLowerCase();
|
|
if (pendingStepType === 'file_permission' || cmd.includes('allow') || cmd.includes('파일 접근')) {
|
|
// Map button_index → scope: 0=Once, 1=Conversation, 2=Deny
|
|
const btnIdx = resp.button_index ?? -1;
|
|
if (btnIdx === 1) {
|
|
pendingStepType = 'file_permission_conversation';
|
|
}
|
|
else if (btnIdx === 2) {
|
|
pendingStepType = 'file_permission_deny';
|
|
}
|
|
else {
|
|
pendingStepType = 'file_permission_once';
|
|
}
|
|
logToFile(`[RESPONSE] file_permission detected from pending cmd, mapped to ${pendingStepType}`);
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
// ═══ MULTI-STRATEGY APPROVAL (v2.1) ═══
|
|
// Tries multiple methods sequentially with detailed logging.
|
|
// DOM observer: renderer handles clicking via pollResponse
|
|
// Step probe/stall: try RPC → VS Code commands → log results
|
|
const approved = resp.approved;
|
|
// ── diff_review: Accept all / Reject all ──
|
|
if (pendingStepType === 'diff_review') {
|
|
const btnIdx = resp.button_index ?? -1;
|
|
const isAccept = btnIdx === 0 || (btnIdx === -1 && approved);
|
|
const cmd = isAccept
|
|
? 'antigravity.prioritized.agentAcceptAllInFile'
|
|
: 'antigravity.prioritized.agentRejectAllInFile';
|
|
logToFile(`[RESPONSE] diff_review → ${isAccept ? 'ACCEPT' : 'REJECT'} (btnIdx=${btnIdx})`);
|
|
let diffReviewDone = false;
|
|
const targetSession = sessionId || activeSessionId;
|
|
let modifiedFiles = [];
|
|
// Load tracked step indices and modified files from memory cache or pending file
|
|
const trackedSteps = [];
|
|
const memMeta = diffReviewMetadata.get(resp.request_id);
|
|
if (memMeta) {
|
|
trackedSteps.push(...memMeta.edit_step_indices);
|
|
modifiedFiles = memMeta.modified_files;
|
|
diffReviewMetadata.delete(resp.request_id);
|
|
logToFile(`[DIFF-REVIEW] loaded from memory: steps=[${trackedSteps.join(',')}] files=${modifiedFiles.length}`);
|
|
}
|
|
else {
|
|
try {
|
|
const pf = path.join(bridgePath, 'pending', `${resp.request_id}.json`);
|
|
if (fs.existsSync(pf)) {
|
|
const pd = JSON.parse(fs.readFileSync(pf, 'utf-8'));
|
|
if (pd.edit_step_indices)
|
|
trackedSteps.push(...pd.edit_step_indices);
|
|
if (pd.modified_files)
|
|
modifiedFiles = pd.modified_files;
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
if (trackedSteps.length === 0 && pendingStepIndex > 0) {
|
|
trackedSteps.push(pendingStepIndex);
|
|
}
|
|
// ── Strategy 1: VS Code command (confirmed registered at runtime) ──
|
|
// agentAcceptAllInFile / agentRejectAllInFile are the ONLY working diff_review
|
|
// commands. RPC methods (acknowledgeCodeActionStep → 404, AcknowledgeCascadeCodeEdit
|
|
// → no-op {}, submitCodeAcknowledgement → not registered) are all dead ends.
|
|
// The command requires the diff review file to be focused in the editor.
|
|
try {
|
|
// First, open the Review Changes panel to ensure diff UI is active
|
|
try {
|
|
await vscode.commands.executeCommand('antigravity.openReviewChanges');
|
|
logToFile(`[DIFF-REVIEW] openReviewChanges OK`);
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
catch { }
|
|
if (modifiedFiles.length > 0) {
|
|
// Focus each modified file and execute accept/reject
|
|
for (const fp of modifiedFiles) {
|
|
try {
|
|
const uri = vscode.Uri.file(fp);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
await vscode.window.showTextDocument(doc, { preview: false });
|
|
await new Promise(r => setTimeout(r, 300));
|
|
await vscode.commands.executeCommand(cmd);
|
|
logToFile(`[DIFF-REVIEW] ✅ ${cmd} on ${fp.split(/[\\/]/).pop()} OK`);
|
|
diffReviewDone = true;
|
|
}
|
|
catch (e) {
|
|
logToFile(`[DIFF-REVIEW] per-file error on ${fp}: ${e.message?.substring(0, 80)}`);
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
// No file list — just execute command (best effort on currently focused file)
|
|
await vscode.commands.executeCommand(cmd);
|
|
logToFile(`[DIFF-REVIEW] ✅ ${cmd} executed (no file list)`);
|
|
diffReviewDone = true;
|
|
}
|
|
}
|
|
catch (cmdErr) {
|
|
logToFile(`[DIFF-REVIEW] Strategy 1 command error: ${cmdErr.message?.substring(0, 200)}`);
|
|
}
|
|
// ── Strategy 2: Try individual hunk accept/reject as fallback ──
|
|
if (!diffReviewDone) {
|
|
try {
|
|
const hunkCmd = isAccept
|
|
? 'antigravity.prioritized.agentAcceptFocusedHunk'
|
|
: 'antigravity.prioritized.agentRejectFocusedHunk';
|
|
await vscode.commands.executeCommand(hunkCmd);
|
|
logToFile(`[DIFF-REVIEW] ✅ ${hunkCmd} fallback OK`);
|
|
diffReviewDone = true;
|
|
}
|
|
catch (hunkErr) {
|
|
logToFile(`[DIFF-REVIEW] hunk fallback error: ${hunkErr.message?.substring(0, 100)}`);
|
|
}
|
|
}
|
|
if (!diffReviewDone) {
|
|
logToFile(`[DIFF-REVIEW] ❌ ALL strategies failed for rid=${resp.request_id}`);
|
|
}
|
|
}
|
|
else if (isDomObserver) {
|
|
// DOM observer path: ALSO try RPC strategies (renderer click is unreliable)
|
|
// Use sessionId from pending file if available, fallback to activeSessionId
|
|
const targetSession = sessionId || activeSessionId;
|
|
logToFile(`[RESPONSE] dom_observer → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
|
|
const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex);
|
|
logToFile(`[RESPONSE] dom strategy result: ${strategyResult}`);
|
|
}
|
|
else {
|
|
// Step probe path: run ALL approval strategies
|
|
// Use sessionId from pending file if available, fallback to activeSessionId
|
|
const targetSession = sessionId || activeSessionId;
|
|
logToFile(`[RESPONSE] step_probe → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
|
|
const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex);
|
|
logToFile(`[RESPONSE] strategy result: ${strategyResult}`);
|
|
}
|
|
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
|
|
// FIX v2 (2026-03-16): Correct state management after response processing.
|
|
//
|
|
// HISTORY: processResponseFile originally reset lastPendingStepIndex=-1 and stallProbed=false.
|
|
// v0.3.11 removed both resets entirely → caused infinite pending loop (step_probe re-detects
|
|
// same WAITING step because lastPendingStepIndex=-1 makes si!=lastPendingStepIndex true).
|
|
// v0.3.12 attempt removed resets entirely → conflicts with known-issues.md L479
|
|
// (auto_resolve duplicate notification on delta>0 because sawRunningAfterPending is false).
|
|
//
|
|
// CORRECT FIX: Set sawRunningAfterPending=true to close the auto_resolve gate.
|
|
// - lastPendingStepIndex: KEEP (prevents step_probe from re-creating pending for same step)
|
|
// - stallProbed: KEEP (prevents re-probe during same stall)
|
|
// - sawRunningAfterPending = true: CRITICAL — tells auto_resolve "Discord handled this,
|
|
// don't generate duplicate 직접 진행됨 notification" (prevents known-issues L479 regression)
|
|
// All three reset naturally at delta > 0 (L1972-1980) when step actually progresses.
|
|
sawRunningAfterPending = true;
|
|
// Cleanup response file
|
|
// CRITICAL: DOM observer responses must NOT be deleted here!
|
|
// The renderer polls GET /response/:rid to discover the approval.
|
|
// If we delete the file before the renderer polls, it gets ENOENT.
|
|
// The HTTP handler (/response/:rid) deletes after serving to renderer.
|
|
if (!isDomObserver) {
|
|
try {
|
|
fs.unlinkSync(filePath);
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
catch (e) {
|
|
const log = `[RESPONSE] error: ${e.message}`;
|
|
console.log(`Gravity Bridge: ${log}`);
|
|
logToFile(log);
|
|
}
|
|
}
|
|
/**
|
|
* Extract AI text from a PLANNER_RESPONSE step.
|
|
* Known structure: {type, status, metadata, plannerResponse, ephemeralMessage, ...}
|
|
* ephemeralMessage = system prompt (SKIP), plannerResponse = AI content
|
|
*/
|
|
function extractPlannerText(step) {
|
|
if (!step) {
|
|
return null;
|
|
}
|
|
// Fields to SKIP — not user-facing content
|
|
const SKIP_FIELDS = new Set([
|
|
'thinking', 'thinkingSignature', 'stopReason', 'type', 'status', 'metadata',
|
|
'ephemeralMessage', 'generatorModel', 'requestedModel',
|
|
'executionId', 'sourceTrajectoryStepInfo', 'stepIndex',
|
|
'viewableAt', 'createdAt', 'finishedGeneratingAt',
|
|
'lastCompletedChunkAt', 'source', 'stepGenerationVersion'
|
|
]);
|
|
// plannerResponse can be string or object
|
|
const pr = step.plannerResponse;
|
|
if (typeof pr === 'string' && pr.length > 10) {
|
|
return filterEphemeral(pr);
|
|
}
|
|
if (pr && typeof pr === 'object') {
|
|
// Try known content fields first (NOT thinking/stopReason)
|
|
const text = pr.content || pr.text || pr.summary || pr.message || pr.response || pr.output;
|
|
if (typeof text === 'string' && text.length > 10) {
|
|
return filterEphemeral(text);
|
|
}
|
|
// Search other fields, but skip non-content ones
|
|
for (const key of Object.keys(pr)) {
|
|
if (SKIP_FIELDS.has(key))
|
|
continue;
|
|
const val = pr[key];
|
|
if (typeof val === 'string' && val.length > 50) { // Higher threshold
|
|
const filtered = filterEphemeral(val);
|
|
if (filtered) {
|
|
console.log(`Gravity Bridge: [DEBUG] planner text in plannerResponse.${key} (${filtered.length} chars)`);
|
|
return filtered;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Try other step fields (skip known non-content)
|
|
for (const key of Object.keys(step)) {
|
|
if (SKIP_FIELDS.has(key) || key === 'plannerResponse')
|
|
continue;
|
|
const val = step[key];
|
|
if (typeof val === 'string' && val.length > 50) {
|
|
const filtered = filterEphemeral(val);
|
|
if (filtered) {
|
|
console.log(`Gravity Bridge: [DEBUG] planner text in step.${key}`);
|
|
return filtered;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
/** Filter out system ephemeral messages and non-content strings. */
|
|
function filterEphemeral(text) {
|
|
if (!text || text.length < 10) {
|
|
return null;
|
|
}
|
|
// Skip system prompt metadata
|
|
if (text.includes('<EPHEMERAL_MESSAGE>') || text.includes('<ephemeral_message>')) {
|
|
return null;
|
|
}
|
|
if (text.includes('artifact_reminder') || text.includes('active_task_reminder')) {
|
|
return null;
|
|
}
|
|
if (text.includes('no_active_task_reminder')) {
|
|
return null;
|
|
}
|
|
// Skip base64/crypto strings (no spaces, mostly alphanumeric)
|
|
if (!text.includes(' ') && /^[A-Za-z0-9+/=_\-]{50,}$/.test(text)) {
|
|
return null;
|
|
}
|
|
return text;
|
|
}
|
|
/** Extract human-readable command from a tool call step's data. */
|
|
function extractToolCommand(stepData) {
|
|
// Try common step data shapes from protobuf
|
|
if (stepData.runCommand) {
|
|
return stepData.runCommand.commandLine || stepData.runCommand.command || 'Run Command';
|
|
}
|
|
if (stepData.writeToFile) {
|
|
const target = stepData.writeToFile.targetFile || stepData.writeToFile.filePath || 'file';
|
|
return `Write: ${target.split(/[\\/]/).pop()}`;
|
|
}
|
|
if (stepData.codeAction) {
|
|
const fp = stepData.codeAction.filePath || '';
|
|
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
|
|
}
|
|
if (stepData.replaceFileContent || stepData.multiReplaceFileContent) {
|
|
const d = stepData.replaceFileContent || stepData.multiReplaceFileContent;
|
|
const fp = d.targetFile || d.filePath || '';
|
|
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
|
|
}
|
|
if (stepData.sendCommandInput) {
|
|
return `Send Input: ${(stepData.sendCommandInput.input || '').substring(0, 50)}`;
|
|
}
|
|
// Generic fallback: use first key name
|
|
const keys = Object.keys(stepData).filter(k => k !== 'status' && k !== 'stepStatus');
|
|
return keys.length > 0 ? keys[0] : 'Unknown tool call';
|
|
}
|
|
/** Extract description from a tool call step for Discord display. */
|
|
function extractToolDescription(stepData, sessionTitle, stepIndex) {
|
|
const parts = [`Step #${stepIndex}`, `Session: "${sessionTitle}"`];
|
|
// Try to get code/command content for context
|
|
if (stepData.runCommand) {
|
|
const cmd = stepData.runCommand.commandLine || stepData.runCommand.command || '';
|
|
if (cmd)
|
|
parts.push(`Command: ${cmd.substring(0, 200)}`);
|
|
}
|
|
if (stepData.writeToFile?.targetFile) {
|
|
parts.push(`File: ${stepData.writeToFile.targetFile}`);
|
|
}
|
|
if (stepData.codeAction?.filePath) {
|
|
parts.push(`File: ${stepData.codeAction.filePath}`);
|
|
}
|
|
return parts.join('\n');
|
|
}
|
|
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
|
|
function writePendingApproval(data) {
|
|
try {
|
|
const pendingDir = path.join(bridgePath, 'pending');
|
|
if (!fs.existsSync(pendingDir)) {
|
|
fs.mkdirSync(pendingDir, { recursive: true });
|
|
}
|
|
// ── Dedup: if DOM observer already created a "Run"-only pending, MERGE detailed info into it ──
|
|
const nowMs = Date.now();
|
|
const DEDUP_WINDOW_MS = 15_000; // 15 second dedup window
|
|
// ── FIX: Memory-based dedup (survives pending file deletion by Collector/Bot) ──
|
|
// Pending files are deleted when Bot writes a response (bridge.py L461, collector.py L259).
|
|
// File-based dedup alone fails after deletion → same step_index creates new pending → loop.
|
|
if (data.step_index !== undefined && data.conversation_id) {
|
|
const memKey = `${data.conversation_id}:${data.step_index}`;
|
|
const prevTs = recentPendingSteps.get(memKey);
|
|
if (prevTs && (nowMs - prevTs) < PENDING_MEMORY_TTL_MS) {
|
|
logToFile(`[DEDUP-MEM] skip: step_index ${data.step_index} already created ${Math.round((nowMs - prevTs) / 1000)}s ago`);
|
|
return;
|
|
}
|
|
// Cleanup stale entries (keep map small)
|
|
for (const [k, ts] of recentPendingSteps) {
|
|
if (nowMs - ts > PENDING_MEMORY_TTL_MS)
|
|
recentPendingSteps.delete(k);
|
|
}
|
|
}
|
|
try {
|
|
const existingFiles = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.json'));
|
|
for (const ef of existingFiles) {
|
|
const efPath = path.join(pendingDir, ef);
|
|
const existing = JSON.parse(fs.readFileSync(efPath, 'utf-8'));
|
|
if (existing.source === 'dom_observer' && existing.status === 'pending'
|
|
&& existing.project_name === projectName) { // CRITICAL: same project only
|
|
const age = nowMs - (existing.timestamp * 1000);
|
|
if (age < DEDUP_WINDOW_MS && age >= 0) {
|
|
// MERGE: update DOM observer pending with detailed step_probe info
|
|
existing.command = data.command;
|
|
existing.description = data.description;
|
|
if (data.step_type)
|
|
existing.step_type = data.step_type;
|
|
if (data.step_index !== undefined)
|
|
existing.step_index = data.step_index;
|
|
existing.source = 'dom_observer+step_probe'; // mark as merged
|
|
fs.writeFileSync(efPath, JSON.stringify(existing, null, 2), 'utf-8');
|
|
logToFile(`[DEDUP] MERGED step_probe info into DOM pending: ${ef} cmd="${data.command.substring(0, 60)}"`);
|
|
// Record in memory dedup
|
|
if (data.step_index !== undefined && data.conversation_id) {
|
|
recentPendingSteps.set(`${data.conversation_id}:${data.step_index}`, nowMs);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
// Dedup: skip if step_probe already created pending for same step_index IN SAME SESSION (within window)
|
|
if (existing.status === 'pending' && existing.project_name === projectName
|
|
&& existing.conversation_id === data.conversation_id // CRITICAL: same session only
|
|
&& data.step_index !== undefined && existing.step_index === data.step_index) {
|
|
const age = nowMs - (existing.timestamp * 1000);
|
|
if (age < DEDUP_WINDOW_MS && age >= 0) {
|
|
logToFile(`[DEDUP] skip: step_index ${data.step_index} already pending in ${ef}`);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (dedupErr) {
|
|
logToFile(`[DEDUP] check error (non-fatal): ${dedupErr.message}`);
|
|
}
|
|
const id = nowMs.toString();
|
|
// Auto-inject 3-button array for file_permission steps
|
|
// (step_probe sets step_type but not buttons; DOM observer /pending handler
|
|
// only injects buttons when command contains 'allow' which misses step_probe paths)
|
|
let buttons = data.buttons;
|
|
if (!buttons && data.step_type === 'file_permission') {
|
|
buttons = [
|
|
{ text: 'Allow Once', index: 0 },
|
|
{ text: 'Allow This Conversation', index: 1 },
|
|
{ text: 'Deny', index: 2 },
|
|
];
|
|
}
|
|
const payload = {
|
|
request_id: id,
|
|
conversation_id: data.conversation_id,
|
|
command: data.command,
|
|
description: data.description,
|
|
timestamp: nowMs / 1000,
|
|
status: 'pending',
|
|
discord_message_id: 0,
|
|
project_name: projectName,
|
|
...(data.step_type ? { step_type: data.step_type } : {}),
|
|
...(data.step_index !== undefined ? { step_index: data.step_index } : {}),
|
|
...(data.source ? { source: data.source } : {}),
|
|
...(buttons ? { buttons } : {}),
|
|
...(data.modified_files ? { modified_files: data.modified_files } : {}),
|
|
...(data.edit_step_indices && data.edit_step_indices.length > 0 ? { edit_step_indices: data.edit_step_indices } : {}),
|
|
};
|
|
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
|
|
console.log(`Gravity Bridge: pending approval written → ${id}.json`);
|
|
// Cache diff_review metadata in-memory (survives pending file deletion by Collector/Bot)
|
|
if (data.step_type === 'diff_review' && (data.edit_step_indices?.length || data.modified_files?.length)) {
|
|
diffReviewMetadata.set(id, {
|
|
edit_step_indices: data.edit_step_indices || [],
|
|
modified_files: data.modified_files || [],
|
|
});
|
|
logToFile(`[DIFF-REVIEW-CACHE] stored metadata for rid=${id}: steps=[${(data.edit_step_indices || []).join(',')}] files=${(data.modified_files || []).length}`);
|
|
}
|
|
// Record in memory dedup cache (survives file deletion by Collector/Bot)
|
|
if (data.step_index !== undefined && data.conversation_id) {
|
|
recentPendingSteps.set(`${data.conversation_id}:${data.step_index}`, nowMs);
|
|
}
|
|
// Register session → project mapping (correct because projectName is per-window)
|
|
if (data.conversation_id) {
|
|
writeRegistration(data.conversation_id);
|
|
}
|
|
}
|
|
catch (e) {
|
|
console.log(`Gravity Bridge: pending write error: ${e.message}`);
|
|
}
|
|
}
|
|
// ─── Multi-Strategy Approval Execution ───
|
|
/**
|
|
* Try multiple approval methods sequentially.
|
|
* Returns a string describing which method succeeded (or all failed).
|
|
*
|
|
* Strategy order (most reliable first):
|
|
* 1. HandleCascadeUserInteraction RPC (cross-platform, no focus)
|
|
* 2. VS Code accept/reject commands (focus-dependent)
|
|
* 3. Log failure for manual intervention
|
|
*/
|
|
async function tryApprovalStrategies(approved, sessionId, stepType = '', stepIndex = -1) {
|
|
const action = approved ? 'APPROVE' : 'REJECT';
|
|
const effectiveStepIndex = stepIndex >= 0 ? stepIndex : lastPendingStepIndex;
|
|
logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${effectiveStepIndex}`);
|
|
// ── Dynamic Command Discovery (log what's available during WAITING state) ──
|
|
let approvalCmdList = [];
|
|
try {
|
|
const allCmds = await vscode.commands.getCommands(true);
|
|
const agCmds = allCmds.filter((c) => c.startsWith('antigravity.'));
|
|
approvalCmdList = agCmds.filter((c) => {
|
|
const lower = c.toLowerCase();
|
|
return lower.includes('accept') || lower.includes('reject') || lower.includes('approve')
|
|
|| lower.includes('terminal') || lower.includes('run') || lower.includes('step')
|
|
|| lower.includes('cascade') || lower.includes('action');
|
|
});
|
|
logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`);
|
|
for (const c of approvalCmdList) {
|
|
logToFile(`[APPROVAL-CMD-CHECK] → ${c}`);
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`);
|
|
}
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source)
|
|
// Routes interaction sub-message by step_type:
|
|
// run_command → CascadeRunCommandInteraction { confirm }
|
|
// write_to_file → AcknowledgeCascadeCodeEdit RPC (separate)
|
|
// open_browser_url → CascadeOpenBrowserUrlInteraction { confirm }
|
|
// send_command_input → CascadeSendCommandInputInteraction { confirm }
|
|
// read_url_content → CascadeReadUrlContentInteraction { confirm }
|
|
// mcp_tool → CascadeMcpInteraction { confirm }
|
|
// invoke_subagent → CascadeRunExtensionCodeInteraction { confirm }
|
|
// ══════════════════════════════════════════════════════════
|
|
if (sdk && approved) {
|
|
// Build interaction sub-message based on step_type
|
|
const typeLower = stepType.toLowerCase().replace('cortex_step_type_', '');
|
|
let interactionPayload = {};
|
|
if (typeLower.includes('code_edit') || typeLower.includes('write_to_file') || typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')) {
|
|
// CODE EDIT: Uses acknowledgeCodeActionStep RPC (correct AG LS method)
|
|
// Try VS Code command first (same path as UI Accept all button)
|
|
try {
|
|
logToFile(`[APPROVAL-CODE-EDIT] trying submitCodeAcknowledgement command`);
|
|
await vscode.commands.executeCommand('antigravity.prioritized.submitCodeAcknowledgement');
|
|
logToFile(`[APPROVAL-CODE-EDIT] ✅ submitCodeAcknowledgement OK`);
|
|
return `CMD:submitCodeAcknowledgement(accept=${approved})`;
|
|
}
|
|
catch {
|
|
logToFile(`[APPROVAL-CODE-EDIT] submitCodeAcknowledgement not available, trying RPC`);
|
|
}
|
|
// Direct LS RPC with correct method name
|
|
try {
|
|
logToFile(`[APPROVAL-CODE-EDIT] acknowledgeCodeActionStep(cascadeId=${sessionId.substring(0, 8)}, accept=${approved}, stepIndices=[${effectiveStepIndex}])`);
|
|
const ackResult = await sdk.ls.rawRPC('acknowledgeCodeActionStep', {
|
|
cascadeId: sessionId,
|
|
accept: approved,
|
|
stepIndices: [effectiveStepIndex],
|
|
});
|
|
logToFile(`[APPROVAL-CODE-EDIT] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`);
|
|
return `RPC:acknowledgeCodeActionStep(accept=${approved})`;
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-CODE-EDIT] ❌ ${e.message.substring(0, 200)}`);
|
|
// Fallback: try HandleCascadeUserInteraction with runCommand
|
|
logToFile(`[APPROVAL-CODE-EDIT] falling back to HandleCascadeUserInteraction`);
|
|
interactionPayload = { runCommand: { confirm: true } };
|
|
}
|
|
}
|
|
// Map step_type to interaction sub-message field
|
|
if (typeLower.includes('run_command') || typeLower.includes('shell_exec')) {
|
|
interactionPayload = { runCommand: { confirm: true } };
|
|
}
|
|
else if (typeLower.includes('open_browser')) {
|
|
interactionPayload = { openBrowserUrl: { confirm: true } };
|
|
}
|
|
else if (typeLower.includes('send_command_input')) {
|
|
interactionPayload = { sendCommandInput: { confirm: true } }; // guess field name
|
|
}
|
|
else if (typeLower.includes('read_url')) {
|
|
interactionPayload = { readUrlContent: { confirm: true } }; // guess
|
|
}
|
|
else if (typeLower.includes('mcp')) {
|
|
interactionPayload = { mcpTool: { confirm: true } }; // guess
|
|
}
|
|
else if (typeLower.includes('invoke_subagent') || typeLower.includes('extension_code')) {
|
|
interactionPayload = { runExtensionCode: { confirm: true } };
|
|
}
|
|
else if (typeLower.includes('file_permission')) {
|
|
// FilePermissionInteraction: allow=true, scope depends on cmd
|
|
// file_permission_once → 1, file_permission_conversation → 2
|
|
const scope = typeLower.includes('conversation') ? 2 : 1;
|
|
interactionPayload = { filePermission: { allow: true, scope } };
|
|
}
|
|
else if (typeLower.includes('elicitation')) {
|
|
interactionPayload = { elicitation: {} }; // ElicitationInteraction (TBD)
|
|
}
|
|
else {
|
|
// Default: try run_command (most common)
|
|
interactionPayload = { runCommand: { confirm: true } };
|
|
}
|
|
const protoVariants = [
|
|
// Variant A: camelCase with trajectoryId (proven working for run_command)
|
|
{
|
|
cascadeId: sessionId,
|
|
interaction: {
|
|
trajectoryId: activeTrajectoryId || sessionId,
|
|
stepIndex: effectiveStepIndex,
|
|
...interactionPayload,
|
|
},
|
|
},
|
|
// Variant B: snake_case
|
|
{
|
|
cascade_id: sessionId,
|
|
interaction: {
|
|
trajectory_id: activeTrajectoryId || sessionId,
|
|
step_index: effectiveStepIndex,
|
|
...interactionPayload,
|
|
},
|
|
},
|
|
// Variant C: minimal (no trajectoryId)
|
|
{
|
|
cascadeId: sessionId,
|
|
interaction: {
|
|
stepIndex: effectiveStepIndex,
|
|
...interactionPayload,
|
|
},
|
|
},
|
|
];
|
|
for (let i = 0; i < protoVariants.length; i++) {
|
|
try {
|
|
const payload = protoVariants[i];
|
|
logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`);
|
|
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', payload);
|
|
logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
|
|
return `RPC-PROTO-${i}:HandleCascadeUserInteraction(${typeLower})`;
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-PROTO-${i}] ❌ ${e.message.substring(0, 300)}`);
|
|
}
|
|
}
|
|
}
|
|
// ── Strategies 0A-1 REMOVED (v0.3.11) — all confirmed failing, caused log spam + AG interference ──
|
|
// Kept: Strategy 0-PROTO (above) for correct proto-based RPC
|
|
// Kept: Strategy 2 (below) for renderer DOM click fallback
|
|
// ── Strategy 2: Renderer DOM Click via HTTP Bridge (primary fallback) ──
|
|
try {
|
|
const triggerAction = approved ? 'approve' : 'reject';
|
|
logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`);
|
|
clickTrigger = { action: triggerAction, timestamp: Date.now() };
|
|
logToFile(`[APPROVAL-2] ✅ clickTrigger set — renderer will poll and click within 2s`);
|
|
}
|
|
catch (e) {
|
|
logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`);
|
|
}
|
|
logToFile(`[APPROVAL] strategies complete — check logs for results`);
|
|
return `STRATEGIES_DONE:${action}`;
|
|
}
|
|
// ─── Activation ───
|
|
async function activate(context) {
|
|
console.log('Gravity Bridge: activating...');
|
|
// Project detection
|
|
projectName = detectProjectName();
|
|
// Store workspace folder path for session filtering (prevents cross-window session grabbing)
|
|
const folders = vscode.workspace.workspaceFolders;
|
|
workspaceUri = folders && folders.length > 0 ? folders[0].uri.fsPath : '';
|
|
console.log(`Gravity Bridge: project "${projectName}" workspace="${workspaceUri}"`);
|
|
// 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}`);
|
|
// ── Multi-project: no lock file, each project uses project_name-based filtering ──
|
|
// (active_project.lock removed — was blocking concurrent multi-project usage)
|
|
logToFile(`[INIT] project="${projectName}" pid=${process.pid} — multi-project mode (no lock)`);
|
|
// Status bar
|
|
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
|
statusBar.text = '$(sync~spin) Bridge';
|
|
statusBar.tooltip = `Gravity Bridge: ${projectName}`;
|
|
statusBar.show();
|
|
context.subscriptions.push(statusBar);
|
|
// Initialize SDK
|
|
const sdkReady = await initSDK(context);
|
|
if (sdkReady) {
|
|
// ── Command Discovery Diagnostic ──
|
|
// Enumerate ALL antigravity.* commands to find correct approval command names
|
|
try {
|
|
const allCmds = await vscode.commands.getCommands(true);
|
|
const agCmds = allCmds.filter((c) => c.startsWith('antigravity.'));
|
|
logToFile(`[CMD-DISCOVERY] Total antigravity.* commands: ${agCmds.length}`);
|
|
// Log approval-related commands specifically
|
|
const approvalKeywords = ['accept', 'reject', 'approve', 'terminal', 'agent', 'cascade', 'step', 'run', 'command.'];
|
|
const relevantCmds = agCmds.filter((c) => approvalKeywords.some(kw => c.toLowerCase().includes(kw)));
|
|
logToFile(`[CMD-DISCOVERY] Approval-related commands (${relevantCmds.length}):`);
|
|
for (const cmd of relevantCmds) {
|
|
logToFile(`[CMD-DISCOVERY] → ${cmd}`);
|
|
}
|
|
// Also dump ALL commands for full reference
|
|
logToFile(`[CMD-DISCOVERY] ALL antigravity.* commands:`);
|
|
for (const cmd of agCmds) {
|
|
logToFile(`[CMD-DISCOVERY] ${cmd}`);
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[CMD-DISCOVERY] error: ${e.message}`);
|
|
}
|
|
setupMonitor(); // Now just logs that monitor is disabled
|
|
setupApprovalObserver(); // DOM observer via SDK IntegrationManager
|
|
statusBar.text = '$(check) Bridge';
|
|
statusBar.tooltip = `Gravity Bridge: ${projectName} (POLL + Observer active)`;
|
|
// Register SDK-powered commands
|
|
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.approve', async () => {
|
|
try {
|
|
await sdk.cascade.acceptStep();
|
|
vscode.window.showInformationMessage('Gravity Bridge: Step approved');
|
|
}
|
|
catch (e) {
|
|
vscode.window.showErrorMessage(`Approve failed: ${e.message}`);
|
|
}
|
|
}), vscode.commands.registerCommand('gravityBridge.reject', async () => {
|
|
try {
|
|
await sdk.cascade.rejectStep();
|
|
vscode.window.showInformationMessage('Gravity Bridge: Step rejected');
|
|
}
|
|
catch (e) {
|
|
vscode.window.showErrorMessage(`Reject failed: ${e.message}`);
|
|
}
|
|
}));
|
|
}
|
|
else {
|
|
statusBar.text = '$(warning) Bridge (no SDK)';
|
|
console.log('Gravity Bridge: SDK not available, file-based mode only');
|
|
}
|
|
// Watch commands directory
|
|
watchCommandsDir();
|
|
// Watch response directory for approval interactions
|
|
setupResponseWatcher();
|
|
// Register basic commands
|
|
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.start', () => {
|
|
isActive = true;
|
|
statusBar.text = sdkReady ? '$(check) Bridge SDK' : '$(sync~spin) Bridge';
|
|
vscode.window.showInformationMessage(`Gravity Bridge started for "${projectName}"`);
|
|
}), vscode.commands.registerCommand('gravityBridge.stop', () => {
|
|
isActive = false;
|
|
// SDK monitor is disabled, no need to stop
|
|
statusBar.text = '$(circle-slash) Bridge OFF';
|
|
vscode.window.showInformationMessage('Gravity Bridge stopped');
|
|
}), vscode.commands.registerCommand('gravityBridge.connect', async () => {
|
|
if (!sdk) {
|
|
vscode.window.showErrorMessage('SDK not initialized');
|
|
return;
|
|
}
|
|
try {
|
|
const sessions = await sdk.cascade.getSessions();
|
|
const items = sessions.map((s) => ({
|
|
label: s.title || 'Untitled',
|
|
description: `step ${s.stepCount} • ${s.id?.substring(0, 8)}`,
|
|
sessionId: s.id,
|
|
}));
|
|
const pick = await vscode.window.showQuickPick(items, {
|
|
placeHolder: 'Select a conversation to connect'
|
|
});
|
|
if (pick) {
|
|
await sdk.cascade.focusSession(pick.sessionId);
|
|
vscode.window.showInformationMessage(`Connected to: ${pick.label}`);
|
|
}
|
|
}
|
|
catch (e) {
|
|
vscode.window.showErrorMessage(`Connect failed: ${e.message}`);
|
|
}
|
|
}));
|
|
// Cleanup
|
|
context.subscriptions.push({
|
|
dispose: () => {
|
|
if (sdk) {
|
|
try {
|
|
sdk.dispose();
|
|
}
|
|
catch { }
|
|
}
|
|
if (watcher) {
|
|
watcher.close();
|
|
}
|
|
if (commandsWatcher) {
|
|
commandsWatcher.close();
|
|
}
|
|
}
|
|
});
|
|
console.log('Gravity Bridge: ✅ activated');
|
|
isActive = true;
|
|
}
|
|
function deactivate() {
|
|
// Clean up stale lock file if it exists (legacy cleanup)
|
|
try {
|
|
const lockFile = path.join(bridgePath, 'active_project.lock');
|
|
if (fs.existsSync(lockFile)) {
|
|
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf-8'));
|
|
if (lockData.pid === process.pid) {
|
|
fs.unlinkSync(lockFile);
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
if (sdk) {
|
|
try {
|
|
sdk.dispose();
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
//# sourceMappingURL=extension.js.map
|