Compare commits

...

30 Commits

Author SHA1 Message Date
CD
67619e8950 docs: mid-session devlog + entry 002 (sendPromptToAgentPanel discovery) 2026-03-07 17:39:00 +09:00
CD
befa5d7c26 feat: start scanner loops, remove debug enum, enable chat response relay to Discord 2026-03-07 17:29:23 +09:00
CD
8d5e59c60f feat: use sendPromptToAgentPanel + add 171 command reference doc 2026-03-07 17:16:19 +09:00
CD
c85bb64c03 debug: enumerate all antigravity + chat commands at startup 2026-03-07 17:04:26 +09:00
CD
ae0fd78f7a fix: separate panel open and sendTextToChat with 1s delay, try true first 2026-03-07 16:42:21 +09:00
CD
180dba18be fix: try openNewConversation + sendTextToChat, clipboard paste as fallback 2026-03-07 16:31:44 +09:00
CD
c688812089 fix: try sendTextToChat with false param (plain text) first, fix try/catch structure 2026-03-07 16:17:53 +09:00
CD
e4eb7565ea feat: use antigravity.sendTextToChat native API for chat submission 2026-03-07 16:09:05 +09:00
CD
7f81528c18 fix: try carriage return and Enter keybinding for chat submit 2026-03-07 15:59:31 +09:00
CD
5780896273 fix: dynamic chat command discovery + removed toast notification 2026-03-07 15:53:48 +09:00
CD
35f39abf39 fix: simulate Enter key after clipboard paste for chat submission 2026-03-07 15:29:12 +09:00
CD
b42475c610 fix: chat input submit via acceptInput + query param, clipboard paste fallback 2026-03-07 15:24:32 +09:00
CD
0bd525a54c feat: Discord slash commands /stop /auto /send with guild sync 2026-03-07 15:20:10 +09:00
CD
02e9e4d424 fix: bot text confirmation on relay + rename @bridge to @gravity + channel dedup 2026-03-07 15:15:56 +09:00
CD
af14e5fbc7 fix: _get_channel fetches existing channels before creating (prevents duplicates) 2026-03-07 15:10:43 +09:00
CD
7f15e98e85 fix: try multiple chat open commands for Antigravity compatibility + always consume commands 2026-03-07 15:07:45 +09:00
CD
c8c9920dd0 feat: auto-create Discord channel when new project registration detected 2026-03-07 14:59:15 +09:00
CD
d227ba57f7 fix: only log registration count when it changes (stop 3s spam) 2026-03-07 14:53:44 +09:00
CD
35ee916440 feat: chat capture (@bridge participant, onDidChangeTextDocument), !stop command, chat snapshot scanner 2026-03-07 14:45:44 +09:00
CD
d44b4c2f77 feat: full artifact content in Discord (split into chunks) + full task content display 2026-03-07 14:36:18 +09:00
CD
046c58879c fix: periodic register reload in approval scanner + direct project_name access 2026-03-07 14:31:14 +09:00
CD
3c84cf5b4b fix: connect session shows task.md titles + auto-connect option for new projects 2026-03-07 14:20:01 +09:00
CD
98bb037c81 feat: session connect command + auto-registration + bridge/register protocol 2026-03-07 14:15:53 +09:00
CD
887850d0c9 feat: extension detects project name from git remote URL (priority over folder name) 2026-03-07 14:09:27 +09:00
CD
2c56fc7607 feat: multi-project routing — project_name in bridge, per-project channels, extension filtering 2026-03-07 14:07:40 +09:00
CD
2a4ef8d0d9 fix: extension fs.watch accepts all event types + debounce (Windows compatibility) 2026-03-07 14:00:08 +09:00
CD
f0184ec9bd fix: bridge.py uses utf-8-sig to handle Windows BOM in pending JSON 2026-03-07 13:43:12 +09:00
CD
51cfd57930 fix: asyncio.Lock on channel create + PROJECT_NAME in .env.example 2026-03-07 13:37:22 +09:00
CD
efaf29a6d2 refactor: single project channel - guild.fetch_channels API + project_channel singleton 2026-03-07 13:24:42 +09:00
CD
7c081e70b5 fix: channel duplication root fix - ready gate + conv_id-first + Discord API search + hash pre-init 2026-03-07 13:09:05 +09:00
15 changed files with 1515 additions and 350 deletions

View File

@@ -10,5 +10,8 @@ BRAIN_PATH=C:\Users\Certes\.gemini\antigravity\brain
# 세션 활성 판단: 마지막 파일 변경으로부터 이 시간(초) 이내면 활성 # 세션 활성 판단: 마지막 파일 변경으로부터 이 시간(초) 이내면 활성
ACTIVE_TIMEOUT_SECONDS=300 ACTIVE_TIMEOUT_SECONDS=300
# Project name (used for Discord channel: AG-{PROJECT_NAME})
PROJECT_NAME=gravity_control
# Watcher Settings # Watcher Settings
DEBOUNCE_SECONDS=2 DEBOUNCE_SECONDS=2

520
bot.py
View File

