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:
@@ -4,34 +4,43 @@
|
||||
|
||||
## 언어 & 런타임
|
||||
|
||||
| 항목 | 버전 | 비고 |
|
||||
|------|------|------|
|
||||
| (예: Node.js) | (예: 20.x) | (설치 경로 등) |
|
||||
| (예: Python) | (예: 3.12) | (가상환경 경로 등) |
|
||||
| 항목 | 버전 | 경로/비고 |
|
||||
|------|------|-----------|
|
||||
| Python | 3.x (miniforge3) | `C:\ProgramData\miniforge3\envs\gravity_control\python.exe` |
|
||||
| Node.js | 시스템 설치 | `node`, `npm` (PowerShell에서 `cmd /c npm` 권장) |
|
||||
| TypeScript | (Extension) | `extension/src/extension.ts` → `tsc` 빌드 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Python은 **반드시** 위 miniforge3 경로를 사용. WindowsApps의 python stub은 동작하지 않음.
|
||||
|
||||
## 프레임워크
|
||||
|
||||
| 항목 | 버전 | 용도 |
|
||||
|------|------|------|
|
||||
| (예: Express) | (예: 4.18) | (서버) |
|
||||
| (예: React) | (예: 18.x) | (프론트엔드) |
|
||||
| discord.py | 2.x | Discord 봇 |
|
||||
| watchdog | - | 파일시스템 감시 |
|
||||
| antigravity-sdk | 로컬 | VS Code Extension SDK 연동 |
|
||||
|
||||
## 패키지 관리
|
||||
|
||||
- 패키지 매니저: (npm / yarn / pnpm / pip 등)
|
||||
- Lock 파일: (package-lock.json / yarn.lock 등)
|
||||
- **Python**: pip (`requirements.txt`)
|
||||
- **Extension**: npm (`extension/package.json`)
|
||||
|
||||
## 개발 도구
|
||||
|
||||
| 도구 | 명령어 |
|
||||
|------|--------|
|
||||
| 개발 서버 | (예: `cmd /c npm run dev`) |
|
||||
| 빌드 | (예: `cmd /c npm run build`) |
|
||||
| 테스트 | (예: `cmd /c npm test`) |
|
||||
| 린트 | (예: `cmd /c npm run lint`) |
|
||||
| **봇 실행** | `start_bot.bat` 또는 `C:\ProgramData\miniforge3\envs\gravity_control\python.exe main.py` |
|
||||
| **Extension 빌드** | `cd extension && cmd /c npm run compile` |
|
||||
| **Extension VSIX** | `cd extension && cmd /c npx vsce package` |
|
||||
| **봇 구문 검사** | `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
62
bot.py
@@ -179,6 +179,7 @@ class GravityBot(commands.Bot):
|
||||
self.bridge = BridgeProtocol()
|
||||
self.session_category: discord.CategoryChannel | None = None
|
||||
self.guild: discord.Guild | None = None
|
||||
self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled
|
||||
|
||||
@staticmethod
|
||||
def _make_channel_name(project_name: str) -> str:
|
||||
@@ -211,12 +212,18 @@ class GravityBot(commands.Bot):
|
||||
)
|
||||
|
||||
@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)
|
||||
if not project:
|
||||
await interaction.response.send_message("⚠️ 프로젝트 채널이 아닙니다.", ephemeral=True)
|
||||
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)
|
||||
emoji = "🟢" if enabled else "🔴"
|
||||
await interaction.response.send_message(
|
||||
@@ -521,6 +528,35 @@ class GravityBot(commands.Bot):
|
||||
if req.discord_message_id != 0:
|
||||
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)
|
||||
# to give step_probe time to merge detailed command info
|
||||
# (step_probe MERGE happens ~10s after pending creation)
|
||||
@@ -540,11 +576,6 @@ class GravityBot(commands.Bot):
|
||||
# Clean up defer tracking
|
||||
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)
|
||||
if channel:
|
||||
self._sent_approval_ids.add(req.request_id)
|
||||
@@ -732,17 +763,22 @@ class GravityBot(commands.Bot):
|
||||
await message.channel.send(embed=embed)
|
||||
return
|
||||
|
||||
# Special command: !auto on/off
|
||||
if text in ("!auto on", "!auto off"):
|
||||
self.bridge.write_command(project, text, project_name=project)
|
||||
enabled = text == "!auto on"
|
||||
# Special command: !auto — toggle auto-approve
|
||||
if text == "!auto":
|
||||
# Toggle per-project auto-approve
|
||||
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 "🔴"
|
||||
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}`",
|
||||
f"모든 승인 요청이 {'자동으로 승인됩니다' if enabled else '수동 확인이 필요합니다'}",
|
||||
color=discord.Color.green() if enabled else discord.Color.red(),
|
||||
)
|
||||
await message.channel.send(embed=embed)
|
||||
|
||||
235
bridge.py
235
bridge.py
@@ -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/
|
||||
Structure:
|
||||
@@ -12,11 +12,16 @@ Protocol:
|
||||
2. Bot reads pending/ → sends Discord message with ✅/❌ buttons
|
||||
3. User clicks button → Bot writes JSON to response/
|
||||
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 time
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
@@ -59,37 +64,158 @@ class UserResponse:
|
||||
project_name: str = "" # for multi-project: extension uses this when pending file is missing
|
||||
|
||||
|
||||
# ─── Transport Abstraction ───
|
||||
|
||||
class BridgeTransport(ABC):
|
||||
"""Abstract transport for bridge I/O.
|
||||
|
||||
Implementations handle reading/writing JSON files for the bridge protocol,
|
||||
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)
|
||||
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 file-based bridge protocol."""
|
||||
"""Manages the bridge protocol via a pluggable transport."""
|
||||
|
||||
def __init__(self):
|
||||
self.bridge_dir = Config.BRAIN_PATH.parent / "bridge"
|
||||
self.pending_dir = self.bridge_dir / "pending"
|
||||
self.response_dir = self.bridge_dir / "response"
|
||||
self.commands_dir = self.bridge_dir / "commands"
|
||||
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
|
||||
|
||||
# Create directories
|
||||
for d in [self.pending_dir, self.response_dir, self.commands_dir]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
# 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)
|
||||
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):
|
||||
"""Remove pending files older than max_age_seconds on startup."""
|
||||
now = time.time()
|
||||
cleaned = 0
|
||||
for f in self.pending_dir.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
||||
ts = data.get("timestamp", 0)
|
||||
if now - ts > max_age_seconds:
|
||||
f.unlink()
|
||||
cleaned += 1
|
||||
except (json.JSONDecodeError, OSError):
|
||||
f.unlink() # corrupt file, remove
|
||||
for fname in self.transport.list_json_files("pending"):
|
||||
data = self.transport.read_json("pending", fname)
|
||||
if data is None:
|
||||
self.transport.delete_file("pending", fname)
|
||||
cleaned += 1
|
||||
continue
|
||||
ts = data.get("timestamp", 0)
|
||||
if now - ts > max_age_seconds:
|
||||
self.transport.delete_file("pending", fname)
|
||||
cleaned += 1
|
||||
if cleaned:
|
||||
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()}
|
||||
now = time.time()
|
||||
MAX_AGE = 1800 # 30 minutes (matches Discord button timeout)
|
||||
for f in self.pending_dir.glob("*.json"):
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
||||
ts = data.get("timestamp", 0)
|
||||
if now - ts > MAX_AGE:
|
||||
# Too old — mark expired and skip
|
||||
data["status"] = "expired"
|
||||
f.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
continue
|
||||
if data.get("status") == "pending":
|
||||
# Filter to known fields only
|
||||
filtered = {k: v for k, v in data.items() if k in fields}
|
||||
for fname in self.transport.list_json_files("pending"):
|
||||
data = self.transport.read_json("pending", fname)
|
||||
if data is None:
|
||||
continue
|
||||
ts = data.get("timestamp", 0)
|
||||
if now - ts > MAX_AGE:
|
||||
# Too old — mark expired and skip
|
||||
data["status"] = "expired"
|
||||
self.transport.write_json("pending", fname, data)
|
||||
continue
|
||||
if data.get("status") == "pending":
|
||||
# Filter to known fields only
|
||||
filtered = {k: v for k, v in data.items() if k in fields}
|
||||
try:
|
||||
requests.append(ApprovalRequest(**filtered))
|
||||
except (json.JSONDecodeError, TypeError, OSError) as e:
|
||||
logger.warning(f"Bad pending request {f.name}: {e}")
|
||||
except TypeError as e:
|
||||
logger.warning(f"Bad pending request {fname}: {e}")
|
||||
return requests
|
||||
|
||||
def read_pending_request(self, request_id: str) -> ApprovalRequest | None:
|
||||
"""Re-read a specific pending request (to get merged data)."""
|
||||
f = self.pending_dir / f"{request_id}.json"
|
||||
if not f.exists():
|
||||
fname = f"{request_id}.json"
|
||||
data = self.transport.read_json("pending", fname)
|
||||
if data is None:
|
||||
return None
|
||||
fields = {fn.name for fn in ApprovalRequest.__dataclass_fields__.values()}
|
||||
filtered = {k: v for k, v in data.items() if k in fields}
|
||||
try:
|
||||
data = json.loads(f.read_text(encoding="utf-8-sig"))
|
||||
fields = {fn.name for fn in ApprovalRequest.__dataclass_fields__.values()}
|
||||
filtered = {k: v for k, v in data.items() if k in fields}
|
||||
return ApprovalRequest(**filtered)
|
||||
except (json.JSONDecodeError, TypeError, OSError):
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
def write_response(self, response: UserResponse):
|
||||
"""Write a user response to the response directory."""
|
||||
response.timestamp = time.time()
|
||||
filename = f"{response.request_id}.json"
|
||||
filepath = self.response_dir / filename
|
||||
fname = f"{response.request_id}.json"
|
||||
|
||||
filepath.write_text(
|
||||
json.dumps(asdict(response), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
logger.info(f"Response written: {filename} (approved={response.approved})")
|
||||
self.transport.write_json("response", fname, asdict(response))
|
||||
logger.info(f"Response written: {fname} (approved={response.approved})")
|
||||
|
||||
# Delete pending file after processing (prevents re-processing and accumulation)
|
||||
pending_file = self.pending_dir / filename
|
||||
if pending_file.exists():
|
||||
try:
|
||||
pending_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self.transport.delete_file("pending", fname)
|
||||
|
||||
def write_command(self, conversation_id: str, text: str, *, project_name: str = ""):
|
||||
"""Write a user text command for Antigravity to consume."""
|
||||
cmd_id = f"{int(time.time() * 1000)}"
|
||||
filepath = self.commands_dir / f"{cmd_id}.json"
|
||||
fname = f"{cmd_id}.json"
|
||||
|
||||
data = {
|
||||
"id": cmd_id,
|
||||
@@ -167,9 +283,6 @@ class BridgeProtocol:
|
||||
"consumed": False,
|
||||
}
|
||||
|
||||
filepath.write_text(
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.transport.write_json("commands", fname, data)
|
||||
logger.info(f"Command written: {cmd_id} → project={project_name}")
|
||||
return cmd_id
|
||||
|
||||
@@ -40,6 +40,10 @@ class Config:
|
||||
CHANNEL_PREFIX: str = "AG"
|
||||
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
|
||||
def validate(cls) -> list[str]:
|
||||
"""Return list of configuration errors."""
|
||||
|
||||
@@ -5,3 +5,6 @@
|
||||
| 001 | 00:00~00:20 | Discord 릴레이 미작동 진단 — config.py BRAIN_PATH 빈문자열 버그 수정 | `pending` | ✅ |
|
||||
| 002 | 00:20~01:05 | 크로스 프로젝트 pending DEDUP MERGE 버그 진단 및 수정 (project_name 가드 3곳) | `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
127
docs/usage-guide.md
Normal 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` 사용 |
|
||||
@@ -42,6 +42,7 @@ let bridgePath: string;
|
||||
let projectName: string;
|
||||
let workspaceUri: string = ''; // filesystem path of the workspace folder (for session filtering)
|
||||
let isActive = false;
|
||||
let autoApproveEnabled = false; // toggled via !auto from Discord
|
||||
let deterministicPort = 0; // derived from projectName, consistent across restarts
|
||||
let watcher: fs.FSWatcher | null = null;
|
||||
let commandsWatcher: fs.FSWatcher | null = null;
|
||||
@@ -155,10 +156,17 @@ function processCommandFile(filePath: string) {
|
||||
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
|
||||
.then(() => console.log('Gravity Bridge: ✅ stop sent'),
|
||||
() => { });
|
||||
} else if (text.startsWith('!auto ')) {
|
||||
} else if (text.startsWith('!auto')) {
|
||||
// Auto-approve mode toggle
|
||||
const mode = text.includes('on') ? 'true' : 'false';
|
||||
console.log(`Gravity Bridge: auto-approve → ${mode}`);
|
||||
if (text === '!auto on') {
|
||||
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) {
|
||||
// Send message to Antigravity — use VS Code command (most reliable)
|
||||
recentDiscordSentTexts.set(text.trim(), Date.now());
|
||||
@@ -1891,14 +1899,20 @@ function setupMonitor() {
|
||||
lastPendingStepIndex = actualIndex;
|
||||
lastPendingTime = Date.now();
|
||||
sawRunningAfterPending = false;
|
||||
writePendingApproval({
|
||||
conversation_id: activeSessionId,
|
||||
command,
|
||||
description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
|
||||
step_type: ['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,
|
||||
step_index: actualIndex,
|
||||
source: 'step_probe_offset',
|
||||
});
|
||||
// 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({
|
||||
conversation_id: activeSessionId,
|
||||
command,
|
||||
description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
|
||||
step_type: ['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,
|
||||
step_index: actualIndex,
|
||||
source: 'step_probe_offset',
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1946,14 +1960,20 @@ function setupMonitor() {
|
||||
lastPendingStepIndex = si;
|
||||
lastPendingTime = Date.now();
|
||||
sawRunningAfterPending = false;
|
||||
writePendingApproval({
|
||||
conversation_id: activeSessionId,
|
||||
command,
|
||||
description,
|
||||
step_type: ['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,
|
||||
step_index: si,
|
||||
source: 'step_probe',
|
||||
});
|
||||
// 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({
|
||||
conversation_id: activeSessionId,
|
||||
command,
|
||||
description,
|
||||
step_type: ['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,
|
||||
step_index: si,
|
||||
source: 'step_probe',
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
16
main.py
16
main.py
@@ -42,6 +42,7 @@ async def main():
|
||||
logger.info("=" * 50)
|
||||
logger.info(f"Brain path: {Config.BRAIN_PATH}")
|
||||
logger.info(f"Debounce: {Config.DEBOUNCE_SECONDS}s")
|
||||
logger.info(f"Bot mode: {Config.BOT_MODE}")
|
||||
|
||||
# Shared event queue
|
||||
event_queue = asyncio.Queue()
|
||||
@@ -49,10 +50,25 @@ async def main():
|
||||
# Get the 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
|
||||
watcher = BrainWatcher(event_queue, loop)
|
||||
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:
|
||||
# Start watcher (runs in a separate thread via watchdog)
|
||||
watcher.start()
|
||||
|
||||
Reference in New Issue
Block a user