feat(bot,bridge): P1 !auto 토글 자동승인 + P2 BridgeTransport 추상화 #task-304 #task-305

P1: !auto 토글 (bot.py + extension.ts)
- auto_approve_projects set으로 프로젝트별 상태 관리
- !auto → on/off 토글, pending 자동 승인 + 🤖 자동 승인됨 embed
- Extension step_probe에서 autoApproveEnabled 시 직접 tryApprovalStrategies

P2: BridgeTransport 추상화 (bridge.py)
- BridgeTransport ABC + LocalTransport (기존 동작 100% 호환)
- RemoteTransport 스켈레톤 (multi-PC 대비)
- config.py BOT_MODE/REMOTE_BRIDGE_URL, main.py transport 주입

docs: usage-guide.md + tech-stack.md Python 경로 기록
This commit is contained in:
Variet Worker
2026-03-11 19:25:40 +09:00
parent 1696a2976b
commit c1303999cf
8 changed files with 434 additions and 106 deletions

View File

@@ -4,34 +4,43 @@
## 언어 & 런타임 ## 언어 & 런타임
| 항목 | 버전 | 비고 | | 항목 | 버전 | 경로/비고 |
|------|------|------| |------|------|-----------|
| (예: Node.js) | (예: 20.x) | (설치 경로 등) | | Python | 3.x (miniforge3) | `C:\ProgramData\miniforge3\envs\gravity_control\python.exe` |
| (예: Python) | (예: 3.12) | (가상환경 경로 등) | | Node.js | 시스템 설치 | `node`, `npm` (PowerShell에서 `cmd /c npm` 권장) |
| TypeScript | (Extension) | `extension/src/extension.ts``tsc` 빌드 |
> [!IMPORTANT]
> Python은 **반드시** 위 miniforge3 경로를 사용. WindowsApps의 python stub은 동작하지 않음.
## 프레임워크 ## 프레임워크
| 항목 | 버전 | 용도 | | 항목 | 버전 | 용도 |
|------|------|------| |------|------|------|
| (예: Express) | (예: 4.18) | (서버) | | discord.py | 2.x | Discord 봇 |
| (예: React) | (예: 18.x) | (프론트엔드) | | watchdog | - | 파일시스템 감시 |
| antigravity-sdk | 로컬 | VS Code Extension SDK 연동 |
## 패키지 관리 ## 패키지 관리
- 패키지 매니저: (npm / yarn / pnpm / pip 등) - **Python**: pip (`requirements.txt`)
- Lock 파일: (package-lock.json / yarn.lock 등) - **Extension**: npm (`extension/package.json`)
## 개발 도구 ## 개발 도구
| 도구 | 명령어 | | 도구 | 명령어 |
|------|--------| |------|--------|
| 개발 서버 | (예: `cmd /c npm run dev`) | | **봇 실행** | `start_bot.bat` 또는 `C:\ProgramData\miniforge3\envs\gravity_control\python.exe main.py` |
| 빌드 | (예: `cmd /c npm run build`) | | **Extension 빌드** | `cd extension && cmd /c npm run compile` |
| 테스트 | (예: `cmd /c npm test`) | | **Extension VSIX** | `cd extension && cmd /c npx vsce package` |
| 린트 | (예: `cmd /c npm run lint`) | | **봇 구문 검사** | `C:\ProgramData\miniforge3\envs\gravity_control\python.exe -c "import bot, bridge, config, main"` |
## 환경 변수 ## 환경 변수
| 변수명 | 용도 | 기본값 | | 변수명 | 용도 | 기본값 |
|--------|------|--------| |--------|------|--------|
| (예: PORT) | (서버 포트) | (3000) | | DISCORD_TOKEN | Discord 봇 토큰 | (필수) |
| DISCORD_GUILD_ID | Discord 서버 ID | (필수) |
| BRAIN_PATH | AG 브레인 경로 | `~/.gemini/antigravity/brain` |
| BOT_MODE | 봇 모드 (local/remote) | `local` |
| REMOTE_BRIDGE_URL | 원격 브릿지 URL | (remote 모드 전용) |

