fix(bridge): approval ENOENT race condition + multi-choice button grouping #task-276 #task-277

This commit is contained in:
2026-03-10 06:32:20 +09:00
parent 373c0f7ddc
commit aab1cfba27
8 changed files with 371 additions and 77 deletions

View File

@@ -284,3 +284,16 @@
- **원인**: Discord.py View의 기본 timeout이 300초(5분)로 설정됨 - **원인**: Discord.py View의 기본 timeout이 300초(5분)로 설정됨
- **해결**: timeout을 1800초(30분)로 증가 - **해결**: timeout을 1800초(30분)로 증가
- **주의**: Discord View timeout은 서버 재시작 후만 적용. 기존 메시지의 View는 이미 만료됨 - **주의**: Discord View timeout은 서버 재시작 후만 적용. 기존 메시지의 View는 이미 만료됨
### [2026-03-10] DOM Observer 승인 ENOENT — response 파일 Race Condition
- **증상**: Discord에서 ✅승인 → extension.log에 `ENOENT: response/xxx.json` 에러 → AG에서 Run 미실행 (간헐적)
- **원인**: `processResponseFile()` (response watcher)이 response 파일을 읽고 즉시 `fs.unlinkSync()` → renderer `pollResponse()``GET /response/:rid`로 조회할 때 파일 이미 없음
- **해결**: DOM observer 경로(`isDomObserver`)일 때 `processResponseFile()`에서 response 파일을 삭제하지 않고, HTTP handler(`GET /response/:rid`)가 renderer에 전달 후 삭제. step_probe 경로는 `clickTrigger`로 처리하므로 기존처럼 삭제 OK
- **주의**: DOM observer와 step_probe 두 경로가 독립적으로 pending을 생성할 수 있으므로, response 삭제 타이밍을 경로별로 구분해야 함
### [2026-03-10] Allow Once + Allow This Conversation — 별개 pending으로 분리되는 문제
- **증상**: 파일 접근 시 Allow Once와 Allow This Conversation이 Discord에 2개 별도 메시지로 도착. Deny 선택지 없음
- **원인**: renderer `scan()`이 한 사이클에 한 버튼만 처리 후 `return`. 각 버튼이 별도 pending으로 전송. Deny는 PATS 패턴에 미등록
- **해결**: (1) `scan()`에서 `findButtonContainer()`+`collectSiblingButtons()`로 같은 컨테이너의 관련 버튼을 그룹화하여 `buttons` 배열로 전송, (2) `bot.py` `ApprovalView``buttons` 배열이 있으면 `clear_items()` 후 동적 Discord 버튼 추가, (3) `bridge.py` `UserResponse``button_index` 추가, (4) renderer `pollResponseGroup()`에서 `button_index`에 따라 DOM 버튼 클릭
- **주의**: `buttons` 배열이 없는 legacy pending은 기존 2버튼(✅승인/❌거부)으로 표시. Deny를 PATS에 추가함

97
bot.py
View File

