diff --git a/.agents/references/tech-stack.md b/.agents/references/tech-stack.md index af6ce80..f0cd549 100644 --- a/.agents/references/tech-stack.md +++ b/.agents/references/tech-stack.md @@ -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 모드 전용) | diff --git a/bot.py b/bot.py index 29e53f4..9169266 100644 --- a/bot.py +++ b/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) diff --git a/bridge.py b/bridge.py index 84e4cfa..055671c 100644 --- a/bridge.py +++ b/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 diff --git a/config.py b/config.py index 8346f38..185929d 100644 --- a/config.py +++ b/config.py @@ -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.""" diff --git a/docs/devlog/2026-03-11.md b/docs/devlog/2026-03-11.md index 19d2edc..2fc35da 100644 --- a/docs/devlog/2026-03-11.md +++ b/docs/devlog/2026-03-11.md @@ -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` | ✅ | diff --git a/docs/usage-guide.md b/docs/usage-guide.md new file mode 100644 index 0000000..5263763 --- /dev/null +++ b/docs/usage-guide.md @@ -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` 사용 | diff --git a/extension/src/extension.ts b/extension/src/extension.ts index d84cb53..056afc0 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -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; } diff --git a/main.py b/main.py index 7c30e11..d50bbd5 100644 --- a/main.py +++ b/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()