Files
gravity_control/extension/src/extension.ts

509 lines
18 KiB
TypeScript

/**
* Gravity Bridge — VS Code Extension
*
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
*
* Multi-project routing:
* - Each workspace has a project name (from settings or workspace folder name)
* - Extension only processes commands/responses matching its project_name
* - Pending approvals include project_name for Discord channel routing
*/
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 commandsWatcher: fs.FSWatcher | null = null;
let statusBar: vscode.StatusBarItem;
let bridgePath: string;
let projectName: string;
let isActive = false;
// Track pending approvals we've already sent
const sentPendingIds = new Set<string>();
import * as cp from 'child_process';
/**
* Detect project name from workspace.
* Priority: settings > git remote repo name > workspace folder name
*/
function detectProjectName(): string {
const config = vscode.workspace.getConfiguration('gravityBridge');
const configName = config.get<string>('projectName');
if (configName) { return configName; }
// Try git remote URL → extract repo name
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();
// "https://gitea.example.com/Variet/gravity_control.git" → "gravity_control"
// "git@github.com:user/repo.git" → "repo"
const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
if (match && match[1]) {
const repoName = match[1].toLowerCase().replace(/[\s\-]+/g, '_');
console.log(`Gravity Bridge: project from git remote → "${repoName}"`);
return repoName;
}
} catch {
// No git or no remote — fall through
}
// Fallback: workspace folder name
return folders[0].name.toLowerCase().replace(/[\s\-]+/g, '_');
}
return 'unknown_project';
}
export function activate(context: vscode.ExtensionContext) {
projectName = detectProjectName();
console.log(`Gravity Bridge: activating for project "${projectName}"...`);
// 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', 'register'];
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) ${projectName}: Off`;
statusBar.tooltip = `Gravity Bridge — ${projectName}`;
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.connect', connectSession),
vscode.commands.registerCommand('gravityBridge.approve', () => handleManualAction(true)),
vscode.commands.registerCommand('gravityBridge.reject', () => handleManualAction(false)),
);
// Auto-watch brain/ for new conversations → auto-register
watchBrainForNewSessions();
// Auto-start
startBridge();
}
function startBridge() {
if (isActive) {
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): already running`);
return;
}
isActive = true;
statusBar.text = `$(radio-tower) ${projectName}: On`;
statusBar.tooltip = `Gravity Bridge — ${projectName} (Active)`;
statusBar.command = 'gravityBridge.stop';
// Watch bridge/response/ for Discord user responses
const responsePath = path.join(bridgePath, 'response');
const processedFiles = new Set<string>(); // Debounce
try {
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
processedFiles.add(filename);
setTimeout(() => processedFiles.delete(filename), 2000);
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 {
commandsWatcher = fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
processedFiles.add(filename);
setTimeout(() => processedFiles.delete(filename), 2000);
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 (${projectName}): Started`);
console.log(`Gravity Bridge: started for project "${projectName}", bridge: ${bridgePath}`);
}
function stopBridge() {
if (!isActive) { return; }
isActive = false;
statusBar.text = `$(radio-tower) ${projectName}: Off`;
statusBar.tooltip = `Gravity Bridge — ${projectName}`;
statusBar.command = 'gravityBridge.start';
if (watcher) { watcher.close(); watcher = null; }
if (commandsWatcher) { commandsWatcher.close(); commandsWatcher = null; }
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Stopped`);
}
/**
* Handle a response from Discord (approve/reject).
* Only processes responses — no project filtering needed since request_id is unique.
*/
async function handleResponse(filePath: string) {
try {
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 [${projectName}]: response — approved=${response.approved}`);
if (response.approved) {
await simulateApproval();
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
} else {
await simulateRejection();
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
}
try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ }
} catch (err) {
console.error('Gravity Bridge: error handling response', err);
}
}
/**
* Handle a text command from Discord.
* ONLY processes commands matching this project's name.
*/
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; }
// ★ PROJECT FILTER — only process commands for THIS project
const cmdProject = command.project_name || '';
if (cmdProject && cmdProject !== projectName) {
console.log(`Gravity Bridge [${projectName}]: skipping command for "${cmdProject}"`);
return; // Not for us — leave file for the correct Extension instance
}
const text = command.text.trim();
console.log(`Gravity Bridge [${projectName}]: command — "${text.substring(0, 50)}"`);
// Special command: auto-approve toggle
if (text === '!auto on' || text === '!auto off') {
const enabled = text === '!auto on';
await toggleAutoApprove(enabled);
command.consumed = true;
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
return;
}
// General text: type into chat panel
await vscode.commands.executeCommand('workbench.action.chat.open');
await new Promise(resolve => setTimeout(resolve, 500));
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(`📨 [${projectName}] Discord: ${command.text.substring(0, 50)}...`);
} catch (err) {
console.error('Gravity Bridge: error handling command', err);
}
}
/**
* Toggle Antigravity's auto-approve settings.
*/
async function toggleAutoApprove(enabled: boolean) {
const config = vscode.workspace.getConfiguration();
try {
await config.update('chat.tools.autoApprove', enabled, vscode.ConfigurationTarget.Global);
await config.update('chat.agent.autoApprove', enabled, vscode.ConfigurationTarget.Global);
if (enabled) {
await config.update('chat.tools.terminal.enableAutoApprove', true, vscode.ConfigurationTarget.Global);
}
await config.update('autoAcceptV2.autoAcceptFileEdits', enabled, vscode.ConfigurationTarget.Global);
statusBar.text = enabled
? `$(radio-tower) ${projectName}: Auto ✅`
: `$(radio-tower) ${projectName}: Manual 🔒`;
const mode = enabled ? '자동 승인 ON 🟢' : '수동 승인 OFF 🔴';
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): ${mode}`);
const statusPath = path.join(bridgePath, 'commands', `auto-status-${Date.now()}.json`);
fs.writeFileSync(statusPath, JSON.stringify({
id: `auto-status-${Date.now()}`,
project_name: projectName,
text: `[SYSTEM] Auto-approve: ${enabled ? 'ON' : 'OFF'}`,
timestamp: Date.now() / 1000,
consumed: true,
auto_approve: enabled,
}, null, 2), 'utf-8');
} catch (err) {
console.error('Gravity Bridge: failed to toggle auto-approve', err);
}
}
async function simulateApproval() {
try {
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
} catch {
try {
await vscode.commands.executeCommand('type', { text: '\n' });
} catch {
await vscode.commands.executeCommand('workbench.action.terminal.focus');
}
}
}
async function simulateRejection() {
try {
await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
} catch {
try {
await vscode.commands.executeCommand('cancelSelection');
} catch {
console.log('Gravity Bridge: rejection sent but no active dialog found');
}
}
}
/**
* Manual approve/reject from command palette.
*/
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.
* Includes project_name for correct channel routing.
*/
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,
project_name: projectName, // ★ Project routing
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 [${projectName}]: pending approval — ${requestId}`);
return requestId;
}
/**
* Register a conversation → project mapping in bridge/register/.
* The bot reads these files to route brain events to the correct channel.
*/
function registerConversation(conversationId: string) {
const registerDir = path.join(bridgePath, 'register');
if (!fs.existsSync(registerDir)) {
fs.mkdirSync(registerDir, { recursive: true });
}
const filePath = path.join(registerDir, `${conversationId}.json`);
const data = {
conversation_id: conversationId,
project_name: projectName,
timestamp: Date.now() / 1000,
};
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Gravity Bridge [${projectName}]: registered ${conversationId.substring(0, 8)}`);
}
/**
* Read the title (first # heading) from a conversation's task.md or implementation_plan.md.
*/
function getConversationTitle(convDir: string): string {
for (const fname of ['task.md', 'implementation_plan.md']) {
const fpath = path.join(convDir, fname);
if (fs.existsSync(fpath)) {
try {
const lines = fs.readFileSync(fpath, 'utf-8').split('\n').slice(0, 5);
for (const line of lines) {
const match = line.match(/^#\s+(.+)/);
if (match) { return match[1].trim().substring(0, 50); }
}
} catch { /* ignore */ }
}
}
return '';
}
/**
* Manual connect: scan brain/ for recent conversations and let user pick.
* Shows task.md titles for readability. Offers auto-connect for new projects.
*/
async function connectSession() {
const brainPath = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
if (!fs.existsSync(brainPath)) {
vscode.window.showErrorMessage('Brain 디렉토리를 찾을 수 없습니다.');
return;
}
// Get conversation dirs sorted by modification time (newest first)
const dirs = fs.readdirSync(brainPath)
.filter(d => {
const fullPath = path.join(brainPath, d);
return fs.statSync(fullPath).isDirectory() && d.includes('-');
})
.map(d => {
const fullPath = path.join(brainPath, d);
return {
name: d,
mtime: fs.statSync(fullPath).mtimeMs,
title: getConversationTitle(fullPath),
};
})
.sort((a, b) => b.mtime - a.mtime)
.slice(0, 10);
// Build QuickPick items
const items: (vscode.QuickPickItem & { convId?: string })[] = [];
// Always offer auto-connect option first
items.push({
label: '$(sync) 새 대화 자동 연결',
description: '다음에 시작하는 대화가 자동으로 이 프로젝트에 연결됩니다',
detail: `프로젝트: ${projectName}`,
});
// Add conversation items with titles
for (const d of dirs) {
const titleLabel = d.title || '(제목 없음)';
const timeStr = new Date(d.mtime).toLocaleString();
items.push({
label: `$(comment-discussion) ${titleLabel}`,
description: d.name.substring(0, 8),
detail: `${d.name} · ${timeStr}`,
convId: d.name,
} as any);
}
const selected = await vscode.window.showQuickPick(items, {
placeHolder: `프로젝트 "${projectName}"에 연결할 세션을 선택하세요`,
});
if (!selected) { return; }
if (!('convId' in selected) || !selected.convId) {
// Auto-connect mode
vscode.window.showInformationMessage(
`🔄 ${projectName}: 다음 대화가 자동으로 연결됩니다`
);
return;
}
registerConversation(selected.convId);
vscode.window.showInformationMessage(
`${selected.description}${projectName} 연결됨`
);
}
/**
* Auto-watch brain/ for new conversation directories → auto-register.
*/
function watchBrainForNewSessions() {
const brainPath = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
if (!fs.existsSync(brainPath)) { return; }
// Track known dirs
const knownDirs = new Set(
fs.readdirSync(brainPath).filter(d =>
fs.statSync(path.join(brainPath, d)).isDirectory()
)
);
try {
fs.watch(brainPath, { persistent: false }, (eventType, filename) => {
if (!filename || !filename.includes('-')) { return; }
const fullPath = path.join(brainPath, filename);
// Check if it's a new directory
if (!knownDirs.has(filename) && fs.existsSync(fullPath) &&
fs.statSync(fullPath).isDirectory()) {
knownDirs.add(filename);
registerConversation(filename);
console.log(`Gravity Bridge [${projectName}]: auto-registered new session ${filename.substring(0, 8)}`);
}
});
console.log(`Gravity Bridge [${projectName}]: watching brain/ for new sessions`);
} catch (err) {
console.error('Gravity Bridge: failed to watch brain dir', err);
}
}
export function deactivate() {
stopBridge();
}