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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
"name": "gravity-bridge",
|
||||
"displayName": "Gravity Bridge",
|
||||
"description": "Antigravity ↔ Discord 브리지 연동 확장",
|
||||
"version": "0.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
698
extension/src/observer-script.ts
Normal file
698
extension/src/observer-script.ts
Normal 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
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
114
extension/src/step-utils.ts
Normal 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
505
extension/src/ws-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user