feat(phase3): VS Code Extension 스캐폴드 - bridge 연동 (approve/reject/text relay)

This commit is contained in:
2026-03-07 11:32:30 +09:00
parent a76208e4e6
commit 52fed8c1d3
7 changed files with 714 additions and 0 deletions

285
extension/src/extension.ts Normal file
View File

@@ -0,0 +1,285 @@
/**
* Gravity Bridge — VS Code Extension
*
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
*
* Flow:
* 1. Extension watches for tool approval notifications in VS Code
* 2. Writes pending approval to bridge/pending/
* 3. Discord bot sends buttons to user
* 4. User clicks approve/reject
* 5. Bot writes response to bridge/response/
* 6. Extension reads response → sends keyboard command to approve/reject
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
let watcher: fs.FSWatcher | null = null;
let statusBar: vscode.StatusBarItem;
let bridgePath: string;
let isActive = false;
// Track pending approvals we've already sent
const sentPendingIds = new Set<string>();
export function activate(context: vscode.ExtensionContext) {
console.log('Gravity Bridge: activating...');
// Determine bridge path
const config = vscode.workspace.getConfiguration('gravityBridge');
const configPath = config.get<string>('bridgePath');
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
// Ensure bridge directories exist
const dirs = ['pending', 'response', 'commands'];
for (const dir of dirs) {
const dirPath = path.join(bridgePath, dir);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
// Status bar
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
statusBar.command = 'gravityBridge.start';
statusBar.text = '$(radio-tower) Bridge: Off';
statusBar.tooltip = 'Gravity Bridge — Click to start';
statusBar.show();
context.subscriptions.push(statusBar);
// Register commands
context.subscriptions.push(
vscode.commands.registerCommand('gravityBridge.start', startBridge),
vscode.commands.registerCommand('gravityBridge.stop', stopBridge),
vscode.commands.registerCommand('gravityBridge.approve', () => handleManualAction(true)),
vscode.commands.registerCommand('gravityBridge.reject', () => handleManualAction(false)),
);
// Auto-start
startBridge();
}
function startBridge() {
if (isActive) {
vscode.window.showInformationMessage('Gravity Bridge is already running');
return;
}
isActive = true;
statusBar.text = '$(radio-tower) Bridge: On';
statusBar.tooltip = 'Gravity Bridge — Active';
statusBar.command = 'gravityBridge.stop';
// Watch bridge/response/ for Discord user responses
const responsePath = path.join(bridgePath, 'response');
try {
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
if (eventType === 'rename' && filename && filename.endsWith('.json')) {
handleResponse(path.join(responsePath, filename));
}
});
console.log('Gravity Bridge: watching response directory');
} catch (err) {
console.error('Gravity Bridge: failed to watch response dir', err);
}
// Watch for commands (user text input from Discord)
const commandsPath = path.join(bridgePath, 'commands');
try {
fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
if (eventType === 'rename' && filename && filename.endsWith('.json')) {
handleCommand(path.join(commandsPath, filename));
}
});
console.log('Gravity Bridge: watching commands directory');
} catch (err) {
console.error('Gravity Bridge: failed to watch commands dir', err);
}
vscode.window.showInformationMessage('Gravity Bridge: Started');
console.log(`Gravity Bridge: started, bridge path: ${bridgePath}`);
}
function stopBridge() {
if (!isActive) { return; }
isActive = false;
statusBar.text = '$(radio-tower) Bridge: Off';
statusBar.tooltip = 'Gravity Bridge — Click to start';
statusBar.command = 'gravityBridge.start';
if (watcher) {
watcher.close();
watcher = null;
}
vscode.window.showInformationMessage('Gravity Bridge: Stopped');
}
/**
* Handle a response from Discord (approve/reject).
* Reads the response JSON and simulates the appropriate action.
*/
async function handleResponse(filePath: string) {
try {
// Small delay to ensure file is fully written
await new Promise(resolve => setTimeout(resolve, 200));
if (!fs.existsSync(filePath)) { return; }
const content = fs.readFileSync(filePath, 'utf-8');
const response = JSON.parse(content);
if (response.approved === undefined) { return; }
console.log(`Gravity Bridge: response received — approved=${response.approved}`);
if (response.approved) {
// Simulate pressing Enter or clicking approve
// Strategy: Use VS Code command to accept suggestion
await simulateApproval();
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
} else {
await simulateRejection();
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
}
// Cleanup: delete the response file after processing
try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ }
} catch (err) {
console.error('Gravity Bridge: error handling response', err);
}
}
/**
* Handle a text command from Discord.
* Types the text into the active editor or chat input.
*/
async function handleCommand(filePath: string) {
try {
await new Promise(resolve => setTimeout(resolve, 200));
if (!fs.existsSync(filePath)) { return; }
const content = fs.readFileSync(filePath, 'utf-8');
const command = JSON.parse(content);
if (command.consumed || !command.text) { return; }
console.log(`Gravity Bridge: command received — "${command.text.substring(0, 50)}..."`);
// Type into the active text input (chat panel)
await vscode.commands.executeCommand('workbench.action.chat.open');
// Small delay for chat panel to open
await new Promise(resolve => setTimeout(resolve, 500));
// Type the text using clipboard
const oldClipboard = await vscode.env.clipboard.readText();
await vscode.env.clipboard.writeText(command.text);
await vscode.commands.executeCommand('editor.action.clipboardPasteAction');
await vscode.env.clipboard.writeText(oldClipboard);
// Mark as consumed
command.consumed = true;
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
vscode.window.showInformationMessage(`📨 Discord input: ${command.text.substring(0, 50)}...`);
} catch (err) {
console.error('Gravity Bridge: error handling command', err);
}
}
/**
* Simulate approval — try multiple strategies.
*/
async function simulateApproval() {
try {
// Strategy 1: Try executing the accept command if available
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
} catch {
// Strategy 2: Send Enter key via type command
try {
await vscode.commands.executeCommand('type', { text: '\n' });
} catch {
// Strategy 3: Focus terminal and send Enter
await vscode.commands.executeCommand('workbench.action.terminal.focus');
}
}
}
/**
* Simulate rejection — try multiple strategies.
*/
async function simulateRejection() {
try {
// Strategy 1: Escape key
await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
} catch {
try {
await vscode.commands.executeCommand('cancelSelection');
} catch {
// Fallback: just notify
console.log('Gravity Bridge: rejection sent but no active dialog found');
}
}
}
/**
* Manual approve/reject from command palette.
* Writes a pending request for testing purposes.
*/
function handleManualAction(approved: boolean) {
const requestId = `manual-${Date.now()}`;
const responsePath = path.join(bridgePath, 'response', `${requestId}.json`);
const response = {
request_id: requestId,
approved: approved,
user_input: '',
timestamp: Date.now() / 1000,
};
fs.writeFileSync(responsePath, JSON.stringify(response, null, 2), 'utf-8');
if (approved) {
simulateApproval();
} else {
simulateRejection();
}
}
/**
* Write a pending approval request to bridge/pending/ for Discord bot to pick up.
*/
export function writePendingApproval(
conversationId: string,
command: string,
description: string
): string {
const requestId = `req-${Date.now()}`;
const pendingPath = path.join(bridgePath, 'pending', `${requestId}.json`);
const request = {
request_id: requestId,
conversation_id: conversationId,
command: command,
description: description,
timestamp: Date.now() / 1000,
status: 'pending',
discord_message_id: 0,
};
fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8');
sentPendingIds.add(requestId);
console.log(`Gravity Bridge: pending approval written — ${requestId}`);
return requestId;
}
export function deactivate() {
stopBridge();
}