@@ -1,15 +1,15 @@
"""Discord bot — relays Antigravity brain events to Discord channels. """Discord bot — relays Antigravity brain events to Discord channels.
Dynamic channel management: Multi-project channel architecture:
- Creates `AG-{project_name}` channels only when file events arrive - One channel per project: AG-{project_name} (e.g. ag-gravity_control, ag-deriva)
- NO startup channel creation — only reconnects to existing Discord channels - Each conversation maps to a project via conv_to_project dict
- Archives channels after 10 minutes of inactivity - Extension registers projects via bridge/pending/ files
- Commands include project_name for routing to correct IDE window
""" """
import asyncio import asyncio
import json import json
import logging import logging
import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -77,46 +77,16 @@ class ApprovalView(discord.ui.View):
)) ))
# ─── Project Name Detection ─────────────────────────────────────────
def detect_project_name(conv_dir: Path) -> str:
"""Extract project name from conversation artifacts.
Returns: lowercase_with_underscores (e.g. 'gravity_control')
Uses FIRST successful extraction and caches it.
"""
short_id = conv_dir.name[:8]
def _sanitize(raw: str) -> str:
for suffix in ["Task Tracker", "— Task Tracker", "태스크", "구현 계획",
"Implementation Plan", "Walkthrough"]:
raw = raw.replace(suffix, "")
raw = raw.strip(" —-")
raw = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', raw)
raw = re.sub(r'[\s\-]+', '_', raw).strip('_').lower()
return raw[:30] if raw else ""
for fname in ["task.md", "implementation_plan.md"]:
fpath = conv_dir / fname
if fpath.exists():
try:
first_lines = fpath.read_text(encoding="utf-8").splitlines()[:5]
for line in first_lines:
match = re.match(r'^#\s+(.+)', line)
if match:
name = _sanitize(match.group(1))
# Require at least 5 chars to avoid short generic names
if name and name != "task" and len(name) >= 5:
return name
except (OSError, UnicodeDecodeError):
pass
return short_id
# ─── Bot ───────────────────────────────────────────────────────────── # ─── Bot ─────────────────────────────────────────────────────────────
class GravityBot(commands.Bot): class GravityBot(commands.Bot):
"""Discord bot for Antigravity session monitoring.""" """Discord bot for Antigravity session monitoring.
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): def __init__(self, event_queue: asyncio.Queue):
intents = discord.Intents.default() intents = discord.Intents.default()
@@ -125,19 +95,78 @@ class GravityBot(commands.Bot):
super().__init__(command_prefix="!", intents=intents) super().__init__(command_prefix="!", intents=intents)
self.event_queue = event_queue self.event_queue = event_queue
self.session_channels: dict[str, discord.TextChannel] = {} self.project_channels: dict[str, discord.TextChannel] = {} # project → channel
self.session_status_messages: dict[str, int] = {} self.conv_to_project: dict[str, str] = {} # conv_id → project
self.session_names: dict[str, str] = {} self.channel_to_project: dict[int, str] = {} # channel.id → project
self._channel_create_lock = asyncio.Lock() # SINGLE global lock self.session_status_messages: dict[str, int] = {} # conv_id → msg_id
self._sent_approval_ids: set[str] = set() # Track sent approvals self._sent_approval_ids: set[str] = set()
self._ready_event = asyncio.Event()
self._channel_lock = asyncio.Lock()
self.bridge = BridgeProtocol() self.bridge = BridgeProtocol()
self.session_category: discord.CategoryChannel | None = None self.session_category: discord.CategoryChannel | None = None
self.guild: discord.Guild | None = None self.guild: discord.Guild | None = None
@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): async def setup_hook(self):
self.loop.create_task(self._process_events()) self.loop.create_task(self._process_events())
self.pending_approval_scanner.start() self.pending_approval_scanner.start()
logger.info("Bot setup complete, event processor started") self.chat_snapshot_scanner.start()
self._register_slash_commands()
logger.info("Bot setup complete")
def _register_slash_commands(self):
"""Register Discord slash commands."""
@self.tree.command(name="stop", description="AI 작업 중지")
async def slash_stop(interaction: discord.Interaction):
project = self.channel_to_project.get(interaction.channel_id)
if not project:
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
return
self.bridge.write_command(project, "!stop", project_name=project)
await interaction.response.send_message(
embed=discord.Embed(
title="⏹️ AI 작업 중지",
description=f"**{project}** IDE에 중지 요청 전달됨",
color=discord.Color.orange(),
)
)
@self.tree.command(name="auto", description="자동 승인 토글")
async def slash_auto(interaction: discord.Interaction, mode: str):
project = self.channel_to_project.get(interaction.channel_id)
if not project:
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
return
enabled = mode.lower() in ("on", "true", "1")
self.bridge.write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
emoji = "🟢" if enabled else "🔴"
await interaction.response.send_message(
embed=discord.Embed(
title=f"{emoji} {'자동 승인' if enabled else '수동 승인'} 모드",
description=f"프로젝트: **{project}**",
color=discord.Color.green() if enabled else discord.Color.red(),
)
)
@self.tree.command(name="send", description="IDE 채팅에 메시지 전송")
async def slash_send(interaction: discord.Interaction, message: str):
project = self.channel_to_project.get(interaction.channel_id)
if not project:
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
return
self.bridge.write_command(project, message, project_name=project)
await interaction.response.send_message(
embed=discord.Embed(
description=f"📨 → **{project}** IDE에 전달됨\n`{message[:100]}`",
color=discord.Color.blurple(),
),
delete_after=10,
)
async def on_ready(self): async def on_ready(self):
logger.info(f"Bot connected as {self.user} (ID: {self.user.id})") logger.info(f"Bot connected as {self.user} (ID: {self.user.id})")
@@ -160,120 +189,138 @@ class GravityBot(commands.Bot):
logger.error("No permission to create category!") logger.error("No permission to create category!")
return return
# ONLY reconnect to existing Discord channels (NO new creation) # Discover existing project channels
await self._reconnect_existing_channels() await self._discover_channels()
async def _reconnect_existing_channels(self): # Load conversation → project registrations from Extension
"""Scan existing Discord channels and map them — MERGE same-name channels.""" self._load_registrations()
if not self.session_category:
return
# Group channels by normalized name # Sync slash commands to guild
name_to_channel: dict[str, discord.TextChannel] = {}
duplicates: list[discord.TextChannel] = []
for ch in self.session_category.text_channels:
if ch.topic and "Antigravity Session:" in ch.topic:
if ch.name in name_to_channel:
# DUPLICATE — mark for cleanup
duplicates.append(ch)
else:
name_to_channel[ch.name] = ch
# Map the primary channel for each name
count = 0
for ch in name_to_channel.values():
conv_id = ch.topic.replace("Antigravity Session:", "").strip()
if conv_id:
self.session_channels[conv_id] = ch
await self._recover_task_message(ch, conv_id)
count += 1
# Delete duplicate channels
for ch in duplicates:
try:
await ch.delete(reason="Duplicate channel cleanup")
logger.info(f"Deleted duplicate channel: #{ch.name}")
except (discord.Forbidden, discord.HTTPException) as e:
logger.warning(f"Failed to delete duplicate #{ch.name}: {e}")
logger.info(f"Reconnected to {count} channels, cleaned {len(duplicates)} duplicates")
async def _recover_task_message(
self, channel: discord.TextChannel, conversation_id: str
):
if conversation_id in self.session_status_messages:
return
try: try:
async for msg in channel.history(limit=10): self.tree.copy_global_to(guild=self.guild)
if msg.author == self.user and msg.embeds: synced = await self.tree.sync(guild=self.guild)
embed = msg.embeds[0] logger.info(f"Synced {len(synced)} slash commands to guild")
if embed.title and "Task" in embed.title: except Exception as e:
self.session_status_messages[conversation_id] = msg.id logger.warning(f"Slash command sync failed: {e}")
return
except (discord.Forbidden, discord.HTTPException): # Open the gate
pass self._ready_event.set()
logger.info("Ready gate opened — event processing enabled")
# Start scanner loops
if not self.pending_approval_scanner.is_running():
self.pending_approval_scanner.start()
if not self.chat_snapshot_scanner.is_running():
self.chat_snapshot_scanner.start()
logger.info("Scanner loops started")
# ─── Channel Management ────────────────────────────────────────── # ─── Channel Management ──────────────────────────────────────────
async def _ensure_channel( def _load_registrations(self):
self, conversation_id: str, project_name: str """Read bridge/register/ to learn conversation → project mappings."""
) -> discord.TextChannel: register_dir = self.bridge.bridge_dir / "register"
"""Get or create a channel. SINGLE channel per project name, guaranteed.""" if not register_dir.exists():
channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}" return
target_name = channel_name.lower().replace(" ", "-")
# Fast path: this conv_id already mapped count = 0
if conversation_id in self.session_channels: for f in register_dir.glob("*.json"):
ch = self.session_channels[conversation_id]
# Verify the channel name matches (project name might have changed)
if ch.name == target_name:
return ch
async with self._channel_create_lock:
# Double-check after lock
if conversation_id in self.session_channels:
ch = self.session_channels[conversation_id]
if ch.name == target_name:
return ch
# Check ALL mapped channels for same name
for cid, ch in self.session_channels.items():
if ch.name == target_name:
self.session_channels[conversation_id] = ch
self.session_names[conversation_id] = project_name
logger.info(f"Reusing channel #{ch.name} for {conversation_id[:8]}")
return ch
# Create new channel (truly no match anywhere)
try: try:
channel = await self.guild.create_text_channel( data = json.loads(f.read_text(encoding="utf-8-sig"))
conv_id = data.get("conversation_id", "")
project = data.get("project_name", "")
if conv_id and project:
self.conv_to_project[conv_id] = project
count += 1
except (json.JSONDecodeError, OSError):
pass
# Only log when count changes
prev = getattr(self, '_last_reg_count', -1)
if count != prev:
self._last_reg_count = count
if count:
logger.info(f"Loaded {count} conversation→project registrations")
# ─── Channel Management ──────────────────────────────────────────
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() + "-"
for ch in all_channels:
if (isinstance(ch, discord.TextChannel)
and ch.category_id == self.session_category.id
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}")
logger.info(f"Discovered {len(self.project_channels)} project channels")
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 lock
if project_name in self.project_channels:
return self.project_channels[project_name]
channel_name = self._make_channel_name(project_name)
# Search existing channels FIRST (prevents duplicates)
try:
all_channels = await self.guild.fetch_channels()
for ch in all_channels:
if (isinstance(ch, discord.TextChannel)
and ch.name == channel_name
and ch.category_id == self.session_category.id):
self.project_channels[project_name] = ch
self.channel_to_project[ch.id] = project_name
logger.info(f"Found existing channel: #{channel_name}")
return ch
except Exception as e:
logger.warning(f"fetch_channels failed: {e}")
# No existing channel — create new
try:
ch = await self.guild.create_text_channel(
name=channel_name, name=channel_name,
category=self.session_category, category=self.session_category,
topic=f"Antigravity Session: {conversation_id}", topic=f"Antigravity Bridge — {project_name}",
) )
self.session_channels[conversation_id] = channel self.project_channels[project_name] = ch
self.session_names[conversation_id] = project_name self.channel_to_project[ch.id] = project_name
logger.info(f"Created channel #{channel_name}") logger.info(f"Created channel: #{channel_name}")
embed = discord.Embed( embed = discord.Embed(
title=f"🚀 {project_name}", title=f"🚀 {project_name}",
description=f"Antigravity 세션 연결됨\nSession: `{conversation_id}`", description=f"Antigravity Bridge 연결됨",
color=discord.Color.blue(), color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
await channel.send(embed=embed) await ch.send(embed=embed)
return channel return ch
except discord.errors.Forbidden: except discord.errors.Forbidden:
logger.error(f"No permission to create channel: {channel_name}") logger.error(f"No permission to create channel: {channel_name}")
return None return None
# ─── Event Processing (SINGLE ROUTE) ───────────────────────────── 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 ─────────────────────────────────────────────
async def _process_events(self): async def _process_events(self):
"""Main event loop — ALL events go through here sequentially.""" """Main event loop — ALL events go through here sequentially."""
await self.wait_until_ready() await self.wait_until_ready()
await self._ready_event.wait()
logger.info("Event processor started (ready gate passed)")
while not self.is_closed(): while not self.is_closed():
try: try:
@@ -287,17 +334,13 @@ class GravityBot(commands.Bot):
logger.error(f"Error processing event: {e}", exc_info=True) logger.error(f"Error processing event: {e}", exc_info=True)
async def _handle_event(self, event: BrainEvent): async def _handle_event(self, event: BrainEvent):
"""Route brain events to handlers — single entry point.""" """Route brain events to the correct project channel."""
conv_dir = Config.BRAIN_PATH / event.conversation_id project = self._resolve_project(event.conversation_id)
project_name = detect_project_name(conv_dir) channel = await self._get_channel(project)
if not channel:
if event.event_type == EventType.SESSION_START:
await self._ensure_channel(event.conversation_id, project_name)
return return
# FILE_CREATED or FILE_CHANGED if event.event_type == EventType.SESSION_START:
channel = await self._ensure_channel(event.conversation_id, project_name)
if not channel:
return return
try: try:
@@ -306,22 +349,25 @@ class GravityBot(commands.Bot):
else: else:
await self._send_artifact_update(channel, event) await self._send_artifact_update(channel, event)
except discord.NotFound: except discord.NotFound:
# Channel was deleted while we held a reference self.project_channels.pop(project, None)
self.session_channels.pop(event.conversation_id, None) logger.warning(f"Channel deleted for project {project}, will recreate")
self.session_status_messages.pop(event.conversation_id, None)
logger.warning(f"Channel deleted, cleared: {event.conversation_id[:8]}")
# ─── Message Senders ───────────────────────────────────────────── # ─── Message Senders ─────────────────────────────────────────────
async def _send_task_update( async def _send_task_update(
self, channel: discord.TextChannel, event: BrainEvent self, channel: discord.TextChannel, event: BrainEvent
): ):
"""Send/edit task progress embed (SINGLE message, always edited)."""
progress = parse_task_progress(event.content) progress = parse_task_progress(event.content)
# Full task content (truncated to embed limit)
full_content = event.content.strip()
description = format_task_embed_text(progress) + "\n\n" + full_content
if len(description) > 4000:
description = description[:4000] + "\n…(truncated)"
embed = discord.Embed( embed = discord.Embed(
title="📋 Task 진행 현황", title="📋 Task 진행 현황",
description=format_task_embed_text(progress), description=description,
color=discord.Color.gold() if progress.in_progress > 0 color=discord.Color.gold() if progress.in_progress > 0
else discord.Color.green() if progress.done == progress.total else discord.Color.green() if progress.done == progress.total
else discord.Color.greyple(), else discord.Color.greyple(),
@@ -329,7 +375,6 @@ class GravityBot(commands.Bot):
) )
embed.set_footer(text=f"Session: {event.conversation_id[:8]}") embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
# Always try to edit existing message first
msg_id = self.session_status_messages.get(event.conversation_id) msg_id = self.session_status_messages.get(event.conversation_id)
if msg_id: if msg_id:
try: try:
@@ -345,7 +390,6 @@ class GravityBot(commands.Bot):
async def _send_artifact_update( async def _send_artifact_update(
self, channel: discord.TextChannel, event: BrainEvent self, channel: discord.TextChannel, event: BrainEvent
): ):
"""Send artifact update as single compact embed (preview only)."""
labels = { labels = {
"implementation_plan.md": "📐 구현 계획", "implementation_plan.md": "📐 구현 계획",
"walkthrough.md": "📝 작업 결과 요약", "walkthrough.md": "📝 작업 결과 요약",
@@ -353,42 +397,65 @@ class GravityBot(commands.Bot):
label = labels.get(event.file_name, f"📄 {event.file_name}") label = labels.get(event.file_name, f"📄 {event.file_name}")
event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트" event_label = "생성" if event.event_type == EventType.FILE_CREATED else "업데이트"
# Preview: first 6 non-empty lines only full_content = event.content.strip()
lines = event.content.strip().splitlines() CHUNK_SIZE = 4000 # Discord embed desc limit is 4096
preview = "\n".join(l for l in lines[:6] if l.strip())
if len(lines) > 6:
preview += f"\n... (+{len(lines) - 6} lines)"
# Split into chunks for long content
chunks = []
while full_content:
chunks.append(full_content[:CHUNK_SIZE])
full_content = full_content[CHUNK_SIZE:]
if not chunks:
chunks = ["(빈 파일)"]
# First chunk with title
embed = discord.Embed( embed = discord.Embed(
title=f"{label} ({event_label}됨)", title=f"{label} ({event_label}됨)",
description=preview[:1000], description=chunks[0],
color=discord.Color.blue(), color=discord.Color.blue(),
timestamp=datetime.now(timezone.utc), timestamp=datetime.now(timezone.utc),
) )
embed.set_footer(text=f"Session: {event.conversation_id[:8]}")
await channel.send(embed=embed) await channel.send(embed=embed)
# Additional chunks if content is long
for i, chunk in enumerate(chunks[1:], 2):
embed = discord.Embed(
title=f"{label} (계속 {i}/{len(chunks)})",
description=chunk,
color=discord.Color.blue(),
)
await channel.send(embed=embed)
# ─── Approval Scanner ──────────────────────────────────────────── # ─── Approval Scanner ────────────────────────────────────────────
@tasks.loop(seconds=3) @tasks.loop(seconds=3)
async def pending_approval_scanner(self): async def pending_approval_scanner(self):
"""Scan bridge/pending/ for new approval requests.""" """Scan bridge/pending/ for new approval requests + reload registrations."""
try: try:
# Reload conv→project registrations each cycle
self._load_registrations()
# Ensure channels exist for all registered projects
for project in set(self.conv_to_project.values()):
if project not in self.project_channels:
await self._get_channel(project)
logger.info(f"Auto-created channel for registered project: {project}")
requests = self.bridge.get_pending_requests() requests = self.bridge.get_pending_requests()
for req in requests: for req in requests:
if req.request_id in self._sent_approval_ids: if req.request_id in self._sent_approval_ids:
continue # Already sent continue
if req.discord_message_id != 0: if req.discord_message_id != 0:
continue continue
channel = self.session_channels.get(req.conversation_id) # Learn project mapping from pending approval
if not channel: project = req.project_name or Config.PROJECT_NAME
conv_dir = Config.BRAIN_PATH / req.conversation_id if req.conversation_id and req.conversation_id != '__global__':
if conv_dir.exists(): self.conv_to_project[req.conversation_id] = project
project_name = detect_project_name(conv_dir)
channel = await self._ensure_channel(
req.conversation_id, project_name
)
channel = await self._get_channel(project)
if channel: if channel:
self._sent_approval_ids.add(req.request_id) self._sent_approval_ids.add(req.request_id)
await self._send_approval_request(channel, req) await self._send_approval_request(channel, req)
@@ -416,11 +483,10 @@ class GravityBot(commands.Bot):
view = ApprovalView(self.bridge, request) view = ApprovalView(self.bridge, request)
msg = await channel.send(embed=embed, view=view) 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" pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
if pending_file.exists(): if pending_file.exists():
try: 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 data["discord_message_id"] = msg.id
pending_file.write_text( pending_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
@@ -428,47 +494,99 @@ class GravityBot(commands.Bot):
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass 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): async def on_message(self, message: discord.Message):
if message.author == self.user: if message.author == self.user:
return return
if not message.channel.name.startswith(Config.CHANNEL_PREFIX.lower() + "-"): # Determine project from channel
project = self.channel_to_project.get(message.channel.id)
if not project:
await self.process_commands(message)
return return
conv_id = None
for cid, ch in self.session_channels.items():
if ch.id == message.channel.id:
conv_id = cid
break
text = message.content.strip() text = message.content.strip()
# Special command: !auto on/off # Special command: !stop — cancel AI work
if text in ("!auto on", "!auto off"): if text == "!stop":
if conv_id: self.bridge.write_command(project, "!stop", project_name=project)
self.bridge.write_command(conv_id, text) embed = discord.Embed(
enabled = text == "!auto on" title="⏹️ AI 작업 중지",
emoji = "🟢" if enabled else "🔴" description=f"프로젝트: **{project}**\n중지 요청을 Extension에 전달했습니다.",
mode = "자동 승인" if enabled else "수동 승인" color=discord.Color.orange(),
embed = discord.Embed( )
title=f"{emoji} {mode} 모드", await message.channel.send(embed=embed)
description=f"Antigravity IDE 설정이 변경됩니다.\n"
f"`chat.tools.autoApprove = {enabled}`\n"
f"`chat.agent.autoApprove = {enabled}`",
color=discord.Color.green() if enabled else discord.Color.red(),
)
await message.channel.send(embed=embed)
else:
await message.reply("⚠️ 채널에 연결된 세션이 없습니다.")
return return
# General text relay # Special command: !auto on/off
if conv_id and text: if text in ("!auto on", "!auto off"):
self.bridge.write_command(conv_id, 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"프로젝트: **{project}**\n"
f"`chat.tools.autoApprove = {enabled}`\n"
f"`chat.agent.autoApprove = {enabled}`",
color=discord.Color.green() if enabled else discord.Color.red(),
)
await message.channel.send(embed=embed)
return
# General text relay — routed by project
if text:
self.bridge.write_command(project, text, project_name=project)
await message.add_reaction("📨") await message.add_reaction("📨")
embed = discord.Embed(
description=f"📨 → **{project}** IDE에 전달됨\n`{text[:100]}`",
color=discord.Color.blurple(),
)
await message.channel.send(embed=embed, delete_after=10)
await self.process_commands(message) await self.process_commands(message)
# ─── Chat Snapshot Scanner ─────────────────────────────────────────
@tasks.loop(seconds=5)
async def chat_snapshot_scanner(self):
"""Scan bridge/chat_snapshots/ for AI response dumps."""
try:
snapshot_dir = self.bridge.bridge_dir / "chat_snapshots"
if not snapshot_dir.exists():
return
for f in snapshot_dir.glob("*.json"):
try:
data = json.loads(f.read_text(encoding="utf-8-sig"))
project = data.get("project_name", Config.PROJECT_NAME)
content = data.get("content", "")
if content:
channel = await self._get_channel(project)
if channel:
# Split long content
CHUNK = 4000
chunks = [content[i:i+CHUNK] for i in range(0, len(content), CHUNK)]
for i, chunk in enumerate(chunks):
title = "💬 AI 대화 내용" if i == 0 else f"💬 (계속 {i+1}/{len(chunks)})"
embed = discord.Embed(
title=title,
description=chunk,
color=discord.Color.purple(),
timestamp=datetime.now(timezone.utc),
)
await channel.send(embed=embed)
f.unlink() # Cleanup
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Bad chat snapshot {f.name}: {e}")
except Exception as e:
logger.error(f"Error scanning chat snapshots: {e}")
@chat_snapshot_scanner.before_loop
async def before_chat_scanner(self):
await self.wait_until_ready()

View File

@@ -43,6 +43,7 @@ class ApprovalRequest:
timestamp: float timestamp: float
status: str = "pending" status: str = "pending"
discord_message_id: int = 0 discord_message_id: int = 0
project_name: str = "" # Project routing key
@dataclass @dataclass
@@ -72,11 +73,14 @@ class BridgeProtocol:
def get_pending_requests(self) -> list[ApprovalRequest]: def get_pending_requests(self) -> list[ApprovalRequest]:
"""Read all pending approval requests.""" """Read all pending approval requests."""
requests = [] requests = []
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
for f in self.pending_dir.glob("*.json"): for f in self.pending_dir.glob("*.json"):
try: try:
data = json.loads(f.read_text(encoding="utf-8")) data = json.loads(f.read_text(encoding="utf-8-sig"))
if data.get("status") == "pending": 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: except (json.JSONDecodeError, TypeError, OSError) as e:
logger.warning(f"Bad pending request {f.name}: {e}") logger.warning(f"Bad pending request {f.name}: {e}")
return requests return requests
@@ -106,7 +110,7 @@ class BridgeProtocol:
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass 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.""" """Write a user text command for Antigravity to consume."""
cmd_id = f"{int(time.time() * 1000)}" cmd_id = f"{int(time.time() * 1000)}"
filepath = self.commands_dir / f"{cmd_id}.json" filepath = self.commands_dir / f"{cmd_id}.json"
@@ -114,6 +118,7 @@ class BridgeProtocol:
data = { data = {
"id": cmd_id, "id": cmd_id,
"conversation_id": conversation_id, "conversation_id": conversation_id,
"project_name": project_name,
"text": text, "text": text,
"timestamp": time.time(), "timestamp": time.time(),
"consumed": False, "consumed": False,
@@ -123,5 +128,5 @@ class BridgeProtocol:
json.dumps(data, ensure_ascii=False, indent=2), json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8" 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 return cmd_id

View File

@@ -37,6 +37,7 @@ class Config:
# Channel naming # Channel naming
CHANNEL_PREFIX: str = "AG" CHANNEL_PREFIX: str = "AG"
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "gravity_control")
@classmethod @classmethod
def validate(cls) -> list[str]: def validate(cls) -> list[str]:

View File

@@ -0,0 +1,234 @@
# Antigravity IDE Commands Reference
> Verified from running Antigravity IDE (v2026-03) via `vscode.commands.getCommands(true)`
> Total: **171 antigravity commands** + **6 chat commands**
---
## 🎯 Chat / Agent Communication
| Command | Description |
|---------|-------------|
| `antigravity.sendPromptToAgentPanel` | **Send prompt directly to agent panel** |
| `antigravity.sendChatActionMessage` | Send a chat action message |
| `antigravity.sendTextToChat` | Send text to chat (may appear as @tagged) |
| `antigravity.sendTerminalToSidePanel` | Send terminal content to side panel |
| `antigravity.startNewConversation` | Start a new conversation |
| `antigravity.setVisibleConversation` | Set the visible conversation |
| `antigravity.openConversationPicker` | Open conversation picker |
| `antigravity.openConversationWorkspaceQuickPick` | Open conversation workspace quick pick |
| `antigravity.toggleChatFocus` | Toggle chat focus |
| `antigravity.switchBetweenWorkspaceAndAgent` | Switch between workspace and agent |
| `antigravity.prioritized.chat.openNewConversation` | Open new conversation (prioritized) |
## 🖥️ Agent Panel
| Command | Description |
|---------|-------------|
| `antigravity.agentPanel.open` | Open agent panel |
| `antigravity.agentPanel.focus` | Focus agent panel |
| `antigravity.agentPanel.expandView` | Expand agent panel view |
| `antigravity.agentPanel.resetViewLocation` | Reset panel view location |
| `antigravity.agentSidePanel.open` | Open agent side panel |
| `antigravity.agentSidePanel.focus` | Focus agent side panel |
| `antigravity.agentSidePanel.expandView` | Expand side panel view |
| `antigravity.agentSidePanel.removeView` | Remove side panel view |
| `antigravity.agentSidePanel.resetViewLocation` | Reset side panel location |
| `antigravity.agentSidePanel.toggleVisibility` | Toggle side panel visibility |
| `antigravity.openAgent` | Open agent |
| `antigravity.initializeAgent` | Initialize agent |
## ✅ Agent Step Control
| Command | Description |
|---------|-------------|
| `antigravity.agent.acceptAgentStep` | Accept agent step |
| `antigravity.agent.rejectAgentStep` | Reject agent step |
| `antigravity.command.accept` | Accept command |
| `antigravity.command.reject` | Reject command |
| `antigravity.terminalCommand.accept` | Accept terminal command |
| `antigravity.terminalCommand.reject` | Reject terminal command |
| `antigravity.terminalCommand.run` | Run terminal command |
| `antigravity.acceptCompletion` | Accept completion |
| `antigravity.prioritized.agentAcceptAllInFile` | Accept all changes in file |
| `antigravity.prioritized.agentAcceptFocusedHunk` | Accept focused hunk |
| `antigravity.prioritized.agentFocusNextFile` | Focus next file |
| `antigravity.prioritized.agentFocusNextHunk` | Focus next hunk |
| `antigravity.prioritized.agentFocusPreviousFile` | Focus previous file |
| `antigravity.prioritized.agentFocusPreviousHunk` | Focus previous hunk |
| `antigravity.prioritized.agentRejectAllInFile` | Reject all changes in file |
| `antigravity.prioritized.agentRejectFocusedHunk` | Reject focused hunk |
| `antigravity.prioritized.supercompleteAccept` | Supercomplete accept |
| `antigravity.prioritized.supercompleteEscape` | Supercomplete escape |
## 📝 Diff / Editor
| Command | Description |
|---------|-------------|
| `antigravity.closeAllDiffZones` | Close all diff zones |
| `antigravity.handleDiffZoneEdit` | Handle diff zone edit |
| `antigravity.openDiffZones` | Open diff zones |
| `antigravity.setDiffZonesState` | Set diff zones state |
| `antigravity.sidecar.sendDiffZone` | Send diff zone via sidecar |
| `antigravity.openReviewChanges` | Open review changes |
## 🖥️ Terminal
| Command | Description |
|---------|-------------|
| `antigravity.showManagedTerminal` | Show managed terminal |
| `antigravity.onManagerTerminalCommandData` | Terminal command data event |
| `antigravity.onManagerTerminalCommandFinish` | Terminal command finish event |
| `antigravity.onManagerTerminalCommandStart` | Terminal command start event |
| `antigravity.onShellCommandCompletion` | Shell command completion event |
| `antigravity.updateTerminalLastCommand` | Update terminal last command |
| `antigravity.prioritized.terminalCommand.open` | Open terminal command |
| `antigravity.prioritized.command.open` | Open command (prioritized) |
## 🔧 System / Config
| Command | Description |
|---------|-------------|
| `antigravity.getDiagnostics` | Get diagnostics |
| `antigravity.downloadDiagnostics` | Download diagnostics |
| `antigravity.getManagerTrace` | Get manager trace |
| `antigravity.getWorkbenchTrace` | Get workbench trace |
| `antigravity.enableTracing` | Enable tracing |
| `antigravity.clearAndDisableTracing` | Clear and disable tracing |
| `antigravity.captureTraces` | Capture traces |
| `antigravity.reloadWindow` | Reload window |
| `antigravity.restartLanguageServer` | Restart language server |
| `antigravity.killLanguageServerAndReloadWindow` | Kill LS and reload window |
| `antigravity.killRemoteExtensionHost` | Kill remote extension host |
| `antigravity.restartUserStatusUpdater` | Restart user status updater |
| `antigravity.handleAuthRefresh` | Handle auth refresh |
| `antigravity.cancelLogin` | Cancel login |
| `antigravity.editorModeSettings` | Editor mode settings |
| `antigravity.setWorkingDirectories` | Set working directories |
| `antigravity.simulateSegFault` | Simulate segfault (debug) |
## 🌐 Browser / MCP
| Command | Description |
|---------|-------------|
| `antigravity.openBrowser` | Open browser |
| `antigravity.getBrowserOnboardingPort` | Get browser onboarding port |
| `antigravity.getChromeDevtoolsMcpUrl` | Get Chrome DevTools MCP URL |
| `antigravity.openMcpConfigFile` | Open MCP config file |
| `antigravity.openMcpDocsPage` | Open MCP docs page |
| `antigravity.pollMcpServerStates` | Poll MCP server states |
| `antigravity.showBrowserAllowList` | Show browser allow list |
## 📦 Workflow / Rules / Artifacts
| Command | Description |
|---------|-------------|
| `antigravity.createGlobalWorkflow` | Create global workflow |
| `antigravity.createRule` | Create rule |
| `antigravity.createWorkflow` | Create workflow |
| `antigravity.artifacts.startComment` | Start artifact comment |
| `antigravity.getCascadePluginTemplate` | Get cascade plugin template |
| `antigravity.executeCascadeAction` | Execute cascade action |
| `antigravity.explainAndFixProblem` | Explain and fix problem |
| `antigravity.prioritized.explainProblem` | Explain problem (prioritized) |
## 📊 Reporting / Analytics
| Command | Description |
|---------|-------------|
| `antigravity.generateCommitMessage` | Generate commit message |
| `antigravity.cancelGenerateCommitMessage` | Cancel commit message generation |
| `antigravity.sendAnalyticsAction` | Send analytics action |
| `antigravity.logObservabilityDataAction` | Log observability data |
| `antigravity.tabReporting` | Tab reporting |
| `antigravity.trackBackgroundConversationCreated` | Track background conversation |
| `antigravity.uploadErrorAction` | Upload error action |
| `antigravityAgentManager.clearErrors` | Clear agent manager errors |
| `antigravityAgentManager.reportError` | Report error |
| `antigravityAgentManager.reportNotification` | Report notification |
| `antigravityAgentManager.reportStatus` | Report status |
## 🎨 UI / Navigation
| Command | Description |
|---------|-------------|
| `antigravity.openDocs` | Open docs |
| `antigravity.openChangeLog` | Open changelog |
| `antigravity.openCustomizationsTab` | Open customizations tab |
| `antigravity.openConfigurePluginsPage` | Open configure plugins page |
| `antigravity.openGenericUrl` | Open generic URL |
| `antigravity.openGlobalRules` | Open global rules |
| `antigravity.openInteractiveEditor` | Open interactive editor |
| `antigravity.openIssueReporter` | Open issue reporter |
| `antigravity.openQuickSettingsPanel` | Open quick settings panel |
| `antigravity.openRulesEducationalLink` | Open rules educational link |
| `antigravity.openTroubleshooting` | Open troubleshooting |
| `antigravity.openInCiderAction.topBar` | Open in Cider action |
| `antigravity.customizeAppIcon` | Customize app icon |
| `antigravity.hideFullScreenView` | Hide full screen view |
## 🔌 Import / Migration
| Command | Description |
|---------|-------------|
| `antigravity.importCiderSettings` | Import Cider settings |
| `antigravity.importCursorExtensions` | Import Cursor extensions |
| `antigravity.importCursorSettings` | Import Cursor settings |
| `antigravity.importVSCodeExtensions` | Import VS Code extensions |
| `antigravity.importVSCodeRecentWorkspaces` | Import VS Code workspaces |
| `antigravity.importVSCodeSettings` | Import VS Code settings |
| `antigravity.importWindsurfExtensions` | Import Windsurf extensions |
| `antigravity.importWindsurfSettings` | Import Windsurf settings |
| `antigravity.migrateWindsurfSettings` | Migrate Windsurf settings |
## 🐳 Dev Containers / SSH / WSL
| Command | Description |
|---------|-------------|
| `antigravity-dev-containers.attachToRunningContainer` | Attach to running container |
| `antigravity-dev-containers.openInContainer` | Open in container |
| `antigravity-dev-containers.reopenFolderLocally` | Reopen folder locally |
| `antigravity-dev-containers.reopenInContainer` | Reopen in container |
| `antigravity-dev-containers.showLog` | Show container log |
| `antigravitySSHHosts.*` | SSH host management |
| `antigravityWslTargets.*` | WSL target management |
## 🔊 Audio / Demo
| Command | Description |
|---------|-------------|
| `antigravity.playAudio` | Play audio |
| `antigravity.playNote` | Play note |
| `antigravity.startDemoMode` | Start demo mode |
| `antigravity.endDemoMode` | End demo mode |
## 🔍 Debug
| Command | Description |
|---------|-------------|
| `antigravity.toggleDebugInfoWidget` | Toggle debug info widget |
| `antigravity.updateDebugInfoWidget` | Update debug info widget |
| `antigravity.toggleManagerDevTools` | Toggle manager dev tools |
| `antigravity.toggleSettingsDevTools` | Toggle settings dev tools |
| `antigravity.toggleNewConvoStreamFormat` | Toggle new convo stream format |
| `antigravity.togglePersistentLanguageServer` | Toggle persistent LS |
| `antigravity.toggleRerenderFrequencyAlerts` | Toggle rerender frequency alerts |
## 💬 Chat Commands (Non-Antigravity)
| Command | Description |
|---------|-------------|
| `chat.inlineResourceAnchor.openToSide` | Open inline resource |
| `chat.openFileSnapshot` | Open file snapshot |
| `chat.openFileUpdatedBySnapshot` | Open file updated by snapshot |
| `workbench.action.chat.openStorageFolder` | Open chat storage folder |
| `workbench.action.terminal.chat.openTerminalSettingsLink` | Open terminal chat settings |
## 🔗 Git (Antigravity)
| Command | Description |
|---------|-------------|
| `git.antigravityClearCloneProgress` | Clear clone progress |
| `git.antigravityCloneNonInteractive` | Clone non-interactive |
| `git.antigravityGetRemoteUrl` | Get remote URL |
| `git.antigravityReportCloneProgress` | Report clone progress |

View File

@@ -10,3 +10,7 @@
| 6 | 02:25 | Extension 스캐폴드 + VSIX 빌드 | `52fed8c`~`1af5fb7` | ✅ | | 6 | 02:25 | Extension 스캐폴드 + VSIX 빌드 | `52fed8c`~`1af5fb7` | ✅ |
| 7 | 11:40 | 전면 재설계 (채널 스팸/중복 해결) | `e32be6b`~`51ece61` | ✅ | | 7 | 11:40 | 전면 재설계 (채널 스팸/중복 해결) | `e32be6b`~`51ece61` | ✅ |
| 8 | 12:17 | 승인 중복 전송 수정 + E2E 테스트 통과 | `ce2336c` | ✅ | | 8 | 12:17 | 승인 중복 전송 수정 + E2E 테스트 통과 | `ce2336c` | ✅ |
| 9 | 13:00 | 채팅 제출 탐색: clipboard paste + Enter 시뮬레이션 | `7f15e98`~`35f39ab` | 🔧 |
| 10 | 15:00 | @bridge@gravity 이름 변경 + 슬래시 명령어 /stop /auto /send | `02e9e4d`~`0bd525a` | ✅ |
| 11 | 16:00 | sendTextToChat 탐색 → sendPromptToAgentPanel 발견 | `e4eb756`~`8d5e59c` | ✅ |
| 12 | 17:15 | 양방향 통신 완성 + 171개 명령어 문서화 + scanner 시작 수정 | `befa5d7` | ✅ |

View File

@@ -0,0 +1,27 @@
# Discord ↔ Antigravity 양방향 채팅 통합
- **시간**: 2026-03-07 13:00~17:15
- **Commit**: `7f15e98`~`befa5d7` (15 commits)
- **Vikunja**: #223 → 진행중
## 결정 사항
### sendPromptToAgentPanel 발견 경위
- clipboard paste → Enter 시뮬레이션 (`\r`, `default:Enter`, `\u000d`) → 전부 실패
- `antigravity.sendTextToChat(true/false)` → 에러 없이 실행되지만 텍스트 표시 안 됨
- `vscode.commands.getCommands(true)` 로 171개 내부 명령어 전수 조사
- **`antigravity.sendPromptToAgentPanel`** 발견 → 성공
### Chat Snapshot 파이프라인 (응답 릴레이)
- Extension `onDidChangeTextDocument` → chat scheme 필터 → 2초 debounce → `bridge/chat_snapshots/`
- Bot `chat_snapshot_scanner` @tasks.loop(5초) → Discord embed 전송
- **`.start()` 누락 발견** — 두 scanner 모두 정의만 되고 시작 안 됐음
### SDK (antigravity-sdk)
- npm 패키지 존재, README에 `sendPrompt`, `sendMessage` 등 광고
- 실제 JS 코드에 해당 함수 미구현 (커뮤니티 프로젝트, vaporware)
- `state.vscdb` 읽기 + `vscode.commands.executeCommand` 래핑만 구현됨
## 미완료
- 응답 릴레이(Antigravity→Discord) 테스트 중
- listener leak (351개 누적) — Extension 재로드 시 dispose 문제

Binary file not shown.

Binary file not shown.

View File

@@ -4,13 +4,10 @@
* *
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol. * Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
* *
* Flow: * Multi-project routing:
* 1. Extension watches for tool approval notifications in VS Code * - Each workspace has a project name (from settings or workspace folder name)
* 2. Writes pending approval to bridge/pending/ * - Extension only processes commands/responses matching its project_name
* 3. Discord bot sends buttons to user * - Pending approvals include project_name for Discord channel routing
* 4. User clicks approve/reject
* 5. Bot writes response to bridge/response/
* 6. Extension reads response → sends keyboard command to approve/reject
*/ */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k; if (k2 === undefined) k2 = k;
@@ -54,19 +51,58 @@ const fs = __importStar(require("fs"));
const path = __importStar(require("path")); const path = __importStar(require("path"));
const os = __importStar(require("os")); const os = __importStar(require("os"));
let watcher = null; let watcher = null;
let commandsWatcher = null;
let statusBar; let statusBar;
let bridgePath; let bridgePath;
let projectName;
let isActive = false; let isActive = false;
// Track pending approvals we've already sent // Track pending approvals we've already sent
const sentPendingIds = new Set(); const sentPendingIds = new Set();
const cp = __importStar(require("child_process"));
/**
* Detect project name from workspace.
* Priority: settings > git remote repo name > workspace folder name
*/
function detectProjectName() {
const config = vscode.workspace.getConfiguration('gravityBridge');
const configName = config.get('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';
}
function activate(context) { function activate(context) {
console.log('Gravity Bridge: activating...'); projectName = detectProjectName();
console.log(`Gravity Bridge: activating for project "${projectName}"...`);
// Determine bridge path // Determine bridge path
const config = vscode.workspace.getConfiguration('gravityBridge'); const config = vscode.workspace.getConfiguration('gravityBridge');
const configPath = config.get('bridgePath'); const configPath = config.get('bridgePath');
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge'); bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
// Ensure bridge directories exist // Ensure bridge directories exist
const dirs = ['pending', 'response', 'commands']; const dirs = ['pending', 'response', 'commands', 'register'];
for (const dir of dirs) { for (const dir of dirs) {
const dirPath = path.join(bridgePath, dir); const dirPath = path.join(bridgePath, dir);
if (!fs.existsSync(dirPath)) { if (!fs.existsSync(dirPath)) {
@@ -76,29 +112,48 @@ function activate(context) {
// Status bar // Status bar
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
statusBar.command = 'gravityBridge.start'; statusBar.command = 'gravityBridge.start';
statusBar.text = '$(radio-tower) Bridge: Off'; statusBar.text = `$(radio-tower) ${projectName}: Off`;
statusBar.tooltip = 'Gravity Bridge — Click to start'; statusBar.tooltip = `Gravity Bridge — ${projectName}`;
statusBar.show(); statusBar.show();
context.subscriptions.push(statusBar); context.subscriptions.push(statusBar);
// Register commands // 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))); 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)));
// 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 // Auto-start
startBridge(); startBridge();
} }
function startBridge() { function startBridge() {
if (isActive) { if (isActive) {
vscode.window.showInformationMessage('Gravity Bridge is already running'); vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): already running`);
return; return;
} }
isActive = true; isActive = true;
statusBar.text = '$(radio-tower) Bridge: On'; statusBar.text = `$(radio-tower) ${projectName}: On`;
statusBar.tooltip = 'Gravity Bridge — Active'; statusBar.tooltip = `Gravity Bridge — ${projectName} (Active)`;
statusBar.command = 'gravityBridge.stop'; statusBar.command = 'gravityBridge.stop';
// Watch bridge/response/ for Discord user responses // Watch bridge/response/ for Discord user responses
const responsePath = path.join(bridgePath, 'response'); const responsePath = path.join(bridgePath, 'response');
const processedFiles = new Set(); // Debounce
try { try {
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => { watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
if (eventType === 'rename' && filename && filename.endsWith('.json')) { if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
processedFiles.add(filename);
setTimeout(() => processedFiles.delete(filename), 2000);
handleResponse(path.join(responsePath, filename)); handleResponse(path.join(responsePath, filename));
} }
}); });
@@ -110,8 +165,10 @@ function startBridge() {
// Watch for commands (user text input from Discord) // Watch for commands (user text input from Discord)
const commandsPath = path.join(bridgePath, 'commands'); const commandsPath = path.join(bridgePath, 'commands');
try { try {
fs.watch(commandsPath, { persistent: false }, (eventType, filename) => { commandsWatcher = fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
if (eventType === 'rename' && filename && filename.endsWith('.json')) { if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
processedFiles.add(filename);
setTimeout(() => processedFiles.delete(filename), 2000);
handleCommand(path.join(commandsPath, filename)); handleCommand(path.join(commandsPath, filename));
} }
}); });
@@ -120,30 +177,33 @@ function startBridge() {
catch (err) { catch (err) {
console.error('Gravity Bridge: failed to watch commands dir', err); console.error('Gravity Bridge: failed to watch commands dir', err);
} }
vscode.window.showInformationMessage('Gravity Bridge: Started'); vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Started`);
console.log(`Gravity Bridge: started, bridge path: ${bridgePath}`); console.log(`Gravity Bridge: started for project "${projectName}", bridge: ${bridgePath}`);
} }
function stopBridge() { function stopBridge() {
if (!isActive) { if (!isActive) {
return; return;
} }
isActive = false; isActive = false;
statusBar.text = '$(radio-tower) Bridge: Off'; statusBar.text = `$(radio-tower) ${projectName}: Off`;
statusBar.tooltip = 'Gravity Bridge — Click to start'; statusBar.tooltip = `Gravity Bridge — ${projectName}`;
statusBar.command = 'gravityBridge.start'; statusBar.command = 'gravityBridge.start';
if (watcher) { if (watcher) {
watcher.close(); watcher.close();
watcher = null; watcher = null;
} }
vscode.window.showInformationMessage('Gravity Bridge: Stopped'); if (commandsWatcher) {
commandsWatcher.close();
commandsWatcher = null;
}
vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Stopped`);
} }
/** /**
* Handle a response from Discord (approve/reject). * 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) { async function handleResponse(filePath) {
try { try {
// Small delay to ensure file is fully written
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise(resolve => setTimeout(resolve, 200));
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return; return;
@@ -153,10 +213,8 @@ async function handleResponse(filePath) {
if (response.approved === undefined) { if (response.approved === undefined) {
return; return;
} }
console.log(`Gravity Bridge: response received — approved=${response.approved}`); console.log(`Gravity Bridge [${projectName}]: response — approved=${response.approved}`);
if (response.approved) { if (response.approved) {
// Simulate pressing Enter or clicking approve
// Strategy: Use VS Code command to accept suggestion
await simulateApproval(); await simulateApproval();
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`); vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
} }
@@ -164,7 +222,6 @@ async function handleResponse(filePath) {
await simulateRejection(); await simulateRejection();
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`); vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
} }
// Cleanup: delete the response file after processing
try { try {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
} }
@@ -176,7 +233,7 @@ async function handleResponse(filePath) {
} }
/** /**
* Handle a text command from Discord. * Handle a text command from Discord.
* Types the text into the active editor or chat input. * ONLY processes commands matching this project's name.
*/ */
async function handleCommand(filePath) { async function handleCommand(filePath) {
try { try {
@@ -189,50 +246,118 @@ async function handleCommand(filePath) {
if (command.consumed || !command.text) { if (command.consumed || !command.text) {
return; return;
} }
console.log(`Gravity Bridge: command received — "${command.text.substring(0, 50)}..."`); // ★ PROJECT FILTER — only process commands for THIS project
// Type into the active text input (chat panel) const cmdProject = command.project_name || '';
await vscode.commands.executeCommand('workbench.action.chat.open'); if (cmdProject && cmdProject !== projectName) {
// Small delay for chat panel to open console.log(`Gravity Bridge [${projectName}]: skipping command for "${cmdProject}"`);
await new Promise(resolve => setTimeout(resolve, 500)); return; // Not for us — leave file for the correct Extension instance
// Type the text using clipboard }
const oldClipboard = await vscode.env.clipboard.readText(); const text = command.text.trim();
await vscode.env.clipboard.writeText(command.text); console.log(`Gravity Bridge [${projectName}]: command — "${text.substring(0, 50)}"`);
await vscode.commands.executeCommand('editor.action.clipboardPasteAction'); // Special command: !stop — cancel AI work
await vscode.env.clipboard.writeText(oldClipboard); if (text === '!stop') {
// Mark as consumed 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; command.consumed = true;
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
vscode.window.showInformationMessage(`📨 Discord input: ${command.text.substring(0, 50)}...`);
} }
catch (err) { catch (err) {
console.error('Gravity Bridge: error handling command', err); console.error('Gravity Bridge: error handling command', err);
} }
} }
/** /**
* Simulate approval — try multiple strategies. * Toggle Antigravity's auto-approve settings.
*/ */
async function toggleAutoApprove(enabled) {
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() { async function simulateApproval() {
try { try {
// Strategy 1: Try executing the accept command if available
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction'); await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
} }
catch { catch {
// Strategy 2: Send Enter key via type command
try { try {
await vscode.commands.executeCommand('type', { text: '\n' }); await vscode.commands.executeCommand('type', { text: '\n' });
} }
catch { catch {
// Strategy 3: Focus terminal and send Enter
await vscode.commands.executeCommand('workbench.action.terminal.focus'); await vscode.commands.executeCommand('workbench.action.terminal.focus');
} }
} }
} }
/**
* Simulate rejection — try multiple strategies.
*/
async function simulateRejection() { async function simulateRejection() {
try { try {
// Strategy 1: Escape key
await vscode.commands.executeCommand('workbench.action.closeQuickOpen'); await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
} }
catch { catch {
@@ -240,14 +365,12 @@ async function simulateRejection() {
await vscode.commands.executeCommand('cancelSelection'); await vscode.commands.executeCommand('cancelSelection');
} }
catch { catch {
// Fallback: just notify
console.log('Gravity Bridge: rejection sent but no active dialog found'); console.log('Gravity Bridge: rejection sent but no active dialog found');
} }
} }
} }
/** /**
* Manual approve/reject from command palette. * Manual approve/reject from command palette.
* Writes a pending request for testing purposes.
*/ */
function handleManualAction(approved) { function handleManualAction(approved) {
const requestId = `manual-${Date.now()}`; const requestId = `manual-${Date.now()}`;
@@ -268,6 +391,7 @@ function handleManualAction(approved) {
} }
/** /**
* Write a pending approval request to bridge/pending/ for Discord bot to pick up. * Write a pending approval request to bridge/pending/ for Discord bot to pick up.
* Includes project_name for correct channel routing.
*/ */
function writePendingApproval(conversationId, command, description) { function writePendingApproval(conversationId, command, description) {
const requestId = `req-${Date.now()}`; const requestId = `req-${Date.now()}`;
@@ -275,6 +399,7 @@ function writePendingApproval(conversationId, command, description) {
const request = { const request = {
request_id: requestId, request_id: requestId,
conversation_id: conversationId, conversation_id: conversationId,
project_name: projectName, // ★ Project routing
command: command, command: command,
description: description, description: description,
timestamp: Date.now() / 1000, timestamp: Date.now() / 1000,
@@ -283,9 +408,249 @@ function writePendingApproval(conversationId, command, description) {
}; };
fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8'); fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8');
sentPendingIds.add(requestId); sentPendingIds.add(requestId);
console.log(`Gravity Bridge: pending approval written ${requestId}`); console.log(`Gravity Bridge [${projectName}]: pending approval — ${requestId}`);
return 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) {
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) {
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 = [];
// 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,
});
}
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 = null;
function handleChatDocumentChange(event) {
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) {
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 = async (request, context, stream, token) => {
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 = [];
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`);
};
function deactivate() { function deactivate() {
stopBridge(); stopBridge();
} }

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +1,22 @@
{ {
"name": "gravity-bridge", "name": "gravity-bridge",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gravity-bridge", "name": "gravity-bridge",
"version": "0.1.0", "version": "0.2.0",
"dependencies": {
"antigravity-sdk": "^1.6.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/vscode": "^1.80.0", "@types/vscode": "^1.100.0",
"typescript": "^5.3.0" "typescript": "^5.3.0"
}, },
"engines": { "engines": {
"vscode": "^1.80.0" "vscode": "^1.100.0"
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
@@ -27,10 +30,30 @@
} }
}, },
"node_modules/@types/vscode": { "node_modules/@types/vscode": {
"version": "1.109.0", "version": "1.100.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
"integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", "integrity": "sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==",
"dev": true, "license": "MIT"
},
"node_modules/antigravity-sdk": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/antigravity-sdk/-/antigravity-sdk-1.6.0.tgz",
"integrity": "sha512-VdaLlSujbr+9WNCxs57N8nkuWdSmUT4tD5BsO1XGjAKD6f1aPMCtqeMBYP6iWHRUGZZjtGvSQaUGBt/WR/9iAA==",
"license": "AGPL-3.0-or-later",
"dependencies": {
"sql.js": "^1.14.0"
},
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@types/vscode": "^1.85.0"
}
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/typescript": { "node_modules/typescript": {

View File

@@ -2,13 +2,14 @@
"name": "gravity-bridge", "name": "gravity-bridge",
"displayName": "Gravity Bridge", "displayName": "Gravity Bridge",
"description": "Antigravity ↔ Discord 브리지 연동 확장", "description": "Antigravity ↔ Discord 브리지 연동 확장",
"version": "0.1.0", "version": "0.2.0",
"publisher": "variet", "publisher": "variet",
"engines": { "engines": {
"vscode": "^1.80.0" "vscode": "^1.100.0"
}, },
"categories": [ "categories": [
"Other" "Other",
"Chat"
], ],
"activationEvents": [ "activationEvents": [
"onStartupFinished" "onStartupFinished"
@@ -19,11 +20,20 @@
"watch": "tsc -watch -p ./" "watch": "tsc -watch -p ./"
}, },
"devDependencies": { "devDependencies": {
"@types/vscode": "^1.80.0",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/vscode": "^1.100.0",
"typescript": "^5.3.0" "typescript": "^5.3.0"
}, },
"contributes": { "contributes": {
"chatParticipants": [
{
"id": "gravity-bridge.gravity",
"name": "gravity",
"fullName": "Gravity Bridge",
"description": "대화 히스토리를 Discord로 전송 + AI 제어",
"isSticky": false
}
],
"commands": [ "commands": [
{ {
"command": "gravityBridge.start", "command": "gravityBridge.start",
@@ -40,6 +50,10 @@
{ {
"command": "gravityBridge.reject", "command": "gravityBridge.reject",
"title": "Gravity Bridge: Reject Pending" "title": "Gravity Bridge: Reject Pending"
},
{
"command": "gravityBridge.connect",
"title": "Gravity Bridge: Connect Session"
} }
], ],
"configuration": { "configuration": {
@@ -49,8 +63,16 @@
"type": "string", "type": "string",
"default": "", "default": "",
"description": "Bridge 디렉토리 경로 (기본: ~/.gemini/antigravity/bridge)" "description": "Bridge 디렉토리 경로 (기본: ~/.gemini/antigravity/bridge)"
},
"gravityBridge.projectName": {
"type": "string",
"default": "",
"description": "프로젝트 이름 (기본: git remote 레포명)"
} }
} }
} }
},
"dependencies": {
"antigravity-sdk": "^1.6.0"
} }
} }

