1052 lines
42 KiB
TypeScript
1052 lines
42 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)),
|
|
);
|
|
|
|
// === LS ConnectRPC Bridge: Relay AI responses to Discord ===
|
|
let lsPort: number | null = null;
|
|
let lsCsrf: string = '';
|
|
let lsPid: number | null = null;
|
|
let lsUseTls: boolean = false; // track detected protocol
|
|
let lastStepIndex: Record<string, number> = {}; // cascadeId → last known step index
|
|
|
|
async function discoverLS(): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
// Phase 1: Find LS process → PID + CSRF token
|
|
cp.exec(
|
|
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object {$_.Name -eq \'language_server_exe.exe\' -or ($_.CommandLine -and $_.CommandLine -like \'*language_server*\' -and $_.CommandLine -notlike \'*powershell*\')} | Select-Object ProcessId, CommandLine | ConvertTo-Json"',
|
|
{ maxBuffer: 2 * 1024 * 1024 },
|
|
(err, stdout) => {
|
|
if (err || !stdout.trim()) {
|
|
console.log(`Gravity Bridge: [LS] process not found`);
|
|
resolve(false);
|
|
return;
|
|
}
|
|
try {
|
|
let procs = JSON.parse(stdout.trim());
|
|
if (!Array.isArray(procs)) { procs = [procs]; }
|
|
// Find a process with csrf_token in command line
|
|
for (const proc of procs) {
|
|
const cmd = proc.CommandLine || '';
|
|
const csrfM = cmd.match(/--csrf_token[= ]([^\s"]+)/);
|
|
if (csrfM) {
|
|
lsPid = proc.ProcessId;
|
|
lsCsrf = csrfM[1];
|
|
console.log(`Gravity Bridge: [LS] PID=${lsPid}, CSRF=${lsCsrf.substring(0, 12)}...`);
|
|
break;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log(`Gravity Bridge: [LS] parse error: ${e}`);
|
|
}
|
|
|
|
if (!lsPid || !lsCsrf) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
// Phase 2: netstat → find LS listening ports
|
|
cp.exec(
|
|
`netstat -ano | findstr "LISTENING" | findstr " ${lsPid}"`,
|
|
{ maxBuffer: 512 * 1024 },
|
|
async (err2, stdout2) => {
|
|
if (err2 || !stdout2.trim()) {
|
|
console.log(`Gravity Bridge: [LS] no listening ports found for PID ${lsPid}`);
|
|
resolve(false);
|
|
return;
|
|
}
|
|
// Parse ports
|
|
const ports: number[] = [];
|
|
for (const line of stdout2.split('\n')) {
|
|
const m = line.match(/:(\d+)\s+.*LISTENING/);
|
|
if (m) { ports.push(parseInt(m[1])); }
|
|
}
|
|
const uniquePorts = [...new Set(ports)].sort((a, b) => a - b);
|
|
console.log(`Gravity Bridge: [LS] ports for PID ${lsPid}: ${uniquePorts.join(', ')}`);
|
|
|
|
// Try ConnectRPC probe on each port (HTTP first, then HTTPS)
|
|
for (const port of uniquePorts) {
|
|
const ok = await probeLSPort(port);
|
|
if (ok) {
|
|
lsPort = port;
|
|
console.log(`Gravity Bridge: [LS] ✅ ConnectRPC active on port ${port}`);
|
|
resolve(true);
|
|
return;
|
|
}
|
|
}
|
|
console.log(`Gravity Bridge: [LS] no ConnectRPC port responded`);
|
|
resolve(false);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function probeLSPort(port: number): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const http = require('http');
|
|
const https = require('https');
|
|
|
|
// Try HTTP first (extension_server uses HTTP)
|
|
const tryProto = (proto: any, useTls: boolean) => {
|
|
const req = proto.request({
|
|
hostname: '127.0.0.1',
|
|
port,
|
|
path: '/exa.language_server_pb.LanguageServerService/Heartbeat',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-codeium-csrf-token': lsCsrf,
|
|
},
|
|
rejectUnauthorized: false,
|
|
timeout: 2000,
|
|
}, (res: any) => {
|
|
let data = '';
|
|
res.on('data', (chunk: any) => { data += chunk; });
|
|
res.on('end', () => {
|
|
console.log(`Gravity Bridge: [LS] port ${port} (${useTls ? 'https' : 'http'}) status=${res.statusCode} body=${data.substring(0, 200)}`);
|
|
// If HTTP got "HTTPS server" response, retry with HTTPS
|
|
if (!useTls && data.includes('HTTPS server')) {
|
|
tryProto(https, true);
|
|
return;
|
|
}
|
|
if (res.statusCode !== 404) {
|
|
lsUseTls = useTls; // remember which protocol worked
|
|
}
|
|
resolve(res.statusCode !== 404);
|
|
});
|
|
});
|
|
req.on('error', () => {
|
|
if (!useTls) {
|
|
// Try HTTPS
|
|
tryProto(https, true);
|
|
} else {
|
|
resolve(false);
|
|
}
|
|
});
|
|
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
req.write('{}');
|
|
req.end();
|
|
};
|
|
tryProto(http, false);
|
|
});
|
|
}
|
|
|
|
async function lsRPC(method: string, payload: any = {}): Promise<any> {
|
|
if (!lsPort || !lsCsrf) { return null; }
|
|
return new Promise((resolve) => {
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const proto = lsUseTls ? https : http; // use detected protocol
|
|
const body = JSON.stringify(payload);
|
|
const req = proto.request({
|
|
hostname: '127.0.0.1',
|
|
port: lsPort,
|
|
path: `/exa.language_server_pb.LanguageServerService/${method}`,
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-codeium-csrf-token': lsCsrf,
|
|
'Content-Length': Buffer.byteLength(body),
|
|
},
|
|
rejectUnauthorized: false,
|
|
timeout: 5000,
|
|
}, (res: any) => {
|
|
let data = '';
|
|
res.on('data', (chunk: any) => { data += chunk; });
|
|
res.on('end', () => {
|
|
try { resolve(JSON.parse(data)); } catch { resolve(data); }
|
|
});
|
|
});
|
|
req.on('error', (e: any) => {
|
|
console.log(`Gravity Bridge: [LS RPC] ${method} error: ${e.message}`);
|
|
resolve(null);
|
|
});
|
|
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
let pollFailCount = 0;
|
|
let pollCount = 0;
|
|
|
|
async function pollConversations() {
|
|
if (!lsPort) { return; }
|
|
if (pollFailCount > 10) { return; } // stop after repeated failures
|
|
pollCount++;
|
|
try {
|
|
// Use getDiagnostics to get cascade-level conversation IDs
|
|
const diag: any = await vscode.commands.executeCommand('antigravity.getDiagnostics');
|
|
if (!diag) { return; }
|
|
|
|
const parsed = typeof diag === 'string' ? JSON.parse(diag) : diag;
|
|
const trajectories = parsed.recentTrajectories || [];
|
|
if (!Array.isArray(trajectories) || trajectories.length === 0) { return; }
|
|
|
|
const isFirstPoll = Object.keys(lastStepIndex).length === 0;
|
|
if (isFirstPoll) {
|
|
console.log(`Gravity Bridge: [LS] ${trajectories.length} trajectories from getDiagnostics`);
|
|
}
|
|
|
|
// Periodic debug every ~1 min (12 * 5s)
|
|
if (pollCount % 12 === 0) {
|
|
const summary = trajectories.map((t: any) => {
|
|
const id = (t.googleAgentId || '').substring(0, 8);
|
|
return `${id}:s${t.lastStepIndex ?? '?'}`;
|
|
}).join(', ');
|
|
console.log(`Gravity Bridge: [LS] poll#${pollCount} — ${trajectories.length} trajs: [${summary}]`);
|
|
}
|
|
|
|
// Check ALL trajectories for step count changes
|
|
for (const traj of trajectories) {
|
|
const agentId = traj.googleAgentId || '';
|
|
const trajId = traj.trajectoryId || '';
|
|
const stepIdx = traj.lastStepIndex ?? 0;
|
|
const summary = traj.summary || '';
|
|
|
|
if (!agentId && !trajId) { continue; }
|
|
|
|
const key = agentId || trajId;
|
|
const prev = lastStepIndex[key];
|
|
|
|
if (prev === undefined) {
|
|
// First time seeing this trajectory — initialize
|
|
lastStepIndex[key] = stepIdx;
|
|
if (isFirstPoll) {
|
|
console.log(`Gravity Bridge: [LS] init ${key.substring(0, 8)} at step ${stepIdx} "${summary.substring(0, 30)}"`);
|
|
} else if (stepIdx > 0) {
|
|
// New conversation with AI response! Try to get actual text
|
|
console.log(`Gravity Bridge: [LS] NEW conversation ${key.substring(0, 8)} at step ${stepIdx} "${summary.substring(0, 40)}"`);
|
|
|
|
// Try multiple methods to get actual AI response text
|
|
const rpcAttempts = [
|
|
{ m: 'LoadTrajectory', p: { trajectoryId: trajId } },
|
|
{ m: 'LoadTrajectory', p: { googleAgentId: agentId } },
|
|
{ m: 'GetCascadeTrajectory', p: { googleAgentId: agentId } },
|
|
{ m: 'GetCascadeTrajectorySteps', p: { googleAgentId: agentId } },
|
|
];
|
|
|
|
let gotText = false;
|
|
for (const a of rpcAttempts) {
|
|
const res = await lsRPC(a.m, a.p);
|
|
if (res && !res.code) {
|
|
console.log(`Gravity Bridge: [LS] ✅ ${a.m}(${Object.keys(a.p)[0]}) → keys: ${Object.keys(res).join(', ')}`);
|
|
console.log(`Gravity Bridge: [LS] sample: ${JSON.stringify(res).substring(0, 1000)}`);
|
|
// Try to extract text
|
|
const steps = res.steps || res.cortexSteps || [];
|
|
if (Array.isArray(steps) && steps.length > 0) {
|
|
extractAndRelaySteps(steps.slice(-3)); // last 3 steps
|
|
gotText = true;
|
|
}
|
|
break;
|
|
} else {
|
|
const err = res?.message || res?.code || 'empty';
|
|
console.log(`Gravity Bridge: [LS] ❌ ${a.m}(${Object.keys(a.p)[0]}): ${typeof err === 'string' ? err.substring(0, 60) : err}`);
|
|
}
|
|
}
|
|
|
|
// Fallback: relay summary
|
|
if (!gotText && summary) {
|
|
writeChatSnapshot(`**${summary}**\n\n(새 대화, step ${stepIdx})`);
|
|
lastStepIndex[key + '_summary'] = summary;
|
|
console.log(`Gravity Bridge: [LS] → summary fallback to Discord`);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (stepIdx > prev) {
|
|
// New steps detected!
|
|
console.log(`Gravity Bridge: [LS] ${key.substring(0, 8)} steps: ${prev} → ${stepIdx} "${summary.substring(0, 40)}"`);
|
|
|
|
// Try RPC to get full step text
|
|
const attempts = [
|
|
{ method: 'GetCascadeTrajectorySteps', params: { googleAgentId: agentId, trajectoryId: trajId, startStepIndex: prev } },
|
|
{ method: 'GetCascadeTrajectory', params: { googleAgentId: agentId, trajectoryId: trajId } },
|
|
];
|
|
|
|
let stepsResult: any = null;
|
|
for (const attempt of attempts) {
|
|
const res = await lsRPC(attempt.method, attempt.params);
|
|
if (res && !res.code && !res.message?.includes('not found')) {
|
|
stepsResult = res;
|
|
console.log(`Gravity Bridge: [LS] ✅ ${attempt.method} worked!`);
|
|
console.log(`Gravity Bridge: [LS] keys: ${Object.keys(res).join(', ')}`);
|
|
console.log(`Gravity Bridge: [LS] sample: ${JSON.stringify(res).substring(0, 800)}`);
|
|
pollFailCount = 0;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (stepsResult) {
|
|
const steps = stepsResult.steps || stepsResult.cortexSteps || stepsResult.cortex_steps || [];
|
|
if (Array.isArray(steps) && steps.length > 0) {
|
|
const newSteps = steps.slice(-(stepIdx - prev));
|
|
extractAndRelaySteps(newSteps);
|
|
}
|
|
} else {
|
|
// Fallback: relay summary text
|
|
const summaryKey = key + '_summary';
|
|
if (summary && summary !== lastStepIndex[summaryKey]) {
|
|
writeChatSnapshot(`[AI 응답 감지]\n\n**${summary}**\n\n(step ${prev} → ${stepIdx})`);
|
|
lastStepIndex[summaryKey] = summary;
|
|
console.log(`Gravity Bridge: [LS] relayed summary: "${summary.substring(0, 80)}"`);
|
|
}
|
|
pollFailCount++;
|
|
}
|
|
|
|
lastStepIndex[key] = stepIdx;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log(`Gravity Bridge: [LS poll] error: ${e}`);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractAndRelaySteps(steps: any[]) {
|
|
const messages: string[] = [];
|
|
|
|
for (const step of steps) {
|
|
// Try every possible way to find AI text in a step
|
|
const type = step.type || step.stepType || step.step_type || '';
|
|
const content = step.content || step.summary || step.text || step.message || '';
|
|
|
|
// PlannerResponse = AI's text output to user
|
|
if (content && (
|
|
type.includes('Response') || type.includes('response') ||
|
|
type.includes('Message') || type.includes('message') ||
|
|
type.includes('Notify') || type.includes('notify')
|
|
)) {
|
|
messages.push(content);
|
|
continue;
|
|
}
|
|
|
|
// Check nested data/content
|
|
if (step.data?.content && typeof step.data.content === 'string') {
|
|
messages.push(step.data.content);
|
|
continue;
|
|
}
|
|
|
|
// Check role-based (assistant messages)
|
|
if ((step.role === 'assistant' || step.role === 'model') && content) {
|
|
messages.push(content);
|
|
continue;
|
|
}
|
|
|
|
// Catch-all: any string content longer than 20 chars that's not a tool call
|
|
if (typeof content === 'string' && content.length > 20 &&
|
|
!type.includes('Tool') && !type.includes('tool') &&
|
|
!type.includes('Command') && !type.includes('command')) {
|
|
messages.push(content);
|
|
}
|
|
}
|
|
|
|
if (messages.length > 0) {
|
|
const combined = messages.join('\n\n---\n\n');
|
|
writeChatSnapshot(combined);
|
|
console.log(`Gravity Bridge: [LS] relayed ${messages.length} response(s) (${combined.length} chars) to Discord`);
|
|
}
|
|
}
|
|
|
|
|
|
// Start LS bridge after a delay
|
|
setTimeout(async () => {
|
|
const found = await discoverLS();
|
|
if (found) {
|
|
console.log(`Gravity Bridge: [LS] bridge active — polling every 5s`);
|
|
// Initialize step counts
|
|
await pollConversations();
|
|
// Start polling loop
|
|
setInterval(pollConversations, 5000);
|
|
} else {
|
|
console.log(`Gravity Bridge: [LS] bridge NOT available — AI responses won't relay`);
|
|
}
|
|
}, 8000);
|
|
|
|
|
|
// Chat document change listener — captures AI text responses
|
|
context.subscriptions.push(
|
|
vscode.workspace.onDidChangeTextDocument((event) => {
|
|
handleChatDocumentChange(event);
|
|
})
|
|
);
|
|
|
|
// Register @bridge Chat Participant for history relay
|
|
try {
|
|
const participant = vscode.chat.createChatParticipant(
|
|
'gravity-bridge.gravity',
|
|
bridgeChatHandler
|
|
);
|
|
participant.iconPath = new vscode.ThemeIcon('radio-tower');
|
|
context.subscriptions.push(participant);
|
|
console.log('Gravity Bridge: @bridge chat participant registered');
|
|
} catch (err) {
|
|
console.log('Gravity Bridge: chat participant API not available (OK)');
|
|
}
|
|
|
|
// 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: !stop — cancel AI work
|
|
if (text === '!stop') {
|
|
try {
|
|
await vscode.commands.executeCommand('workbench.action.chat.stop');
|
|
vscode.window.showWarningMessage(`⏹️ [${projectName}] AI 작업 중지됨`);
|
|
} catch {
|
|
vscode.window.showErrorMessage('AI 중지 명령 실행 실패');
|
|
}
|
|
command.consumed = true;
|
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
|
return;
|
|
}
|
|
|
|
// 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: send directly to Antigravity agent panel
|
|
try {
|
|
await vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', command.text);
|
|
console.log(`Gravity Bridge: ✅ sent via sendPromptToAgentPanel`);
|
|
} catch (e1) {
|
|
console.log(`Gravity Bridge: sendPromptToAgentPanel failed: ${e1}`);
|
|
// Fallback: try sendChatActionMessage
|
|
try {
|
|
await vscode.commands.executeCommand('antigravity.sendChatActionMessage', command.text);
|
|
console.log(`Gravity Bridge: ✅ sent via sendChatActionMessage`);
|
|
} catch (e2) {
|
|
console.log(`Gravity Bridge: sendChatActionMessage failed: ${e2}`);
|
|
// Last resort: focus panel + clipboard paste
|
|
try {
|
|
await vscode.commands.executeCommand('antigravity.agentPanel.focus');
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
const oldClip = await vscode.env.clipboard.readText();
|
|
await vscode.env.clipboard.writeText(command.text);
|
|
await vscode.commands.executeCommand('editor.action.clipboardPasteAction');
|
|
await vscode.env.clipboard.writeText(oldClip);
|
|
console.log('Gravity Bridge: clipboard paste fallback');
|
|
} catch (e3) {
|
|
console.error('Gravity Bridge: all methods failed', e3);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Always mark as consumed
|
|
command.consumed = true;
|
|
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Monitor text document changes for chat panel content.
|
|
* VS Code chat documents have special URI schemes (vscode-chat-response, etc.).
|
|
* We capture significant changes and relay to Discord.
|
|
*/
|
|
let lastChatContent = '';
|
|
let chatDebounceTimer: NodeJS.Timeout | null = null;
|
|
|
|
function handleChatDocumentChange(event: vscode.TextDocumentChangeEvent) {
|
|
const doc = event.document;
|
|
const scheme = doc.uri.scheme;
|
|
|
|
// Log ALL schemes to discover chat-related ones (debug mode)
|
|
if (scheme !== 'file' && scheme !== 'git' && scheme !== 'output' &&
|
|
scheme !== 'vscode-userdata' && scheme !== 'untitled') {
|
|
console.log(`Gravity Bridge [${projectName}]: doc change scheme="${scheme}" uri="${doc.uri.toString().substring(0, 80)}"`);
|
|
}
|
|
|
|
// Capture chat-related documents
|
|
// Known chat schemes: vscode-chat-response, vscode-copilot-chat, etc.
|
|
const isChatDoc = scheme.includes('chat') || scheme.includes('copilot') ||
|
|
scheme.includes('notebook') || doc.uri.path.includes('chat');
|
|
|
|
if (!isChatDoc) { return; }
|
|
|
|
const content = doc.getText();
|
|
if (!content || content === lastChatContent) { return; }
|
|
|
|
// Debounce: wait 2s for content to stabilize (AI streams text)
|
|
if (chatDebounceTimer) { clearTimeout(chatDebounceTimer); }
|
|
chatDebounceTimer = setTimeout(() => {
|
|
const finalContent = doc.getText();
|
|
if (finalContent && finalContent !== lastChatContent && finalContent.length > 20) {
|
|
lastChatContent = finalContent;
|
|
writeChatSnapshot(finalContent);
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
/**
|
|
* Write a chat content snapshot to bridge for the bot to relay.
|
|
*/
|
|
function writeChatSnapshot(content: string) {
|
|
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
|
|
if (!fs.existsSync(snapshotDir)) {
|
|
fs.mkdirSync(snapshotDir, { recursive: true });
|
|
}
|
|
|
|
const id = `chat-${Date.now()}`;
|
|
const filePath = path.join(snapshotDir, `${id}.json`);
|
|
const data = {
|
|
id,
|
|
project_name: projectName,
|
|
content: content.substring(0, 4000), // Limit size
|
|
timestamp: Date.now() / 1000,
|
|
};
|
|
|
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
console.log(`Gravity Bridge [${projectName}]: chat snapshot written (${content.length} chars)`);
|
|
}
|
|
|
|
/**
|
|
* @bridge Chat Participant handler.
|
|
* Reads conversation history and sends to Discord via bridge.
|
|
*/
|
|
const bridgeChatHandler: vscode.ChatRequestHandler = async (
|
|
request: vscode.ChatRequest,
|
|
context: vscode.ChatContext,
|
|
stream: vscode.ChatResponseStream,
|
|
token: vscode.CancellationToken
|
|
) => {
|
|
const command = request.prompt.trim().toLowerCase();
|
|
|
|
if (command === 'stop' || command === '중지') {
|
|
// Cancel current AI work
|
|
try {
|
|
await vscode.commands.executeCommand('workbench.action.chat.stop');
|
|
stream.markdown('⏹️ AI 작업 중지 요청을 보냈습니다.');
|
|
} catch {
|
|
stream.markdown('⚠️ 중지 명령을 실행할 수 없습니다.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Collect conversation history
|
|
const historyLines: string[] = [];
|
|
historyLines.push(`# 대화 히스토리 (${projectName})\n`);
|
|
|
|
for (const entry of context.history) {
|
|
if (entry instanceof vscode.ChatRequestTurn) {
|
|
historyLines.push(`## 👤 사용자\n${entry.prompt}\n`);
|
|
} else if (entry instanceof vscode.ChatResponseTurn) {
|
|
let responseText = '';
|
|
for (const part of entry.response) {
|
|
if (part instanceof vscode.ChatResponseMarkdownPart) {
|
|
responseText += part.value.value;
|
|
}
|
|
}
|
|
if (responseText) {
|
|
historyLines.push(`## 🤖 AI\n${responseText}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (historyLines.length <= 1) {
|
|
stream.markdown('대화 히스토리가 비어있습니다. AI와 대화를 먼저 진행한 후 `@bridge`를 호출하세요.');
|
|
return;
|
|
}
|
|
|
|
// Write to bridge for Discord relay
|
|
const fullHistory = historyLines.join('\n');
|
|
const cmdId = `bridge-history-${Date.now()}`;
|
|
const cmdPath = path.join(bridgePath, 'commands', `${cmdId}.json`);
|
|
|
|
const data = {
|
|
id: cmdId,
|
|
project_name: projectName,
|
|
text: `[HISTORY]\n${fullHistory}`,
|
|
timestamp: Date.now() / 1000,
|
|
consumed: false,
|
|
};
|
|
|
|
fs.writeFileSync(cmdPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
|
|
stream.markdown(`✅ 대화 히스토리 (${context.history.length}개 턴)를 Discord에 전송했습니다.`);
|
|
console.log(`Gravity Bridge [${projectName}]: sent ${context.history.length} turns to Discord`);
|
|
};
|
|
|
|
export function deactivate() {
|
|
stopBridge();
|
|
}
|