From 2c56fc7607be16b590eeaa8d3f2c6f6574209bf6 Mon Sep 17 00:00:00 2001 From: CD Date: Sat, 7 Mar 2026 14:07:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20multi-project=20routing=20=E2=80=94=20p?= =?UTF-8?q?roject=5Fname=20in=20bridge,=20per-project=20channels,=20extens?= =?UTF-8?q?ion=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 176 ++++++++++++++++--------------------- bridge.py | 11 ++- extension/package.json | 5 ++ extension/src/extension.ts | 127 +++++++++++++------------- 4 files changed, 153 insertions(+), 166 deletions(-) diff --git a/bot.py b/bot.py index 9988fec..f36155a 100644 --- a/bot.py +++ b/bot.py @@ -1,9 +1,10 @@ """Discord bot — relays Antigravity brain events to Discord channels. -Single project channel design: -- ONE channel: AG-{PROJECT_NAME} (e.g. ag-gravity_control) -- ALL conversations route to this single channel -- Uses guild.fetch_channels() API, NOT cached text_channels +Multi-project channel architecture: +- One channel per project: AG-{project_name} (e.g. ag-gravity_control, ag-deriva) +- Each conversation maps to a project via conv_to_project dict +- Extension registers projects via bridge/pending/ files +- Commands include project_name for routing to correct IDE window """ import asyncio @@ -81,9 +82,10 @@ class ApprovalView(discord.ui.View): class GravityBot(commands.Bot): """Discord bot for Antigravity session monitoring. - Single-channel architecture: - - ONE channel per project (ag-gravity_control) - - self.project_channel is the singleton — trivially prevents duplication + Multi-project architecture: + - project_channels: project_name → TextChannel (ag-gravity_control, ag-deriva, etc.) + - conv_to_project: conversation_id → project_name (learned from pending approvals) + - channel_to_project: channel_id → project_name (for Discord→IDE routing) """ def __init__(self, event_queue: asyncio.Queue): @@ -93,19 +95,21 @@ class GravityBot(commands.Bot): super().__init__(command_prefix="!", intents=intents) self.event_queue = event_queue - self.project_channel: discord.TextChannel | None = None # THE channel + self.project_channels: dict[str, discord.TextChannel] = {} # project → channel + self.conv_to_project: dict[str, str] = {} # conv_id → project + self.channel_to_project: dict[int, str] = {} # channel.id → project self.session_status_messages: dict[str, int] = {} # conv_id → msg_id self._sent_approval_ids: set[str] = set() self._ready_event = asyncio.Event() - self._channel_lock = asyncio.Lock() # Prevents double-create race + self._channel_lock = asyncio.Lock() self.bridge = BridgeProtocol() self.session_category: discord.CategoryChannel | None = None self.guild: discord.Guild | None = None - @property - def _channel_name(self) -> str: - """The ONE channel name: ag-gravity_control (lowercase).""" - return f"{Config.CHANNEL_PREFIX}-{Config.PROJECT_NAME}".lower() + @staticmethod + def _make_channel_name(project_name: str) -> str: + """ag-gravity_control, ag-deriva, etc.""" + return f"{Config.CHANNEL_PREFIX}-{project_name}".lower() async def setup_hook(self): self.loop.create_task(self._process_events()) @@ -133,95 +137,69 @@ class GravityBot(commands.Bot): logger.error("No permission to create category!") return - # Find the project channel + cleanup duplicates - await self._init_project_channel() + # Discover existing project channels + await self._discover_channels() # Open the gate self._ready_event.set() logger.info("Ready gate opened — event processing enabled") - # ─── Channel Init (ONE channel, guild.fetch_channels API) ──────── + # ─── Channel Management ────────────────────────────────────────── - async def _init_project_channel(self): - """Find or create the single project channel. Delete any duplicates. - - Uses guild.fetch_channels() — the REAL Discord API, not the cache. - """ - target_name = self._channel_name - - # Fetch ALL channels from Discord API (not cache) + async def _discover_channels(self): + """Find existing project channels via Discord API (not cache).""" all_channels = await self.guild.fetch_channels() + prefix = Config.CHANNEL_PREFIX.lower() + "-" - matches: list[discord.TextChannel] = [] for ch in all_channels: if (isinstance(ch, discord.TextChannel) and ch.category_id == self.session_category.id - and ch.name == target_name): - matches.append(ch) + and ch.name.startswith(prefix)): + project = ch.name[len(prefix):] + self.project_channels[project] = ch + self.channel_to_project[ch.id] = project + logger.info(f"Found channel: #{ch.name} → project={project}") - if matches: - # Keep the first, delete the rest - self.project_channel = matches[0] - logger.info(f"Found project channel: #{target_name} (id={self.project_channel.id})") + logger.info(f"Discovered {len(self.project_channels)} project channels") - for dup in matches[1:]: - try: - await dup.delete(reason="Duplicate project channel cleanup") - logger.info(f"Deleted duplicate: #{dup.name} (id={dup.id})") - except (discord.Forbidden, discord.HTTPException) as e: - logger.warning(f"Failed to delete duplicate: {e}") - - # Also delete any OLD-style channels with different names - for ch in all_channels: - if (isinstance(ch, discord.TextChannel) - and ch.category_id == self.session_category.id - and ch.name != target_name - and ch.topic and "Antigravity Session:" in ch.topic): - try: - await ch.delete(reason="Old-style channel cleanup") - logger.info(f"Deleted old channel: #{ch.name}") - except (discord.Forbidden, discord.HTTPException) as e: - logger.warning(f"Failed to delete old channel: {e}") - else: - logger.info(f"No existing project channel found. Will create on first event.") - - async def _get_project_channel(self) -> discord.TextChannel: - """Get the project channel. Create if it doesn't exist yet. - - Uses asyncio.Lock to prevent race between event processor - and approval scanner both creating channels simultaneously. - """ - if self.project_channel: - return self.project_channel + async def _get_channel(self, project_name: str) -> discord.TextChannel: + """Get or create a channel for a project. Lock-protected.""" + if project_name in self.project_channels: + return self.project_channels[project_name] async with self._channel_lock: - # Double-check after acquiring lock - if self.project_channel: - return self.project_channel + # Double-check + if project_name in self.project_channels: + return self.project_channels[project_name] - # Create the channel + channel_name = self._make_channel_name(project_name) try: - self.project_channel = await self.guild.create_text_channel( - name=self._channel_name, + ch = await self.guild.create_text_channel( + name=channel_name, category=self.session_category, - topic=f"Gravity Control — Antigravity Bridge", + topic=f"Antigravity Bridge — {project_name}", ) - logger.info(f"Created project channel: #{self._channel_name}") + self.project_channels[project_name] = ch + self.channel_to_project[ch.id] = project_name + logger.info(f"Created channel: #{channel_name}") embed = discord.Embed( - title=f"🚀 {Config.PROJECT_NAME}", - description=( - f"Antigravity Bridge 연결됨\n" - f"모든 세션 이벤트가 이 채널로 전달됩니다." - ), + title=f"🚀 {project_name}", + description=f"Antigravity Bridge 연결됨", color=discord.Color.blue(), timestamp=datetime.now(timezone.utc), ) - await self.project_channel.send(embed=embed) + await ch.send(embed=embed) + return ch except discord.errors.Forbidden: - logger.error(f"No permission to create channel: {self._channel_name}") + logger.error(f"No permission to create channel: {channel_name}") + return None - return self.project_channel + def _resolve_project(self, conversation_id: str) -> str: + """Get project name for a conversation. Falls back to default.""" + return self.conv_to_project.get( + conversation_id, Config.PROJECT_NAME + ) # ─── Event Processing ───────────────────────────────────────────── @@ -243,14 +221,13 @@ class GravityBot(commands.Bot): logger.error(f"Error processing event: {e}", exc_info=True) async def _handle_event(self, event: BrainEvent): - """Route brain events to the single project channel.""" - if event.event_type == EventType.SESSION_START: - # Just ensure channel exists, no message needed - await self._get_project_channel() + """Route brain events to the correct project channel.""" + project = self._resolve_project(event.conversation_id) + channel = await self._get_channel(project) + if not channel: return - channel = await self._get_project_channel() - if not channel: + if event.event_type == EventType.SESSION_START: return try: @@ -259,15 +236,14 @@ class GravityBot(commands.Bot): else: await self._send_artifact_update(channel, event) except discord.NotFound: - self.project_channel = None # Channel was deleted, recreate next time - logger.warning("Project channel was deleted, will recreate") + self.project_channels.pop(project, None) + logger.warning(f"Channel deleted for project {project}, will recreate") # ─── Message Senders ───────────────────────────────────────────── async def _send_task_update( self, channel: discord.TextChannel, event: BrainEvent ): - """Send/edit task progress embed (ONE message per conv_id, always edited).""" progress = parse_task_progress(event.content) embed = discord.Embed( @@ -280,7 +256,6 @@ class GravityBot(commands.Bot): ) embed.set_footer(text=f"Session: {event.conversation_id[:8]}") - # Try to edit existing message for this conversation msg_id = self.session_status_messages.get(event.conversation_id) if msg_id: try: @@ -296,7 +271,6 @@ class GravityBot(commands.Bot): async def _send_artifact_update( self, channel: discord.TextChannel, event: BrainEvent ): - """Send artifact update as single compact embed (preview only).""" labels = { "implementation_plan.md": "📐 구현 계획", "walkthrough.md": "📝 작업 결과 요약", @@ -304,7 +278,6 @@ class GravityBot(commands.Bot): label = labels.get(event.file_name, f"📄 {event.file_name}") event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트" - # Preview: first 6 non-empty lines only lines = event.content.strip().splitlines() preview = "\n".join(l for l in lines[:6] if l.strip()) if len(lines) > 6: @@ -332,7 +305,12 @@ class GravityBot(commands.Bot): if req.discord_message_id != 0: continue - channel = await self._get_project_channel() + # Learn project mapping from pending approval + project = getattr(req, 'project_name', '') or Config.PROJECT_NAME + if req.conversation_id and req.conversation_id != '__global__': + self.conv_to_project[req.conversation_id] = project + + channel = await self._get_channel(project) if channel: self._sent_approval_ids.add(req.request_id) await self._send_approval_request(channel, req) @@ -360,11 +338,10 @@ class GravityBot(commands.Bot): view = ApprovalView(self.bridge, request) msg = await channel.send(embed=embed, view=view) - # Update pending file with discord message id pending_file = self.bridge.pending_dir / f"{request.request_id}.json" if pending_file.exists(): try: - data = json.loads(pending_file.read_text(encoding="utf-8")) + data = json.loads(pending_file.read_text(encoding="utf-8-sig")) data["discord_message_id"] = msg.id pending_file.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" @@ -372,16 +349,17 @@ class GravityBot(commands.Bot): except (json.JSONDecodeError, OSError): pass - logger.info(f"Sent approval request: {request.request_id[:8]}") + logger.info(f"Sent approval request: {request.request_id[:12]}") - # ─── Discord → Antigravity Text Relay ───────────────────────────── + # ─── Discord → IDE Text Relay ───────────────────────────────────── async def on_message(self, message: discord.Message): if message.author == self.user: return - # Only respond in the project channel - if not self.project_channel or message.channel.id != self.project_channel.id: + # Determine project from channel + project = self.channel_to_project.get(message.channel.id) + if not project: await self.process_commands(message) return @@ -389,13 +367,13 @@ class GravityBot(commands.Bot): # Special command: !auto on/off if text in ("!auto on", "!auto off"): - self.bridge.write_command("__global__", text) + self.bridge.write_command(project, text, project_name=project) enabled = text == "!auto on" emoji = "🟢" if enabled else "🔴" mode = "자동 승인" if enabled else "수동 승인" embed = discord.Embed( title=f"{emoji} {mode} 모드", - description=f"Antigravity IDE 설정이 변경됩니다.\n" + description=f"프로젝트: **{project}**\n" f"`chat.tools.autoApprove = {enabled}`\n" f"`chat.agent.autoApprove = {enabled}`", color=discord.Color.green() if enabled else discord.Color.red(), @@ -403,9 +381,9 @@ class GravityBot(commands.Bot): await message.channel.send(embed=embed) return - # General text relay (broadcast to most recent session or global) + # General text relay — routed by project if text: - self.bridge.write_command("__global__", text) + self.bridge.write_command(project, text, project_name=project) await message.add_reaction("📨") await self.process_commands(message) diff --git a/bridge.py b/bridge.py index 64da8d3..83408cf 100644 --- a/bridge.py +++ b/bridge.py @@ -43,6 +43,7 @@ class ApprovalRequest: timestamp: float status: str = "pending" discord_message_id: int = 0 + project_name: str = "" # Project routing key @dataclass @@ -72,11 +73,14 @@ class BridgeProtocol: def get_pending_requests(self) -> list[ApprovalRequest]: """Read all pending approval requests.""" requests = [] + fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()} for f in self.pending_dir.glob("*.json"): try: data = json.loads(f.read_text(encoding="utf-8-sig")) if data.get("status") == "pending": - requests.append(ApprovalRequest(**data)) + # Filter to known fields only + filtered = {k: v for k, v in data.items() if k in fields} + requests.append(ApprovalRequest(**filtered)) except (json.JSONDecodeError, TypeError, OSError) as e: logger.warning(f"Bad pending request {f.name}: {e}") return requests @@ -106,7 +110,7 @@ class BridgeProtocol: except (json.JSONDecodeError, OSError): pass - def write_command(self, conversation_id: str, text: str): + def write_command(self, conversation_id: str, text: str, *, project_name: str = ""): """Write a user text command for Antigravity to consume.""" cmd_id = f"{int(time.time() * 1000)}" filepath = self.commands_dir / f"{cmd_id}.json" @@ -114,6 +118,7 @@ class BridgeProtocol: data = { "id": cmd_id, "conversation_id": conversation_id, + "project_name": project_name, "text": text, "timestamp": time.time(), "consumed": False, @@ -123,5 +128,5 @@ class BridgeProtocol: json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) - logger.info(f"Command written: {cmd_id} for {conversation_id[:8]}") + logger.info(f"Command written: {cmd_id} → project={project_name}") return cmd_id diff --git a/extension/package.json b/extension/package.json index ba73fe6..6642ef0 100644 --- a/extension/package.json +++ b/extension/package.json @@ -49,6 +49,11 @@ "type": "string", "default": "", "description": "Bridge 디렉토리 경로 (기본: ~/.gemini/antigravity/bridge)" + }, + "gravityBridge.projectName": { + "type": "string", + "default": "", + "description": "프로젝트 이름 (기본: 워크스페이스 폴더명, 예: gravity_control)" } } } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index a888e5e..5c5142d 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -3,13 +3,10 @@ * * 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 + * 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'; @@ -18,15 +15,38 @@ 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(); +/** + * Detect project name from workspace. + * Priority: settings > workspace folder name > fallback + */ +function detectProjectName(): string { + const config = vscode.workspace.getConfiguration('gravityBridge'); + const configName = config.get('projectName'); + if (configName) { return configName; } + + // Use workspace folder name + const folders = vscode.workspace.workspaceFolders; + if (folders && folders.length > 0) { + const folderName = folders[0].name; + // Convert to snake_case: "antig_web" → "antig_web", "My Project" → "my_project" + return folderName.toLowerCase().replace(/[\s\-]+/g, '_'); + } + + return 'unknown_project'; +} + export function activate(context: vscode.ExtensionContext) { - console.log('Gravity Bridge: activating...'); + projectName = detectProjectName(); + console.log(`Gravity Bridge: activating for project "${projectName}"...`); // Determine bridge path const config = vscode.workspace.getConfiguration('gravityBridge'); @@ -45,8 +65,8 @@ export function activate(context: vscode.ExtensionContext) { // 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.text = `$(radio-tower) ${projectName}: Off`; + statusBar.tooltip = `Gravity Bridge — ${projectName}`; statusBar.show(); context.subscriptions.push(statusBar); @@ -64,18 +84,18 @@ export function activate(context: vscode.ExtensionContext) { function startBridge() { if (isActive) { - vscode.window.showInformationMessage('Gravity Bridge is already running'); + vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): already running`); return; } isActive = true; - statusBar.text = '$(radio-tower) Bridge: On'; - statusBar.tooltip = 'Gravity Bridge — Active'; + 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(); // Debounce: prevent double-processing + const processedFiles = new Set(); // Debounce try { watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => { if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) { @@ -92,7 +112,7 @@ function startBridge() { // Watch for commands (user text input from Discord) const commandsPath = path.join(bridgePath, 'commands'); try { - fs.watch(commandsPath, { persistent: false }, (eventType, filename) => { + commandsWatcher = fs.watch(commandsPath, { persistent: false }, (eventType, filename) => { if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) { processedFiles.add(filename); setTimeout(() => processedFiles.delete(filename), 2000); @@ -104,33 +124,30 @@ function startBridge() { console.error('Gravity Bridge: failed to watch commands dir', err); } - vscode.window.showInformationMessage('Gravity Bridge: Started'); - console.log(`Gravity Bridge: started, bridge path: ${bridgePath}`); + 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) Bridge: Off'; - statusBar.tooltip = 'Gravity Bridge — Click to start'; + statusBar.text = `$(radio-tower) ${projectName}: Off`; + statusBar.tooltip = `Gravity Bridge — ${projectName}`; statusBar.command = 'gravityBridge.start'; - if (watcher) { - watcher.close(); - watcher = null; - } + if (watcher) { watcher.close(); watcher = null; } + if (commandsWatcher) { commandsWatcher.close(); commandsWatcher = null; } - vscode.window.showInformationMessage('Gravity Bridge: Stopped'); + vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Stopped`); } /** * Handle a response from Discord (approve/reject). - * Reads the response JSON and simulates the appropriate action. + * Only processes responses — no project filtering needed since request_id is unique. */ 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; } @@ -140,11 +157,9 @@ async function handleResponse(filePath: string) { if (response.approved === undefined) { return; } - console.log(`Gravity Bridge: response received — approved=${response.approved}`); + console.log(`Gravity Bridge [${projectName}]: response — 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 { @@ -152,9 +167,7 @@ async function handleResponse(filePath: string) { 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); } @@ -162,7 +175,7 @@ async function handleResponse(filePath: string) { /** * Handle a text command from Discord. - * Supports special commands (!auto on/off) and general text relay. + * ONLY processes commands matching this project's name. */ async function handleCommand(filePath: string) { try { @@ -175,15 +188,21 @@ async function handleCommand(filePath: string) { 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: command received — "${text.substring(0, 50)}"`); + 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); - // Mark as consumed command.consumed = true; fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8'); return; @@ -202,7 +221,7 @@ async function handleCommand(filePath: string) { command.consumed = true; fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8'); - vscode.window.showInformationMessage(`📨 Discord input: ${command.text.substring(0, 50)}...`); + vscode.window.showInformationMessage(`📨 [${projectName}] Discord: ${command.text.substring(0, 50)}...`); } catch (err) { console.error('Gravity Bridge: error handling command', err); } @@ -215,31 +234,25 @@ async function toggleAutoApprove(enabled: boolean) { const config = vscode.workspace.getConfiguration(); try { - // Core auto-approve settings await config.update('chat.tools.autoApprove', enabled, vscode.ConfigurationTarget.Global); await config.update('chat.agent.autoApprove', enabled, vscode.ConfigurationTarget.Global); - // Terminal auto-execution if (enabled) { await config.update('chat.tools.terminal.enableAutoApprove', true, vscode.ConfigurationTarget.Global); } - - // File edits auto-accept await config.update('autoAcceptV2.autoAcceptFileEdits', enabled, vscode.ConfigurationTarget.Global); - // Update status bar statusBar.text = enabled - ? '$(radio-tower) Bridge: Auto ✅' - : '$(radio-tower) Bridge: Manual 🔒'; + ? `$(radio-tower) ${projectName}: Auto ✅` + : `$(radio-tower) ${projectName}: Manual 🔒`; const mode = enabled ? '자동 승인 ON 🟢' : '수동 승인 OFF 🔴'; - vscode.window.showInformationMessage(`Gravity Bridge: ${mode}`); - console.log(`Gravity Bridge: auto-approve set to ${enabled}`); + vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): ${mode}`); - // Write status back to bridge for bot to report 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, @@ -248,40 +261,28 @@ async function toggleAutoApprove(enabled: boolean) { } catch (err) { console.error('Gravity Bridge: failed to toggle auto-approve', err); - vscode.window.showErrorMessage(`Auto-approve toggle failed: ${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'); } } @@ -289,7 +290,6 @@ async function simulateRejection() { /** * Manual approve/reject from command palette. - * Writes a pending request for testing purposes. */ function handleManualAction(approved: boolean) { const requestId = `manual-${Date.now()}`; @@ -304,15 +304,13 @@ function handleManualAction(approved: boolean) { fs.writeFileSync(responsePath, JSON.stringify(response, null, 2), 'utf-8'); - if (approved) { - simulateApproval(); - } else { - simulateRejection(); - } + 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, @@ -325,6 +323,7 @@ export function writePendingApproval( const request = { request_id: requestId, conversation_id: conversationId, + project_name: projectName, // ★ Project routing command: command, description: description, timestamp: Date.now() / 1000, @@ -335,7 +334,7 @@ export function writePendingApproval( fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8'); sentPendingIds.add(requestId); - console.log(`Gravity Bridge: pending approval written — ${requestId}`); + console.log(`Gravity Bridge [${projectName}]: pending approval — ${requestId}`); return requestId; }