fix(bridge): multi-window isolation v0.3.4

This commit is contained in:
2026-03-08 16:56:23 +09:00
parent c97414cd37
commit b92c3c072f
6 changed files with 745 additions and 257 deletions

1
.gitignore vendored
View File

@@ -28,3 +28,4 @@ Thumbs.db
# Node # Node
node_modules/ node_modules/
extension/out/ extension/out/
*.vsix

View File

@@ -14,3 +14,4 @@
| 10 | 08:00 | GetCascadeTrajectorySteps 완전 제거 + stall-based WAITING 감지 | `9b9c9c7` | ✅ | | 10 | 08:00 | GetCascadeTrajectorySteps 완전 제거 + stall-based WAITING 감지 | `9b9c9c7` | ✅ |
| 11 | 08:10 | Stall 감지 calibration + VS Code 명령어 기반 승인 핸들러 | `f1f9a0b` | 🔧 | | 11 | 08:10 | Stall 감지 calibration + VS Code 명령어 기반 승인 핸들러 | `f1f9a0b` | 🔧 |
| 12 | 11:30~14:35 | 승인 로직 정밀 디버깅: IDLE→stall 전환, lastModifiedTime 구분, RPC/Commands 전수 테스트, ResolveOutstandingSteps cancel 발견 | - | 🔧 | | 12 | 11:30~14:35 | 승인 로직 정밀 디버깅: IDLE→stall 전환, lastModifiedTime 구분, RPC/Commands 전수 테스트, ResolveOutstandingSteps cancel 발견 | - | 🔧 |
| 13 | 15:00~16:52 | Multi-window 격리 (v0.3.1→0.3.4): 세션 필터, per-project 포트, 등록 경쟁 조건 수정, DOM Observer 렌더러 디버깅 | - | 🔧 |

View File

@@ -0,0 +1,29 @@
# Multi-Window 격리 + DOM Observer 렌더러 디버깅
- **시간**: 2026-03-08 15:00~16:52
- **Vikunja**: 관련 태스크 없음 (신규 생성 필요)
## 결정 사항
### 1. 세션 등록 방식: 폴링 등록 → 활동 기반 지연 등록
- **이유**: 두 확장이 같은 LS를 공유하므로 `GetAllCascadeTrajectories` 결과에서 세션 소유 창을 구분 불가
- **방식**: `writeRegistration()``setupMonitor` 폴링에서 제거하고, `writeChatSnapshot`/`writePendingApproval` 호출 시에만 등록. 이 두 함수는 올바른 `projectName`을 보장
### 2. 포트 디스커버리: 단일 파일 → JSON 멀티포트
- **이유**: 양쪽 확장이 같은 JS 파일(`ag-sdk-variet-gravity-bridge.js`)을 덮어씀
- **방식**: `ag-bridge-ports.json``{projectName: port}` 형태로 모든 확장이 추가. 렌더러가 JSON을 읽고 모든 포트에 ping
### 3. DOM Observer 경로 vs VS Code 명령어 경로 분리
- **이유**: DOM observer 승인은 렌더러가 직접 버튼 클릭 → VS Code 명령어 불필요
- **방식**: `processResponseFile`에서 `auto_detected && source=dom_observer`이면 VS Code 명령어 건너뜀
## 미완료
1. **렌더러 스크립트 미실행**: `workbench.html`에 script 태그 존재하나 `[GB Observer]` 로그 없음
- Antigravity 재설치 후 확장 재설치로 깨끗한 상태에서 테스트 필요
2. **Discord 승인 클릭스루**: E2E 미검증 — 렌더러 DOM click 경로가 작동해야 완성
3. **확장 버전**: v0.3.4 빌드 완료 (VSIX 존재), 재설치 후 적용 필요
## 변경 파일 (미커밋)
- `extension/src/extension.ts` — v0.3.1→0.3.4 (세션 필터, 포트 격리, 경쟁 조건, DOM 경로 분리)
- `.agents/references/known-issues.md` — 3건 추가

View File

