230 lines
8.3 KiB
TypeScript
230 lines
8.3 KiB
TypeScript
/**
|
|
* 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<string, number>;
|
|
/** 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}`);
|
|
}
|
|
}
|