View File

@@ -3,13 +3,10 @@
* *
* Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol. * Bridges Antigravity IDE approval dialogs to the Discord bot via file-based protocol.
* *
* Flow: * Multi-project routing:
* 1. Extension watches for tool approval notifications in VS Code * - Each workspace has a project name (from settings or workspace folder name)
* 2. Writes pending approval to bridge/pending/ * - Extension only processes commands/responses matching its project_name
* 3. Discord bot sends buttons to user * - Pending approvals include project_name for Discord channel routing
* 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 vscode from 'vscode';
@@ -18,15 +15,56 @@ import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
let watcher: fs.FSWatcher | null = null; let watcher: fs.FSWatcher | null = null;
let commandsWatcher: fs.FSWatcher | null = null;
let statusBar: vscode.StatusBarItem; let statusBar: vscode.StatusBarItem;
let bridgePath: string; let bridgePath: string;
let projectName: string;
let isActive = false; let isActive = false;
// Track pending approvals we've already sent // Track pending approvals we've already sent
const sentPendingIds = new Set<string>(); 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) { export function activate(context: vscode.ExtensionContext) {
console.log('Gravity Bridge: activating...'); projectName = detectProjectName();
console.log(`Gravity Bridge: activating for project "${projectName}"...`);
// Determine bridge path // Determine bridge path
const config = vscode.workspace.getConfiguration('gravityBridge'); const config = vscode.workspace.getConfiguration('gravityBridge');
@@ -34,7 +72,7 @@ export function activate(context: vscode.ExtensionContext) {
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge'); bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
// Ensure bridge directories exist // Ensure bridge directories exist
const dirs = ['pending', 'response', 'commands']; const dirs = ['pending', 'response', 'commands', 'register'];
for (const dir of dirs) { for (const dir of dirs) {
const dirPath = path.join(bridgePath, dir); const dirPath = path.join(bridgePath, dir);
if (!fs.existsSync(dirPath)) { if (!fs.existsSync(dirPath)) {
@@ -45,8 +83,8 @@ export function activate(context: vscode.ExtensionContext) {
// Status bar // Status bar
statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100);
statusBar.command = 'gravityBridge.start'; statusBar.command = 'gravityBridge.start';
statusBar.text = '$(radio-tower) Bridge: Off'; statusBar.text = `$(radio-tower) ${projectName}: Off`;
statusBar.tooltip = 'Gravity Bridge — Click to start'; statusBar.tooltip = `Gravity Bridge — ${projectName}`;
statusBar.show(); statusBar.show();
context.subscriptions.push(statusBar); context.subscriptions.push(statusBar);
@@ -54,30 +92,57 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand('gravityBridge.start', startBridge), vscode.commands.registerCommand('gravityBridge.start', startBridge),
vscode.commands.registerCommand('gravityBridge.stop', stopBridge), vscode.commands.registerCommand('gravityBridge.stop', stopBridge),
vscode.commands.registerCommand('gravityBridge.connect', connectSession),
vscode.commands.registerCommand('gravityBridge.approve', () => handleManualAction(true)), vscode.commands.registerCommand('gravityBridge.approve', () => handleManualAction(true)),
vscode.commands.registerCommand('gravityBridge.reject', () => handleManualAction(false)), vscode.commands.registerCommand('gravityBridge.reject', () => handleManualAction(false)),
); );
// 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 // Auto-start
startBridge(); startBridge();
} }
function startBridge() { function startBridge() {
if (isActive) { if (isActive) {
vscode.window.showInformationMessage('Gravity Bridge is already running'); vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): already running`);
return; return;
} }
isActive = true; isActive = true;
statusBar.text = '$(radio-tower) Bridge: On'; statusBar.text = `$(radio-tower) ${projectName}: On`;
statusBar.tooltip = 'Gravity Bridge — Active'; statusBar.tooltip = `Gravity Bridge — ${projectName} (Active)`;
statusBar.command = 'gravityBridge.stop'; statusBar.command = 'gravityBridge.stop';
// Watch bridge/response/ for Discord user responses // Watch bridge/response/ for Discord user responses
const responsePath = path.join(bridgePath, 'response'); const responsePath = path.join(bridgePath, 'response');
const processedFiles = new Set<string>(); // Debounce
try { try {
watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => { watcher = fs.watch(responsePath, { persistent: false }, (eventType, filename) => {
if (eventType === 'rename' && filename && filename.endsWith('.json')) { if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
processedFiles.add(filename);
setTimeout(() => processedFiles.delete(filename), 2000);
handleResponse(path.join(responsePath, filename)); handleResponse(path.join(responsePath, filename));
} }
}); });
@@ -89,8 +154,10 @@ function startBridge() {
// Watch for commands (user text input from Discord) // Watch for commands (user text input from Discord)
const commandsPath = path.join(bridgePath, 'commands'); const commandsPath = path.join(bridgePath, 'commands');
try { try {
fs.watch(commandsPath, { persistent: false }, (eventType, filename) => { commandsWatcher = fs.watch(commandsPath, { persistent: false }, (eventType, filename) => {
if (eventType === 'rename' && filename && filename.endsWith('.json')) { if (filename && filename.endsWith('.json') && !processedFiles.has(filename)) {
processedFiles.add(filename);
setTimeout(() => processedFiles.delete(filename), 2000);
handleCommand(path.join(commandsPath, filename)); handleCommand(path.join(commandsPath, filename));
} }
}); });
@@ -99,33 +166,30 @@ function startBridge() {
console.error('Gravity Bridge: failed to watch commands dir', err); console.error('Gravity Bridge: failed to watch commands dir', err);
} }
vscode.window.showInformationMessage('Gravity Bridge: Started'); vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Started`);
console.log(`Gravity Bridge: started, bridge path: ${bridgePath}`); console.log(`Gravity Bridge: started for project "${projectName}", bridge: ${bridgePath}`);
} }
function stopBridge() { function stopBridge() {
if (!isActive) { return; } if (!isActive) { return; }
isActive = false; isActive = false;
statusBar.text = '$(radio-tower) Bridge: Off'; statusBar.text = `$(radio-tower) ${projectName}: Off`;
statusBar.tooltip = 'Gravity Bridge — Click to start'; statusBar.tooltip = `Gravity Bridge — ${projectName}`;
statusBar.command = 'gravityBridge.start'; statusBar.command = 'gravityBridge.start';
if (watcher) { if (watcher) { watcher.close(); watcher = null; }
watcher.close(); if (commandsWatcher) { commandsWatcher.close(); commandsWatcher = null; }
watcher = null;
}
vscode.window.showInformationMessage('Gravity Bridge: Stopped'); vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): Stopped`);
} }
/** /**
* Handle a response from Discord (approve/reject). * 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) { async function handleResponse(filePath: string) {
try { try {
// Small delay to ensure file is fully written
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise(resolve => setTimeout(resolve, 200));
if (!fs.existsSync(filePath)) { return; } if (!fs.existsSync(filePath)) { return; }
@@ -135,11 +199,9 @@ async function handleResponse(filePath: string) {
if (response.approved === undefined) { return; } 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) { if (response.approved) {
// Simulate pressing Enter or clicking approve
// Strategy: Use VS Code command to accept suggestion
await simulateApproval(); await simulateApproval();
vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`); vscode.window.showInformationMessage(`✅ Approved: ${response.request_id}`);
} else { } else {
@@ -147,9 +209,7 @@ async function handleResponse(filePath: string) {
vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`); vscode.window.showInformationMessage(`❌ Rejected: ${response.request_id}`);
} }
// Cleanup: delete the response file after processing
try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ } try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ }
} catch (err) { } catch (err) {
console.error('Gravity Bridge: error handling response', err); console.error('Gravity Bridge: error handling response', err);
} }
@@ -157,7 +217,7 @@ async function handleResponse(filePath: string) {
/** /**
* Handle a text command from Discord. * 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) { async function handleCommand(filePath: string) {
try { try {
@@ -170,34 +230,69 @@ async function handleCommand(filePath: string) {
if (command.consumed || !command.text) { return; } 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(); 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: !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 // Special command: auto-approve toggle
if (text === '!auto on' || text === '!auto off') { if (text === '!auto on' || text === '!auto off') {
const enabled = text === '!auto on'; const enabled = text === '!auto on';
await toggleAutoApprove(enabled); await toggleAutoApprove(enabled);
// Mark as consumed
command.consumed = true; command.consumed = true;
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
return; return;
} }
// General text: type into chat panel // General text: send directly to Antigravity agent panel
await vscode.commands.executeCommand('workbench.action.chat.open'); try {
await new Promise(resolve => setTimeout(resolve, 500)); 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);
}
}
}
const oldClipboard = await vscode.env.clipboard.readText(); // Always mark as consumed
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; command.consumed = true;
fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(command, null, 2), 'utf-8');
vscode.window.showInformationMessage(`📨 Discord input: ${command.text.substring(0, 50)}...`);
} catch (err) { } catch (err) {
console.error('Gravity Bridge: error handling command', err); console.error('Gravity Bridge: error handling command', err);
} }
@@ -210,31 +305,25 @@ async function toggleAutoApprove(enabled: boolean) {
const config = vscode.workspace.getConfiguration(); const config = vscode.workspace.getConfiguration();
try { try {
// Core auto-approve settings
await config.update('chat.tools.autoApprove', enabled, vscode.ConfigurationTarget.Global); await config.update('chat.tools.autoApprove', enabled, vscode.ConfigurationTarget.Global);
await config.update('chat.agent.autoApprove', enabled, vscode.ConfigurationTarget.Global); await config.update('chat.agent.autoApprove', enabled, vscode.ConfigurationTarget.Global);
// Terminal auto-execution
if (enabled) { if (enabled) {
await config.update('chat.tools.terminal.enableAutoApprove', true, vscode.ConfigurationTarget.Global); await config.update('chat.tools.terminal.enableAutoApprove', true, vscode.ConfigurationTarget.Global);
} }
// File edits auto-accept
await config.update('autoAcceptV2.autoAcceptFileEdits', enabled, vscode.ConfigurationTarget.Global); await config.update('autoAcceptV2.autoAcceptFileEdits', enabled, vscode.ConfigurationTarget.Global);
// Update status bar
statusBar.text = enabled statusBar.text = enabled
? '$(radio-tower) Bridge: Auto ✅' ? `$(radio-tower) ${projectName}: Auto ✅`
: '$(radio-tower) Bridge: Manual 🔒'; : `$(radio-tower) ${projectName}: Manual 🔒`;
const mode = enabled ? '자동 승인 ON 🟢' : '수동 승인 OFF 🔴'; const mode = enabled ? '자동 승인 ON 🟢' : '수동 승인 OFF 🔴';
vscode.window.showInformationMessage(`Gravity Bridge: ${mode}`); vscode.window.showInformationMessage(`Gravity Bridge (${projectName}): ${mode}`);
console.log(`Gravity Bridge: auto-approve set to ${enabled}`);
// Write status back to bridge for bot to report
const statusPath = path.join(bridgePath, 'commands', `auto-status-${Date.now()}.json`); const statusPath = path.join(bridgePath, 'commands', `auto-status-${Date.now()}.json`);
fs.writeFileSync(statusPath, JSON.stringify({ fs.writeFileSync(statusPath, JSON.stringify({
id: `auto-status-${Date.now()}`, id: `auto-status-${Date.now()}`,
project_name: projectName,
text: `[SYSTEM] Auto-approve: ${enabled ? 'ON' : 'OFF'}`, text: `[SYSTEM] Auto-approve: ${enabled ? 'ON' : 'OFF'}`,
timestamp: Date.now() / 1000, timestamp: Date.now() / 1000,
consumed: true, consumed: true,
@@ -243,40 +332,28 @@ async function toggleAutoApprove(enabled: boolean) {
} catch (err) { } catch (err) {
console.error('Gravity Bridge: failed to toggle auto-approve', 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() { async function simulateApproval() {
try { try {
// Strategy 1: Try executing the accept command if available
await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction'); await vscode.commands.executeCommand('workbench.action.acceptSelectedCodeAction');
} catch { } catch {
// Strategy 2: Send Enter key via type command
try { try {
await vscode.commands.executeCommand('type', { text: '\n' }); await vscode.commands.executeCommand('type', { text: '\n' });
} catch { } catch {
// Strategy 3: Focus terminal and send Enter
await vscode.commands.executeCommand('workbench.action.terminal.focus'); await vscode.commands.executeCommand('workbench.action.terminal.focus');
} }
} }
} }
/**
* Simulate rejection — try multiple strategies.
*/
async function simulateRejection() { async function simulateRejection() {
try { try {
// Strategy 1: Escape key
await vscode.commands.executeCommand('workbench.action.closeQuickOpen'); await vscode.commands.executeCommand('workbench.action.closeQuickOpen');
} catch { } catch {
try { try {
await vscode.commands.executeCommand('cancelSelection'); await vscode.commands.executeCommand('cancelSelection');
} catch { } catch {
// Fallback: just notify
console.log('Gravity Bridge: rejection sent but no active dialog found'); console.log('Gravity Bridge: rejection sent but no active dialog found');
} }
} }
@@ -284,7 +361,6 @@ async function simulateRejection() {
/** /**
* Manual approve/reject from command palette. * Manual approve/reject from command palette.
* Writes a pending request for testing purposes.
*/ */
function handleManualAction(approved: boolean) { function handleManualAction(approved: boolean) {
const requestId = `manual-${Date.now()}`; const requestId = `manual-${Date.now()}`;
@@ -299,15 +375,13 @@ function handleManualAction(approved: boolean) {
fs.writeFileSync(responsePath, JSON.stringify(response, null, 2), 'utf-8'); fs.writeFileSync(responsePath, JSON.stringify(response, null, 2), 'utf-8');
if (approved) { if (approved) { simulateApproval(); }
simulateApproval(); else { simulateRejection(); }
} else {
simulateRejection();
}
} }
/** /**
* Write a pending approval request to bridge/pending/ for Discord bot to pick up. * Write a pending approval request to bridge/pending/ for Discord bot to pick up.
* Includes project_name for correct channel routing.
*/ */
export function writePendingApproval( export function writePendingApproval(
conversationId: string, conversationId: string,
@@ -320,6 +394,7 @@ export function writePendingApproval(
const request = { const request = {
request_id: requestId, request_id: requestId,
conversation_id: conversationId, conversation_id: conversationId,
project_name: projectName, // ★ Project routing
command: command, command: command,
description: description, description: description,
timestamp: Date.now() / 1000, timestamp: Date.now() / 1000,
@@ -330,10 +405,281 @@ export function writePendingApproval(
fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8'); fs.writeFileSync(pendingPath, JSON.stringify(request, null, 2), 'utf-8');
sentPendingIds.add(requestId); sentPendingIds.add(requestId);
console.log(`Gravity Bridge: pending approval written ${requestId}`); console.log(`Gravity Bridge [${projectName}]: pending approval — ${requestId}`);
return 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() { export function deactivate() {
stopBridge(); stopBridge();
} }

View File

@@ -52,13 +52,30 @@ class BrainEventHandler(FileSystemEventHandler):
self._initialize_known_sessions() self._initialize_known_sessions()
def _initialize_known_sessions(self): def _initialize_known_sessions(self):
"""Scan existing brain directories to establish baseline (no events emitted).""" """Scan existing brain directories to establish baseline (no events emitted).
Also pre-loads content hashes for watched files to prevent spurious events.
"""
brain_path = Config.BRAIN_PATH brain_path = Config.BRAIN_PATH
hash_count = 0
if brain_path.exists(): if brain_path.exists():
for entry in brain_path.iterdir(): for entry in brain_path.iterdir():
if entry.is_dir() and self._is_conversation_id(entry.name): if entry.is_dir() and self._is_conversation_id(entry.name):
self._known_sessions.add(entry.name) self._known_sessions.add(entry.name)
logger.info(f"Found {len(self._known_sessions)} existing sessions at startup") # Pre-load content hashes for watched files
for watched in Config.WATCHED_FILES:
fpath = entry / watched
if fpath.exists():
try:
content = fpath.read_text(encoding="utf-8")
h = hashlib.md5(content.encode()).hexdigest()
self._content_hashes[str(fpath)] = h
hash_count += 1
except (OSError, UnicodeDecodeError):
pass
logger.info(
f"Found {len(self._known_sessions)} existing sessions, "
f"pre-loaded {hash_count} content hashes"
)
def _is_conversation_id(self, name: str) -> bool: def _is_conversation_id(self, name: str) -> bool:
parts = name.split("-") parts = name.split("-")