refactor(bridge): migrate gravity bridge to pure websocket gateway architecture, deleting legacy local file scanners and dependencies

This commit is contained in:
Variet Worker
2026-04-11 13:06:38 +09:00
parent 5e697cd919
commit 072f83bf25
20 changed files with 756 additions and 1537 deletions

View File

@@ -0,0 +1,96 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { WSBridgeClient } from './ws-client';
export interface BrainWatcherContext {
logToFile: (msg: string) => void;
wsBridge: WSBridgeClient;
projectName: string;
}
export class BrainWatcher {
private brainDir: string;
private ctx: BrainWatcherContext;
private currentSessionId: string = '';
private watcher: fs.FSWatcher | null = null;
private lastEventTimes: Map<string, number> = new Map();
constructor(ctx: BrainWatcherContext) {
this.ctx = ctx;
// The bridgePath is ~/.gemini/antigravity/bridge, so brain is sibling
this.brainDir = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
}
public updateSession(sessionId: string) {
if (!sessionId || this.currentSessionId === sessionId) {
return;
}
this.currentSessionId = sessionId;
this.startWatching(sessionId);
}
private startWatching(sessionId: string) {
this.stop();
const sessionDir = path.join(this.brainDir, sessionId);
if (!fs.existsSync(sessionDir)) {
// It might not be created yet, poll gently
setTimeout(() => this.startWatching(sessionId), 2000);
return;
}
try {
this.watcher = fs.watch(sessionDir, { persistent: false }, (eventType, filename) => {
if (!filename || !filename.endsWith('.md')) return;
// Dedup rapid events
const now = Date.now();
const last = this.lastEventTimes.get(filename) || 0;
if (now - last < 500) return; // 500ms debounce
this.lastEventTimes.set(filename, now);
this.handleFileChange(sessionDir, filename, eventType);
});
this.ctx.logToFile(`[BRAIN-WATCHER] Started watching session: ${sessionId.substring(0, 8)}`);
} catch (e: any) {
this.ctx.logToFile(`[BRAIN-WATCHER] Failed to watch ${sessionId}: ${e.message}`);
}
}
private handleFileChange(dir: string, filename: string, rawEventType: string) {
const filePath = path.join(dir, filename);
let content = '';
let eventType = 'file_changed';
try {
if (fs.existsSync(filePath)) {
content = fs.readFileSync(filePath, 'utf-8');
} else {
eventType = 'file_deleted';
}
} catch (e) {
// File might be locked or deleted during read
return;
}
if (this.ctx.wsBridge && this.ctx.wsBridge.isConnected()) {
this.ctx.wsBridge.sendBrainEvent({
event_type: eventType,
conversation_id: this.currentSessionId,
file_name: filename,
content: content,
timestamp: Date.now() / 1000,
project_name: this.ctx.projectName,
});
this.ctx.logToFile(`[BRAIN-WATCHER] Sent ${eventType} for ${filename}`);
}
}
public stop() {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
}
}

View File

