3321 lines
170 KiB
JavaScript
3321 lines
170 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}`);
|
|
// Confirm back to Discord
|
|
const emoji = autoApproveEnabled ? '🟢' : '🔴';
|
|
const mode = autoApproveEnabled ? '자동 승인 활성' : '수동 승인 모드';
|
|
writeChatSnapshot(`${emoji} **Extension 확인**: ${mode} (project=${projectName})`);
|
|
}
|
|
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)$/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;
|
|
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',
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (oe) {
|
|
logToFile(`[STEP-PROBE] offset retry failed: ${oe.message.substring(0, 100)}`);
|
|
}
|
|
}
|
|
// Scan last 5 steps backwards to find WAITING (RUN_COMMAND may not be last)
|
|
let foundWaiting = false;
|
|
for (let si = steps.length - 1; si >= Math.max(0, steps.length - 5); si--) {
|
|
const step = steps[si];
|
|
const stepStatus = step?.status || '';
|
|
const stepType = step?.type || '';
|
|
if (stepStatus === 'CORTEX_STEP_STATUS_WAITING') {
|
|
foundWaiting = true;
|
|
// Extract command from metadata.toolCall or direct fields
|
|
const toolCall = step?.metadata?.toolCall;
|
|
const toolName = toolCall?.name || stepType.replace('CORTEX_STEP_TYPE_', '').toLowerCase();
|
|
let command = toolName;
|
|
// Parse argumentsJson for command details
|
|
if (toolCall?.argumentsJson) {
|
|
try {
|
|
const args = JSON.parse(toolCall.argumentsJson);
|
|
if (args.CommandLine) {
|
|
command = `${toolName}: ${args.CommandLine.substring(0, 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
|
|
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',
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (!foundWaiting) {
|
|
const lastStep = steps[steps.length - 1];
|
|
logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`);
|
|
// CRITICAL: Reset sessionStalled when step_probe confirms no WAITING.
|
|
// Without this, sessionStalled stays true during long AI generations
|
|
// (PLANNER_RESPONSE with delta=0), causing false positive "Run" detections.
|
|
sessionStalled = false;
|
|
}
|
|
}
|
|
}
|
|
catch (e) {
|
|
logToFile(`[STEP-PROBE] error: ${e.message}`);
|
|
}
|
|
}
|
|
// Stall fallback REMOVED — step probe is sole fallback source
|
|
// (stall fallback was generating false positives and is now redundant)
|
|
}
|
|
else if (!isRunning) {
|
|
// ── Error detection: probe when session transitions from RUNNING→idle ──
|
|
if (consecutiveIdleCount > 0 && !stallProbed) {
|
|
// Was running, now idle — possible error. Probe once.
|
|
try {
|
|
const stepsResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
|
|
cascadeId: bestSessionId,
|
|
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)}`);
|
|
}
|
|
}
|
|
// ── 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
|