- WS response 파일에 _from_ws 마커 추가하여 processResponseFile 삭제 방지 - extractContextFromNearby에 sibling 탐색 추가 (AG Native DOM 구조 대응) - thinking 블록 (max-h-[200px]) 필터링으로 내부 사고 릴레이 차단 - DOM 탐색 depth 5→10 확대 + pre.font-mono 우선 탐색 - 사용자 메시지 셀렉터 (.select-text.rounded-lg) 추가
559 lines
27 KiB
TypeScript
559 lines
27 KiB
TypeScript
/**
|
|
* Approval Handler — response processing + approval execution pipeline.
|
|
*
|
|
* Extracted from step-probe.ts to reduce file size.
|
|
* Handles:
|
|
* - Response file watching (file-based bridge fallback)
|
|
* - Response processing (diff_review, DOM observer, step_probe paths)
|
|
* - Multi-strategy approval execution (VS Code commands, RPC, DOM click)
|
|
* - Diff review Accept/Reject via VS Code commands
|
|
*
|
|
* STRATEGY ORDER (most reliable first):
|
|
* 0. antigravity.acceptAgentStep / rejectAgentStep — AG's own commands, always works
|
|
* 1. HandleCascadeUserInteraction RPC — cross-platform, needs stepIndex
|
|
* 2. DOM click trigger via HTTP bridge — fallback
|
|
*/
|
|
|
|
import * as vscode from 'vscode';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import type { BridgeContext } from './step-probe';
|
|
|
|
// ─── Module-level state (injected via initApprovalHandler) ───
|
|
|
|
let ctx: BridgeContext;
|
|
let responseWatcher: fs.FSWatcher | null = null;
|
|
let getTrajectoryId: () => string = () => '';
|
|
|
|
// ─── Public API ───
|
|
|
|
/**
|
|
* Initialize the approval handler with shared context.
|
|
* Called from initStepProbe() in step-probe.ts.
|
|
*/
|
|
export function initApprovalHandler(
|
|
context: BridgeContext,
|
|
trajectoryIdGetter: () => string,
|
|
) {
|
|
ctx = context;
|
|
getTrajectoryId = trajectoryIdGetter;
|
|
}
|
|
|
|
/**
|
|
* Handle diff_review Accept all / Reject all response.
|
|
* Called from both WS onResponse (extension.ts) and processResponseFile.
|
|
*
|
|
* This was previously only in processResponseFile (file-bridge path).
|
|
* When WS was added (v0.4.x), the onResponse handler skipped this logic entirely,
|
|
* causing Accept All to stop working — a regression.
|
|
*/
|
|
export async function handleDiffReviewResponse(data: {
|
|
request_id: string;
|
|
approved: boolean;
|
|
button_index?: number;
|
|
step_type?: string;
|
|
}): Promise<boolean> {
|
|
const btnIdx = data.button_index ?? -1;
|
|
const isAccept = btnIdx === 0 || (btnIdx === -1 && data.approved);
|
|
const cmd = isAccept
|
|
? 'antigravity.prioritized.agentAcceptAllInFile'
|
|
: 'antigravity.prioritized.agentRejectAllInFile';
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] → ${isAccept ? 'ACCEPT' : 'REJECT'} (btnIdx=${btnIdx}, rid=${data.request_id?.substring(0, 12)})`);
|
|
|
|
let diffReviewDone = false;
|
|
let modifiedFiles: string[] = [];
|
|
|
|
// Load tracked step indices and modified files from memory cache or pending file
|
|
const trackedSteps: number[] = [];
|
|
const memMeta = ctx.diffReviewMetadata.get(data.request_id);
|
|
if (memMeta) {
|
|
trackedSteps.push(...memMeta.edit_step_indices);
|
|
modifiedFiles = memMeta.modified_files;
|
|
ctx.diffReviewMetadata.delete(data.request_id);
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] loaded from memory: steps=[${trackedSteps.join(',')}] files=${modifiedFiles.length}`);
|
|
} else {
|
|
try {
|
|
const pf = path.join(ctx.bridgePath, 'pending', `${data.request_id}.json`);
|
|
if (fs.existsSync(pf)) {
|
|
const pd = JSON.parse(fs.readFileSync(pf, 'utf-8'));
|
|
if (pd.edit_step_indices) trackedSteps.push(...pd.edit_step_indices);
|
|
if (pd.modified_files) modifiedFiles = pd.modified_files;
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
// Strategy 1: VS Code command — open review panel + focus each file + accept/reject
|
|
try {
|
|
try {
|
|
await vscode.commands.executeCommand('antigravity.openReviewChanges');
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] openReviewChanges OK`);
|
|
await new Promise(r => setTimeout(r, 500));
|
|
} catch { }
|
|
|
|
if (modifiedFiles.length > 0) {
|
|
for (const fp of modifiedFiles) {
|
|
try {
|
|
const uri = vscode.Uri.file(fp);
|
|
const doc = await vscode.workspace.openTextDocument(uri);
|
|
await vscode.window.showTextDocument(doc, { preview: false });
|
|
await new Promise(r => setTimeout(r, 300));
|
|
await vscode.commands.executeCommand(cmd);
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] ✅ ${cmd} on ${fp.split(/[\\/]/).pop()} OK`);
|
|
diffReviewDone = true;
|
|
} catch (e: any) {
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] per-file error on ${fp}: ${e.message?.substring(0, 80)}`);
|
|
}
|
|
}
|
|
} else {
|
|
await vscode.commands.executeCommand(cmd);
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] ✅ ${cmd} executed (no file list)`);
|
|
diffReviewDone = true;
|
|
}
|
|
} catch (cmdErr: any) {
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] Strategy 1 command error: ${cmdErr.message?.substring(0, 200)}`);
|
|
}
|
|
|
|
// Strategy 2: individual hunk accept/reject
|
|
if (!diffReviewDone) {
|
|
try {
|
|
const hunkCmd = isAccept
|
|
? 'antigravity.prioritized.agentAcceptFocusedHunk'
|
|
: 'antigravity.prioritized.agentRejectFocusedHunk';
|
|
await vscode.commands.executeCommand(hunkCmd);
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] ✅ ${hunkCmd} fallback OK`);
|
|
diffReviewDone = true;
|
|
} catch (hunkErr: any) {
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] hunk fallback error: ${hunkErr.message?.substring(0, 100)}`);
|
|
}
|
|
}
|
|
|
|
if (!diffReviewDone) {
|
|
ctx.logToFile(`[DIFF-REVIEW-WS] ❌ ALL strategies failed for rid=${data.request_id}`);
|
|
}
|
|
return diffReviewDone;
|
|
}
|
|
|
|
// ─── Response Watcher ───
|
|
|
|
export function setupResponseWatcher() {
|
|
const responseDir = path.join(ctx.bridgePath, 'response');
|
|
if (!fs.existsSync(responseDir)) {
|
|
fs.mkdirSync(responseDir, { recursive: true });
|
|
}
|
|
|
|
const processAnyResponse = (filename: string) => {
|
|
const fp = path.join(responseDir, filename);
|
|
if (fs.existsSync(fp)) {
|
|
// Check if this response belongs to our project
|
|
const rid = filename.replace('.json', '');
|
|
const pendingFile = path.join(ctx.bridgePath, 'pending', `${rid}.json`);
|
|
if (fs.existsSync(pendingFile)) {
|
|
try {
|
|
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
|
|
if (pending.project_name && pending.project_name !== ctx.projectName) {
|
|
return; // Not our project
|
|
}
|
|
} catch { }
|
|
} else {
|
|
// Pending file missing (deleted or auto_resolved) — check response data itself
|
|
try {
|
|
const respData = JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
|
if (respData.project_name && respData.project_name !== ctx.projectName) {
|
|
return;
|
|
}
|
|
} catch { }
|
|
}
|
|
setTimeout(() => processResponseFile(fp), 300);
|
|
}
|
|
};
|
|
|
|
const pollAllResponses = () => {
|
|
try {
|
|
if (!fs.existsSync(responseDir)) return;
|
|
for (const f of fs.readdirSync(responseDir)) {
|
|
if (f.endsWith('.json')) {
|
|
processAnyResponse(f);
|
|
}
|
|
}
|
|
} catch { }
|
|
};
|
|
|
|
pollAllResponses(); // Process any existing responses on startup
|
|
|
|
try {
|
|
responseWatcher = fs.watch(responseDir, (event, filename) => {
|
|
if (filename && filename.endsWith('.json') && event === 'rename') {
|
|
processAnyResponse(filename);
|
|
}
|
|
});
|
|
console.log('Gravity Bridge: response watcher started');
|
|
} catch (e: any) {
|
|
console.log(`Gravity Bridge: response watcher failed: ${e.message}`);
|
|
}
|
|
|
|
// Polling fallback: fs.watch on Windows can silently fail
|
|
setInterval(pollAllResponses, 3000);
|
|
}
|
|
|
|
// ─── Response File Processing ───
|
|
|
|
async function processResponseFile(filePath: string) {
|
|
try {
|
|
// Gracefully handle files already consumed by HTTP handler
|
|
if (!fs.existsSync(filePath)) {
|
|
return;
|
|
}
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const resp = JSON.parse(content);
|
|
|
|
// v22: Skip files written by the WS response handler (extension.ts onResponse).
|
|
// Those files exist ONLY for Observer's pollResponseGroup to read via HTTP.
|
|
// The WS handler already calls tryApprovalStrategies, so processing here is redundant.
|
|
// Without this skip, the watcher deletes the file before Observer can poll it
|
|
// (since no pending file exists for the isDomObserver check).
|
|
if (resp._from_ws) {
|
|
ctx.logToFile(`[RESPONSE] SKIP _from_ws file (for Observer pollResponseGroup): ${resp.request_id}`);
|
|
return;
|
|
}
|
|
|
|
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved} step_type=${resp.step_type || '(missing)'} keys=[${Object.keys(resp).join(',')}]`;
|
|
console.log(`Gravity Bridge: ${msg}`);
|
|
ctx.logToFile(msg);
|
|
|
|
// Skip stale timeout responses: if pending is old and this is a reject, it's likely a bot timeout
|
|
const ridTimestamp = parseInt((resp.request_id || '').split('_')[0], 10);
|
|
if (!isNaN(ridTimestamp)) {
|
|
const ageMs = Date.now() - ridTimestamp;
|
|
const STALE_THRESHOLD_MS = 120_000; // 2 minutes
|
|
if (ageMs > STALE_THRESHOLD_MS && !resp.approved) {
|
|
ctx.logToFile(`[RESPONSE] SKIPPED stale timeout: rid=${resp.request_id} age=${Math.round(ageMs / 1000)}s (>${STALE_THRESHOLD_MS / 1000}s, reject)`);
|
|
try { fs.unlinkSync(filePath); } catch { }
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Find matching pending request
|
|
const pendingDir = path.join(ctx.bridgePath, 'pending');
|
|
const pendingFile = path.join(pendingDir, `${resp.request_id}.json`);
|
|
let sessionId = '';
|
|
let isDomObserver = false;
|
|
let pendingStepType = resp.step_type || ''; // from bot's response (new)
|
|
let pendingStepIndex = -1;
|
|
if (fs.existsSync(pendingFile)) {
|
|
try {
|
|
const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
|
|
|
|
// FIX #2: Skip if pending was already resolved locally (auto_resolve or expired)
|
|
if (pending.status === 'auto_resolved' || pending.status === 'expired') {
|
|
ctx.logToFile(`[RESPONSE] SKIP — pending already ${pending.status} (rid=${resp.request_id})`);
|
|
try { fs.unlinkSync(filePath); } catch { }
|
|
return;
|
|
}
|
|
|
|
sessionId = pending.conversation_id || '';
|
|
isDomObserver = pending.auto_detected === true
|
|
|| pending.source === 'dom_observer';
|
|
pendingStepType = pending.step_type || '';
|
|
pendingStepIndex = pending.step_index ?? ctx.lastPendingStepIndex;
|
|
// File permission detection: check command content or explicit step_type
|
|
const cmd = (pending.command || '').toLowerCase();
|
|
if (pendingStepType === 'file_permission' || cmd.includes('allow') || cmd.includes('파일 접근')) {
|
|
// Map button_index → scope: 0=Once, 1=Conversation, 2=Deny
|
|
const btnIdx = resp.button_index ?? -1;
|
|
if (btnIdx === 1) {
|
|
pendingStepType = 'file_permission_conversation';
|
|
} else if (btnIdx === 2) {
|
|
pendingStepType = 'file_permission_deny';
|
|
} else {
|
|
pendingStepType = 'file_permission_once';
|
|
}
|
|
ctx.logToFile(`[RESPONSE] file_permission detected from pending cmd, mapped to ${pendingStepType}`);
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
// ═══ MULTI-STRATEGY APPROVAL (v3.0) ═══
|
|
const approved = resp.approved;
|
|
|
|
// ── diff_review: Accept all / Reject all ──
|
|
if (pendingStepType === 'diff_review') {
|
|
// Delegate to shared handler (also used by WS onResponse path in extension.ts)
|
|
await handleDiffReviewResponse({
|
|
request_id: resp.request_id,
|
|
approved,
|
|
button_index: resp.button_index,
|
|
step_type: pendingStepType,
|
|
});
|
|
} else {
|
|
// ALL paths (dom_observer + step_probe) use same strategy pipeline
|
|
const targetSession = sessionId || ctx.activeSessionId;
|
|
ctx.logToFile(`[RESPONSE] → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
|
|
const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex);
|
|
ctx.logToFile(`[RESPONSE] strategy result: ${strategyResult}`);
|
|
}
|
|
|
|
ctx.logToFile(`[RESPONSE] ${approved ? 'approve' : 'reject'} done (${isDomObserver ? 'dom' : 'step_probe'})`);
|
|
|
|
// FIX v2 (2026-03-16): Correct state management after response processing.
|
|
// Set ctx.sawRunningAfterPending=true to close the auto_resolve gate.
|
|
ctx.sawRunningAfterPending = true;
|
|
|
|
// Cleanup response file
|
|
// CRITICAL: DOM observer responses must NOT be deleted here!
|
|
if (!isDomObserver) {
|
|
try { fs.unlinkSync(filePath); } catch { }
|
|
}
|
|
} catch (e: any) {
|
|
const log = `[RESPONSE] error: ${e.message}`;
|
|
console.log(`Gravity Bridge: ${log}`);
|
|
ctx.logToFile(log);
|
|
}
|
|
}
|
|
|
|
// ─── Approval Strategies ───
|
|
|
|
/**
|
|
* Try multiple approval methods sequentially.
|
|
* Returns a string describing which method succeeded (or all failed).
|
|
*
|
|
* Strategy order (most reliable first):
|
|
* 0. antigravity.acceptAgentStep / rejectAgentStep (AG VS Code commands — always works)
|
|
* 1. HandleCascadeUserInteraction RPC (cross-platform, needs stepIndex)
|
|
* 2. Renderer DOM Click via HTTP Bridge (fallback)
|
|
*/
|
|
export async function tryApprovalStrategies(approved: boolean, sessionId: string, stepType: string = '', stepIndex: number = -1): Promise<string> {
|
|
const action = approved ? 'APPROVE' : 'REJECT';
|
|
const effectiveStepIndex = stepIndex >= 0 ? stepIndex
|
|
: (ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : -1);
|
|
ctx.logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${effectiveStepIndex}`);
|
|
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 0: SDK-verified AG commands (step_type-aware dispatch)
|
|
//
|
|
// From SDK index.js (verified command mapping):
|
|
// antigravity.agent.acceptAgentStep — code edits, file writes
|
|
// antigravity.agent.rejectAgentStep — reject code edits
|
|
// antigravity.command.accept — non-terminal commands (Run, Allow, etc.)
|
|
// antigravity.command.reject — reject non-terminal commands
|
|
// antigravity.terminalCommand.accept — terminal commands
|
|
// antigravity.terminalCommand.reject — reject terminal commands
|
|
// antigravity.terminalCommand.run — run terminal commands
|
|
//
|
|
// These operate on the currently focused/active step — no stepIndex needed!
|
|
// ══════════════════════════════════════════════════════════
|
|
{
|
|
const typeLower = stepType.toLowerCase().replace('cortex_step_type_', '');
|
|
|
|
// Determine which SDK command pair to use based on step_type
|
|
let acceptCmd: string;
|
|
let rejectCmd: string;
|
|
|
|
if (typeLower.includes('code_edit') || typeLower.includes('write_to_file')
|
|
|| typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')
|
|
|| typeLower === 'diff_review') {
|
|
// Code edits → agent step commands
|
|
acceptCmd = 'antigravity.agent.acceptAgentStep';
|
|
rejectCmd = 'antigravity.agent.rejectAgentStep';
|
|
} else if (typeLower.includes('run_command') || typeLower.includes('shell_exec')
|
|
|| typeLower.includes('send_command_input')) {
|
|
// Terminal commands → terminal command pair
|
|
acceptCmd = 'antigravity.terminalCommand.accept';
|
|
rejectCmd = 'antigravity.terminalCommand.reject';
|
|
} else if (typeLower === 'command' || typeLower.includes('permission')
|
|
|| typeLower.includes('browser') || typeLower.includes('mcp')
|
|
|| typeLower.includes('extension_code') || typeLower.includes('subagent')
|
|
|| typeLower.includes('open_browser') || typeLower.includes('read_url')
|
|
|| typeLower.includes('invoke_subagent')) {
|
|
// Non-terminal commands (Run, Allow, etc.) → command pair
|
|
acceptCmd = 'antigravity.command.accept';
|
|
rejectCmd = 'antigravity.command.reject';
|
|
} else {
|
|
// Unknown type — try all three in order
|
|
acceptCmd = 'antigravity.command.accept';
|
|
rejectCmd = 'antigravity.command.reject';
|
|
}
|
|
|
|
const primaryCmd = approved ? acceptCmd : rejectCmd;
|
|
ctx.logToFile(`[APPROVAL-0] stepType="${stepType}" → ${primaryCmd}`);
|
|
|
|
try {
|
|
await vscode.commands.executeCommand(primaryCmd);
|
|
ctx.logToFile(`[APPROVAL-0] ✅ ${primaryCmd} SUCCESS`);
|
|
return `SDK:${primaryCmd}`;
|
|
} catch (e: any) {
|
|
ctx.logToFile(`[APPROVAL-0] ❌ ${primaryCmd} failed: ${e.message?.substring(0, 200)}`);
|
|
}
|
|
|
|
// Fallback: if the primary type-specific command failed, try the other pairs
|
|
const fallbackPairs = [
|
|
approved ? 'antigravity.command.accept' : 'antigravity.command.reject',
|
|
approved ? 'antigravity.agent.acceptAgentStep' : 'antigravity.agent.rejectAgentStep',
|
|
approved ? 'antigravity.terminalCommand.accept' : 'antigravity.terminalCommand.reject',
|
|
].filter(cmd => cmd !== primaryCmd); // skip already-tried
|
|
|
|
for (const fallbackCmd of fallbackPairs) {
|
|
try {
|
|
ctx.logToFile(`[APPROVAL-0-FB] Trying ${fallbackCmd}...`);
|
|
await vscode.commands.executeCommand(fallbackCmd);
|
|
ctx.logToFile(`[APPROVAL-0-FB] ✅ ${fallbackCmd} SUCCESS`);
|
|
return `SDK-FB:${fallbackCmd}`;
|
|
} catch (e: any) {
|
|
ctx.logToFile(`[APPROVAL-0-FB] ❌ ${fallbackCmd}: ${e.message?.substring(0, 100)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 1: HandleCascadeUserInteraction RPC
|
|
// Now supports BOTH approve AND reject.
|
|
// Requires valid stepIndex for most step types.
|
|
// ══════════════════════════════════════════════════════════
|
|
if (ctx.sdk && effectiveStepIndex >= 0) {
|
|
const typeLower = stepType.toLowerCase().replace('cortex_step_type_', '');
|
|
let interactionPayload: Record<string, any> = {};
|
|
|
|
// Code edit steps — use dedicated RPC
|
|
if (typeLower.includes('code_edit') || typeLower.includes('write_to_file') || typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')) {
|
|
try {
|
|
ctx.logToFile(`[APPROVAL-1-CODE] trying submitCodeAcknowledgement command`);
|
|
await vscode.commands.executeCommand('antigravity.prioritized.submitCodeAcknowledgement');
|
|
ctx.logToFile(`[APPROVAL-1-CODE] ✅ submitCodeAcknowledgement OK`);
|
|
return `CMD:submitCodeAcknowledgement(accept=${approved})`;
|
|
} catch {
|
|
ctx.logToFile(`[APPROVAL-1-CODE] submitCodeAcknowledgement not available, trying RPC`);
|
|
}
|
|
try {
|
|
ctx.logToFile(`[APPROVAL-1-CODE] acknowledgeCodeActionStep(cascadeId=${sessionId.substring(0, 8)}, accept=${approved}, stepIndices=[${effectiveStepIndex}])`);
|
|
const ackResult = await ctx.sdk.ls.rawRPC('acknowledgeCodeActionStep', {
|
|
cascadeId: sessionId,
|
|
accept: approved,
|
|
stepIndices: [effectiveStepIndex],
|
|
});
|
|
ctx.logToFile(`[APPROVAL-1-CODE] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`);
|
|
return `RPC:acknowledgeCodeActionStep(accept=${approved})`;
|
|
} catch (e: any) {
|
|
ctx.logToFile(`[APPROVAL-1-CODE] ❌ ${e.message.substring(0, 200)}`);
|
|
// Fall through to generic HandleCascadeUserInteraction
|
|
interactionPayload = { runCommand: { confirm: approved } };
|
|
}
|
|
}
|
|
|
|
// Map step_type to interaction sub-message field
|
|
// CRITICAL FIX: Use `confirm: approved` (not always true) to support REJECT
|
|
if (typeLower.includes('run_command') || typeLower.includes('shell_exec')) {
|
|
interactionPayload = { runCommand: { confirm: approved } };
|
|
} else if (typeLower.includes('open_browser')) {
|
|
interactionPayload = { openBrowserUrl: { confirm: approved } };
|
|
} else if (typeLower.includes('send_command_input')) {
|
|
interactionPayload = { sendCommandInput: { confirm: approved } };
|
|
} else if (typeLower.includes('read_url')) {
|
|
interactionPayload = { readUrlContent: { confirm: approved } };
|
|
} else if (typeLower.includes('mcp')) {
|
|
interactionPayload = { mcpTool: { confirm: approved } };
|
|
} else if (typeLower.includes('invoke_subagent') || typeLower.includes('extension_code') || typeLower.includes('browser_subagent')) {
|
|
interactionPayload = { runExtensionCode: { confirm: approved } };
|
|
} else if (typeLower.includes('file_permission')) {
|
|
if (typeLower.includes('deny')) {
|
|
interactionPayload = { filePermission: { allow: false, scope: 1 } };
|
|
} else {
|
|
const scope = typeLower.includes('conversation') ? 2 : 1;
|
|
interactionPayload = { filePermission: { allow: approved, scope } };
|
|
}
|
|
} else if (typeLower.includes('elicitation')) {
|
|
interactionPayload = { elicitation: {} };
|
|
} else if (typeLower === 'permission' || typeLower.includes('permission')) {
|
|
interactionPayload = { runExtensionCode: { confirm: approved } };
|
|
} else if (typeLower === 'command' || typeLower === '') {
|
|
// Generic command — most common case from DOM observer
|
|
interactionPayload = { runCommand: { confirm: approved } };
|
|
} else {
|
|
// Default: try run_command
|
|
interactionPayload = { runCommand: { confirm: approved } };
|
|
}
|
|
|
|
const activeTrajectoryId = getTrajectoryId();
|
|
const protoVariants = [
|
|
// Variant A: camelCase with trajectoryId
|
|
{
|
|
cascadeId: sessionId,
|
|
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,
|
|
},
|
|
},
|
|
];
|
|
let lastRpcError = '';
|
|
for (let i = 0; i < protoVariants.length; i++) {
|
|
try {
|
|
const payload = protoVariants[i];
|
|
ctx.logToFile(`[APPROVAL-1-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`);
|
|
const rpcResult = await ctx.sdk.ls.rawRPC('HandleCascadeUserInteraction', payload);
|
|
ctx.logToFile(`[APPROVAL-1-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
|
|
return `RPC-${i}:HandleCascadeUserInteraction(${typeLower},${action})`;
|
|
} catch (e: any) {
|
|
lastRpcError = e.message || '';
|
|
ctx.logToFile(`[APPROVAL-1-${i}] ❌ ${lastRpcError.substring(0, 300)}`);
|
|
}
|
|
}
|
|
|
|
// ── Auto-recovery: wrong-LS detection ──────────────────────
|
|
if (ctx.fixLSConnection && lastRpcError.includes('input not registered')) {
|
|
ctx.logToFile('[APPROVAL] ⚠️ wrong-LS detected ("input not registered"), attempting LS fix...');
|
|
try {
|
|
const lsChanged = await ctx.fixLSConnection();
|
|
if (lsChanged) {
|
|
ctx.logToFile('[APPROVAL] LS reconnected — retrying first proto variant...');
|
|
try {
|
|
const retryPayload = protoVariants[0];
|
|
const retryResult = await ctx.sdk.ls.rawRPC('HandleCascadeUserInteraction', retryPayload);
|
|
ctx.logToFile(`[APPROVAL-RETRY] ✅ SUCCESS: ${JSON.stringify(retryResult).substring(0, 200)}`);
|
|
return `RPC-RETRY:HandleCascadeUserInteraction(${typeLower},${action})`;
|
|
} catch (retryErr: any) {
|
|
ctx.logToFile(`[APPROVAL-RETRY] ❌ ${retryErr.message?.substring(0, 200)}`);
|
|
}
|
|
} else {
|
|
ctx.logToFile('[APPROVAL] LS not changed — already on correct port or fix unavailable');
|
|
}
|
|
} catch (fixErr: any) {
|
|
ctx.logToFile(`[APPROVAL] fixLSConnection error: ${fixErr.message?.substring(0, 200)}`);
|
|
}
|
|
}
|
|
} else if (ctx.sdk && effectiveStepIndex < 0) {
|
|
ctx.logToFile(`[APPROVAL-1] SKIPPED RPC: stepIndex=${effectiveStepIndex} (unknown) — Strategy 0 (VS Code command) was the primary attempt`);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════
|
|
// STRATEGY 2: Renderer DOM Click via HTTP Bridge (fallback)
|
|
// Sets a click trigger that the observer script polls and executes.
|
|
// ══════════════════════════════════════════════════════════
|
|
try {
|
|
const triggerAction = approved ? 'approve' : 'reject';
|
|
ctx.logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`);
|
|
ctx.setClickTrigger(triggerAction as 'approve' | 'reject');
|
|
ctx.logToFile(`[APPROVAL-2] ✅ clickTrigger set — renderer will poll and click within 2s`);
|
|
} catch (e: any) {
|
|
ctx.logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`);
|
|
}
|
|
|
|
ctx.logToFile(`[APPROVAL] All strategies complete for ${action}`);
|
|
return `STRATEGIES_DONE:${action}`;
|
|
}
|