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

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;
}
}