Compare commits
11 Commits
98646fed27
...
563fbadd5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 563fbadd5a | |||
| 2958bdc950 | |||
| 9b047c0c7d | |||
| 7ed2db90df | |||
| 1089c6ce61 | |||
| e586bb6d41 | |||
| 8c6d25c6d4 | |||
| 628b5ae2fa | |||
| 2361aa7558 | |||
| 0e3a896c86 | |||
| 1f63f60280 |
49
bot.py
49
bot.py
@@ -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
|
||||
|
||||
|
||||
30
docs/devlog/entries/20260310-002.md
Normal file
30
docs/devlog/entries/20260310-002.md
Normal 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 / 코드 편집 승인 테스트
|
||||
@@ -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
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user