refactor(extension): split extension.ts into 3 modules - http-bridge, html-patcher, command-handler (#398)

This commit is contained in:
Variet Worker
2026-03-17 18:48:46 +09:00
parent 1ce8b7c707
commit 6640d42449
7 changed files with 928 additions and 688 deletions

View File

@@ -0,0 +1,189 @@
/**
* 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;
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>;
}
// ─── 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');
if (ctx.sdk) {
try { ctx.sdk.cascade.cancelCurrentTask(); } catch { }
}
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)}`);
if (ctx.sdk) {
try {
ctx.sdk.cascade.sendPrompt(text);
} catch (e: any) {
ctx.logToFile(`[WS-CMD] SDK sendPrompt error: ${e.message}`);
}
}
}
// ─── Private ───
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
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
.then(() => console.log('Gravity Bridge: ✅ stop sent'),
() => { });
} 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}`);
}
}