/** * Command Handler — Discord → AG command processing. * * Extracted from extension.ts to reduce file size. * Handles commands from both: * - File-based bridge (legacy/fallback): fs.watch + polling * - WebSocket Hub (primary): direct callbacks */ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; // ─── Context interface (shared state from extension.ts) ─── export interface CommandHandlerContext { bridgePath: string; projectName: string; sdk: any; /** LSBridge instance for direct LS RPC calls (cancelCascade, etc.) */ ls: any; autoApproveEnabled: boolean; logToFile: (msg: string) => void; /** Called when auto-approve is toggled; extension.ts updates its own state */ onAutoApproveChanged: (enabled: boolean) => void; /** Track recently sent Discord→AG texts to avoid echo relay */ recentDiscordSentTexts: Map; /** Get the active cascade/session ID from step-probe polling state */ getActiveSessionId: () => string; } // ─── File-based command watcher ─── let commandsWatcher: fs.FSWatcher | null = null; /** * Watch the commands directory for Discord→AG command files. * Uses fs.watch + polling fallback (fs.watch unreliable on Windows). */ export function watchCommandsDir(ctx: CommandHandlerContext) { const cmdDir = path.join(ctx.bridgePath, 'commands'); // Process existing files const processAllCommands = () => { try { for (const f of fs.readdirSync(cmdDir)) { if (f.endsWith('.json')) { _processCommandFile(path.join(cmdDir, f), ctx); } } } 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, ctx), 200); } } }); } catch { } // Polling fallback: fs.watch on Windows can silently fail setInterval(() => { processAllCommands(); }, 3000); } /** Dispose the file watcher on deactivate */ export function disposeCommandsWatcher() { if (commandsWatcher) { commandsWatcher.close(); commandsWatcher = null; } } // ─── WebSocket command handler ─── /** * Handle a command received via WebSocket Hub. * Same logic as file-based processing but without file I/O. */ export function handleWSCommand(ctx: CommandHandlerContext, data: { text?: string; action?: string; project_name?: string }) { const text = data.text || ''; if (!text) return; // Project filtering (WS already routes by project, but double-check) if (data.project_name && data.project_name !== ctx.projectName) { ctx.logToFile(`[WS-CMD] Ignoring command for ${data.project_name} (we are ${ctx.projectName})`); return; } if (text === '!stop') { ctx.logToFile('[WS-CMD] !stop — cancelling AG task'); _cancelCurrentCascade(ctx); return; } if (text.startsWith('!auto')) { const parts = text.split(' '); const enabled = parts[1] !== 'off'; ctx.onAutoApproveChanged(enabled); ctx.logToFile(`[WS-CMD] auto_approve=${enabled}`); return; } // General text → send as user message to AG ctx.logToFile(`[WS-CMD] Sending text to AG: ${text.substring(0, 80)}`); // Mark for echo-dedup: step-probe will skip relaying this back to Discord ctx.recentDiscordSentTexts.set(text.trim(), Date.now()); if (ctx.sdk) { try { ctx.sdk.cascade.sendPrompt(text); } catch (e: any) { ctx.logToFile(`[WS-CMD] SDK sendPrompt error: ${e.message}`); } } } // ─── Private ─── /** * Cancel the currently active cascade via CancelCascadeInvocation RPC. * This is the same mechanism AG's native red ■ stop button uses. */ async function _cancelCurrentCascade(ctx: CommandHandlerContext) { // 1. Get the active cascade ID from extension state (step-probe polling) // NOTE: ctx.sdk.titles.getActiveCascadeId() is renderer-only (DOM scraping) // and always returns undefined from extension host. Use activeSessionId instead. const cascadeId = ctx.getActiveSessionId(); if (!cascadeId) { ctx.logToFile('[STOP] No active cascade — no session tracked yet'); return; } ctx.logToFile(`[STOP] Cancelling cascade: ${cascadeId.substring(0, 12)}...`); // 2. Use LSBridge.cancelCascade() → CancelCascadeInvocation RPC if (ctx.ls) { try { await ctx.ls.cancelCascade(cascadeId); ctx.logToFile(`[STOP] ✅ CancelCascadeInvocation sent for ${cascadeId.substring(0, 12)}`); return; } catch (e: any) { ctx.logToFile(`[STOP] LSBridge cancelCascade failed: ${e.message}`); } } // 3. Fallback: try rawRPC directly via sdk.ls if (ctx.sdk?.ls?.rawRPC) { try { await ctx.sdk.ls.rawRPC('CancelCascadeInvocation', { cascadeId }); ctx.logToFile(`[STOP] ✅ rawRPC CancelCascadeInvocation sent for ${cascadeId.substring(0, 12)}`); } catch (e: any) { ctx.logToFile(`[STOP] rawRPC fallback also failed: ${e.message}`); } } } function _processCommandFile(filePath: string, ctx: CommandHandlerContext) { 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 !== ctx.projectName) { console.log(`Gravity Bridge: skipping command for "${cmd.project_name}" (we are "${ctx.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' && ctx.sdk) { ctx.sdk.cascade.acceptStep().catch((e: any) => console.log(`Gravity Bridge: approve error: ${e.message}`) ); } else if (action === 'reject' && ctx.sdk) { ctx.sdk.cascade.rejectStep().catch((e: any) => console.log(`Gravity Bridge: reject error: ${e.message}`) ); } else if (action === 'approve_terminal' && ctx.sdk) { ctx.sdk.cascade.acceptTerminalCommand().catch((e: any) => console.log(`Gravity Bridge: approve_terminal error: ${e.message}`) ); } else if (text === '!stop') { // Cancel current operation — use CancelCascadeInvocation RPC (same as AG's red ■ button) _cancelCurrentCascade(ctx); } else if (text.startsWith('!auto')) { // Auto-approve mode toggle let enabled: boolean; if (text === '!auto on') { enabled = true; } else if (text === '!auto off') { enabled = false; } else { // Toggle if no explicit on/off enabled = !ctx.autoApproveEnabled; } ctx.onAutoApproveChanged(enabled); ctx.logToFile(`[AUTO] auto-approve toggled → ${enabled}`); } else if (text) { // Send message to Antigravity — use VS Code command (most reliable) ctx.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}`); } }