refactor(observer): v7 step-aware AG Native DOM parser with data-testid/data-step-index based content extraction

- Replace CSS class-based scanning with [data-testid='conversation-view'] + [data-step-index] traversal
- New extractCleanStepText(): clone-and-strip buttons/SVG/icons before text extraction
- New extractStepContext(): step-container-aware context with header + code block
- NOISE_RE: block Material icon names, button labels, UI artifacts
- Auto DOM structure dump on first conversation-view detection
- Enhanced deep-inspect with step element + button inventory
- known-issues: document AG Native SDK API incompatibility
This commit is contained in:
Variet Worker
2026-04-12 06:14:46 +09:00
parent 70dc301dca
commit a4d7286bce
7 changed files with 766 additions and 472 deletions

View File

@@ -5,8 +5,13 @@
* Handles:
* - Response file watching (file-based bridge fallback)
* - Response processing (diff_review, DOM observer, step_probe paths)
* - Multi-strategy approval execution (RPC, VS Code commands, DOM click)
* - 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';
@@ -256,7 +261,7 @@ async function processResponseFile(filePath: string) {
} catch { }
}
// ═══ MULTI-STRATEGY APPROVAL (v2.1) ═══
// ═══ MULTI-STRATEGY APPROVAL (v3.0) ═══
const approved = resp.approved;
// ── diff_review: Accept all / Reject all ──
@@ -268,16 +273,10 @@ async function processResponseFile(filePath: string) {
button_index: resp.button_index,
step_type: pendingStepType,
});
} else if (isDomObserver) {
// DOM observer path: ALSO try RPC strategies (renderer click is unreliable)
const targetSession = sessionId || ctx.activeSessionId;
ctx.logToFile(`[RESPONSE] dom_observer → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex);
ctx.logToFile(`[RESPONSE] dom strategy result: ${strategyResult}`);
} else {
// Step probe path: run ALL approval strategies
// ALL paths (dom_observer + step_probe) use same strategy pipeline
const targetSession = sessionId || ctx.activeSessionId;
ctx.logToFile(`[RESPONSE] step_probe → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
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}`);
}
@@ -307,9 +306,9 @@ async function processResponseFile(filePath: string) {
* Returns a string describing which method succeeded (or all failed).
*
* Strategy order (most reliable first):
* 1. HandleCascadeUserInteraction RPC (cross-platform, no focus)
* 2. VS Code accept/reject commands (focus-dependent)
* 3. Log failure for manual intervention
* 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';
@@ -317,90 +316,153 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
: (ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : -1);
ctx.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: string[] = [];
try {
const allCmds = await vscode.commands.getCommands(true);
const agCmds = allCmds.filter((c: string) => c.startsWith('antigravity.'));
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('cascade') || lower.includes('action');
});
ctx.logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`);
for (const c of approvalCmdList) {
ctx.logToFile(`[APPROVAL-CMD-CHECK] → ${c}`);
// ══════════════════════════════════════════════════════════
// 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)}`);
}
}
} catch (e: any) {
ctx.logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`);
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source)
// STRATEGY 1: HandleCascadeUserInteraction RPC
// Now supports BOTH approve AND reject.
// Requires valid stepIndex for most step types.
// ══════════════════════════════════════════════════════════
if (ctx.sdk && approved && effectiveStepIndex >= 0) {
// Build interaction sub-message based on step_type
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')) {
// CODE EDIT: Uses acknowledgeCodeActionStep RPC (correct AG LS method)
try {
ctx.logToFile(`[APPROVAL-CODE-EDIT] trying submitCodeAcknowledgement command`);
ctx.logToFile(`[APPROVAL-1-CODE] trying submitCodeAcknowledgement command`);
await vscode.commands.executeCommand('antigravity.prioritized.submitCodeAcknowledgement');
ctx.logToFile(`[APPROVAL-CODE-EDIT] ✅ submitCodeAcknowledgement OK`);
ctx.logToFile(`[APPROVAL-1-CODE] ✅ submitCodeAcknowledgement OK`);
return `CMD:submitCodeAcknowledgement(accept=${approved})`;
} catch {
ctx.logToFile(`[APPROVAL-CODE-EDIT] submitCodeAcknowledgement not available, trying RPC`);
ctx.logToFile(`[APPROVAL-1-CODE] submitCodeAcknowledgement not available, trying RPC`);
}
// Direct LS RPC with correct method name
try {
ctx.logToFile(`[APPROVAL-CODE-EDIT] acknowledgeCodeActionStep(cascadeId=${sessionId.substring(0, 8)}, accept=${approved}, stepIndices=[${effectiveStepIndex}])`);
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-CODE-EDIT] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`);
ctx.logToFile(`[APPROVAL-1-CODE] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`);
return `RPC:acknowledgeCodeActionStep(accept=${approved})`;
} catch (e: any) {
ctx.logToFile(`[APPROVAL-CODE-EDIT] ❌ ${e.message.substring(0, 200)}`);
ctx.logToFile(`[APPROVAL-CODE-EDIT] falling back to HandleCascadeUserInteraction`);
interactionPayload = { runCommand: { confirm: true } };
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: true } };
interactionPayload = { runCommand: { confirm: approved } };
} else if (typeLower.includes('open_browser')) {
interactionPayload = { openBrowserUrl: { confirm: true } };
interactionPayload = { openBrowserUrl: { confirm: approved } };
} else if (typeLower.includes('send_command_input')) {
interactionPayload = { sendCommandInput: { confirm: true } };
interactionPayload = { sendCommandInput: { confirm: approved } };
} else if (typeLower.includes('read_url')) {
interactionPayload = { readUrlContent: { confirm: true } };
interactionPayload = { readUrlContent: { confirm: approved } };
} else if (typeLower.includes('mcp')) {
interactionPayload = { mcpTool: { confirm: true } };
interactionPayload = { mcpTool: { confirm: approved } };
} else if (typeLower.includes('invoke_subagent') || typeLower.includes('extension_code') || typeLower.includes('browser_subagent')) {
interactionPayload = { runExtensionCode: { confirm: true } };
interactionPayload = { runExtensionCode: { confirm: approved } };
} else if (typeLower.includes('file_permission')) {
const scope = typeLower.includes('conversation') ? 2 : 1;
interactionPayload = { filePermission: { allow: true, scope } };
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')) {
// DOM observer 'permission' type: browser_subagent Allow/Deny dialog
// Try runExtensionCode first (most common for JS execution permission)
interactionPayload = { runExtensionCode: { confirm: true } };
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 (most common)
interactionPayload = { runCommand: { confirm: true } };
// Default: try run_command
interactionPayload = { runCommand: { confirm: approved } };
}
const activeTrajectoryId = getTrajectoryId();
const protoVariants = [
// Variant A: camelCase with trajectoryId (proven working for run_command)
// Variant A: camelCase with trajectoryId
{
cascadeId: sessionId,
interaction: {
@@ -431,20 +493,17 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
for (let i = 0; i < protoVariants.length; i++) {
try {
const payload = protoVariants[i];
ctx.logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`);
ctx.logToFile(`[APPROVAL-1-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`);
const rpcResult = await ctx.sdk.ls.rawRPC('HandleCascadeUserInteraction', payload);
ctx.logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-PROTO-${i}:HandleCascadeUserInteraction(${typeLower})`;
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-PROTO-${i}] ❌ ${lastRpcError.substring(0, 300)}`);
ctx.logToFile(`[APPROVAL-1-${i}] ❌ ${lastRpcError.substring(0, 300)}`);
}
}
// ── Auto-recovery: wrong-LS detection ──────────────────────
// All 3 proto variants failed. If the error is "input not registered",
// SDK is likely connected to wrong LS process. Attempt fixLSConnection
// and retry ONE time to avoid permanent failure.
if (ctx.fixLSConnection && lastRpcError.includes('input not registered')) {
ctx.logToFile('[APPROVAL] ⚠️ wrong-LS detected ("input not registered"), attempting LS fix...');
try {
@@ -453,10 +512,9 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
ctx.logToFile('[APPROVAL] LS reconnected — retrying first proto variant...');
try {
const retryPayload = protoVariants[0];
ctx.logToFile(`[APPROVAL-RETRY] HandleCascadeUserInteraction(${JSON.stringify(retryPayload).substring(0, 250)})`);
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})`;
return `RPC-RETRY:HandleCascadeUserInteraction(${typeLower},${action})`;
} catch (retryErr: any) {
ctx.logToFile(`[APPROVAL-RETRY] ❌ ${retryErr.message?.substring(0, 200)}`);
}
@@ -467,9 +525,14 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
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 (primary fallback) ──
// ══════════════════════════════════════════════════════════
// 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`);
@@ -479,6 +542,6 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
ctx.logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`);
}
ctx.logToFile(`[APPROVAL] strategies complete — check logs for results`);
ctx.logToFile(`[APPROVAL] All strategies complete for ${action}`);
return `STRATEGIES_DONE:${action}`;
}

View File

@@ -151,6 +151,25 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
return;
}
// GET /status — diagnostic endpoint
if (req.method === 'GET' && url.pathname === '/status') {
const { getStepProbeContext } = require('./step-probe');
const probeCtx = getStepProbeContext();
const status = {
projectName: ctx.projectName,
activeSessionId: probeCtx.activeSessionId || ctx.activeSessionId,
lastPendingStepIndex: probeCtx.lastPendingStepIndex,
sessionStalled: probeCtx.sessionStalled,
wsConnected: ctx.wsBridge?.isConnected() ?? false,
clickTrigger: clickTrigger ? { ...clickTrigger, ageMs: Date.now() - clickTrigger.timestamp } : null,
uptime: Math.round(process.uptime()),
timestamp: new Date().toISOString(),
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(status, null, 2));
return;
}
// GET /ping — health check
if (url.pathname === '/ping') {
res.writeHead(200); res.end('pong');

View File

@@ -1,15 +1,17 @@
export function generateApprovalObserverScript(_port: number): string {
return `
// ── Gravity Bridge v6: Clean Context Extraction ──
// ── Gravity Bridge v7: Step-Aware AG Native DOM Parser ──
// Uses data-testid="conversation-view" + data-step-index for reliable parsing
(function(){
'use strict';
var BASE='',_obs=false,_sent={},_ready=false;
var _scanScheduled=false,_lastScanTs=0;
var THROTTLE_MS=500;
var CLEANUP_MS=300000;
var _dumpSent=false; // one-time DOM dump
function log(m){console.log('[GB Observer] '+m);}
log('v6 Script loaded — Clean Context Extraction');
log('v7 Script loaded — Step-Aware AG Native DOM Parser');
// React-Compatible Synthetic Clicker
function dispatchReactClick(el){
@@ -26,57 +28,50 @@ export function generateApprovalObserverScript(_port: number): string {
}
// ── Noise filter: lines that are UI artifacts, not real content ──
var NOISE_PATTERNS = [
/^chevron_right$/i,
/^chevron_left$/i,
/^arrow_/i,
/^Thought for \\\\d+/i,
/^expand_/i,
/^close$/i,
/^more_/i,
/^content_copy$/i,
/^check$/i,
/^\\\\d+ lines?$/i,
/^Show more$/i,
/^Show less$/i,
/^Copy$/i,
/^Edit$/i,
/^Copied!$/i,
/^\\\\s*$/,
/^declare\\\\s+(class|function|interface|type|enum|const|var|let)\\\\s/, // TypeScript declarations
/^(import|export|from)\\\\s/, // JS imports
/^\\\\s*[{}()\\\\[\\\\];]\\\\s*$/, // lone brackets
/\\\\.ts:\\\\d+:/, // file:line references
/extension.*src.*sdk/i, // SDK file paths
];
var NOISE_RE = new RegExp(
'^(' +
'chevron_right|chevron_left|arrow_drop_down|arrow_drop_up|arrow_right|arrow_left|' +
'arrow_forward|arrow_back|expand_more|expand_less|close|more_horiz|more_vert|' +
'content_copy|content_paste|check|check_circle|error|warning|info|' +
'keyboard_arrow_up|keyboard_arrow_down|keyboard_arrow_left|keyboard_arrow_right|' +
'Thought for \\\\d+|Show more|Show less|Copy|Copied!|Edit|Cancel|' +
'Always run|Always allow|Running command|Running \\\\d+ commands?|' +
'Deny|Allow|Allow Once|Allow This Conversation|' +
'Run|Send|Stop|Review Changes|Accept all|Reject all|Accept|Reject' +
')$', 'i'
);
var NOISE_CODE_RE = /^(declare\\s+(class|function|interface|type|enum|const|var|let)\\s|(import|export|from)\\s|\\s*[{}()\\[\\];]\\s*$|\\.ts:\\d+:|extension.*src.*sdk)/i;
function isNoiseLine(line) {
if (!line || line.trim().length < 2) return true;
var trimmed = line.trim();
for (var i = 0; i < NOISE_PATTERNS.length; i++) {
if (NOISE_PATTERNS[i].test(trimmed)) return true;
}
if (NOISE_RE.test(trimmed)) return true;
if (NOISE_CODE_RE.test(trimmed)) return true;
// Single-word Material icon names (all lowercase, no spaces)
if (/^[a-z_]+$/.test(trimmed) && trimmed.length < 30) return true;
return false;
}
function cleanLines(text) {
if (!text) return '';
var lines = text.split('\\\\n');
var lines = text.split('\\n');
var clean = [];
for (var i = 0; i < lines.length; i++) {
if (!isNoiseLine(lines[i])) clean.push(lines[i].trim());
}
return clean.join('\\\\n').trim();
return clean.join('\\n').trim();
}
function cleanButtonText(btn) {
if (!btn) return '';
var clone = btn.cloneNode(true);
var icons = clone.querySelectorAll('.google-symbols, .codicon, [class*="icon"], svg');
var icons = clone.querySelectorAll('.google-symbols, .codicon, [class*="icon"], svg, .material-symbols-outlined, .material-icons');
for(var i=0; i<icons.length; i++) {
if(icons[i].parentNode) icons[i].parentNode.removeChild(icons[i]);
}
var tr = clone.querySelector('.truncate');
var txt = (tr ? tr.textContent : clone.textContent) || '';
return txt.trim().replace(/^[\\\\s\\\\u200B-\\\\u200D\\\\uFEFF\\\\u00A0]+/, '').replace(/(Alt|Ctrl|Shift|Meta)\\\\+.*/i,'').trim();
return txt.trim().replace(/^[\\s\\u200B-\\u200D\\uFEFF\\u00A0]+/, '').replace(/(Alt|Ctrl|Shift|Meta)\\+.*/i,'').trim();
}
function btnId(b,type){
@@ -90,81 +85,73 @@ export function generateApprovalObserverScript(_port: number): string {
return type+'|'+txt+'|'+idx;
}
// ── Context extraction: TIGHT scope — only button's immediate context ──
// v6 FIX: Never climb more than 4 parents. Never grab editor/sidebar content.
// ══════════════════════════════════════════════════════════════════
// v7: STEP-AWARE CONTEXT EXTRACTION
// Find the closest [data-step-index] ancestor, extract step info
// ══════════════════════════════════════════════════════════════════
function extractCommandContext(b) {
// Strategy 1: aria-label or title on button itself
var ariaLabel = b.getAttribute('aria-label') || b.getAttribute('title') || '';
if (ariaLabel && ariaLabel.length > 5 && ariaLabel.length < 500) {
return ariaLabel;
function getStepContainer(el) {
var node = el;
for (var depth = 0; depth < 10 && node; depth++) {
if (node.hasAttribute && node.hasAttribute('data-step-index')) return node;
node = node.parentElement;
}
return null;
}
function extractStepContext(btn) {
var stepEl = getStepContainer(btn);
if (!stepEl) return cleanButtonText(btn);
var stepIdx = stepEl.getAttribute('data-step-index') || '?';
// Get step header text (first line, usually has the tool name/command)
var header = stepEl.querySelector('[class*="cursor-pointer"]');
var headerText = '';
if (header) {
// Clone and strip icons/buttons
var hClone = header.cloneNode(true);
var hRemove = hClone.querySelectorAll('button, svg, [class*="icon"], .google-symbols, .material-symbols-outlined');
for (var i = 0; i < hRemove.length; i++) {
if (hRemove[i].parentNode) hRemove[i].parentNode.removeChild(hRemove[i]);
}
headerText = (hClone.textContent || '').trim().substring(0, 300);
// Clean noise
headerText = cleanLines(headerText);
}
// Strategy 2: Look for command text in button's DIRECT parent chain (max 3 levels)
var el = b.parentElement;
for (var depth = 0; depth < 3 && el; depth++) {
// Check for code/pre elements (command text)
var pres = el.querySelectorAll('pre, code');
for (var pi = 0; pi < pres.length; pi++) {
var preText = (pres[pi].textContent || '').trim();
if (preText.length > 2 && preText.length < 500 && !isNoiseLine(preText)) {
return preText.substring(0, 400);
}
}
// Check for span with title attribute containing command info
var titleSpans = el.querySelectorAll('span[title]');
for (var ti = 0; ti < titleSpans.length; ti++) {
var spanTitle = titleSpans[ti].getAttribute('title') || '';
if (spanTitle.length > 5 && spanTitle.length < 500) {
return spanTitle.substring(0, 400);
}
}
el = el.parentElement;
// Try to get code/pre content (command detail)
var codeEl = stepEl.querySelector('pre, code');
var codeText = '';
if (codeEl) {
codeText = (codeEl.textContent || '').trim().substring(0, 400);
}
// Strategy 3: Immediate parent's text only (NOT full page)
var immediateParent = b.parentElement;
if (immediateParent) {
var parentText = '';
var children = immediateParent.childNodes;
for (var ci = 0; ci < children.length; ci++) {
var child = children[ci];
if (child.nodeType === 3 && child.nodeValue && child.nodeValue.trim()) {
parentText += child.nodeValue.trim() + ' ';
} else if (child.nodeType === 1 && child.tagName !== 'BUTTON' && child.tagName !== 'SVG') {
var childText = child.textContent || '';
if (childText.trim().length > 2 && childText.trim().length < 200) {
parentText += childText.trim() + ' ';
}
}
}
parentText = parentText.trim();
if (parentText.length > 3 && parentText.length < 300) {
return cleanLines(parentText).substring(0, 300);
}
}
// Try aria-label on button
var ariaLabel = btn.getAttribute('aria-label') || btn.getAttribute('title') || '';
return '';
var parts = [];
if (headerText) parts.push(headerText);
if (codeText && !headerText.includes(codeText.substring(0, 20))) parts.push(codeText);
if (ariaLabel && ariaLabel.length > 5 && !headerText.includes(ariaLabel)) parts.push(ariaLabel);
var result = parts.join(' — ');
if (!result) result = cleanButtonText(btn);
return 'Step #' + stepIdx + ': ' + result;
}
function extractContext(b) {
var cmd = cleanButtonText(b);
var detail = extractCommandContext(b);
if (!detail) return cmd;
// Deduplicate: if detail contains button text, just show detail
if (detail.includes(cmd)) return cleanLines(detail);
return cmd + ': ' + cleanLines(detail);
return extractStepContext(b);
}
var ACTION_WORDS = ['Allow', 'Run', 'Approve', 'Accept', 'Always allow', '결행', '수락', '반영', '허용', '승인'];
var REJECT_WORDS = ['Reject', 'Cancel', 'Deny', 'Stop', 'Decline', 'Dismiss', '거절', '거부', '취소', '차단', '정지', '무시'];
var ACTION_WORDS = ['Allow', 'Run', 'Approve', 'Accept', 'Always allow', 'Always run'];
var REJECT_WORDS = ['Reject', 'Cancel', 'Deny', 'Stop', 'Decline', 'Dismiss'];
function isActionBtn(txt) {
for(var i=0; i<ACTION_WORDS.length; i++) {
if(txt.indexOf(ACTION_WORDS[i]) !== -1) return true;
}
// "Running N command(s)" pattern
if (/Running\\\\d*\\\\s*command/i.test(txt)) return true;
if (/Running\\s*\\d*\\s*command/i.test(txt)) return true;
return false;
}
function isRejectBtn(txt) {
@@ -202,7 +189,7 @@ export function generateApprovalObserverScript(_port: number): string {
var items = document.querySelectorAll('[aria-label^="Gravity Bridge Control"], [title^="Gravity Bridge Control"]');
if (items.length > 0) {
var text = items[0].getAttribute('aria-label') || items[0].getAttribute('title') || '';
var m = text.match(/port:(\\\\d+)/);
var m = text.match(/port:(\\d+)/);
if (m && m[1]) {
clearInterval(timer);
tryPingAsync(parseInt(m[1], 10)).then(function(ok){ cb(ok ? parseInt(m[1],10) : HARDCODED_PORT); });
@@ -222,67 +209,218 @@ export function generateApprovalObserverScript(_port: number): string {
startObserver();
});
// ── Chat body scanning (for Discord relay of AI responses) ──
var _lastText = "";
var _lastTextTime = 0;
var _lastTextSent = false;
// ══════════════════════════════════════════════════════════════════
// v7: DOM STRUCTURE DUMP (one-time, on first conversation-view detection)
// Posts the DOM tree structure to /dump-html for debugging
// ══════════════════════════════════════════════════════════════════
function extractCleanChatText(container) {
if (!container) return '';
// Try markdown body first
var md = container.querySelector('.markdown-body') || container.querySelector('.prose');
var rawText = '';
if (md && md.innerText && md.innerText.trim().length > 10) {
rawText = md.innerText.trim();
} else {
rawText = (container.innerText || container.textContent || '').trim();
function dumpDOMStructure() {
if (_dumpSent || !_ready) return;
var cv = document.querySelector('[data-testid="conversation-view"]');
if (!cv) return;
_dumpSent = true;
// Walk the DOM tree and capture structure (classes, data-attrs, tag names)
function walkNode(el, depth) {
if (depth > 8) return null;
var info = {
tag: el.tagName ? el.tagName.toLowerCase() : '#text',
cls: (el.className && typeof el.className === 'string') ? el.className.substring(0, 200) : '',
attrs: {},
text: '',
children: []
};
// Capture data-* and role attributes
if (el.attributes) {
for (var ai = 0; ai < el.attributes.length; ai++) {
var attr = el.attributes[ai];
if (attr.name.startsWith('data-') || attr.name === 'role' || attr.name === 'aria-label') {
info.attrs[attr.name] = (attr.value || '').substring(0, 100);
}
}
}
// For leaf text nodes, capture short text
if (!el.children || el.children.length === 0) {
var t = (el.textContent || '').trim();
if (t.length > 0 && t.length < 100) info.text = t;
}
// Recurse children (limit to first 10 per level)
if (el.children) {
for (var ci = 0; ci < Math.min(el.children.length, 10); ci++) {
var childInfo = walkNode(el.children[ci], depth + 1);
if (childInfo) info.children.push(childInfo);
}
if (el.children.length > 10) {
info.children.push({tag: '...', text: '+' + (el.children.length - 10) + ' more'});
}
}
return info;
}
// Clean the text
var structure = walkNode(cv, 0);
var payload = JSON.stringify({
timestamp: new Date().toISOString(),
source: 'v7_dom_dump',
conversationView: structure
});
log('DOM dump: conversation-view found, sending ' + payload.length + ' bytes');
fetch(BASE + '/dump-html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload
}).catch(function(e) { log('DOM dump failed: ' + e.message); });
}
// ══════════════════════════════════════════════════════════════════
// v7: STEP-AWARE CHAT BODY SCANNING
// Scans [data-step-index] elements inside [data-testid="conversation-view"]
// Extracts AI response text while filtering UI noise
// ══════════════════════════════════════════════════════════════════
var _lastScrapedStepIndex = -1;
var _lastStepText = '';
var _lastStepTextTime = 0;
var _lastStepTextSent = false;
function extractCleanStepText(stepEl) {
if (!stepEl) return '';
// Clone the step element so we can strip UI elements without affecting the DOM
var clone = stepEl.cloneNode(true);
// Remove all buttons (Run, Allow, Cancel, etc.)
var buttons = clone.querySelectorAll('button');
for (var bi = 0; bi < buttons.length; bi++) {
if (buttons[bi].parentNode) buttons[bi].parentNode.removeChild(buttons[bi]);
}
// Remove all SVGs and icon elements
var icons = clone.querySelectorAll('svg, .google-symbols, .material-symbols-outlined, .material-icons, [class*="codicon"], [class*="icon"]');
for (var ii = 0; ii < icons.length; ii++) {
if (icons[ii].parentNode) icons[ii].parentNode.removeChild(icons[ii]);
}
// Remove copy buttons and their containers
var copyBtns = clone.querySelectorAll('[class*="copy"], [aria-label*="copy"], [title*="Copy"]');
for (var ci = 0; ci < copyBtns.length; ci++) {
if (copyBtns[ci].parentNode) copyBtns[ci].parentNode.removeChild(copyBtns[ci]);
}
// Try to get text from markdown rendering area first
// Look for known markdown container patterns
var mdEl = clone.querySelector('.markdown-body, .prose, [class*="markdown"], [class*="rendered"]');
var rawText = '';
if (mdEl && mdEl.innerText && mdEl.innerText.trim().length > 10) {
rawText = mdEl.innerText.trim();
} else {
// Fallback: get all text but filter aggressively
rawText = (clone.innerText || clone.textContent || '').trim();
}
// Apply line-by-line noise filter
return cleanLines(rawText).substring(0, 3500);
}
function scanChatBodies() {
if(!_ready)return;
// Find bot response containers — try multiple selectors for compatibility
var botTurns = document.querySelectorAll(
'.text-ide-message-block-bot-color, ' +
'[data-testid*="bot"], [data-testid*="assistant"], ' +
'[class*="agent-convo"], [class*="bot-message"]'
);
if (botTurns.length === 0) return;
var lastTurn = botTurns[botTurns.length - 1];
if (lastTurn.dataset.agChatScraped === "true" || lastTurn.dataset.agChatScraped === "pending") return;
if (!_ready) return;
var currentText = lastTurn.textContent || '';
if (currentText.length < 5) return;
// One-time DOM dump
dumpDOMStructure();
if (_lastText !== currentText) {
_lastText = currentText;
_lastTextTime = Date.now();
_lastTextSent = false;
} else if (!_lastTextSent) {
if (Date.now() - _lastTextTime > 3000) {
_lastTextSent = true;
lastTurn.dataset.agChatScraped = "pending";
var finalTxt = extractCleanChatText(lastTurn);
if (finalTxt && finalTxt.length > 5 && finalTxt !== "Review Changes") {
fetch(BASE+'/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: finalTxt })
}).then(function(){
lastTurn.dataset.agChatScraped = "true";
}).catch(function(){
lastTurn.dataset.agChatScraped = "false";
});
} else {
lastTurn.dataset.agChatScraped = "true";
}
// PRIMARY: Find conversation-view container
var cv = document.querySelector('[data-testid="conversation-view"]');
if (!cv) {
// FALLBACK: Try older selectors
cv = document.querySelector('[class*="conversation"], [class*="chat-container"]');
if (!cv) return;
}
// Find ALL step elements within the conversation
var stepEls = cv.querySelectorAll('[data-step-index]');
if (stepEls.length === 0) {
// FALLBACK: Try text-ide-message-block-bot-color directly
var botMsgs = cv.querySelectorAll('.text-ide-message-block-bot-color');
if (botMsgs.length === 0) return;
// Use the last bot message as a pseudo-step
var lastBot = botMsgs[botMsgs.length - 1];
if (lastBot.dataset.agChatScraped === 'true' || lastBot.dataset.agChatScraped === 'pending') return;
var botText = extractCleanStepText(lastBot);
if (botText && botText.length > 20) {
if (_lastStepText !== botText) {
_lastStepText = botText;
_lastStepTextTime = Date.now();
_lastStepTextSent = false;
} else if (!_lastStepTextSent && (Date.now() - _lastStepTextTime > 3000)) {
_lastStepTextSent = true;
lastBot.dataset.agChatScraped = 'pending';
fetch(BASE + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: botText, source: 'fallback_bot_msg' })
}).then(function() { lastBot.dataset.agChatScraped = 'true'; })
.catch(function() { lastBot.dataset.agChatScraped = 'false'; });
}
}
return;
}
// Find the LATEST step that has meaningful text content (AI response)
// Iterate backwards to find the most recent unscraped step
for (var si = stepEls.length - 1; si >= Math.max(0, stepEls.length - 5); si--) {
var stepEl = stepEls[si];
var stepIdx = parseInt(stepEl.getAttribute('data-step-index') || '-1', 10);
// Skip already scraped
if (stepEl.dataset.agChatScraped === 'true' || stepEl.dataset.agChatScraped === 'pending') continue;
// Skip steps we've already processed
if (stepIdx >= 0 && stepIdx <= _lastScrapedStepIndex) continue;
// Check if this step has substantial text content (not just a tool call header)
var stepText = extractCleanStepText(stepEl);
if (!stepText || stepText.length < 30) continue;
// QUALITY CHECK: Skip if the text is mostly short lines (UI artifacts)
var lines = stepText.split('\\n').filter(function(l) { return l.trim().length > 0; });
var longLines = lines.filter(function(l) { return l.trim().length > 20; });
if (longLines.length === 0) {
log('Step ' + stepIdx + ': skipped (no long lines, likely UI noise)');
continue;
}
// Wait for content to stabilize (3s no change)
if (_lastStepText !== stepText) {
_lastStepText = stepText;
_lastStepTextTime = Date.now();
_lastStepTextSent = false;
break; // Wait for next scan cycle
}
if (_lastStepTextSent) continue;
if (Date.now() - _lastStepTextTime < 3000) break; // Still waiting
// Content is stable — send it
_lastStepTextSent = true;
_lastScrapedStepIndex = stepIdx;
stepEl.dataset.agChatScraped = 'pending';
log('Chat relay: step=' + stepIdx + ' text=' + stepText.length + ' chars');
(function(el, txt, idx) {
fetch(BASE + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: txt, source: 'step_' + idx, step_index: idx })
}).then(function() { el.dataset.agChatScraped = 'true'; })
.catch(function() { el.dataset.agChatScraped = 'false'; });
})(stepEl, stepText, stepIdx);
break;
}
}
// ══════════════════════════════════════════════════════════════════
// BUTTON SCANNING (approval detection)
// ══════════════════════════════════════════════════════════════════
function scan(){
if(!_ready)return;
scanChatBodies();
@@ -299,15 +437,19 @@ export function generateApprovalObserverScript(_port: number): string {
if(!isActionBtn(txt)) continue;
// Skip inline code lens buttons
if (b.closest('.codelens-decoration') && !txt.includes('Accept') && !txt.includes('수락') && !txt.includes('반영')) {
if (b.closest('.codelens-decoration') && !txt.includes('Accept')) {
continue;
}
var matchedType = txt.includes('Accept') ? 'diff_review' : (txt.includes('Run') || /Running\\\\d/.test(txt) ? 'command' : 'permission');
var container=b.parentElement;
var groupKey=matchedType+'|'+btnId(b,matchedType);
var matchedType = txt.includes('Accept') ? 'diff_review' : (txt.includes('Run') || /Running\\d/.test(txt) ? 'command' : 'permission');
// v7: Use step-index for more unique group key
var stepContainer = getStepContainer(b);
var stepIdx = stepContainer ? stepContainer.getAttribute('data-step-index') : 'none';
var groupKey = matchedType + '|' + stepIdx + '|' + btnId(b, matchedType);
if(_sent[groupKey])continue;
var container=b.parentElement;
var siblings=collectSiblingButtons(container,b);
if(siblings.length===0)siblings=[{btn:b,text:txt,isPrimary:true}];
@@ -329,7 +471,7 @@ export function generateApprovalObserverScript(_port: number): string {
_sent[groupKey]={rid:rid,ts:now};
for(var mk=0;mk<bidList.length;mk++)_sent[bidList[mk]]={rid:rid,ts:now};
log('DETECTED '+matchedType+': '+txt+' ['+desc.substring(0,80)+']');
log('DETECTED '+matchedType+': '+txt+' ['+desc.substring(0,80)+'] step='+stepIdx);
(function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
var payload={
@@ -379,13 +521,13 @@ export function generateApprovalObserverScript(_port: number): string {
clearInterval(timer);
var btnIdx=(typeof d.button_index==='number'&&d.button_index>=0)?d.button_index:-1;
if(btnIdx>=0&&btnIdx<btnRefs.length){
log((d.approved?'':'')+' CHOICE '+rid+' clicking button['+btnIdx+']');
log((d.approved?'OK':'NO')+' CHOICE '+rid+' -> clicking button['+btnIdx+']');
dispatchReactClick(btnRefs[btnIdx]);
} else if(d.approved){
log(' APPROVED '+rid+' clicking primary');
log('OK APPROVED '+rid+' -> clicking primary');
dispatchReactClick(btnRefs[0]);
} else {
log(' REJECTED '+rid+' finding reject button');
log('NO REJECTED '+rid+' -> finding reject button');
clickRejectButton(btnRefs[0]);
}
delete _sent[groupKey];
@@ -471,6 +613,57 @@ export function generateApprovalObserverScript(_port: number): string {
setTimeout(pollTriggerClick, 2000);
})();
// ── DEEP-INSPECT POLLING ──
(function pollDeepInspect(){
if(_ready&&BASE){
fetch(BASE+'/deep-inspect-trigger?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
if(!d.inspect)return;
log('Deep inspect triggered');
var cv = document.querySelector('[data-testid="conversation-view"]');
var result = {
timestamp: new Date().toISOString(),
windowURL: window.location.href,
conversationViewFound: !!cv,
stepElements: [],
buttons: [],
totalElements: document.body ? document.querySelectorAll('*').length : 0,
};
if (cv) {
var steps = cv.querySelectorAll('[data-step-index]');
for (var si = 0; si < steps.length; si++) {
var s = steps[si];
var text = (s.textContent || '').trim().substring(0, 200);
result.stepElements.push({
stepIndex: s.getAttribute('data-step-index'),
classes: (s.className || '').substring(0, 200),
textPreview: text,
childCount: s.children ? s.children.length : 0,
});
}
}
var allBtns = document.querySelectorAll('button');
for (var bi = 0; bi < Math.min(allBtns.length, 30); bi++) {
var btn = allBtns[bi];
var btxt = cleanButtonText(btn);
if (btxt.length > 0) {
var stepC = getStepContainer(btn);
result.buttons.push({
text: btxt,
stepIndex: stepC ? stepC.getAttribute('data-step-index') : null,
visible: !!(btn.offsetParent || btn.style.display === 'fixed'),
});
}
}
fetch(BASE+'/deep-inspect-result', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(result)
}).catch(function(){});
}).catch(function(){});
}
setTimeout(pollDeepInspect, 3000);
})();
_obs=true;
}
})();

View File

@@ -39,7 +39,7 @@ let responseWatcher: fs.FSWatcher | null = null;
let brainWatcher: BrainWatcher | null = null;
let activeTrajectoryId = '';
const recentPendingSteps = new Map<string, number>();
const PENDING_MEMORY_TTL_MS = 60_000;
const PENDING_MEMORY_TTL_MS = 30_000;
// generateApprovalObserverScript → extracted to ./observer-script.ts
const lastSnapshotText = new Map<string, string>();
@@ -589,6 +589,7 @@ function setupMonitor() {
// lastModifiedTime is still changing = AI is thinking, NOT approval
consecutiveIdleCount = 0; // Reset!
ctx.stallProbed = false;
ctx.sessionStalled = false; // FIX: also reset stalled flag on modTime change
if (pollCount <= 10 || pollCount % 12 === 0) {
ctx.logToFile(`[THINK] step=${currentCount} modTime changing → not stall`);
}