chore(extension): bump to v0.4.4 - dual delivery fix + echo dedup
This commit is contained in:
@@ -51,10 +51,11 @@ 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"));
|
||||
const ws_client_1 = require("./ws-client");
|
||||
const observer_script_1 = require("./observer-script");
|
||||
const step_probe_1 = require("./step-probe");
|
||||
const http_bridge_1 = require("./http-bridge");
|
||||
const html_patcher_1 = require("./html-patcher");
|
||||
const command_handler_1 = require("./command-handler");
|
||||
// ─── File-based logging (AI can read directly) ───
|
||||
function logToFile(msg) {
|
||||
const ts = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
@@ -81,9 +82,7 @@ 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;
|
||||
let wsBridge = null; // WebSocket Hub connection
|
||||
const sentPendingIds = new Set();
|
||||
// Memory-based dedup: tracks recently created pending step_indexes to prevent
|
||||
@@ -214,103 +213,7 @@ function writeChatSnapshotWithFiles(text, files) {
|
||||
console.log(`Gravity Bridge: snapshot+files write error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// ─── Command File Watcher (Discord → Antigravity) ───
|
||||
function processCommandFile(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const cmd = JSON.parse(content);
|
||||
// Skip already consumed commands
|
||||
if (cmd.consumed) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
catch { }
|
||||
return;
|
||||
}
|
||||
// Ignore commands for other projects
|
||||
if (cmd.project_name && cmd.project_name !== projectName) {
|
||||
console.log(`Gravity Bridge: skipping command for "${cmd.project_name}" (we are "${projectName}")`);
|
||||
return;
|
||||
}
|
||||
// Bot writes 'text' field, not 'message'
|
||||
const text = cmd.text || cmd.message || '';
|
||||
const action = cmd.action || '';
|
||||
console.log(`Gravity Bridge: command — text="${text}" action="${action}"`);
|
||||
if (action === 'approve' && sdk) {
|
||||
sdk.cascade.acceptStep().catch((e) => console.log(`Gravity Bridge: approve error: ${e.message}`));
|
||||
}
|
||||
else if (action === 'reject' && sdk) {
|
||||
sdk.cascade.rejectStep().catch((e) => console.log(`Gravity Bridge: reject error: ${e.message}`));
|
||||
}
|
||||
else if (action === 'approve_terminal' && sdk) {
|
||||
sdk.cascade.acceptTerminalCommand().catch((e) => console.log(`Gravity Bridge: approve_terminal error: ${e.message}`));
|
||||
}
|
||||
else if (text === '!stop') {
|
||||
// Cancel current operation
|
||||
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
|
||||
.then(() => console.log('Gravity Bridge: ✅ stop sent'), () => { });
|
||||
}
|
||||
else if (text.startsWith('!auto')) {
|
||||
// Auto-approve mode toggle
|
||||
if (text === '!auto on') {
|
||||
autoApproveEnabled = true;
|
||||
}
|
||||
else if (text === '!auto off') {
|
||||
autoApproveEnabled = false;
|
||||
}
|
||||
else {
|
||||
// Toggle if no explicit on/off
|
||||
autoApproveEnabled = !autoApproveEnabled;
|
||||
}
|
||||
logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`);
|
||||
}
|
||||
else if (text) {
|
||||
// Send message to Antigravity — use VS Code command (most reliable)
|
||||
recentDiscordSentTexts.set(text.trim(), Date.now());
|
||||
vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', text)
|
||||
.then(() => console.log(`Gravity Bridge: ✅ sent "${text.substring(0, 50)}" via sendPromptToAgentPanel`), (e) => console.log(`Gravity Bridge: sendPrompt failed: ${e.message}`));
|
||||
}
|
||||
// Remove processed command file
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`Gravity Bridge: command processing error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
function watchCommandsDir() {
|
||||
const cmdDir = path.join(bridgePath, 'commands');
|
||||
// Process existing files
|
||||
const processAllCommands = () => {
|
||||
try {
|
||||
for (const f of fs.readdirSync(cmdDir)) {
|
||||
if (f.endsWith('.json')) {
|
||||
processCommandFile(path.join(cmdDir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
};
|
||||
processAllCommands();
|
||||
// Watch for new files (may not fire reliably on Windows)
|
||||
try {
|
||||
commandsWatcher = fs.watch(cmdDir, (event, filename) => {
|
||||
if (filename && filename.endsWith('.json') && event === 'rename') {
|
||||
const fp = path.join(cmdDir, filename);
|
||||
if (fs.existsSync(fp)) {
|
||||
setTimeout(() => processCommandFile(fp), 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
// Polling fallback: fs.watch on Windows can silently fail
|
||||
setInterval(() => {
|
||||
processAllCommands();
|
||||
}, 3000);
|
||||
}
|
||||
// ─── Command handling extracted to ./command-handler.ts ───
|
||||
// ─── SDK Integration ───
|
||||
async function initSDK(context) {
|
||||
try {
|
||||
@@ -469,574 +372,13 @@ async function fixLSConnection() {
|
||||
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 = (0, observer_script_1.generateApprovalObserverScript)(bridgePort);
|
||||
const patcher = integration._patcher;
|
||||
if (patcher && typeof patcher.getScriptPath === 'function') {
|
||||
let baseScript = '';
|
||||
try {
|
||||
baseScript = integration.build();
|
||||
}
|
||||
catch {
|
||||
baseScript = '';
|
||||
}
|
||||
const combinedScript = baseScript + '\n' + observerJS;
|
||||
const scriptPath = patcher.getScriptPath();
|
||||
fs.writeFileSync(scriptPath, combinedScript, 'utf8');
|
||||
logToFile(`[OBSERVER] script written → ${scriptPath} (port=${bridgePort})`);
|
||||
if (!integration.isInstalled()) {
|
||||
patcher.install(combinedScript);
|
||||
logToFile('[OBSERVER] patcher.install() called (needs reload)');
|
||||
}
|
||||
// Patch BOTH HTML files with inline script injection.
|
||||
// CRITICAL: vscode-file:// does NOT serve custom .js files (silent 404),
|
||||
// so we MUST inline the script directly into BOTH HTML files.
|
||||
// workbench.html — loaded by DevTools/standard mode
|
||||
// workbench-jetski-agent.html — loaded by AG agent mode
|
||||
const scriptDir = path.dirname(scriptPath);
|
||||
// Each HTML file has DIFFERENT CSS/JS entry points — they are NOT interchangeable:
|
||||
// workbench.html → workbench.desktop.main.css + workbench.js
|
||||
// workbench-jetski-agent.html → tw-base.tailwind.css + jetskiMain.tailwind.css + jetskiAgent.js
|
||||
// Cross-restoring between them causes CSS to not load → layout broken (elements visible but all shifted left).
|
||||
const htmlFileSpecs = [
|
||||
{
|
||||
name: 'workbench.html',
|
||||
requiredMarker: 'workbench.desktop.main.css', // CSS unique to this file
|
||||
requiredScript: 'workbench.js', // JS entry point
|
||||
},
|
||||
{
|
||||
name: 'workbench-jetski-agent.html',
|
||||
requiredMarker: 'jetskiMain.tailwind.css', // CSS unique to this file
|
||||
requiredScript: 'jetskiAgent.js', // JS entry point
|
||||
},
|
||||
];
|
||||
// ── FIX #1: File lock to prevent multi-instance HTML patching race ──
|
||||
const lockFile = path.join(scriptDir, '.patch-lock');
|
||||
let lockAcquired = false;
|
||||
try {
|
||||
if (fs.existsSync(lockFile)) {
|
||||
const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs;
|
||||
if (lockAge < 30_000) {
|
||||
logToFile(`[OBSERVER] another instance is patching (lock age=${Math.round(lockAge / 1000)}s) — skipping`);
|
||||
return; // Exit setupApprovalObserver entirely
|
||||
}
|
||||
logToFile(`[OBSERVER] stale lock (age=${Math.round(lockAge / 1000)}s) — force-acquiring`);
|
||||
}
|
||||
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, ts: Date.now() }), 'utf-8');
|
||||
lockAcquired = true;
|
||||
}
|
||||
catch (lockErr) {
|
||||
logToFile(`[OBSERVER] lock acquire error: ${lockErr.message} — proceeding anyway`);
|
||||
}
|
||||
for (const spec of htmlFileSpecs) {
|
||||
const htmlPath = path.join(scriptDir, spec.name);
|
||||
const backupPath = htmlPath + '.orig';
|
||||
try {
|
||||
if (!fs.existsSync(htmlPath)) {
|
||||
logToFile(`[OBSERVER] ${spec.name} not found — skipping`);
|
||||
continue;
|
||||
}
|
||||
let html = fs.readFileSync(htmlPath, 'utf8');
|
||||
// ── BACKUP: Save original before first-ever patch ──
|
||||
// Only backup if the file looks valid AND hasn't been backed up yet.
|
||||
if (!fs.existsSync(backupPath)
|
||||
&& html.length >= 500
|
||||
&& html.includes('<!DOCTYPE html>')
|
||||
&& html.includes(spec.requiredMarker)) {
|
||||
fs.writeFileSync(backupPath, html, 'utf8');
|
||||
logToFile(`[OBSERVER] ${spec.name} backed up to .orig (${html.length} bytes)`);
|
||||
}
|
||||
// ── SAFETY: Refuse to patch if file is corrupt, empty, or wrong type ──
|
||||
// Race condition: another extension instance may be mid-write (0-byte).
|
||||
// Wrong type: restored from the other HTML file (different CSS/JS refs).
|
||||
const isCorrupt = html.length < 500 || !html.includes('<!DOCTYPE html>');
|
||||
const isWrongType = !isCorrupt && !html.includes(spec.requiredMarker);
|
||||
if (isCorrupt || isWrongType) {
|
||||
const reason = isCorrupt
|
||||
? `corrupt/empty (${html.length} bytes)`
|
||||
: `wrong type (missing ${spec.requiredMarker})`;
|
||||
logToFile(`[OBSERVER] ${spec.name} detected ${reason}`);
|
||||
// Try to restore from backup
|
||||
if (fs.existsSync(backupPath)) {
|
||||
const backup = fs.readFileSync(backupPath, 'utf8');
|
||||
if (backup.length >= 500
|
||||
&& backup.includes('<!DOCTYPE html>')
|
||||
&& backup.includes(spec.requiredMarker)) {
|
||||
fs.writeFileSync(htmlPath, backup, 'utf8');
|
||||
html = backup;
|
||||
logToFile(`[OBSERVER] ${spec.name} RESTORED from .orig backup (${backup.length} bytes) ✅`);
|
||||
}
|
||||
else {
|
||||
logToFile(`[OBSERVER] ${spec.name} .orig backup also invalid — SKIPPING`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else {
|
||||
logToFile(`[OBSERVER] ${spec.name} no .orig backup available — SKIPPING to prevent further damage`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// CRITICAL: Patch CSP to allow inline scripts.
|
||||
// Default CSP has script-src 'self' 'unsafe-eval' blob: — NO 'unsafe-inline'.
|
||||
// Without 'unsafe-inline', all inline <script> tags are silently blocked.
|
||||
if (html.includes('script-src') && !html.match(/script-src[^;]*'unsafe-inline'/)) {
|
||||
html = html.replace(/(script-src\s[^;]*?)('self')/, "$1$2\n\t\t\t\t\t'unsafe-inline'");
|
||||
logToFile(`[OBSERVER] ${spec.name} CSP patched: added 'unsafe-inline' to script-src`);
|
||||
}
|
||||
// Remove old external script tag if present (legacy, cannot be served)
|
||||
const extMarkerStart = '<!-- AG SDK [variet-gravity-bridge] -->';
|
||||
const extMarkerEnd = '<!-- /AG SDK [variet-gravity-bridge] -->';
|
||||
if (html.includes(extMarkerStart)) {
|
||||
const extRe = new RegExp('\\n?' + extMarkerStart.replace(/[[\]]/g, '\\$&') +
|
||||
'[\\s\\S]*?' +
|
||||
extMarkerEnd.replace(/[[\]]/g, '\\$&') + '\\n?');
|
||||
html = html.replace(extRe, '');
|
||||
logToFile(`[OBSERVER] removed external script tag from ${spec.name}`);
|
||||
}
|
||||
// Insert or update inline script
|
||||
const inlineMarkerStart = '<!-- AG SDK INLINE [variet-gravity-bridge] -->';
|
||||
const inlineMarkerEnd = '<!-- /AG SDK INLINE [variet-gravity-bridge] -->';
|
||||
if (html.includes(inlineMarkerStart)) {
|
||||
const re = new RegExp(inlineMarkerStart.replace(/[[\]]/g, '\\$&') +
|
||||
'[\\s\\S]*?' +
|
||||
inlineMarkerEnd.replace(/[[\]]/g, '\\$&'));
|
||||
html = html.replace(re, `${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}`);
|
||||
logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`);
|
||||
}
|
||||
else {
|
||||
html = html.replace('</html>', `\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
|
||||
logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`);
|
||||
}
|
||||
// SAFETY: Final validation before write
|
||||
if (html.length < 500 || !html.includes('<!DOCTYPE html>') || !html.includes(spec.requiredMarker)) {
|
||||
logToFile(`[OBSERVER] ${spec.name} WOULD BE CORRUPT after patching (${html.length} bytes) — ABORTING write`);
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(htmlPath, html, 'utf8');
|
||||
}
|
||||
catch (e) {
|
||||
logToFile(`[OBSERVER] ${spec.name} patch error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// Release patch lock
|
||||
if (lockAcquired) {
|
||||
try {
|
||||
fs.unlinkSync(lockFile);
|
||||
}
|
||||
catch { }
|
||||
logToFile('[OBSERVER] patch lock released');
|
||||
}
|
||||
}
|
||||
// 4. Update product.json checksums so vscode-file:// serves our patched files
|
||||
updateProductChecksums();
|
||||
try {
|
||||
integration.enableAutoRepair();
|
||||
}
|
||||
catch { }
|
||||
setInterval(() => { try {
|
||||
integration.signalActive();
|
||||
}
|
||||
catch { } }, 30_000);
|
||||
logToFile(`[OBSERVER] setup complete (HTTP bridge on port ${bridgePort})`);
|
||||
console.log(`Gravity Bridge: ✅ Approval observer installed (port ${bridgePort})`);
|
||||
}
|
||||
catch (err) {
|
||||
logToFile(`[OBSERVER] setup error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// ─── Product.json Checksum Auto-Update ───
|
||||
// vscode-file:// protocol validates SHA256 checksums in product.json.
|
||||
// If a file's checksum doesn't match, Electron serves the ORIGINAL cached version.
|
||||
// This function recalculates checksums for files we modify (HTML files with <script> tags).
|
||||
function updateProductChecksums() {
|
||||
try {
|
||||
// Find product.json (2 levels up from workbench dir: resources/app/product.json)
|
||||
const patcher = sdk?.integration?._patcher;
|
||||
if (!patcher || typeof patcher.getWorkbenchDir !== 'function') {
|
||||
logToFile('[CHECKSUM] no patcher/workbenchDir — skipping');
|
||||
return;
|
||||
}
|
||||
const workbenchDir = patcher.getWorkbenchDir();
|
||||
// workbenchDir = .../resources/app/out/vs/code/electron-browser/workbench
|
||||
// product.json = .../resources/app/product.json (5 levels up from workbench)
|
||||
const appDir = path.resolve(workbenchDir, '..', '..', '..', '..', '..');
|
||||
const productJsonPath = path.join(appDir, 'product.json');
|
||||
if (!fs.existsSync(productJsonPath)) {
|
||||
logToFile(`[CHECKSUM] product.json not found at ${productJsonPath}`);
|
||||
return;
|
||||
}
|
||||
// Read product.json (may have BOM)
|
||||
let raw = fs.readFileSync(productJsonPath, 'utf8');
|
||||
if (raw.charCodeAt(0) === 0xFEFF)
|
||||
raw = raw.substring(1);
|
||||
const product = JSON.parse(raw);
|
||||
if (!product.checksums) {
|
||||
logToFile('[CHECKSUM] no checksums section in product.json');
|
||||
return;
|
||||
}
|
||||
// Files we may modify or create (relative key in product.json → absolute path)
|
||||
// CRITICAL: vscode-file:// only serves files with valid checksums in product.json.
|
||||
// Custom JS files MUST be added here or they'll silently 404.
|
||||
const filesToCheck = {
|
||||
'vs/code/electron-browser/workbench/workbench.html': path.join(workbenchDir, 'workbench.html'),
|
||||
'vs/code/electron-browser/workbench/workbench-jetski-agent.html': path.join(workbenchDir, 'workbench-jetski-agent.html'),
|
||||
'vs/code/electron-browser/workbench/ag-sdk-variet-gravity-bridge.js': path.join(workbenchDir, 'ag-sdk-variet-gravity-bridge.js'),
|
||||
};
|
||||
let updated = false;
|
||||
for (const [key, filePath] of Object.entries(filesToCheck)) {
|
||||
if (!fs.existsSync(filePath))
|
||||
continue;
|
||||
const fileBytes = fs.readFileSync(filePath);
|
||||
const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, '');
|
||||
if (product.checksums[key] !== hash) {
|
||||
logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`);
|
||||
product.checksums[key] = hash;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
fs.writeFileSync(productJsonPath, JSON.stringify(product, null, '\t'), 'utf8');
|
||||
logToFile('[CHECKSUM] product.json updated ✅');
|
||||
}
|
||||
else {
|
||||
logToFile('[CHECKSUM] all checksums already match ✅');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
logToFile(`[CHECKSUM] error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// ─── HTTP Bridge Server (Extension Host → Renderer communication) ───
|
||||
let observerHttpServer = null;
|
||||
const pendingResponses = new Map();
|
||||
// Click trigger: extension sets this, renderer polls and clicks button
|
||||
let clickTrigger = null;
|
||||
let sessionStalled = false; // true when session is stalled waiting for approval
|
||||
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step (module-level for tryApprovalStrategies)
|
||||
let stallProbed = false; // prevent repeated step probes during same stall (module-level for processResponseFile reset)
|
||||
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending (module-level for processResponseFile)
|
||||
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
|
||||
let deepInspectRequested = false;
|
||||
let deepInspectResult = null;
|
||||
let deepInspectWaiters = [];
|
||||
/** Derive a deterministic port from project name (range 10000-60000) */
|
||||
function getDeterministicPort(name) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
||||
}
|
||||
return 10000 + (Math.abs(hash) % 50000);
|
||||
}
|
||||
function startObserverHttpBridge() {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const http = require('http');
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers for renderer fetch()
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url, `http://127.0.0.1`);
|
||||
// POST /pending — renderer reports a detected approval button
|
||||
if (req.method === 'POST' && url.pathname === '/pending') {
|
||||
let body = '';
|
||||
req.on('data', (c) => body += c);
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
// ── Server-side false positive filter ──
|
||||
const cmd = (data.command || '').trim();
|
||||
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Deny|Allow Once|Allow This Conversation|Dismiss|Decline|Accept|Reject|Accept all|Reject all)$/i;
|
||||
if (FALSE_POSITIVE_RE.test(cmd)) {
|
||||
logToFile(`[HTTP] filtered false positive: "${cmd}"`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, filtered: true }));
|
||||
return;
|
||||
}
|
||||
// "Run" button → step_probe handles these with full command detail
|
||||
// Only let through if session is stalled AND step_probe hasn't created a pending yet
|
||||
if (/^Run$/i.test(cmd)) {
|
||||
if (!sessionStalled || lastPendingStepIndex >= 0) {
|
||||
logToFile(`[HTTP] filtered "Run" — ${!sessionStalled ? 'not stalled' : 'step_probe pending exists'}`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, filtered: true }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const rid = data.request_id || Date.now().toString();
|
||||
// Write pending file for Discord bot
|
||||
const pendingDir = path.join(bridgePath, 'pending');
|
||||
if (!fs.existsSync(pendingDir))
|
||||
fs.mkdirSync(pendingDir, { recursive: true });
|
||||
const pending = {
|
||||
...data,
|
||||
request_id: rid,
|
||||
conversation_id: activeSessionId || '',
|
||||
timestamp: Date.now() / 1000,
|
||||
status: 'pending',
|
||||
project_name: projectName,
|
||||
auto_detected: true,
|
||||
source: 'dom_observer',
|
||||
step_index: lastPendingStepIndex >= 0 ? lastPendingStepIndex : undefined,
|
||||
};
|
||||
// File permission: inject multi-choice buttons
|
||||
const cmdLower = (data.command || '').toLowerCase();
|
||||
if (cmdLower.includes('allow') && !pending.buttons) {
|
||||
// Dedup: skip if another file_permission pending was created within 10s
|
||||
const nowMs = Date.now();
|
||||
try {
|
||||
const existingFiles = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.json'));
|
||||
for (const ef of existingFiles) {
|
||||
const existing = JSON.parse(fs.readFileSync(path.join(pendingDir, ef), 'utf-8'));
|
||||
if (existing.step_type === 'file_permission' && existing.status === 'pending'
|
||||
&& existing.project_name === projectName) {
|
||||
const age = nowMs - (existing.timestamp * 1000);
|
||||
if (age < 10_000 && age >= 0) {
|
||||
logToFile(`[HTTP] filtered duplicate file_permission (${age}ms old): ${ef}`);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
pending.buttons = [
|
||||
{ text: 'Allow Once', index: 0 },
|
||||
{ text: 'Allow This Conversation', index: 1 },
|
||||
{ text: 'Deny', index: 2 },
|
||||
];
|
||||
pending.step_type = 'file_permission';
|
||||
// Clean description: remove button labels from text
|
||||
const rawDesc = (data.description || data.command || '').replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim();
|
||||
pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`;
|
||||
}
|
||||
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
|
||||
// WS dual-write
|
||||
if (wsBridge && wsBridge.isConnected()) {
|
||||
wsBridge.sendPending({
|
||||
request_id: rid,
|
||||
command: pending.command || data.command || '',
|
||||
description: pending.description || data.description || '',
|
||||
step_type: pending.step_type,
|
||||
status: 'pending',
|
||||
buttons: pending.buttons,
|
||||
project_name: projectName,
|
||||
});
|
||||
logToFile(`[HTTP-WS] pending sent via WS: ${rid}`);
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
// ─── Approval Observer + Product.json Checksums extracted to ./html-patcher.ts ───
|
||||
// ─── HTTP Bridge Server extracted to ./http-bridge.ts ───
|
||||
// Shared state for HTTP bridge context (module-level, referenced by BridgeContext too)
|
||||
let sessionStalled = false;
|
||||
let lastPendingStepIndex = -1;
|
||||
let stallProbed = false;
|
||||
let sawRunningAfterPending = true;
|
||||
// ─── Step Probe, Response Watcher, Approval Strategies → extracted to ./step-probe.ts ───
|
||||
async function activate(context) {
|
||||
console.log('Gravity Bridge: activating...');
|
||||
@@ -1074,8 +416,11 @@ async function activate(context) {
|
||||
},
|
||||
onCommand: (data) => {
|
||||
logToFile(`[WS-CMD] ${data.text?.substring(0, 50)}`);
|
||||
// Process command directly (same logic as processCommandFile)
|
||||
_handleWSCommand(data);
|
||||
(0, command_handler_1.handleWSCommand)({
|
||||
bridgePath, projectName, sdk, autoApproveEnabled, logToFile,
|
||||
onAutoApproveChanged: (enabled) => { autoApproveEnabled = enabled; },
|
||||
recentDiscordSentTexts,
|
||||
}, data);
|
||||
},
|
||||
onInstanceUpdate: (count, instances) => {
|
||||
logToFile(`[WS-INSTANCE] ${count} active instances`);
|
||||
@@ -1145,7 +490,10 @@ async function activate(context) {
|
||||
lastPendingStepIndex,
|
||||
stallProbed,
|
||||
sawRunningAfterPending,
|
||||
clickTrigger,
|
||||
setClickTrigger: (action) => {
|
||||
const { setClickTrigger: setTrigger } = require('./http-bridge');
|
||||
setTrigger(action);
|
||||
},
|
||||
logToFile,
|
||||
workspaceUri,
|
||||
diffReviewMetadata: new Map(),
|
||||
@@ -1153,7 +501,18 @@ async function activate(context) {
|
||||
writeChatSnapshot,
|
||||
writeChatSnapshotWithFiles,
|
||||
});
|
||||
setupApprovalObserver(); // DOM observer via SDK IntegrationManager
|
||||
// Start HTTP bridge, then setup observer
|
||||
const httpBridgeCtx = {
|
||||
bridgePath, projectName, activeSessionId, wsBridge,
|
||||
sessionStalled, lastPendingStepIndex, logToFile,
|
||||
};
|
||||
const bridgePort = await (0, http_bridge_1.startHttpBridge)(httpBridgeCtx, sdk);
|
||||
if (bridgePort) {
|
||||
await (0, html_patcher_1.setupApprovalObserver)(sdk, bridgePort, logToFile);
|
||||
}
|
||||
else {
|
||||
logToFile('[OBSERVER] HTTP bridge failed — skipping observer setup');
|
||||
}
|
||||
statusBar.text = '$(check) Bridge';
|
||||
statusBar.tooltip = `Gravity Bridge: ${projectName} (POLL + Observer active)`;
|
||||
// Register SDK-powered commands
|
||||
@@ -1180,7 +539,11 @@ async function activate(context) {
|
||||
console.log('Gravity Bridge: SDK not available, file-based mode only');
|
||||
}
|
||||
// Watch commands directory
|
||||
watchCommandsDir();
|
||||
(0, command_handler_1.watchCommandsDir)({
|
||||
bridgePath, projectName, sdk, autoApproveEnabled, logToFile,
|
||||
onAutoApproveChanged: (enabled) => { autoApproveEnabled = enabled; },
|
||||
recentDiscordSentTexts,
|
||||
});
|
||||
// Response watcher is now initialized by initStepProbe() above
|
||||
// Register basic commands
|
||||
context.subscriptions.push(vscode.commands.registerCommand('gravityBridge.start', () => {
|
||||
@@ -1228,9 +591,7 @@ async function activate(context) {
|
||||
if (watcher) {
|
||||
watcher.close();
|
||||
}
|
||||
if (commandsWatcher) {
|
||||
commandsWatcher.close();
|
||||
}
|
||||
(0, command_handler_1.disposeCommandsWatcher)();
|
||||
}
|
||||
});
|
||||
console.log('Gravity Bridge: ✅ activated');
|
||||
@@ -1260,41 +621,4 @@ function deactivate() {
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
// ─── WS Command Handler ───
|
||||
function _handleWSCommand(data) {
|
||||
const text = data.text || '';
|
||||
if (!text)
|
||||
return;
|
||||
// Project filtering (WS already routes by project, but double-check)
|
||||
if (data.project_name && data.project_name !== projectName) {
|
||||
logToFile(`[WS-CMD] Ignoring command for ${data.project_name} (we are ${projectName})`);
|
||||
return;
|
||||
}
|
||||
if (text === '!stop') {
|
||||
logToFile('[WS-CMD] !stop — cancelling AG task');
|
||||
if (sdk) {
|
||||
try {
|
||||
sdk.cascade.cancelCurrentTask();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (text.startsWith('!auto')) {
|
||||
const parts = text.split(' ');
|
||||
autoApproveEnabled = parts[1] !== 'off';
|
||||
logToFile(`[WS-CMD] auto_approve=${autoApproveEnabled}`);
|
||||
return;
|
||||
}
|
||||
// General text → send as user message to AG
|
||||
logToFile(`[WS-CMD] Sending text to AG: ${text.substring(0, 80)}`);
|
||||
if (sdk) {
|
||||
try {
|
||||
sdk.cascade.sendPrompt(text);
|
||||
}
|
||||
catch (e) {
|
||||
logToFile(`[WS-CMD] SDK sendPrompt error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=extension.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
"name": "gravity-bridge",
|
||||
"displayName": "Gravity Bridge",
|
||||
"description": "Antigravity ↔ Discord 브리지 연동 확장",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"publisher": "variet",
|
||||
"engines": {
|
||||
"vscode": "^1.100.0"
|
||||
|
||||
Reference in New Issue
Block a user