@@ -86,13 +86,7 @@ function detectProjectName(): string {
// ─── 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 = '';
@@ -102,34 +96,15 @@ const recentDiscordSentTexts: Map<string, number> = new Map();
function writeChatSnapshot(text: string) {
try {
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
conversation_id: activeSessionId,
conversation_id: getStepProbeSessionId(),
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
return;
if (getStepProbeSessionId()) { writeRegistration(getStepProbeSessionId()); }
}
// File route (fallback — only when WS is NOT connected)
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: any) {
console.log(`Gravity Bridge: snapshot write error: ${e.message}`);
}
@@ -137,33 +112,16 @@ function writeChatSnapshot(text: string) {
function writeChatSnapshotWithFiles(text: string, files: Array<{ name: string, content: string }>) {
try {
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
attached_files: files,
conversation_id: activeSessionId,
conversation_id: getStepProbeSessionId(),
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
return;
if (getStepProbeSessionId()) { writeRegistration(getStepProbeSessionId()); }
}
// File route (fallback — only when WS is NOT connected)
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: any) {
console.log(`Gravity Bridge: snapshot+files write error: ${e.message}`);
}
@@ -383,7 +341,7 @@ export async function activate(context: vscode.ExtensionContext) {
const config = vscode.workspace.getConfiguration('gravityBridge');
const configPath = config.get<string>('bridgePath');
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
ensureBridgeDir();
console.log(`Gravity Bridge: bridge path: ${bridgePath}`);
// ── WebSocket Hub Connection ──
@@ -527,6 +485,7 @@ export async function activate(context: vscode.ExtensionContext) {
get activeSessionId() { return getStepProbeContext().activeSessionId; },
get sessionStalled() { return getStepProbeContext().sessionStalled; },
get lastPendingStepIndex() { return getStepProbeContext().lastPendingStepIndex; },
writeChatSnapshot,
};
const bridgePort = await startHttpBridge(httpBridgeCtx, sdk);
let localPort = bridgePort;

View File

@@ -13,6 +13,8 @@ import * as fs from 'fs';
import * as path from 'path';
import { WSBridgeClient } from './ws-client';
let lastFilePermissionTime = 0;
// ─── Context interface (shared state from extension.ts) ───
export interface HttpBridgeContext {
@@ -127,7 +129,7 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
const fs = require('fs');
const path = require('path');
fs.writeFileSync(path.join(ctx.bridgePath, 'dump_html.json'), dumpBody, 'utf-8');
} catch(e) {}
} catch (e) { }
res.writeHead(200); res.end('ok');
});
return;
@@ -140,9 +142,9 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
try {
const params = JSON.parse(rpcBody);
const result = await sdk.ls.rawRPC(params.method, params.args || {});
res.writeHead(200, {'Content-Type': 'application/json'});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(typeof result === 'string' ? result : JSON.stringify(result));
} catch(e: any) {
} catch (e: any) {
res.writeHead(500); res.end(e.message);
}
});
@@ -246,9 +248,6 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
}
const rid = data.request_id || Date.now().toString();
// Write pending file for Discord bot
const pendingDir = path.join(ctx.bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
const pending: Record<string, any> = {
...data,
request_id: rid,
@@ -265,22 +264,13 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
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: string) => 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 === ctx.projectName) {
const age = nowMs - (existing.timestamp * 1000);
if (age < 10_000 && age >= 0) {
ctx.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 { }
if (nowMs - lastFilePermissionTime < 10000) {
ctx.logToFile(`[HTTP] filtered duplicate file_permission (${nowMs - lastFilePermissionTime}ms old)`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' }));
return;
}
lastFilePermissionTime = nowMs;
pending.buttons = [
{ text: 'Allow Once', index: 0 },
@@ -292,8 +282,7 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
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
// WS dispatch
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
ctx.wsBridge.sendPending({
request_id: rid,

View File

@@ -1,6 +1,6 @@
export function generateApprovalObserverScript(_port: number): string {
return `
// ── Gravity Bridge v4: React Tailwind UI Observer ──
// ── Gravity Bridge v5: Context-First DOM Extraction ──
(function(){
'use strict';
var BASE='',_obs=false,_sent={},_ready=false;
@@ -9,7 +9,7 @@ export function generateApprovalObserverScript(_port: number): string {
var CLEANUP_MS=300000;
function log(m){console.log('[GB Observer] '+m);}
log('v4 Script loaded — deep Tailwind DOM traversal enabled');
log('v5 Script loaded — Context-First Tailored Extraction');
// React-Compatible Synthetic Clicker
function dispatchReactClick(el){
@@ -21,19 +21,10 @@ export function generateApprovalObserverScript(_port: number): string {
el.dispatchEvent(new MouseEvent('mouseup', {bubbles:true, cancelable:true, view:window, composed:true}));
el.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, view:window, composed:true}));
} catch(e) {
el.click(); // fallback
el.click();
}
}
// ── Find common container for the step ──
function findButtonContainer(btn){
return btn.closest('.p-1')
|| btn.closest('.bg-agent-convo-background')
|| btn.closest('[class*="border-gray-500/10"]')
|| btn.closest('.monaco-list-row')
|| btn.parentElement;
}
function cleanButtonText(btn) {
if (!btn) return '';
var clone = btn.cloneNode(true);
@@ -43,10 +34,9 @@ export function generateApprovalObserverScript(_port: number): string {
}
var tr = clone.querySelector('.truncate');
var txt = (tr ? tr.textContent : clone.textContent) || '';
return txt.trim().replace(/^[\s\u200B-\u200D\uFEFF\u00A0]+/, '').replace(/(Alt|Ctrl|Shift|Meta)\\+.*/i,'').trim();
return txt.trim().replace(/^[\s\u200B-\u200D\uFEFF\u00A0]+/, '').replace(/(Alt|Ctrl|Shift|Meta)\\\\+.*/i,'').trim();
}
// ── Stable button fingerprint ──
function btnId(b,type){
var txt = cleanButtonText(b);
var parent = b.parentElement;
@@ -58,104 +48,78 @@ export function generateApprovalObserverScript(_port: number): string {
return type+'|'+txt+'|'+idx;
}
// ── Context extraction — target BOTH chat history and command payload ──
function extractCommandContext(b){
var container = findButtonContainer(b);
var container = b.closest('.p-1') || b.parentElement.parentElement;
if (!container) return "";
var titleSpans = container.querySelectorAll('span[title^="command("]');
if (titleSpans && titleSpans.length > 0) {
var t = titleSpans[0].getAttribute('title');
if (t && t.length > 5) return t.substring(0, 800);
}
var preEls = container.querySelectorAll('pre');
if (preEls && preEls.length > 0) {
var t2 = (preEls[preEls.length-1].textContent || '').trim();
if (t2.length > 2) return t2.substring(0, 800);
}
var codeText = '';
var codes = container.querySelectorAll('code, [class*="command"]');
for(var i=0; i<codes.length; i++) {
codeText += (codes[i].textContent || '').trim() + ' ';
}
if (codeText.length > 2) return codeText.trim().substring(0, 800);
var fallback = (container.textContent || '').replace(cleanButtonText(b), '').trim();
return fallback.substring(0, 500);
}
function extractChatContextFromNode(botTurn) {
if (!botTurn) return '';
var res = '';
// Use innerText if available on the markdown container (preserves spacing perfectly)
var md = botTurn.querySelector('.markdown-body') || botTurn.querySelector('.prose');
if (md && md.innerText && md.innerText.trim().length > 10) {
res = md.innerText.trim();
return res.substring(0, 3500);
}
var toolContainer = botTurn.querySelector('.bg-agent-convo-background') || botTurn.querySelector('.bg-ide-background-color');
var textParts = [];
function walk(node) {
if (toolContainer && node === toolContainer) return;
if (node.id === 'antigravity.agentSidePanelInputBox') return;
if (node.nodeType === 1) {
var tag = node.tagName.toUpperCase();
if (tag==='BUTTON' || tag==='SVG' || tag==='STYLE' || tag==='SCRIPT') return;
// Skip tool action blocks aggressively if they masquerade as normal divs
if (node !== botTurn && node.classList && (node.classList.contains('bg-ide-background-color') || node.classList.contains('bg-agent-convo-background'))) return;
}
if (node.nodeType === 3) {
var val = node.nodeValue;
if (val && val.trim()) textParts.push(val.trim());
} else {
if (node.childNodes && node.childNodes.length > 0) {
for(var i=0; i<node.childNodes.length; i++) {
walk(node.childNodes[i]);
}
}
}
if (node.nodeType === 1) {
var tg = node.tagName.toUpperCase();
if (tg==='P' || tg==='DIV' || tg==='BR' || tg==='LI' || tg==='PRE') textParts.push('\\n');
}
}
walk(botTurn);
res = textParts.join(' ').replace(/ \\n /g, '\\n').replace(/\\n+/g, '\\n').trim();
return res.substring(0, 3500);
}
function extractChatContext(b) {
try {
var botTurn = b.closest('.bg-agent-convo-background') || b.closest('.text-ide-message-block-bot-color');
var botTurn = b.closest('.text-ide-message-block-bot-color') || b.closest('.bg-agent-convo-background');
if (!botTurn) {
var container = findButtonContainer(b);
var container = b.closest('.p-1') || b.parentElement;
botTurn = container ? container.parentElement : null;
}
if (!botTurn) return '';
var toolContainer = findButtonContainer(b) || b;
var textParts = [];
function walk(node) {
if (node === toolContainer) return true; // Stop traversal at the tool box
if (node.nodeType === 1) {
var tag = node.tagName.toUpperCase();
if (tag==='BUTTON' || tag==='SVG' || tag==='STYLE' || tag==='SCRIPT') return false;
}
if (node.nodeType === 3) {
var val = node.nodeValue;
if (val && val.trim()) textParts.push(val.trim());
} else {
for(var i=0; i<node.childNodes.length; i++) {
if (walk(node.childNodes[i])) return true;
}
}
if (node.nodeType === 1) {
var tg = node.tagName.toUpperCase();
if (tg==='P' || tg==='DIV' || tg==='BR' || tg==='LI' || tg==='PRE') textParts.push('\\n');
}
return false;
}
walk(botTurn);
var result = textParts.join(' ').replace(/ \\n /g, '\\n').replace(/\\n+/g, '\\n').trim();
return result.substring(0, 1500);
return extractChatContextFromNode(botTurn);
} catch(e) {
return '';
}
}
function extractChatContextFromNode(botTurn) {
if (!botTurn) return '';
var toolContainer = botTurn.querySelector('.bg-ide-background-color'); // Stop at tool blocks
var textParts = [];
function walk(node) {
if (toolContainer && node === toolContainer) return true;
if (node.nodeType === 1) {
var tag = node.tagName.toUpperCase();
if (tag==='BUTTON' || tag==='SVG' || tag==='STYLE' || tag==='SCRIPT') return false;
}
if (node.nodeType === 3) {
var val = node.nodeValue;
if (val && val.trim()) textParts.push(val.trim());
} else {
for(var i=0; i<node.childNodes.length; i++) {
if (walk(node.childNodes[i])) return true;
}
}
if (node.nodeType === 1) {
var tg = node.tagName.toUpperCase();
if (tg==='P' || tg==='DIV' || tg==='BR' || tg==='LI' || tg==='PRE') textParts.push('\\n');
}
return false;
}
walk(botTurn);
var result = textParts.join(' ').replace(/ \\n /g, '\\n').replace(/\\n+/g, '\\n').trim();
return result.substring(0, 3500);
}
function extractContext(b) {
var cmd = extractCommandContext(b);
var chat = extractChatContext(b);
@@ -166,15 +130,21 @@ export function generateApprovalObserverScript(_port: number): string {
return combined.trim();
}
// ── Action Buttons Patterns (EN / KO) ──
var PATS = [
{ type: 'command', re: /^(?:Always\\s*)?(?:Run\\b|결행사양\\s*항상|결행)/i },
{ type: 'permission', re: /^(?:Always\\s*)?(?:Allow\\b|허용)/i },
{ type: 'permission', re: /^(?:Always\\s*)?(?:Approve\\b|승인)/i },
{ type: 'diff_review', re: /^(?:Always\\s*)?(?:Accept\\b|수락|반영)/i },
];
var ALL_ACTION_RE=[/^(?:Always\\s*)?(?:Run\\b|결행)/i,/^(?:Always\\s*)?(?:Accept\\b|수락|반영)/i,/^(?:Reject\\b|거절|거부)/i,/^(?:Always\\s*)?(?:Allow\\b|허용)/i,/^(?:Deny\\b|차단)/i,/^(?:Always\\s*)?(?:Approve\\b|승인)/i,/^(?:Cancel\\b|취소)/i,/^Retry\\b/i,/^(?:Dismiss\\b|무시)/i,/^(?:Stop\\b|정지)/i,/^Decline\\b/i];
var REJECT_RE=[/^(?:Reject\\b|거절|거부)/i,/^(?:Cancel\\b|취소)/i,/^(?:Deny\\b|차단)/i,/^(?:Stop\\b|정지)/i,/^Decline\\b/i,/^(?:Dismiss\\b|무시)/i];
var ACTION_WORDS = ['Allow', 'Run', 'Approve', 'Accept', '결행', '수락', '반영', '허용', '승인'];
var REJECT_WORDS = ['Reject', 'Cancel', 'Deny', 'Stop', 'Decline', 'Dismiss', '거절', '거부', '취소', '차단', '정지', '무시'];
function isActionBtn(txt) {
for(var i=0; i<ACTION_WORDS.length; i++) {
if(txt.indexOf(ACTION_WORDS[i]) !== -1) return true;
}
return false;
}
function isRejectBtn(txt) {
for(var i=0; i<REJECT_WORDS.length; i++) {
if(txt.indexOf(REJECT_WORDS[i]) !== -1) return true;
}
return false;
}
function collectSiblingButtons(container,triggerBtn){
if(!container)return [];
@@ -183,110 +153,86 @@ export function generateApprovalObserverScript(_port: number): string {
for(var i=0;i<siblings.length;i++){
var sb=siblings[i];
if(sb.disabled||sb.hidden||(!sb.offsetParent&&sb.style.display!=='fixed'))continue;
var stxt = cleanButtonText(sb);
if(stxt.length <= 1) continue; // Ignore icon buttons
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;
if(stxt.length <= 1) continue;
if(!isActionBtn(stxt) && !isRejectBtn(stxt)) continue;
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
}
return result;
}
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;});
.then(function(r){return r.text();}).then(function(t){return t==='pong';}).catch(function(){return false;});
}
function discoverPort(cb){
log('Waiting for Gravity Bridge status...');
var attempts=0;
var timer=setInterval(function(){
attempts++;
var items = document.querySelectorAll('[aria-label^="Gravity Bridge Control"], [title^="Gravity Bridge Control"]');
if (items.length > 0) {
var text = items[0].getAttribute('aria-label') || items[0].getAttribute('title') || '';
var m = text.match(/port:(\d+)/);
var m = text.match(/port:(\\d+)/);
if (m && m[1]) {
var domPort = parseInt(m[1], 10);
clearInterval(timer);
tryPingAsync(domPort).then(function(ok){
if(ok) cb(domPort); else cb(HARDCODED_PORT);
});
tryPingAsync(parseInt(m[1], 10)).then(function(ok){ cb(ok ? parseInt(m[1],10) : HARDCODED_PORT); });
return;
}
}
// If we are in the webview, the status bar is invisible. Skip quickly.
if(attempts>1){
clearInterval(timer);
tryPingAsync(HARDCODED_PORT).then(function(ok){ cb(HARDCODED_PORT); }); // Assume HARDCODED_PORT works!
tryPingAsync(HARDCODED_PORT).then(function(){ cb(HARDCODED_PORT); });
}
},500); // Wait 500ms * 2 = 1 second total
},500);
}
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'){_ready=true;startObserver();}
}).catch(function(e){});
_ready=true;
startObserver();
});
var _chatSnapshots = [];
var _firstChatScan = true;
var _lastText = "";
var _lastTextTime = 0;
var _lastTextSent = false;
function scanChatBodies() {
if(!_ready)return;
var botTurns = document.querySelectorAll('.text-ide-message-block-bot-color');
for (var i = 0; i < botTurns.length; i++) {
var turn = botTurns[i];
if (turn.dataset.agChatScraped === "true" || turn.dataset.agChatScraped === "pending") continue;
if (_firstChatScan) {
turn.dataset.agChatScraped = "true";
continue;
}
var currentText = turn.textContent || '';
var found = -1;
for (var j = 0; j < _chatSnapshots.length; j++) {
if (_chatSnapshots[j].node === turn) { found = j; break; }
}
if (found === -1) {
_chatSnapshots.push({ node: turn, text: currentText, lastChanged: Date.now() });
} else {
if (_chatSnapshots[found].text !== currentText) {
_chatSnapshots[found].text = currentText;
_chatSnapshots[found].lastChanged = Date.now();
if (botTurns.length === 0) return;
var lastTurn = botTurns[botTurns.length - 1];
if (lastTurn.dataset.agChatScraped === "true" || lastTurn.dataset.agChatScraped === "pending") return;
var currentText = lastTurn.textContent || '';
if (currentText.length < 5) return;
if (_lastText !== currentText) {
_lastText = currentText;
_lastTextTime = Date.now();
_lastTextSent = false;
} else if (!_lastTextSent) {
if (Date.now() - _lastTextTime > 3000) {
_lastTextSent = true;
lastTurn.dataset.agChatScraped = "pending";
var finalTxt = extractChatContextFromNode(lastTurn);
if (finalTxt && finalTxt.length > 5 && finalTxt !== "Review Changes") {
fetch(BASE+'/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: finalTxt })
}).then(function(){
lastTurn.dataset.agChatScraped = "true";
}).catch(function(){
lastTurn.dataset.agChatScraped = "false";
});
} else {
if (Date.now() - _chatSnapshots[found].lastChanged > 3500) {
turn.dataset.agChatScraped = "pending"; // prevent re-entry
var finalTxt = extractChatContextFromNode(turn);
if (finalTxt && finalTxt.length > 5) {
fetch(BASE+'/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: finalTxt })
}).then(function(){
turn.dataset.agChatScraped = "true";
}).catch(function(){
turn.dataset.agChatScraped = "false"; // retry
});
} else {
turn.dataset.agChatScraped = "true";
}
}
lastTurn.dataset.agChatScraped = "true";
}
}
}
_firstChatScan = false;
}
function scan(){
@@ -301,26 +247,17 @@ export function generateApprovalObserverScript(_port: number): string {
if(b.disabled||b.hidden||(!b.offsetParent&&b.style.display!=='fixed'))continue;
var txt=cleanButtonText(b);
console.log("[JSDOM] Button scan:", txt);
if(txt.length <= 1) continue; // Icon
if(txt.length <= 1) continue;
var matchedType=null;
for(var p=0;p<PATS.length;p++){
if(PATS[p].re.test(txt)){
if (b.closest('.codelens-decoration') && PATS[p].type !== 'diff_review' && PATS[p].type !== 'permission') {
continue;
}
matchedType=PATS[p].type;
break;
}
if(!isActionBtn(txt)) continue;
// Skip inline code lens buttons unless they actually match the pattern properly
if (b.closest('.codelens-decoration') && !txt.includes('Accept') && !txt.includes('수락') && !txt.includes('반영')) {
continue;
}
if(!matchedType){
console.log("[JSDOM] NOT MATCHED:", txt);
continue;
}
var container=findButtonContainer(b);
var matchedType = txt.includes('Accept') ? 'diff_review' : (txt.includes('Run') ? 'command' : 'permission');
var container=b.closest('.p-1') || b.parentElement.parentElement;
var groupKey=matchedType+'|'+btnId(b,matchedType);
console.log("[JSDOM] MATCHED:", matchedType, "KEY:", groupKey, "SENT:", !!_sent[groupKey]);
if(_sent[groupKey])continue;
var siblings=collectSiblingButtons(container,b);
@@ -338,7 +275,6 @@ export function generateApprovalObserverScript(_port: number): string {
}
var desc=extractContext(b);
var is_dom_dummy = false;
if (!desc || desc.trim().length <= 2) {
desc = "MISSING_DESCRIPTION_FROM_DOM_FALLBACK_TO_STEP_PROBE";
@@ -417,17 +353,15 @@ export function generateApprovalObserverScript(_port: number): string {
}
function clickRejectButton(approveBtn){
var container=findButtonContainer(approveBtn);
var container=approveBtn.closest('.p-1') || approveBtn.parentElement.parentElement;
if(!container)return;
var siblings=container.querySelectorAll('button');
for(var i=0;i<siblings.length;i++){
var t=cleanButtonText(siblings[i]);
for(var r=0;r<REJECT_RE.length;r++){
if(REJECT_RE[r].test(t)){
log('Clicking reject: '+t);
dispatchReactClick(siblings[i]);
return;
}
if(isRejectBtn(t)){
log('Clicking reject: '+t);
dispatchReactClick(siblings[i]);
return;
}
}
}
@@ -476,22 +410,17 @@ export function generateApprovalObserverScript(_port: number): string {
if(_ready&&BASE){
fetch(BASE+'/trigger-click?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
if(!d.action)return;
var approveRe=[/^(?:Always\\\\s*)?(?:Run\\\\b|결행)/i,/^(?:Always\\\\s*)?(?:Accept\\\\b|수락)/i,/^(?:Always\\\\s*)?(?:Accept all\\\\b|모두 수락)/i,/^(?:Always\\\\s*)?(?:Allow\\\\b|허용)/i,/^(?:Always\\\\s*)?(?:Approve\\\\b|승인)/i];
var rejectRe=[/^(?:Reject\\\\b|거절|거부)/i,/^(?:Cancel\\\\b|취소)/i,/^(?:Deny\\\\b|차단)/i,/^(?:Stop\\\\b|정지)/i,/^Decline\\\\b/i,/^(?:Dismiss\\\\b|무시)/i];
var patterns=(d.action==='approve')?approveRe:rejectRe;
var isApprove = (d.action==='approve');
var btns = document.querySelectorAll('button');
for(var i=0;i<btns.length;i++){
var bx = btns[i];
if(bx.disabled||bx.hidden||(!bx.offsetParent&&bx.style.display!=='fixed'))continue;
var t = cleanButtonText(bx);
if(t.length <= 1) continue;
for(var pi=0;pi<patterns.length;pi++){
if(patterns[pi].test(t)){
log('Fallback TRIGGER-CLICK on "' + t + '"');
dispatchReactClick(bx);
return;
}
if (isApprove ? isActionBtn(t) : isRejectBtn(t)) {
log('Fallback TRIGGER-CLICK on "' + t + '"');
dispatchReactClick(bx);
return;
}
}
}).catch(function(){});

View File

@@ -9,6 +9,7 @@ import * as path from 'path';
import { WSBridgeClient } from './ws-client';
import { extractPlannerText, extractToolCommand, extractToolDescription } from './step-utils';
import { initApprovalHandler, setupResponseWatcher } from './approval-handler';
import { BrainWatcher } from './brain-watcher';
// Re-export from approval-handler for backward compatibility with extension.ts imports
export { handleDiffReviewResponse, tryApprovalStrategies } from './approval-handler';
@@ -35,6 +36,7 @@ export interface BridgeContext {
let ctx: BridgeContext;
let responseWatcher: fs.FSWatcher | null = null;
let brainWatcher: BrainWatcher | null = null;
let activeTrajectoryId = '';
const recentPendingSteps = new Map<string, number>();
const PENDING_MEMORY_TTL_MS = 60_000;
@@ -276,15 +278,16 @@ function setupMonitor() {
} catch (e: any) {
if (pollCount <= 30) ctx.logToFile(`[POLL] Fallback 2 error for sid=${sid}: ${e.message}`);
// If trajectory explicitly does not exist, it might be an Antigravity or non-Cascade session directory.
if (e.message?.includes('trajectory not found')) {
continue;
}
// FIXED: known-issues "AI Response Missing for New Sessions" -> Force register to prevent session loss on proto/UTF-8 parse errors
// We MUST register it so activeSessionId tracks it properly.
// To prevent old ghost sessions from hijacking, we only mark it RUNNING if it was recently modified.
const ageMs = Date.now() - brainDirs[i].time;
const isFresh = ageMs < 120_000; // updated within 2 mins
allTraj.trajectorySummaries[sid] = {
status: 'CASCADE_RUN_STATUS_RUNNING',
status: isFresh ? 'CASCADE_RUN_STATUS_RUNNING' : 'CASCADE_RUN_STATUS_IDLE',
stepCount: 1, // Assume progressing to allow loop delta>0 trigger
lastModifiedTime: new Date(brainDirs[i].time).toISOString(),
summary: 'Discovered via brain/ scan (Fallback Error)',
summary: 'Discovered via brain/ scan (Antigravity Native)',
trajectoryMetadata: { workspaces: [{ workspaceFolderAbsoluteUri: ctx.workspaceUri.replace(/\\/g, '/') }] }
};
}
@@ -381,6 +384,9 @@ function setupMonitor() {
// Session changed?
if (bestSessionId !== ctx.activeSessionId) {
ctx.activeSessionId = bestSessionId;
if (brainWatcher) {
brainWatcher.updateSession(bestSessionId);
}
activeTrajectoryId = (bestSession as any).trajectoryId || '';
activeSessionTitle = currentTitle;
lastKnownStepCount = currentCount;
@@ -1261,6 +1267,13 @@ export function writePendingApproval(data: { conversation_id: string; command: s
*/
export function initStepProbe(context: BridgeContext) {
ctx = context;
if (ctx.wsBridge) {
brainWatcher = new BrainWatcher({
logToFile: ctx.logToFile,
wsBridge: ctx.wsBridge,
projectName: ctx.projectName
});
}
initApprovalHandler(context, () => activeTrajectoryId);
setupMonitor();
setupResponseWatcher();