62
bot.py
View File

@@ -179,6 +179,7 @@ class GravityBot(commands.Bot):
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
self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled
@staticmethod @staticmethod
def _make_channel_name(project_name: str) -> str: def _make_channel_name(project_name: str) -> str:
@@ -211,12 +212,18 @@ class GravityBot(commands.Bot):
) )
@self.tree.command(name="auto", description="자동 승인 토글") @self.tree.command(name="auto", description="자동 승인 토글")
async def slash_auto(interaction: discord.Interaction, mode: str): async def slash_auto(interaction: discord.Interaction):
project = self.channel_to_project.get(interaction.channel_id) project = self.channel_to_project.get(interaction.channel_id)
if not project: if not project:
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True) await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
return return
enabled = mode.lower() in ("on", "true", "1") # Toggle
if project in self.auto_approve_projects:
self.auto_approve_projects.discard(project)
enabled = False
else:
self.auto_approve_projects.add(project)
enabled = True
self.bridge.write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project) self.bridge.write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
emoji = "🟢" if enabled else "🔴" emoji = "🟢" if enabled else "🔴"
await interaction.response.send_message( await interaction.response.send_message(
@@ -521,6 +528,35 @@ class GravityBot(commands.Bot):
if req.discord_message_id != 0: if req.discord_message_id != 0:
continue continue
# Learn project mapping from pending approval
project = req.project_name or Config.PROJECT_NAME
if req.conversation_id and req.conversation_id != '__global__':
self.conv_to_project[req.conversation_id] = project
# ── Auto-approve: if project has auto enabled, approve immediately ──
if project in self.auto_approve_projects:
self._sent_approval_ids.add(req.request_id)
# Write auto-approve response for Extension
self.bridge.write_response(UserResponse(
request_id=req.request_id,
approved=True,
button_index=0, # first button (Allow Once / Run)
step_type=getattr(req, 'step_type', ''),
project_name=project,
))
# Show compact auto-approved embed in Discord
channel = await self._get_channel(project)
if channel:
embed = discord.Embed(
title="🤖 자동 승인됨",
description=f"```\n{req.command[:500]}\n```",
color=discord.Color.green(),
)
embed.set_footer(text=f"auto-approve | {req.request_id[:12]}")
await channel.send(embed=embed)
logger.info(f"Auto-approved: {req.request_id[:12]} project={project}")
continue
# Defer short-command pendings (e.g. "Run") by 4 cycles (~12s) # Defer short-command pendings (e.g. "Run") by 4 cycles (~12s)
# to give step_probe time to merge detailed command info # to give step_probe time to merge detailed command info
# (step_probe MERGE happens ~10s after pending creation) # (step_probe MERGE happens ~10s after pending creation)
@@ -540,11 +576,6 @@ class GravityBot(commands.Bot):
# Clean up defer tracking # Clean up defer tracking
self._deferred_ids.pop(req.request_id, None) self._deferred_ids.pop(req.request_id, None)
# Learn project mapping from pending approval
project = req.project_name or Config.PROJECT_NAME
if req.conversation_id and req.conversation_id != '__global__':
self.conv_to_project[req.conversation_id] = project
channel = await self._get_channel(project) 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)
@@ -732,17 +763,22 @@ class GravityBot(commands.Bot):
await message.channel.send(embed=embed) await message.channel.send(embed=embed)
return return
# Special command: !auto on/off # Special command: !auto — toggle auto-approve
if text in ("!auto on", "!auto off"): if text == "!auto":
self.bridge.write_command(project, text, project_name=project) # Toggle per-project auto-approve
enabled = text == "!auto on" if project in self.auto_approve_projects:
self.auto_approve_projects.discard(project)
enabled = False
else:
self.auto_approve_projects.add(project)
enabled = True
self.bridge.write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project)
emoji = "🟢" if enabled else "🔴" emoji = "🟢" if enabled else "🔴"
mode = "자동 승인" if enabled else "수동 승인" mode = "자동 승인" if enabled else "수동 승인"
embed = discord.Embed( embed = discord.Embed(
title=f"{emoji} {mode} 모드", title=f"{emoji} {mode} 모드",
description=f"프로젝트: **{project}**\n" description=f"프로젝트: **{project}**\n"
f"`chat.tools.autoApprove = {enabled}`\n" f"모든 승인 요청이 {'자동으로 승인됩니다' if enabled else '수동 확인이 필요합니다'}",
f"`chat.agent.autoApprove = {enabled}`",
color=discord.Color.green() if enabled else discord.Color.red(), color=discord.Color.green() if enabled else discord.Color.red(),
) )
await message.channel.send(embed=embed) await message.channel.send(embed=embed)

