refactor(extension): 모듈 분리 + Hub 통합 테스트 #task-395

- extension.ts 3,446→1,289줄 (-63%)
- step-probe.ts (1,435줄): setupMonitor, processResponseFile, tryApprovalStrategies
- observer-script.ts (687줄): DOM observer script
- ws-client.ts (390줄): WSBridgeClient
- step-utils.ts (114줄): step 파싱 유틸
- auth.py (115줄): JWT + registration code
- hub.py (581줄): WSHub + per-client queue
- Hub WS 연동 테스트 통과 (auth, chat, register)
- VSIX v0.4.0 빌드
This commit is contained in:
Variet Worker
2026-03-17 06:41:42 +09:00
parent a372bd8b2d
commit 5f795b9a91
19 changed files with 5426 additions and 5538 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"name": "gravity-bridge",
"displayName": "Gravity Bridge",
"description": "Antigravity ↔ Discord 브리지 연동 확장",
"version": "0.3.16",
"version": "0.4.0",
"publisher": "variet",
"engines": {
"vscode": "^1.100.0"
@@ -68,6 +68,16 @@
"type": "string",
"default": "",
"description": "프로젝트 이름 (기본: git remote 레포명)"
},
"gravityBridge.hubUrl": {
"type": "string",
"default": "",
"description": "WebSocket Hub URL (예: wss://your-server.com/ws)"
},
"gravityBridge.registrationCode": {
"type": "string",
"default": "",
"description": "Hub 등록 코드 (서버에서 발급)"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,698 @@
/**
* Approval Observer Script — injected into AG's renderer process.
*
* This is a self-contained JavaScript string template that runs in the
* browser context (no Node.js APIs). It scans the DOM for approval buttons,
* reports them to the HTTP bridge, and handles trigger clicks.
*
* Extracted from extension.ts for maintainability.
*/
export function generateApprovalObserverScript(_port: number): string {
// Port is hardcoded as fallback, but renderer also reads ag-bridge-ports.json for multi-bridge
return `
// ── Gravity Bridge v3: Approval Observer (deep DOM traversal — iframes, webviews, shadow DOMs) ──
(function(){
'use strict';
var BASE='',_obs=false,_sent={},_ready=false;
var _scanScheduled=false,_lastScanTs=0;
var THROTTLE_MS=100;
var CLEANUP_MS=300000;
var _domDumped=false;
function log(m){console.log('[GB Observer] '+m);}
log('v3 Script loaded — deep DOM traversal enabled');
// ── Deep DOM Traversal: find buttons across ALL boundaries ──
// Searches: main document → iframes (contentDocument) → webview elements → shadow DOMs
function deepFindButtons(patterns){
var results=[];
// 1. Main document buttons
collectButtons(document,results,patterns,'main');
// 2. Iframe traversal (try contentDocument — works if same-origin or webSecurity off)
var iframes=document.querySelectorAll('iframe');
for(var i=0;i<iframes.length;i++){
try{
var idoc=iframes[i].contentDocument||iframes[i].contentWindow.document;
if(idoc){collectButtons(idoc,results,patterns,'iframe#'+i+'('+iframes[i].className.substring(0,30)+')');}
}catch(e){
// Cross-origin — can't access. Log only on first dom dump
if(!_domDumped)log('iframe#'+i+' cross-origin: '+e.message.substring(0,60));
}
}
// 3. Webview elements (Electron <webview> tag — has executeJavaScript)
var webviews=document.querySelectorAll('webview');
for(var w=0;w<webviews.length;w++){
try{
var wvDoc=webviews[w].contentDocument;
if(wvDoc){collectButtons(wvDoc,results,patterns,'webview#'+w);}
}catch(e){
if(!_domDumped)log('webview#'+w+' access error: '+e.message.substring(0,60));
}
}
return results;
}
function collectButtons(doc,results,patterns,source){
if(!doc||!doc.querySelectorAll)return;
var btns=doc.querySelectorAll('button');
for(var i=0;i<btns.length;i++){
var b=btns[i];
if(b.disabled||b.hidden)continue;
try{if(!b.offsetParent&&b.style.display!=='fixed')continue;}catch(e){}
var txt=(b.textContent||'').trim();
if(!txt)continue;
for(var p=0;p<patterns.length;p++){
if(patterns[p].test(txt)){
results.push({btn:b,text:txt,source:source});
break;
}
}
}
// 4. Recurse into shadow DOMs
try{
var allEls=doc.querySelectorAll('*');
for(var j=0;j<allEls.length;j++){
var sr=allEls[j].shadowRoot;
if(sr)collectButtons(sr,results,patterns,source+'>shadow');
}
}catch(e){}
}
// ── Deep DOM Inspector (recursive, POSTs results to bridge) ──
function runDeepInspect(){
var result={timestamp:new Date().toISOString(),windowURL:window.location.href,windowOrigin:window.location.origin,windowProtocol:window.location.protocol,framesCount:window.frames.length,nodes:[]};
log('DEEP-INSPECT: starting recursive DOM analysis...');
function inspectDoc(doc,depth,label){
var node={label:label,depth:depth,accessible:true,url:'',buttons:[],roleBtns:[],iframes:[],webviews:[],shadowDOMs:0,totalElements:0};
if(!doc){node.accessible=false;node.error='null document';result.nodes.push(node);return;}
try{node.url=(doc.URL||doc.documentURI||'unknown').substring(0,200);}catch(e){node.url='blocked';}
try{node.title=(doc.title||'').substring(0,100);}catch(e){}
try{node.readyState=doc.readyState;}catch(e){}
// CSP
try{
var csp=doc.querySelectorAll('meta[http-equiv="Content-Security-Policy"]');
if(csp.length>0){node.csp=[];for(var c=0;c<csp.length;c++){node.csp.push((csp[c].content||'').substring(0,200));}}
}catch(e){}
try{
var allEls=doc.querySelectorAll('*');
node.totalElements=allEls.length;
// Buttons
var btns=doc.querySelectorAll('button');
for(var i=0;i<btns.length;i++){
var b=btns[i];
var txt=(b.textContent||'').trim().substring(0,80);
if(!txt)continue;
var cls=(b.className||'').substring(0,60);
var disabled=b.disabled;
var hidden=b.hidden||false;
try{if(!b.offsetParent&&b.style.display!=='fixed')hidden=true;}catch(e){}
var aria=b.getAttribute('aria-label')||'';
var ttl=b.getAttribute('title')||'';
node.buttons.push({text:txt,class:cls,disabled:disabled,hidden:hidden,aria:aria,title:ttl});
}
// role=button
var rbs=doc.querySelectorAll('[role="button"]');
for(var r=0;r<rbs.length;r++){
if(rbs[r].tagName==='BUTTON')continue;
var rtxt=(rbs[r].textContent||'').trim().substring(0,60);
node.roleBtns.push({tag:rbs[r].tagName.toLowerCase(),text:rtxt});
}
// Shadow DOMs
for(var s=0;s<allEls.length;s++){
var sr=allEls[s].shadowRoot;
if(sr){node.shadowDOMs++;inspectDoc(sr,depth+1,'shadow(<'+allEls[s].tagName.toLowerCase()+' class="'+(allEls[s].className||'').substring(0,30)+'">)');}
}
// Iframes
var ifs=doc.querySelectorAll('iframe');
for(var fi=0;fi<ifs.length;fi++){
var f=ifs[fi];
var finfo={index:fi,class:(f.className||'').substring(0,60),src:(f.src||'').substring(0,150),id:f.id||'',sandbox:f.getAttribute('sandbox')||'',allow:f.getAttribute('allow')||'',accessible:false,cwExists:false,cwFrames:0};
try{
var idoc=f.contentDocument||(f.contentWindow&&f.contentWindow.document);
if(idoc){finfo.accessible=true;inspectDoc(idoc,depth+1,'iframe#'+fi+'('+finfo.class.substring(0,30)+')');
}else{finfo.error='contentDocument=null';}
}catch(e){finfo.error=e.message.substring(0,80);}
try{var cw=f.contentWindow;if(cw){finfo.cwExists=true;finfo.cwFrames=cw.length;try{finfo.cwLocation=cw.location.href;}catch(e2){finfo.cwLocation='blocked: '+e2.message.substring(0,40);}}}
catch(e){}
node.iframes.push(finfo);
}
// Webviews
var wvs=doc.querySelectorAll('webview');
for(var wi=0;wi<wvs.length;wi++){
var wv=wvs[wi];
var winfo={index:wi,src:(wv.src||'').substring(0,150),class:(wv.className||'').substring(0,60),partition:wv.getAttribute('partition')||'',preload:wv.getAttribute('preload')||'',nodeintegration:wv.getAttribute('nodeintegration')||'',webpreferences:wv.getAttribute('webpreferences')||'',hasExecJS:typeof wv.executeJavaScript==='function',contentDocAccessible:false};
try{var wdoc=wv.contentDocument;if(wdoc){winfo.contentDocAccessible=true;inspectDoc(wdoc,depth+1,'webview#'+wi+'.contentDocument');}}catch(e){winfo.contentDocError=e.message.substring(0,60);}
node.webviews.push(winfo);
}
}catch(e){node.error=e.message;}
result.nodes.push(node);
return node;
}
inspectDoc(document,0,'MainDocument');
// Webview executeJavaScript probe (async)
var webviews=document.querySelectorAll('webview');
var probesPending=webviews.length;
result.webviewProbes=[];
if(probesPending===0)postResults();
for(var pw=0;pw<webviews.length;pw++){
(function(wv,idx){
if(typeof wv.executeJavaScript!=='function'){result.webviewProbes.push({index:idx,error:'executeJavaScript not available'});probesPending--;if(probesPending<=0)postResults();return;}
try{
wv.executeJavaScript('(function(){var btns=document.querySelectorAll("button");var allEls=document.querySelectorAll("*");var ifs=document.querySelectorAll("iframe");var wvs=document.querySelectorAll("webview");var btnArr=[];for(var i=0;i<btns.length;i++){var b=btns[i];var txt=(b.textContent||"").trim();var cls=(b.className||"").substring(0,50);var dis=b.disabled;var hid=b.hidden||!b.offsetParent;btnArr.push({text:txt.substring(0,60),class:cls,disabled:dis,hidden:hid,aria:b.getAttribute("aria-label")||"",title:b.getAttribute("title")||""});}var rbs=document.querySelectorAll("[role=button]");var rbArr=[];for(var j=0;j<rbs.length;j++){if(rbs[j].tagName!=="BUTTON")rbArr.push({tag:rbs[j].tagName.toLowerCase(),text:(rbs[j].textContent||"").trim().substring(0,40)});}var sc=0;for(var k=0;k<allEls.length;k++){if(allEls[k].shadowRoot)sc++;}return JSON.stringify({url:document.URL,title:document.title,totalElements:allEls.length,buttons:btnArr,roleBtns:rbArr,iframes:ifs.length,webviews:wvs.length,shadowDOMs:sc});})()')
.then(function(r){
try{var d=JSON.parse(r);result.webviewProbes.push({index:idx,success:true,data:d});log('DEEP-INSPECT: webview#'+idx+' probe OK: '+d.buttons.length+' buttons, '+d.totalElements+' elements');}catch(e){result.webviewProbes.push({index:idx,parseError:e.message,raw:r});}
probesPending--;if(probesPending<=0)postResults();
})
.catch(function(e){
result.webviewProbes.push({index:idx,execError:e.message});
log('DEEP-INSPECT: webview#'+idx+' execJS error: '+e.message);
probesPending--;if(probesPending<=0)postResults();
});
}catch(e){
result.webviewProbes.push({index:idx,callError:e.message});
probesPending--;if(probesPending<=0)postResults();
}
})(webviews[pw],pw);
}
function postResults(){
var summary='nodes='+result.nodes.length;
var totalBtns=0;for(var n=0;n<result.nodes.length;n++)totalBtns+=result.nodes[n].buttons.length;
summary+=' totalButtons='+totalBtns+' webviewProbes='+result.webviewProbes.length;
log('DEEP-INSPECT complete: '+summary);
// Also log buttons from each node
for(var n2=0;n2<result.nodes.length;n2++){
var nd=result.nodes[n2];
if(nd.buttons.length>0){
log(' '+nd.label+': '+nd.buttons.length+' buttons');
for(var bi=0;bi<Math.min(15,nd.buttons.length);bi++){
log(' ['+bi+'] "'+nd.buttons[bi].text+'"'+(nd.buttons[bi].disabled?' DISABLED':'')+(nd.buttons[bi].hidden?' HIDDEN':''));
}
}
}
// POST to bridge
fetch(BASE+'/deep-inspect-result',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(result)})
.then(function(){log('DEEP-INSPECT results posted to bridge');})
.catch(function(e){log('DEEP-INSPECT post error: '+e.message);});
}
}
// Auto-dump on startup (3s delay)
function dumpDOMStructure(){runDeepInspect();}
// ── Port Discovery: async fetch()-based (sync XHR blocked in Electron renderer) ──
var HARDCODED_PORT=${_port};
function tryPingAsync(port){
return fetch('http://127.0.0.1:'+port+'/ping?t='+Date.now(),{signal:AbortSignal.timeout(2000)})
.then(function(r){return r.text();})
.then(function(t){return t==='pong';})
.catch(function(){return false;});
}
function discoverPort(cb){
log('Trying hardcoded port '+HARDCODED_PORT+'...');
tryPingAsync(HARDCODED_PORT).then(function(ok){
if(ok){log('Port discovered (hardcoded): '+HARDCODED_PORT);cb(HARDCODED_PORT);return;}
log('Hardcoded port failed, retrying with backoff...');
var attempts=0;
var timer=setInterval(function(){
attempts++;
if(attempts>60){clearInterval(timer);log('Port discovery timeout after 2min');return;}
tryPingAsync(HARDCODED_PORT).then(function(ok2){
if(ok2){clearInterval(timer);log('Port discovered (retry #'+attempts+'): '+HARDCODED_PORT);cb(HARDCODED_PORT);}
});
},2000);
});
}
discoverPort(function(port){
BASE='http://127.0.0.1:'+port;
fetch(BASE+'/ping').then(function(r){return r.text();}).then(function(t){
if(t==='pong'){log('Bridge connected on port '+port);_ready=true;startObserver();setTimeout(dumpDOMStructure,3000);}
else log('Bridge ping failed: '+t);
}).catch(function(e){log('Bridge unreachable: '+e.message);});
});
// ── Button patterns to detect (order matters: first match wins per scan) ──
// ONLY positive triggers should initiate a pending request group.
// Negative/secondary buttons (Deny, Reject, Dismiss) will be collected as siblings.
var PATS=[
{re:/^Run/i, type:'terminal_command'},
{re:/^Accept all$/i, type:'diff_review'},
{re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'},
{re:/^Retry$/i, type:'error_recovery'},
];
// ALL actionable button patterns (for grouping siblings in same container)
var ALL_ACTION_RE=[/^Run/i,/^Accept/i,/^Reject/i,/^Allow/i,/^Deny/i,/^Approve/i,/^Cancel$/i,/^Retry$/i,/^Dismiss$/i,/^Stop$/i,/^Decline$/i];
// Reject button patterns for finding the counterpart
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i];
// ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
function btnId(b,type){
// Use: type + button text + parent's first 40 chars of text content
var txt=(b.textContent||'').trim();
var parent=b.parentElement;
var pctx=parent?(parent.textContent||'').substring(0,40).replace(/\\s+/g,' '):'';
// Also use DOM position: nth-child among sibling buttons
var idx=0;
if(parent){
var siblings=parent.querySelectorAll('button');
for(var i=0;i<siblings.length;i++){if(siblings[i]===b){idx=i;break;}}
}
return type+'|'+txt+'|'+idx+'|'+pctx.substring(0,20);
}
// ── Context extraction — walk up DOM to find command/code description ──
function extractContext(b){
// Strategy 1: Look for code/pre/terminal blocks near the button
var container=b.closest('[class*="step"]')
||b.closest('[class*="action"]')
||b.closest('[class*="tool"]')
||b.closest('[class*="cascade"]')
||b.closest('[class*="message"]');
if(!container)container=b.parentElement;
if(!container)return '';
// Look for code blocks
var codeEl=container.querySelector('pre,code,[class*="command"],[class*="terminal"],[class*="code-block"]');
if(codeEl){
var codeText=(codeEl.textContent||'').trim();
if(codeText.length>0)return codeText.substring(0,500);
}
// Strategy 2: Get surrounding text (exclude button text itself)
var full=(container.textContent||'');
var btnText=(b.textContent||'');
var desc=full.replace(btnText,'').trim();
// Trim to reasonable length
return desc.substring(0,500);
}
// ── Find common container of related buttons ──
function findButtonContainer(btn){
return btn.closest('[class*="step"]')
||btn.closest('[class*="action"]')
||btn.closest('[class*="tool"]')
||btn.closest('[class*="cascade"]')
||btn.closest('[class*="message"]')
||btn.closest('[class*="dialog"]')
||btn.closest('[class*="notification"]')
||btn.parentElement;
}
// ── Collect all actionable sibling buttons from a container ──
function collectSiblingButtons(container,triggerBtn){
if(!container)return [];
var siblings=container.querySelectorAll('button');
var result=[];
for(var i=0;i<siblings.length;i++){
var sb=siblings[i];
if(sb.disabled||sb.hidden)continue;
try{if(!sb.offsetParent&&sb.style.display!=='fixed')continue;}catch(e){}
var stxt=(sb.textContent||'').trim();
stxt=stxt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
if(!stxt)continue;
// Check if this button matches any actionable pattern
var isAction=false;
for(var a=0;a<ALL_ACTION_RE.length;a++){
if(ALL_ACTION_RE[a].test(stxt)){isAction=true;break;}
}
if(!isAction)continue;
result.push({btn:sb,text:stxt,isPrimary:(sb===triggerBtn)});
}
return result;
}
// ── Find the React app container (Antigravity's main UI root) ──
function findPanel(){
// Priority order of panel selectors (most specific first)
var selectors=[
'.antigravity-agent-side-panel',
'#jetski-agent-panel',
'.react-app-container',
'[class*="agent-panel"]',
'[class*="agentPanel"]',
];
for(var i=0;i<selectors.length;i++){
var el=document.querySelector(selectors[i]);
if(el)return el;
}
return null;
}
// ── Core scan — finds actionable buttons and reports to bridge ──
// Groups related buttons from same container into a single pending
function scan(){
if(!_ready)return;
var now=Date.now();
var panel=findPanel();
// Expand search: panel-scoped first, then full body for review bars
var searchRoots=[];
if(panel)searchRoots.push(panel);
// Always also scan body for diff review bar (Accept all/Reject all)
// which lives outside the agent panel in the editor notification area
if(document.body)searchRoots.push(document.body);
if(!searchRoots.length)return;
var seen={}; // dedupe buttons across search roots
for(var r=0;r<searchRoots.length;r++){
var allBtns=searchRoots[r].querySelectorAll('button');
if(!allBtns.length)continue;
for(var j=0;j<allBtns.length;j++){
var b=allBtns[j];
if(b.disabled||b.hidden)continue;
// Check visibility (offsetParent null = hidden via CSS)
if(!b.offsetParent&&b.style.display!=='fixed')continue;
var txt=(b.textContent||'').trim();
if(!txt)continue;
// Strip keyboard shortcut suffixes (e.g. "RunAlt+↵" → "Run")
txt=txt.replace(/(Alt|Ctrl|Shift|Meta)\+.*/,'').trim();
if(!txt)continue;
// Match against patterns
var matchedType=null;
for(var p=0;p<PATS.length;p++){
if(PATS[p].re.test(txt)){matchedType=PATS[p].type;break;}
}
if(!matchedType)continue;
// Generate stable ID for the GROUP (use container-based key)
var container=findButtonContainer(b);
var groupKey=matchedType+'|group|'+(container?(container.textContent||'').substring(0,40).replace(/\\s+/g,' '):'none');
if(_sent[groupKey])continue;
// Collect ALL related buttons from the same container
var siblings=collectSiblingButtons(container,b);
if(siblings.length===0)siblings=[{btn:b,text:txt,isPrimary:true}];
// Build buttons array for multi-choice support
var buttonsArr=[];
var btnRefs=[];
var bidList=[];
for(var si=0;si<siblings.length;si++){
var sb=siblings[si];
var sbid=btnId(sb.btn,matchedType);
buttonsArr.push({text:sb.text,index:si,is_primary:sb.isPrimary});
btnRefs.push(sb.btn);
bidList.push(sbid);
}
// Extract context from trigger button
var desc=extractContext(b);
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
// Mark entire group as sent
_sent[groupKey]={rid:rid,ts:now};
for(var mk=0;mk<bidList.length;mk++){_sent[bidList[mk]]={rid:rid,ts:now};}
log('DETECTED GROUP '+matchedType+': '+buttonsArr.map(function(x){return '"'+x.text+'"';}).join(', ')+' → pending to bridge');
// Send to bridge (closure to capture refs)
(function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
var payload={
request_id:rid2,
command:txt2,
description:desc2,
step_type:type2,
buttons:buttonsArr2
};
fetch(BASE+'/pending',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload)
}).then(function(r){return r.json();}).then(function(d){
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
}).catch(function(e){
log('POST error: '+e.message);
delete _sent[groupKey2];
for(var di=0;di<bidList2.length;di++){delete _sent[bidList2[di]];}
});
})(rid,btnRefs,bidList,groupKey,txt,desc,matchedType,buttonsArr);
// Process ONE button GROUP per scan cycle (avoid flooding)
return;
}
} // end searchRoots loop
}
// ── Poll for Discord response (multi-button group aware) ──
function pollResponseGroup(rid,btnRefs,bidList,groupKey){
var polls=0;
var maxPolls=200; // 5 minutes at 1500ms interval
var timer=setInterval(function(){
polls++;
// Check if ANY button in the group is still in DOM
var anyAlive=false;
for(var ai=0;ai<btnRefs.length;ai++){
if(document.body.contains(btnRefs[ai])){anyAlive=true;break;}
}
if(!anyAlive){
log('All buttons removed from DOM — stopping poll for '+rid);
clearInterval(timer);
delete _sent[groupKey];
for(var ci=0;ci<bidList.length;ci++){delete _sent[bidList[ci]];}
return;
}
if(polls>maxPolls){
log('Poll timeout for '+rid);
clearInterval(timer);
delete _sent[groupKey];
for(var ti=0;ti<bidList.length;ti++){delete _sent[bidList[ti]];}
return;
}
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
if(d.waiting)return;
clearInterval(timer);
var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
if(btnIdx>=0&&btnIdx<btnRefs.length){
// Multi-choice: click specific button by index
var targetBtn=btnRefs[btnIdx];
var targetTxt=(targetBtn.textContent||'').trim();
log((d.approved?'✅':'❌')+' CHOICE '+rid+' → clicking button['+btnIdx+'] "'+targetTxt+'"');
targetBtn.click();
} else if(d.approved){
// Legacy single-button: click first (primary) button
var primaryBtn=btnRefs[0];
log('✅ APPROVED '+rid+' → clicking "'+((primaryBtn.textContent||'').trim())+'"');
primaryBtn.click();
} else {
// Legacy reject: find and click reject/deny button
log('❌ REJECTED '+rid+' → finding reject button');
clickRejectButton(btnRefs[0]);
}
delete _sent[groupKey];
for(var ri=0;ri<bidList.length;ri++){delete _sent[bidList[ri]];}
}).catch(function(){});
},1500);
}
// Legacy pollResponse for backward compatibility (single button)
function pollResponse(rid,btn,bid){
pollResponseGroup(rid,[btn],[bid],bid);
}
// ── Find and click the reject/cancel counterpart button ──
function clickRejectButton(approveBtn){
// Walk up to find the container, then search for reject buttons
var container=approveBtn.closest('[class*="step"]')
||approveBtn.closest('[class*="action"]')
||approveBtn.closest('[class*="tool"]')
||approveBtn.parentElement;
if(!container){log('No container for reject');return;}
var siblings=container.querySelectorAll('button');
for(var i=0;i<siblings.length;i++){
var t=(siblings[i].textContent||'').trim();
for(var r=0;r<REJECT_RE.length;r++){
if(REJECT_RE[r].test(t)){
log('Clicking reject: "'+t+'"');
siblings[i].click();
return;
}
}
}
log('No reject button found near approve button');
}
// ── Throttled scan — leading-edge: fires immediately, then locks ──
function scheduleScan(){
if(!_ready)return;
var now=Date.now();
if(now-_lastScanTs>=THROTTLE_MS){
_lastScanTs=now;
scan();
} else if(!_scanScheduled){
_scanScheduled=true;
setTimeout(function(){
_scanScheduled=false;
_lastScanTs=Date.now();
scan();
},THROTTLE_MS-(now-_lastScanTs));
}
}
// ── Periodic cleanup of stale _sent entries ──
setInterval(function(){
var now=Date.now();
var keys=Object.keys(_sent);
for(var i=0;i<keys.length;i++){
var entry=_sent[keys[i]];
if(entry&&entry.ts&&(now-entry.ts)>CLEANUP_MS){
log('Cleanup stale entry: '+keys[i]);
delete _sent[keys[i]];
}
}
},60000);
// ── Start observation ──
function startObserver(){
if(_obs)return;
// PRIMARY: MutationObserver — reacts instantly to DOM changes
new MutationObserver(function(mutations){
// Only scan if mutations contain added nodes (new buttons potentially)
for(var i=0;i<mutations.length;i++){
if(mutations[i].addedNodes.length>0){
scheduleScan();
return;
}
}
}).observe(document.body,{childList:true,subtree:true});
// FALLBACK: periodic scan every 3s for any missed mutations
setInterval(scheduleScan,3000);
// ── Adaptive idle detection for HTTP polls ──
var _lastActivity=Date.now();
var _idleThreshold=60000; // 60s without DOM changes → slow mode
new MutationObserver(function(){_lastActivity=Date.now();}).observe(document.body,{childList:true,subtree:true,attributes:true});
function getAdaptiveInterval(){return (Date.now()-_lastActivity>_idleThreshold)?10000:2000;}
// ── DEEP-INSPECT POLLING: curl→Bridge→Renderer→Results ──
(function pollDeepInspect(){
if(_ready&&BASE){
fetch(BASE+'/deep-inspect-trigger?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
if(d.inspect){log('🔍 Deep inspect triggered via HTTP');runDeepInspect();}
}).catch(function(){});
}
setTimeout(pollDeepInspect,getAdaptiveInterval());
})();
// ── TRIGGER-CLICK: Extension→Renderer bridge for programmatic button clicks ──
// Extension sets clickTrigger via tryApprovalStrategies → renderer polls and clicks
// v3: uses deepFindButtons() to traverse iframes, webviews, shadow DOMs
(function pollTriggerClick(){
if(_ready&&BASE){
fetch(BASE+'/trigger-click?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
if(!d.action)return;
log('🔔 TRIGGER-CLICK received: action='+d.action);
var approveRe=[/^Run$/i,/^Run /i,/^Accept/i,/^Allow/i,/^Approve/i,/^Continue$/i,/^Proceed$/i,/^Retry$/i];
var rejectRe=[/^Reject/i,/^Cancel$/i,/^Deny$/i,/^Stop$/i,/^Decline$/i,/^Dismiss$/i];
var patterns=(d.action==='approve')?approveRe:rejectRe;
var emoji=(d.action==='approve')?'✅':'❌';
// Phase 1: deepFindButtons in main doc + accessible iframes + shadow DOMs
var found=deepFindButtons(patterns);
if(found.length>0){
log(emoji+' TRIGGER-CLICK: clicking "'+found[0].text+'" from '+found[0].source);
found[0].btn.click();
return;
}
// Phase 2: Try <webview>.executeJavaScript for inaccessible webviews
var webviews=document.querySelectorAll('webview');
if(webviews.length>0){
log('TRIGGER-CLICK: trying '+webviews.length+' webview(s) via executeJavaScript...');
var patternsStr=patterns.map(function(re){return re.source;}).join('|');
var clickScript='(function(){'+
'var re=new RegExp("'+patternsStr+'","i");'+
'var btns=document.querySelectorAll("button");'+
'for(var i=0;i<btns.length;i++){'+
'var b=btns[i];if(b.disabled||b.hidden)continue;'+
'var t=(b.textContent||"").trim();'+
'if(re.test(t)){b.click();return "CLICKED:"+t;}'+
'}'+
'return "NOT_FOUND:"+btns.length+"_buttons";'+
'})()';
for(var w=0;w<webviews.length;w++){
(function(wv,idx){
try{
if(typeof wv.executeJavaScript==='function'){
wv.executeJavaScript(clickScript).then(function(result){
log(emoji+' TRIGGER-CLICK webview#'+idx+': '+result);
}).catch(function(e){
log('TRIGGER-CLICK webview#'+idx+' execJS error: '+e.message);
});
}
}catch(e){
log('TRIGGER-CLICK webview#'+idx+' error: '+e.message);
}
})(webviews[w],w);
}
}
// Phase 3: Try iframes via postMessage (cross-origin fallback)
var iframes=document.querySelectorAll('iframe');
if(iframes.length>0){
log('TRIGGER-CLICK: trying '+iframes.length+' iframe(s) — checking accessibility...');
var clickedAny=false;
for(var fi=0;fi<iframes.length;fi++){
try{
var idoc=iframes[fi].contentDocument||iframes[fi].contentWindow.document;
if(!idoc)continue;
var ibtns=idoc.querySelectorAll('button');
for(var bi=0;bi<ibtns.length;bi++){
var ib=ibtns[bi];
if(ib.disabled||ib.hidden)continue;
var itxt=(ib.textContent||'').trim();
for(var pi=0;pi<patterns.length;pi++){
if(patterns[pi].test(itxt)){
log(emoji+' TRIGGER-CLICK iframe#'+fi+': clicking "'+itxt+'"');
ib.click();
clickedAny=true;
return;
}
}
}
}catch(e){}
}
}
if(!found.length){
// Log what we DID find for debugging
var allBtns=document.querySelectorAll('button');
var btnTexts=[];
for(var di=0;di<Math.min(10,allBtns.length);di++){
btnTexts.push('"'+((allBtns[di].textContent||'').trim()).substring(0,30)+'"');
}
log('⚠️ TRIGGER-CLICK: no '+d.action+' button found. Main DOM has '+allBtns.length+' btns: ['+btnTexts.join(',')+']');
log('⚠️ iframes='+document.querySelectorAll('iframe').length+' webviews='+document.querySelectorAll('webview').length);
}
}).catch(function(){});
}
setTimeout(pollTriggerClick,getAdaptiveInterval());
})();
_obs=true;
log('v3 Observer active — deep DOM traversal + MutationObserver + trigger-click polling');
}
})();
`;
}

1435
extension/src/step-probe.ts Normal file

File diff suppressed because it is too large Load Diff

114
extension/src/step-utils.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Step Utilities — pure functions for parsing step data, planner responses,
* and tool call information. No external state dependencies.
*
* Extracted from extension.ts for maintainability.
*/
export function extractPlannerText(step: any): string | null {
if (!step) { return null; }
// Fields to SKIP — not user-facing content
const SKIP_FIELDS = new Set([
'thinking', 'thinkingSignature', 'stopReason', 'type', 'status', 'metadata',
'ephemeralMessage', 'generatorModel', 'requestedModel',
'executionId', 'sourceTrajectoryStepInfo', 'stepIndex',
'viewableAt', 'createdAt', 'finishedGeneratingAt',
'lastCompletedChunkAt', 'source', 'stepGenerationVersion'
]);
// plannerResponse can be string or object
const pr = step.plannerResponse;
if (typeof pr === 'string' && pr.length > 10) {
return filterEphemeral(pr);
}
if (pr && typeof pr === 'object') {
// Try known content fields first (NOT thinking/stopReason)
const text = pr.content || pr.text || pr.summary || pr.message || pr.response || pr.output;
if (typeof text === 'string' && text.length > 10) {
return filterEphemeral(text);
}
// Search other fields, but skip non-content ones
for (const key of Object.keys(pr)) {
if (SKIP_FIELDS.has(key)) continue;
const val = pr[key];
if (typeof val === 'string' && val.length > 50) { // Higher threshold
const filtered = filterEphemeral(val);
if (filtered) {
console.log(`Gravity Bridge: [DEBUG] planner text in plannerResponse.${key} (${filtered.length} chars)`);
return filtered;
}
}
}
}
// Try other step fields (skip known non-content)
for (const key of Object.keys(step)) {
if (SKIP_FIELDS.has(key) || key === 'plannerResponse') continue;
const val = step[key];
if (typeof val === 'string' && val.length > 50) {
const filtered = filterEphemeral(val);
if (filtered) {
console.log(`Gravity Bridge: [DEBUG] planner text in step.${key}`);
return filtered;
}
}
}
return null;
}
/** Filter out system ephemeral messages and non-content strings. */
export function filterEphemeral(text: string): string | null {
if (!text || text.length < 10) { return null; }
// Skip system prompt metadata
if (text.includes('<EPHEMERAL_MESSAGE>') || text.includes('<ephemeral_message>')) { return null; }
if (text.includes('artifact_reminder') || text.includes('active_task_reminder')) { return null; }
if (text.includes('no_active_task_reminder')) { return null; }
// Skip base64/crypto strings (no spaces, mostly alphanumeric)
if (!text.includes(' ') && /^[A-Za-z0-9+/=_\-]{50,}$/.test(text)) { return null; }
return text;
}
/** Extract human-readable command from a tool call step's data. */
export function extractToolCommand(stepData: any): string {
// Try common step data shapes from protobuf
if (stepData.runCommand) {
return stepData.runCommand.commandLine || stepData.runCommand.command || 'Run Command';
}
if (stepData.writeToFile) {
const target = stepData.writeToFile.targetFile || stepData.writeToFile.filePath || 'file';
return `Write: ${target.split(/[\\/]/).pop()}`;
}
if (stepData.codeAction) {
const fp = stepData.codeAction.filePath || '';
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
}
if (stepData.replaceFileContent || stepData.multiReplaceFileContent) {
const d = stepData.replaceFileContent || stepData.multiReplaceFileContent;
const fp = d.targetFile || d.filePath || '';
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
}
if (stepData.sendCommandInput) {
return `Send Input: ${(stepData.sendCommandInput.input || '').substring(0, 50)}`;
}
// Generic fallback: use first key name
const keys = Object.keys(stepData).filter(k => k !== 'status' && k !== 'stepStatus');
return keys.length > 0 ? keys[0] : 'Unknown tool call';
}
/** Extract description from a tool call step for Discord display. */
export function extractToolDescription(stepData: any, sessionTitle: string, stepIndex: number): string {
const parts = [`Step #${stepIndex}`, `Session: "${sessionTitle}"`];
// Try to get code/command content for context
if (stepData.runCommand) {
const cmd = stepData.runCommand.commandLine || stepData.runCommand.command || '';
if (cmd) parts.push(`Command: ${cmd.substring(0, 200)}`);
}
if (stepData.writeToFile?.targetFile) {
parts.push(`File: ${stepData.writeToFile.targetFile}`);
}
if (stepData.codeAction?.filePath) {
parts.push(`File: ${stepData.codeAction.filePath}`);
}
return parts.join('\n');
}

505
extension/src/ws-client.ts Normal file
View File

@@ -0,0 +1,505 @@
/**
* WebSocket Bridge Client — connects Extension to the Hub server.
*
* Replaces file-based IPC for:
* - Pending approvals (Extension → Hub → Bot → Discord)
* - User responses (Discord → Bot → Hub → Extension)
* - Chat snapshots (Extension → Hub → Bot → Discord)
* - Commands (Discord → Bot → Hub → Extension)
* - Session registration
* - Auto-resolve notifications
*
* Features:
* - Exponential backoff + jitter reconnection
* - Message queue (survives reconnection)
* - Heartbeat ping/pong
* - First-message JWT authentication
*/
import * as vscode from 'vscode';
// ─── Types ───
export interface WSMessage {
type: string;
data?: any;
msg_id?: string;
}
export interface WSAuthMessage {
type: 'auth';
token?: string;
registration_code?: string;
project: string;
pc: string;
}
export interface WSAuthOkResponse {
type: 'auth_ok';
conn_id: string;
instance_number: number;
session_token: string;
active_count: number;
}
export interface WSPendingData {
request_id: string;
command: string;
description?: string;
step_type?: string;
status?: string;
buttons?: Array<{ text: string; index: number }>;
project_name?: string;
// diff_review metadata
edit_step_indices?: number[];
modified_files?: string[];
}
export interface WSResponseData {
request_id: string;
approved: boolean;
button_index?: number;
step_type?: string;
project_name?: string;
}
export interface WSCommandData {
text: string;
project_name?: string;
action?: string;
}
export interface WSChatData {
content: string;
attached_files?: Array<{ name: string; content: string }>;
conversation_id?: string;
project_name?: string;
}
export interface WSRegisterData {
conversation_id: string;
project_name: string;
}
// ─── Event Handlers ───
export interface WSBridgeHandlers {
onResponse?: (data: WSResponseData) => void;
onCommand?: (data: WSCommandData) => void;
onInstanceUpdate?: (activeCount: number, instances: Array<{ instance_number: number; pc: string }>) => void;
onConnected?: (connId: string, instanceNumber: number, sessionToken: string) => void;
onDisconnected?: () => void;
onError?: (error: string) => void;
}
// ─── Constants ───
const INITIAL_RECONNECT_DELAY = 1000; // 1s
const MAX_RECONNECT_DELAY = 60000; // 60s
const RECONNECT_JITTER = 0.3; // ±30%
const HEARTBEAT_INTERVAL = 25000; // 25s (server expects 30s)
const MAX_QUEUE_SIZE = 200;
const AUTH_TIMEOUT = 10000; // 10s
// ─── WSBridgeClient ───
export class WSBridgeClient {
private ws: any = null; // WebSocket instance (Node.js ws module)
private hubUrl: string;
private registrationCode: string;
private project: string;
private pcName: string;
private handlers: WSBridgeHandlers;
private logFn: (msg: string) => void;
// Connection state
private connected = false;
private authenticated = false;
private connId = '';
private instanceNumber = 0;
private sessionToken = '';
private shouldReconnect = true;
private reconnectDelay = INITIAL_RECONNECT_DELAY;
private reconnectTimer: NodeJS.Timeout | null = null;
private heartbeatTimer: NodeJS.Timeout | null = null;
private authTimer: NodeJS.Timeout | null = null;
// Message queue (survives reconnection)
private messageQueue: WSMessage[] = [];
private msgIdCounter = 0;
constructor(
hubUrl: string,
registrationCode: string,
project: string,
pcName: string,
handlers: WSBridgeHandlers,
logFn: (msg: string) => void,
) {
this.hubUrl = hubUrl;
this.registrationCode = registrationCode;
this.project = project;
this.pcName = pcName;
this.handlers = handlers;
this.logFn = logFn;
}
// ─── Public API ───
/** Start the WebSocket connection. */
async connect(): Promise<void> {
if (!this.hubUrl) {
this.logFn('[WS] No hub URL configured — WS disabled');
return;
}
this.shouldReconnect = true;
await this._connect();
}
/** Gracefully disconnect. */
disconnect(): void {
this.shouldReconnect = false;
this._cleanup();
this.logFn('[WS] Disconnected (intentional)');
}
/** Check if connected and authenticated. */
isConnected(): boolean {
return this.connected && this.authenticated;
}
/** Get the instance number assigned by the Hub. */
getInstanceNumber(): number {
return this.instanceNumber;
}
/** Send a pending approval to the Hub. */
sendPending(data: WSPendingData): boolean {
return this._send({ type: 'pending', data });
}
/** Send a chat snapshot to the Hub. */
sendChat(data: WSChatData): boolean {
return this._send({ type: 'chat', data });
}
/** Send a session registration. */
sendRegister(data: WSRegisterData): boolean {
return this._send({ type: 'register', data });
}
/** Send an auto_resolve notification. */
sendAutoResolve(requestId: string): boolean {
return this._send({ type: 'auto_resolve', data: { request_id: requestId } });
}
/** Send a brain event. */
sendBrainEvent(data: any): boolean {
return this._send({ type: 'brain_event', data });
}
// ─── Internal Connection ───
private async _connect(): Promise<void> {
try {
// Dynamic import of ws module (Node.js built-in or npm package)
const WebSocket = await this._getWebSocketClass();
if (!WebSocket) {
this.logFn('[WS] WebSocket module not available');
return;
}
this.logFn(`[WS] Connecting to ${this.hubUrl}...`);
const ws = new WebSocket(this.hubUrl);
ws.on('open', () => {
this.logFn('[WS] Connection opened, authenticating...');
this.ws = ws;
this.connected = true;
this._authenticate();
});
ws.on('message', (raw: Buffer | string) => {
try {
const data = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf-8'));
this._handleMessage(data);
} catch (e: any) {
this.logFn(`[WS] Parse error: ${e.message}`);
}
});
ws.on('close', (code: number, reason: Buffer) => {
const reasonStr = reason ? reason.toString('utf-8') : '';
this.logFn(`[WS] Connection closed: code=${code} reason=${reasonStr}`);
this._onDisconnect();
});
ws.on('error', (err: Error) => {
this.logFn(`[WS] Error: ${err.message}`);
// close event will follow
});
ws.on('pong', () => {
// Server responded to our ping — connection is alive
});
} catch (e: any) {
this.logFn(`[WS] Connect failed: ${e.message}`);
this._scheduleReconnect();
}
}
private async _getWebSocketClass(): Promise<any> {
try {
// Try Node.js built-in WebSocket (v21+)
if (typeof globalThis.WebSocket !== 'undefined') {
return globalThis.WebSocket;
}
// Try require('ws') — should be available in VS Code's Node.js
const ws = require('ws');
return ws;
} catch {
// ws module not available
try {
// Fallback: try the built-in undici WebSocket
const { WebSocket } = require('undici');
return WebSocket;
} catch {
return null;
}
}
}
// ─── Authentication ───
private _authenticate(): void {
if (!this.ws) return;
const authMsg: WSAuthMessage = {
type: 'auth',
project: this.project,
pc: this.pcName,
};
// Use session token if available (from previous connection)
if (this.sessionToken) {
authMsg.token = this.sessionToken;
} else if (this.registrationCode) {
authMsg.registration_code = this.registrationCode;
}
this._sendRaw(authMsg);
// Timeout for auth response
this.authTimer = setTimeout(() => {
if (!this.authenticated) {
this.logFn('[WS] Auth timeout — closing connection');
this._cleanup();
this._scheduleReconnect();
}
}, AUTH_TIMEOUT);
}
// ─── Message Handling ───
private _handleMessage(msg: WSMessage): void {
switch (msg.type) {
case 'auth_ok': {
const authOk = msg as unknown as WSAuthOkResponse;
this.authenticated = true;
this.connId = authOk.conn_id;
this.instanceNumber = authOk.instance_number;
this.sessionToken = authOk.session_token;
this.reconnectDelay = INITIAL_RECONNECT_DELAY;
if (this.authTimer) {
clearTimeout(this.authTimer);
this.authTimer = null;
}
this.logFn(`[WS] Authenticated: conn=${this.connId} instance=#${this.instanceNumber} active=${authOk.active_count}`);
this._startHeartbeat();
this._flushQueue();
this.handlers.onConnected?.(this.connId, this.instanceNumber, this.sessionToken);
break;
}
case 'auth_fail': {
const reason = (msg as any).reason || 'Unknown';
this.logFn(`[WS] Auth failed: ${reason}`);
// Clear session token if it was rejected
this.sessionToken = '';
this._cleanup();
// Don't reconnect on auth failure (needs manual fix)
this.handlers.onError?.(`Auth failed: ${reason}`);
break;
}
case 'response': {
const data = msg.data as WSResponseData;
if (data) {
this.logFn(`[WS] Response received: ${data.request_id?.substring(0, 12)} approved=${data.approved}`);
this.handlers.onResponse?.(data);
}
break;
}
case 'command': {
const data = msg.data as WSCommandData;
if (data) {
this.logFn(`[WS] Command received: ${data.text?.substring(0, 50)}`);
this.handlers.onCommand?.(data);
}
break;
}
case 'instance_update': {
const activeCount = (msg as any).active_count || 0;
const instances = (msg as any).instances || [];
this.logFn(`[WS] Instance update: ${activeCount} active`);
this.handlers.onInstanceUpdate?.(activeCount, instances);
break;
}
case 'error': {
const error = (msg as any).error || 'Unknown error';
this.logFn(`[WS] Server error: ${error}`);
this.handlers.onError?.(error);
break;
}
default:
this.logFn(`[WS] Unknown message type: ${msg.type}`);
}
}
// ─── Send ───
private _send(msg: WSMessage): boolean {
// Add unique message ID for dedup
msg.msg_id = `${this.project}-${Date.now()}-${++this.msgIdCounter}`;
if (this.isConnected()) {
return this._sendRaw(msg);
}
// Queue for later
if (this.messageQueue.length >= MAX_QUEUE_SIZE) {
// Drop oldest
this.messageQueue.shift();
this.logFn('[WS] Queue full — dropped oldest message');
}
this.messageQueue.push(msg);
this.logFn(`[WS] Queued message (type=${msg.type}, queue=${this.messageQueue.length})`);
return false;
}
private _sendRaw(msg: any): boolean {
try {
if (this.ws && this.connected) {
this.ws.send(JSON.stringify(msg));
return true;
}
return false;
} catch (e: any) {
this.logFn(`[WS] Send error: ${e.message}`);
return false;
}
}
private _flushQueue(): void {
if (this.messageQueue.length === 0) return;
this.logFn(`[WS] Flushing ${this.messageQueue.length} queued messages`);
const queue = [...this.messageQueue];
this.messageQueue = [];
for (const msg of queue) {
this._sendRaw(msg);
}
}
// ─── Heartbeat ───
private _startHeartbeat(): void {
this._stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.connected) {
try {
this.ws.ping();
} catch {
// ping failure will trigger close event
}
}
}, HEARTBEAT_INTERVAL);
}
private _stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// ─── Reconnection ───
private _onDisconnect(): void {
const wasAuthenticated = this.authenticated;
this.connected = false;
this.authenticated = false;
this.ws = null;
this._stopHeartbeat();
if (this.authTimer) {
clearTimeout(this.authTimer);
this.authTimer = null;
}
if (wasAuthenticated) {
this.handlers.onDisconnected?.();
}
if (this.shouldReconnect) {
this._scheduleReconnect();
}
}
private _scheduleReconnect(): void {
if (this.reconnectTimer) return;
// Exponential backoff with jitter
const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER;
const delay = Math.min(this.reconnectDelay * jitter, MAX_RECONNECT_DELAY);
this.logFn(`[WS] Reconnecting in ${Math.round(delay)}ms...`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
this._connect();
}, delay);
}
// ─── Cleanup ───
private _cleanup(): void {
this._stopHeartbeat();
if (this.authTimer) {
clearTimeout(this.authTimer);
this.authTimer = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
try {
this.ws.close();
} catch { }
this.ws = null;
}
this.connected = false;
this.authenticated = false;
}
}