From 5f795b9a9117a5832605f7c1f6098d235e116473 Mon Sep 17 00:00:00 2001 From: Variet Worker Date: Tue, 17 Mar 2026 06:41:42 +0900 Subject: [PATCH] =?UTF-8?q?refactor(extension):=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20+=20Hub=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20#task-395?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extension.ts 3,446→1,289줄 (-63%) - step-probe.ts (1,435줄): setupMonitor, processResponseFile, tryApprovalStrategies - observer-script.ts (687줄): DOM observer script - ws-client.ts (390줄): WSBridgeClient - step-utils.ts (114줄): step 파싱 유틸 - auth.py (115줄): JWT + registration code - hub.py (581줄): WSHub + per-client queue - Hub WS 연동 테스트 통과 (auth, chat, register) - VSIX v0.4.0 빌드 --- .agents/references/architecture.md | 56 +- .agents/references/tech-stack.md | 5 +- auth.py | 127 + bot.py | 322 +- config.py | 4 + docs/devlog/2026-03-17.md | 5 + extension/out/extension.js | 2337 +------------- extension/out/extension.js.map | 2 +- extension/package.json | 12 +- extension/src/extension.ts | 4592 ++++++++-------------------- extension/src/observer-script.ts | 698 +++++ extension/src/step-probe.ts | 1435 +++++++++ extension/src/step-utils.ts | 114 + extension/src/ws-client.ts | 505 +++ gateway.py | 47 +- hub.py | 580 ++++ main.py | 21 +- tests/test_syntax.py | 28 + tests/test_ws_hub.py | 74 + 19 files changed, 5426 insertions(+), 5538 deletions(-) create mode 100644 auth.py create mode 100644 docs/devlog/2026-03-17.md create mode 100644 extension/src/observer-script.ts create mode 100644 extension/src/step-probe.ts create mode 100644 extension/src/step-utils.ts create mode 100644 extension/src/ws-client.ts create mode 100644 hub.py create mode 100644 tests/test_syntax.py create mode 100644 tests/test_ws_hub.py diff --git a/.agents/references/architecture.md b/.agents/references/architecture.md index 7cfb37c..1b5d54c 100644 --- a/.agents/references/architecture.md +++ b/.agents/references/architecture.md @@ -1,35 +1,59 @@ # Architecture -> 이 프로젝트의 아키텍처를 설명하는 문서입니다. > AI 에이전트는 구현 전 이 문서를 반드시 확인합니다. ## 프로젝트 개요 - - -(프로젝트 설명을 여기에 작성하세요) +Antigravity AI 코딩 에이전트의 Discord 연동 시스템. +- AG Extension ↔ WebSocket Hub ↔ Discord Bot (실시간) +- AG Extension ↔ 파일 bridge ↔ Collector ↔ Gateway ↔ Discord Bot (레거시) ## 디렉토리 구조 ``` -project-root/ -├── src/ # 소스 코드 -├── tests/ # 테스트 -├── docs/ # 문서 -├── .agents/ # AI 에이전트 설정 -└── ... +gravity_control/ +├── auth.py # JWT 토큰 관리 +├── hub.py # WebSocket Hub (메시지 라우팅, 인스턴스 관리) +├── bot.py # Discord 봇 (승인 UI, 채널 관리, Hub 핸들러) +├── gateway.py # HTTP REST + /ws endpoint +├── bridge.py # 파일 기반 IPC (레거시) +├── collector.py # 원격 파일→HTTP 릴레이 (Phase 2 삭제 예정) +├── watcher.py # Brain 디렉토리 변경 감시 +├── config.py # 환경변수 설정 +├── main.py # 진입점 (Bot + Hub + Watcher 시작) +├── parser.py # Markdown→Discord 파서 +├── extension/src/ +│ ├── extension.ts # 메인 Extension (3,300줄, 점진적 모듈화 진행) +│ ├── ws-client.ts # WSBridgeClient (Hub 연결, 재연결, 메시지 큐) +│ └── sdk/ # Antigravity SDK (로컬 임베드) +└── .agents/references/ # AI 에이전트 레퍼런스 문서 ``` ## 핵심 모듈 - - | 모듈 | 역할 | 의존성 | |------|------|--------| -| (모듈명) | (역할 설명) | (의존하는 모듈) | +| hub.py | WS 연결 관리, 메시지 라우팅, 인스턴스 번호 | auth.py | +| auth.py | JWT 토큰 생성/검증, registration code | - | +| bot.py | Discord UI, 승인 처리, 채팅 릴레이 | hub.py, bridge.py, parser.py | +| gateway.py | HTTP REST API + /ws endpoint | hub.py | +| ws-client.ts | Extension→Hub WS 클라이언트 | - | +| extension.ts | AG SDK 연동, step 감시, DOM observer | ws-client.ts, sdk/ | ## 데이터 흐름 - - -(데이터 흐름을 여기에 작성하세요) +``` + [Extension] + │ + ┌────┴────┐ + │ WS Hub │ ← 실시간 (preferred) + │ (ws-client.ts → hub.py) + └────┬────┘ + │ ┌─────────────┐ + ├───────────────────→│ Discord Bot │→ Discord + │ └─────────────┘ + ┌────┴────┐ + │파일 bridge│ ← 레거시 fallback + │(Collector → Gateway) + └─────────┘ +``` diff --git a/.agents/references/tech-stack.md b/.agents/references/tech-stack.md index f0cd549..42202ed 100644 --- a/.agents/references/tech-stack.md +++ b/.agents/references/tech-stack.md @@ -42,5 +42,8 @@ | DISCORD_TOKEN | Discord 봇 토큰 | (필수) | | DISCORD_GUILD_ID | Discord 서버 ID | (필수) | | BRAIN_PATH | AG 브레인 경로 | `~/.gemini/antigravity/brain` | -| BOT_MODE | 봇 모드 (local/remote) | `local` | +| BOT_MODE | 봇 모드 (local/remote/gateway) | `local` | | REMOTE_BRIDGE_URL | 원격 브릿지 URL | (remote 모드 전용) | +| GATEWAY_API_KEY | Gateway REST API 인증 키 | (gateway 모드) | +| GRAVITY_HUB_SECRET | WS Hub JWT 서명 시크릿 | (자동생성 가능) | +| GRAVITY_REGISTRATION_CODE | Extension 등록 코드 | (미설정 시 인증 생략) | diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..c9b86a6 --- /dev/null +++ b/auth.py @@ -0,0 +1,127 @@ +"""Authentication module — JWT token management for WebSocket Hub. + +Two-stage auth: +1. Extension connects with a registration code (built into .vsix at build time) +2. Hub validates the code and issues a short-lived JWT session token +3. Subsequent reconnections use the JWT token directly + +The master secret is stored server-side only (GRAVITY_HUB_SECRET env var). +""" + +import hashlib +import hmac +import json +import logging +import os +import time +from base64 import urlsafe_b64decode, urlsafe_b64encode + +logger = logging.getLogger(__name__) + +# Defaults +DEFAULT_TOKEN_TTL = 86400 # 24 hours +DEFAULT_REGISTRATION_CODE = "" # Set via GRAVITY_REGISTRATION_CODE env var + + +class TokenManager: + """Manages JWT-like token creation and verification. + + Uses HMAC-SHA256 for signing. Tokens contain: + - project: project name scope + - pc: PC identifier (hostname or custom name) + - iat: issued at (unix timestamp) + - exp: expiration (unix timestamp) + """ + + def __init__(self, secret: str = "", registration_code: str = ""): + self.secret = secret or os.getenv("GRAVITY_HUB_SECRET", "") + self.registration_code = registration_code or os.getenv( + "GRAVITY_REGISTRATION_CODE", DEFAULT_REGISTRATION_CODE + ) + if not self.secret: + # Auto-generate a secret if not set (ephemeral — tokens invalid after restart) + self.secret = hashlib.sha256(os.urandom(32)).hexdigest() + logger.warning( + "[AUTH] No GRAVITY_HUB_SECRET set — generated ephemeral secret. " + "Tokens will be invalid after server restart." + ) + + def validate_registration_code(self, code: str) -> bool: + """Check if the provided registration code matches.""" + if not self.registration_code: + # No registration code configured → allow all connections + logger.warning("[AUTH] No registration code configured — accepting all") + return True + return hmac.compare_digest(code, self.registration_code) + + def create_token( + self, project: str, pc_name: str, ttl: int = DEFAULT_TOKEN_TTL + ) -> str: + """Create a signed token for a specific project and PC. + + Returns a base64-encoded string: {header}.{payload}.{signature} + """ + now = int(time.time()) + payload = { + "project": project, + "pc": pc_name, + "iat": now, + "exp": now + ttl, + } + payload_b64 = _b64_encode(json.dumps(payload)) + signature = self._sign(payload_b64) + return f"{payload_b64}.{signature}" + + def verify_token(self, token: str) -> dict | None: + """Verify and decode a token. + + Returns the payload dict if valid, None if invalid or expired. + """ + try: + parts = token.split(".") + if len(parts) != 2: + return None + + payload_b64, signature = parts + expected_sig = self._sign(payload_b64) + + if not hmac.compare_digest(signature, expected_sig): + logger.warning("[AUTH] Invalid token signature") + return None + + payload = json.loads(_b64_decode(payload_b64)) + + # Check expiration + if payload.get("exp", 0) < time.time(): + logger.info(f"[AUTH] Token expired for {payload.get('pc', '?')}") + return None + + return payload + except (json.JSONDecodeError, ValueError, KeyError) as e: + logger.warning(f"[AUTH] Token decode error: {e}") + return None + + def _sign(self, data: str) -> str: + """HMAC-SHA256 sign and return base64.""" + sig = hmac.new( + self.secret.encode("utf-8"), + data.encode("utf-8"), + hashlib.sha256, + ).digest() + return _b64_encode_bytes(sig) + + +def _b64_encode(data: str) -> str: + """URL-safe base64 encode a string, no padding.""" + return urlsafe_b64encode(data.encode("utf-8")).rstrip(b"=").decode("ascii") + + +def _b64_encode_bytes(data: bytes) -> str: + """URL-safe base64 encode bytes, no padding.""" + return urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64_decode(data: str) -> str: + """URL-safe base64 decode, handles missing padding.""" + padded = data + "=" * (4 - len(data) % 4) + return urlsafe_b64decode(padded).decode("utf-8") diff --git a/bot.py b/bot.py index 4b07896..8bb5458 100644 --- a/bot.py +++ b/bot.py @@ -5,9 +5,15 @@ Multi-project channel architecture: - Each conversation maps to a project via conv_to_project dict - Extension registers projects via bridge/pending/ files - Commands include project_name for routing to correct IDE window + +Multi-PC UX: +- When multiple AG instances are active, messages get instance numbers (PC #1, #2) +- Users can target specific instances with !N (e.g. !2 hello) +- When only one instance is active, natural conversation without numbers """ import asyncio +import re import json import logging import time @@ -184,17 +190,41 @@ class GravityBot(commands.Bot): self._processed_message_ids: deque[int] = deque(maxlen=200) # dedup for Gateway event replay self._approval_messages: dict[str, int] = {} # FIX #4: request_id → discord message_id (for auto_resolved lookup) self.gateway = None # Set by main.py in gateway mode + self.hub = None # Set by main.py in gateway mode (WSHub instance) - def _write_command(self, project: str, text: str, **kwargs): - """Write command to bridge AND push to gateway (if gateway mode).""" + def _write_command(self, project: str, text: str, *, + target_instance: int | None = None, **kwargs): + """Write command to bridge AND push to gateway/hub. + + Args: + target_instance: If set, send only to this instance number (via Hub). + If None, broadcast to all instances. + """ + cmd_data = { + "text": text, + "project_name": kwargs.get('project_name', project), + } + + # Hub route (preferred if available) + if self.hub: + import time as _time + cmd_data["id"] = str(int(_time.time() * 1000)) + msg = {"type": "command", "data": cmd_data} + if target_instance is not None: + asyncio.create_task( + self.hub.send_to_instance(project, target_instance, msg) + ) + else: + asyncio.create_task( + self.hub.broadcast_to_project(project, msg) + ) + + # Legacy routes (file bridge + gateway HTTP) self.bridge.write_command(project, text, **kwargs) if self.gateway: - import time - self.gateway.push_command(project, { - "id": str(int(time.time() * 1000)), - "text": text, - "project_name": kwargs.get('project_name', project), - }) + import time as _time + cmd_data["id"] = cmd_data.get("id", str(int(_time.time() * 1000))) + self.gateway.push_command(project, cmd_data) @staticmethod def _make_channel_name(project_name: str) -> str: @@ -206,6 +236,8 @@ class GravityBot(commands.Bot): self.pending_approval_scanner.start() self.chat_snapshot_scanner.start() self._register_slash_commands() + # Register Hub handlers (if Hub is available, set after setup_hook by main.py) + asyncio.get_event_loop().call_soon(self._register_hub_handlers) logger.info("Bot setup complete") def _register_slash_commands(self): @@ -826,7 +858,33 @@ class GravityBot(commands.Bot): logger.info(f"Sent approval request: {request.request_id[:12]}") self._approval_messages[request.request_id] = msg.id # FIX #4: Track msg_id for auto_resolved lookup - # ─── Discord → IDE Text Relay ───────────────────────────────────── + # ─── Discord → IDE Text Relay + Multi-PC UX ─────────────────────────── + + def _get_instance_header(self, project: str, instance_number: int) -> str: + """Format instance header based on active count. + + Single instance: empty string (natural conversation) + Multiple instances: **[PC #N]** prefix + """ + if not self.hub: + return "" + active = self.hub.get_active_count(project) + if active <= 1: + return "" + return f"**[PC #{instance_number}]** " + + def _parse_instance_target(self, text: str) -> tuple[int | None, str]: + """Parse !N prefix from message text. + + Returns (target_instance, remaining_text). + '!2 hello' -> (2, 'hello') + 'hello' -> (None, 'hello') + '!stop' -> (None, '!stop') # special commands not treated as targeting + """ + match = re.match(r'^!(\d+)\s+(.+)', text, re.DOTALL) + if match: + return int(match.group(1)), match.group(2).strip() + return None, text async def on_message(self, message: discord.Message): if message.author == self.user: @@ -845,19 +903,24 @@ class GravityBot(commands.Bot): text = message.content.strip() + # Parse !N instance targeting (before special commands) + target_instance, actual_text = self._parse_instance_target(text) + # Special command: !stop — cancel AI work - if text == "!stop": - self._write_command(project, "!stop", project_name=project) + if actual_text == "!stop": + self._write_command(project, "!stop", target_instance=target_instance, + project_name=project) + target_label = f" (PC #{target_instance})" if target_instance else "" embed = discord.Embed( title="⏹️ AI 작업 중지", - description=f"프로젝트: **{project}**\n중지 요청을 Extension에 전달했습니다.", + description=f"프로젝트: **{project}**{target_label}\n중지 요청을 Extension에 전달했습니다.", color=discord.Color.orange(), ) await message.channel.send(embed=embed) return # Special command: !auto — toggle auto-approve - if text == "!auto": + if actual_text == "!auto": # Toggle per-project auto-approve if project in self.auto_approve_projects: self.auto_approve_projects.discard(project) @@ -865,7 +928,8 @@ class GravityBot(commands.Bot): else: self.auto_approve_projects.add(project) enabled = True - self._write_command(project, f"!auto {'on' if enabled else 'off'}", project_name=project) + self._write_command(project, f"!auto {'on' if enabled else 'off'}", + target_instance=target_instance, project_name=project) emoji = "🟢" if enabled else "🔴" mode = "자동 승인" if enabled else "수동 승인" embed = discord.Embed( @@ -877,18 +941,240 @@ class GravityBot(commands.Bot): await message.channel.send(embed=embed) return - # General text relay — routed by project - if text: - self._write_command(project, text, project_name=project) + # General text relay — routed by project (+ optional instance targeting) + if actual_text: + self._write_command(project, actual_text, target_instance=target_instance, + project_name=project) await message.add_reaction("📨") + target_label = f" PC #{target_instance}" if target_instance else "" embed = discord.Embed( - description=f"📨 → **{project}** IDE에 전달됨\n`{text[:100]}`", + description=f"📨 → **{project}**{target_label} IDE에 전달됨\n`{actual_text[:100]}`", color=discord.Color.blurple(), ) await message.channel.send(embed=embed, delete_after=10) await self.process_commands(message) + # ─── Hub Event Handlers ────────────────────────────────────────── + + def _register_hub_handlers(self): + """Register callbacks on the Hub for Extension->Bot messages.""" + if not self.hub: + return + self.hub.set_bot_handlers( + on_pending=self._hub_on_pending, + on_chat=self._hub_on_chat, + on_register=self._hub_on_register, + on_auto_resolve=self._hub_on_auto_resolve, + on_brain_event=self._hub_on_brain_event, + ) + logger.info("[BOT] Hub handlers registered") + + async def _hub_on_pending(self, project: str, data: dict): + """Handle pending approval from Hub (Extension->Hub->Bot).""" + try: + request_id = data.get("request_id", "") + if not request_id: + return + + # Skip if already sent + if request_id in self._sent_approval_ids: + return + + # Check auto_resolved status + status = data.get("status", "pending") + if status in ("auto_resolved", "expired"): + await self._handle_auto_resolved(request_id, status) + return + + instance_number = data.get("_instance_number", 0) + pc_name = data.get("_pc_name", "") + header = self._get_instance_header(project, instance_number) + + # Build approval request + request = ApprovalRequest( + request_id=request_id, + command=data.get("command", ""), + description=data.get("description", ""), + project_name=project, + step_type=data.get("step_type", ""), + status=status, + ) + + # Auto-approve check + if project in self.auto_approve_projects: + await self._auto_approve_via_hub(request) + return + + # Send to Discord + channel = await self._get_channel(project) + if not channel: + logger.warning(f"[HUB-PENDING] No channel for project={project}") + return + + buttons = data.get("buttons", []) + desc_parts = [] + if header: + desc_parts.append(header) + desc_parts.append(f"**명령:** `{request.command[:200]}`") + if buttons: + btn_names = [b.get("text", "?") for b in buttons] + desc_parts.append(f"**선택지:** {' / '.join(btn_names)}") + if request.description: + desc_parts.append(request.description[:500]) + + embed = discord.Embed( + title="⚠️ 승인 요청", + description="\n".join(desc_parts), + color=discord.Color.orange(), + timestamp=datetime.now(timezone.utc), + ) + embed.set_footer(text=f"ID: {request_id}") + + view = ApprovalView(self.bridge, request, buttons=buttons) + msg = await channel.send(embed=embed, view=view) + + self._sent_approval_ids.add(request_id) + self._approval_messages[request_id] = msg.id + logger.info(f"[HUB-PENDING] Sent approval: {request_id[:12]} project={project}") + + except Exception as e: + logger.error(f"[HUB-PENDING] Error: {e}") + + async def _auto_approve_via_hub(self, request: ApprovalRequest): + """Auto-approve a pending request via Hub.""" + if self.hub: + await self.hub.send_response_to_pending_owner(request.request_id, { + "type": "response", + "data": { + "request_id": request.request_id, + "approved": True, + "button_index": 0, + "step_type": request.step_type, + "project_name": request.project_name, + }, + }) + # Also write via legacy bridge + self.bridge.write_response(UserResponse( + request_id=request.request_id, approved=True, + step_type=request.step_type, + project_name=request.project_name, + )) + logger.info(f"[HUB-AUTO] Auto-approved: {request.request_id[:12]}") + + async def _hub_on_chat(self, project: str, data: dict): + """Handle chat snapshot from Hub (Extension->Hub->Bot->Discord).""" + try: + content = data.get("content", "") + attached_files = data.get("attached_files", []) + if not content and not attached_files: + return + + instance_number = data.get("_instance_number", 0) + header = self._get_instance_header(project, instance_number) + + channel = await self._get_channel(project) + if not channel: + return + + import io as _io + discord_files = [] + for af in attached_files: + af_name = af.get("name", "document.md") + af_content = af.get("content", "") + if af_content: + discord_files.append(discord.File( + _io.BytesIO(af_content.encode("utf-8")), + filename=af_name, + )) + + display_content = f"{header}{content}" if header else content + + FILE_ATTACH_THRESHOLD = 4000 + if len(display_content) > FILE_ATTACH_THRESHOLD: + summary = display_content[:500].rsplit('\n', 1)[0] + embed = discord.Embed( + title="💬 AI 대화 내용", + description=f"{summary}\n\n📎 *전체 내용은 첨부 파일 참조* ({len(content):,}자)", + color=discord.Color.purple(), + timestamp=datetime.now(timezone.utc), + ) + discord_files.append(discord.File( + _io.BytesIO(content.encode("utf-8")), + filename="chat_message.md", + )) + await channel.send(embed=embed, files=discord_files) + else: + embed = discord.Embed( + title="💬 AI 대화 내용", + description=display_content, + color=discord.Color.purple(), + timestamp=datetime.now(timezone.utc), + ) + await channel.send( + embed=embed, + files=discord_files if discord_files else discord.utils.MISSING, + ) + + logger.info(f"[HUB-CHAT] Sent to #{channel.name} ({len(content)} chars)") + except Exception as e: + logger.error(f"[HUB-CHAT] Error: {e}") + + async def _hub_on_register(self, data: dict): + """Handle session registration from Hub.""" + conv_id = data.get("conversation_id", "") + project = data.get("project_name", "") + if conv_id and project: + self.conv_to_project[conv_id] = project + logger.info(f"[HUB-REG] {conv_id[:8]} → {project}") + + async def _hub_on_auto_resolve(self, project: str, data: dict): + """Handle auto_resolve notification from Hub.""" + request_id = data.get("request_id", "") + if request_id: + await self._handle_auto_resolved(request_id, "auto_resolved") + + async def _hub_on_brain_event(self, project: str, data: dict): + """Handle brain event from Hub (Extension->Hub->Bot->Discord).""" + try: + from watcher import BrainEvent, EventType + event = BrainEvent( + event_type=EventType(data.get("event_type", "file_changed")), + conversation_id=data.get("conversation_id", ""), + file_name=data.get("file_name", ""), + file_path=None, + content=data.get("content", ""), + timestamp=data.get("timestamp", time.time()), + ) + await self.event_queue.put(event) + except Exception as e: + logger.error(f"[HUB-EVENT] Error: {e}") + + async def _handle_auto_resolved(self, request_id: str, status: str): + """Edit Discord message to show auto-resolved/expired status.""" + msg_id = self._approval_messages.get(request_id) + if not msg_id: + return + # Find the channel containing this message + for channel in self.project_channels.values(): + try: + msg = await channel.fetch_message(msg_id) + embed = msg.embeds[0] if msg.embeds else None + if embed: + if status == "auto_resolved": + embed.color = discord.Color.green() + embed.set_footer(text="✅ 자동 해결됨") + else: + embed.color = discord.Color.greyple() + embed.set_footer(text="⏰ 만료됨") + await msg.edit(embed=embed, view=None) + self._approval_messages.pop(request_id, None) + break + except (discord.NotFound, discord.Forbidden): + continue + except Exception: + break + # ─── Chat Snapshot Scanner ───────────────────────────────────────── @tasks.loop(seconds=5) diff --git a/config.py b/config.py index 2d3c607..b966711 100644 --- a/config.py +++ b/config.py @@ -48,6 +48,10 @@ class Config: REMOTE_BRIDGE_URL: str = os.getenv("REMOTE_BRIDGE_URL", "") GATEWAY_API_KEY: str = os.getenv("GATEWAY_API_KEY", "") + # WebSocket Hub + GRAVITY_HUB_SECRET: str = os.getenv("GRAVITY_HUB_SECRET", "") # JWT signing secret + GRAVITY_REGISTRATION_CODE: str = os.getenv("GRAVITY_REGISTRATION_CODE", "") # Extension auth + @classmethod def validate(cls) -> list[str]: """Return list of configuration errors.""" diff --git a/docs/devlog/2026-03-17.md b/docs/devlog/2026-03-17.md new file mode 100644 index 0000000..4d364f2 --- /dev/null +++ b/docs/devlog/2026-03-17.md @@ -0,0 +1,5 @@ +# Devlog — 2026-03-17 + +| # | 시간 | 작업 | 커밋 | 상태 | +|---|------|------|------|------| +| 009 | 00:00~06:38 | Extension 모듈 분리 + Hub 통합 테스트 + VSIX v0.4.0 빌드 | `TBD` | ✅ | diff --git a/extension/out/extension.js b/extension/out/extension.js index 455bc86..eae24b4 100644 --- a/extension/out/extension.js +++ b/extension/out/extension.js @@ -52,6 +52,9 @@ const path = __importStar(require("path")); const os = __importStar(require("os")); const cp = __importStar(require("child_process")); const crypto = __importStar(require("crypto")); +const ws_client_1 = require("./ws-client"); +const observer_script_1 = require("./observer-script"); +const step_probe_1 = require("./step-probe"); // ─── File-based logging (AI can read directly) ─── function logToFile(msg) { const ts = new Date().toISOString().replace('T', ' ').substring(0, 19); @@ -81,6 +84,7 @@ let autoApproveEnabled = false; // toggled via !auto from Discord let deterministicPort = 0; // derived from projectName, consistent across restarts let watcher = null; let commandsWatcher = null; +let wsBridge = null; // WebSocket Hub connection const sentPendingIds = new Set(); // Memory-based dedup: tracks recently created pending step_indexes to prevent // regeneration after pending file deletion (by Collector/Bot response cycle). @@ -131,7 +135,16 @@ let activeTrajectoryId = ''; const recentDiscordSentTexts = new Map(); function writeChatSnapshot(text) { try { - // Write to chat_snapshots/*.json for Bot's chat_snapshot_scanner to pick up + // WS route (preferred) + if (wsBridge && wsBridge.isConnected()) { + wsBridge.sendChat({ + content: text, + conversation_id: activeSessionId, + project_name: projectName, + }); + logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`); + } + // File route (fallback / Phase 0 dual-write) const snapshotDir = path.join(bridgePath, 'chat_snapshots'); if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); @@ -150,7 +163,7 @@ function writeChatSnapshot(text) { logToFile(`[SNAPSHOT] content: ${text.substring(0, 200)}`); // Lazily register session → project mapping (correct because projectName is per-window) if (activeSessionId) { - writeRegistration(activeSessionId); + (0, step_probe_1.writeRegistration)(activeSessionId); } } catch (e) { @@ -159,6 +172,17 @@ function writeChatSnapshot(text) { } function writeChatSnapshotWithFiles(text, files) { try { + // WS route (preferred) + if (wsBridge && wsBridge.isConnected()) { + wsBridge.sendChat({ + content: text, + attached_files: files, + conversation_id: activeSessionId, + project_name: projectName, + }); + logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`); + } + // File route (fallback) const snapshotDir = path.join(bridgePath, 'chat_snapshots'); if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); @@ -175,7 +199,7 @@ function writeChatSnapshotWithFiles(text, files) { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars, ${files.length} files)`); if (activeSessionId) { - writeRegistration(activeSessionId); + (0, step_probe_1.writeRegistration)(activeSessionId); } } catch (e) { @@ -466,7 +490,7 @@ async function setupApprovalObserver() { } catch { /* already registered */ } // 3. Write renderer script with HTTP fetch() approach - const observerJS = generateApprovalObserverScript(bridgePort); + const observerJS = (0, observer_script_1.generateApprovalObserverScript)(bridgePort); const patcher = integration._patcher; if (patcher && typeof patcher.getScriptPath === 'function') { let baseScript = ''; @@ -813,6 +837,19 @@ function startObserverHttpBridge() { pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`; } fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2)); + // WS dual-write + if (wsBridge && wsBridge.isConnected()) { + wsBridge.sendPending({ + request_id: rid, + command: pending.command || data.command || '', + description: pending.description || data.description || '', + step_type: pending.step_type, + status: 'pending', + buttons: pending.buttons, + project_name: projectName, + }); + logToFile(`[HTTP-WS] pending sent via WS: ${rid}`); + } logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, request_id: rid })); @@ -992,2184 +1029,7 @@ function startObserverHttpBridge() { } }); } -// ─── Renderer Script (uses fetch() — no Node.js APIs) ─── -function generateApprovalObserverScript(_port) { - // Port is hardcoded as fallback, but renderer also reads ag-bridge-ports.json for multi-bridge - return ` -// ── Gravity Bridge v3: Approval Observer (deep DOM traversal — iframes, webviews, shadow DOMs) ── -(function(){ - 'use strict'; - var BASE='',_obs=false,_sent={},_ready=false; - var _scanScheduled=false,_lastScanTs=0; - var THROTTLE_MS=100; - var CLEANUP_MS=300000; - var _domDumped=false; - - function log(m){console.log('[GB Observer] '+m);} - log('v3 Script loaded — deep DOM traversal enabled'); - - // ── Deep DOM Traversal: find buttons across ALL boundaries ── - // Searches: main document → iframes (contentDocument) → webview elements → shadow DOMs - function deepFindButtons(patterns){ - var results=[]; - // 1. Main document buttons - collectButtons(document,results,patterns,'main'); - // 2. Iframe traversal (try contentDocument — works if same-origin or webSecurity off) - var iframes=document.querySelectorAll('iframe'); - for(var i=0;i tag — has executeJavaScript) - var webviews=document.querySelectorAll('webview'); - for(var w=0;wshadow'); - } - }catch(e){} - } - - // ── Deep DOM Inspector (recursive, POSTs results to bridge) ── - function runDeepInspect(){ - var result={timestamp:new Date().toISOString(),windowURL:window.location.href,windowOrigin:window.location.origin,windowProtocol:window.location.protocol,framesCount:window.frames.length,nodes:[]}; - log('DEEP-INSPECT: starting recursive DOM analysis...'); - - function inspectDoc(doc,depth,label){ - var node={label:label,depth:depth,accessible:true,url:'',buttons:[],roleBtns:[],iframes:[],webviews:[],shadowDOMs:0,totalElements:0}; - if(!doc){node.accessible=false;node.error='null document';result.nodes.push(node);return;} - try{node.url=(doc.URL||doc.documentURI||'unknown').substring(0,200);}catch(e){node.url='blocked';} - try{node.title=(doc.title||'').substring(0,100);}catch(e){} - try{node.readyState=doc.readyState;}catch(e){} - - // CSP - try{ - var csp=doc.querySelectorAll('meta[http-equiv="Content-Security-Policy"]'); - if(csp.length>0){node.csp=[];for(var c=0;c