Compare commits

...

11 Commits

Author SHA1 Message Date
CD
563fbadd5a docs: devlog 20260310-002 session summary 2026-03-10 10:42:44 +09:00
CD
2958bdc950 feat: real-time PLANNER_RESPONSE capture on every delta>0 during RUNNING 2026-03-10 09:54:30 +09:00
CD
9b047c0c7d fix: extract text from plannerResponse.modifiedResponse field 2026-03-10 09:38:24 +09:00
CD
7ed2db90df fix: add verbosity=DEBUG to GetCascadeTrajectorySteps for response text 2026-03-10 09:13:13 +09:00
CD
1089c6ce61 fix: extract text from plannerResponse field for Discord relay 2026-03-10 09:02:16 +09:00
CD
e586bb6d41 feat: capture AI text responses on RUNNING->IDLE for Discord relay 2026-03-10 08:43:57 +09:00
CD
8c6d25c6d4 fix: add snapshot diagnostics + lower content filter for Discord messages 2026-03-10 08:18:36 +09:00
CD
628b5ae2fa fix: use stepOffset to bypass 775-step API limit with full details 2026-03-10 08:08:36 +09:00
CD
2361aa7558 fix: disable ResolveOutstandingSteps + add 775-limit stall fallback 2026-03-10 08:03:57 +09:00
CD
0e3a896c86 feat: step_type routing for all approval interaction types 2026-03-10 07:56:36 +09:00
CD
1f63f60280 feat: proto-based RPC approval for Run commands via Discord
Decoded HandleCascadeUserInteractionRequest protobuf schema from AG's
extension.js (message #162, base64 FileDescriptor 78KB).

Working payload (variant PROTO-0):
  cascadeId + interaction.{trajectoryId, stepIndex, runCommand.confirm}

Changes:
- extension.ts: Added Strategy 0-PROTO with decoded proto RPC call
- extension.ts: Fixed processResponseFile to call tryApprovalStrategies()
  instead of direct clickTrigger (was bypassing all strategies)
- extension.ts: Fixed false positive Run detection (sessionStalled reset
  when step_probe confirms no WAITING)
- extension.ts: Moved lastPendingStepIndex to module scope
- extension.ts: Added activeTrajectoryId tracking from session init
- bot.py: Added MERGE detection + Discord message edit for command updates
- bot.py: Added _sent_commands tracking for merge detection

Proto RE methodology:
1. Found schema exports in AG extension.js
2. Located fileDesc() with base64 protobuf descriptor
3. Decoded 58KB raw proto, found message names
4. Extracted CascadeRunCommandInteraction.confirm field
5. Tested camelCase JSON via ConnectRPC = SUCCESS
2026-03-10 07:45:10 +09:00
5 changed files with 1059 additions and 203 deletions

49
bot.py
View File

@@ -165,6 +165,7 @@ class GravityBot(commands.Bot):
self.session_status_messages: dict[str, int] = {} # conv_id → msg_id
self._sent_approval_ids: set[str] = set()
self._deferred_ids: dict[str, int] = {} # request_id → defer count
self._sent_commands: dict[str, str] = {} # request_id → command text (for MERGE edit detection)
self._ready_event = asyncio.Event()
self._channel_lock = asyncio.Lock()
self.bridge = BridgeProtocol()
@@ -542,6 +543,7 @@ class GravityBot(commands.Bot):
channel = await self._get_channel(project)
if channel:
self._sent_approval_ids.add(req.request_id)
self._sent_commands[req.request_id] = req.command
await self._send_approval_request(channel, req)
# ── Check for auto_resolved pendings (approved directly in AG) ──
@@ -567,6 +569,53 @@ class GravityBot(commands.Bot):
pass
f.unlink()
self._deferred_ids.pop(data.get("request_id", ""), None)
self._sent_commands.pop(data.get("request_id", ""), None)
except (json.JSONDecodeError, OSError):
pass
# ── Check for MERGE updates (step_probe updated command in already-sent pending) ──
for f in self.bridge.pending_dir.glob("*.json"):
try:
data = json.loads(f.read_text(encoding="utf-8-sig"))
rid = data.get("request_id", "")
if rid not in self._sent_approval_ids:
continue
if data.get("status") != "pending":
continue
msg_id = data.get("discord_message_id", 0)
if not msg_id:
continue
# Check if command was updated via MERGE
new_cmd = data.get("command", "")
old_cmd = self._sent_commands.get(rid, "")
if new_cmd and new_cmd != old_cmd and len(new_cmd) > len(old_cmd):
# MERGE detected — edit Discord message
self._sent_commands[rid] = new_cmd
project = data.get("project_name", Config.PROJECT_NAME)
channel = await self._get_channel(project)
if channel:
try:
msg = await channel.fetch_message(msg_id)
# Rebuild embed with full command
buttons = data.get("buttons")
desc_parts = [f"**명령어:**\n```\n{new_cmd[:1000]}\n```"]
if buttons and len(buttons) > 1:
btn_names = [b.get("text", "?") for b in buttons]
desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")
desc = data.get("description", "")
if desc:
desc_parts.append(desc[:500])
embed = discord.Embed(
title="⚠️ 승인 요청",
description="\n".join(desc_parts),
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
embed.set_footer(text=f"ID: {rid}")
await msg.edit(embed=embed)
logger.info(f"MERGE edit: {rid[:12]} cmd='{new_cmd[:60]}'")
except discord.NotFound:
pass
except (json.JSONDecodeError, OSError):
pass

View File

@@ -0,0 +1,30 @@
---
id: "20260310-002"
date: "2026-03-10"
session_start: "07:55"
session_end: "10:40"
tags: [approval-flow, discord-relay, proto-rpc, step-offset]
---
# 승인 플로우 완성 + Discord 실시간 릴레이
## 요약
Proto RPC 기반 승인 플로우의 치명적 버그 2건 수정 + Discord 실시간 AI 응답 릴레이 구현.
## 주요 성과
### 1. 자동 취소 버그 수정 (`2361aa7`)
- `ResolveOutstandingSteps` RPC 비활성화 (reject 시 AG 작업 전체 취소 방지)
### 2. 775-step API limit 해결 (`628b5ae`)
- `GetCascadeTrajectorySteps` proto에서 `step_offset` 파라미터 발견/활용
- 장시간 세션(step 527+)에서도 최신 WAITING step 감지
### 3. Discord 실시간 AI 응답 릴레이 (`e586bb6` ~ `2958bdc`)
- `plannerResponse.modifiedResponse` + `verbosity: 1` (DEBUG)
- RUNNING 중 `delta > 0` 마다 RT 캡쳐 → 중간 메시지도 Discord에 표시
- notify_user/task_boundary 스냅샷 동작 확인
## 다음 단계
- Allow Once / Allow This Conversation 파일 접근 승인
- Retry / 코드 편집 승인 테스트

View File

@@ -114,6 +114,7 @@ function ensureBridgeDir() {
}
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
let activeSessionId = '';
let activeTrajectoryId = '';
function writeChatSnapshot(text) {
try {
// Write to chat_snapshots/*.json for Bot's chat_snapshot_scanner to pick up
@@ -131,6 +132,8 @@ function writeChatSnapshot(text) {
const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`);
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars)`);
logToFile(`[SNAPSHOT] content: ${text.substring(0, 200)}`);
// Lazily register session → project mapping (correct because projectName is per-window)
if (activeSessionId) {
writeRegistration(activeSessionId);
@@ -426,6 +429,7 @@ const pendingResponses = new Map();
// Click trigger: extension sets this, renderer polls and clicks button
let clickTrigger = null;
let sessionStalled = false; // true when session is stalled waiting for approval
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step (module-level for tryApprovalStrategies)
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
let deepInspectRequested = false;
let deepInspectResult = null;
@@ -511,8 +515,15 @@ function startObserverHttpBridge() {
if (fs.existsSync(respFile)) {
try {
const data = JSON.parse(fs.readFileSync(respFile, 'utf8'));
fs.unlinkSync(respFile);
logToFile(`[HTTP] response sent: ${rid} approved=${data.approved}`);
logToFile(`[HTTP] response served to renderer: ${rid} approved=${data.approved}`);
// Delay deletion: processResponseFile (response watcher) may need to read it too.
// The watcher fires with 300ms delay, so 2s is safe.
setTimeout(() => {
try {
fs.unlinkSync(respFile);
}
catch { }
}, 2000);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
@@ -1398,13 +1409,16 @@ function setupMonitor() {
let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1;
let lastTaskStepIndex = -1;
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step
// lastPendingStepIndex is module-level (above sessionStalled)
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
let lastPendingTime = 0; // cooldown: minimum gap between pendings
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending
let lastModTime = ''; // track lastModifiedTime to distinguish thinking vs approval
let stallProbed = false; // prevent repeated step probes during same stall
let lastRelayedTaskText = ''; // dedup TASK_BOUNDARY relay
let wasRunning = false; // track RUNNING→IDLE transition for response capture
let lastUserInputStepIdx = -1; // track user input for response matching
let lastResponseCaptureStep = -1; // dedup: don't capture same response twice
setInterval(async () => {
pollCount++;
if (pollCount <= 3 || pollCount % 12 === 0) {
@@ -1452,6 +1466,7 @@ function setupMonitor() {
// Session changed?
if (bestSessionId !== activeSessionId) {
activeSessionId = bestSessionId;
activeTrajectoryId = bestSession.trajectoryId || '';
activeSessionTitle = currentTitle;
lastKnownStepCount = currentCount;
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
@@ -1474,6 +1489,46 @@ function setupMonitor() {
lastKnownStepCount = currentCount;
if (delta > 0) {
console.log(`Gravity Bridge: [POLL#${pollCount}] +${delta} steps (${currentCount}) "${currentTitle}"`);
// Real-time response capture: fetch latest steps on every delta>0
if (isRunning && currentCount > lastResponseCaptureStep && sdk) {
try {
const rtOffset = Math.max(0, currentCount - 3);
const rtResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: bestSessionId,
stepOffset: rtOffset,
verbosity: 1, // DEBUG — includes plannerResponse text
});
if (rtResp?.steps?.length > 0) {
for (let ri = rtResp.steps.length - 1; ri >= 0; ri--) {
const s = rtResp.steps[ri];
const sType = s?.type || '';
const actualIdx = rtOffset + ri;
if (actualIdx <= lastResponseCaptureStep)
continue;
if (sType.includes('PLANNER_RESPONSE') && s?.status?.includes('DONE')) {
const pr = s?.plannerResponse;
if (pr) {
let text = pr.modifiedResponse || pr.rawText || pr.text || '';
if (text.length > 10) {
lastResponseCaptureStep = actualIdx;
logToFile(`[RT-CAPTURE] step=${actualIdx} (${text.length} chars)`);
const truncated = text.length > 1800
? text.substring(0, 1800) + '\n\n_(이하 생략)_'
: text;
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;
}
}
}
}
}
}
catch (rte) {
// Non-critical — don't spam logs
if (pollCount <= 5)
logToFile(`[RT-CAPTURE] error: ${rte.message.substring(0, 80)}`);
}
}
}
// Log session state on EVERY poll for diagnostics
const statusStr = String(bestSession.status || 'UNKNOWN');
@@ -1549,7 +1604,63 @@ function setupMonitor() {
// Diagnostic: compare returned steps vs trajectory stepCount
logToFile(`[STEP-PROBE] returned=${steps.length} vs trajectory.stepCount=${currentCount}`);
if (steps.length < currentCount) {
logToFile(`[STEP-PROBE] ⚠️ 775-limit hit! steps=${steps.length} < stepCount=${currentCount}`);
logToFile(`[STEP-PROBE] ⚠️ 775-limit hit! steps=${steps.length} < stepCount=${currentCount} — retrying with stepOffset`);
// 775-LIMIT FIX: Retry with stepOffset to get latest steps
try {
const offset = Math.max(0, currentCount - 10);
const offsetResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: bestSessionId,
stepOffset: offset,
});
if (offsetResp?.steps?.length > 0) {
// Replace steps array with offset results
const offsetSteps = offsetResp.steps;
logToFile(`[STEP-PROBE] offset=${offset} returned ${offsetSteps.length} steps (latest)`);
// Scan for WAITING in offset results
for (let osi = offsetSteps.length - 1; osi >= 0; osi--) {
const oStep = offsetSteps[osi];
if (oStep?.status === 'CORTEX_STEP_STATUS_WAITING') {
const toolCall = oStep?.metadata?.toolCall;
const toolName = toolCall?.name || (oStep.type || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();
let command = toolName;
if (toolCall?.argumentsJson) {
try {
const args = JSON.parse(toolCall.argumentsJson);
if (args.CommandLine)
command = `${toolName}: ${args.CommandLine.substring(0, 150)}`;
else if (args.TargetFile)
command = `${toolName}: ${args.TargetFile.split(/[\\/]/).pop()}`;
else
command = `${toolName}: ${Object.keys(args).join(', ')}`;
}
catch {
command = toolName;
}
}
const actualIndex = offset + osi;
logToFile(`[STEP-PROBE] ★ WAITING (via offset)! step=${actualIndex} type=${oStep.type} cmd='${command}'`);
if (actualIndex !== lastPendingStepIndex) {
stallProbed = true;
lastPendingStepIndex = actualIndex;
lastPendingTime = Date.now();
sawRunningAfterPending = false;
writePendingApproval({
conversation_id: activeSessionId,
command,
description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
step_type: toolName,
step_index: actualIndex,
source: 'step_probe_offset',
});
}
break;
}
}
}
}
catch (oe) {
logToFile(`[STEP-PROBE] offset retry failed: ${oe.message.substring(0, 100)}`);
}
}
// Scan last 5 steps backwards to find WAITING (RUN_COMMAND may not be last)
let foundWaiting = false;
@@ -1603,6 +1714,10 @@ function setupMonitor() {
if (!foundWaiting) {
const lastStep = steps[steps.length - 1];
logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`);
// CRITICAL: Reset sessionStalled when step_probe confirms no WAITING.
// Without this, sessionStalled stays true during long AI generations
// (PLANNER_RESPONSE with delta=0), causing false positive "Run" detections.
sessionStalled = false;
}
}
}
@@ -1670,37 +1785,134 @@ function setupMonitor() {
}
// ── Process latestNotifyUserStep ──
const notifyStep = bestSession.latestNotifyUserStep;
if (notifyStep && notifyStep.stepIndex > lastNotifyStepIndex) {
lastNotifyStepIndex = notifyStep.stepIndex;
const content = notifyStep.step?.notifyUser?.notificationContent || '';
// Filter: only relay meaningful notifications (skip trivial ones)
if (content.length > 50) {
writeChatSnapshot(`📣 **알림** (step ${notifyStep.stepIndex})\n\n${content}`);
console.log(`Gravity Bridge: [POLL#${pollCount}] NOTIFY step=${notifyStep.stepIndex} ${content.length} chars`);
}
else if (content.length > 0) {
logToFile(`[POLL] NOTIFY skipped (too short: ${content.length} chars): ${content.substring(0, 80)}`);
if (notifyStep) {
if (notifyStep.stepIndex > lastNotifyStepIndex) {
lastNotifyStepIndex = notifyStep.stepIndex;
const content = notifyStep.step?.notifyUser?.notificationContent || '';
logToFile(`[NOTIFY-STEP] NEW step=${notifyStep.stepIndex} content=${content.length} chars`);
// Filter: relay all non-empty notifications
if (content.length > 10) {
writeChatSnapshot(`📣 **알림** (step ${notifyStep.stepIndex})\n\n${content}`);
}
else if (content.length > 0) {
logToFile(`[NOTIFY-STEP] skipped (too short: ${content.length} chars): ${content}`);
}
}
}
else if (pollCount <= 5) {
logToFile(`[NOTIFY-STEP] null (no notify step in session)`);
}
// ── Process latestTaskBoundaryStep ──
const taskStep = bestSession.latestTaskBoundaryStep;
if (taskStep && taskStep.stepIndex > lastTaskStepIndex) {
lastTaskStepIndex = taskStep.stepIndex;
const tb = taskStep.step?.taskBoundary;
if (tb?.taskName) {
const mode = tb.mode ? tb.mode.replace('AGENT_MODE_', '') : '';
// Filter: skip status-only updates with same task name (noise)
const taskText = `${tb.taskName}|${tb.taskStatus || ''}`;
if (taskText !== lastRelayedTaskText) {
lastRelayedTaskText = taskText;
writeChatSnapshot(`📋 **[${mode}] ${tb.taskName}**\n${tb.taskStatus || ''}\n\n${tb.taskSummary || ''}`);
console.log(`Gravity Bridge: [POLL#${pollCount}] TASK step=${taskStep.stepIndex} "${tb.taskName}"`);
}
else {
logToFile(`[POLL] TASK skipped (duplicate): "${tb.taskName}"`);
if (taskStep) {
if (taskStep.stepIndex > lastTaskStepIndex) {
lastTaskStepIndex = taskStep.stepIndex;
const tb = taskStep.step?.taskBoundary;
if (tb?.taskName) {
const mode = tb.mode ? tb.mode.replace('AGENT_MODE_', '') : '';
const taskText = `${tb.taskName}|${tb.taskStatus || ''}`;
logToFile(`[TASK-STEP] NEW step=${taskStep.stepIndex} name="${tb.taskName}" mode=${mode}`);
if (taskText !== lastRelayedTaskText) {
lastRelayedTaskText = taskText;
writeChatSnapshot(`📋 **[${mode}] ${tb.taskName}**\n${tb.taskStatus || ''}\n\n${tb.taskSummary || ''}`);
}
else {
logToFile(`[TASK-STEP] skipped (duplicate): "${tb.taskName}"`);
}
}
}
}
else if (pollCount <= 5) {
logToFile(`[TASK-STEP] null (no task step in session)`);
}
// ── RUNNING → IDLE transition: capture AI response for Discord ──
const userInputIdx = bestSession.lastUserInputStepIndex ?? -1;
if (userInputIdx > lastUserInputStepIdx) {
lastUserInputStepIdx = userInputIdx;
logToFile(`[RESPONSE-CAPTURE] user input detected at step ${userInputIdx}`);
}
if (wasRunning && !isRunning && currentCount > lastResponseCaptureStep) {
logToFile(`[RESPONSE-CAPTURE] RUNNING→IDLE, steps=${currentCount}, capturing response...`);
lastResponseCaptureStep = currentCount;
try {
const offset = Math.max(0, currentCount - 5);
const latestResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: bestSessionId,
stepOffset: offset,
verbosity: 1, // CLIENT_TRAJECTORY_VERBOSITY_DEBUG — includes full plannerResponse text
});
if (latestResp?.steps?.length > 0) {
const steps = latestResp.steps;
for (let ri = steps.length - 1; ri >= 0; ri--) {
const s = steps[ri];
const sType = s?.type || '';
if (sType.includes('PLANNER_RESPONSE') || sType.includes('MESSAGE')) {
let textContent = '';
// Extract from plannerResponse field
const pr = s?.plannerResponse;
if (pr) {
// Priority: modifiedResponse (confirmed field from AG)
if (pr.modifiedResponse)
textContent = pr.modifiedResponse;
else if (pr.rawText)
textContent = pr.rawText;
else if (pr.text)
textContent = pr.text;
else if (pr.message)
textContent = typeof pr.message === 'string' ? pr.message : '';
else if (pr.content?.parts) {
for (const p of pr.content.parts) {
if (p?.text)
textContent += p.text;
}
}
// Log first time to capture actual field names
if (!textContent) {
logToFile(`[RESPONSE-CAPTURE] plannerResponse keys: [${Object.keys(pr).join(',')}] values(100): ${JSON.stringify(pr).substring(0, 200)}`);
}
}
// Extract from ephemeralMessage field
const em = s?.ephemeralMessage;
if (!textContent && em) {
if (typeof em === 'string')
textContent = em;
else if (em.message)
textContent = em.message;
else if (em.content)
textContent = typeof em.content === 'string' ? em.content : JSON.stringify(em.content);
}
// Fallback: metadata, content, rawOutput
if (!textContent) {
const parts = s?.content?.parts || s?.parts || [];
for (const p of parts) {
if (p?.text)
textContent += p.text;
}
}
if (!textContent && s?.metadata?.text)
textContent = s.metadata.text;
if (!textContent && s?.rawOutput)
textContent = typeof s.rawOutput === 'string' ? s.rawOutput : JSON.stringify(s.rawOutput);
if (textContent.length > 10) {
logToFile(`[RESPONSE-CAPTURE] found ${sType} (${textContent.length} chars) at step ${offset + ri}`);
const truncated = textContent.length > 1800
? textContent.substring(0, 1800) + '\n\n_(이하 생략)_'
: textContent;
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;
}
else {
logToFile(`[RESPONSE-CAPTURE] ${sType} too short (${textContent.length}), keys=[${Object.keys(s).join(',')}]`);
}
}
}
}
}
catch (re) {
logToFile(`[RESPONSE-CAPTURE] error: ${re.message.substring(0, 100)}`);
}
}
wasRunning = isRunning;
}
catch (e) {
if (pollCount <= 5 || pollCount % 20 === 0) {
@@ -1746,6 +1958,11 @@ function setupResponseWatcher() {
}
async function processResponseFile(filePath) {
try {
// Gracefully handle files already consumed by HTTP handler
if (!fs.existsSync(filePath)) {
// HTTP GET /response/:rid already served and deleted this file — skip silently
return;
}
const content = fs.readFileSync(filePath, 'utf-8');
const resp = JSON.parse(content);
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved}`;
@@ -1756,12 +1973,16 @@ async function processResponseFile(filePath) {
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
let sessionId = '';
let isDomObserver = false;
let pendingStepType = '';
let pendingStepIndex = -1;
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
sessionId = pending.conversation_id || '';
isDomObserver = pending.auto_detected === true
|| pending.source === 'dom_observer';
pendingStepType = pending.step_type || '';
pendingStepIndex = pending.step_index ?? lastPendingStepIndex;
}
catch { }
}
@@ -1775,15 +1996,10 @@ async function processResponseFile(filePath) {
logToFile(`[RESPONSE] renderer-handled approval (rid=${resp.request_id})`);
}
else {
// Step probe path: approve → trigger renderer click, reject → log only
if (approved) {
logToFile(`[RESPONSE] step_probe → approve via trigger-click`);
clickTrigger = { action: 'approve', timestamp: Date.now() };
}
else {
// SAFE: reject logs only — NO ResolveOutstandingSteps (it cancels AI work!)
logToFile(`[RESPONSE] step_probe → reject (log only, no destructive action)`);
}
// Step probe path: run ALL approval strategies
logToFile(`[RESPONSE] step_probe → tryApprovalStrategies(${approved}, ${activeSessionId.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
const strategyResult = await tryApprovalStrategies(approved, activeSessionId, pendingStepType, pendingStepIndex);
logToFile(`[RESPONSE] strategy result: ${strategyResult}`);
}
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
// Cleanup response file
@@ -1996,79 +2212,274 @@ function writePendingApproval(data) {
* 2. VS Code accept/reject commands (focus-dependent)
* 3. Log failure for manual intervention
*/
async function tryApprovalStrategies(approved, sessionId) {
async function tryApprovalStrategies(approved, sessionId, stepType = '', stepIndex = -1) {
const action = approved ? 'APPROVE' : 'REJECT';
logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)}`);
// ── Dynamic Command Discovery (check if approval commands appear when step is WAITING) ──
const effectiveStepIndex = stepIndex >= 0 ? stepIndex : lastPendingStepIndex;
logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${effectiveStepIndex}`);
// ── Dynamic Command Discovery (log what's available during WAITING state) ──
let approvalCmdList = [];
try {
const allCmds = await vscode.commands.getCommands(true);
const agCmds = allCmds.filter((c) => c.startsWith('antigravity.'));
const approvalCmds = agCmds.filter((c) => {
approvalCmdList = agCmds.filter((c) => {
const lower = c.toLowerCase();
return lower.includes('accept') || lower.includes('reject') || lower.includes('approve')
|| lower.includes('terminal') || lower.includes('run') || lower.includes('step');
|| lower.includes('terminal') || lower.includes('run') || lower.includes('step')
|| lower.includes('cascade') || lower.includes('action');
});
logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmds.length} approval-related:`);
for (const c of approvalCmds) {
logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`);
for (const c of approvalCmdList) {
logToFile(`[APPROVAL-CMD-CHECK] → ${c}`);
}
// Dump ALL antigravity.* commands for full comparison
logToFile(`[APPROVAL-CMD-CHECK] FULL LIST (${agCmds.length}):`);
for (const c of agCmds) {
logToFile(`[APPROVAL-CMD-FULL] ${c}`);
}
}
catch (e) {
logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`);
}
// ── Strategy 1: HandleCascadeUserInteraction RPC ──
if (sdk) {
// Try variant A: { cascadeId, approved }
try {
logToFile(`[APPROVAL-1A] HandleCascadeUserInteraction { cascadeId, approved: ${approved} }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
// ══════════════════════════════════════════════════════════
// STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source)
// Routes interaction sub-message by step_type:
// run_command → CascadeRunCommandInteraction { confirm }
// write_to_file → AcknowledgeCascadeCodeEdit RPC (separate)
// open_browser_url → CascadeOpenBrowserUrlInteraction { confirm }
// send_command_input → CascadeSendCommandInputInteraction { confirm }
// read_url_content → CascadeReadUrlContentInteraction { confirm }
// mcp_tool → CascadeMcpInteraction { confirm }
// invoke_subagent → CascadeRunExtensionCodeInteraction { confirm }
// ══════════════════════════════════════════════════════════
if (sdk && approved) {
// Build interaction sub-message based on step_type
const typeLower = stepType.toLowerCase().replace('cortex_step_type_', '');
let interactionPayload = {};
if (typeLower.includes('write_to_file') || typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')) {
// CODE EDIT: Uses separate AcknowledgeCascadeCodeEdit RPC
try {
logToFile(`[APPROVAL-PROTO-ACK] AcknowledgeCascadeCodeEdit(cascadeId=${sessionId.substring(0, 8)}, accept=true, stepIndices=[${effectiveStepIndex}])`);
const ackResult = await sdk.ls.rawRPC('AcknowledgeCascadeCodeEdit', {
cascadeId: sessionId,
accept: true,
stepIndices: [effectiveStepIndex],
});
logToFile(`[APPROVAL-PROTO-ACK] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`);
return `RPC-PROTO-ACK:AcknowledgeCascadeCodeEdit`;
}
catch (e) {
logToFile(`[APPROVAL-PROTO-ACK] ❌ ${e.message.substring(0, 200)}`);
// Fall through to HandleCascadeUserInteraction
}
}
// Map step_type to interaction sub-message field
if (typeLower.includes('run_command') || typeLower.includes('shell_exec')) {
interactionPayload = { runCommand: { confirm: true } };
}
else if (typeLower.includes('open_browser')) {
interactionPayload = { openBrowserUrl: { confirm: true } };
}
else if (typeLower.includes('send_command_input')) {
interactionPayload = { sendCommandInput: { confirm: true } }; // guess field name
}
else if (typeLower.includes('read_url')) {
interactionPayload = { readUrlContent: { confirm: true } }; // guess
}
else if (typeLower.includes('mcp')) {
interactionPayload = { mcpTool: { confirm: true } }; // guess
}
else if (typeLower.includes('invoke_subagent') || typeLower.includes('extension_code')) {
interactionPayload = { runExtensionCode: { confirm: true } };
}
else {
// Default: try run_command (most common)
interactionPayload = { runCommand: { confirm: true } };
}
const protoVariants = [
// Variant A: camelCase with trajectoryId (proven working for run_command)
{
cascadeId: sessionId,
approved: approved,
});
logToFile(`[APPROVAL-1A] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1A:HandleCascadeUserInteraction(approved=${approved})`;
}
catch (e) {
logToFile(`[APPROVAL-1A] ❌ FAIL: ${e.message}`);
}
// Try variant B: { cascadeId, stepAction }
try {
const stepAction = approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT';
logToFile(`[APPROVAL-1B] HandleCascadeUserInteraction { cascadeId, stepAction: '${stepAction}' }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
interaction: {
trajectoryId: activeTrajectoryId || sessionId,
stepIndex: effectiveStepIndex,
...interactionPayload,
},
},
// Variant B: snake_case
{
cascade_id: sessionId,
interaction: {
trajectory_id: activeTrajectoryId || sessionId,
step_index: effectiveStepIndex,
...interactionPayload,
},
},
// Variant C: minimal (no trajectoryId)
{
cascadeId: sessionId,
stepAction: stepAction,
});
logToFile(`[APPROVAL-1B] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1B:HandleCascadeUserInteraction(stepAction=${stepAction})`;
}
catch (e) {
logToFile(`[APPROVAL-1B] ❌ FAIL: ${e.message}`);
}
// Try variant C: { cascadeId, userAction } (experimental)
try {
const userAction = approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED';
logToFile(`[APPROVAL-1C] HandleCascadeUserInteraction { cascadeId, userAction: '${userAction}' }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
userAction: userAction,
});
logToFile(`[APPROVAL-1C] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1C:HandleCascadeUserInteraction(userAction=${userAction})`;
}
catch (e) {
logToFile(`[APPROVAL-1C] ❌ FAIL: ${e.message}`);
interaction: {
stepIndex: effectiveStepIndex,
...interactionPayload,
},
},
];
for (let i = 0; i < protoVariants.length; i++) {
try {
const payload = protoVariants[i];
logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', payload);
logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-PROTO-${i}:HandleCascadeUserInteraction(${typeLower})`;
}
catch (e) {
logToFile(`[APPROVAL-PROTO-${i}] ❌ ${e.message.substring(0, 300)}`);
}
}
}
// ── Strategy 2: Renderer DOM Click via HTTP Bridge ──
// 2026-03-09: All SDK approval commands NOT REGISTERED (119 cmds tested).
// Keyboard simulation sends Enter to chat input (sends empty message — WRONG).
// New approach: set clickTrigger flag → renderer polls /trigger-click → clicks button.
// ══════════════════════════════════════════════════════════
// STRATEGY 0A: executeCascadeAction
// ══════════════════════════════════════════════════════════
const cascadeActionVariants = [
{ action: approved ? 'accept' : 'reject' },
{ action: approved ? 'ACCEPT' : 'REJECT' },
{ action: approved ? 'approve' : 'deny' },
{ action: approved ? 'run' : 'reject' },
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
{ cascadeId: sessionId, type: approved ? 'accept' : 'reject' },
approved ? 'accept' : 'reject', // plain string arg
approved ? 1 : 0, // numeric arg
];
for (let i = 0; i < cascadeActionVariants.length; i++) {
try {
const arg = cascadeActionVariants[i];
logToFile(`[APPROVAL-0A-${i}] executeCascadeAction(${JSON.stringify(arg)})`);
const result = await vscode.commands.executeCommand('antigravity.executeCascadeAction', arg);
logToFile(`[APPROVAL-0A-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0A:executeCascadeAction(variant=${i})`;
}
catch (e) {
logToFile(`[APPROVAL-0A-${i}] ❌ ${e.message.substring(0, 100)}`);
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0B: Direct approval commands (brute-force try all 7 SDK commands)
// ══════════════════════════════════════════════════════════
const commandsToTry = approved
? [
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.agent.acceptAgentStep',
'antigravity.command.accept',
]
: [
'antigravity.terminalCommand.reject',
'antigravity.agent.rejectAgentStep',
'antigravity.command.reject',
];
for (const cmd of commandsToTry) {
// Try with no args, then with sessionId
for (const args of [[], [sessionId], [{ cascadeId: sessionId }]]) {
try {
logToFile(`[APPROVAL-0B] ${cmd}(${JSON.stringify(args)})`);
const result = await vscode.commands.executeCommand(cmd, ...args);
logToFile(`[APPROVAL-0B] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0B:${cmd}`;
}
catch (e) {
logToFile(`[APPROVAL-0B] ❌ ${e.message.substring(0, 80)}`);
}
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0C: Electron webContents access (pierce webview isolation)
// ══════════════════════════════════════════════════════════
try {
logToFile(`[APPROVAL-0C] Attempting Electron webContents access...`);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const electron = require('electron');
const remote = electron.remote;
if (remote) {
logToFile(`[APPROVAL-0C] electron.remote available!`);
const allWC = remote.webContents.getAllWebContents();
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
for (const wc of allWC) {
const wcUrl = wc.getURL() || '';
logToFile(`[APPROVAL-0C] wc: ${wcUrl.substring(0, 100)}`);
if (wcUrl.includes('vscode-webview://') || wcUrl.includes('webview')) {
const clickScript = approved
? `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Run|Accept|Allow/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`
: `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Reject|Deny|Cancel/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`;
const clickResult = await wc.executeJavaScript(clickScript);
logToFile(`[APPROVAL-0C] executeJavaScript result: ${clickResult}`);
if (clickResult && clickResult.startsWith('clicked:')) {
return `ELECTRON-0C:webContents(${clickResult})`;
}
}
}
}
else {
logToFile(`[APPROVAL-0C] electron.remote NOT available`);
// Try without remote (main process context)
const wc = electron.webContents;
if (wc && typeof wc.getAllWebContents === 'function') {
logToFile(`[APPROVAL-0C] Direct electron.webContents available!`);
const allWC = wc.getAllWebContents();
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
}
else {
logToFile(`[APPROVAL-0C] No webContents access from Extension Host`);
}
}
}
catch (e) {
logToFile(`[APPROVAL-0C] ❌ ${e.message.substring(0, 150)}`);
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0D: sendChatActionMessage (may route to agent)
// ══════════════════════════════════════════════════════════
const chatActionVariants = [
{ action: approved ? 'accept' : 'reject', cascadeId: sessionId },
{ message: approved ? 'accept' : 'reject', conversationId: sessionId },
approved ? 'accept' : 'reject',
];
for (let i = 0; i < chatActionVariants.length; i++) {
try {
logToFile(`[APPROVAL-0D-${i}] sendChatActionMessage(${JSON.stringify(chatActionVariants[i])})`);
const result = await vscode.commands.executeCommand('antigravity.sendChatActionMessage', chatActionVariants[i]);
logToFile(`[APPROVAL-0D-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0D:sendChatActionMessage(variant=${i})`;
}
catch (e) {
logToFile(`[APPROVAL-0D-${i}] ❌ ${e.message.substring(0, 80)}`);
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 1: HandleCascadeUserInteraction RPC (expanded variants)
// ══════════════════════════════════════════════════════════
if (sdk) {
const rpcVariants = [
// Original variants
{ cascadeId: sessionId, approved: approved },
{ cascadeId: sessionId, stepAction: approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT' },
{ cascadeId: sessionId, userAction: approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED' },
// New variants — ConnectRPC protobuf field naming conventions
{ cascade_id: sessionId, accepted: approved },
{ cascadeId: sessionId, accepted: approved },
{ cascade_id: sessionId, user_action: approved ? 1 : 2 }, // enum as int
{ cascadeId: sessionId, interaction: approved ? 'ACCEPT' : 'REJECT' },
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
// With step index from last known waiting step
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, accepted: approved },
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, approved: approved },
];
for (let i = 0; i < rpcVariants.length; i++) {
try {
logToFile(`[APPROVAL-1-${i}] HandleCascadeUserInteraction(${JSON.stringify(rpcVariants[i]).substring(0, 120)})`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', rpcVariants[i]);
logToFile(`[APPROVAL-1-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1-${i}:HandleCascadeUserInteraction`;
}
catch (e) {
logToFile(`[APPROVAL-1-${i}] ❌ ${e.message.substring(0, 80)}`);
}
}
}
// ── Strategy 2: Renderer DOM Click via HTTP Bridge (kept as fallback) ──
try {
const triggerAction = approved ? 'approve' : 'reject';
logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`);
@@ -2078,19 +2489,11 @@ async function tryApprovalStrategies(approved, sessionId) {
catch (e) {
logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`);
}
// ── Strategy 3: ResolveOutstandingSteps (REJECT ONLY — this CANCELS!) ──
// ── Strategy 3: ResolveOutstandingSteps — DISABLED (too destructive!) ──
// This was cancelling AG work when bot sent approved=false.
// DO NOT enable without explicit user confirmation.
if (!approved && sdk) {
try {
logToFile(`[APPROVAL-3] ResolveOutstandingSteps (REJECT/CANCEL only!)`);
const rpcResult = await sdk.ls.rawRPC('ResolveOutstandingSteps', {
cascadeId: sessionId,
});
logToFile(`[APPROVAL-3] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-3:ResolveOutstandingSteps(cancel)`;
}
catch (e) {
logToFile(`[APPROVAL-3] ❌ FAIL: ${e.message}`);
}
logToFile(`[APPROVAL-3] ResolveOutstandingSteps DISABLED — reject only logs, no cancel`);
}
logToFile(`[APPROVAL] ⚠️ ALL strategies attempted — check logs for results`);
return `ALL_ATTEMPTED:${action}`;

File diff suppressed because one or more lines are too long

View File

@@ -81,6 +81,7 @@ function ensureBridgeDir() {
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
let activeSessionId = '';
let activeTrajectoryId = '';
function writeChatSnapshot(text: string) {
try {
@@ -97,6 +98,8 @@ function writeChatSnapshot(text: string) {
const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`);
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars)`);
logToFile(`[SNAPSHOT] content: ${text.substring(0, 200)}`);
// Lazily register session → project mapping (correct because projectName is per-window)
if (activeSessionId) { writeRegistration(activeSessionId); }
} catch (e: any) {
@@ -403,6 +406,7 @@ const pendingResponses = new Map<string, { approved: boolean } | null>();
// Click trigger: extension sets this, renderer polls and clicks button
let clickTrigger: { action: 'approve' | 'reject'; timestamp: number } | null = null;
let sessionStalled = false; // true when session is stalled waiting for approval
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step (module-level for tryApprovalStrategies)
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
let deepInspectRequested = false;
@@ -489,8 +493,12 @@ function startObserverHttpBridge(): Promise<number> {
if (fs.existsSync(respFile)) {
try {
const data = JSON.parse(fs.readFileSync(respFile, 'utf8'));
fs.unlinkSync(respFile);
logToFile(`[HTTP] response sent: ${rid} approved=${data.approved}`);
logToFile(`[HTTP] response served to renderer: ${rid} approved=${data.approved}`);
// Delay deletion: processResponseFile (response watcher) may need to read it too.
// The watcher fires with 300ms delay, so 2s is safe.
setTimeout(() => {
try { fs.unlinkSync(respFile); } catch { }
}, 2000);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
} catch {
@@ -1376,13 +1384,16 @@ function setupMonitor() {
let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1;
let lastTaskStepIndex = -1;
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step
// lastPendingStepIndex is module-level (above sessionStalled)
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
let lastPendingTime = 0; // cooldown: minimum gap between pendings
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending
let lastModTime = ''; // track lastModifiedTime to distinguish thinking vs approval
let stallProbed = false; // prevent repeated step probes during same stall
let lastRelayedTaskText = ''; // dedup TASK_BOUNDARY relay
let wasRunning = false; // track RUNNING→IDLE transition for response capture
let lastUserInputStepIdx = -1; // track user input for response matching
let lastResponseCaptureStep = -1; // dedup: don't capture same response twice
setInterval(async () => {
pollCount++;
@@ -1432,6 +1443,7 @@ function setupMonitor() {
// Session changed?
if (bestSessionId !== activeSessionId) {
activeSessionId = bestSessionId;
activeTrajectoryId = (bestSession as any).trajectoryId || '';
activeSessionTitle = currentTitle;
lastKnownStepCount = currentCount;
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
@@ -1456,6 +1468,44 @@ function setupMonitor() {
if (delta > 0) {
console.log(`Gravity Bridge: [POLL#${pollCount}] +${delta} steps (${currentCount}) "${currentTitle}"`);
// Real-time response capture: fetch latest steps on every delta>0
if (isRunning && currentCount > lastResponseCaptureStep && sdk) {
try {
const rtOffset = Math.max(0, currentCount - 3);
const rtResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: bestSessionId,
stepOffset: rtOffset,
verbosity: 1, // DEBUG — includes plannerResponse text
});
if (rtResp?.steps?.length > 0) {
for (let ri = rtResp.steps.length - 1; ri >= 0; ri--) {
const s = rtResp.steps[ri];
const sType = s?.type || '';
const actualIdx = rtOffset + ri;
if (actualIdx <= lastResponseCaptureStep) continue;
if (sType.includes('PLANNER_RESPONSE') && s?.status?.includes('DONE')) {
const pr = s?.plannerResponse;
if (pr) {
let text = pr.modifiedResponse || pr.rawText || pr.text || '';
if (text.length > 10) {
lastResponseCaptureStep = actualIdx;
logToFile(`[RT-CAPTURE] step=${actualIdx} (${text.length} chars)`);
const truncated = text.length > 1800
? text.substring(0, 1800) + '\n\n_(이하 생략)_'
: text;
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;
}
}
}
}
}
} catch (rte: any) {
// Non-critical — don't spam logs
if (pollCount <= 5) logToFile(`[RT-CAPTURE] error: ${rte.message.substring(0, 80)}`);
}
}
}
// Log session state on EVERY poll for diagnostics
@@ -1532,7 +1582,56 @@ function setupMonitor() {
// Diagnostic: compare returned steps vs trajectory stepCount
logToFile(`[STEP-PROBE] returned=${steps.length} vs trajectory.stepCount=${currentCount}`);
if (steps.length < currentCount) {
logToFile(`[STEP-PROBE] ⚠️ 775-limit hit! steps=${steps.length} < stepCount=${currentCount}`);
logToFile(`[STEP-PROBE] ⚠️ 775-limit hit! steps=${steps.length} < stepCount=${currentCount} — retrying with stepOffset`);
// 775-LIMIT FIX: Retry with stepOffset to get latest steps
try {
const offset = Math.max(0, currentCount - 10);
const offsetResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: bestSessionId,
stepOffset: offset,
});
if (offsetResp?.steps?.length > 0) {
// Replace steps array with offset results
const offsetSteps = offsetResp.steps;
logToFile(`[STEP-PROBE] offset=${offset} returned ${offsetSteps.length} steps (latest)`);
// Scan for WAITING in offset results
for (let osi = offsetSteps.length - 1; osi >= 0; osi--) {
const oStep = offsetSteps[osi];
if (oStep?.status === 'CORTEX_STEP_STATUS_WAITING') {
const toolCall = oStep?.metadata?.toolCall;
const toolName = toolCall?.name || (oStep.type || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();
let command = toolName;
if (toolCall?.argumentsJson) {
try {
const args = JSON.parse(toolCall.argumentsJson);
if (args.CommandLine) command = `${toolName}: ${args.CommandLine.substring(0, 150)}`;
else if (args.TargetFile) command = `${toolName}: ${args.TargetFile.split(/[\\/]/).pop()}`;
else command = `${toolName}: ${Object.keys(args).join(', ')}`;
} catch { command = toolName; }
}
const actualIndex = offset + osi;
logToFile(`[STEP-PROBE] ★ WAITING (via offset)! step=${actualIndex} type=${oStep.type} cmd='${command}'`);
if (actualIndex !== lastPendingStepIndex) {
stallProbed = true;
lastPendingStepIndex = actualIndex;
lastPendingTime = Date.now();
sawRunningAfterPending = false;
writePendingApproval({
conversation_id: activeSessionId,
command,
description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
step_type: toolName,
step_index: actualIndex,
source: 'step_probe_offset',
});
}
break;
}
}
}
} catch (oe: any) {
logToFile(`[STEP-PROBE] offset retry failed: ${oe.message.substring(0, 100)}`);
}
}
// Scan last 5 steps backwards to find WAITING (RUN_COMMAND may not be last)
@@ -1586,6 +1685,10 @@ function setupMonitor() {
if (!foundWaiting) {
const lastStep = steps[steps.length - 1];
logToFile(`[STEP-PROBE] lastStep status=${lastStep?.status} type=${lastStep?.type} (not waiting)`);
// CRITICAL: Reset sessionStalled when step_probe confirms no WAITING.
// Without this, sessionStalled stays true during long AI generations
// (PLANNER_RESPONSE with delta=0), causing false positive "Run" detections.
sessionStalled = false;
}
}
} catch (e: any) {
@@ -1649,36 +1752,120 @@ function setupMonitor() {
// ── Process latestNotifyUserStep ──
const notifyStep = bestSession.latestNotifyUserStep;
if (notifyStep && notifyStep.stepIndex > lastNotifyStepIndex) {
lastNotifyStepIndex = notifyStep.stepIndex;
const content = notifyStep.step?.notifyUser?.notificationContent || '';
// Filter: only relay meaningful notifications (skip trivial ones)
if (content.length > 50) {
writeChatSnapshot(`📣 **알림** (step ${notifyStep.stepIndex})\n\n${content}`);
console.log(`Gravity Bridge: [POLL#${pollCount}] NOTIFY step=${notifyStep.stepIndex} ${content.length} chars`);
} else if (content.length > 0) {
logToFile(`[POLL] NOTIFY skipped (too short: ${content.length} chars): ${content.substring(0, 80)}`);
if (notifyStep) {
if (notifyStep.stepIndex > lastNotifyStepIndex) {
lastNotifyStepIndex = notifyStep.stepIndex;
const content = notifyStep.step?.notifyUser?.notificationContent || '';
logToFile(`[NOTIFY-STEP] NEW step=${notifyStep.stepIndex} content=${content.length} chars`);
// Filter: relay all non-empty notifications
if (content.length > 10) {
writeChatSnapshot(`📣 **알림** (step ${notifyStep.stepIndex})\n\n${content}`);
} else if (content.length > 0) {
logToFile(`[NOTIFY-STEP] skipped (too short: ${content.length} chars): ${content}`);
}
}
} else if (pollCount <= 5) {
logToFile(`[NOTIFY-STEP] null (no notify step in session)`);
}
// ── Process latestTaskBoundaryStep ──
const taskStep = bestSession.latestTaskBoundaryStep;
if (taskStep && taskStep.stepIndex > lastTaskStepIndex) {
lastTaskStepIndex = taskStep.stepIndex;
const tb = taskStep.step?.taskBoundary;
if (tb?.taskName) {
const mode = tb.mode ? tb.mode.replace('AGENT_MODE_', '') : '';
// Filter: skip status-only updates with same task name (noise)
const taskText = `${tb.taskName}|${tb.taskStatus || ''}`;
if (taskText !== lastRelayedTaskText) {
lastRelayedTaskText = taskText;
writeChatSnapshot(`📋 **[${mode}] ${tb.taskName}**\n${tb.taskStatus || ''}\n\n${tb.taskSummary || ''}`);
console.log(`Gravity Bridge: [POLL#${pollCount}] TASK step=${taskStep.stepIndex} "${tb.taskName}"`);
} else {
logToFile(`[POLL] TASK skipped (duplicate): "${tb.taskName}"`);
if (taskStep) {
if (taskStep.stepIndex > lastTaskStepIndex) {
lastTaskStepIndex = taskStep.stepIndex;
const tb = taskStep.step?.taskBoundary;
if (tb?.taskName) {
const mode = tb.mode ? tb.mode.replace('AGENT_MODE_', '') : '';
const taskText = `${tb.taskName}|${tb.taskStatus || ''}`;
logToFile(`[TASK-STEP] NEW step=${taskStep.stepIndex} name="${tb.taskName}" mode=${mode}`);
if (taskText !== lastRelayedTaskText) {
lastRelayedTaskText = taskText;
writeChatSnapshot(`📋 **[${mode}] ${tb.taskName}**\n${tb.taskStatus || ''}\n\n${tb.taskSummary || ''}`);
} else {
logToFile(`[TASK-STEP] skipped (duplicate): "${tb.taskName}"`);
}
}
}
} else if (pollCount <= 5) {
logToFile(`[TASK-STEP] null (no task step in session)`);
}
// ── RUNNING → IDLE transition: capture AI response for Discord ──
const userInputIdx = bestSession.lastUserInputStepIndex ?? -1;
if (userInputIdx > lastUserInputStepIdx) {
lastUserInputStepIdx = userInputIdx;
logToFile(`[RESPONSE-CAPTURE] user input detected at step ${userInputIdx}`);
}
if (wasRunning && !isRunning && currentCount > lastResponseCaptureStep) {
logToFile(`[RESPONSE-CAPTURE] RUNNING→IDLE, steps=${currentCount}, capturing response...`);
lastResponseCaptureStep = currentCount;
try {
const offset = Math.max(0, currentCount - 5);
const latestResp = await sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: bestSessionId,
stepOffset: offset,
verbosity: 1, // CLIENT_TRAJECTORY_VERBOSITY_DEBUG — includes full plannerResponse text
});
if (latestResp?.steps?.length > 0) {
const steps = latestResp.steps;
for (let ri = steps.length - 1; ri >= 0; ri--) {
const s = steps[ri];
const sType = s?.type || '';
if (sType.includes('PLANNER_RESPONSE') || sType.includes('MESSAGE')) {
let textContent = '';
// Extract from plannerResponse field
const pr = s?.plannerResponse;
if (pr) {
// Priority: modifiedResponse (confirmed field from AG)
if (pr.modifiedResponse) textContent = pr.modifiedResponse;
else if (pr.rawText) textContent = pr.rawText;
else if (pr.text) textContent = pr.text;
else if (pr.message) textContent = typeof pr.message === 'string' ? pr.message : '';
else if (pr.content?.parts) {
for (const p of pr.content.parts) {
if (p?.text) textContent += p.text;
}
}
// Log first time to capture actual field names
if (!textContent) {
logToFile(`[RESPONSE-CAPTURE] plannerResponse keys: [${Object.keys(pr).join(',')}] values(100): ${JSON.stringify(pr).substring(0,200)}`);
}
}
// Extract from ephemeralMessage field
const em = s?.ephemeralMessage;
if (!textContent && em) {
if (typeof em === 'string') textContent = em;
else if (em.message) textContent = em.message;
else if (em.content) textContent = typeof em.content === 'string' ? em.content : JSON.stringify(em.content);
}
// Fallback: metadata, content, rawOutput
if (!textContent) {
const parts = s?.content?.parts || s?.parts || [];
for (const p of parts) {
if (p?.text) textContent += p.text;
}
}
if (!textContent && s?.metadata?.text) textContent = s.metadata.text;
if (!textContent && s?.rawOutput) textContent = typeof s.rawOutput === 'string' ? s.rawOutput : JSON.stringify(s.rawOutput);
if (textContent.length > 10) {
logToFile(`[RESPONSE-CAPTURE] found ${sType} (${textContent.length} chars) at step ${offset + ri}`);
const truncated = textContent.length > 1800
? textContent.substring(0, 1800) + '\n\n_(이하 생략)_'
: textContent;
writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;
} else {
logToFile(`[RESPONSE-CAPTURE] ${sType} too short (${textContent.length}), keys=[${Object.keys(s).join(',')}]`);
}
}
}
}
} catch (re: any) {
logToFile(`[RESPONSE-CAPTURE] error: ${re.message.substring(0, 100)}`);
}
}
wasRunning = isRunning;
} catch (e: any) {
if (pollCount <= 5 || pollCount % 20 === 0) {
console.log(`Gravity Bridge: [POLL#${pollCount}] error: ${e.message}`);
@@ -1726,6 +1913,11 @@ function setupResponseWatcher() {
async function processResponseFile(filePath: string) {
try {
// Gracefully handle files already consumed by HTTP handler
if (!fs.existsSync(filePath)) {
// HTTP GET /response/:rid already served and deleted this file — skip silently
return;
}
const content = fs.readFileSync(filePath, 'utf-8');
const resp = JSON.parse(content);
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved}`;
@@ -1737,12 +1929,16 @@ async function processResponseFile(filePath: string) {
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
let sessionId = '';
let isDomObserver = false;
let pendingStepType = '';
let pendingStepIndex = -1;
if (fs.existsSync(pendingFile)) {
try {
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
sessionId = pending.conversation_id || '';
isDomObserver = pending.auto_detected === true
|| pending.source === 'dom_observer';
pendingStepType = pending.step_type || '';
pendingStepIndex = pending.step_index ?? lastPendingStepIndex;
} catch { }
}
@@ -1757,14 +1953,10 @@ async function processResponseFile(filePath: string) {
// DOM observer path: renderer polls /response/:rid and clicks directly
logToFile(`[RESPONSE] renderer-handled approval (rid=${resp.request_id})`);
} else {
// Step probe path: approve → trigger renderer click, reject → log only
if (approved) {
logToFile(`[RESPONSE] step_probe → approve via trigger-click`);
clickTrigger = { action: 'approve' as const, timestamp: Date.now() };
} else {
// SAFE: reject logs only — NO ResolveOutstandingSteps (it cancels AI work!)
logToFile(`[RESPONSE] step_probe → reject (log only, no destructive action)`);
}
// Step probe path: run ALL approval strategies
logToFile(`[RESPONSE] step_probe → tryApprovalStrategies(${approved}, ${activeSessionId.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
const strategyResult = await tryApprovalStrategies(approved, activeSessionId, pendingStepType, pendingStepIndex);
logToFile(`[RESPONSE] strategy result: ${strategyResult}`);
}
logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
@@ -1964,80 +2156,269 @@ function writePendingApproval(data: { conversation_id: string; command: string;
* 2. VS Code accept/reject commands (focus-dependent)
* 3. Log failure for manual intervention
*/
async function tryApprovalStrategies(approved: boolean, sessionId: string): Promise<string> {
async function tryApprovalStrategies(approved: boolean, sessionId: string, stepType: string = '', stepIndex: number = -1): Promise<string> {
const action = approved ? 'APPROVE' : 'REJECT';
logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)}`);
const effectiveStepIndex = stepIndex >= 0 ? stepIndex : lastPendingStepIndex;
logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${effectiveStepIndex}`);
// ── Dynamic Command Discovery (check if approval commands appear when step is WAITING) ──
// ── Dynamic Command Discovery (log what's available during WAITING state) ──
let approvalCmdList: string[] = [];
try {
const allCmds = await vscode.commands.getCommands(true);
const agCmds = allCmds.filter((c: string) => c.startsWith('antigravity.'));
const approvalCmds = agCmds.filter((c: string) => {
approvalCmdList = agCmds.filter((c: string) => {
const lower = c.toLowerCase();
return lower.includes('accept') || lower.includes('reject') || lower.includes('approve')
|| lower.includes('terminal') || lower.includes('run') || lower.includes('step');
|| lower.includes('terminal') || lower.includes('run') || lower.includes('step')
|| lower.includes('cascade') || lower.includes('action');
});
logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmds.length} approval-related:`);
for (const c of approvalCmds) {
logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`);
for (const c of approvalCmdList) {
logToFile(`[APPROVAL-CMD-CHECK] → ${c}`);
}
// Dump ALL antigravity.* commands for full comparison
logToFile(`[APPROVAL-CMD-CHECK] FULL LIST (${agCmds.length}):`);
for (const c of agCmds) {
logToFile(`[APPROVAL-CMD-FULL] ${c}`);
}
} catch (e: any) {
logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`);
}
// ── Strategy 1: HandleCascadeUserInteraction RPC ──
if (sdk) {
// Try variant A: { cascadeId, approved }
try {
logToFile(`[APPROVAL-1A] HandleCascadeUserInteraction { cascadeId, approved: ${approved} }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
approved: approved,
});
logToFile(`[APPROVAL-1A] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1A:HandleCascadeUserInteraction(approved=${approved})`;
} catch (e: any) {
logToFile(`[APPROVAL-1A] ❌ FAIL: ${e.message}`);
// ══════════════════════════════════════════════════════════
// STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source)
// Routes interaction sub-message by step_type:
// run_command → CascadeRunCommandInteraction { confirm }
// write_to_file → AcknowledgeCascadeCodeEdit RPC (separate)
// open_browser_url → CascadeOpenBrowserUrlInteraction { confirm }
// send_command_input → CascadeSendCommandInputInteraction { confirm }
// read_url_content → CascadeReadUrlContentInteraction { confirm }
// mcp_tool → CascadeMcpInteraction { confirm }
// invoke_subagent → CascadeRunExtensionCodeInteraction { confirm }
// ══════════════════════════════════════════════════════════
if (sdk && approved) {
// Build interaction sub-message based on step_type
const typeLower = stepType.toLowerCase().replace('cortex_step_type_', '');
let interactionPayload: Record<string, any> = {};
if (typeLower.includes('write_to_file') || typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')) {
// CODE EDIT: Uses separate AcknowledgeCascadeCodeEdit RPC
try {
logToFile(`[APPROVAL-PROTO-ACK] AcknowledgeCascadeCodeEdit(cascadeId=${sessionId.substring(0,8)}, accept=true, stepIndices=[${effectiveStepIndex}])`);
const ackResult = await sdk.ls.rawRPC('AcknowledgeCascadeCodeEdit', {
cascadeId: sessionId,
accept: true,
stepIndices: [effectiveStepIndex],
});
logToFile(`[APPROVAL-PROTO-ACK] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`);
return `RPC-PROTO-ACK:AcknowledgeCascadeCodeEdit`;
} catch (e: any) {
logToFile(`[APPROVAL-PROTO-ACK] ❌ ${e.message.substring(0, 200)}`);
// Fall through to HandleCascadeUserInteraction
}
}
// Try variant B: { cascadeId, stepAction }
try {
const stepAction = approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT';
logToFile(`[APPROVAL-1B] HandleCascadeUserInteraction { cascadeId, stepAction: '${stepAction}' }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
cascadeId: sessionId,
stepAction: stepAction,
});
logToFile(`[APPROVAL-1B] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1B:HandleCascadeUserInteraction(stepAction=${stepAction})`;
} catch (e: any) {
logToFile(`[APPROVAL-1B] ❌ FAIL: ${e.message}`);
// Map step_type to interaction sub-message field
if (typeLower.includes('run_command') || typeLower.includes('shell_exec')) {
interactionPayload = { runCommand: { confirm: true } };
} else if (typeLower.includes('open_browser')) {
interactionPayload = { openBrowserUrl: { confirm: true } };
} else if (typeLower.includes('send_command_input')) {
interactionPayload = { sendCommandInput: { confirm: true } }; // guess field name
} else if (typeLower.includes('read_url')) {
interactionPayload = { readUrlContent: { confirm: true } }; // guess
} else if (typeLower.includes('mcp')) {
interactionPayload = { mcpTool: { confirm: true } }; // guess
} else if (typeLower.includes('invoke_subagent') || typeLower.includes('extension_code')) {
interactionPayload = { runExtensionCode: { confirm: true } };
} else {
// Default: try run_command (most common)
interactionPayload = { runCommand: { confirm: true } };
}
// Try variant C: { cascadeId, userAction } (experimental)
try {
const userAction = approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED';
logToFile(`[APPROVAL-1C] HandleCascadeUserInteraction { cascadeId, userAction: '${userAction}' }`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', {
const protoVariants = [
// Variant A: camelCase with trajectoryId (proven working for run_command)
{
cascadeId: sessionId,
userAction: userAction,
});
logToFile(`[APPROVAL-1C] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1C:HandleCascadeUserInteraction(userAction=${userAction})`;
} catch (e: any) {
logToFile(`[APPROVAL-1C] ❌ FAIL: ${e.message}`);
interaction: {
trajectoryId: activeTrajectoryId || sessionId,
stepIndex: effectiveStepIndex,
...interactionPayload,
},
},
// Variant B: snake_case
{
cascade_id: sessionId,
interaction: {
trajectory_id: activeTrajectoryId || sessionId,
step_index: effectiveStepIndex,
...interactionPayload,
},
},
// Variant C: minimal (no trajectoryId)
{
cascadeId: sessionId,
interaction: {
stepIndex: effectiveStepIndex,
...interactionPayload,
},
},
];
for (let i = 0; i < protoVariants.length; i++) {
try {
const payload = protoVariants[i];
logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', payload);
logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-PROTO-${i}:HandleCascadeUserInteraction(${typeLower})`;
} catch (e: any) {
logToFile(`[APPROVAL-PROTO-${i}] ❌ ${e.message.substring(0, 300)}`);
}
}
}
// ── Strategy 2: Renderer DOM Click via HTTP Bridge ──
// 2026-03-09: All SDK approval commands NOT REGISTERED (119 cmds tested).
// Keyboard simulation sends Enter to chat input (sends empty message — WRONG).
// New approach: set clickTrigger flag → renderer polls /trigger-click → clicks button.
// ══════════════════════════════════════════════════════════
// STRATEGY 0A: executeCascadeAction
// ══════════════════════════════════════════════════════════
const cascadeActionVariants = [
{ action: approved ? 'accept' : 'reject' },
{ action: approved ? 'ACCEPT' : 'REJECT' },
{ action: approved ? 'approve' : 'deny' },
{ action: approved ? 'run' : 'reject' },
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
{ cascadeId: sessionId, type: approved ? 'accept' : 'reject' },
approved ? 'accept' : 'reject', // plain string arg
approved ? 1 : 0, // numeric arg
];
for (let i = 0; i < cascadeActionVariants.length; i++) {
try {
const arg = cascadeActionVariants[i];
logToFile(`[APPROVAL-0A-${i}] executeCascadeAction(${JSON.stringify(arg)})`);
const result = await vscode.commands.executeCommand('antigravity.executeCascadeAction', arg);
logToFile(`[APPROVAL-0A-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0A:executeCascadeAction(variant=${i})`;
} catch (e: any) {
logToFile(`[APPROVAL-0A-${i}] ❌ ${e.message.substring(0, 100)}`);
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0B: Direct approval commands (brute-force try all 7 SDK commands)
// ══════════════════════════════════════════════════════════
const commandsToTry = approved
? [
'antigravity.terminalCommand.run',
'antigravity.terminalCommand.accept',
'antigravity.agent.acceptAgentStep',
'antigravity.command.accept',
]
: [
'antigravity.terminalCommand.reject',
'antigravity.agent.rejectAgentStep',
'antigravity.command.reject',
];
for (const cmd of commandsToTry) {
// Try with no args, then with sessionId
for (const args of [[], [sessionId], [{ cascadeId: sessionId }]]) {
try {
logToFile(`[APPROVAL-0B] ${cmd}(${JSON.stringify(args)})`);
const result = await vscode.commands.executeCommand(cmd, ...args);
logToFile(`[APPROVAL-0B] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0B:${cmd}`;
} catch (e: any) {
logToFile(`[APPROVAL-0B] ❌ ${e.message.substring(0, 80)}`);
}
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0C: Electron webContents access (pierce webview isolation)
// ══════════════════════════════════════════════════════════
try {
logToFile(`[APPROVAL-0C] Attempting Electron webContents access...`);
// eslint-disable-next-line @typescript-eslint/no-var-requires
const electron = require('electron');
const remote = (electron as any).remote;
if (remote) {
logToFile(`[APPROVAL-0C] electron.remote available!`);
const allWC = remote.webContents.getAllWebContents();
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
for (const wc of allWC) {
const wcUrl = wc.getURL() || '';
logToFile(`[APPROVAL-0C] wc: ${wcUrl.substring(0, 100)}`);
if (wcUrl.includes('vscode-webview://') || wcUrl.includes('webview')) {
const clickScript = approved
? `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Run|Accept|Allow/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`
: `(function(){ var btns = document.querySelectorAll('button'); for(var b of btns){ if(/Reject|Deny|Cancel/i.test(b.textContent)){ b.click(); return 'clicked:'+b.textContent; } } return 'no-match'; })()`;
const clickResult = await wc.executeJavaScript(clickScript);
logToFile(`[APPROVAL-0C] executeJavaScript result: ${clickResult}`);
if (clickResult && clickResult.startsWith('clicked:')) {
return `ELECTRON-0C:webContents(${clickResult})`;
}
}
}
} else {
logToFile(`[APPROVAL-0C] electron.remote NOT available`);
// Try without remote (main process context)
const wc = (electron as any).webContents;
if (wc && typeof wc.getAllWebContents === 'function') {
logToFile(`[APPROVAL-0C] Direct electron.webContents available!`);
const allWC = wc.getAllWebContents();
logToFile(`[APPROVAL-0C] Found ${allWC.length} webContents`);
} else {
logToFile(`[APPROVAL-0C] No webContents access from Extension Host`);
}
}
} catch (e: any) {
logToFile(`[APPROVAL-0C] ❌ ${e.message.substring(0, 150)}`);
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0D: sendChatActionMessage (may route to agent)
// ══════════════════════════════════════════════════════════
const chatActionVariants = [
{ action: approved ? 'accept' : 'reject', cascadeId: sessionId },
{ message: approved ? 'accept' : 'reject', conversationId: sessionId },
approved ? 'accept' : 'reject',
];
for (let i = 0; i < chatActionVariants.length; i++) {
try {
logToFile(`[APPROVAL-0D-${i}] sendChatActionMessage(${JSON.stringify(chatActionVariants[i])})`);
const result = await vscode.commands.executeCommand('antigravity.sendChatActionMessage', chatActionVariants[i]);
logToFile(`[APPROVAL-0D-${i}] ✅ SUCCESS: ${JSON.stringify(result).substring(0, 200)}`);
return `CMD-0D:sendChatActionMessage(variant=${i})`;
} catch (e: any) {
logToFile(`[APPROVAL-0D-${i}] ❌ ${e.message.substring(0, 80)}`);
}
}
// ══════════════════════════════════════════════════════════
// STRATEGY 1: HandleCascadeUserInteraction RPC (expanded variants)
// ══════════════════════════════════════════════════════════
if (sdk) {
const rpcVariants = [
// Original variants
{ cascadeId: sessionId, approved: approved },
{ cascadeId: sessionId, stepAction: approved ? 'STEP_ACTION_ACCEPT' : 'STEP_ACTION_REJECT' },
{ cascadeId: sessionId, userAction: approved ? 'USER_ACTION_APPROVED' : 'USER_ACTION_REJECTED' },
// New variants — ConnectRPC protobuf field naming conventions
{ cascade_id: sessionId, accepted: approved },
{ cascadeId: sessionId, accepted: approved },
{ cascade_id: sessionId, user_action: approved ? 1 : 2 }, // enum as int
{ cascadeId: sessionId, interaction: approved ? 'ACCEPT' : 'REJECT' },
{ cascadeId: sessionId, action: approved ? 'accept' : 'reject' },
// With step index from last known waiting step
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, accepted: approved },
{ cascadeId: sessionId, stepIndex: lastPendingStepIndex, approved: approved },
];
for (let i = 0; i < rpcVariants.length; i++) {
try {
logToFile(`[APPROVAL-1-${i}] HandleCascadeUserInteraction(${JSON.stringify(rpcVariants[i]).substring(0, 120)})`);
const rpcResult = await sdk.ls.rawRPC('HandleCascadeUserInteraction', rpcVariants[i]);
logToFile(`[APPROVAL-1-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-1-${i}:HandleCascadeUserInteraction`;
} catch (e: any) {
logToFile(`[APPROVAL-1-${i}] ❌ ${e.message.substring(0, 80)}`);
}
}
}
// ── Strategy 2: Renderer DOM Click via HTTP Bridge (kept as fallback) ──
try {
const triggerAction = approved ? 'approve' : 'reject';
logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`);
@@ -2047,18 +2428,11 @@ async function tryApprovalStrategies(approved: boolean, sessionId: string): Prom
logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`);
}
// ── Strategy 3: ResolveOutstandingSteps (REJECT ONLY — this CANCELS!) ──
// ── Strategy 3: ResolveOutstandingSteps — DISABLED (too destructive!) ──
// This was cancelling AG work when bot sent approved=false.
// DO NOT enable without explicit user confirmation.
if (!approved && sdk) {
try {
logToFile(`[APPROVAL-3] ResolveOutstandingSteps (REJECT/CANCEL only!)`);
const rpcResult = await sdk.ls.rawRPC('ResolveOutstandingSteps', {
cascadeId: sessionId,
});
logToFile(`[APPROVAL-3] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-3:ResolveOutstandingSteps(cancel)`;
} catch (e: any) {
logToFile(`[APPROVAL-3] ❌ FAIL: ${e.message}`);
}
logToFile(`[APPROVAL-3] ResolveOutstandingSteps DISABLED — reject only logs, no cancel`);
}
logToFile(`[APPROVAL] ⚠️ ALL strategies attempted — check logs for results`);