213
bridge.py
View File

@@ -1,4 +1,4 @@
"""Bridge protocol — file-based communication between Discord bot and Antigravity. """Bridge protocol — communication between Discord bot and Antigravity.
Bridge directory: ~/.gemini/antigravity/bridge/ Bridge directory: ~/.gemini/antigravity/bridge/
Structure: Structure:
@@ -12,11 +12,16 @@ Protocol:
2. Bot reads pending/ → sends Discord message with ✅/❌ buttons 2. Bot reads pending/ → sends Discord message with ✅/❌ buttons
3. User clicks button → Bot writes JSON to response/ 3. User clicks button → Bot writes JSON to response/
4. VS Code Extension reads response/ → executes action 4. VS Code Extension reads response/ → executes action
Transport layer:
LocalTransport — file-based (default, single-PC)
RemoteTransport — HTTP-based (future: multi-PC collector mode)
""" """
import json import json
import time import time
import logging import logging
from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from enum import Enum from enum import Enum
@@ -59,37 +64,158 @@ class UserResponse:
project_name: str = "" # for multi-project: extension uses this when pending file is missing project_name: str = "" # for multi-project: extension uses this when pending file is missing
class BridgeProtocol: # ─── Transport Abstraction ───
"""Manages the file-based bridge protocol."""
def __init__(self): class BridgeTransport(ABC):
self.bridge_dir = Config.BRAIN_PATH.parent / "bridge" """Abstract transport for bridge I/O.
self.pending_dir = self.bridge_dir / "pending"
self.response_dir = self.bridge_dir / "response"
self.commands_dir = self.bridge_dir / "commands"
# Create directories Implementations handle reading/writing JSON files for the bridge protocol,
for d in [self.pending_dir, self.response_dir, self.commands_dir]: regardless of whether the storage is local filesystem or remote HTTP.
"""
@abstractmethod
def list_json_files(self, subdir: str) -> list[str]:
"""List JSON filenames in a subdirectory (e.g. 'pending', 'response')."""
...
@abstractmethod
def read_json(self, subdir: str, filename: str) -> dict | None:
"""Read and parse a JSON file. Returns None if not found or corrupt."""
...
@abstractmethod
def write_json(self, subdir: str, filename: str, data: dict) -> None:
"""Write data as JSON to a file in the given subdirectory."""
...
@abstractmethod
def delete_file(self, subdir: str, filename: str) -> bool:
"""Delete a file. Returns True if deleted, False if not found."""
...
@abstractmethod
def ensure_dirs(self) -> None:
"""Ensure all required subdirectories exist."""
...
class LocalTransport(BridgeTransport):
"""File-system based transport (default, single-PC mode).
Reads/writes directly to the bridge directory on local disk.
This is the existing behavior, extracted into a transport class.
"""
def __init__(self, bridge_dir: Path):
self.bridge_dir = bridge_dir
def list_json_files(self, subdir: str) -> list[str]:
d = self.bridge_dir / subdir
if not d.exists():
return []
return [f.name for f in d.glob("*.json")]
def read_json(self, subdir: str, filename: str) -> dict | None:
fp = self.bridge_dir / subdir / filename
if not fp.exists():
return None
try:
return json.loads(fp.read_text(encoding="utf-8-sig"))
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"LocalTransport: bad file {subdir}/{filename}: {e}")
return None
def write_json(self, subdir: str, filename: str, data: dict) -> None:
d = self.bridge_dir / subdir
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
fp = d / filename
fp.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def delete_file(self, subdir: str, filename: str) -> bool:
fp = self.bridge_dir / subdir / filename
if fp.exists():
try:
fp.unlink()
return True
except OSError:
return False
return False
def ensure_dirs(self) -> None:
for sub in ("pending", "response", "commands"):
(self.bridge_dir / sub).mkdir(parents=True, exist_ok=True)
class RemoteTransport(BridgeTransport):
"""HTTP-based transport for remote/multi-PC mode (skeleton).
Future implementation: polls a remote bridge HTTP server that
exposes the same pending/response/commands JSON files via API.
"""
def __init__(self, base_url: str):
self.base_url = base_url.rstrip("/")
logger.info(f"RemoteTransport: initialized with {self.base_url}")
def list_json_files(self, subdir: str) -> list[str]:
raise NotImplementedError("RemoteTransport not yet implemented")
def read_json(self, subdir: str, filename: str) -> dict | None:
raise NotImplementedError("RemoteTransport not yet implemented")
def write_json(self, subdir: str, filename: str, data: dict) -> None:
raise NotImplementedError("RemoteTransport not yet implemented")
def delete_file(self, subdir: str, filename: str) -> bool:
raise NotImplementedError("RemoteTransport not yet implemented")
def ensure_dirs(self) -> None:
pass # Remote server manages its own directories
# ─── Bridge Protocol (uses Transport) ───
class BridgeProtocol:
"""Manages the bridge protocol via a pluggable transport."""
def __init__(self, transport: BridgeTransport | None = None):
if transport is None:
bridge_dir = Config.BRAIN_PATH.parent / "bridge"
transport = LocalTransport(bridge_dir)
self.transport = transport
# Legacy attributes for backward compatibility
# (bot.py uses self.bridge.pending_dir etc. in some places)
if isinstance(transport, LocalTransport):
self.bridge_dir = transport.bridge_dir
self.pending_dir = transport.bridge_dir / "pending"
self.response_dir = transport.bridge_dir / "response"
self.commands_dir = transport.bridge_dir / "commands"
# Ensure directories exist
self.transport.ensure_dirs()
# Startup cleanup: purge stale pending files (> 5 min old) # Startup cleanup: purge stale pending files (> 5 min old)
self._cleanup_stale_pending() self._cleanup_stale_pending()
logger.info(f"Bridge protocol initialized: {self.bridge_dir}") logger.info(f"Bridge protocol initialized: transport={type(transport).__name__}")
def _cleanup_stale_pending(self, max_age_seconds: int = 300): def _cleanup_stale_pending(self, max_age_seconds: int = 300):
"""Remove pending files older than max_age_seconds on startup.""" """Remove pending files older than max_age_seconds on startup."""
now = time.time() now = time.time()
cleaned = 0 cleaned = 0
for f in self.pending_dir.glob("*.json"): for fname in self.transport.list_json_files("pending"):
try: data = self.transport.read_json("pending", fname)
data = json.loads(f.read_text(encoding="utf-8-sig")) if data is None:
self.transport.delete_file("pending", fname)
cleaned += 1
continue
ts = data.get("timestamp", 0) ts = data.get("timestamp", 0)
if now - ts > max_age_seconds: if now - ts > max_age_seconds:
f.unlink() self.transport.delete_file("pending", fname)
cleaned += 1
except (json.JSONDecodeError, OSError):
f.unlink() # corrupt file, remove
cleaned += 1 cleaned += 1
if cleaned: if cleaned:
logger.info(f"Startup cleanup: removed {cleaned} stale pending files") logger.info(f"Startup cleanup: removed {cleaned} stale pending files")
@@ -100,63 +226,53 @@ class BridgeProtocol:
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()} fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
now = time.time() now = time.time()
MAX_AGE = 1800 # 30 minutes (matches Discord button timeout) MAX_AGE = 1800 # 30 minutes (matches Discord button timeout)
for f in self.pending_dir.glob("*.json"): for fname in self.transport.list_json_files("pending"):
try: data = self.transport.read_json("pending", fname)
data = json.loads(f.read_text(encoding="utf-8-sig")) if data is None:
continue
ts = data.get("timestamp", 0) ts = data.get("timestamp", 0)
if now - ts > MAX_AGE: if now - ts > MAX_AGE:
# Too old — mark expired and skip # Too old — mark expired and skip
data["status"] = "expired" data["status"] = "expired"
f.write_text( self.transport.write_json("pending", fname, data)
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
continue continue
if data.get("status") == "pending": if data.get("status") == "pending":
# Filter to known fields only # Filter to known fields only
filtered = {k: v for k, v in data.items() if k in fields} filtered = {k: v for k, v in data.items() if k in fields}
try:
requests.append(ApprovalRequest(**filtered)) requests.append(ApprovalRequest(**filtered))
except (json.JSONDecodeError, TypeError, OSError) as e: except TypeError as e:
logger.warning(f"Bad pending request {f.name}: {e}") logger.warning(f"Bad pending request {fname}: {e}")
return requests return requests
def read_pending_request(self, request_id: str) -> ApprovalRequest | None: def read_pending_request(self, request_id: str) -> ApprovalRequest | None:
"""Re-read a specific pending request (to get merged data).""" """Re-read a specific pending request (to get merged data)."""
f = self.pending_dir / f"{request_id}.json" fname = f"{request_id}.json"
if not f.exists(): data = self.transport.read_json("pending", fname)
if data is None:
return None return None
try:
data = json.loads(f.read_text(encoding="utf-8-sig"))
fields = {fn.name for fn in ApprovalRequest.__dataclass_fields__.values()} fields = {fn.name for fn in ApprovalRequest.__dataclass_fields__.values()}
filtered = {k: v for k, v in data.items() if k in fields} filtered = {k: v for k, v in data.items() if k in fields}
try:
return ApprovalRequest(**filtered) return ApprovalRequest(**filtered)
except (json.JSONDecodeError, TypeError, OSError): except TypeError:
return None return None
def write_response(self, response: UserResponse): def write_response(self, response: UserResponse):
"""Write a user response to the response directory.""" """Write a user response to the response directory."""
response.timestamp = time.time() response.timestamp = time.time()
filename = f"{response.request_id}.json" fname = f"{response.request_id}.json"
filepath = self.response_dir / filename
filepath.write_text( self.transport.write_json("response", fname, asdict(response))
json.dumps(asdict(response), ensure_ascii=False, indent=2), logger.info(f"Response written: {fname} (approved={response.approved})")
encoding="utf-8"
)
logger.info(f"Response written: {filename} (approved={response.approved})")
# Delete pending file after processing (prevents re-processing and accumulation) # Delete pending file after processing (prevents re-processing and accumulation)
pending_file = self.pending_dir / filename self.transport.delete_file("pending", fname)
if pending_file.exists():
try:
pending_file.unlink()
except OSError:
pass
def write_command(self, conversation_id: str, text: str, *, project_name: 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" fname = f"{cmd_id}.json"
data = { data = {
"id": cmd_id, "id": cmd_id,
@@ -167,9 +283,6 @@ class BridgeProtocol:
"consumed": False, "consumed": False,
} }
filepath.write_text( self.transport.write_json("commands", fname, data)
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
logger.info(f"Command written: {cmd_id} → project={project_name}") logger.info(f"Command written: {cmd_id} → project={project_name}")
return cmd_id return cmd_id

View File

@@ -40,6 +40,10 @@ class Config:
CHANNEL_PREFIX: str = "AG" CHANNEL_PREFIX: str = "AG"
PROJECT_NAME: str = os.getenv("PROJECT_NAME", "gravity_control") PROJECT_NAME: str = os.getenv("PROJECT_NAME", "gravity_control")
# Bot mode: 'local' (file-based bridge) or 'remote' (HTTP polling — future)
BOT_MODE: str = os.getenv("BOT_MODE", "local")
REMOTE_BRIDGE_URL: str = os.getenv("REMOTE_BRIDGE_URL", "")
@classmethod @classmethod
def validate(cls) -> list[str]: def validate(cls) -> list[str]:
"""Return list of configuration errors.""" """Return list of configuration errors."""

View File

@@ -5,3 +5,6 @@
| 001 | 00:00~00:20 | Discord 릴레이 미작동 진단 — config.py BRAIN_PATH 빈문자열 버그 수정 | `pending` | ✅ | | 001 | 00:00~00:20 | Discord 릴레이 미작동 진단 — config.py BRAIN_PATH 빈문자열 버그 수정 | `pending` | ✅ |
| 002 | 00:20~01:05 | 크로스 프로젝트 pending DEDUP MERGE 버그 진단 및 수정 (project_name 가드 3곳) | `pending` | ✅ | | 002 | 00:20~01:05 | 크로스 프로젝트 pending DEDUP MERGE 버그 진단 및 수정 (project_name 가드 3곳) | `pending` | ✅ |
| 003 | 09:25~09:33 | Auto-approve 기능 감사 (미구현 확인) + Vikunja 태스크 #304, #305 등록 | `pending` | ✅ | | 003 | 09:25~09:33 | Auto-approve 기능 감사 (미구현 확인) + Vikunja 태스크 #304, #305 등록 | `pending` | ✅ |
| 004 | 10:00~10:35 | P1: `!auto` 토글 자동 승인 구현 (bot.py + extension.ts) | `pending` | ✅ |
| 005 | 10:35~10:45 | P2: BridgeTransport 추상화 (bridge.py 리팩토링 + config/main 모드 설정) | `pending` | ✅ |
| 006 | 10:43~10:55 | 사용 가이드 작성 (docs/usage-guide.md) + tech-stack.md Python 경로 기록 | `pending` | ✅ |

127
docs/usage-guide.md Normal file
View File

@@ -0,0 +1,127 @@
# Gravity Control 사용 가이드
## 시작하기
### 1. 봇 실행
```batch
start_bot.bat
```
또는:
```powershell
C:\ProgramData\miniforge3\envs\gravity_control\python.exe main.py
```
### 2. Extension 설치
```powershell
cd extension
cmd /c npm run compile
cmd /c npx vsce package
# 생성된 .vsix 파일을 VS Code에서 설치
```
---
## Discord 명령어
### 채팅 명령어 (메시지 입력)
| 명령어 | 설명 |
|--------|------|
| `!auto` | 자동 승인 토글 (on/off 반복) |
| `!stop` | AG 에이전트 중단 |
| 그 외 텍스트 | AG에 직접 메시지 전달 |
### 슬래시 명령어
| 명령어 | 설명 |
|--------|------|
| `/auto` | 자동 승인 토글 (on/off 반복) |
| `/stop` | AG 에이전트 중단 |
---
## 자동 승인 (`!auto`)
Discord에서 `!auto` 를 입력할 때마다 on↔off 토글됩니다.
### 동작
- **OFF (기본)**: 승인 요청마다 Discord에 ✅/❌ 버튼 표시 → 클릭하여 수동 승인
- **ON**: 승인 요청 시 자동으로 승인 → Discord에 `🤖 자동 승인됨` 표시
```
사용자: !auto
봇: 🟢 자동 승인 모드
프로젝트: gravity_control
모든 승인 요청이 자동으로 승인됩니다
사용자: !auto
봇: 🔴 수동 승인 모드
프로젝트: gravity_control
모든 승인 요청이 수동 확인이 필요합니다
```
### 자동 승인 시 Discord 표시
```
🤖 자동 승인됨
┌─────────────────────────────┐
│ run_command: npm run build │
└─────────────────────────────┘
auto-approve | 1741678...
```
> **주의**: 봇 재시작 시 auto-approve 상태는 초기화됩니다 (기본 OFF).
---
## 아키텍처
```
[AG IDE] ← Extension → bridge/ ← Bot → Discord
│ │
└── step_probe └── pending 스캔
(WAITING 감지) (자동 승인 처리)
```
### Bridge 프로토콜
```
~/.gemini/antigravity/bridge/
├── pending/ Extension → Bot (승인 요청)
├── response/ Bot → Extension (승인 결과)
├── commands/ Bot → Extension (사용자 명령)
└── register/ Extension → Bot (세션 매핑)
```
### Bot Mode
| 모드 | 설정 | 설명 |
|------|------|------|
| `local` (기본) | `BOT_MODE=local` | 로컬 파일시스템 bridge 사용 |
| `remote` (미래) | `BOT_MODE=remote` | HTTP로 원격 bridge 폴링 (미구현) |
---
## 설정 (.env)
```env
DISCORD_TOKEN=xxx # Discord 봇 토큰 (필수)
DISCORD_GUILD_ID=xxx # Discord 서버 ID (필수)
BRAIN_PATH= # AG 브레인 경로 (기본: ~/.gemini/antigravity/brain)
BOT_MODE=local # 봇 모드 (local/remote)
REMOTE_BRIDGE_URL= # 원격 브릿지 URL (remote 모드 전용)
DEBOUNCE_SECONDS=2 # 이벤트 디바운스 (초)
```
---
## 트러블슈팅
| 증상 | 해결 |
|------|------|
| `!auto` 했는데 자동 승인 안 됨 | Extension VSIX 재빌드 + 재설치 필요 |
| 봇 재시작 후 auto가 꺼져있음 | 정상 — `!auto`로 다시 켜기 |
| Python 못 찾음 | `C:\ProgramData\miniforge3\envs\gravity_control\python.exe` 사용 |

View File

@@ -42,6 +42,7 @@ let bridgePath: string;
let projectName: string; let projectName: string;
let workspaceUri: string = ''; // filesystem path of the workspace folder (for session filtering) let workspaceUri: string = ''; // filesystem path of the workspace folder (for session filtering)
let isActive = false; let isActive = false;
let autoApproveEnabled = false; // toggled via !auto from Discord
let deterministicPort = 0; // derived from projectName, consistent across restarts let deterministicPort = 0; // derived from projectName, consistent across restarts
let watcher: fs.FSWatcher | null = null; let watcher: fs.FSWatcher | null = null;
let commandsWatcher: fs.FSWatcher | null = null; let commandsWatcher: fs.FSWatcher | null = null;
@@ -155,10 +156,17 @@ function processCommandFile(filePath: string) {
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep') vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
.then(() => console.log('Gravity Bridge: ✅ stop sent'), .then(() => console.log('Gravity Bridge: ✅ stop sent'),
() => { }); () => { });
} else if (text.startsWith('!auto ')) { } else if (text.startsWith('!auto')) {
// Auto-approve mode toggle // Auto-approve mode toggle
const mode = text.includes('on') ? 'true' : 'false'; if (text === '!auto on') {
console.log(`Gravity Bridge: auto-approve${mode}`); autoApproveEnabled = true;
} else if (text === '!auto off') {
autoApproveEnabled = false;
} else {
// Toggle if no explicit on/off
autoApproveEnabled = !autoApproveEnabled;
}
logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`);
} else if (text) { } else if (text) {
// Send message to Antigravity — use VS Code command (most reliable) // Send message to Antigravity — use VS Code command (most reliable)
recentDiscordSentTexts.set(text.trim(), Date.now()); recentDiscordSentTexts.set(text.trim(), Date.now());
@@ -1891,6 +1899,11 @@ function setupMonitor() {
lastPendingStepIndex = actualIndex; lastPendingStepIndex = actualIndex;
lastPendingTime = Date.now(); lastPendingTime = Date.now();
sawRunningAfterPending = false; sawRunningAfterPending = false;
// Auto-approve: skip Discord, approve directly
if (autoApproveEnabled) {
logToFile(`[AUTO] auto-approving step=${actualIndex} cmd='${command.substring(0, 60)}'`);
tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, actualIndex);
} else {
writePendingApproval({ writePendingApproval({
conversation_id: activeSessionId, conversation_id: activeSessionId,
command, command,
@@ -1900,6 +1913,7 @@ function setupMonitor() {
source: 'step_probe_offset', source: 'step_probe_offset',
}); });
} }
}
break; break;
} }
} }
@@ -1946,6 +1960,11 @@ function setupMonitor() {
lastPendingStepIndex = si; lastPendingStepIndex = si;
lastPendingTime = Date.now(); lastPendingTime = Date.now();
sawRunningAfterPending = false; sawRunningAfterPending = false;
// Auto-approve: skip Discord, approve directly
if (autoApproveEnabled) {
logToFile(`[AUTO] auto-approving step=${si} cmd='${command.substring(0, 60)}'`);
tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, si);
} else {
writePendingApproval({ writePendingApproval({
conversation_id: activeSessionId, conversation_id: activeSessionId,
command, command,
@@ -1955,6 +1974,7 @@ function setupMonitor() {
source: 'step_probe', source: 'step_probe',
}); });
} }
}
break; break;
} }
} }

16
main.py
View File

@@ -42,6 +42,7 @@ async def main():
logger.info("=" * 50) logger.info("=" * 50)
logger.info(f"Brain path: {Config.BRAIN_PATH}") logger.info(f"Brain path: {Config.BRAIN_PATH}")
logger.info(f"Debounce: {Config.DEBOUNCE_SECONDS}s") logger.info(f"Debounce: {Config.DEBOUNCE_SECONDS}s")
logger.info(f"Bot mode: {Config.BOT_MODE}")
# Shared event queue # Shared event queue
event_queue = asyncio.Queue() event_queue = asyncio.Queue()
@@ -49,10 +50,25 @@ async def main():
# Get the running loop # Get the running loop
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
# Create transport based on BOT_MODE
transport = None # None → LocalTransport (default)
if Config.BOT_MODE == "remote":
from bridge import RemoteTransport
if not Config.REMOTE_BRIDGE_URL:
logger.error("REMOTE_BRIDGE_URL is required for remote mode")
sys.exit(1)
transport = RemoteTransport(Config.REMOTE_BRIDGE_URL)
logger.info(f"Remote transport: {Config.REMOTE_BRIDGE_URL}")
# Create components # Create components
watcher = BrainWatcher(event_queue, loop) watcher = BrainWatcher(event_queue, loop)
bot = GravityBot(event_queue) bot = GravityBot(event_queue)
# Inject transport if specified (otherwise bot uses default LocalTransport)
if transport is not None:
from bridge import BridgeProtocol
bot.bridge = BridgeProtocol(transport)
try: try:
# Start watcher (runs in a separate thread via watchdog) # Start watcher (runs in a separate thread via watchdog)
watcher.start() watcher.start()