@@ -110,6 +110,8 @@ function ensureBridgeDir() {
} }
} }
} }
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
let activeSessionId = '';
function writeChatSnapshot(text) { function writeChatSnapshot(text) {
try { try {
// Write to chat_snapshots/*.json for Bot's chat_snapshot_scanner to pick up // Write to chat_snapshots/*.json for Bot's chat_snapshot_scanner to pick up
@@ -127,6 +129,10 @@ function writeChatSnapshot(text) {
const filePath = path.join(snapshotDir, `${id}.json`); const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`); console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`);
// Lazily register session → project mapping (correct because projectName is per-window)
if (activeSessionId) {
writeRegistration(activeSessionId);
}
} }
catch (e) { catch (e) {
console.log(`Gravity Bridge: snapshot write error: ${e.message}`); console.log(`Gravity Bridge: snapshot write error: ${e.message}`);
@@ -405,12 +411,21 @@ function startObserverHttpBridge() {
const port = server.address().port; const port = server.address().port;
observerHttpServer = server; observerHttpServer = server;
logToFile(`[HTTP] bridge server started on port ${port}`); logToFile(`[HTTP] bridge server started on port ${port}`);
// Write port to workbench dir so renderer can read it via XHR // Write port to shared ports JSON (multi-bridge support)
const patcher = sdk.integration?._patcher; const patcher = sdk.integration?._patcher;
if (patcher && typeof patcher.getWorkbenchDir === 'function') { if (patcher && typeof patcher.getWorkbenchDir === 'function') {
const portFile = path.join(patcher.getWorkbenchDir(), 'ag-bridge-port'); const workbenchDir = patcher.getWorkbenchDir();
fs.writeFileSync(portFile, port.toString(), 'utf8'); const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
logToFile(`[HTTP] port written → ${portFile}`); 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); resolve(port);
}); });
@@ -427,32 +442,49 @@ function startObserverHttpBridge() {
} }
// ─── Renderer Script (uses fetch() — no Node.js APIs) ─── // ─── Renderer Script (uses fetch() — no Node.js APIs) ───
function generateApprovalObserverScript(_port) { function generateApprovalObserverScript(_port) {
// Port is NOT hardcoded renderer reads it dynamically from ag-bridge-port file via XHR // Port is hardcoded as fallback, but renderer also reads ag-bridge-ports.json for multi-bridge
return ` return `
// ── Gravity Bridge: Approval Observer (renderer-side, dynamic port) ── // ── Gravity Bridge v2: Approval Observer (MutationObserver-first, throttled) ──
(function(){ (function(){
'use strict'; 'use strict';
var BASE='',_lastTs=0,_obs=false,_sent={},_ready=false; var BASE='',_obs=false,_sent={},_ready=false;
var _scanScheduled=false,_lastScanTs=0;
var THROTTLE_MS=100;
var CLEANUP_MS=300000;
function log(m){console.log('[GB Observer] '+m);} function log(m){console.log('[GB Observer] '+m);}
log('Script loaded — discovering bridge port...'); log('v2 Script loaded — discovering bridge port...');
// ── Dynamic Port Discovery (like SDK heartbeat) ── // ── Multi-Port Discovery: reads ag-bridge-ports.json, tries ALL bridges ──
function discoverPort(cb){ function discoverPort(cb){
var attempts=0; var attempts=0;
var timer=setInterval(function(){ var timer=setInterval(function(){
attempts++; attempts++;
if(attempts>30){clearInterval(timer);log('Port discovery timeout');return;} if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
try{ try{
var xhr=new XMLHttpRequest(); var xhr=new XMLHttpRequest();
xhr.open('GET','./ag-bridge-port?t='+Date.now(),false); xhr.open('GET','./ag-bridge-ports.json?t='+Date.now(),false);
xhr.send(); xhr.send();
if(xhr.status===200){ if(xhr.status===200){
var port=parseInt(xhr.responseText.trim(),10); var ports=JSON.parse(xhr.responseText);
if(port>0&&port<65536){ var keys=Object.keys(ports);
clearInterval(timer); for(var i=0;i<keys.length;i++){
log('Port discovered: '+port); var port=ports[keys[i]];
cb(port); if(port>0&&port<65536){
// Try ping on each port
try{
var xhr2=new XMLHttpRequest();
xhr2.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
xhr2.timeout=1000;
xhr2.send();
if(xhr2.status===200&&xhr2.responseText==='pong'){
clearInterval(timer);
log('Port discovered: '+port+' (project='+keys[i]+')');
cb(port);
return;
}
}catch(e2){}
}
} }
} }
}catch(e){} }catch(e){}
@@ -461,82 +493,254 @@ function generateApprovalObserverScript(_port) {
discoverPort(function(port){ discoverPort(function(port){
BASE='http://127.0.0.1:'+port; BASE='http://127.0.0.1:'+port;
// Verify bridge is alive
fetch(BASE+'/ping').then(function(r){return r.text();}).then(function(t){ fetch(BASE+'/ping').then(function(r){return r.text();}).then(function(t){
if(t==='pong'){log('Bridge connected on port '+port);_ready=true;startObserver();} if(t==='pong'){log('Bridge connected on port '+port);_ready=true;startObserver();}
else log('Bridge ping failed: '+t); else log('Bridge ping failed: '+t);
}).catch(function(e){log('Bridge unreachable: '+e.message);}); }).catch(function(e){log('Bridge unreachable: '+e.message);});
}); });
// ── Button patterns to detect (order matters: first match wins per scan) ──
var PATS=[ var PATS=[
{sel:'button',re:/^Run$/i,type:'terminal_command'}, {re:/^Run$/i, type:'terminal_command'},
{sel:'button',re:/^Accept/i,type:'agent_step'}, {re:/^Accept/i, type:'agent_step'},
{sel:'button',re:/^Allow/i,type:'permission'}, {re:/^Allow/i, type:'permission'},
{sel:'button',re:/^Continue$/i,type:'continue'}, {re:/^Approve/i, type:'agent_step'},
{re:/^Continue$/i, type:'continue'},
{re:/^Proceed$/i, type:'continue'},
]; ];
function ctx(b){ // Reject button patterns for finding the counterpart
var p=b.closest('[class*="step"]')||b.closest('[class*="action"]')||b.parentElement; var REJECT_RE=[/^reject$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
if(!p)return '';
var c=p.querySelector('pre,code,[class*="command"],[class*="terminal"]'); // ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
if(c)return(c.textContent||'').trim().substring(0,200); function btnId(b,type){
return(p.textContent||'').replace((b.textContent||''),'').trim().substring(0,200); // Use: type + button text + parent's first 40 chars of text content
var txt=(b.textContent||'').trim();
var parent=b.parentElement;
var pctx=parent?(parent.textContent||'').substring(0,40).replace(/\\s+/g,' '):'';
// Also use DOM position: nth-child among sibling buttons
var idx=0;
if(parent){
var siblings=parent.querySelectorAll('button');
for(var i=0;i<siblings.length;i++){if(siblings[i]===b){idx=i;break;}}
}
return type+'|'+txt+'|'+idx+'|'+pctx.substring(0,20);
} }
// ── Context extraction — walk up DOM to find command/code description ──
function extractContext(b){
// Strategy 1: Look for code/pre/terminal blocks near the button
var container=b.closest('[class*="step"]')
||b.closest('[class*="action"]')
||b.closest('[class*="tool"]')
||b.closest('[class*="cascade"]')
||b.closest('[class*="message"]');
if(!container)container=b.parentElement;
if(!container)return '';
// Look for code blocks
var codeEl=container.querySelector('pre,code,[class*="command"],[class*="terminal"],[class*="code-block"]');
if(codeEl){
var codeText=(codeEl.textContent||'').trim();
if(codeText.length>0)return codeText.substring(0,500);
}
// Strategy 2: Get surrounding text (exclude button text itself)
var full=(container.textContent||'');
var btnText=(b.textContent||'');
var desc=full.replace(btnText,'').trim();
// Trim to reasonable length
return desc.substring(0,500);
}
// ── Find the React app container (Antigravity's main UI root) ──
function findPanel(){
// Priority order of panel selectors (most specific first)
var selectors=[
'.antigravity-agent-side-panel',
'#jetski-agent-panel',
'.react-app-container',
'[class*="agent-panel"]',
'[class*="agentPanel"]',
];
for(var i=0;i<selectors.length;i++){
var el=document.querySelector(selectors[i]);
if(el)return el;
}
return null;
}
// ── Core scan — finds actionable buttons and reports to bridge ──
function scan(){ function scan(){
if(!_ready)return; if(!_ready)return;
var now=Date.now();if(now-_lastTs<1000)return; var now=Date.now();
var panel=document.querySelector('#jetski-agent-panel,.antigravity-agent-side-panel,[class*="agent-panel"]');
var panel=findPanel();
if(!panel)return; if(!panel)return;
for(var i=0;i<PATS.length;i++){
var pat=PATS[i],btns=panel.querySelectorAll(pat.sel); // Find ALL buttons in the panel
for(var j=0;j<btns.length;j++){ var allBtns=panel.querySelectorAll('button');
var b=btns[j],txt=(b.textContent||'').trim(); if(!allBtns.length)return;
if(!pat.re.test(txt)||b.disabled)continue;
var bid=pat.type+'_'+txt+'_'+Math.round(b.getBoundingClientRect().top); for(var j=0;j<allBtns.length;j++){
if(_sent[bid])continue; var b=allBtns[j];
var desc=ctx(b),rid=now.toString(); if(b.disabled||b.hidden)continue;
_sent[bid]=rid;_lastTs=now; // Check visibility (offsetParent null = hidden via CSS)
log('FOUND '+pat.type+': "'+txt+'" → sending to bridge'); if(!b.offsetParent&&b.style.display!=='fixed')continue;
(function(rid2,b2,bid2){
fetch(BASE+'/pending',{ var txt=(b.textContent||'').trim();
method:'POST', if(!txt)continue;
headers:{'Content-Type':'application/json'},
body:JSON.stringify({request_id:rid2,command:txt,description:desc,step_type:pat.type}) // Match against patterns
}).then(function(r){return r.json();}).then(function(d){ var matchedType=null;
log('Pending created: '+d.request_id); for(var p=0;p<PATS.length;p++){
pollResp(d.request_id,b2,bid2); if(PATS[p].re.test(txt)){matchedType=PATS[p].type;break;}
}).catch(function(e){log('POST error: '+e.message);delete _sent[bid2];});
})(rid,b,bid);
return;
} }
if(!matchedType)continue;
// Generate stable ID
var bid=btnId(b,matchedType);
if(_sent[bid])continue;
// Extract context
var desc=extractContext(b);
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
// Mark as sent
_sent[bid]={rid:rid,ts:now};
log('DETECTED '+matchedType+': "'+txt+'" → pending to bridge');
// Send to bridge (closure to capture refs)
(function(rid2,b2,bid2,txt2,desc2,type2){
fetch(BASE+'/pending',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
request_id:rid2,
command:txt2,
description:desc2,
step_type:type2
})
}).then(function(r){return r.json();}).then(function(d){
log('Pending created: '+d.request_id+' for "'+txt2+'"');
pollResponse(d.request_id,b2,bid2);
}).catch(function(e){
log('POST error: '+e.message);
delete _sent[bid2];
});
})(rid,b,bid,txt,desc,matchedType);
// Process ONE button per scan cycle (avoid flooding)
return;
} }
} }
function pollResp(rid,b,bid){ // ── Poll for Discord response ──
var n=0,t=setInterval(function(){ function pollResponse(rid,btn,bid){
n++;if(n>600){clearInterval(t);delete _sent[bid];return;} var polls=0;
var maxPolls=600; // 5 minutes at 500ms interval
var timer=setInterval(function(){
polls++;
// Check if button is still in DOM (step may have been resolved by other means)
if(!document.body.contains(btn)){
log('Button removed from DOM — stopping poll for '+rid);
clearInterval(timer);
delete _sent[bid];
return;
}
if(polls>maxPolls){
log('Poll timeout for '+rid);
clearInterval(timer);
delete _sent[bid];
return;
}
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){ fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
if(d.waiting)return; if(d.waiting)return;
clearInterval(t); clearInterval(timer);
if(d.approved){log('APPROVED '+rid+' → clicking');b.click();} if(d.approved){
else{ log('✅ APPROVED '+rid+' → clicking "'+((btn.textContent||'').trim())+'"');
log('REJECTED '+rid); btn.click();
var p=b.closest('[class*="step"]')||b.parentElement; } else {
if(p){var rb=p.querySelectorAll('button'); log('❌ REJECTED '+rid+' → finding reject button');
for(var k=0;k<rb.length;k++){var rt=(rb[k].textContent||'').trim().toLowerCase(); clickRejectButton(btn);
if(rt==='reject'||rt==='cancel'||rt==='deny'){rb[k].click();break;}}}
} }
delete _sent[bid]; delete _sent[bid];
}).catch(function(){}); }).catch(function(){});
},500); },500);
} }
// ── Find and click the reject/cancel counterpart button ──
function clickRejectButton(approveBtn){
// Walk up to find the container, then search for reject buttons
var container=approveBtn.closest('[class*="step"]')
||approveBtn.closest('[class*="action"]')
||approveBtn.closest('[class*="tool"]')
||approveBtn.parentElement;
if(!container){log('No container for reject');return;}
var siblings=container.querySelectorAll('button');
for(var i=0;i<siblings.length;i++){
var t=(siblings[i].textContent||'').trim();
for(var r=0;r<REJECT_RE.length;r++){
if(REJECT_RE[r].test(t)){
log('Clicking reject: "'+t+'"');
siblings[i].click();
return;
}
}
}
log('No reject button found near approve button');
}
// ── Throttled scan — leading-edge: fires immediately, then locks ──
function scheduleScan(){
if(!_ready)return;
var now=Date.now();
if(now-_lastScanTs>=THROTTLE_MS){
_lastScanTs=now;
scan();
} else if(!_scanScheduled){
_scanScheduled=true;
setTimeout(function(){
_scanScheduled=false;
_lastScanTs=Date.now();
scan();
},THROTTLE_MS-(now-_lastScanTs));
}
}
// ── Periodic cleanup of stale _sent entries ──
setInterval(function(){
var now=Date.now();
var keys=Object.keys(_sent);
for(var i=0;i<keys.length;i++){
var entry=_sent[keys[i]];
if(entry&&entry.ts&&(now-entry.ts)>CLEANUP_MS){
log('Cleanup stale entry: '+keys[i]);
delete _sent[keys[i]];
}
}
},60000);
// ── Start observation ──
function startObserver(){ function startObserver(){
if(_obs)return; if(_obs)return;
new MutationObserver(function(){scan();}).observe(document.body,{childList:true,subtree:true}); // PRIMARY: MutationObserver — reacts instantly to DOM changes
setInterval(scan,2000); new MutationObserver(function(mutations){
_obs=true;log('Observer active — watching for approval buttons'); // Only scan if mutations contain added nodes (new buttons potentially)
for(var i=0;i<mutations.length;i++){
if(mutations[i].addedNodes.length>0){
scheduleScan();
return;
}
}
}).observe(document.body,{childList:true,subtree:true});
// FALLBACK: periodic scan every 3s for any missed mutations
setInterval(scheduleScan,3000);
_obs=true;
log('v2 Observer active — MutationObserver + 3s fallback');
} }
})(); })();
`; `;
@@ -550,21 +754,19 @@ const registeredSessions = new Set(); // track which sessions have been register
* Called automatically on first step event per session. * Called automatically on first step event per session.
*/ */
function writeRegistration(sessionId) { function writeRegistration(sessionId) {
if (registeredSessions.has(sessionId)) {
return;
}
registeredSessions.add(sessionId);
try { try {
const regDir = path.join(bridgePath, 'register'); const regDir = path.join(bridgePath, 'register');
if (!fs.existsSync(regDir)) { if (!fs.existsSync(regDir)) {
fs.mkdirSync(regDir, { recursive: true }); 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 = { const data = {
conversation_id: sessionId, conversation_id: sessionId,
project_name: projectName, project_name: projectName,
timestamp: Date.now() / 1000, timestamp: Date.now() / 1000,
}; };
fs.writeFileSync(path.join(regDir, `${sessionId}.json`), JSON.stringify(data, null, 2), 'utf-8'); fs.writeFileSync(regFile, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Gravity Bridge: registered session ${sessionId.substring(0, 8)}${projectName}`); console.log(`Gravity Bridge: registered session ${sessionId.substring(0, 8)}${projectName}`);
} }
catch (e) { catch (e) {
@@ -593,7 +795,7 @@ function setupMonitor() {
// stepIndex on each → perfect for dedup // stepIndex on each → perfect for dedup
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
let pollCount = 0; let pollCount = 0;
let activeSessionId = ''; // activeSessionId is module-level (for writeChatSnapshot lazy registration)
let activeSessionTitle = ''; let activeSessionTitle = '';
let lastKnownStepCount = 0; let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1; let lastNotifyStepIndex = -1;
@@ -615,10 +817,26 @@ function setupMonitor() {
logToFile('[POLL] no trajectorySummaries'); logToFile('[POLL] no trajectorySummaries');
return; return;
} }
// ── Filter to sessions owned by THIS window ──
// Each window claims sessions it sees first via writeRegistration().
// Only process sessions registered to THIS projectName (or unclaimed ones).
let bestSession = null; let bestSession = null;
let bestSessionId = ''; let bestSessionId = '';
let bestModTime = ''; let bestModTime = '';
const regDir = path.join(bridgePath, 'register');
for (const [sid, data] of Object.entries(allTraj.trajectorySummaries)) { for (const [sid, data] of Object.entries(allTraj.trajectorySummaries)) {
// Check if this session is claimed by another project
const regFile = path.join(regDir, `${sid}.json`);
if (fs.existsSync(regFile)) {
try {
const reg = JSON.parse(fs.readFileSync(regFile, 'utf-8'));
if (reg.project_name && reg.project_name !== projectName) {
// Session belongs to another window — skip
continue;
}
}
catch { }
}
const modTime = data.lastModifiedTime || ''; const modTime = data.lastModifiedTime || '';
if (!bestSession || modTime > bestModTime) { if (!bestSession || modTime > bestModTime) {
bestSession = data; bestSession = data;
@@ -639,7 +857,8 @@ function setupMonitor() {
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1; lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1; lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1;
lastPendingStepIndex = -1; lastPendingStepIndex = -1;
writeRegistration(activeSessionId); // Don't register here — registration happens lazily in writeChatSnapshot/writePendingApproval
// to avoid race conditions between multiple extension instances
console.log(`Gravity Bridge: [POLL#${pollCount}] session: ${activeSessionId.substring(0, 8)} "${currentTitle}" steps=${currentCount} ${isRunning ? 'RUNNING' : 'idle'}`); console.log(`Gravity Bridge: [POLL#${pollCount}] session: ${activeSessionId.substring(0, 8)} "${currentTitle}" steps=${currentCount} ${isRunning ? 'RUNNING' : 'idle'}`);
return; return;
} }
@@ -756,6 +975,19 @@ function setupResponseWatcher() {
if (filename && filename.endsWith('.json') && event === 'rename') { if (filename && filename.endsWith('.json') && event === 'rename') {
const fp = path.join(responseDir, filename); const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) { if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
}
catch { }
}
setTimeout(() => processResponseFile(fp), 300); setTimeout(() => processResponseFile(fp), 300);
} }
} }
@@ -773,90 +1005,82 @@ async function processResponseFile(filePath) {
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved}`; const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved}`;
console.log(`Gravity Bridge: ${msg}`); console.log(`Gravity Bridge: ${msg}`);
logToFile(msg); logToFile(msg);
// Find matching pending request for session_id // Find matching pending request
const pendingDir = path.join(bridgePath, 'pending'); const pendingDir = path.join(bridgePath, 'pending');
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`); const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
let sessionId = ''; let sessionId = '';
let isDomObserver = false;
if (fs.existsSync(pendingFile)) { if (fs.existsSync(pendingFile)) {
try { try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8')); const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
sessionId = pending.conversation_id || ''; sessionId = pending.conversation_id || '';
isDomObserver = pending.auto_detected === true && pending.source === 'dom_observer';
} }
catch { } catch { }
} }
// ═══ APPROVAL STRATEGY (VS Code Commands Only) ═══ // ═══ APPROVAL STRATEGY ═══
// Phase 0 ResolveOutstandingSteps: REMOVED — confirmed it CANCELS steps! // DOM observer approvals: renderer handles clicking via pollResponse — skip VS Code commands
// Phase 1 HandleCascadeUserInteraction: REMOVED — always gets "socket hang up" // Stall-detection approvals: use VS Code commands as fallback (focus-dependent)
// Phase 2: ALL VS Code commands sequentially (no break on "success")
const approved = resp.approved; const approved = resp.approved;
// Focus panel with multiple attempts + longer delay if (isDomObserver) {
for (let i = 0; i < 2; i++) { // DOM observer path: renderer polls /response/:rid and clicks the button directly
try { logToFile(`[RESPONSE] DOM observer approval — renderer will handle click (rid=${resp.request_id})`);
await vscode.commands.executeCommand('antigravity.agentPanel.focus');
if (i === 0)
logToFile('[RESPONSE] panel focus attempt 1');
}
catch (e) {
logToFile(`[RESPONSE] panel focus attempt ${i + 1} failed: ${e.message}`);
}
await new Promise(r => setTimeout(r, 500));
}
// Phase 2: Sequential VS Code commands (MUST try ALL — no break!)
// Focus panel first
try {
await vscode.commands.executeCommand('antigravity.agentPanel.focus');
logToFile('[RESPONSE] panel focused');
}
catch (e) {
logToFile(`[RESPONSE] panel focus failed: ${e.message}`);
}
await new Promise(r => setTimeout(r, 500));
if (approved) {
const approveCommands = [
'antigravity.interactiveCascade.acceptSuggestedAction',
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.command.accept',
'antigravity.agent.acceptAgentStep',
];
for (const cmd of approveCommands) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
}
catch (e) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
}
} }
else { else {
const rejectCommands = [ // Stall-detection path: use VS Code commands (legacy, focus-dependent)
'antigravity.interactiveCascade.rejectSuggestedAction', logToFile(`[RESPONSE] Stall-detection approval — using VS Code commands`);
'antigravity.terminalCommand.reject', // Focus panel
'antigravity.command.reject', for (let i = 0; i < 2; i++) {
'antigravity.agent.rejectAgentStep',
];
for (const cmd of rejectCommands) {
try { try {
await vscode.commands.executeCommand(cmd); await vscode.commands.executeCommand('antigravity.agentPanel.focus');
logToFile(`[RESPONSE] cmd OK: ${cmd}`); if (i === 0)
logToFile('[RESPONSE] panel focus attempt 1');
} }
catch (e) { catch (e) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd} ${e.message}`); logToFile(`[RESPONSE] panel focus attempt ${i + 1} failed: ${e.message}`);
}
await new Promise(r => setTimeout(r, 500));
}
if (approved) {
const approveCommands = [
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.command.accept',
'antigravity.agent.acceptAgentStep',
];
for (const cmd of approveCommands) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
}
catch (e) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
}
}
else {
const rejectCommands = [
'antigravity.terminalCommand.reject',
'antigravity.command.reject',
'antigravity.agent.rejectAgentStep',
];
for (const cmd of rejectCommands) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
}
catch (e) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
} }
} }
} }
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done`); logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'stall'})`);
// Cleanup // Cleanup response file (but NOT pending — renderer still polls it)
try { try {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
} }
catch { } catch { }
try {
if (fs.existsSync(pendingFile))
fs.unlinkSync(pendingFile);
}
catch { }
} }
catch (e) { catch (e) {
const log = `[RESPONSE] error: ${e.message}`; const log = `[RESPONSE] error: ${e.message}`;
@@ -962,6 +1186,10 @@ function writePendingApproval(data) {
}; };
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8'); fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
console.log(`Gravity Bridge: pending approval written → ${id}.json`); console.log(`Gravity Bridge: pending approval written → ${id}.json`);
// Register session → project mapping (correct because projectName is per-window)
if (data.conversation_id) {
writeRegistration(data.conversation_id);
}
} }
catch (e) { catch (e) {
console.log(`Gravity Bridge: pending write error: ${e.message}`); console.log(`Gravity Bridge: pending write error: ${e.message}`);

File diff suppressed because one or more lines are too long

View File

@@ -77,6 +77,9 @@ function ensureBridgeDir() {
} }
} }
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
let activeSessionId = '';
function writeChatSnapshot(text: string) { function writeChatSnapshot(text: string) {
try { try {
// Write to chat_snapshots/*.json for Bot's chat_snapshot_scanner to pick up // Write to chat_snapshots/*.json for Bot's chat_snapshot_scanner to pick up
@@ -92,6 +95,8 @@ function writeChatSnapshot(text: string) {
const filePath = path.join(snapshotDir, `${id}.json`); const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`); console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`);
// Lazily register session → project mapping (correct because projectName is per-window)
if (activeSessionId) { writeRegistration(activeSessionId); }
} catch (e: any) { } catch (e: any) {
console.log(`Gravity Bridge: snapshot write error: ${e.message}`); console.log(`Gravity Bridge: snapshot write error: ${e.message}`);
} }
@@ -358,12 +363,20 @@ function startObserverHttpBridge(): Promise<number> {
observerHttpServer = server; observerHttpServer = server;
logToFile(`[HTTP] bridge server started on port ${port}`); logToFile(`[HTTP] bridge server started on port ${port}`);
// Write port to workbench dir so renderer can read it via XHR // Write port to shared ports JSON (multi-bridge support)
const patcher = (sdk.integration as any)?._patcher; const patcher = (sdk.integration as any)?._patcher;
if (patcher && typeof patcher.getWorkbenchDir === 'function') { if (patcher && typeof patcher.getWorkbenchDir === 'function') {
const portFile = path.join(patcher.getWorkbenchDir(), 'ag-bridge-port'); const workbenchDir = patcher.getWorkbenchDir();
fs.writeFileSync(portFile, port.toString(), 'utf8'); const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
logToFile(`[HTTP] port written → ${portFile}`); let portsData: Record<string, number> = {};
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); resolve(port);
@@ -383,32 +396,49 @@ function startObserverHttpBridge(): Promise<number> {
// ─── Renderer Script (uses fetch() — no Node.js APIs) ─── // ─── Renderer Script (uses fetch() — no Node.js APIs) ───
function generateApprovalObserverScript(_port: number): string { function generateApprovalObserverScript(_port: number): string {
// Port is NOT hardcoded renderer reads it dynamically from ag-bridge-port file via XHR // Port is hardcoded as fallback, but renderer also reads ag-bridge-ports.json for multi-bridge
return ` return `
// ── Gravity Bridge: Approval Observer (renderer-side, dynamic port) ── // ── Gravity Bridge v2: Approval Observer (MutationObserver-first, throttled) ──
(function(){ (function(){
'use strict'; 'use strict';
var BASE='',_lastTs=0,_obs=false,_sent={},_ready=false; var BASE='',_obs=false,_sent={},_ready=false;
var _scanScheduled=false,_lastScanTs=0;
var THROTTLE_MS=100;
var CLEANUP_MS=300000;
function log(m){console.log('[GB Observer] '+m);} function log(m){console.log('[GB Observer] '+m);}
log('Script loaded — discovering bridge port...'); log('v2 Script loaded — discovering bridge port...');
// ── Dynamic Port Discovery (like SDK heartbeat) ── // ── Multi-Port Discovery: reads ag-bridge-ports.json, tries ALL bridges ──
function discoverPort(cb){ function discoverPort(cb){
var attempts=0; var attempts=0;
var timer=setInterval(function(){ var timer=setInterval(function(){
attempts++; attempts++;
if(attempts>30){clearInterval(timer);log('Port discovery timeout');return;} if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
try{ try{
var xhr=new XMLHttpRequest(); var xhr=new XMLHttpRequest();
xhr.open('GET','./ag-bridge-port?t='+Date.now(),false); xhr.open('GET','./ag-bridge-ports.json?t='+Date.now(),false);
xhr.send(); xhr.send();
if(xhr.status===200){ if(xhr.status===200){
var port=parseInt(xhr.responseText.trim(),10); var ports=JSON.parse(xhr.responseText);
if(port>0&&port<65536){ var keys=Object.keys(ports);
clearInterval(timer); for(var i=0;i<keys.length;i++){
log('Port discovered: '+port); var port=ports[keys[i]];
cb(port); if(port>0&&port<65536){
// Try ping on each port
try{
var xhr2=new XMLHttpRequest();
xhr2.open('GET','http://127.0.0.1:'+port+'/ping?t='+Date.now(),false);
xhr2.timeout=1000;
xhr2.send();
if(xhr2.status===200&&xhr2.responseText==='pong'){
clearInterval(timer);
log('Port discovered: '+port+' (project='+keys[i]+')');
cb(port);
return;
}
}catch(e2){}
}
} }
} }
}catch(e){} }catch(e){}
@@ -417,82 +447,254 @@ function generateApprovalObserverScript(_port: number): string {
discoverPort(function(port){ discoverPort(function(port){
BASE='http://127.0.0.1:'+port; BASE='http://127.0.0.1:'+port;
// Verify bridge is alive
fetch(BASE+'/ping').then(function(r){return r.text();}).then(function(t){ fetch(BASE+'/ping').then(function(r){return r.text();}).then(function(t){
if(t==='pong'){log('Bridge connected on port '+port);_ready=true;startObserver();} if(t==='pong'){log('Bridge connected on port '+port);_ready=true;startObserver();}
else log('Bridge ping failed: '+t); else log('Bridge ping failed: '+t);
}).catch(function(e){log('Bridge unreachable: '+e.message);}); }).catch(function(e){log('Bridge unreachable: '+e.message);});
}); });
// ── Button patterns to detect (order matters: first match wins per scan) ──
var PATS=[ var PATS=[
{sel:'button',re:/^Run$/i,type:'terminal_command'}, {re:/^Run$/i, type:'terminal_command'},
{sel:'button',re:/^Accept/i,type:'agent_step'}, {re:/^Accept/i, type:'agent_step'},
{sel:'button',re:/^Allow/i,type:'permission'}, {re:/^Allow/i, type:'permission'},
{sel:'button',re:/^Continue$/i,type:'continue'}, {re:/^Approve/i, type:'agent_step'},
{re:/^Continue$/i, type:'continue'},
{re:/^Proceed$/i, type:'continue'},
]; ];
function ctx(b){ // Reject button patterns for finding the counterpart
var p=b.closest('[class*="step"]')||b.closest('[class*="action"]')||b.parentElement; var REJECT_RE=[/^reject$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
if(!p)return '';
var c=p.querySelector('pre,code,[class*="command"],[class*="terminal"]'); // ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
if(c)return(c.textContent||'').trim().substring(0,200); function btnId(b,type){
return(p.textContent||'').replace((b.textContent||''),'').trim().substring(0,200); // Use: type + button text + parent's first 40 chars of text content
var txt=(b.textContent||'').trim();
var parent=b.parentElement;
var pctx=parent?(parent.textContent||'').substring(0,40).replace(/\\s+/g,' '):'';
// Also use DOM position: nth-child among sibling buttons
var idx=0;
if(parent){
var siblings=parent.querySelectorAll('button');
for(var i=0;i<siblings.length;i++){if(siblings[i]===b){idx=i;break;}}
}
return type+'|'+txt+'|'+idx+'|'+pctx.substring(0,20);
} }
// ── Context extraction — walk up DOM to find command/code description ──
function extractContext(b){
// Strategy 1: Look for code/pre/terminal blocks near the button
var container=b.closest('[class*="step"]')
||b.closest('[class*="action"]')
||b.closest('[class*="tool"]')
||b.closest('[class*="cascade"]')
||b.closest('[class*="message"]');
if(!container)container=b.parentElement;
if(!container)return '';
// Look for code blocks
var codeEl=container.querySelector('pre,code,[class*="command"],[class*="terminal"],[class*="code-block"]');
if(codeEl){
var codeText=(codeEl.textContent||'').trim();
if(codeText.length>0)return codeText.substring(0,500);
}
// Strategy 2: Get surrounding text (exclude button text itself)
var full=(container.textContent||'');
var btnText=(b.textContent||'');
var desc=full.replace(btnText,'').trim();
// Trim to reasonable length
return desc.substring(0,500);
}
// ── Find the React app container (Antigravity's main UI root) ──
function findPanel(){
// Priority order of panel selectors (most specific first)
var selectors=[
'.antigravity-agent-side-panel',
'#jetski-agent-panel',
'.react-app-container',
'[class*="agent-panel"]',
'[class*="agentPanel"]',
];
for(var i=0;i<selectors.length;i++){
var el=document.querySelector(selectors[i]);
if(el)return el;
}
return null;
}
// ── Core scan — finds actionable buttons and reports to bridge ──
function scan(){ function scan(){
if(!_ready)return; if(!_ready)return;
var now=Date.now();if(now-_lastTs<1000)return; var now=Date.now();
var panel=document.querySelector('#jetski-agent-panel,.antigravity-agent-side-panel,[class*="agent-panel"]');
var panel=findPanel();
if(!panel)return; if(!panel)return;
for(var i=0;i<PATS.length;i++){
var pat=PATS[i],btns=panel.querySelectorAll(pat.sel); // Find ALL buttons in the panel
for(var j=0;j<btns.length;j++){ var allBtns=panel.querySelectorAll('button');
var b=btns[j],txt=(b.textContent||'').trim(); if(!allBtns.length)return;
if(!pat.re.test(txt)||b.disabled)continue;
var bid=pat.type+'_'+txt+'_'+Math.round(b.getBoundingClientRect().top); for(var j=0;j<allBtns.length;j++){
if(_sent[bid])continue; var b=allBtns[j];
var desc=ctx(b),rid=now.toString(); if(b.disabled||b.hidden)continue;
_sent[bid]=rid;_lastTs=now; // Check visibility (offsetParent null = hidden via CSS)
log('FOUND '+pat.type+': "'+txt+'" → sending to bridge'); if(!b.offsetParent&&b.style.display!=='fixed')continue;
(function(rid2,b2,bid2){
fetch(BASE+'/pending',{ var txt=(b.textContent||'').trim();
method:'POST', if(!txt)continue;
headers:{'Content-Type':'application/json'},
body:JSON.stringify({request_id:rid2,command:txt,description:desc,step_type:pat.type}) // Match against patterns
}).then(function(r){return r.json();}).then(function(d){ var matchedType=null;
log('Pending created: '+d.request_id); for(var p=0;p<PATS.length;p++){
pollResp(d.request_id,b2,bid2); if(PATS[p].re.test(txt)){matchedType=PATS[p].type;break;}
}).catch(function(e){log('POST error: '+e.message);delete _sent[bid2];});
})(rid,b,bid);
return;
} }
if(!matchedType)continue;
// Generate stable ID
var bid=btnId(b,matchedType);
if(_sent[bid])continue;
// Extract context
var desc=extractContext(b);
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
// Mark as sent
_sent[bid]={rid:rid,ts:now};
log('DETECTED '+matchedType+': "'+txt+'" → pending to bridge');
// Send to bridge (closure to capture refs)
(function(rid2,b2,bid2,txt2,desc2,type2){
fetch(BASE+'/pending',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({
request_id:rid2,
command:txt2,
description:desc2,
step_type:type2
})
}).then(function(r){return r.json();}).then(function(d){
log('Pending created: '+d.request_id+' for "'+txt2+'"');
pollResponse(d.request_id,b2,bid2);
}).catch(function(e){
log('POST error: '+e.message);
delete _sent[bid2];
});
})(rid,b,bid,txt,desc,matchedType);
// Process ONE button per scan cycle (avoid flooding)
return;
} }
} }
function pollResp(rid,b,bid){ // ── Poll for Discord response ──
var n=0,t=setInterval(function(){ function pollResponse(rid,btn,bid){
n++;if(n>600){clearInterval(t);delete _sent[bid];return;} var polls=0;
var maxPolls=600; // 5 minutes at 500ms interval
var timer=setInterval(function(){
polls++;
// Check if button is still in DOM (step may have been resolved by other means)
if(!document.body.contains(btn)){
log('Button removed from DOM — stopping poll for '+rid);
clearInterval(timer);
delete _sent[bid];
return;
}
if(polls>maxPolls){
log('Poll timeout for '+rid);
clearInterval(timer);
delete _sent[bid];
return;
}
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){ fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
if(d.waiting)return; if(d.waiting)return;
clearInterval(t); clearInterval(timer);
if(d.approved){log('APPROVED '+rid+' → clicking');b.click();} if(d.approved){
else{ log('✅ APPROVED '+rid+' → clicking "'+((btn.textContent||'').trim())+'"');
log('REJECTED '+rid); btn.click();
var p=b.closest('[class*="step"]')||b.parentElement; } else {
if(p){var rb=p.querySelectorAll('button'); log('❌ REJECTED '+rid+' → finding reject button');
for(var k=0;k<rb.length;k++){var rt=(rb[k].textContent||'').trim().toLowerCase(); clickRejectButton(btn);
if(rt==='reject'||rt==='cancel'||rt==='deny'){rb[k].click();break;}}}
} }
delete _sent[bid]; delete _sent[bid];
}).catch(function(){}); }).catch(function(){});
},500); },500);
} }
// ── Find and click the reject/cancel counterpart button ──
function clickRejectButton(approveBtn){
// Walk up to find the container, then search for reject buttons
var container=approveBtn.closest('[class*="step"]')
||approveBtn.closest('[class*="action"]')
||approveBtn.closest('[class*="tool"]')
||approveBtn.parentElement;
if(!container){log('No container for reject');return;}
var siblings=container.querySelectorAll('button');
for(var i=0;i<siblings.length;i++){
var t=(siblings[i].textContent||'').trim();
for(var r=0;r<REJECT_RE.length;r++){
if(REJECT_RE[r].test(t)){
log('Clicking reject: "'+t+'"');
siblings[i].click();
return;
}
}
}
log('No reject button found near approve button');
}
// ── Throttled scan — leading-edge: fires immediately, then locks ──
function scheduleScan(){
if(!_ready)return;
var now=Date.now();
if(now-_lastScanTs>=THROTTLE_MS){
_lastScanTs=now;
scan();
} else if(!_scanScheduled){
_scanScheduled=true;
setTimeout(function(){
_scanScheduled=false;
_lastScanTs=Date.now();
scan();
},THROTTLE_MS-(now-_lastScanTs));
}
}
// ── Periodic cleanup of stale _sent entries ──
setInterval(function(){
var now=Date.now();
var keys=Object.keys(_sent);
for(var i=0;i<keys.length;i++){
var entry=_sent[keys[i]];
if(entry&&entry.ts&&(now-entry.ts)>CLEANUP_MS){
log('Cleanup stale entry: '+keys[i]);
delete _sent[keys[i]];
}
}
},60000);
// ── Start observation ──
function startObserver(){ function startObserver(){
if(_obs)return; if(_obs)return;
new MutationObserver(function(){scan();}).observe(document.body,{childList:true,subtree:true}); // PRIMARY: MutationObserver — reacts instantly to DOM changes
setInterval(scan,2000); new MutationObserver(function(mutations){
_obs=true;log('Observer active — watching for approval buttons'); // Only scan if mutations contain added nodes (new buttons potentially)
for(var i=0;i<mutations.length;i++){
if(mutations[i].addedNodes.length>0){
scheduleScan();
return;
}
}
}).observe(document.body,{childList:true,subtree:true});
// FALLBACK: periodic scan every 3s for any missed mutations
setInterval(scheduleScan,3000);
_obs=true;
log('v2 Observer active — MutationObserver + 3s fallback');
} }
})(); })();
`; `;
@@ -508,17 +710,17 @@ const registeredSessions = new Set<string>(); // track which sessions have been
* Called automatically on first step event per session. * Called automatically on first step event per session.
*/ */
function writeRegistration(sessionId: string) { function writeRegistration(sessionId: string) {
if (registeredSessions.has(sessionId)) { return; }
registeredSessions.add(sessionId);
try { try {
const regDir = path.join(bridgePath, 'register'); const regDir = path.join(bridgePath, 'register');
if (!fs.existsSync(regDir)) { fs.mkdirSync(regDir, { recursive: true }); } 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 = { const data = {
conversation_id: sessionId, conversation_id: sessionId,
project_name: projectName, project_name: projectName,
timestamp: Date.now() / 1000, timestamp: Date.now() / 1000,
}; };
fs.writeFileSync(path.join(regDir, `${sessionId}.json`), JSON.stringify(data, null, 2), 'utf-8'); fs.writeFileSync(regFile, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Gravity Bridge: registered session ${sessionId.substring(0, 8)}${projectName}`); console.log(`Gravity Bridge: registered session ${sessionId.substring(0, 8)}${projectName}`);
} catch (e: any) { } catch (e: any) {
console.log(`Gravity Bridge: registration write error: ${e.message}`); console.log(`Gravity Bridge: registration write error: ${e.message}`);
@@ -546,7 +748,7 @@ function setupMonitor() {
// stepIndex on each → perfect for dedup // stepIndex on each → perfect for dedup
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
let pollCount = 0; let pollCount = 0;
let activeSessionId = ''; // activeSessionId is module-level (for writeChatSnapshot lazy registration)
let activeSessionTitle = ''; let activeSessionTitle = '';
let lastKnownStepCount = 0; let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1; let lastNotifyStepIndex = -1;
@@ -569,10 +771,26 @@ function setupMonitor() {
return; return;
} }
// ── Filter to sessions owned by THIS window ──
// Each window claims sessions it sees first via writeRegistration().
// Only process sessions registered to THIS projectName (or unclaimed ones).
let bestSession: any = null; let bestSession: any = null;
let bestSessionId = ''; let bestSessionId = '';
let bestModTime = ''; let bestModTime = '';
const regDir = path.join(bridgePath, 'register');
for (const [sid, data] of Object.entries(allTraj.trajectorySummaries) as [string, any][]) { for (const [sid, data] of Object.entries(allTraj.trajectorySummaries) as [string, any][]) {
// Check if this session is claimed by another project
const regFile = path.join(regDir, `${sid}.json`);
if (fs.existsSync(regFile)) {
try {
const reg = JSON.parse(fs.readFileSync(regFile, 'utf-8'));
if (reg.project_name && reg.project_name !== projectName) {
// Session belongs to another window — skip
continue;
}
} catch { }
}
const modTime = data.lastModifiedTime || ''; const modTime = data.lastModifiedTime || '';
if (!bestSession || modTime > bestModTime) { if (!bestSession || modTime > bestModTime) {
bestSession = data; bestSession = data;
@@ -594,7 +812,8 @@ function setupMonitor() {
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1; lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1; lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1;
lastPendingStepIndex = -1; lastPendingStepIndex = -1;
writeRegistration(activeSessionId); // Don't register here — registration happens lazily in writeChatSnapshot/writePendingApproval
// to avoid race conditions between multiple extension instances
console.log(`Gravity Bridge: [POLL#${pollCount}] session: ${activeSessionId.substring(0, 8)} "${currentTitle}" steps=${currentCount} ${isRunning ? 'RUNNING' : 'idle'}`); console.log(`Gravity Bridge: [POLL#${pollCount}] session: ${activeSessionId.substring(0, 8)} "${currentTitle}" steps=${currentCount} ${isRunning ? 'RUNNING' : 'idle'}`);
return; return;
} }
@@ -721,6 +940,18 @@ function setupResponseWatcher() {
if (filename && filename.endsWith('.json') && event === 'rename') { if (filename && filename.endsWith('.json') && event === 'rename') {
const fp = path.join(responseDir, filename); const fp = path.join(responseDir, filename);
if (fs.existsSync(fp)) { if (fs.existsSync(fp)) {
// Check if this response belongs to our project
const rid = filename.replace('.json', '');
const pendingFile = path.join(bridgePath, 'pending', `${rid}.json`);
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
if (pending.project_name && pending.project_name !== projectName) {
logToFile(`[RESPONSE] skip ${rid} (project=${pending.project_name}, we=${projectName})`);
return; // Not our project
}
} catch { }
}
setTimeout(() => processResponseFile(fp), 300); setTimeout(() => processResponseFile(fp), 300);
} }
} }
@@ -739,83 +970,79 @@ async function processResponseFile(filePath: string) {
console.log(`Gravity Bridge: ${msg}`); console.log(`Gravity Bridge: ${msg}`);
logToFile(msg); logToFile(msg);
// Find matching pending request for session_id // Find matching pending request
const pendingDir = path.join(bridgePath, 'pending'); const pendingDir = path.join(bridgePath, 'pending');
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`); const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
let sessionId = ''; let sessionId = '';
let isDomObserver = false;
if (fs.existsSync(pendingFile)) { if (fs.existsSync(pendingFile)) {
try { try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8')); const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
sessionId = pending.conversation_id || ''; sessionId = pending.conversation_id || '';
isDomObserver = pending.auto_detected === true && pending.source === 'dom_observer';
} catch { } } catch { }
} }
// ═══ APPROVAL STRATEGY (VS Code Commands Only) ═══ // ═══ APPROVAL STRATEGY ═══
// Phase 0 ResolveOutstandingSteps: REMOVED — confirmed it CANCELS steps! // DOM observer approvals: renderer handles clicking via pollResponse — skip VS Code commands
// Phase 1 HandleCascadeUserInteraction: REMOVED — always gets "socket hang up" // Stall-detection approvals: use VS Code commands as fallback (focus-dependent)
// Phase 2: ALL VS Code commands sequentially (no break on "success")
const approved = resp.approved; const approved = resp.approved;
// Focus panel with multiple attempts + longer delay if (isDomObserver) {
for (let i = 0; i < 2; i++) { // DOM observer path: renderer polls /response/:rid and clicks the button directly
try { logToFile(`[RESPONSE] DOM observer approval — renderer will handle click (rid=${resp.request_id})`);
await vscode.commands.executeCommand('antigravity.agentPanel.focus');
if (i === 0) logToFile('[RESPONSE] panel focus attempt 1');
} catch (e: any) {
logToFile(`[RESPONSE] panel focus attempt ${i + 1} failed: ${e.message}`);
}
await new Promise(r => setTimeout(r, 500));
}
// Phase 2: Sequential VS Code commands (MUST try ALL — no break!)
// Focus panel first
try {
await vscode.commands.executeCommand('antigravity.agentPanel.focus');
logToFile('[RESPONSE] panel focused');
} catch (e: any) {
logToFile(`[RESPONSE] panel focus failed: ${e.message}`);
}
await new Promise(r => setTimeout(r, 500));
if (approved) {
const approveCommands = [
'antigravity.interactiveCascade.acceptSuggestedAction',
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.command.accept',
'antigravity.agent.acceptAgentStep',
];
for (const cmd of approveCommands) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
} catch (e: any) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
}
} else { } else {
const rejectCommands = [ // Stall-detection path: use VS Code commands (legacy, focus-dependent)
'antigravity.interactiveCascade.rejectSuggestedAction', logToFile(`[RESPONSE] Stall-detection approval — using VS Code commands`);
'antigravity.terminalCommand.reject',
'antigravity.command.reject', // Focus panel
'antigravity.agent.rejectAgentStep', for (let i = 0; i < 2; i++) {
];
for (const cmd of rejectCommands) {
try { try {
await vscode.commands.executeCommand(cmd); await vscode.commands.executeCommand('antigravity.agentPanel.focus');
logToFile(`[RESPONSE] cmd OK: ${cmd}`); if (i === 0) logToFile('[RESPONSE] panel focus attempt 1');
} catch (e: any) { } catch (e: any) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd} ${e.message}`); logToFile(`[RESPONSE] panel focus attempt ${i + 1} failed: ${e.message}`);
}
await new Promise(r => setTimeout(r, 500));
}
if (approved) {
const approveCommands = [
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.command.accept',
'antigravity.agent.acceptAgentStep',
];
for (const cmd of approveCommands) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
} catch (e: any) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
}
} else {
const rejectCommands = [
'antigravity.terminalCommand.reject',
'antigravity.command.reject',
'antigravity.agent.rejectAgentStep',
];
for (const cmd of rejectCommands) {
try {
await vscode.commands.executeCommand(cmd);
logToFile(`[RESPONSE] cmd OK: ${cmd}`);
} catch (e: any) {
logToFile(`[RESPONSE] cmd FAIL: ${cmd}${e.message}`);
}
} }
} }
} }
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done`); logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'stall'})`);
// Cleanup // Cleanup response file (but NOT pending — renderer still polls it)
try { fs.unlinkSync(filePath); } catch { } try { fs.unlinkSync(filePath); } catch { }
try { if (fs.existsSync(pendingFile)) fs.unlinkSync(pendingFile); } catch { }
} catch (e: any) { } catch (e: any) {
const log = `[RESPONSE] error: ${e.message}`; const log = `[RESPONSE] error: ${e.message}`;
console.log(`Gravity Bridge: ${log}`); console.log(`Gravity Bridge: ${log}`);
@@ -910,6 +1137,8 @@ function writePendingApproval(data: { conversation_id: string; command: string;
}; };
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8'); fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
console.log(`Gravity Bridge: pending approval written → ${id}.json`); console.log(`Gravity Bridge: pending approval written → ${id}.json`);
// Register session → project mapping (correct because projectName is per-window)
if (data.conversation_id) { writeRegistration(data.conversation_id); }
} catch (e: any) { } catch (e: any) {
console.log(`Gravity Bridge: pending write error: ${e.message}`); console.log(`Gravity Bridge: pending write error: ${e.message}`);
} }