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.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
View File

@@ -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
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/
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

View File

@@ -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."""

View File

@@ -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
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 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
View File

@@ -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()