Files
gravity_control/extension/src/extension.ts

1296 lines
62 KiB
TypeScript

/**
* Gravity Bridge — VS Code Extension (SDK Edition)
*
* Uses antigravity-sdk for:
* - Real-time step/conversation monitoring via EventMonitor
* - Full conversation content via LSBridge.getConversation()
* - Message sending via CascadeManager.sendPrompt()
* - Accept/Reject via CascadeManager.acceptStep()/rejectStep()
*
* Communication with Discord via file-based bridge protocol.
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as cp from 'child_process';
import * as crypto from 'crypto';
import { WSBridgeClient, WSResponseData, WSCommandData } from './ws-client';
import { generateApprovalObserverScript } from './observer-script';
import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState } from './step-probe';
// ─── File-based logging (AI can read directly) ───
function logToFile(msg: string) {
const ts = new Date().toISOString().replace('T', ' ').substring(0, 19);
// Include projectName prefix so shared log can distinguish which extension instance logged
const prefix = projectName ? `[${projectName}]` : '';
const line = `${ts} ${prefix} ${msg}`;
console.log(`Gravity Bridge: ${prefix} ${msg}`);
try {
if (!bridgePath) return;
const logFile = path.join(bridgePath, 'extension.log');
fs.appendFileSync(logFile, line + '\n', 'utf-8');
} catch (e: any) {
console.error(`Gravity Bridge LOG WRITE FAIL: ${e.message}`);
}
}
// antigravity-sdk embedded locally (src/sdk/)
let AntigravitySDK: any;
let sdk: any;
let statusBar: vscode.StatusBarItem;
let bridgePath: string;
let projectName: string;
let workspaceUri: string = ''; // filesystem path of the workspace folder (for session filtering)
let isActive = false;
let autoApproveEnabled = false; // toggled via !auto from Discord
let deterministicPort = 0; // derived from projectName, consistent across restarts
let watcher: fs.FSWatcher | null = null;
let commandsWatcher: fs.FSWatcher | null = null;
let wsBridge: WSBridgeClient | null = null; // WebSocket Hub connection
const sentPendingIds = new Set<string>();
// Memory-based dedup: tracks recently created pending step_indexes to prevent
// regeneration after pending file deletion (by Collector/Bot response cycle).
// Map<string, number> = `${conversationId}:${stepIndex}` → creation timestamp
const recentPendingSteps = new Map<string, number>();
const PENDING_MEMORY_TTL_MS = 60_000; // 60 seconds memory retention
// In-memory cache for diff_review metadata (survives pending file deletion by Collector).
// Map<request_id, { edit_step_indices, modified_files }>
const diffReviewMetadata = new Map<string, { edit_step_indices: number[]; modified_files: string[] }>();
// ─── Project Detection ───
function detectProjectName(): string {
const config = vscode.workspace.getConfiguration('gravityBridge');
const configName = config.get<string>('projectName');
if (configName) { return configName; }
const folders = vscode.workspace.workspaceFolders;
if (folders && folders.length > 0) {
const cwd = folders[0].uri.fsPath;
try {
const remoteUrl = cp.execSync('git remote get-url origin', {
cwd, encoding: 'utf-8', timeout: 3000
}).trim();
const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
if (match && match[1]) {
return match[1].toLowerCase().replace(/[\s\-]+/g, '_');
}
} catch { }
return path.basename(cwd).toLowerCase().replace(/[\s\-]+/g, '_');
}
return 'default';
}
// ─── Bridge File I/O ───
function ensureBridgeDir() {
const dirs = ['', 'response', 'commands', 'chat_snapshots'];
for (const d of dirs) {
const p = path.join(bridgePath, d);
if (!fs.existsSync(p)) { fs.mkdirSync(p, { recursive: true }); }
}
}
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
let activeSessionId = '';
let activeTrajectoryId = '';
// Track recently sent Discord→AG texts to avoid echo relay
const recentDiscordSentTexts: Map<string, number> = new Map();
function writeChatSnapshot(text: string) {
try {
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
conversation_id: activeSessionId,
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
return;
}
// File route (fallback — only when WS is NOT connected)
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); }
const id = Date.now().toString();
const data = {
id,
project_name: projectName,
content: text,
timestamp: Date.now() / 1000,
};
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) {
console.log(`Gravity Bridge: snapshot write error: ${e.message}`);
}
}
function writeChatSnapshotWithFiles(text: string, files: Array<{name: string, content: string}>) {
try {
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
attached_files: files,
conversation_id: activeSessionId,
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
return;
}
// File route (fallback — only when WS is NOT connected)
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); }
const id = Date.now().toString();
const data = {
id,
project_name: projectName,
content: text,
attached_files: files,
timestamp: Date.now() / 1000,
};
const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars, ${files.length} files)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
} catch (e: any) {
console.log(`Gravity Bridge: snapshot+files write error: ${e.message}`);
}
}
// ─── Command File Watcher (Discord → Antigravity) ───
function processCommandFile(filePath: string) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const cmd = JSON.parse(content);
// Skip already consumed commands
if (cmd.consumed) {
try { fs.unlinkSync(filePath); } catch { }
return;
}
// Ignore commands for other projects
if (cmd.project_name && cmd.project_name !== projectName) {
console.log(`Gravity Bridge: skipping command for "${cmd.project_name}" (we are "${projectName}")`);
return;
}
// Bot writes 'text' field, not 'message'
const text = cmd.text || cmd.message || '';
const action = cmd.action || '';
console.log(`Gravity Bridge: command — text="${text}" action="${action}"`);
if (action === 'approve' && sdk) {
sdk.cascade.acceptStep().catch((e: any) =>
console.log(`Gravity Bridge: approve error: ${e.message}`)
);
} else if (action === 'reject' && sdk) {
sdk.cascade.rejectStep().catch((e: any) =>
console.log(`Gravity Bridge: reject error: ${e.message}`)
);
} else if (action === 'approve_terminal' && sdk) {
sdk.cascade.acceptTerminalCommand().catch((e: any) =>
console.log(`Gravity Bridge: approve_terminal error: ${e.message}`)
);
} else if (text === '!stop') {
// Cancel current operation
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
.then(() => console.log('Gravity Bridge: ✅ stop sent'),
() => { });
} else if (text.startsWith('!auto')) {
// Auto-approve mode toggle
if (text === '!auto on') {
autoApproveEnabled = true;
} else if (text === '!auto off') {
autoApproveEnabled = false;
} else {
// Toggle if no explicit on/off
autoApproveEnabled = !autoApproveEnabled;
}
logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`);
} else if (text) {
// Send message to Antigravity — use VS Code command (most reliable)
recentDiscordSentTexts.set(text.trim(), Date.now());
vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', text)
.then(() => console.log(`Gravity Bridge: ✅ sent "${text.substring(0, 50)}" via sendPromptToAgentPanel`),
(e: any) => console.log(`Gravity Bridge: sendPrompt failed: ${e.message}`));
}
// Remove processed command file
try { fs.unlinkSync(filePath); } catch { }
} catch (e: any) {
console.log(`Gravity Bridge: command processing error: ${e.message}`);
}
}
function watchCommandsDir() {
const cmdDir = path.join(bridgePath, 'commands');
// Process existing files
const processAllCommands = () => {
try {
for (const f of fs.readdirSync(cmdDir)) {
if (f.endsWith('.json')) {
processCommandFile(path.join(cmdDir, f));
}
}
} catch { }
};
processAllCommands();
// Watch for new files (may not fire reliably on Windows)
try {
commandsWatcher = fs.watch(cmdDir, (event, filename) => {
if (filename && filename.endsWith('.json') && event === 'rename') {
const fp = path.join(cmdDir, filename);
if (fs.existsSync(fp)) {
setTimeout(() => processCommandFile(fp), 200);
}
}
});
} catch { }
// Polling fallback: fs.watch on Windows can silently fail
setInterval(() => {
processAllCommands();
}, 3000);
}
// ─── SDK Integration ───
async function initSDK(context: vscode.ExtensionContext): Promise<boolean> {
try {
const sdkModule = require('./sdk/index');
AntigravitySDK = sdkModule.AntigravitySDK;
} catch (err: any) {
console.log(`Gravity Bridge: antigravity-sdk load failed: ${err.message}`);
return false;
}
try {
sdk = new AntigravitySDK(context);
await sdk.initialize();
console.log('Gravity Bridge: ✅ SDK initialized');
// ── FIX: SDK's _findLSProcess() uses case-sensitive String.includes() ──
// workspace_id in LS process has 'Desktop' (capital D), but SDK hint
// generates 'desktop' (lowercase) → match fails → connects to WRONG LS.
// Re-discover the correct LS using case-insensitive workspace_id matching.
await fixLSConnection();
return true;
} catch (err: any) {
console.log(`Gravity Bridge: SDK init failed: ${err.message}`);
return false;
}
}
/**
* Fix SDK's LS connection by finding the correct language_server process
* for this workspace using case-insensitive matching.
*
* SDK bug: _findLSProcess() compares workspaceHint via JS String.includes()
* which is case-sensitive. workspace_id in process args has original casing
* (e.g., file_c_3A_Users_Certes_Desktop_variet_agent) but SDK hint is
* lowercased (desktop_variet_agent) → no match → falls back to first LS
* found (wrong workspace).
*/
async function fixLSConnection(): Promise<void> {
if (!sdk?.ls) return;
try {
const folders = vscode.workspace.workspaceFolders;
if (!folders || folders.length === 0) return;
// Generate the workspace hint the same way SDK does, but we'll match case-insensitively
const folder = folders[0].uri.fsPath;
const parts = folder.replace(/\\/g, '/').split('/');
const hint = parts.slice(-2).join('_').replace(/[-.\s]/g, '_').toLowerCase();
if (!hint) return;
// Find all language_server processes with csrf_token
const { exec } = cp;
const { promisify } = require('util');
const execAsync = promisify(exec);
let output: string;
try {
const psScript = `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'language_server' -and $_.CommandLine -match 'csrf_token' } | ForEach-Object { $_.ProcessId.ToString() + '|' + $_.CommandLine }`;
const encoded = Buffer.from(psScript, 'utf16le').toString('base64');
const result = await execAsync(
`powershell.exe -NoProfile -EncodedCommand ${encoded}`,
{ encoding: 'utf8', timeout: 15000, windowsHide: true }
);
output = result.stdout;
} catch {
return; // Can't discover processes — leave SDK's choice
}
const lines = output.split('\n').filter((l: string) => l.trim().length > 0);
if (lines.length <= 1) return; // Only one LS — no ambiguity
// Find the line whose workspace_id matches our workspace (case-insensitive)
let matchedLine: string | null = null;
for (const line of lines) {
const lower = line.toLowerCase();
// Match workspace_id arg against our hint
const wsMatch = line.match(/--workspace_id[= ](\S+)/i);
if (wsMatch) {
const wsid = wsMatch[1].toLowerCase();
if (wsid.includes(hint)) {
matchedLine = line;
break;
}
}
}
if (!matchedLine) {
logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`);
return;
}
// Extract port and csrf_token from matched line
const csrfMatch = matchedLine.match(/--csrf_token[= ](\S+)/);
const extPortMatch = matchedLine.match(/--extension_server_port[= ](\d+)/);
const pidMatch = matchedLine.split('|')[0]?.trim();
if (!csrfMatch || !extPortMatch) {
logToFile(`[LS-FIX] Matched LS but missing csrf/port args`);
return;
}
const csrfToken = csrfMatch[1];
const extPort = parseInt(extPortMatch[1], 10);
const pid = parseInt(pidMatch || '0', 10);
// Check if SDK already connected to this LS
if (sdk.ls.port === extPort) {
logToFile(`[LS-FIX] SDK already on correct LS port=${extPort}`);
return;
}
// Find ConnectRPC port via netstat (same as SDK logic)
let netstatOutput: string;
try {
const result = await execAsync(
`netstat -aon | findstr "LISTENING" | findstr "${pid}"`,
{ encoding: 'utf8', timeout: 5000, windowsHide: true }
);
netstatOutput = result.stdout;
} catch {
// Netstat failed — try extension_server_port as fallback
logToFile(`[LS-FIX] netstat failed, using ext_port=${extPort} for PID=${pid}`);
sdk.ls.setConnection(extPort, csrfToken, false);
logToFile(`[LS-FIX] ✅ Reconnected to correct LS: port=${extPort} hint="${hint}" PID=${pid}`);
return;
}
const portMatches = netstatOutput.matchAll(/127\.0\.0\.1:(\d+)/g);
const ports: number[] = [];
for (const m of portMatches) {
const p = parseInt(m[1], 10);
if (p !== extPort && !ports.includes(p)) {
ports.push(p);
}
}
// Try each port — prefer HTTPS, fall back to HTTP
const httpModule = require('http');
const httpsModule = require('https');
for (const useTls of [true, false]) {
const mod = useTls ? httpsModule : httpModule;
const proto = useTls ? 'https' : 'http';
for (const port of ports) {
try {
const ok = await new Promise<boolean>((resolve) => {
const req = mod.request(
`${proto}://127.0.0.1:${port}/exa.language_server_pb.LanguageServerService/GetUserStatus`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
rejectUnauthorized: false,
timeout: 2000,
},
(res: any) => resolve(res.statusCode === 200 || res.statusCode === 401)
);
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
req.write('{}');
req.end();
});
if (ok) {
sdk.ls.setConnection(port, csrfToken, useTls);
logToFile(`[LS-FIX] ✅ Reconnected to correct LS: port=${port} ${proto} hint="${hint}" PID=${pid}`);
return;
}
} catch { /* try next */ }
}
}
// Last resort: use extension_server_port
sdk.ls.setConnection(extPort, csrfToken, false);
logToFile(`[LS-FIX] ✅ Reconnected via ext_port=${extPort} hint="${hint}" PID=${pid}`);
} catch (err: any) {
logToFile(`[LS-FIX] error: ${err.message}`);
}
}
// ─── Approval Observer via SDK IntegrationManager ───
async function setupApprovalObserver() {
if (!sdk) { logToFile('[OBSERVER] no SDK'); return; }
try {
const integration = sdk.integration;
if (!integration) { logToFile('[OBSERVER] sdk.integration unavailable'); return; }
// 1. Start HTTP bridge server in Extension Host
const bridgePort = await startObserverHttpBridge();
if (!bridgePort) { logToFile('[OBSERVER] HTTP bridge failed'); return; }
// 2. Register a TOP_BAR button so build() works
try {
integration.register({
id: 'gravity_bridge_status',
point: 'topBar',
icon: '🌉',
tooltip: 'Gravity Bridge Active',
});
} catch { /* already registered */ }
// 3. Write renderer script with HTTP fetch() approach
const observerJS = generateApprovalObserverScript(bridgePort);
const patcher = (integration as any)._patcher;
if (patcher && typeof patcher.getScriptPath === 'function') {
let baseScript = '';
try { baseScript = integration.build(); } catch { baseScript = ''; }
const combinedScript = baseScript + '\n' + observerJS;
const scriptPath = patcher.getScriptPath();
fs.writeFileSync(scriptPath, combinedScript, 'utf8');
logToFile(`[OBSERVER] script written → ${scriptPath} (port=${bridgePort})`);
if (!integration.isInstalled()) {
patcher.install(combinedScript);
logToFile('[OBSERVER] patcher.install() called (needs reload)');
}
// Patch BOTH HTML files with inline script injection.
// CRITICAL: vscode-file:// does NOT serve custom .js files (silent 404),
// so we MUST inline the script directly into BOTH HTML files.
// workbench.html — loaded by DevTools/standard mode
// workbench-jetski-agent.html — loaded by AG agent mode
const scriptDir = path.dirname(scriptPath);
// Each HTML file has DIFFERENT CSS/JS entry points — they are NOT interchangeable:
// workbench.html → workbench.desktop.main.css + workbench.js
// workbench-jetski-agent.html → tw-base.tailwind.css + jetskiMain.tailwind.css + jetskiAgent.js
// Cross-restoring between them causes CSS to not load → layout broken (elements visible but all shifted left).
const htmlFileSpecs = [
{
name: 'workbench.html',
requiredMarker: 'workbench.desktop.main.css', // CSS unique to this file
requiredScript: 'workbench.js', // JS entry point
},
{
name: 'workbench-jetski-agent.html',
requiredMarker: 'jetskiMain.tailwind.css', // CSS unique to this file
requiredScript: 'jetskiAgent.js', // JS entry point
},
];
// ── FIX #1: File lock to prevent multi-instance HTML patching race ──
const lockFile = path.join(scriptDir, '.patch-lock');
let lockAcquired = false;
try {
if (fs.existsSync(lockFile)) {
const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs;
if (lockAge < 30_000) {
logToFile(`[OBSERVER] another instance is patching (lock age=${Math.round(lockAge/1000)}s) — skipping`);
return; // Exit setupApprovalObserver entirely
}
logToFile(`[OBSERVER] stale lock (age=${Math.round(lockAge/1000)}s) — force-acquiring`);
}
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, ts: Date.now() }), 'utf-8');
lockAcquired = true;
} catch (lockErr: any) {
logToFile(`[OBSERVER] lock acquire error: ${lockErr.message} — proceeding anyway`);
}
for (const spec of htmlFileSpecs) {
const htmlPath = path.join(scriptDir, spec.name);
const backupPath = htmlPath + '.orig';
try {
if (!fs.existsSync(htmlPath)) {
logToFile(`[OBSERVER] ${spec.name} not found — skipping`);
continue;
}
let html = fs.readFileSync(htmlPath, 'utf8');
// ── BACKUP: Save original before first-ever patch ──
// Only backup if the file looks valid AND hasn't been backed up yet.
if (!fs.existsSync(backupPath)
&& html.length >= 500
&& html.includes('<!DOCTYPE html>')
&& html.includes(spec.requiredMarker)) {
fs.writeFileSync(backupPath, html, 'utf8');
logToFile(`[OBSERVER] ${spec.name} backed up to .orig (${html.length} bytes)`);
}
// ── SAFETY: Refuse to patch if file is corrupt, empty, or wrong type ──
// Race condition: another extension instance may be mid-write (0-byte).
// Wrong type: restored from the other HTML file (different CSS/JS refs).
const isCorrupt = html.length < 500 || !html.includes('<!DOCTYPE html>');
const isWrongType = !isCorrupt && !html.includes(spec.requiredMarker);
if (isCorrupt || isWrongType) {
const reason = isCorrupt
? `corrupt/empty (${html.length} bytes)`
: `wrong type (missing ${spec.requiredMarker})`;
logToFile(`[OBSERVER] ${spec.name} detected ${reason}`);
// Try to restore from backup
if (fs.existsSync(backupPath)) {
const backup = fs.readFileSync(backupPath, 'utf8');
if (backup.length >= 500
&& backup.includes('<!DOCTYPE html>')
&& backup.includes(spec.requiredMarker)) {
fs.writeFileSync(htmlPath, backup, 'utf8');
html = backup;
logToFile(`[OBSERVER] ${spec.name} RESTORED from .orig backup (${backup.length} bytes) ✅`);
} else {
logToFile(`[OBSERVER] ${spec.name} .orig backup also invalid — SKIPPING`);
continue;
}
} else {
logToFile(`[OBSERVER] ${spec.name} no .orig backup available — SKIPPING to prevent further damage`);
continue;
}
}
// CRITICAL: Patch CSP to allow inline scripts.
// Default CSP has script-src 'self' 'unsafe-eval' blob: — NO 'unsafe-inline'.
// Without 'unsafe-inline', all inline <script> tags are silently blocked.
if (html.includes('script-src') && !html.match(/script-src[^;]*'unsafe-inline'/)) {
html = html.replace(
/(script-src\s[^;]*?)('self')/,
"$1$2\n\t\t\t\t\t'unsafe-inline'"
);
logToFile(`[OBSERVER] ${spec.name} CSP patched: added 'unsafe-inline' to script-src`);
}
// Remove old external script tag if present (legacy, cannot be served)
const extMarkerStart = '<!-- AG SDK [variet-gravity-bridge] -->';
const extMarkerEnd = '<!-- /AG SDK [variet-gravity-bridge] -->';
if (html.includes(extMarkerStart)) {
const extRe = new RegExp(
'\\n?' + extMarkerStart.replace(/[[\]]/g, '\\$&') +
'[\\s\\S]*?' +
extMarkerEnd.replace(/[[\]]/g, '\\$&') + '\\n?'
);
html = html.replace(extRe, '');
logToFile(`[OBSERVER] removed external script tag from ${spec.name}`);
}
// Insert or update inline script
const inlineMarkerStart = '<!-- AG SDK INLINE [variet-gravity-bridge] -->';
const inlineMarkerEnd = '<!-- /AG SDK INLINE [variet-gravity-bridge] -->';
if (html.includes(inlineMarkerStart)) {
const re = new RegExp(
inlineMarkerStart.replace(/[[\]]/g, '\\$&') +
'[\\s\\S]*?' +
inlineMarkerEnd.replace(/[[\]]/g, '\\$&')
);
html = html.replace(re,
`${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}`);
logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`);
} else {
html = html.replace('</html>',
`\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`);
}
// SAFETY: Final validation before write
if (html.length < 500 || !html.includes('<!DOCTYPE html>') || !html.includes(spec.requiredMarker)) {
logToFile(`[OBSERVER] ${spec.name} WOULD BE CORRUPT after patching (${html.length} bytes) — ABORTING write`);
continue;
}
fs.writeFileSync(htmlPath, html, 'utf8');
} catch (e: any) {
logToFile(`[OBSERVER] ${spec.name} patch error: ${e.message}`);
}
}
// Release patch lock
if (lockAcquired) {
try { fs.unlinkSync(lockFile); } catch { }
logToFile('[OBSERVER] patch lock released');
}
}
// 4. Update product.json checksums so vscode-file:// serves our patched files
updateProductChecksums();
try { integration.enableAutoRepair(); } catch { }
setInterval(() => { try { integration.signalActive(); } catch { } }, 30_000);
logToFile(`[OBSERVER] setup complete (HTTP bridge on port ${bridgePort})`);
console.log(`Gravity Bridge: ✅ Approval observer installed (port ${bridgePort})`);
} catch (err: any) {
logToFile(`[OBSERVER] setup error: ${err.message}`);
}
}
// ─── Product.json Checksum Auto-Update ───
// vscode-file:// protocol validates SHA256 checksums in product.json.
// If a file's checksum doesn't match, Electron serves the ORIGINAL cached version.
// This function recalculates checksums for files we modify (HTML files with <script> tags).
function updateProductChecksums() {
try {
// Find product.json (2 levels up from workbench dir: resources/app/product.json)
const patcher = (sdk?.integration as any)?._patcher;
if (!patcher || typeof patcher.getWorkbenchDir !== 'function') {
logToFile('[CHECKSUM] no patcher/workbenchDir — skipping');
return;
}
const workbenchDir = patcher.getWorkbenchDir();
// workbenchDir = .../resources/app/out/vs/code/electron-browser/workbench
// product.json = .../resources/app/product.json (5 levels up from workbench)
const appDir = path.resolve(workbenchDir, '..', '..', '..', '..', '..');
const productJsonPath = path.join(appDir, 'product.json');
if (!fs.existsSync(productJsonPath)) {
logToFile(`[CHECKSUM] product.json not found at ${productJsonPath}`);
return;
}
// Read product.json (may have BOM)
let raw = fs.readFileSync(productJsonPath, 'utf8');
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.substring(1);
const product = JSON.parse(raw);
if (!product.checksums) {
logToFile('[CHECKSUM] no checksums section in product.json');
return;
}
// Files we may modify or create (relative key in product.json → absolute path)
// CRITICAL: vscode-file:// only serves files with valid checksums in product.json.
// Custom JS files MUST be added here or they'll silently 404.
const filesToCheck: Record<string, string> = {
'vs/code/electron-browser/workbench/workbench.html': path.join(workbenchDir, 'workbench.html'),
'vs/code/electron-browser/workbench/workbench-jetski-agent.html': path.join(workbenchDir, 'workbench-jetski-agent.html'),
'vs/code/electron-browser/workbench/ag-sdk-variet-gravity-bridge.js': path.join(workbenchDir, 'ag-sdk-variet-gravity-bridge.js'),
};
let updated = false;
for (const [key, filePath] of Object.entries(filesToCheck)) {
if (!fs.existsSync(filePath)) continue;
const fileBytes = fs.readFileSync(filePath);
const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, '');
if (product.checksums[key] !== hash) {
logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`);
product.checksums[key] = hash;
updated = true;
}
}
if (updated) {
fs.writeFileSync(productJsonPath, JSON.stringify(product, null, '\t'), 'utf8');
logToFile('[CHECKSUM] product.json updated ✅');
} else {
logToFile('[CHECKSUM] all checksums already match ✅');
}
} catch (e: any) {
logToFile(`[CHECKSUM] error: ${e.message}`);
}
}
// ─── HTTP Bridge Server (Extension Host → Renderer communication) ───
let observerHttpServer: any = null;
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)
let stallProbed = false; // prevent repeated step probes during same stall (module-level for processResponseFile reset)
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending (module-level for processResponseFile)
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
let deepInspectRequested = false;
let deepInspectResult: any = null;
let deepInspectWaiters: Array<(data: any) => void> = [];
/** Derive a deterministic port from project name (range 10000-60000) */
function getDeterministicPort(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
}
return 10000 + (Math.abs(hash) % 50000);
}
function startObserverHttpBridge(): Promise<number> {
return new Promise((resolve) => {
try {
const http = require('http');
const server = http.createServer((req: any, res: any) => {
// CORS headers for renderer fetch()
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
const url = new URL(req.url, `http://127.0.0.1`);
// POST /pending — renderer reports a detected approval button
if (req.method === 'POST' && url.pathname === '/pending') {
let body = '';
req.on('data', (c: string) => body += c);
req.on('end', () => {
try {
const data = JSON.parse(body);
// ── Server-side false positive filter ──
const cmd = (data.command || '').trim();
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Deny|Allow Once|Allow This Conversation|Dismiss|Decline|Accept|Reject|Accept all|Reject all)$/i;
if (FALSE_POSITIVE_RE.test(cmd)) {
logToFile(`[HTTP] filtered false positive: "${cmd}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true }));
return;
}
// "Run" button → step_probe handles these with full command detail
// Only let through if session is stalled AND step_probe hasn't created a pending yet
if (/^Run$/i.test(cmd)) {
if (!sessionStalled || lastPendingStepIndex >= 0) {
logToFile(`[HTTP] filtered "Run" — ${!sessionStalled ? 'not stalled' : 'step_probe pending exists'}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true }));
return;
}
}
const rid = data.request_id || Date.now().toString();
// Write pending file for Discord bot
const pendingDir = path.join(bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
const pending: Record<string, any> = {
...data,
request_id: rid,
conversation_id: activeSessionId || '',
timestamp: Date.now() / 1000,
status: 'pending',
project_name: projectName,
auto_detected: true,
source: 'dom_observer',
step_index: lastPendingStepIndex >= 0 ? lastPendingStepIndex : undefined,
};
// File permission: inject multi-choice buttons
const cmdLower = (data.command || '').toLowerCase();
if (cmdLower.includes('allow') && !pending.buttons) {
// Dedup: skip if another file_permission pending was created within 10s
const nowMs = Date.now();
try {
const existingFiles = fs.readdirSync(pendingDir).filter((f: string) => f.endsWith('.json'));
for (const ef of existingFiles) {
const existing = JSON.parse(fs.readFileSync(path.join(pendingDir, ef), 'utf-8'));
if (existing.step_type === 'file_permission' && existing.status === 'pending'
&& existing.project_name === projectName) {
const age = nowMs - (existing.timestamp * 1000);
if (age < 10_000 && age >= 0) {
logToFile(`[HTTP] filtered duplicate file_permission (${age}ms old): ${ef}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' }));
return;
}
}
}
} catch { }
pending.buttons = [
{ text: 'Allow Once', index: 0 },
{ text: 'Allow This Conversation', index: 1 },
{ text: 'Deny', index: 2 },
];
pending.step_type = 'file_permission';
// Clean description: remove button labels from text
const rawDesc = (data.description || data.command || '').replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim();
pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`;
}
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
// WS dual-write
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendPending({
request_id: rid,
command: pending.command || data.command || '',
description: pending.description || data.description || '',
step_type: pending.step_type,
status: 'pending',
buttons: pending.buttons,
project_name: projectName,
});
logToFile(`[HTTP-WS] pending sent via WS: ${rid}`);
}
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, request_id: rid }));
} catch (e: any) {
logToFile(`[HTTP] pending error: ${e.message}`);
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
// GET /response/:rid — renderer polls for Discord approval
if (req.method === 'GET' && url.pathname.startsWith('/response/')) {
const rid = url.pathname.split('/')[2];
const respFile = path.join(bridgePath, 'response', `${rid}.json`);
if (fs.existsSync(respFile)) {
try {
const data = JSON.parse(fs.readFileSync(respFile, 'utf8'));
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 {
res.writeHead(200); res.end(JSON.stringify({ waiting: true }));
}
} else {
res.writeHead(200); res.end(JSON.stringify({ waiting: true }));
}
return;
}
// GET /trigger-click — renderer polls to check if extension wants a click
if (req.method === 'GET' && url.pathname === '/trigger-click') {
if (clickTrigger && (Date.now() - clickTrigger.timestamp) < 30000) {
const trigger = clickTrigger;
clickTrigger = null; // consume once
logToFile(`[HTTP] trigger-click consumed: ${trigger.action}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ action: trigger.action }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ action: null }));
}
return;
}
// GET /deep-inspect — trigger deep DOM inspection from renderer
if (req.method === 'GET' && url.pathname === '/deep-inspect') {
deepInspectRequested = true;
logToFile('[HTTP] deep-inspect triggered — waiting for renderer result...');
// Wait up to 10s for renderer to POST result
const timeout = setTimeout(() => {
deepInspectWaiters = deepInspectWaiters.filter(w => w !== waiter);
if (deepInspectResult) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(deepInspectResult));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'timeout', message: 'Renderer did not respond in 10s. Is the v3 script loaded?' }));
}
}, 10000);
const waiter = (data: any) => {
clearTimeout(timeout);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
deepInspectWaiters.push(waiter);
return;
}
// GET /deep-inspect-trigger — renderer polls this
if (req.method === 'GET' && url.pathname === '/deep-inspect-trigger') {
const requested = deepInspectRequested;
deepInspectRequested = false;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ inspect: requested }));
return;
}
// POST /deep-inspect-result — renderer posts inspection results here
if (req.method === 'POST' && url.pathname === '/deep-inspect-result') {
let body = '';
req.on('data', (c: string) => body += c);
req.on('end', () => {
try {
const data = JSON.parse(body);
deepInspectResult = data;
logToFile(`[HTTP] deep-inspect result received (${body.length} bytes)`);
// Write to file for reference
const inspectFile = path.join(bridgePath, 'deep-inspect-result.json');
fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2));
// Notify waiters
const waiters = [...deepInspectWaiters];
deepInspectWaiters = [];
waiters.forEach(w => w(data));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (e: any) {
logToFile(`[HTTP] deep-inspect-result parse error: ${e.message}`);
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
// GET /ping — health check
if (url.pathname === '/ping') {
res.writeHead(200); res.end('pong');
return;
}
res.writeHead(404); res.end('not found');
});
// Listen on deterministic port (derived from projectName), fallback to random
deterministicPort = getDeterministicPort(projectName);
const tryListen = (targetPort: number) => {
server.listen(targetPort, '127.0.0.1', () => {
const port = server.address().port;
observerHttpServer = server;
logToFile(`[HTTP] bridge server started on port ${port}`);
// Write port to shared ports JSON (multi-bridge support)
const patcher = (sdk.integration as any)?._patcher;
if (patcher && typeof patcher.getWorkbenchDir === 'function') {
const workbenchDir = patcher.getWorkbenchDir();
const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
let portsData: Record<string, number> = {};
try {
if (fs.existsSync(portsFile)) {
portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8'));
}
} catch { }
portsData[projectName] = port;
fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8');
logToFile(`[HTTP] ports JSON updated → ${portsFile} (${projectName}=${port})`);
}
resolve(port);
});
};
server.on('error', (e: any) => {
if (e.code === 'EADDRINUSE' && deterministicPort > 0) {
logToFile(`[HTTP] deterministic port ${deterministicPort} in use, trying random...`);
deterministicPort = 0;
const server2 = require('http').createServer(server._events.request);
observerHttpServer = server2;
server2.on('error', (e2: any) => {
logToFile(`[HTTP] random port also failed: ${e2.message}`);
resolve(0);
});
server2.listen(0, '127.0.0.1', () => {
const port = server2.address().port;
logToFile(`[HTTP] bridge server started on RANDOM port ${port}`);
resolve(port);
});
return;
}
logToFile(`[HTTP] server error: ${e.message}`);
resolve(0);
});
tryListen(deterministicPort);
} catch (e: any) {
logToFile(`[HTTP] server failed: ${e.message}`);
resolve(0);
}
});
}
// ─── Step Probe, Response Watcher, Approval Strategies → extracted to ./step-probe.ts ───
export async function activate(context: vscode.ExtensionContext) {
console.log('Gravity Bridge: activating...');
// Project detection
projectName = detectProjectName();
// Store workspace folder path for session filtering (prevents cross-window session grabbing)
const folders = vscode.workspace.workspaceFolders;
workspaceUri = folders && folders.length > 0 ? folders[0].uri.fsPath : '';
console.log(`Gravity Bridge: project "${projectName}" workspace="${workspaceUri}"`);
// Bridge path
const config = vscode.workspace.getConfiguration('gravityBridge');
const configPath = config.get<string>('bridgePath');
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
ensureBridgeDir();
console.log(`Gravity Bridge: bridge path: ${bridgePath}`);
// ── WebSocket Hub Connection ──
const hubUrl = process.env.GRAVITY_HUB_URL || config.get<string>('hubUrl') || '';
const regCode = process.env.GRAVITY_REGISTRATION_CODE || config.get<string>('registrationCode') || '';
const pcName = os.hostname();
if (hubUrl) {
wsBridge = new WSBridgeClient(hubUrl, regCode, projectName, pcName, {
onResponse: (data: WSResponseData) => {
logToFile(`[WS-RESPONSE] ${data.request_id?.substring(0, 12)} approved=${data.approved} step_type=${data.step_type || '(none)'}`);
// Direct approval — WS path has no pending file, so call tryApprovalStrategies directly
const approved = data.approved ?? true;
const stepType = data.step_type || '';
const approvalCtx = getApprovalContext();
logToFile(`[WS-RESPONSE] Triggering approval: approved=${approved} session=${approvalCtx.sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${approvalCtx.stepIndex}`);
tryApprovalStrategies(approved, approvalCtx.sessionId, stepType, approvalCtx.stepIndex)
.then(result => {
logToFile(`[WS-RESPONSE] Approval result: ${result}`);
resetPendingState();
})
.catch(err => logToFile(`[WS-RESPONSE] Approval error: ${err.message}`));
},
onCommand: (data: WSCommandData) => {
logToFile(`[WS-CMD] ${data.text?.substring(0, 50)}`);
// Process command directly (same logic as processCommandFile)
_handleWSCommand(data);
},
onInstanceUpdate: (count, instances) => {
logToFile(`[WS-INSTANCE] ${count} active instances`);
},
onConnected: (connId, instanceNum, token) => {
logToFile(`[WS] Connected: ${connId} instance=#${instanceNum}`);
statusBar.text = '$(check) Bridge WS';
statusBar.tooltip = `Gravity Bridge: ${projectName} (WS #${instanceNum})`;
},
onDisconnected: () => {
logToFile('[WS] Disconnected — using file fallback');
statusBar.text = '$(warning) Bridge (WS ↓)';
},
onError: (err) => {
logToFile(`[WS-ERR] ${err}`);
},
}, logToFile);
wsBridge.connect();
logToFile(`[WS] Hub connection initiated: ${hubUrl}`);
} else {
logToFile('[WS] No GRAVITY_HUB_URL — WebSocket disabled, using file bridge only');
}
// ── Multi-project: no lock file, each project uses project_name-based filtering ──
// (active_project.lock removed — was blocking concurrent multi-project usage)
logToFile(`[INIT] project="${projectName}" pid=${process.pid} — multi-project mode (no lock)`);
// Status bar
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
statusBar.text = '$(sync~spin) Bridge';
statusBar.tooltip = `Gravity Bridge: ${projectName}`;
statusBar.show();
context.subscriptions.push(statusBar);
// Initialize SDK
const sdkReady = await initSDK(context);
if (sdkReady) {
// ── Command Discovery Diagnostic ──
// Enumerate ALL antigravity.* commands to find correct approval command names
try {
const allCmds = await vscode.commands.getCommands(true);
const agCmds = allCmds.filter((c: string) => c.startsWith('antigravity.'));
logToFile(`[CMD-DISCOVERY] Total antigravity.* commands: ${agCmds.length}`);
// Log approval-related commands specifically
const approvalKeywords = ['accept', 'reject', 'approve', 'terminal', 'agent', 'cascade', 'step', 'run', 'command.'];
const relevantCmds = agCmds.filter((c: string) =>
approvalKeywords.some(kw => c.toLowerCase().includes(kw))
);
logToFile(`[CMD-DISCOVERY] Approval-related commands (${relevantCmds.length}):`);
for (const cmd of relevantCmds) {
logToFile(`[CMD-DISCOVERY] → ${cmd}`);
}
// Also dump ALL commands for full reference
logToFile(`[CMD-DISCOVERY] ALL antigravity.* commands:`);
for (const cmd of agCmds) {
logToFile(`[CMD-DISCOVERY] ${cmd}`);
}
} catch (e: any) {
logToFile(`[CMD-DISCOVERY] error: ${e.message}`);
}
// Initialize step probe (polling + response watcher)
initStepProbe({
bridgePath,
projectName,
sdk,
wsBridge,
autoApproveEnabled,
activeSessionId,
sessionStalled,
lastPendingStepIndex,
stallProbed,
sawRunningAfterPending,
clickTrigger,
logToFile,
workspaceUri,
diffReviewMetadata: new Map(),
recentDiscordSentTexts,
writeChatSnapshot,
writeChatSnapshotWithFiles,
} as BridgeContext);
setupApprovalObserver(); // DOM observer via SDK IntegrationManager
statusBar.text = '$(check) Bridge';
statusBar.tooltip = `Gravity Bridge: ${projectName} (POLL + Observer active)`;
// Register SDK-powered commands
context.subscriptions.push(
vscode.commands.registerCommand('gravityBridge.approve', async () => {
try {
await sdk.cascade.acceptStep();
vscode.window.showInformationMessage('Gravity Bridge: Step approved');
} catch (e: any) {
vscode.window.showErrorMessage(`Approve failed: ${e.message}`);
}
}),
vscode.commands.registerCommand('gravityBridge.reject', async () => {
try {
await sdk.cascade.rejectStep();
vscode.window.showInformationMessage('Gravity Bridge: Step rejected');
} catch (e: any) {
vscode.window.showErrorMessage(`Reject failed: ${e.message}`);
}
})
);
} else {
statusBar.text = '$(warning) Bridge (no SDK)';
console.log('Gravity Bridge: SDK not available, file-based mode only');
}
// Watch commands directory
watchCommandsDir();
// Response watcher is now initialized by initStepProbe() above
// Register basic commands
context.subscriptions.push(
vscode.commands.registerCommand('gravityBridge.start', () => {
isActive = true;
statusBar.text = sdkReady ? '$(check) Bridge SDK' : '$(sync~spin) Bridge';
vscode.window.showInformationMessage(`Gravity Bridge started for "${projectName}"`);
}),
vscode.commands.registerCommand('gravityBridge.stop', () => {
isActive = false;
// SDK monitor is disabled, no need to stop
statusBar.text = '$(circle-slash) Bridge OFF';
vscode.window.showInformationMessage('Gravity Bridge stopped');
}),
vscode.commands.registerCommand('gravityBridge.connect', async () => {
if (!sdk) {
vscode.window.showErrorMessage('SDK not initialized');
return;
}
try {
const sessions = await sdk.cascade.getSessions();
const items = sessions.map((s: any) => ({
label: s.title || 'Untitled',
description: `step ${s.stepCount}${s.id?.substring(0, 8)}`,
sessionId: s.id,
}));
const pick = await vscode.window.showQuickPick(items, {
placeHolder: 'Select a conversation to connect'
});
if (pick) {
await sdk.cascade.focusSession((pick as any).sessionId);
vscode.window.showInformationMessage(`Connected to: ${(pick as any).label}`);
}
} catch (e: any) {
vscode.window.showErrorMessage(`Connect failed: ${e.message}`);
}
})
);
// Cleanup
context.subscriptions.push({
dispose: () => {
if (sdk) { try { sdk.dispose(); } catch { } }
if (watcher) { watcher.close(); }
if (commandsWatcher) { commandsWatcher.close(); }
}
});
console.log('Gravity Bridge: ✅ activated');
isActive = true;
}
export function deactivate() {
// Disconnect WebSocket
if (wsBridge) {
wsBridge.disconnect();
wsBridge = null;
}
// Clean up stale lock file if it exists (legacy cleanup)
try {
const lockFile = path.join(bridgePath, 'active_project.lock');
if (fs.existsSync(lockFile)) {
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf-8'));
if (lockData.pid === process.pid) {
fs.unlinkSync(lockFile);
}
}
} catch { }
if (sdk) {
try { sdk.dispose(); } catch { }
}
}
// ─── WS Command Handler ───
function _handleWSCommand(data: WSCommandData) {
const text = data.text || '';
if (!text) return;
// Project filtering (WS already routes by project, but double-check)
if (data.project_name && data.project_name !== projectName) {
logToFile(`[WS-CMD] Ignoring command for ${data.project_name} (we are ${projectName})`);
return;
}
if (text === '!stop') {
logToFile('[WS-CMD] !stop — cancelling AG task');
if (sdk) {
try { sdk.cascade.cancelCurrentTask(); } catch { }
}
return;
}
if (text.startsWith('!auto')) {
const parts = text.split(' ');
autoApproveEnabled = parts[1] !== 'off';
logToFile(`[WS-CMD] auto_approve=${autoApproveEnabled}`);
return;
}
// General text → send as user message to AG
logToFile(`[WS-CMD] Sending text to AG: ${text.substring(0, 80)}`);
if (sdk) {
try {
sdk.cascade.sendPrompt(text);
} catch (e: any) {
logToFile(`[WS-CMD] SDK sendPrompt error: ${e.message}`);
}
}
}