@@ -32,16 +32,77 @@ logger = logging.getLogger(__name__)
# ─── Discord UI Components ────────────────────────────────────────── # ─── Discord UI Components ──────────────────────────────────────────
class ApprovalView(discord.ui.View): class ApprovalView(discord.ui.View):
"""Discord buttons for approving/rejecting Antigravity actions.""" """Discord buttons for approving/rejecting Antigravity actions.
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest): Supports two modes:
1. Legacy: ✅ 승인 / ❌ 거부 (when no buttons array)
2. Multi-choice: dynamic buttons from pending's buttons array
(e.g., ✅ Allow Once / ✅ Allow This Conversation / ❌ Deny)
"""
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest,
buttons: list[dict] | None = None):
super().__init__(timeout=1800) # 30 minutes super().__init__(timeout=1800) # 30 minutes
self.bridge = bridge self.bridge = bridge
self.request = request self.request = request
self.responded = False self.responded = False
self.buttons_data = buttons
if buttons and len(buttons) > 1:
# Multi-choice mode: remove the default decorated buttons first
# (they are added by @discord.ui.button at class definition time)
self.clear_items()
# Add a Discord button for each option
for btn_info in buttons:
btn_text = btn_info.get("text", "?")
btn_index = btn_info.get("index", 0)
is_reject = btn_text.lower() in ("deny", "reject", "cancel",
"reject all", "decline",
"dismiss", "stop")
style = discord.ButtonStyle.red if is_reject else discord.ButtonStyle.green
emoji = "" if is_reject else ""
button = discord.ui.Button(
label=f"{emoji} {btn_text}",
style=style,
custom_id=f"choice_{request.request_id}_{btn_index}",
)
# Bind the callback with closure over btn_index and btn_text
button.callback = self._make_choice_callback(btn_index, btn_text,
is_reject)
self.add_item(button)
# else: use the default @discord.ui.button decorated methods below
def _make_choice_callback(self, btn_index: int, btn_text: str,
is_reject: bool):
async def callback(interaction: discord.Interaction):
if self.responded:
await interaction.response.send_message("이미 응답됨",
ephemeral=True)
return
self.responded = True
self.bridge.write_response(UserResponse(
request_id=self.request.request_id,
approved=not is_reject,
button_index=btn_index,
))
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
color = discord.Color.red() if is_reject else discord.Color.green()
embed.color = color
emoji = "" if is_reject else ""
embed.set_footer(
text=f"{emoji} {btn_text} by {interaction.user.display_name}"
)
await interaction.response.edit_message(embed=embed, view=None)
return callback
@discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green) @discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green)
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button): async def approve(self, interaction: discord.Interaction, button: discord.ui.Button):
# Only active in legacy mode (no buttons array)
if self.buttons_data and len(self.buttons_data) > 1:
return # multi-choice mode handles via dynamic buttons
if self.responded: if self.responded:
await interaction.response.send_message("이미 응답됨", ephemeral=True) await interaction.response.send_message("이미 응답됨", ephemeral=True)
return return
@@ -57,6 +118,9 @@ class ApprovalView(discord.ui.View):
@discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red) @discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red)
async def reject(self, interaction: discord.Interaction, button: discord.ui.Button): async def reject(self, interaction: discord.Interaction, button: discord.ui.Button):
# Only active in legacy mode (no buttons array)
if self.buttons_data and len(self.buttons_data) > 1:
return # multi-choice mode handles via dynamic buttons
if self.responded: if self.responded:
await interaction.response.send_message("이미 응답됨", ephemeral=True) await interaction.response.send_message("이미 응답됨", ephemeral=True)
return return
@@ -516,21 +580,38 @@ class GravityBot(commands.Bot):
async def _send_approval_request( async def _send_approval_request(
self, channel: discord.TextChannel, request: ApprovalRequest self, channel: discord.TextChannel, request: ApprovalRequest
): ):
# Read buttons array from pending file (if present)
buttons = None
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
if pending_file.exists():
try:
pending_data = json.loads(
pending_file.read_text(encoding="utf-8-sig")
)
buttons = pending_data.get("buttons")
except (json.JSONDecodeError, OSError):
pass
# Build embed description
desc_parts = [f"**명령어:**\n```\n{request.command[:1000]}\n```"]
if buttons and len(buttons) > 1:
# Multi-choice: show all options in description
btn_names = [b.get("text", "?") for b in buttons]
desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")
if request.description:
desc_parts.append(request.description[:500])
embed = discord.Embed( embed = discord.Embed(
title="⚠️ 승인 요청", title="⚠️ 승인 요청",
description=( description="\n".join(desc_parts),
f"**명령어:**\n```\n{request.command[:1000]}\n```\n"
f"{request.description[:500]}"
),
color=discord.Color.orange(), color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
embed.set_footer(text=f"ID: {request.request_id}") embed.set_footer(text=f"ID: {request.request_id}")
view = ApprovalView(self.bridge, request) view = ApprovalView(self.bridge, request, buttons=buttons)
msg = await channel.send(embed=embed, view=view) msg = await channel.send(embed=embed, view=view)
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
if pending_file.exists(): if pending_file.exists():
try: try:
data = json.loads(pending_file.read_text(encoding="utf-8-sig")) data = json.loads(pending_file.read_text(encoding="utf-8-sig"))

View File

@@ -53,6 +53,7 @@ class UserResponse:
approved: bool approved: bool
user_input: str = "" user_input: str = ""
timestamp: float = 0 timestamp: float = 0
button_index: int = -1 # -1 = legacy (approve/reject), 0+ = specific button index
class BridgeProtocol: class BridgeProtocol:

View File

@@ -0,0 +1,5 @@
# 2026-03-10 Devlog
| # | 시간 | 작업 설명 | 커밋 | 상태 |
|---|------|----------|------|------|
| 001 | 06:12~06:30 | Discord 승인 ENOENT race condition 수정 + 버튼 그룹화 (multi-choice) | `` | 🔧 |

View File

@@ -0,0 +1,14 @@
# Discord 승인 ENOENT 수정 + 버튼 그룹화 (multi-choice)
- **시간**: 2026-03-10 06:12 ~ 06:30
- **Commit**: ``
- **Vikunja**: #276 → 진행중 (E2E 검증 대기)
## 결정 사항
- **ENOENT 근본 원인**: extension.log에서 `[RESPONSE] error: ENOENT` 확인. `processResponseFile()`이 DOM observer response를 renderer가 polling하기 전에 삭제. DOM observer 경로만 response 보존, HTTP handler에서 삭제하도록 변경
- **버튼 그룹화**: `findButtonContainer()` + `collectSiblingButtons()`로 같은 컨테이너의 관련 버튼을 하나의 pending으로 묶음. `buttons` 배열 + `button_index` response로 Discord에서 원하는 버튼만 정확히 클릭
## 미완료
- AG 재시작 + V8 CachedData 삭제 후 E2E 검증 필요
- Run 승인 반복 테스트 (5회 이상)
- Allow Once / Allow This Conversation / Deny 3개 선택지 동시 출현 확인

View File

@@ -492,7 +492,7 @@ function startObserverHttpBridge() {
source: 'dom_observer', source: 'dom_observer',
}; };
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2)); fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" ctx="${(data.description || '').substring(0, 50)}"`); logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, request_id: rid })); res.end(JSON.stringify({ ok: true, request_id: rid }));
} }
@@ -906,10 +906,14 @@ function generateApprovalObserverScript(_port) {
{re:/^Accept$/i, type:'agent_step'}, {re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'}, {re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'}, {re:/^Approve/i, type:'agent_step'},
{re:/^Deny$/i, type:'permission'},
{re:/^Retry$/i, type:'error_recovery'}, {re:/^Retry$/i, type:'error_recovery'},
{re:/^Dismiss$/i, type:'error_recovery'}, {re:/^Dismiss$/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 // 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]; var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i];
@@ -954,6 +958,41 @@ function generateApprovalObserverScript(_port) {
return desc.substring(0,500); 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) ── // ── Find the React app container (Antigravity's main UI root) ──
function findPanel(){ function findPanel(){
// Priority order of panel selectors (most specific first) // Priority order of panel selectors (most specific first)
@@ -972,6 +1011,7 @@ function generateApprovalObserverScript(_port) {
} }
// ── Core scan — finds actionable buttons and reports to bridge ── // ── Core scan — finds actionable buttons and reports to bridge ──
// Groups related buttons from same container into a single pending
function scan(){ function scan(){
if(!_ready)return; if(!_ready)return;
var now=Date.now(); var now=Date.now();
@@ -1009,78 +1049,122 @@ function generateApprovalObserverScript(_port) {
} }
if(!matchedType)continue; if(!matchedType)continue;
// Generate stable ID // Generate stable ID for the GROUP (use container-based key)
var bid=btnId(b,matchedType); var container=findButtonContainer(b);
if(_sent[bid])continue; var groupKey=matchedType+'|group|'+(container?(container.textContent||'').substring(0,40).replace(/\\s+/g,' '):'none');
if(_sent[groupKey])continue;
// Extract context // 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 desc=extractContext(b);
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6); var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
// Mark as sent // Mark entire group as sent
_sent[bid]={rid:rid,ts:now}; _sent[groupKey]={rid:rid,ts:now};
log('DETECTED '+matchedType+': "'+txt+'" → pending to bridge'); 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) // Send to bridge (closure to capture refs)
(function(rid2,b2,bid2,txt2,desc2,type2){ (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',{ fetch(BASE+'/pending',{
method:'POST', method:'POST',
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body:JSON.stringify({ body:JSON.stringify(payload)
request_id:rid2,
command:txt2,
description:desc2,
step_type:type2
})
}).then(function(r){return r.json();}).then(function(d){ }).then(function(r){return r.json();}).then(function(d){
log('Pending created: '+d.request_id+' for "'+txt2+'"'); log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
pollResponse(d.request_id,b2,bid2); pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
}).catch(function(e){ }).catch(function(e){
log('POST error: '+e.message); log('POST error: '+e.message);
delete _sent[bid2]; delete _sent[groupKey2];
for(var di=0;di<bidList2.length;di++){delete _sent[bidList2[di]];}
}); });
})(rid,b,bid,txt,desc,matchedType); })(rid,btnRefs,bidList,groupKey,txt,desc,matchedType,buttonsArr);
// Process ONE button per scan cycle (avoid flooding) // Process ONE button GROUP per scan cycle (avoid flooding)
return; return;
} }
} // end searchRoots loop } // end searchRoots loop
} }
// ── Poll for Discord response ── // ── Poll for Discord response (multi-button group aware) ──
function pollResponse(rid,btn,bid){ function pollResponseGroup(rid,btnRefs,bidList,groupKey){
var polls=0; var polls=0;
var maxPolls=600; // 5 minutes at 500ms interval var maxPolls=600; // 5 minutes at 500ms interval
var timer=setInterval(function(){ var timer=setInterval(function(){
polls++; polls++;
// Check if button is still in DOM (step may have been resolved by other means) // Check if ANY button in the group is still in DOM
if(!document.body.contains(btn)){ var anyAlive=false;
log('Button removed from DOM — stopping poll for '+rid); 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); clearInterval(timer);
delete _sent[bid]; delete _sent[groupKey];
for(var ci=0;ci<bidList.length;ci++){delete _sent[bidList[ci]];}
return; return;
} }
if(polls>maxPolls){ if(polls>maxPolls){
log('Poll timeout for '+rid); log('Poll timeout for '+rid);
clearInterval(timer); clearInterval(timer);
delete _sent[bid]; delete _sent[groupKey];
for(var ti=0;ti<bidList.length;ti++){delete _sent[bidList[ti]];}
return; return;
} }
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){ fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
if(d.waiting)return; if(d.waiting)return;
clearInterval(timer); clearInterval(timer);
if(d.approved){ var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
log('✅ APPROVED '+rid+' → clicking "'+((btn.textContent||'').trim())+'"'); if(btnIdx>=0&&btnIdx<btnRefs.length){
btn.click(); // 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 { } else {
// Legacy reject: find and click reject/deny button
log('❌ REJECTED '+rid+' → finding reject button'); log('❌ REJECTED '+rid+' → finding reject button');
clickRejectButton(btn); clickRejectButton(btnRefs[0]);
} }
delete _sent[bid]; delete _sent[groupKey];
for(var ri=0;ri<bidList.length;ri++){delete _sent[bidList[ri]];}
}).catch(function(){}); }).catch(function(){});
},500); },500);
} }
// 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 ── // ── Find and click the reject/cancel counterpart button ──
function clickRejectButton(approveBtn){ function clickRejectButton(approveBtn){
// Walk up to find the container, then search for reject buttons // Walk up to find the container, then search for reject buttons
@@ -1703,10 +1787,16 @@ async function processResponseFile(filePath) {
} }
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`); logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
// Cleanup response file // Cleanup response file
try { // CRITICAL: DOM observer responses must NOT be deleted here!
fs.unlinkSync(filePath); // The renderer polls GET /response/:rid to discover the approval.
// If we delete the file before the renderer polls, it gets ENOENT.
// The HTTP handler (/response/:rid) deletes after serving to renderer.
if (!isDomObserver) {
try {
fs.unlinkSync(filePath);
}
catch { }
} }
catch { }
} }
catch (e) { catch (e) {
const log = `[RESPONSE] error: ${e.message}`; const log = `[RESPONSE] error: ${e.message}`;

File diff suppressed because one or more lines are too long

View File

@@ -471,7 +471,7 @@ function startObserverHttpBridge(): Promise<number> {
source: 'dom_observer', source: 'dom_observer',
}; };
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2)); fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" ctx="${(data.description || '').substring(0, 50)}"`); logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, request_id: rid })); res.end(JSON.stringify({ ok: true, request_id: rid }));
} catch (e: any) { } catch (e: any) {
@@ -885,10 +885,14 @@ function generateApprovalObserverScript(_port: number): string {
{re:/^Accept$/i, type:'agent_step'}, {re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'}, {re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'}, {re:/^Approve/i, type:'agent_step'},
{re:/^Deny$/i, type:'permission'},
{re:/^Retry$/i, type:'error_recovery'}, {re:/^Retry$/i, type:'error_recovery'},
{re:/^Dismiss$/i, type:'error_recovery'}, {re:/^Dismiss$/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 // 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]; var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i];
@@ -933,6 +937,41 @@ function generateApprovalObserverScript(_port: number): string {
return desc.substring(0,500); 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) ── // ── Find the React app container (Antigravity's main UI root) ──
function findPanel(){ function findPanel(){
// Priority order of panel selectors (most specific first) // Priority order of panel selectors (most specific first)
@@ -951,6 +990,7 @@ function generateApprovalObserverScript(_port: number): string {
} }
// ── Core scan — finds actionable buttons and reports to bridge ── // ── Core scan — finds actionable buttons and reports to bridge ──
// Groups related buttons from same container into a single pending
function scan(){ function scan(){
if(!_ready)return; if(!_ready)return;
var now=Date.now(); var now=Date.now();
@@ -988,78 +1028,122 @@ function generateApprovalObserverScript(_port: number): string {
} }
if(!matchedType)continue; if(!matchedType)continue;
// Generate stable ID // Generate stable ID for the GROUP (use container-based key)
var bid=btnId(b,matchedType); var container=findButtonContainer(b);
if(_sent[bid])continue; var groupKey=matchedType+'|group|'+(container?(container.textContent||'').substring(0,40).replace(/\\s+/g,' '):'none');
if(_sent[groupKey])continue;
// Extract context // 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 desc=extractContext(b);
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6); var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
// Mark as sent // Mark entire group as sent
_sent[bid]={rid:rid,ts:now}; _sent[groupKey]={rid:rid,ts:now};
log('DETECTED '+matchedType+': "'+txt+'" → pending to bridge'); 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) // Send to bridge (closure to capture refs)
(function(rid2,b2,bid2,txt2,desc2,type2){ (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',{ fetch(BASE+'/pending',{
method:'POST', method:'POST',
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body:JSON.stringify({ body:JSON.stringify(payload)
request_id:rid2,
command:txt2,
description:desc2,
step_type:type2
})
}).then(function(r){return r.json();}).then(function(d){ }).then(function(r){return r.json();}).then(function(d){
log('Pending created: '+d.request_id+' for "'+txt2+'"'); log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
pollResponse(d.request_id,b2,bid2); pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
}).catch(function(e){ }).catch(function(e){
log('POST error: '+e.message); log('POST error: '+e.message);
delete _sent[bid2]; delete _sent[groupKey2];
for(var di=0;di<bidList2.length;di++){delete _sent[bidList2[di]];}
}); });
})(rid,b,bid,txt,desc,matchedType); })(rid,btnRefs,bidList,groupKey,txt,desc,matchedType,buttonsArr);
// Process ONE button per scan cycle (avoid flooding) // Process ONE button GROUP per scan cycle (avoid flooding)
return; return;
} }
} // end searchRoots loop } // end searchRoots loop
} }
// ── Poll for Discord response ── // ── Poll for Discord response (multi-button group aware) ──
function pollResponse(rid,btn,bid){ function pollResponseGroup(rid,btnRefs,bidList,groupKey){
var polls=0; var polls=0;
var maxPolls=600; // 5 minutes at 500ms interval var maxPolls=600; // 5 minutes at 500ms interval
var timer=setInterval(function(){ var timer=setInterval(function(){
polls++; polls++;
// Check if button is still in DOM (step may have been resolved by other means) // Check if ANY button in the group is still in DOM
if(!document.body.contains(btn)){ var anyAlive=false;
log('Button removed from DOM — stopping poll for '+rid); 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); clearInterval(timer);
delete _sent[bid]; delete _sent[groupKey];
for(var ci=0;ci<bidList.length;ci++){delete _sent[bidList[ci]];}
return; return;
} }
if(polls>maxPolls){ if(polls>maxPolls){
log('Poll timeout for '+rid); log('Poll timeout for '+rid);
clearInterval(timer); clearInterval(timer);
delete _sent[bid]; delete _sent[groupKey];
for(var ti=0;ti<bidList.length;ti++){delete _sent[bidList[ti]];}
return; return;
} }
fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){ fetch(BASE+'/response/'+rid).then(function(r){return r.json();}).then(function(d){
if(d.waiting)return; if(d.waiting)return;
clearInterval(timer); clearInterval(timer);
if(d.approved){ var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
log('✅ APPROVED '+rid+' → clicking "'+((btn.textContent||'').trim())+'"'); if(btnIdx>=0&&btnIdx<btnRefs.length){
btn.click(); // 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 { } else {
// Legacy reject: find and click reject/deny button
log('❌ REJECTED '+rid+' → finding reject button'); log('❌ REJECTED '+rid+' → finding reject button');
clickRejectButton(btn); clickRejectButton(btnRefs[0]);
} }
delete _sent[bid]; delete _sent[groupKey];
for(var ri=0;ri<bidList.length;ri++){delete _sent[bidList[ri]];}
}).catch(function(){}); }).catch(function(){});
},500); },500);
} }
// 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 ── // ── Find and click the reject/cancel counterpart button ──
function clickRejectButton(approveBtn){ function clickRejectButton(approveBtn){
// Walk up to find the container, then search for reject buttons // Walk up to find the container, then search for reject buttons
@@ -1686,7 +1770,13 @@ async function processResponseFile(filePath: string) {
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`); logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
// Cleanup response file // Cleanup response file
try { fs.unlinkSync(filePath); } catch { } // CRITICAL: DOM observer responses must NOT be deleted here!
// The renderer polls GET /response/:rid to discover the approval.
// If we delete the file before the renderer polls, it gets ENOENT.
// The HTTP handler (/response/:rid) deletes after serving to renderer.
if (!isDomObserver) {
try { fs.unlinkSync(filePath); } catch { }
}
} catch (e: any) { } catch (e: any) {
const log = `[RESPONSE] error: ${e.message}`; const log = `[RESPONSE] error: ${e.message}`;
console.log(`Gravity Bridge: ${log}`); console.log(`Gravity Bridge: ${log}`);