Compare commits

...

90 Commits

Author SHA1 Message Date
Variet Worker
32cf69469c docs: session end — known-issues 2건 추가 + observer-dev-guide 3.3 업데이트 + devlog #019
[2026-04-19] Accept all span 렌더링 감지 실패 (v0.5.101)
[2026-04-19] auto-approve _from_ws 마커 누락 (v0.5.103)
observer-dev-guide 3.3: Accept all Observer 접근 가능으로 변경
Vikunja: #638 done, #639 done
2026-04-20 04:58:13 +09:00
Variet Worker
7c8891b99c fix(bridge): v38 auto-approve response에 _from_ws 마커 추가 — Observer polling 실패 수정 (v0.5.103)
근본 원인: auto-approve response 파일에 _from_ws 마커 없음.
processResponseFile(response watcher)이 Observer보다 먼저 파일을 읽고 삭제.
Observer의 GET /response/{rid} polling이 항상 {waiting:true} 반환.
known-issues [2026-04-18] WS response 파일 삭제 버그와 동일 패턴.
2026-04-20 04:43:56 +09:00
Variet Worker
3cc3442fda fix(bridge): v37 openReviewChanges 선호출 — agentAcceptAllInFile 실효성 보장 (v0.5.102)
agentAcceptAllInFile은 diff review 패널이 포커스되어야 동작.
v34에서 직접 호출만 했더니 SUCCESS 반환하지만 실제 효과 없음.
openReviewChanges → 500ms 대기 → agentAcceptAllInFile 순서로 수정.
2026-04-20 04:34:44 +09:00
Variet Worker
e95e7791f9 fix(observer): v36 Accept all 감지 — span.cursor-pointer 선택자 추가 (v0.5.101)
근본 원인: AG Native에서 Accept all 버튼이 <button>이 아닌 <span> 태그.
Observer의 allBtns 선택자가 button만 스캔하여 Accept all 미감지.
ACCEPT-SCAN 로그에서 tag=SPAN cls=cursor-pointer 확인.
span.cursor-pointer 추가로 diff review 버튼 감지 복구.
2026-04-20 04:26:58 +09:00
Variet Worker
2bf1eb41d1 feat(probe): v35 code_edit 자동 Accept — agentAcceptAllInFile 직접 호출 (v0.5.100)
Accept all 버튼이 Observer DOM에 없음 확인 (에디터 레이어).
step_probe에서 WAITING code_edit 감지 시 500ms 후 agentAcceptAllInFile 직접 실행.
Observer relay 필터에 ACCEPT 추가.
2026-04-20 04:05:47 +09:00
Variet Worker
cf1352eefa fix(bridge): v34 Accept all 이중 보장 — agentAcceptAllInFile 직접 호출 + ACCEPT 로그 relay (v0.5.99)
v34: Accept all/diff_review 감지 시 Observer DOM 클릭 + extension host agentAcceptAllInFile 이중 실행.
Observer relay 필터에 ACCEPT 키워드 추가로 ACCEPT-SCAN 진단 로그 활성화.
2026-04-20 00:11:07 +09:00
Variet Worker
6aea48e2e9 feat(bridge): v33 Accept all 자동승인 — diff review 버튼도 auto-approve (v0.5.98)
Always run과 동일하게 Accept all / Accept 버튼도 즉시 자동 승인.
Observer가 step_type=diff_review로 전송 → bridge에서 AUTO_APPROVE_RE 매칭 → 즉시 응답.
2026-04-19 21:27:18 +09:00
Variet Worker
bd5a7ca8b9 fix(observer): v32 터미널 프롬프트 조기감지 — JUNK/PROMPT 필터 전에 명령어 추출 (v0.5.97)
근본원인: code 요소의 textContent '❯ project > command'가 PowerShell 키워드
(return, function, const)를 포함할 수 있어 JUNK_CODE_RE에 잘못 걸림.
v32: ❯ 마커로 시작하는 code 텍스트는 JUNK/PROMPT 전에 명령어 직접 추출.
14/14 E2E 테스트 통과.
2026-04-19 19:55:44 +09:00
Variet Worker
8ada5f7daf fix(observer): v31 — content_copy 아이콘 필터 + 후보 길이순 정렬 + trailing icon strip (v0.5.96) 2026-04-19 15:18:11 +09:00
Variet Worker
4f2be831a1 diag(observer): v30 SCAN 진단 로그 — div textContent 덤프 (v0.5.95) 2026-04-19 15:08:56 +09:00
Variet Worker
cbfd137dcb fix(observer): v30 Running command 추출 — includes 매칭 + raw fallback + 디버그 로그 (v0.5.94) 2026-04-19 15:03:09 +09:00
Variet Worker
a99a1e3f54 docs: 배포 전 체크리스트에 log relay 필터/regex E2E/가정 검증 항목 추가 2026-04-19 14:56:40 +09:00
Variet Worker
ad4ed623bd docs: observer-dev-guide DOM 구조 BTN-DOM-DUMP 기반 갱신 + log relay 필터 규칙 추가 2026-04-19 14:48:42 +09:00
Variet Worker
64800d3c20 fix(observer): 'Running command' div에서 명령어 추출 — pre/code 대신 plain div 탐색 (v0.5.93) 2026-04-19 14:40:05 +09:00
Variet Worker
70c83b4226 fix(observer): log 필터에 CONTEXT/DEFERRED/DETECTED/BTN-DOM 추가 — 진단 로그 relay 누락 수정 (v0.5.92) 2026-04-19 14:10:59 +09:00
Variet Worker
bb54802c06 feat(enrichment): Discord 알림 지연 + Step Probe 폴링 — generic Always run 커맨드 100% 보강 (v0.5.91) 2026-04-19 10:25:55 +09:00
Variet Worker
bf53072f3c feat(enrichment): Step Probe API 메모리 기반 명령어 보강 — Always run 표시 개선 (v0.5.90) 2026-04-19 09:59:20 +09:00
Variet Worker
02b4b03699 diag(observer): Always run 버튼 주변 DOM 구조 원샷 덤프 추가 (v0.5.89) 2026-04-19 09:19:31 +09:00
Variet Worker
db805c6fde fix(observer): matchedType 대소문자 무시 — Always run이 permission으로 잘못 분류되는 문제 수정 (v0.5.88) 2026-04-19 08:01:47 +09:00
Variet Worker
7f33a20e43 docs: 배포 전 자기검증 체크리스트 추가 — 재시작 요구 최소화 정책 2026-04-19 07:58:14 +09:00
Variet Worker
ef788e6ecc docs: Observer 개발 가이드 SSOT 문서 생성 — 제약사항/배포/DOM구조/교훈 종합 2026-04-19 07:52:53 +09:00
Variet Worker
cd00986274 fix(observer): 깨진 문자열 리터럴 2건 수정 — walkNode 크래시 해결 (v0.5.87) 2026-04-19 07:51:29 +09:00
Variet Worker
12095f36a4 fix(observer): function declaration → var expression — strict mode 크래시 수정 (v0.5.86) 2026-04-19 07:41:04 +09:00
Variet Worker
498683c977 fix(approval): _from_ws 파일 60초 TTL 자동 삭제 — stale SKIP 스팸 방지 (v0.5.85) 2026-04-19 07:32:49 +09:00
Variet Worker
1662ac4f6b fix(observer): regex → 문자열 비교로 isGenericDesc 수정 — template literal escaping 회피 (v0.5.84) 2026-04-19 07:02:04 +09:00
Variet Worker
d027562f17 fix(observer): 500ms 딜레이드 컨텍스트 추출 + 버튼 셀렉터 확장 (v0.5.83)
- Always run 감지 시 desc가 generic이면 500ms 딜레이 후 재추출
- 버튼 셀렉터에 role=button, monaco-button, vscode-button 추가
- ACCEPT-SCAN 디버그 로그 (30초 간격)
2026-04-19 06:53:54 +09:00
Variet Worker
cc261011d6 fix(observer): 구 visibility 체크 제거 — Accept all 버튼 감지 차단 수정 (v0.5.81) 2026-04-19 04:18:35 +09:00
Variet Worker
37fbb9657e fix(observer): diff_review Accept all 버튼 감지 — offsetParent 체크 완화 (v0.5.80) 2026-04-19 03:53:03 +09:00
Variet Worker
965f619664 docs: relay-architecture + known-issues 업데이트 (v0.5.78-79 변경사항 반영) 2026-04-19 03:48:31 +09:00
Variet Worker
139ad3ee93 fix(extension): Retry auto-approve 흐름 복구 + Observer 형제 탐색 + thinking 필터링 (v0.5.79)
- WS response 파일에 _from_ws 마커 추가하여 processResponseFile 삭제 방지
- extractContextFromNearby에 sibling 탐색 추가 (AG Native DOM 구조 대응)
- thinking 블록 (max-h-[200px]) 필터링으로 내부 사고 릴레이 차단
- DOM 탐색 depth 5→10 확대 + pre.font-mono 우선 탐색
- 사용자 메시지 셀렉터 (.select-text.rounded-lg) 추가
2026-04-19 03:46:39 +09:00
Variet Worker
08fd08b9a6 feat(observer): diagnostic log relay via HTTP + auto-approve enrichment fallback (v0.5.63) #task-634 2026-04-18 08:18:35 +09:00
Variet Worker
326454be40 fix(bridge): move Always run auto-approve BEFORE filter chain — no more silent drops (v0.5.60) #task-634 2026-04-18 06:54:15 +09:00
Variet Worker
98b7585e3c fix(observer): text-level markdown table wrapping for Discord — AG Native uses divs not HTML tables (v0.5.59) #task-634 2026-04-18 06:46:21 +09:00
Variet Worker
c7f939ce85 fix(bridge): Always run auto-approve now checks buttons array, not just rawCmd (v0.5.58) #task-634 2026-04-18 06:35:01 +09:00
Variet Worker
7a1675fd5d feat(observer): table-to-codeblock conversion for Discord compatibility (v0.5.57) #task-634 2026-04-18 06:25:55 +09:00
Variet Worker
6b9f1188c3 feat(bridge): DOM Markdown parser restoration (v0.5.56) + code noise filter fix + user msg relay #task-634 2026-04-17 08:06:53 +09:00
Variet Worker
13e569f426 chore(bridge): update known-issues and prep for DOM Observer MD restoration (#634) 2026-04-17 06:25:22 +09:00
Variet Worker
b2f17a086b docs: devlog 20260416-005 v0.5.53 Always run auto-approve + index update 2026-04-16 22:08:16 +09:00
Variet Worker
7dbf73aa89 feat(bridge): v17 Always run auto-approve + Retry button relay (v0.5.53) #task-632
- Observer v17: ACTION_WORDS에 'Retry' 추가 — Agent terminated 대화상자의 Retry 버튼 감지/릴레이
- http-bridge: 'Always run' 버튼 자동승인 — response 파일 즉시 생성하여 observer가 바로 클릭
- bot.py: auto_approved 상태 처리 — Discord에 비대화형 '자동 승인' 알림 표시
- Observer matchedType에 'retry' step_type 매핑
2026-04-16 22:03:09 +09:00
Variet Worker
62ee081ffe fix(observer): v16 style/script strip in extractCleanStepText — CSS가 AI 응답으로 Discord 전달되는 버그 수정 (v0.5.52) #task-632 2026-04-16 21:08:52 +09:00
Variet Worker
60a2a97868 docs: E2E 코드 검증 결과 기록 — known-issues #632 업데이트 + devlog 003 2026-04-16 17:17:18 +09:00
Variet Worker
7ae43088e6 docs: devlog 20260416-002 AG Native chat relay + index update 2026-04-16 05:30:20 +09:00
Variet Worker
729875f3a6 feat(observer): v15 AG Native chat relay — scanChatBodies dual strategy (#632)
- Add AG Native DOM path: #conversation + .leading-relaxed.select-text
- Keep Cascade path: [data-testid=conversation-view] + [data-step-index]
- Register #632 in known-issues.md (SDK+DOM both blocked for AG Native)
- Bump version 0.5.50 → 0.5.51
- Add DOM analysis helper scripts
2026-04-16 05:28:44 +09:00
Variet Worker
a00d561e28 docs: devlog 2026-04-16 + known-issues v0.5.50 terminal output filter 2026-04-16 04:59:27 +09:00
Variet Worker
7ade31e4cf fix(bridge): v16 terminal output filter + v15 stale LS auto-fix + heartbeat probe (v0.5.50) #task-619
- http-bridge v16: Block terminal OUTPUT as enriched cmd — if description has no prompt marker (> » $ #), it's stdout from code block, not an actual command. Prevents 'No extension.log found' etc. from reaching Discord.
- step-probe v15: Stale LS auto-detection — if all sessions are >5min old, periodically retry fixLSConnection(). Heartbeat probe every 10 polls to detect step changes when summary API returns frozen stepCount.
- extension.ts v15: fixLSConnection() fallback — match LS processes without --workspace_id (common after AG restart)
2026-04-16 04:58:05 +09:00
Variet Worker
66233bd9cb fix(bridge): strengthen JUNK_CONTENT_RE — add CSS block, .code-block/.code-line, integration.build patterns
Unit tested: 12 legit commands pass, all junk patterns now filtered.
HTML manually patched to v14 (bypasses extension host cache).
2026-04-15 15:37:09 +09:00
Variet Worker
ed90cbf874 fix(observer/bridge): v14 — strict 5-level DOM scope, CSS/code/icon junk filter, auto-version sync (v0.5.47) #task-619
Root causes fixed:
1. extractContextFromNearby depth 20→5 — stops grabbing unrelated UI/editor code
2. JUNK_CODE_RE — rejects CSS rules, JS source code, extension internals
3. ICON_GLUE_RE — rejects Material icon text glued with content
4. Fallback span/div/p collection REMOVED entirely (always grabbed chat text)
5. html-patcher strips old observer from integration.build() cache
6. http-bridge server-side JUNK_CONTENT_RE as last line of defense
2026-04-15 14:55:58 +09:00
Variet Worker
2e32be96fe fix(observer/bridge): v13 _promptOnlySkipped fallback guard + generic button no-context filter (v0.5.46) #task-619 2026-04-15 13:27:09 +09:00
Variet Worker
87c99c7243 docs: devlog 2026-04-15 + scratch cleanup
- devlog entry: PROMPT_ONLY_RE 근본원인 분석 및 수정 (v0.5.45)
- scratch files: 이전 세션 디버깅 스크립트 전체 정리
2026-04-15 09:59:22 +09:00
Variet Worker
01539e9bfb fix(observer/bridge): PROMPT_ONLY_RE — regex escaping + ellipsis prefix handling (v0.5.45) #task-619
Observer: \\\\s (4-backslash) → \\s (2-backslash) in template literal regex
  - Per known-issues rule: regex literals need 2-backslash for \s, \d etc.
  - Old: literal \\s matched, new: whitespace class matched correctly

http-bridge: /^[\s\\\/]*[\w_.-]+\s*[>»$#]\s*$/ → /^.*[>»$#]\s*$/
  - Old pattern failed on ellipsis '…' prefix (U+2026 not in charset)
  - Terminal prompt '…\\gravity_control >' was passing through as cmd
  - New pattern: any text ending with prompt marker is skipped
2026-04-15 09:53:32 +09:00
Variet Worker
4684376c78 fix(http-bridge): remove incorrect extracted===rawDesc skip in enrichment (v0.5.44) #task-619 2026-04-15 07:47:56 +09:00
Variet Worker
7ee5947b32 fix(observer): v12 — skip prompt-only code text + enrichment validation (v0.5.44) #task-619
- extractContextFromNearby: PROMPT_ONLY_RE skips code elements containing only terminal prompts (e.g. '\\gravity_control >')
- Multi-codeEl traversal: tries all code elements at each depth, picks longest non-prompt text
- http-bridge enrichment: validates extracted command is not just a prompt fragment before enriching
- Fixes: cmd='\\gravity_control >' (prompt-only) no longer sent to Discord as the command text
2026-04-15 07:43:15 +09:00
Variet Worker
59ddcbb612 fix(http-bridge): move command enrichment before Run filter — fixes context loss (v0.5.43) #task-619 2026-04-15 06:54:06 +09:00
Variet Worker
ed63f65975 feat(observer): v11 — enhanced context extraction + 5-level sibling search + command enrichment (v0.5.42) #task-619
- extractContextFromNearby: span/div/p text fallback when pre/code not found (depth 0-8)
- collectSiblingButtons: extended to 5 parent levels, stop only when both action+reject found
- http-bridge: command enrichment — swap generic button text with actual command from description
- Fixes: cmd='Always run' now shows actual command text, Cancel button detection improved
2026-04-15 06:20:47 +09:00
Variet Worker
02d9799f20 diag(observer): v10-diag — extractContextFromNearby trail logging for context extraction failure analysis #task-619 2026-04-14 07:38:27 +09:00
Variet Worker
a9c64e6716 docs: known-issues — template literal regex escaping bug (v0.5.41) 2026-04-14 06:06:23 +09:00
Variet Worker
8e89209a27 fix(observer): template literal regex escaping — \s/\d had wrong backslash count (v0.5.41)
Root cause: regex literals inside template literal (\...\) need exactly 2
backslashes (\\s, \\d) to produce \s, \d in the output. The v9 code had 4
backslashes (\\\\s) which produced literal \\s in the browser (matching
backslash+s instead of whitespace class). This broke:
- 'Running N commands' skip pattern (never matched)
- NOISE_CODE_RE (declare/import line filters)
- cleanLines() pre-strip word boundary \\b and split/join \\n
- cleanButtonText() whitespace strip \\s

The fix restores all regex-literal patterns to exactly 2 backslashes.
new RegExp() string patterns (like NOISE_RE) already used 4 backslashes
correctly (string→regex has one extra escaping layer).

Verified: node eval of generated code confirms /^Running\s*\d+\s*commands?$/i
correctly matches 'Running2 commands' and 'Running 4 commands'.

#task-619 #task-620
2026-04-14 06:05:43 +09:00
Variet Worker
a8d875167d fix(observer): v9 - stop treating Running N commands as approval button, add DOM-climbing context extraction 2026-04-13 19:37:18 +09:00
Variet Worker
2a1ebf1020 fix(extension): UTF-8 encoding + noise filter enhancement (v0.5.39)
- http-bridge.ts: add req.setEncoding('utf8') to all POST handlers
  to fix Korean text corruption in pending/chat/dump payloads
- observer-script.ts: add inline pre-strip in cleanLines() for
  Material icon names concatenated by textContent without newlines
- observer-script.ts: apply cleanLines() to codeText extraction
- known-issues: document UTF-8 encoding and noise filter issues
2026-04-13 12:56:25 +09:00
Variet Worker
5a76e30993 docs: known-issues — html-patcher String.replace dollar-pattern corruption bug 2026-04-13 12:22:41 +09:00
Variet Worker
d6ed8764b8 fix(html-patcher): escape $ in inline script replacement to prevent String.replace() special pattern corruption — root cause of observer SyntaxError #task-619 2026-04-13 12:22:02 +09:00
Variet Worker
a214ab029f docs: devlog — observer v8 verification session, V8 cache re-cleared 2026-04-13 11:02:26 +09:00
Variet Worker
f45d2d970d fix(observer): ensure inline script injection before </body> for Electron execution + add BEACON diagnostic ping
- html-patcher: relocate inline script from after </html> to before </body>
- html-patcher: clean up duplicate </html> tags from previous bad insertions
- observer-script: add immediate BEACON fetch to /ping on script load
- http-bridge: add diagnostic request logging for non-polling endpoints
- devlog 003: crash recovery session notes
2026-04-12 21:30:58 +09:00
Variet Worker
6dc0854c47 docs: devlog 003 — observer v8 full-DOM dump 2026-04-12 07:37:52 +09:00
Variet Worker
0e03b3a300 feat(observer): v8 full-DOM unconditional dump — body tree capture at 5s/15s/60s with depth 15, indexed dump files, deep-inspect overhaul #task-619 2026-04-12 07:37:25 +09:00
Variet Worker
353265bed8 docs: AG Native bundle reverse engineering analysis — plannerResponse/Whi renderer structure, V8 cache fix, known-issues update 2026-04-12 07:05:57 +09:00
Variet Worker
eef59e6bb2 docs: devlog entry for AG Native DOM parser v7 rewrite session 2026-04-12 06:15:47 +09:00
Variet Worker
a4d7286bce refactor(observer): v7 step-aware AG Native DOM parser with data-testid/data-step-index based content extraction
- Replace CSS class-based scanning with [data-testid='conversation-view'] + [data-step-index] traversal
- New extractCleanStepText(): clone-and-strip buttons/SVG/icons before text extraction
- New extractStepContext(): step-container-aware context with header + code block
- NOISE_RE: block Material icon names, button labels, UI artifacts
- Auto DOM structure dump on first conversation-view detection
- Enhanced deep-inspect with step element + button inventory
- known-issues: document AG Native SDK API incompatibility
2026-04-12 06:14:46 +09:00
Variet Worker
70dc301dca fix(bridge): isolate DOM observation scope and strip UI noise (TypeScript declarations, metrics) from Discord pending approval embeds 2026-04-11 17:25:33 +09:00
Variet Worker
7630bf1f8c chore: Add jump_url logging and plaintext fallback to approval messages to trace silent Discord drops 2026-04-11 15:45:21 +09:00
Variet Worker
ec7883755a fix(bot): remove lingering bridge dependency triggering AttributeError on pending relay 2026-04-11 13:21:02 +09:00
Variet Worker
072f83bf25 refactor(bridge): migrate gravity bridge to pure websocket gateway architecture, deleting legacy local file scanners and dependencies 2026-04-11 13:06:38 +09:00
Variet Worker
5e697cd919 Fix DOM observer regex/container bugs and add continuous AI Chat Body scraper via HTTP Bridge 2026-04-11 00:08:55 +09:00
Variet Worker
b3825e1c8a fix(extension): skip Antigravity ghost sessions in Fallback 2 to prevent trajectory not found infinite loop 2026-04-10 22:19:09 +09:00
Variet Worker
a99c283656 fix(extension): restore AI Response Content capture by patching DOM extraction, CSP connect-src, and TS regex literal serialization 2026-04-10 21:10:33 +09:00
Variet Worker
58887f6933 chore: bump version to 0.5.23 for vsix 2026-04-10 17:39:07 +09:00
Variet Worker
488b36f192 fix(step-probe): ensure fast AI responses and tool calls are captured by real-time block 2026-04-10 17:12:21 +09:00
Variet Worker
300338d5d3 fix(extension): bypass 10-item limit of GetAllCascadeTrajectories by utilizing GetDiagnostics 2026-04-10 16:52:12 +09:00
Variet Worker
e745744636 fix(Backend): Gravity Bridge response extraction & bot exception crash loop
* Restore step.content.parts traversal missing in prior bugfix
* Catch wide exceptions in bot.py chat_snapshot_scanner and move broken files to .json.failed to prevent loop aborts blocking the pending queue
2026-04-10 16:33:02 +09:00
Variet Worker
b88e75b075 fix(bridge): eliminate discord empty embed spam by disabling DOM observer proactive pending
- removed PATS in observer-script.ts
- step-probe.ts now handles 100% of pending detection with full RPC payload context
- DOM observer restricted to trigger-click polling only
2026-04-10 16:12:15 +09:00
Variet Worker
2ece05fc6f fix(extension): resolve AI response dropping for sub-5s executions by relaxing IDLE capture condition #task-607 2026-04-10 16:05:30 +09:00
Variet Worker
6bbc9ddd00 fix(extension): resolve AI response dropping by adding nested payload extraction in step-utils 2026-04-10 15:52:25 +09:00
Variet Worker
89c95de18c fix(bridge): resolve missing Discord embed bodies by extracting detailed RPC payload from step_probe 2026-04-10 08:02:41 +09:00
Variet Worker
fadd39424b fix(bridge): fix Discord signal relay false-positives and empty body logic 2026-04-10 00:12:01 +09:00
Variet Worker
22e1799d66 fix(extension): resolve Native UI icon text gluing causing DOM observer signal drop #task-603 2026-04-09 23:13:49 +09:00
Variet Worker
e4f674ec9f docs: restore unintentionally deleted known issues (mend destructive commit) 2026-04-09 22:36:50 +09:00
Variet Worker
47c0602427 fix(extension): pin point CodeLens exclusion filter to prevent native Agent UI freezing (v0.5.22) #task-602 2026-04-09 22:32:40 +09:00
Variet Worker
75762964e3 fix(extension): adapt DOM observer to Native Agent panel and Tailwind migration (v0.5.21) 2026-04-09 21:56:29 +09:00
Variet Worker
d2023321bd fix(extension): remove redundant SafeToAutoRun chat snapshot for Discord relay
* Removed writeChatSnapshot calls in step-probe.ts to prevent duplicate ' 자동 실행됨' notifications since bot.py already broadcasts '🤖 자동 승인됨'.
* Docs: Update devlog and known-issues with Discord Bot cache deletion bugs and multi-workspace LS connection conflicts.
* Fixes Vikunja #593
2026-04-08 17:59:40 +09:00
Variet Worker
2eb1fbb6b7 fix(pipeline): resolve SafeToAutoRun deadlock and sync freezing (v0.5.20) (#589) 2026-04-08 07:30:33 +09:00
96 changed files with 6668 additions and 2458 deletions

View File

@@ -1,172 +1,629 @@
# Known Issues & Lessons Learned
> **이 파일은 SSOT(Single Source of Truth)입니다.**
> 디버깅이나 구현 전에 **반드시** 이 파일을 확인하세요.
> 세션 종료 시 새로 발견된 이슈를 이 파일에 추가합니다.
> **<2A><20><EFBFBD><EFBFBD><EC94AA><EFBFBD> SSOT(Single Source of Truth)<29><EFBFBD><EFBFBD>떎.**
> <EFBFBD>뵒踰꾧퉭<EFBFBD><EFBFBD>굹 援ы쁽 <20><EFBFBD>뿉 **諛섎뱶<EC848E>떆** <20><20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><EFBFBD>슂.
> <EFBFBD><EFBFBD>뀡 醫낅즺 <20><20>깉濡<EAB989> 諛쒓껄<EC9293><20><EFBFBD>뒋瑜<EB928B> <20><20><EFBFBD><EFBFBD>뿉 異붽<E795B0><EBB6BD><EFBFBD><EFBFBD><EFBFBD>떎.
# Known Issues & Lessons Learned
> **씠 뙆씪 SSOT(Single Source of Truth)엯땲떎.**
> 뵒踰꾧퉭씠굹 援ы쁽 쟾뿉 **諛섎뱶떆** 씠 뙆뙆씪쓣 솗씤븯꽭슂.
> 꽭뀡 醫낅즺 떆 깉濡 諛쒓껄맂 씠뒋瑜 씠 뙆뙆씪뿉 異붽빀땲떎.
> [!TIP]
> 해결 완료된 과거 이슈는 [`known-issues-archive.md`](file:///c:/Users/Variet-Worker/Desktop/gravity_control/.agents/references/known-issues-archive.md)에 보관되어 있습니다.
> 비슷한 문제가 재발하면 archive에서 검색하세요.
> 빐寃 셿猷뚮맂 怨쇨굅 씠뒋뒗 [`known-issues-archive.md`](file:///c:/Users/Variet-Worker/Desktop/gravity_control/.agents/references/known-issues-archive.md)뿉 蹂닿릺뼱 엳뒿땲떎.
> 鍮꾩듂븳 臾몄젣媛 옱諛쒗븯硫 archive뿉꽌 寃깋븯꽭슂.
### [2026-04-19] [Observer] ★ Accept all 버튼이 `<span>`으로 렌더링 — Observer 감지 실패 (v0.5.101)
- **증상**: "Accept all" diff review 버튼이 화면에 보이지만 Observer가 감지하지 못함. Discord에 "Accept all" 자동 승인 알림이 안 옴.
- **원인**: AG Native UI 업데이트로 "Accept all"이 `<button>`이 아닌 `<span class="cursor-pointer">`로 렌더링됨. Observer의 `allBtns = querySelectorAll('button, [role="button"]')`에 span이 포함되지 않음. ACCEPT-SCAN 로그: `tag=SPAN cls=hover:text-ide-button-hover-color cursor-po txt=Accept all`.
- **해결 (v0.5.101)**: `allBtns` 선택자에 `span.cursor-pointer` 추가.
- **주의**: observer-dev-guide 섹션 3.3 "Accept all — Observer 접근 불가"는 outdated. UI 변경으로 chat panel footer에 Accept all이 표시됨. 가이드 업데이트 필요.
### [2026-04-19] [Bridge] ★ auto-approve response 파일에 `_from_ws` 마커 누락 — Observer polling 실패 (v0.5.103)
- **증상**: Observer가 "Accept all"을 감지하고 bridge가 자동 승인했지만, Observer의 `pollResponseGroup` GET `/response/{rid}`가 항상 `{waiting: true}` 반환. 버튼 클릭이 실행되지 않음.
- **원인**: http-bridge의 auto-approve 경로에서 response JSON 파일에 `_from_ws: true` 마커가 없음 → `processResponseFile`(response watcher)이 Observer보다 먼저 파일을 읽고 삭제 → Observer polling 시 파일 부재. known-issues [2026-04-18] WS response 삭제 버그와 동일 패턴.
- **해결 (v0.5.103)**: auto-approve response에 `_from_ws: true` + `_auto_approve_ttl` 마커 추가.
- **주의**: **response 디렉토리에 파일을 쓰는 모든 경로**는 반드시 `_from_ws: true` 마커를 포함해야 함. processResponseFile이 먼저 소비하는 race condition 항상 존재.
### [2026-04-18] [Extension] ★ WS response 파일이 processResponseFile에 의해 삭제 → Observer pollResponseGroup 실패 (v0.5.78)
- **증상**: `!auto` Retry가 작동하지 않음. Observer가 `/response/{rid}`를 폴링하지만 항상 `{waiting: true}` 반환.
- **원인**: extension.ts의 WS 응답 핸들러가 `response/{rid}.json` 파일 작성 → 300ms 후 response watcher(`processResponseFile`)가 파일 감지 → pending 파일이 없어 `isDomObserver=false``fs.unlinkSync()` 실행 → Observer가 폴링할 때 파일이 이미 삭제됨.
- **해결 (v0.5.78)**: WS 응답 파일에 `_from_ws: true` 마커 추가. `processResponseFile`에서 `_from_ws` 파일 스킵 (WS 핸들러에서 이미 `tryApprovalStrategies` 실행하므로 중복 방지도 함께 해결).
- **주의**: http-bridge의 `_handlePending`는 pending 파일을 생성하지 않음 (WS 전송만 수행). 따라서 `processResponseFile``isDomObserver` 판별이 실패함. WS 경로로 들어오는 모든 response는 반드시 마커로 구분해야 함.
### [2026-04-18] [Extension] ★ extractContextFromNearby 조상 탐색만으로는 명령어 추출 불가 (v0.5.79)
- **증상**: Discord auto-approve 알림에 "Always run"만 표시되고 실제 명령어가 안 보임. depth를 10으로 늘려도 동일.
- **원인**: AG Native DOM 구조에서 "Always run" 버튼은 `footer` 내부에 있고, 실제 명령어(`pre.font-mono`)는 `footer`**형제(sibling)** 요소에 있음. 조상 탐색(parentElement)으로는 도달 불가. trail: `d0:button → d1:div → d2:footer` (footer.parentElement가 null).
- **해결 (v0.5.79)**: `extractContextFromNearby`에 형제 탐색 로직 추가. 각 depth에서 `node.parentElement.children`을 순회하며 `pre.font-mono, pre, code`를 찾음 → `CONTEXT-OK src=sibling` 성공.
- **주의**: Observer 코드 변경은 HTML 인라인 스크립트이므로 AG 2번 재시작(Quit + Relaunch × 2) 필요.
### [2026-04-18] [Extension] Thinking 블록이 AI 응답으로 릴레이됨
- **증상**: AI의 내부 사고 과정(thinking/reasoning)이 Discord에 릴레이됨.
- **원인**: Observer의 `scanChatBodies``.leading-relaxed.select-text` 블록을 전부 캡처하는데, thinking 블록도 이 셀렉터에 매칭됨.
- **해결**: thinking 블록의 조상에 `max-h-[200px]` 클래스가 있는지 확인하여 필터링.
- **주의**: AG UI 업데이트로 thinking 블록의 클래스가 변경될 수 있음.
- **증상**: Step Probe의 RT-CAPTURE, HB-CAPTURE가 현재 대화 중에 전혀 발동하지 않음. POLL에서 `status=IDLE, steps=928, delta=0` 고정. Heartbeat probe에서도 `real=928 known=928` 불변.
- **원인**: AG Native의 `GetCascadeTrajectorySteps` API는 **cascade가 완전히 종료(IDLE 전환)된 후에만** 새 step을 반환합니다. 진행 중인 cascade에서는 step count가 동결됩니다. `GetAllCascadeTrajectories``stepCount`도 마찬가지.
- **영향**: Step Probe 기반의 모든 실시간 캡처(RT-CAPTURE, HB-CAPTURE, USER_INPUT 감지)가 **구조적으로 불가능**. Observer DOM이 유일한 실시간 데이터 경로.
- **해결**: Observer DOM 기반 chat relay를 재활성화 (v0.5.72). Step Probe는 cascade 완료 후 보완 용도로만 사용.
- **주의**: 이 제약은 AG Native 아키텍처의 근본적 특성. Polling 주기나 heartbeat 빈도를 변경해도 해결 불가. **실시간 릴레이는 반드시 Observer DOM 경로를 사용해야 함.**
- **참조**: `.agents/references/relay-architecture.md` (상세 분석)
### [2026-04-18] [Extension] Observer의 /pending POST에 명령어 컨텍스트가 없음 (Always run만 전달)
- **증상**: Discord auto-approve 알림에 "Always run"만 표시되고 실제 명령어가 안 보임.
- **원인**: Observer가 `/pending` POST 시 `command: "Always run"`, `description: "Always run"`만 보냄. `extractContextFromNearby(btn)`이 버튼 주변 DOM에서 유의미한 텍스트를 찾지 못함.
- **해결 (부분)**: v0.5.69에서 bridge/pending/ 디렉토리의 최신 Step Probe pending 파일에서 명령어를 읽는 fallback 추가. Step Probe pending이 있을 때만 작동.
- **주의**: Step Probe WAITING 감지가 진행 중 cascade에서 불가하므로 (위 이슈 참조), 현재 대부분의 경우 fallback도 실패. Observer의 DOM 컨텍스트 추출 개선이 필요.
### [2026-04-18] [Extension] Observer의 사용자 메시지 셀렉터 미매칭
- **증상**: 사용자가 AG에서 입력한 메시지가 Discord에 전혀 전달되지 않음.
- **원인**: Observer의 셀렉터(`.text-ide-message-block-user-color`, `[data-message-role="user"]` 등)가 AG Native DOM에서 매칭되지 않음. AI 응답만 `.leading-relaxed.select-text`로 매칭됨.
- **해결**: DOM 덤프에서 사용자 메시지 블록의 실제 CSS 클래스를 식별 후 셀렉터 추가 필요.
- **주의**: Step Probe의 USER_INPUT 캡처도 진행 중 cascade에서 불가 (위 이슈 참조).
### [2026-04-16] [Extension] ★ AG Native 세션 AI 응답이 Discord에 CSS로 전달됨 (v0.5.52 수정, #632)
- **증상**: Discord에 AI 대화 응답 대신 **CSS 스타일시트 코드** (`remark-github-blockquote-alert/alert.css`)가 전달됨. `scanChatBodies()` → POST /chat 경로는 작동하지만 내용이 CSS.
- **원인**: `extractCleanStepText()`에서 clone한 DOM에서 버튼/SVG/아이콘은 제거하지만 **`<style>` 태그는 제거하지 않음**. AG Native 마크다운 렌더러가 `.leading-relaxed.select-text` 내부에 `<style>` 블록을 주입하는데, 이 CSS textContent가 AI 응답 텍스트로 추출됨.
- **해결 (v0.5.52)**: `extractCleanStepText()` 최상단에 `clone.querySelectorAll('style, script, noscript, link[rel="stylesheet"]')` 제거 로직 추가. CSS/JS가 텍스트로 포함되는 것을 원천 차단.
- **배포**: v0.5.52 VSIX 설치 + v0.5.50/out/ JS 직접 복사 + V8 CachedData 삭제. AG File→Quit 재시작 필요.
- **이전 블로커 해결 이력**:
- SDK 경로: AG Native는 Cascade trajectory API 미등록 → step-probe RT-CAPTURE 불가 (구조적 한계)
- DOM 경로: v15에서 `#conversation` + `.leading-relaxed.select-text` 셀렉터 추가로 해결
- BEACON=0: AG 프로세스 완전 재시작으로 해결 (Reload Window로는 렌더러 HTML 캐시 유지)
- **주의**: AG Native 마크다운 렌더링은 `<style>` 블록을 응답 DOM 내부에 인라인으로 삽입함. DOM 텍스트 추출 시 반드시 style/script 태그를 먼저 제거해야 함.
- **Vikunja**: #632
### [2026-04-16] [Extension] ★ AG Native Observer innerText로 인한 마크다운 포맷 유실 및 사용자 요청 누락
- **증상**: Discord로 릴레이되는 AI 응답에서 리스트 번호(`1. 2.`)나 불릿(`-`) 등 마크다운 포맷이 완전히 유실되고 텍스트만 이어져서 나옴. 그리고 사용자가 입력한 메시지(요청)는 아예 릴레이되지 않음.
- **원인 1**: `extractCleanStepText()`에서 `innerText`를 사용하여 텍스트를 추출할 때, 렌더링되지 않은 DOM이나 CSS 카운터로 생성된 list-item 마커가 무시됨.
- **원인 2**: `scanChatBodies()` 로직이 `.leading-relaxed.select-text` (AI 응답 블록)만을 타겟팅하여 사용자의 메시지 박스(예: `.text-ide-message-block-user-color`)는 추출 대상에서 제외됨.
- **해결 (계획 중)**: `innerText` 대신 HTML DOM 노드를 순회하며 `convertNodeToMarkdown` 변환 함수를 통해 마크다운 문법을 보존하도록 개선. User 블록도 함께 감지하여 브릿지에 `role: 'user'` 플래그를 추가 전송하도록 수정 예정.
- **주의**: Webview 내에서 텍스트 노드를 파싱할 때 `innerText`는 브라우저 레이아웃 엔진에 의존하므로 완전한 마크다운 보존을 위해서는 Node Type 순회를 활용한 구조 복원이 보장되어야 함.
### [2026-04-16] [Extension] 터미널 출력(stdout) 텍스트가 명령어로 Discord에 전송 (v0.5.50)
- **증상**: Discord에 `cmd="No extension.log found"`, `cmd="AG CLI not found..."`, `cmd="Log found: C:\..."` 등 터미널 **출력** 텍스트가 명령어로 전송됨
- **원인**: Observer가 code 블록 2개를 감지: (1) 프롬프트+명령어 → JUNK_CODE_RE로 스킵, (2) 터미널 출력 → 유효한 code로 판단 → description에 포함. http-bridge enrichment에서 description에 prompt marker(`>`)가 없으면 rawDesc 전체를 enrichedCmd로 채택
- **해결 (v0.5.50)**: `promptMatch` 실패 시(description에 `>` 없음) → 터미널 OUTPUT으로 판단하여 `terminal_output` 사유로 즉시 필터. 실제 명령어는 항상 `…\project > command` 프롬프트를 포함
- **주의**: enrichment은 반드시 prompt marker가 있는 경우에만 수행. description에 `>` 없으면 code 블록의 출력 텍스트
### [2026-04-15] [Extension] Observer fallback 컨텍스트가 채팅/UI 텍스트를 명령어로 추출 (v0.5.46)
- **증상**: Discord에 `cmd="실 동작검증을 해봐야하는데"`, `cmd="variet.gravity-bridge"` 등 사용자 채팅/AI 응답 텍스트가 명령어로 전송됨
- **원인**: v0.5.45에서 `PROMPT_ONLY_RE``code/pre` 요소 스킵 성공했으나, `extractContextFromNearby()`의 fallback(`span/div/p` 수집)이 여전히 작동하여 DOM 트리의 채팅 본문/UI 라벨을 명령어로 추출
- **해결 (v0.5.46)**: Observer v13에 `_promptOnlySkipped` 플래그 — code 요소가 모두 prompt-only이면 fallback 비활성화. http-bridge에 generic button + no-context일 때 무조건 필터
- **주의**: 프롬프트 스킵과 fallback 비활성화는 항상 연동해야 함. VSIX 설치 누락 방지를 위해 빌드 후 즉시 `code --install-extension` 확인 필수
### [2026-04-15] [Extension] PROMPT_ONLY_RE regex fixes — prompt-only terminal text leaking as enriched cmd (v0.5.45)
- **증상**: Discord에 `cmd="…\gravity_control >"` (실제 명령어 없는 빈 터미널 프롬프트)가 전송됨. 명령어가 포함된 경우는 정상 작동
- **원인 1 (Observer)**: `PROMPT_ONLY_RE``\\\\s`(4중 backslash)가 template literal 안의 regex 리터럴에서 "literal backslash+s"가 되어 whitespace class가 아닌 문자열 매칭
- **원인 2 (http-bridge)**: `PROMPT_ONLY_RE = /^[\s\\\/]*[\w_.-]+\s*[>»$#]\s*$/` — AG 터미널 프롬프트가 `…`(U+2026 ellipsis)로 시작하는데 첫 문자 클래스에 포함 안 됨
- **해결 (v0.5.45, `d2c44fe`)**: Observer `\\s``\s`, http-bridge 패턴을 `/^.*[>»$#]\s*$/`로 단순화
- **검증**: 11개 테스트 케이스 전체 통과
---
## 포맷
### [2026-04-14] [Extension] Observer template literal 정규식 이스케이핑 오류 — "Running N commands" 스킵 미작동
- **증상**: v9에서 `Running N commands` 그룹 헤더를 스킵하는 정규식 `/^Running\s*\d+\s*commands?$/i`를 추가했으나, 실제로 "Running2 commands" 텍스트를 전혀 매칭하지 못하여 여전히 Discord에 `cmd="Running2 commands"`가 전송됨
- **원인**: `observer-script.ts`의 전체 코드가 TypeScript template literal (`` ` `` ... `` ` ``) 안에 있으므로, 정규식 리터럴의 백슬래시가 2중 해석됨:
- TS 소스 `\\\\s` (4중) → template literal 출력 `\\s`**브라우저에서 regex 리터럴 `\\s` = 리터럴 백슬래시+s**
- TS 소스 `\\s` (2중) → template literal 출력 `\s`**브라우저에서 regex 리터럴 `\s` = whitespace class**
- TS 소스 `\s` (1중) → template literal에서 이스케이프 소멸 → **출력 `s`**
- **영향 범위**: `NOISE_CODE_RE`, `cleanLines()` (word boundary `\b`, newline `\n`), `cleanButtonText()` (whitespace `\s`), port 탐색 regex `\d`, "Running N commands" 스킵 regex 전부 미작동이었음
- 단, `NOISE_RE` (new RegExp() 사용)는 문자열 이스케이핑이므로 4중 백슬래시가 올바름 (string → RegExp는 별도 이스케이핑 레이어)
- **해결**: 모든 정규식 리터럴 (`/pattern/flags`) 안의 `\\\\s``\\s`, `\\\\d``\\d`, `\\\\b``\\b`, `\\\\n``\\n` 으로 수정 (v0.5.41, `8e89209`)
- **주의**: **template literal 안에서 정규식 특수문자를 쓸 때 반드시 구분:**
- `new RegExp('pattern')` 문자열: `\\\\s` (4중) — string 이스케이핑(1번)+regex 이스케이핑(1번) = 총 2번
- `/pattern/` 정규식 리터럴: `\\s` (2중) — template literal 이스케이핑(1번)만 = 총 1번
- **검증법**: `node -e "var f = require('./extension/out/observer-script.js').generateApprovalObserverScript; require('fs').writeFileSync('tmp.js', f(0)); console.log(require('fs').readFileSync('tmp.js','utf8').match(/Running.{10}/g));"` 으로 생성된 코드 확인
---
### [2026-04-13] [Extension] Observer v8 "Running N commands" 그룹 헤더를 승인 버튼으로 오인 — Discord 빈 내용+잘못된 버튼
- **증상**: Discord 승인 요청에 command="Running2 commands", description 비어있음, 버튼도 "Running2 commands / Always run" 형태. 실제 코드/명령어 내용이 전혀 표시되지 않음
- **원인 1**: `observer-script.ts``isActionBtn()``/Running\\s*\\d*\\s*command/i` 패턴이 있어 AG UI의 그룹 헤더 버튼("Running 3 commands")을 승인 버튼으로 분류. `scan()`이 이 버튼을 먼저 만나고 `break`로 나가 실제 "Always run"/"Cancel" 버튼은 처리 안 됨
- **원인 2**: `extractStepContext()``data-step-index` 속성 없으면 `cleanButtonText(btn)` = "Running2 commands"를 그대로 반환. AG Native에는 `data-step-index`/`data-testid` 속성이 없음 (DOM 덤프로 확인)
- **원인 3**: `http-bridge.ts`의 "Run/Always run" 필터가 step-probe 미활성(activeSessionId 비어있음) 시에도 DOM observer 신호를 차단
- **해결**: observer v9 (v0.5.40):
1. `isActionBtn()`에서 "Running N commands" 패턴 제거
2. `scan()`에서 `^Running\\s*\\d+\\s*commands?$` 명시적 스킵
3. `extractContextFromNearby()` 추가: `data-step-index` 없이 DOM 트리를 20레벨까지 올라가며 `pre`/`code` 블록에서 실제 명령어 추출
4. `collectSiblingButtons()` 범위를 parent → grandparent → great-grandparent로 확대, 그룹 헤더 스킵
5. `http-bridge.ts`의 "Run" 필터에 `ctx.activeSessionId` 체크 추가 — step-probe 미활성 시 DOM observer 허용
- **주의**: AG Native UI의 "Running N commands"는 아코디언/그룹 헤더이며, 실제 승인 버튼은 하위 레벨의 "Run"/"Always run"/"Cancel". DOM 구조상 버튼 탐색 시 그룹 헤더를 반드시 스킵해야 함
### [2026-04-13] [Extension] HTTP Bridge UTF-8 인코딩 깨짐 — 한글 description 손실
- **증상**: pending/ 파일의 description 필드에서 한글이 `[AI ]`처럼 깨져서 저장됨. Discord로 전달되는 승인 요청 본문도 깨짐
- **원인**: Node.js HTTP 서버의 `req.on('data', chunk)` 콜백에서 chunk가 Buffer 타입으로 전달되는데, `body += chunk`로 string 결합 시 Buffer의 기본 인코딩(latin1)이 사용되어 multi-byte UTF-8 문자가 손실됨
- **해결**: 모든 POST 핸들러(`/pending`, `/dump-html`, `/chat`, `/deep-inspect-result`, `/test-rpc`)에 `req.setEncoding('utf8')` 추가 (v0.5.39)
- **주의**: Node.js HTTP 서버에서 POST body를 문자열로 수집할 때는 반드시 `req.setEncoding('utf8')`을 호출하거나, Buffer를 배열로 모은 후 `Buffer.concat().toString('utf8')`로 변환해야 함
### [2026-04-13] [Extension] Observer noise 필터 미작동 — textContent가 아이콘 텍스트를 줄바꿈 없이 합침
- **증상**: pending description에 `Thought for 1s`, `chevron_right` 등 Material 아이콘명과 UI 노이즈가 그대로 남아있음
- **원인**: DOM `textContent`는 block 요소 사이에 newline을 삽입하지 않아 `[AI 본문 요약]Thought for 1schevron_right[결행 명령]`처럼 한 줄로 합쳐짐. `cleanLines()`의 줄 단위 noise 필터(`^pattern$`)가 매칭 실패. 또한 `codeText` 추출에는 `cleanLines()`가 아예 미적용
- **해결**: `cleanLines()`에 인라인 pre-strip 추가 — icon명 18종을 regex로 먼저 `\n`으로 치환 후 줄 단위 필터 적용. `codeText`에도 `cleanLines()` 적용 (v0.5.39)
- **주의**: DOM에서 텍스트 추출 시 `textContent`는 레이아웃 무시, `innerText`는 detached 노드에서 미작동. 노이즈 필터링은 줄 단위뿐 아니라 인라인 패턴 제거도 병행해야 함
### [2026-04-13] [Extension] html-patcher String.replace() `$'` 특수 패턴으로 인라인 스크립트 SyntaxError
- **증상**: Observer v8 인라인 스크립트가 workbench.html에 삽입되었으나 렌더러에서 전혀 실행되지 않음 (BEACON 핑 0건). V8 캐시 삭제 + AG 재시작 후에도 동일
- **원인**: `html-patcher.ts`에서 `html.replace('</body>', '\n' + inlineBlock + '\n</body>')`를 사용. 인라인 스크립트의 NOISE_RE 정규식에 `')$', 'i'`가 있는데, `$'`는 JS `String.replace()`의 특수 대체 패턴(match 뒤의 텍스트)으로 해석됨. 이로 인해 `</body>` 뒤의 원본 HTML 구조(`<!-- Startup -->`, `<script src="workbench.js">`, `</html>`)가 JS 코드 중간(정규식 리터럴 안)에 삽입되어 **치명적 SyntaxError** 발생
- **해결**: `inlineBlock.replace(/\$/g, '$$$$')`로 모든 `$`를 이스케이프한 후 replacement 문자열로 사용 (v0.5.38, `d6ed876`)
- **주의**: `String.prototype.replace()`의 replacement 문자열에서 `$&`, `$'`, `` $` ``, `$1` 등은 특수 패턴. 동적 콘텐츠를 replacement로 사용할 때 반드시 `$` → `$$` 이스케이프 필요
### [2026-04-12] [Extension] V8 CachedData가 Observer 스크립트 로딩을 차단
- **증상**: html-patcher가 workbench-jetski-agent.html에 observer v7 인라인 스크립트를 성공적으로 삽입했지만, deep-inspect가 렌더러에서 응답 없음 (10s timeout). AG 재시작 후에도 observer가 로드되지 않음
- **원인**: `%APPDATA%\Antigravity\CachedData\` (50MB)에 V8 캐시가 남아있어, AG가 패치된 HTML 대신 캐시된 이전 버전을 로드. extension.log에 `patcher.install() called (needs reload)` 메시지가 표시되지만 실제 적용이 안 됨
- **해결**: `Remove-Item "$env:APPDATA\Antigravity\CachedData\*" -Recurse -Force` 실행 후 AG 재시작. known-issues-archive #6에도 동일 케이스 있음
- **주의**: HTML 패치 변경 시 **반드시** V8 CachedData 삭제 + AG 재시작 필요. 단순 AG 재시작만으로는 부족
### [2026-04-12] [DOM] text-ide-message-block-bot-color는 AI 응답 컨테이너가 아닌 NUX tooltip 전용
- **증상**: observer-script가 `.text-ide-message-block-bot-color`를 AI 응답 컨테이너로 사용하지만, 실제 AI 텍스트를 추출하지 못함
- **원인**: 번들 분석(jetskiAgent/main.js 10.8MB)으로 확인 결과, 이 클래스는 `hsn` 컴포넌트(NUX Tooltip 텍스트 색상)에서만 사용. AI 응답 텍스트는 `plannerResponse` step의 `Whi` 렌더러 → `div.px-2.py-1``MarkdownRenderer` 내부에 렌더링됨
- **해결**: observer-script에서 `.text-ide-message-block-bot-color` 의존성 제거 필요. `markdown-body` 클래스도 AG Native에 존재하지 않음
- **주의**: AI 응답 마크다운은 `prose` 관련 클래스나 MarkdownRenderer 내부 구조로 타겟팅해야 함. 실제 DOM 덤프로 정확한 셀렉터 확인 필요
### [2026-04-12] [SDK/DOM] AG Native 세션은 Cascade SDK API에 등록되지 않음 — DOM이 유일한 데이터 소스
- **증상**: AG Native 세션에서 Discord 릴레이로 AI 응답이 전혀 전달되지 않고, 대신 UI 노이즈(`content_copy`, `Always run`, `keyboard_arrow_up`, `Cancel`)가 전송됨
- **원인 1 (SDK)**: `GetCascadeTrajectorySteps(cascadeId=세션ID)``500 trajectory not found`. `GetDiagnostics``404`. AG Native 세션은 Cascade trajectory API에 전혀 등록되지 않는 별도 시스템
- **원인 2 (DOM)**: `observer-script.ts` v6의 `scanChatBodies()``.text-ide-message-block-bot-color` 컨테이너의 `textContent`를 통째로 가져오면서 내부 버튼/아이콘 텍스트까지 포함
- **해결**: `observer-script.ts` v7로 전면 재설계:
1. `[data-testid="conversation-view"]` + `[data-step-index]` 기반 step-aware 파싱
2. `extractCleanStepText()`: 클론 후 button/svg/icon 엘리먼트 제거 → 마크다운 텍스트만 추출
3. `extractStepContext()`: `getStepContainer()` → step 헤더 + code 블록만 추출
4. `NOISE_RE`: Material icon 이름, 버튼 레이블, UI 텍스트 전면 차단
5. 최초 `conversation-view` 감지 시 DOM 구조 자동 덤프 (`/dump-html`)
- **주의**: SDK 경로(step-probe RT-CAPTURE)는 AG Native에서 사용 불가. DOM이 유일한 콘텐츠 소스이므로 AG UI 업데이트 시 `data-testid`/`data-step-index` 속성 존재 여부 반드시 확인 필요
### [2026-04-09] [Bridge] Discord Body Content Missing Due to Step Probe Dummy Payload
- **利앹긽**: <20><><EFBFBD>洹쒕え UI 留덉씠洹몃젅<EBAA83><EFBFBD><20>썑, <20><EFBFBD>뒪肄붾뱶 <20><EFBFBD>씤 硫붿떆吏<EB9686> 蹂몃Ц<EBAA83><20><EFBFBD><EFBFBD>븷 肄붾뱶/紐낅졊<EB8285>뼱媛<EBBCB1> <20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>릺怨<EBA6BA> "Step #15"<22><><EFBFBD> 媛숈<E5AA9B><EC8888> <20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>듃留<EB9383> <20><EFBFBD><EFBFBD>맖.
- **<2A><EFBFBD>씤**: Native UI 蹂<>寃쎌쑝濡<EC919D> <20><EFBFBD>빐 DOM observer媛<72> 異붿텧<EBB6BF>븳 踰꾪듉 <20><EFBFBD><EFBFBD>듃("Always run")媛<> `http-bridge.ts` <20><EFBFBD><20><EFBFBD>쉶 諛<> bot.py<70><EFBFBD>꽌 吏<><EFA79E>뿰(defer) 泥섎━<EC848E>맖. 諛섎㈃ `step-probe.ts`<EFBFBD> `GetAllCascadeTrajectories` <20>뤃留곸쓣 <20><EFBFBD><20><EFBFBD><EFBFBD>뿉 諛쒖깮<EC9296><EFBFBD>궓 dummy pending payload (紐낅졊<EB8285><20><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD>`Step #XX` <20><EFBFBD><20><EFBFBD><EFBFBD>듃留<EB9383> <20><EFBFBD>븿)媛<> 遊뉗뿉 <20><EFBFBD>빐 癒쇱<E79992><EC87B1> <20><EFBFBD><20><EFBFBD><EFBFBD>릺硫댁꽌 <20><EFBFBD><20><EFBFBD>젣 肄붾뱶 <20><EFBFBD><20>젙蹂닿<E8B982><EB8BBF> 利앸컻<EC95B8>븿.
- **<2A>빐寃<EBB990>**: `step-probe.ts` <20><EFBFBD>`formatStepProbeCommand` <20><EFBFBD><20>븿<EFBFBD>닔瑜<EB8B94> 異붽<E795B0><EBB6BD><EFBFBD><EFBFBD>뿬, WAITING <20><EFBFBD><20><EFBFBD><EFBFBD>`argumentsJson` <20><EFBFBD><EFBFBD>꽣瑜<EABDA3> 吏곸젒 <20><EFBFBD><EFBFBD>븯怨<EBB8AF> `CommandLine`, `TargetFile` <20><20><EFBFBD>젣 紐낅졊<EB8285><EFBFBD><EBBCB1><EFBFBD> <20><EFBFBD><20><EFBFBD>옄/肄붾뱶瑜<EBB1B6> `command`<EFBFBD><EFBFBD><EFBFBD> `description`<EFBFBD>쑝濡<EFBFBD> <20><EFBFBD><EFBFBD><EFBFBD>뿬 釉뚮┸吏<E294B8><EFBFBD> <20>꽆湲곕룄濡<EBA384> <20>뙣移섑븿. DOM <20><EFBFBD><EC8383><EFBFBD>踰꾩쓽 遺덉븞<EB8D89><EFBFBD>꽦怨<EABDA6><>怨꾩뾾<EABEA9><20>씪愿<EC94AA><E684BF>맂 蹂몃Ц <20><EFBFBD>떖 蹂댁옣.
- **二쇱쓽**: UI <20><EFBFBD><EFBFBD><EFBFBD><EFBFBD><20>쓽議댄븯<EB8C84>뒗 DOM Observer 諛⑹떇<E291B9><EB9687><EFBFBD> UI <20><EFBFBD><EFBFBD><EFBFBD>썐, <20><EFBFBD>씠肄<EC94A0> <20><EFBFBD><20><EFBFBD>뿉 痍⑥빟<E291A5>븯誘<EBB8AF><EFBFBD>, <20><EFBFBD><20><EFBFBD>씠濡쒕뱶 異붿텧<EBB6BF><ED85A7><EFBFBD> <20><EFBFBD>긽 100% <20>떊猶<EB968A><><E5AA9B><EFBFBD>븳 SDK RPC(`step-probe.ts`) <20><EFBFBD><EFBFBD>꽣瑜<EABDA3> <20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD>룄濡<EBA384> 援ъ꽦<D18A><EFBFBD><20>븿.
## <20>룷留<EBA3B7>
### [2026-03-23] [Extension] Cross-Project DOM Observer Leakage
- **증상**: 다중 원격 컴퓨터에서 동일한 프로젝트명으로 실행된 VS Code들이 서로의 `execute JavaScript` (Allow) 승인 신호를 가로채거나 엉뚱한 서버로 보냄.
- **원인**: Extension이 `workbench.html`에 스크립트를 주입할 때 결정론적 포트를 하드코딩했는데, 전역 캐시된 HTML 파일을 모든 로컬/원격 연결이 공유하면서 마지막에 열린 프로젝트의 포트 번호로 덮어씌워짐.
- **해결**: `extension.ts`에서 상태 표시줄(Status Bar) `tooltip`에 포트를 주입하고, `observer-script.ts`에서 DOM 쿼리를 통해 동적으로 자신의 창(Window)에 할당된 포트를 찾아내도록 수정. `vscode.env.asExternalUri`를 사용하여 포트 충돌 시 우회된 주소까지 로컬 포워딩에 매핑되도록 지원.
- **주의**: VS Code UI 코어(HTML) 패치 시, 여러 창(Window)이나 다중 원격 접속 시 환경(Scope) 분리에 각별한 주의가 필요함. 전역 자원에 의존하는 하드코딩 지양.
### [날짜] [키워드] — 한줄 요약
- **증상**: 무엇이 잘못되었는가
- **원인**: 근본 원인
- **해결**: 올바른 해결 방법
- **주의**: 재발 방지를 위한 교훈
- **利앹긽**: <20>떎以<EB968E> <20>썝寃<EC8D9D> 而댄벂<EB8C84><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><20>봽濡쒖젥<EC9296>듃紐낆쑝濡<EC919D> <20><EFBFBD><EFBFBD>맂 VS Code<64><EFBFBD><20>꽌濡쒖쓽 `execute JavaScript` (Allow) <20><EFBFBD><20><EFBFBD>샇瑜<EC8387><>濡쒖콈嫄곕굹 <20><EFBFBD><EFBFBD><20>꽌踰꾨줈 蹂대깂.
- **<EFBFBD><EFBFBD>씤**: Extension<6F>`workbench.html`<EFBFBD><20><EFBFBD>겕由쏀듃瑜<EB9383> 二쇱엯<EC87B1><20>븣 寃곗젙濡좎쟻 <20><EFBFBD>듃瑜<EB9383> <20><EFBFBD>뱶肄붾뵫<EBB6BE><EFBFBD><EFBFBD>뜲, <20><EFBFBD>뿭 罹먯떆<EBA8AF>맂 HTML <20><EFBFBD><EFBFBD>쓣 紐⑤뱺 濡쒖뺄/<2F>썝寃<EC8D9D> <20>뿰寃곗씠 怨듭쑀<EB93AD>븯硫댁꽌 留덉<EFA78D><EB8D89>留됱뿉 <20>뿴由<EBBFB4> <20>봽濡쒖젥<EC9296><EFBFBD><20><EFBFBD>듃 踰덊샇濡<EC8387> <20><EFBFBD><EFBFBD><EFBFBD>썙吏<EC8D99>.
- **<EFBFBD>빐寃<EFBFBD>**: `extension.ts`<EFBFBD><EFBFBD><20><EFBFBD><20><EFBFBD>떆以<EB9686>(Status Bar) `tooltip`<EFBFBD><20><EFBFBD>듃瑜<EB9383> 二쇱엯<EC87B1>븯怨<EBB8AF>, `observer-script.ts`<EFBFBD><EFBFBD>꽌 DOM 荑쇰━瑜<E29481> <20><EFBFBD><20><EFBFBD><EFBFBD>쑝濡<EC919D> <20><EFBFBD><EFBFBD>쓽 李<>(Window)<29><20><EFBFBD><EFBFBD><20><EFBFBD>듃瑜<EB9383> 李얠븘<EC96A0><EFBFBD>룄濡<EBA384> <20><EFBFBD>젙. `vscode.env.asExternalUri`<EFBFBD> <20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD>듃 異⑸룎 <20><20><EFBFBD><EFBFBD>맂 二쇱냼源뚯<E6BA90><EB9AAF> 濡쒖뺄 <20><EFBFBD><EFBFBD><EFBFBD>뿉 留ㅽ븨<E385BD><EFBFBD>룄濡<EBA384><><EFA79E>썝.
- **二쇱쓽**: VS Code UI 肄붿뼱(HTML) <20>뙣移<EB99A3> <20>떆, <20><EFBFBD>윭 李<>(Window)<29><EFBFBD><20>떎以<EB968E> <20>썝寃<EC8D9D> <20><EFBFBD><20><20>솚寃<EC869A>(Scope) 遺꾨━<EABEA8>뿉 媛곷퀎<EAB3B7>븳 二쇱쓽媛<EC93BD> <20><EFBFBD><EFBFBD>븿. <20><EFBFBD><20><EFBFBD><EFBFBD><20>쓽議댄븯<EB8C84><20><EFBFBD>뱶肄붾뵫 吏<><EFA79E>뼇.
### [<5B>궇吏<EAB687>] [<5B><EFBFBD><EFBFBD>뱶] <20><><EFBFBD> <20>븳以<EBB8B3> <20><EFBFBD>
- **利앹긽**: 臾댁뾿<EB8C81><20>옒紐삳릺<EC82B3><EFBFBD>뒗媛<EB9297>
- **<2A><EFBFBD>씤**: 洹쇰낯 <20><EFBFBD>
- **<2A>빐寃<EBB990>**: <20>삱諛붾Ⅸ <20>빐寃<EBB990> 諛⑸쾿
- **二쇱쓽**: <20>옱諛<EC98B1> 諛⑹<E8AB9B><E291B9><EFBFBD> <20><EFBFBD>븳 援먰썕
```
---
## 🔴 Active/Recent Issues
### [2026-04-08] [Discord Bot] Channel Deletion Cache Desync
- **利앹긽**: 遊뉗씠 耳쒖졇 <20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD>꽌 Discord 梨꾨꼸(g-project-name)<29><20><EFBFBD><EFBFBD>븯硫<EBB8AF>, 遊뉗씠 <20><EFBFBD>젣瑜<ECA0A3> <20>씤吏<EC94A4><EFA79E>븯吏<EBB8AF> 紐삵븯怨<EBB8AF> <20>깉 梨꾨꼸<EABEA8><20><EFBFBD><EFBFBD>븯吏<EBB8AF> <20><EFBFBD>쑝硫<EC919D> 硫붿떆吏<EB9686><EFA79E>룄 利앸컻<EC95B8>븿.
- **<2A><EFBFBD>씤**: ot.py<70>쓽 self.project_channels <20><EFBFBD><EFBFBD>꼫由ъ뿉 梨꾨꼸 媛앹껜媛<EABB9C> 罹먯떆<EBA8AF><EFBFBD><20><EFBFBD>뼱, API <20>샇異<EC8387> <20><EFBFBD>씠 罹먯떆<EBA8AF>맂(<28><EFBFBD><EFBFBD>맂) 梨꾨꼸濡<EABCB8> 硫붿떆吏<EB9686><EFBFBD> 蹂대궡<EB8C80><20><EFBFBD><EFBFBD><EFBFBD>떎 404 <20><EFBFBD>윭 諛쒖깮 <20><20><EFBFBD><EFBFBD>븿.
- **<2A>빐寃<EBB990>**: 梨꾨꼸 留듯븨<EB93AF>씠 瑗ъ<E79197><D18A><EFBFBD><20><EFBFBD>뒗 **Python 遊<>(Docker 而⑦뀒<E291A6><EFBFBD>꼫)<29><20><EFBFBD><EFBFBD>옉**<2A><EFBFBD>뿬 罹먯떆瑜<EB9686> 珥덇린<EB8D87><EFBFBD>븯怨<EBB8AF> 梨꾨꼸 紐⑸줉<E291B8><20>깉濡<EAB989> 媛깆떊<EAB986>븯寃<EBB8AF> <20>븿.
- **二쇱쓽**: 梨꾨꼸 愿<>由щ뒗 罹먯떆<EBA8AF><20>쓽議댄븯湲<EBB8AF> <20>븣臾몄뿉 媛뺤젣濡<ECA0A3> Discord UI<55><EFBFBD>꽌 梨꾨꼸<EABEA8>쓣 吏<><EFA79E><EFBFBD><20><EFBFBD>뒗 諛섎뱶<EC848E>떆 遊뉗쓣 <20>옱援щ룞<D189><EFBFBD><20>븿.
### [2026-04-08] [Extension] Multiple Workspace LS Cross-Connection
- **利앹긽**: ariet-llm 李쎌뿉<EC8E8C>꽌 耳곗쑝<EAB397>굹 gravity_control<6F>쓽 諛깃렇<EAB983><EFBFBD><EFBFBD>뱶 援щ룞 以묒씤 LS<4C><20>뿰寃곕릺<EAB395><20>옄湲<EC9884> <20><EFBFBD>떊 李쎌쓽 <20><EFBFBD>샇瑜<EC8387> <20>옟吏<EC989F> 紐삵븿.
- **<2A><EFBFBD>씤**: <20><EFBFBD>윭 VS Code 李쎌쓣 <20><EFBFBD><EFBFBD><20><20><EFBFBD>뼡 李쎌뿉<EC8E8C><EFBFBD>뒗 Antigravity <20><EFBFBD><EFBFBD><20>늻瑜댁<E7919C><EB8C81> <20><EFBFBD><20><EFBFBD>슜 LS媛<53> <20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD>쓬. ixLSConnection()<29><20>옄湲<EC9884> 紐レ쓽 LS瑜<53> 李얠<EFA7A1><EC96A0> 紐삵븯怨<EBB8AF> fallback<63>쑝濡<EC919D> 湲곗〈<EAB397><20><20><EFBFBD><20>떎瑜<EB968E> 李쎌쓽 LS<4C><20>뿰寃곕맖.
- **<2A>빐寃<EBB990>**: <20><><EFBFBD><EFBFBD>긽 李쎌뿉<EC8E8C>꽌 Developer: Reload Window <20><EFBFBD><20>썑 **<2A><EFBFBD><EFBFBD>뱶諛붿쓽 濡쒖뺄 Antigravity 梨쀫큸 <20><EFBFBD><EFBFBD><20>븳 踰<> <20><EFBFBD>뼱** <20><EFBFBD><EFBFBD>쓽 LS <20>봽濡쒖꽭<EC9296>뒪瑜<EB92AA> <20><EFBFBD><20><EFBFBD>뿉 Gravity Bridge瑜<65> Start<72>븿.
- **二쇱쓽**: LS<4C><20><EFBFBD><EFBFBD>쑝濡<EC919D> <20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20>븡怨<EBB8A1> <20><EFBFBD><EFBFBD>옄媛<EC9884> 梨꾪똿 <20><EFBFBD><EFBFBD><20>븳 踰<> <20>겢由<EAB2A2>/<2F><EFBFBD><EFBFBD><EFBFBD><EFBFBD>빞留<EBB99E> Spawn <20>맖.
## <20><EFBFBD> Active/Recent Issues
### [2026-04-09] [Extension] Agent UI Native Migration & Icon Text Gluing
- **利앹긽**: UI Tailwind/Native 留덉씠洹몃젅<EBAA83><EFBFBD>뀡 諛<> <20><EFBFBD>씠肄<EC94A0> <20><EFBFBD><20>썑, Discord 釉뚮┸吏<E294B8><EFBFBD> <20><EFBFBD>샇媛<EC8387> <20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD>쓬.
- **<2A><EFBFBD>씤**: <20><EFBFBD><EFBFBD>떚釉<EB969A> UI 踰꾪듉<EABEAA>쓽 `textContent` 異붿텧 <20>떆, Codicons <20><20><EFBFBD>씠肄<EC94A0> <20><EFBFBD>듃 臾몄옄<EBAA84>뿴(e.g., `<EFBFBD> Accept`)<29><20>븵遺<EBB8B5>遺꾩뿉 蹂묓빀(Gluing)<29>릺硫댁꽌, 湲곗〈<EAB397>쓽 `^` <20>빑而ㅺ<E8808C><E385BA> <20><EFBFBD>븿<EFBFBD><20>젙洹쒖떇 留ㅼ묶(`/^(?:Always\s*)?Run/i`)<29><20><EFBFBD><EFBFBD>븿.
- **<2A>빐寃<EBB990>**: `observer-script.ts`<60><20>뒪罹<EB92AA>, Sibling 踰꾪듉 <20>닔吏<EB8B94>, Webview Trigger-click <20>벑 `textContent`瑜<> 異붿텧<EBB6BF><EFBFBD>뒗 紐⑤뱺 DOM <20>씫湲<EC94AB> 援ш컙<D188>뿉 `txt.replace(/^[^a-zA-Z0-9]+/, '')` <20>쟾泥섎━瑜<E29481> <20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD>뻾 湲고샇/<2F><EFBFBD>씠肄섏쓣 <20><EFBFBD><EFBFBD>븯寃<EBB8AF> <20>젣嫄<ECA0A3>.
- **二쇱쓽**: Native UI 而댄룷<EB8C84><EFBFBD><20>솚寃쎌뿉<EC8E8C><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD>뱶肉먮쭔 <20><EFBFBD><EFBFBD><20><EFBFBD>씠肄<EC94A0>/SVG 而댄룷<EB8C84><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>듃 湲<>猷⑥엵 <20><EFBFBD><EFBFBD>쑝濡<EC919D> <20><EFBFBD><20>뾼寃⑺븳 <20><EFBFBD><EFBFBD>젏(`^`) <20>젙洹쒖떇<EC9296>씠 源⑥쭏 <20><20><EFBFBD>쑝誘<EC919D><EFBFBD>, <20><EFBFBD>긽 遺덊븘<EB8D8A><EFBFBD><20><EFBFBD>닔臾몄옄 <20>쟾泥섎━瑜<E29481> <20><EFBFBD><EFBFBD><EFBFBD><20>븿.
### [2026-04-09] [Extension] Agent UI Native Migration & CodeLens False Positive Filter
- **利앹긽**: UI Tailwind/Native 留덉씠洹몃젅<EBAA83><EFBFBD><20><EFBFBD><20>썑, Discord 釉뚮┸吏<E294B8><EFBFBD> <20><EFBFBD>샇媛<EC8387> <20><EFBFBD><EC9FBE><EFBFBD> <20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD>
- **<2A><EFBFBD>씤**: Agent <20><EFBFBD><EFBFBD><20>꺆/<2F><EFBFBD><EFBFBD>꽣 蹂몃Ц<EBAA83>뿉 吏곸젒 <20><EFBFBD>뜑留곷릺硫댁꽌, 湲곗〈 <20><EFBFBD><EFBFBD>룞 諛⑹<E8AB9B><E291B9> 濡쒖쭅(`if (b.closest('.monaco-editor'))`)<29><20><EFBFBD><20>쟾泥<EC9FBE> 踰꾪듉<EABEAA><20>룷李⑸릺<E291B8>뼱 臾댁떆<EB8C81>
- **<2A>빐寃<EBB990>**: <20>꼫臾<EABCAB> 愿묐쾾<EBAC90><EFBFBD>븳 `.monaco-editor` 諛⑹뼱瑜<EBBCB1> <20><EFBFBD><EFBFBD>븯怨<EBB8AF>, 肄붾뱶 <20>젋利<ECA08B> 怨좎쑀 而⑦뀒<E291A6><EFBFBD><EFBFBD>씤 `.codelens-decoration` <20>궡遺<EAB6A1><E981BA>씪 寃쎌슦<EC8E8C>뿉留<EBBF89> 臾댁떆<EB8C81><EFBFBD>룄濡<EBA384> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD>
- **二쇱쓽**: DOM <20><EFBFBD><EC8383><EFBFBD><EFBFBD> <20><EFBFBD>꽣 議곌굔 <20><EFBFBD><20><20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD>뒗 UI <20><EFBFBD><EFBFBD>씤 媛쒗렪(Native, Editor Tab <20><20>쐞移<EC909E><><EFBFBD>)<29>뿉 留ㅼ슦 痍⑥빟<E291A5>븿. 媛<><E5AA9B>옣 援ъ껜<D18A><EFBFBD><20>궡遺<EAB6A1> <20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><20><><EFBFBD><EFBFBD> 怨좎쑀 <20><EFBFBD><EFBFBD><20><EFBFBD><20><EFBFBD>꽣留곹븷 寃<>
### [2026-03-31] [step-probe] GetAllCascadeTrajectories 10-Item Hard Limit (Signal Drop)
- **증상**: `guitar_score` 등에서 활성화된 세션의 디스코드 승인 신호를 "계속해서" 잡지 못함. (WS 60초 타임아웃보다 더 치명적으로 신호가 아예 가지 않음)
- **원인**: Extension이 활성 세션을 찾기 위해 호출하는 `GetAllCascadeTrajectories` LS API가 `{}`(빈 인자)로 호출될 때, 기본적으로 **10개의 세션만 반환하는 하드 리밋(Pagination Limit)**이 걸려있음. 이로 인해 작업 내역이 누적되면 수많은 최신/진행 중 세션들이 10개 목록에서 밀려나 누락됨. 익스텐션은 세션이 없다고 판단해 강제로 `IDLE` 모드에 진입하며, 승인 대기열(WAITING) 자체를 검사하지 않게 됨.
- **해결** (v0.5.14): `v0.5.13`에서 도입했던 `{ limit: 100 }`이 LS 단의 쿼리 과부하로 인한 VS Code UI 프리징(DoS)을 유발하여 롤백하는 중 필수 정렬 파라미터(`descending: true`)까지 소실되었던 실수를 교정함. 최종적으로 `{ limit: 30, descending: true }`를 적용하여 파싱 부하 최소화 및 최신 세션 최상단(Index 0) 조회를 안전하게 구현함.
- **주의**: LS의 기본 SQLite/DB 응답 Limit 규칙에 의존하여 전체 데이터 스캔을 수행하는 로직은 언제든 Truncation 이슈(Data Loss)를 유발할 수 있음.
- **利앹긽**: `guitar_score` <20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD>뒪肄붾뱶 <20><EFBFBD><20><EFBFBD>샇瑜<EC8387> "怨꾩냽<EABEA9><EFBFBD>꽌" <20>옟吏<EC989F> 紐삵븿. (WS 60珥<30> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>썐蹂대떎 <20>뜑 移섎챸<EC848E><EFBFBD>쑝濡<EC919D> <20><EFBFBD>샇媛<EC8387> <20><EFBFBD>삁 媛<><EFBFBD> <20><EFBFBD>쓬)
- **<EFBFBD><EFBFBD>씤**: Extension<6F><20><EFBFBD><20><EFBFBD><EFBFBD>쓣 李얘린 <20><EFBFBD><20>샇異쒗븯<EC9297>뒗 `GetAllCascadeTrajectories` LS API媛<49> `{}`(鍮<> <20><EFBFBD>옄)濡<> <20>샇異쒕맆 <20>븣, 湲곕낯<EAB395><EFBFBD>쑝濡<EC919D> **10媛쒖쓽 <20><EFBFBD>뀡留<EB80A1> 諛섑솚<EC8491><EFBFBD><20><EFBFBD>뱶 由щ컠(Pagination Limit)**<2A>씠 嫄몃젮<EBAA83><EFBFBD>쓬. <20>씠濡<EC94A0> <20><EFBFBD><20><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>릺硫<EBA6BA> <20>닔留롮<EFA78D><EBA1AE> 理쒖떊/吏꾪뻾 以<> <20><EFBFBD><EFBFBD><EFBFBD>씠 10媛<30> 紐⑸줉<E291B8><EFBFBD>꽌 諛<><E8AB9B><EFBFBD><20><EFBFBD><EFBFBD>맖. <20><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EB80A1><EFBFBD> <20><EFBFBD><EFBFBD><20><EFBFBD>떎怨<EB968E> <20><EFBFBD><EFBFBD>빐 媛뺤젣濡<ECA0A3> `IDLE` 紐⑤뱶<E291A4>뿉 吏꾩엯<EABEA9>븯硫<EBB8AF>, <20><EFBFBD><20><><EFBFBD>湲곗뿴(WAITING) <20>옄泥대<EFA7A3><EB8C80><><E5AF83><EFBFBD>븯吏<EBB8AF> <20>븡寃<EBB8A1> <20>.
- **<2A>빐寃<EBB990>** (v0.5.14): `v0.5.13`<60><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD>뜕 `{ limit: 100 }`<60>씠 LS <20><EFBFBD>쓽 荑쇰━ 怨쇰<E680A8><EC87B0><EFBFBD>븯濡<EBB8AF> <20><EFBFBD>븳 VS Code UI <20>봽由ъ쭠(DoS)<29><20>쑀諛쒗븯<EC9297>뿬 濡ㅻ갚<E385BB><EFBFBD>뒗 以<> <20><EFBFBD><20><EFBFBD><20><EFBFBD>씪誘명꽣(`descending: true`)源뚯<E6BA90><EB9AAF> <20><EFBFBD><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD>닔瑜<EB8B94> 援먯젙<EBA8AF>븿. 理쒖쥌<EC9296><EFBFBD>쑝濡<EC919D> `{ limit: 30, descending: true }`瑜<> <20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD>떛 遺<><E981BA>븯 理쒖냼<EC9296>솕 諛<> 理쒖떊 <20><EFBFBD>뀡 理쒖긽<EC9296>떒(Index 0) 議고쉶瑜<EC89B6> <20><EFBFBD><EFBFBD>븯寃<EBB8AF> 援ы쁽<D18B>븿.
- **二쇱쓽**: LS<4C>쓽 湲곕낯 SQLite/DB <20><EFBFBD>떟 Limit 洹쒖튃<EC9296><20>쓽議댄븯<EB8C84><20>쟾泥<EC9FBE> <20><EFBFBD><EFBFBD><20>뒪罹붿쓣 <20><EFBFBD><EFBFBD><EFBFBD>뒗 濡쒖쭅<EC9296><ECAD85><EFBFBD> <20><EFBFBD><EFBFBD>뱺 Truncation <20><EFBFBD>뒋(Data Loss)瑜<> <20>쑀諛쒗븷 <20><20><EFBFBD>쓬.
### [2026-03-31] [WS] Browser API Fallback 60s Timeout (Zombie Connection)
- **증상**: `guitar_score` 등 모든 작업 환경에서 약 60초마다 WebSocket 연결이 끊기고 재연결되는 현상이 반복되며(extension.log에 `Heartbeat timeout` 계속 출력), 그 사이 디스코드 승인 신호를 놓침.
- **원인**: Extension이 `ws` 모듈 로드 실패(VS Code 환경 등)로 인해 브라우저 내장 `WebSocket` 객체로 Fallback 됨. 브라우저 WS는 서버의 네이티브 ping을 받아 pong을 자동 응답하지만 JS에 이벤트를 노출하지 않음. 이로 인해 `lastPongTime` 갱신이 불가능해져, `Date.now() - lastPongTime > 60000` 조건이 무조건 통과되어 멀쩡한 연결을 강제 종료함 (False Positive).
- **해결** (v0.5.12):
1. `hub.py`: `{"type": "heartbeat"}` JSON 메시지 수신 시 명시적으로 `{"type": "pong"}` JSON을 응답하도록 수정.
2. `ws-client.ts`: 명시적 `pong` 핸들러 추가. JSON pong 지원 서버거나 Node.js ws를 사용할 때만 60초 타임아웃 검증을 거치도록 조건 보강 (`forceHeartbeatTimeoutIfNoPong`).
- **주의**: 브라우저 표준 WebSockets(W3C)는 ping/pong 제어 프레임을 JS로 노출하지 않음. 폴리필/크로스플랫폼 WS 래퍼 사용 시 하트비트는 반드시 JSON 메세지 형태의 Application Layer Ping/Pong으로 풀어내거나, Native WS API 여부를 확실히 체크해야 함.
### [2026-03-28] [step-probe] GetCascadeTrajectorySteps UTF-8 에러 무한 루프
- **증상**: `guitar_score` 프로젝트에서 `[STEP-PROBE] error: ...invalid UTF-8` 에러가 5초마다 반복되며 Discord 승인 신호가 전달되지 않음.
- **원인**: AG LS 서버에서 특정 step의 `CortexStepEphemeralMessage.content`에 바이너리 데이터(이미지 등) 포함 → proto UTF-8 직렬화 500 에러. `catch(e)` 블록에서 `stallProbed=true`를 설정하지 않아 `!ctx.stallProbed` 조건이 항상 true → 5초마다 동일 요청 무한 재시도.
- **해결** (v0.5.11): `catch` 블록에서 UTF-8 에러 감지 시 `stepOffset=currentCount-20`으로 fallback 요청. offset도 실패 시 `stallProbed=true` 설정하여 루프 차단. `delta>0` 이벤트 발생 시 L433에서 자동 리셋.
- **주의**: `stallProbed=true`는 영구 Lock이 아님 — `delta>0` 시 자동 리셋. UTF-8 에러는 AG 서버 측 문제(이미지/바이너리 데이터가 ephemeral message에 포함)이므로 Extension에서 graceful fallback만 처리.
- **利앹긽**: `guitar_score` <20>벑 紐⑤뱺 <20><EFBFBD><20>솚寃쎌뿉<EC8E8C><20>빟 60珥덈쭏<EB8D88>떎 WebSocket <20>뿰寃곗씠 <20>걡湲곌퀬 <20><EFBFBD>뿰寃곕릺<EAB395><20><EFBFBD><EFBFBD>씠 諛섎났<EC848E>릺硫<EBA6BA>(extension.log<6F>뿉 `Heartbeat timeout` 怨꾩냽 異쒕젰), 洹<> <20><EFBFBD><20><EFBFBD>뒪肄붾뱶 <20><EFBFBD><20><EFBFBD>샇瑜<EC8387> <20>넃移<EB8483>.
- **<EFBFBD><EFBFBD>씤**: Extension<6F>씠 `ws` 紐⑤뱢 濡쒕뱶 <20><EFBFBD>뙣(VS Code <20>솚寃<EC869A> <20>벑)濡<> <20><EFBFBD>빐 釉뚮씪<EB9AAE><EFBFBD><EC8AA6><EFBFBD> <20><EFBFBD>옣 `WebSocket` 媛앹껜濡<EABB9C> Fallback <20>맖. 釉뚮씪<EB9AAE><EFBFBD><EC8AA6><EFBFBD> WS<57><20>꽌踰꾩쓽 <20><EFBFBD><EFBFBD>떚釉<EB969A> ping<6E>쓣 諛쏆븘 pong<6E><20><EFBFBD><20><EFBFBD><EFBFBD>븯吏<EBB8AF><EFBFBD> JS<4A><20>씠踰ㅽ듃瑜<EB9383> <20>끂異쒗븯吏<EBB8AF> <20><EFBFBD>쓬. <20>씠濡<EC94A0> <20><EFBFBD>빐 `lastPongTime` 媛깆떊<EAB986>씠 遺덇<E981BA><EB8D87><EFBFBD><EFBFBD><EFBFBD>졇, `Date.now() - lastPongTime > 60000` 議곌굔<EAB38C>씠 臾댁“嫄<E2809C> <20>넻怨쇰릺<EC87B0>뼱 硫<>姨≫븳 <20>뿰寃곗쓣 媛뺤젣 醫낅즺<EB8285>븿 (False Positive).
- **<EFBFBD>빐寃<EFBFBD>** (v0.5.12):
1. `hub.py`: `{"type": "heartbeat"}` JSON 硫붿떆吏<EB9686> <20><EFBFBD><20>떆 紐낆떆<EB8286><EFBFBD>쑝濡<EC919D> `{"type": "pong"}` JSON<4F><20><EFBFBD><EFBFBD><EFBFBD>룄濡<EBA384> <20><EFBFBD>젙.
2. `ws-client.ts`: 紐낆떆<EB8286>쟻 `pong` <20><EFBFBD><EFBFBD>윭 異붽<E795B0><EBB6BD>. JSON pong 吏<><EFA79E><20>꽌踰꾧굅<EABEA7>굹 Node.js ws瑜<73> <20><EFBFBD><EFBFBD><20>븣留<EBB8A3> 60珥<30> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>썐 寃<>利앹쓣 嫄곗튂<EAB397>룄濡<EBA384> 議곌굔 蹂닿컯 (`forceHeartbeatTimeoutIfNoPong`).
- **二쇱쓽**: 釉뚮씪<EB9AAE><EFBFBD><EC8AA6><EFBFBD> <20>몴以<EBAAB4> WebSockets(W3C)<29>뒗 ping/pong <20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD>쓣 JS濡<53> <20>끂異쒗븯吏<EBB8AF> <20><EFBFBD>쓬. <20>뤃由ы븘/<2F>겕濡쒖뒪<EC9296><EFBFBD><EFBFBD>뤌 WS <20><EFBFBD><20><EFBFBD><20><20><EFBFBD>듃鍮꾪듃<EABEAA>뒗 諛섎뱶<EC848E>떆 JSON 硫붿꽭吏<EABDAD> <20><EFBFBD><EFBFBD>쓽 Application Layer Ping/Pong<6E>쑝濡<EC919D> <20><><EFBFBD><EFBFBD><EFBFBD>궡嫄곕굹, Native WS API <20>뿬遺<EBBFAC><EFBFBD> <20><EFBFBD><EFBFBD>엳 泥댄겕<EB8C84><EFBFBD><20>븿.
### [2026-03-28] [step-probe] GetCascadeTrajectorySteps UTF-8 <20><EFBFBD>윭 臾댄븳 猷⑦봽
- **利앹긽**: `guitar_score` <20>봽濡쒖젥<EC9296><EFBFBD><EFBFBD>꽌 `[STEP-PROBE] error: ...invalid UTF-8` <20><EFBFBD>윭媛<EC9CAD> 5珥덈쭏<EB8D88>떎 諛섎났<EC848E>릺硫<EBA6BA> Discord <20><EFBFBD><20><EFBFBD>샇媛<EC8387> <20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD>쓬.
- **<2A><EFBFBD>씤**: AG LS <20>꽌踰꾩뿉<EABEA9><20><EFBFBD>젙 step<65>쓽 `CortexStepEphemeralMessage.content`<60>뿉 諛붿씠<EBB6BF>꼫由<EABCAB> <20><EFBFBD><EFBFBD>꽣(<28>씠誘몄<E8AA98><EBAA84> <20>벑) <20><EFBFBD>븿 <20>넂 proto UTF-8 吏곷젹<EAB3B7>솕 500 <20><EFBFBD>윭. `catch(e)` 釉붾줉<EBB6BE><EFBFBD>꽌 `stallProbed=true`瑜<> <20><EFBFBD><EFBFBD>븯吏<EBB8AF> <20><EFBFBD>븘 `!ctx.stallProbed` 議곌굔<EAB38C><20><EFBFBD>긽 true <20>넂 5珥덈쭏<EB8D88><20><EFBFBD><20>슂泥<EC8A82> 臾댄븳 <20><EFBFBD><EFBFBD>룄.
- **<2A>빐寃<EBB990>** (v0.5.11): `catch` 釉붾줉<EBB6BE><EFBFBD>꽌 UTF-8 <20><EFBFBD>윭 媛먯<E5AA9B><EBA8AF> <20>떆 `stepOffset=currentCount-20`<60>쑝濡<EC919D> fallback <20>슂泥<EC8A82>. offset<65><20><EFBFBD><20>떆 `stallProbed=true` <20><EFBFBD><EFBFBD><EFBFBD>뿬 猷⑦봽 李⑤떒. `delta>0` <20>씠踰ㅽ듃 諛쒖깮 <20>떆 L433<33><EFBFBD><20><EFBFBD>룞 由ъ뀑.
- **二쇱쓽**: `stallProbed=true`<60><20>쁺援<EC81BA> Lock<63><20><EFBFBD><20><><EFBFBD> `delta>0` <20><20><EFBFBD>룞 由ъ뀑. UTF-8 <20><EFBFBD><EFBFBD>뒗 AG <20>꽌踰<EABD8C><> 臾몄젣(<28>씠誘몄<E8AA98><EBAA84>/諛붿씠<EBB6BF>꼫由<EABCAB> <20><EFBFBD><EFBFBD>꽣媛<EABDA3> ephemeral message<67><20><EFBFBD>븿)<29>씠誘<EC94A0><EFBFBD> Extension<6F><EFBFBD>꽌 graceful fallback留<6B> 泥섎━.
### [2026-03-28] [approval-handler] stepIndex 誘명솗<EBAA85><20>떆 wrong-stepIndex RPC <20>궘鍮<EAB698>
- **利앹긽**: DOM observer 寃쎈줈濡<ECA488> `terminal_command` pending <20><EFBFBD><20>썑 Discord <20><EFBFBD><20>떆 `HandleCascadeUserInteraction(stepIndex=0)` <20>넂 `"input not registered for step 0"` <20>넂 LS reconnect <20><20><EFBFBD><EFBFBD><20>넂 DOM click fallback<63>쑝濡<EC919D> <20><><EFBFBD><EFBFBD>븯. (wrong-LS<4C><53><EFBFBD> <20><EFBFBD><EFBFBD>븳 利앹긽<EC95B9><EFBFBD><20>떎瑜<EB968E> <20><EFBFBD>씤)
- **<2A><EFBFBD>씤**: `ctx.lastPendingStepIndex=-1` (step-probe媛<65> UTF-8 <20><EFBFBD>윭濡<EC9CAD> WAITING 誘멸컧吏<ECBBA7>)<29><EFBFBD><EFBFBD>룄 `Math.max(0, -1)=0`<60>쑝濡<EC919D> clamp<6D><EFBFBD>뼱 議댁옱<EB8C81>븯吏<EBB8AF> <20><EFBFBD>뒗 step 0<>뿉 RPC <20><EFBFBD>넚.
- **<2A>빐寃<EBB990>** (v0.5.11): `effectiveStepIndex = stepIndex >= 0 ? stepIndex : (lastPendingStepIndex >= 0 ? lastPendingStepIndex : -1)`. `effectiveStepIndex < 0`<60>씠硫<EC94A0> RPC 釉붾줉 <20>쟾泥<EC9FBE> skip <20>넂 DOM click 吏곹뻾 (湲곗〈怨<E38088> <20><EFBFBD><20><EFBFBD>씪, LS reconnect <20>궘鍮<EAB698> <20>젣嫄<ECA0A3>).
- **二쇱쓽**: 湲곗〈 洹쒖튃 #14(`uint32`<60><20><EFBFBD>닔 湲덉<E6B9B2><EB8D89>)<29><><EFBFBD> 異⑸룎泥섎읆 蹂댁씠<EB8C81>굹, `effectiveStepIndex=-1`<60><20>븣 RPC <20>옄泥대<EFA7A3><EB8C80> **<2A><EFBFBD><EFBFBD>븯吏<EBB8AF> <20><EFBFBD>쑝誘<EC919D><EFBFBD>** <20>쐞諛<EC909E> <20><EFBFBD>떂. RPC <20><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>븳 stepIndex留<78> <20><EFBFBD>슜.
### [2026-03-28] [approval-handler] stepIndex 미확정 시 wrong-stepIndex RPC 낭비
- **증상**: DOM observer 경로로 `terminal_command` pending 생성 후 Discord 승인 시 `HandleCascadeUserInteraction(stepIndex=0)` → `"input not registered for step 0"` → LS reconnect → 재시도 → DOM click fallback으로 저하. (wrong-LS와 동일한 증상이나 다른 원인)
- **원인**: `ctx.lastPendingStepIndex=-1` (step-probe가 UTF-8 에러로 WAITING 미감지)임에도 `Math.max(0, -1)=0`으로 clamp되어 존재하지 않는 step 0에 RPC 전송.
- **해결** (v0.5.11): `effectiveStepIndex = stepIndex >= 0 ? stepIndex : (lastPendingStepIndex >= 0 ? lastPendingStepIndex : -1)`. `effectiveStepIndex < 0`이면 RPC 블록 전체 skip → DOM click 직행 (기존과 동작 동일, LS reconnect 낭비 제거).
- **주의**: 기존 규칙 #14(`uint32`에 음수 금지)와 충돌처럼 보이나, `effectiveStepIndex=-1`일 때 RPC 자체를 **전송하지 않으므로** 위반 아님. RPC 전송 시에는 여전히 유효한 stepIndex만 사용.
### [2026-03-25] [Architecture] Discord Signal Drop & Extension Freezes
- **증상**: 장시간 자리비움 후 복귀 시 Discord로 승인 신호가 오지 않거나 VS Code UI가 간헐적/지속적으로 멈춤(Freeze).
- **원인**:
1. `ws.onerror` 발생 후 `onclose` 누락 시 재연결 콜백 호출이 이루어지지 않아 무한 대기 (장시간 마비)
2. `ws-client` 재연결 시 누적된 200개 큐를 동기식 burst 전송하여 Hub의 속도 제한(60개/10초)에 걸려 확정 영구 삭제됨
3. 로컬 브릿지 `http-bridge.ts`의 과거 유산인 `FALSE_POSITIVE_RE` 정규식이 AI 고유 버튼(Allow, Deny, Accept) 마저 필터링하여 Discord 전송 원천 차단
4. `step-probe.ts` 폴링 루프 내 동기식 파일 I/O 사용으로 인한 프리즈
- **해결** (v0.5.10): ws-client에 하드 타임아웃 및 50ms Paced-flush 적용, http-bridge의 정규식 기능 완화, step-probe 비동기 I/O 전환 체제 적용, observer-script의 필터된 신호 무한 HTTP 폴링 방어 코드 반영.
- **주의**: Extension 내부 로직 버그였으므로 Hub(Python) 코드는 건드리지 않음. Hub 속도 제한은 정상 방어 기제이므로 클라이언트 단의 Pacing이 올바른 방향임.
### [2026-03-24] DOM Observer /trigger-click 렌더링 순서 오작동 및 False Positive 프리징
- **증상**: v0.5.9 패치 이후 코딩 시 Agent 화면이 끊임없이 서명 대기(Pending) 상태로 멈춤. 또는 디스코드에서 `Approve` 시 에디터 내의 엉뚱한 `Run Test`(코드 렌즈)를 클릭함.
- **원인**: 텍스트와 정규식(`/^Run/i` 등)에만 의존하여 `querySelectorAll`을 수행할 경우, DOM 트리에 렌더링된 수많은 VS Code 네이티브 코드 렌즈 버튼을 Agent 버튼보다 먼저 찾아버리는 발생 위치(Context)의 한계점.
- **해결** (v0.5.10):
1. 감지(Scan): `isVSCodeMainWindow` 및 탐색 노드 `isBodyRoot` 확인을 통해, 에디터 본문 영역에서는 "Run", "Approve" 감지를 원천 제거 (오직 패널 내로 한정).
2. 클릭(Trigger-click): `deepFindButtons()` 내에서 `findPanel()`(에이전트 패널) -> 알림 Toasts -> Document 본문 순으로 탐색 **우선순위(Priority)**를 강제 적용.
- **주의**: 버튼 이벤트 후킹 시 텍스트 매칭에만 의존하지 말고, 반드시 DOM 탐색 우선순위와 컨텍스트 범위를 함께 필터링하여 False Positive를 차단할 것.
### [2026-03-24] DOM Observer — VS Code Native UI Blind Spot
- **증상**: "Always Allow" 및 일반 "Allow Alt+↵" 권한 알림 버튼이 디스코드 권한 센싱에서 완전히 누락됨.
- **원인**: VS Code 네이티브 알림 및 채팅 패널 내의 버튼은 `<button>` 태그 대신 `<a role="button">`, `<vscode-button>` 등을 사용하는데, 기존 DOM scan 로직이 `querySelectorAll('button')`으로 하드코딩되어 노드를 아예 찾지 못함. (추가로 Always Allow 정규식 누락)
- **해결** (v0.5.9): DOM scan, 리슨 훅 등 모든 탐색 로직 셀렉터를 `button, [role="button"], vscode-button, .monaco-text-button` 으로 전면 개편. 정규식을 `/^(?:Always )?Allow/i`로 수정.
- **利앹긽**: <20><EFBFBD>떆媛<EB9686> <20>옄由щ퉬<D189><ED89AC><EFBFBD> <20>썑 蹂듦<E8B982><EB93A6> <20>떆 Discord濡<64> <20><EFBFBD><20><EFBFBD>샇媛<EC8387> <20>삤吏<EC82A4> <20>븡嫄곕굹 VS Code UI媛<49> 媛꾪뿉<EABEAA>쟻/吏<><EFA79E><EFBFBD><EFBFBD>쑝濡<EC919D> 硫덉땄(Freeze).
- **<EFBFBD><EFBFBD>씤**:
1. `ws.onerror` 諛쒖깮 <20>썑 `onclose` <20><EFBFBD><20><20><EFBFBD>뿰寃<EBBFB0> 肄쒕갚 <20>샇異쒖씠 <20>씠猷⑥뼱吏<EBBCB1><EFBFBD> <20><EFBFBD>븘 臾댄븳 <20><><EFBFBD><EFBFBD> (<28><EFBFBD>떆媛<EB9686> 留덈퉬)
2. `ws-client` <20><EFBFBD>뿰寃<EBBFB0> <20><20><EFBFBD><EFBFBD>맂 200媛<30> <20>걧瑜<EAB1A7> <20>룞湲곗떇 burst <20><EFBFBD><EFBFBD><EFBFBD>뿬 Hub<75><20><EFBFBD><20><EFBFBD>븳(60媛<30>/10珥<30>)<29>뿉 嫄몃젮 <20><EFBFBD><20>쁺援<EC81BA> <20><EFBFBD><EFBFBD>
3. 濡쒖뺄 釉뚮┸吏<E294B8> `http-bridge.ts`<60>쓽 怨쇨굅 <20><EFBFBD><EFBFBD>씤 `FALSE_POSITIVE_RE` <20>젙洹쒖떇<EC9296>씠 AI 怨좎쑀 踰꾪듉(Allow, Deny, Accept) 留덉<EFA78D><EB8D89> <20><EFBFBD>꽣留곹븯<EAB3B9>뿬 Discord <20><EFBFBD><20>썝泥<EC8D9D> 李⑤떒
4. `step-probe.ts` <20>뤃留<EBA483> 猷⑦봽 <20><20>룞湲곗떇 <20><EFBFBD>씪 I/O <20><EFBFBD><EFBFBD>쑝濡<EC919D> <20><EFBFBD><20>봽由ъ쫰
- **<2A>빐寃<EBB990>** (v0.5.10): ws-client<6E><20><EFBFBD><20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>썐 諛<> 50ms Paced-flush <20><EFBFBD>슜, http-bridge<67><20>젙洹쒖떇 湲곕뒫 <20><EFBFBD>솕, step-probe 鍮꾨룞湲<EBA39E> I/O <20><EFBFBD>솚 泥댁젣 <20><EFBFBD>슜, observer-script<70><20><EFBFBD><EFBFBD><20><EFBFBD>샇 臾댄븳 HTTP <20>뤃留<EBA483> 諛⑹뼱 肄붾뱶 諛섏쁺.
- **二쇱쓽**: Extension <20>궡遺<EAB6A1> 濡쒖쭅 踰꾧렇<EABEA7><EBA087><EFBFBD><EFBFBD>쑝誘<EC919D><EFBFBD> Hub(Python) 肄붾뱶<EBB6BE>뒗 嫄대뱶由ъ<E794B1><D18A> <20><EFBFBD>쓬. Hub <20><EFBFBD><20><EFBFBD><EFBFBD><EBB8B3><EFBFBD> <20><EFBFBD>긽 諛⑹뼱 湲곗젣<EAB397>씠誘<EC94A0><EFBFBD> <20><EFBFBD><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD>쓽 Pacing<6E><20>삱諛붾Ⅸ 諛⑺뼢<E291BA>엫.
### [2026-03-24] DOM Observer /trigger-click <20><EFBFBD>뜑留<EB9C91> <20><EFBFBD><20><EFBFBD><EFBFBD>룞 諛<> False Positive <20>봽由ъ쭠
- **利앹긽**: v0.5.9 <20>뙣移<EB99A3> <20><EFBFBD>썑 肄붾뵫 <20>떆 Agent <20>솕硫댁씠 <20><EFBFBD><EFBFBD><EFBFBD><20>꽌紐<EABD8C> <20><><EFBFBD><EFBFBD>(Pending) <20><EFBFBD>깭濡<EAB9AD> 硫덉땄. <20><EFBFBD><20><EFBFBD>뒪肄붾뱶<EBB6BE><EFBFBD>꽌 `Approve` <20><20><EFBFBD><EFBFBD><20><EFBFBD><20><EFBFBD><EFBFBD>븳 `Run Test`(肄붾뱶 <20>젋利<ECA08B>)瑜<> <20>겢由<EAB2A2><E794B1>븿.
- **<2A><EFBFBD>씤**: <20><EFBFBD><EFBFBD><EFBFBD><EB9383><EFBFBD> <20>젙洹쒖떇(`/^Run/i` <20>벑)<29>뿉留<EBBF89> <20>쓽議댄븯<EB8C84>뿬 `querySelectorAll`<60><20><EFBFBD><EFBFBD>븷 寃쎌슦, DOM <20>듃由ъ뿉 <20><EFBFBD>뜑留곷맂 <20>닔留롮<EFA78D><EBA1AE> VS Code <20><EFBFBD><EFBFBD>떚釉<EB969A> 肄붾뱶 <20>젋利<ECA08B> 踰꾪듉<EABEAA>쓣 Agent 踰꾪듉蹂대떎 癒쇱<E79992><EC87B1> 李얠븘踰꾨━<EABEA8>뒗 諛쒖깮 <20>쐞移<EC909E>(Context)<29><20>븳怨꾩젏.
- **<2A>빐寃<EBB990>** (v0.5.10):
1. 媛먯<E5AA9B><EBA8AF>(Scan): `isVSCodeMainWindow` 諛<> <20><EFBFBD><20><EFBFBD>뱶 `isBodyRoot` <20><EFBFBD><EFBFBD><20><EFBFBD>빐, <20><EFBFBD><EFBFBD>꽣 蹂몃Ц <20><EFBFBD><EFBFBD><EFBFBD><EFBFBD>뒗 "Run", "Approve" 媛먯<E5AA9B><EBA8AF><EFBFBD> <20>썝泥<EC8D9D> <20>젣嫄<ECA0A3> (<28>삤吏<EC82A4> <20><EFBFBD><20>궡濡<EAB6A1> <20><EFBFBD>젙).
2. <20>겢由<EAB2A2>(Trigger-click): `deepFindButtons()` <20><EFBFBD><EFBFBD>꽌 `findPanel()`(<28><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD>꼸) -> <20>븣由<EBB8A3> Toasts -> Document 蹂몃Ц <20><EFBFBD>쑝濡<EC919D> <20><EFBFBD>깋 **<2A><EFBFBD><EFBFBD><EFBFBD>쐞(Priority)**瑜<> 媛뺤젣 <20><EFBFBD>슜.
- **二쇱쓽**: 踰꾪듉 <20>씠踰ㅽ듃 <20><EFBFBD><20><20><EFBFBD><EFBFBD>듃 留ㅼ묶<E385BC>뿉留<EBBF89> <20>쓽議댄븯吏<EBB8AF> 留먭퀬, 諛섎뱶<EC848E>떆 DOM <20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EC909E><EFBFBD> 而⑦뀓<E291A6><EFBFBD>듃 踰붿쐞瑜<EC909E> <20>븿猿<EBB8BF> <20><EFBFBD>꽣留곹븯<EAB3B9>뿬 False Positive瑜<65> 李⑤떒<E291A4>븷 寃<>.
### [2026-03-24] DOM Observer <20><><EFBFBD> VS Code Native UI Blind Spot
- **利앹긽**: "Always Allow" 諛<> <20>씪諛<EC94AA> "Allow Alt+<2B>넻" 沅뚰븳 <20>븣由<EBB8A3> 踰꾪듉<EABEAA><20><EFBFBD>뒪肄붾뱶 沅뚰븳 <20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>맖.
- **<2A><EFBFBD>씤**: VS Code <20><EFBFBD><EFBFBD>떚釉<EB969A> <20>븣由<EBB8A3><> 梨꾪똿 <20><EFBFBD><20><EFBFBD>쓽 踰꾪듉<EABEAA><EB9389><EFBFBD> `<button>` <20>깭洹<EAB9AD> <20><><EFBFBD><EFBFBD>떊 `<a role="button">`, `<vscode-button>` <20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><EFBFBD>뜲, 湲곗〈 DOM scan 濡쒖쭅<EC9296>씠 `querySelectorAll('button')`<60>쑝濡<EC919D> <20><EFBFBD>뱶肄붾뵫<EBB6BE><EFBFBD><20><EFBFBD>뱶瑜<EBB1B6> <20><EFBFBD>삁 李얠<EFA7A1><EC96A0> 紐삵븿. (異붽<E795B0><EBB6BD><EFBFBD> Always Allow <20>젙洹쒖떇 <20><EFBFBD>씫)
- **<2A>빐寃<EBB990>** (v0.5.9): DOM scan, 由ъ뒯 <20><20>벑 紐⑤뱺 <20><EFBFBD>깋 濡쒖쭅 <20><><EFBFBD><EFBFBD><EFBFBD>꽣瑜<EABDA3> `button, [role="button"], vscode-button, .monaco-text-button` <20>쑝濡<EC919D> <20>쟾硫<EC9FBE> 媛쒗렪. <20>젙洹쒖떇<EC9296>쓣 `/^(?:Always )?Allow/i`濡<> <20><EFBFBD>젙.
### [2026-03-24] Python Hub <20><><EFBFBD><><EFBFBD> 而ㅻ꽖<E385BB>뀡 諛<> UI <20>봽由ъ쭠
- **利앹긽**: `npm run` 紐낅졊<EB8285>씠 `<EFBFBD><EFBFBD><20>젙梨<ECA099>` 愿<><E684BF><20>삤瑜섎줈 <20><EFBFBD>
- **<2A><EFBFBD>씤**: PowerShell <20><EFBFBD>겕由쏀듃 <20><EFBFBD><20>젙梨낆씠 <20><EFBFBD><EFBFBD>
- **<2A>빐寃<EBB990>**: `cmd /c npm run dev` <20><EFBFBD><EFBFBD>쑝濡<EC919D> cmd瑜<64> <20><EFBFBD><20><EFBFBD>
- **二쇱쓽**: npm 愿<><E684BF>젴 紐낅졊<EB8285><ECA18A><EFBFBD> <20><EFBFBD>긽 `cmd /c` <20><EFBFBD><EFBFBD><20><EFBFBD>슜 沅뚯옣
### [2026-03-08] PowerShell curl <20><><EFBFBD> Invoke-WebRequest 異⑸룎
- **利앹긽**: `curl` 紐낅졊<EB8285><20><EFBFBD>긽怨<EAB8BD> <20>떎瑜<EB968E> <20><EFBFBD><20><EFBFBD><EFBFBD>쓣 諛섑솚
- **<2A><EFBFBD>씤**: PowerShell<6C><EFBFBD>꽌 `curl`<60><><EFBFBD> `Invoke-WebRequest`<60>쓽 蹂꾩묶
- **<2A>빐寃<EBB990>**: **`curl.exe`**瑜<> 紐낆떆<EB8286><EFBFBD>쑝濡<EC919D> <20><EFBFBD>
- **二쇱쓽**: HTTP 愿<><E684BF>젴 紐⑤뱺 紐낅졊<EB8285><EFBFBD>꽌 `curl.exe` <20><EFBFBD><20><EFBFBD>
### [2026-03-24] Python Hub — 좀비 커넥션 및 UI 프리징
- **증상**: `npm run` 명령이 `실행 정책` 관련 오류로 실패
- **원인**: PowerShell 스크립트 실행 정책이 제한적
- **해결**: `cmd /c npm run dev` 형식으로 cmd를 통해 실행
- **주의**: npm 관련 명령은 항상 `cmd /c` 접두어 사용 권장
### [2026-03-08] PowerShell curl — Invoke-WebRequest 충돌
- **증상**: `curl` 명령이 예상과 다른 응답 형식을 반환
- **원인**: PowerShell에서 `curl`은 `Invoke-WebRequest`의 별칭
- **해결**: **`curl.exe`**를 명시적으로 사용
- **주의**: HTTP 관련 모든 명령에서 `curl.exe` 사용 필수
---
## 미해결 이슈
### [2026-03-23/24] 평생 지속되는 WebSocket 좀비 커넥션 및 False Positive 강제 연결 끊김 (v0.5.5 → 0.5.8)
- **증상**:
1. (v0.5.5) 절전 모드 복구 시 실연결이 끊어졌음에도 확장이 이를 인지하지 못하는 좀비(Half-open) 소켓 발생.
2. (v0.5.6) 좀비 소켓을 잡기 위해 10초 타이머(`pongTimeoutTimer`)를 넣었으나, VS Code의 무거운 파일 검색 시 Event Loop가 블로킹되면 멀쩡한 연결인데도 허위 타임아웃(False Positive) 판정으로 연결을 강제 종료함. 이로 인해 누적된 재연결 딜레이(Exponential Backoff)가 60초까지 늘어나면서 확장이 심각하게 멈춤(Freeze).
- **원인**: Node.js `ws` 라이브러리의 `ws.ping()`은 비동기 I/O 네트워크 큐를 타지만, `setTimeout(..., 10000)` 타임아웃은 Event Loop 블로킹 해제 직후 곧바로 만료되어 버림. 따라서 네트워크 I/O 응답(pong)보다 로컬 타이머가 먼저 터져서 정상적인 소켓을 죽임.
- **해결** (v0.5.8 완성):
- 위험한 `setTimeout` 방식 폐기.
- 기존의 25초 주기 `setInterval` 하트비트 루프 내부에 `Date.now() - lastPongTime > 60000` (60초 초과 시 타임아웃) 검증 로직을 도입.
- 만약 Event Loop가 수십 초 밀리더라도, 블로킹 해제 후 큐된 I/O 이벤트(`pong`)가 `setInterval` 타이머 콜백 이전에 먼저 처리되거나(Node.js Phase 규칙), 적어도 60초라는 버퍼 덕분에 **False Positive 가능성을 원천 차단**함과 동시에 좀비 소켓을 안정적으로 제거함.
- **주의**: Node.js의 단일 스레드 Event Loop 환경(특히 무거운 동기 작업이 잦은 VS Code Extension)에서 네트워크 I/O를 로컬 `setTimeout`과 경주(Race)시키는 설계는 필연적으로 False Positive를 낳음. Timestamp(`Date.now()`) 기반 간격 검증(Interval check)이 훨씬 안전함.
### [2026-03-11] rejectAgentStep / !stop — AG 미등록 커맨드 + 렌더러 전용 함수 + 스테일 프리미티브
- **증상**: `!stop` 명령이 AI를 멈추지 못함. 로그: "No active cascade" / "no session tracked yet"
- **원인**: (1) `antigravity.agent.rejectAgentStep`은 AG 미등록 커맨드. (2) 대체한 `getActiveCascadeId()`는 **렌더러(DOM) 전용 함수** — Extension host에서 항상 `undefined` 반환. (3) **v0.4.5 수정도 실패**: `extension.ts`의 `getActiveSessionId: () => activeSessionId`가 module-level 스트링 프리미티브를 참조 — step-probe가 `ctx.activeSessionId`를 업데이트해도 extension.ts의 변수는 불변 (프리미티브 복사)
- **해결** (2026-03-18 v0.4.6): `step-probe.ts`에서 `getActiveSessionId()` getter 함수 export → extension.ts closures에서 `getStepProbeSessionId()` 호출. 이제 step-probe의 live `ctx.activeSessionId`를 직접 읽음 (`ab0c116`)
- **주의**: JS에서 **string/number는 프리미티브라 참조 전달 불가** — 객체 속성을 공유하려면 getter 함수나 객체 래퍼 사용 필수
## 誘명빐寃<EBB990> <20><EFBFBD>
### [2026-03-23/24] <20><EFBFBD>깮 吏<><EFA79E><EFBFBD><EFBFBD>뒗 WebSocket 醫<><EFBFBD> 而ㅻ꽖<E385BB>뀡 諛<> False Positive 媛뺤젣 <20>뿰寃<EBBFB0> <20>걡源<EAB1A1> (v0.5.5 <20>넂 0.5.8)
- **利앹긽**:
1. (v0.5.5) <20><EFBFBD>쟾 紐⑤뱶 蹂듦뎄 <20><20><EFBFBD>뿰寃곗씠 <20><EFBFBD>뼱議뚯쓬<EB9AAF><EFBFBD><20><EFBFBD><EFBFBD><20>씠瑜<EC94A0> <20>씤吏<EC94A4><EFA79E>븯吏<EBB8AF> 紐삵븯<EC82B5>뒗 醫<><EFBFBD>(Half-open) <20>냼耳<EB83BC> 諛쒖깮.
2. (v0.5.6) 醫<><EFBFBD> <20>냼耳볦쓣 <20>옟湲<EC989F> <20><EFBFBD>빐 10珥<30> <20><><EFBFBD><EFBFBD>씠癒<EC94A0>(`pongTimeoutTimer`)瑜<> <20><EFBFBD><EFBFBD><EFBFBD>굹, VS Code<64>쓽 臾닿굅<EB8BBF><20><EFBFBD>씪 寃<><E5AF83><20>떆 Event Loop媛<70> 釉붾줈<EBB6BE><EFBFBD>릺硫<EBA6BA><>姨≫븳 <20>뿰寃곗씤<EAB397><EFBFBD><20><EFBFBD><20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>썐(False Positive) <20><EFBFBD><EFBFBD>쑝濡<EC919D> <20>뿰寃곗쓣 媛뺤젣 醫낅즺<EB8285>븿. <20>씠濡<EC94A0> <20><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD>뿰寃<EBBFB0> <20><EFBFBD><EFBFBD>씠(Exponential Backoff)媛<> 60珥덇퉴吏<ED89B4> <20><EFBFBD><EFBFBD>굹硫댁꽌 <20><EFBFBD><EFBFBD><20>떖媛곹븯寃<EBB8AF> 硫덉땄(Freeze).
- **<2A><EFBFBD>씤**: Node.js `ws` <20><EFBFBD>씠釉뚮윭由ъ쓽 `ws.ping()`<60><><EFBFBD> 鍮꾨룞湲<EBA39E> I/O <20><EFBFBD><EFBFBD><EFBFBD><20>걧瑜<EAB1A7> <20><><EFBFBD><EFBFBD><EFBFBD>, `setTimeout(..., 10000)` <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EC8D90><EFBFBD> Event Loop 釉붾줈<EBB6BE><20><EFBFBD>젣 吏곹썑 怨㏓컮濡<ECBBAE> 留뚮즺<EB9AAE><EFBFBD>뼱 踰꾨┝. <20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD>겕 I/O <20><EFBFBD>떟(pong)蹂대떎 濡쒖뺄 <20><><EFBFBD><EFBFBD>씠癒멸<E79992><EBA9B8> 癒쇱<E79992><EC87B1> <20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><20>냼耳볦쓣 二쎌엫.
- **<2A>빐寃<EBB990>** (v0.5.8 <20><EFBFBD>꽦):
- <20><EFBFBD><EFBFBD>븳 `setTimeout` 諛⑹떇 <20>룓湲<EBA393>.
- 湲곗〈<EAB397>쓽 25珥<35> 二쇨린 `setInterval` <20><EFBFBD>듃鍮꾪듃 猷⑦봽 <20>궡遺<EAB6A1><E981BA>뿉 `Date.now() - lastPongTime > 60000` (60珥<30> 珥덇낵 <20><20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>썐) 寃<><EFBFBD> 濡쒖쭅<EC9296><20><EFBFBD>엯.
- 留뚯빟 Event Loop媛<70> <20><EFBFBD>떗 珥<><>由щ뜑<D189><EFBFBD>룄, 釉붾줈<EBB6BE><20><EFBFBD><20><20><EFBFBD>맂 I/O <20>씠踰ㅽ듃(`pong`)媛<> `setInterval` <20><><EFBFBD><EFBFBD>씠癒<EC94A0> 肄쒕갚 <20><EFBFBD><EFBFBD>뿉 癒쇱<E79992><EC87B1> 泥섎━<EC848E>릺嫄곕굹(Node.js Phase 洹쒖튃), <20><EFBFBD><EFBFBD>룄 60珥덈씪<EB8D88>뒗 踰꾪띁 <20>뜒遺꾩뿉 **False Positive 媛<><E5AA9B><EFBFBD><EFBFBD><20>썝泥<EC8D9D> 李⑤떒**<2A>븿怨<EBB8BF> <20><EFBFBD><EFBFBD>뿉 醫<><EFBFBD> <20>냼耳볦쓣 <20><EFBFBD><EFBFBD><EFBFBD>쑝濡<EC919D> <20>젣嫄고븿.
- **二쇱쓽**: Node.js<6A><20><EFBFBD><20><EFBFBD><EFBFBD>뱶 Event Loop <20>솚寃<EC869A>(<28><EFBFBD>엳 臾닿굅<EB8BBF><20>룞湲<EBA39E> <20><EFBFBD><EFBFBD><20><EFBFBD><EC98A6><EFBFBD> VS Code Extension)<29><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD>겕 I/O瑜<4F> 濡쒖뺄 `setTimeout`怨<> 寃쎌<(Race)<29><EFBFBD><EFBFBD><20>꽕怨꾨뒗 <20><EFBFBD><EFBFBD><EFBFBD>쑝濡<EC919D> False Positive瑜<65> <20><EFBFBD>쓬. Timestamp(`Date.now()`) 湲곕컲 媛꾧꺽 寃<><EFBFBD>(Interval check)<29><20><EFBFBD><20><EFBFBD><EFBFBD>븿.
### [2026-03-11] rejectAgentStep / !stop <20><><EFBFBD> AG 誘몃벑濡<EBB291> 而ㅻ㎤<E385BB>뱶 + <20><EFBFBD><EFBFBD><20><EFBFBD><20>븿<EFBFBD>닔 + <20><EFBFBD><EFBFBD><20>봽由щ<E794B1>명떚釉<EB969A>
- **利앹긽**: `!stop` 紐낅졊<EB8285>씠 AI瑜<49> 硫덉텛吏<ED859B> 紐삵븿. 濡쒓렇: "No active cascade" / "no session tracked yet"
- **<2A><EFBFBD>씤**: (1) `antigravity.agent.rejectAgentStep`<60><><EFBFBD> AG 誘몃벑濡<EBB291> 而ㅻ㎤<E385BB>뱶. (2) <20><><EFBFBD>泥댄븳 `getActiveCascadeId()`<60>뒗 **<2A><EFBFBD><EFBFBD>윭(DOM) <20><EFBFBD><20>븿<EFBFBD>닔** <20><><EFBFBD> Extension host<73><EFBFBD><20><EFBFBD>긽 `undefined` 諛섑솚. (3) **v0.4.5 <20><EFBFBD><EFBFBD><20><EFBFBD>뙣**: `extension.ts`<60>쓽 `getActiveSessionId: () => activeSessionId`媛<> module-level <20><EFBFBD>듃留<EB9383> <20>봽由щ<E794B1>명떚釉뚮<E98789><EB9AAE> 李몄“ <20><><EFBFBD> step-probe媛<65> `ctx.activeSessionId`瑜<> <20><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>룄 extension.ts<74>쓽 蹂<><E8B982><EFBFBD>뒗 遺덈<E981BA><EB8D88> (<28>봽由щ<E794B1>명떚釉<EB969A> 蹂듭궗)
- **<2A>빐寃<EBB990>** (2026-03-18 v0.4.6): `step-probe.ts`<60><EFBFBD>꽌 `getActiveSessionId()` getter <20>븿<EFBFBD>닔 export <20>넂 extension.ts closures<65><EFBFBD>꽌 `getStepProbeSessionId()` <20>샇異<EC8387>. <20><EFBFBD>젣 step-probe<62>쓽 live `ctx.activeSessionId`瑜<> 吏곸젒 <20><EFBFBD>쓬 (`ab0c116`)
- **二쇱쓽**: JS<4A><EFBFBD>꽌 **string/number<65><20>봽由щ<E794B1>명떚釉뚮씪 李몄“ <20><EFBFBD>떖 遺덇<E981BA><EB8D87>** <20><><EFBFBD> 媛앹껜 <20><EFBFBD><EFBFBD>쓣 怨듭쑀<EB93AD><EFBFBD>젮硫<ECA0AE> getter <20>븿<EFBFBD><EFBFBD>굹 媛앹껜 <20><EFBFBD><20><EFBFBD><20><EFBFBD>
- **Vikunja**: #411, #410
### [2026-03-19] browser_subagent Allow — 잘못된 RPC payload
- **증상**: 서브 에이전트 "execute JavaScript on localhost" Allow 버튼이 자동 승인되지 않음
- **원인**: `step-probe.ts`에서 `browser_subagent` toolName이 step_type 분류 없이 raw toolName으로 전달 → `approval-handler.ts`에서 `runExtensionCode` 매핑에 포함되지 않아 default `runCommand` RPC payload 사용 → AG가 잘못된 interaction type으로 무시
- **해결** (v0.5.1): `approval-handler.ts` L384에 `browser_subagent` 추가, `step-probe.ts` L481/L549에 `browser_subagent`/`open_browser_url` step_type 분류 추가 (`549af6d`)
- **주의**: 새로운 AG 도구 추가 시 반드시 (1) step-probe step_type 매핑 (2) approval-handler RPC payload 매핑 양쪽 모두 업데이트
### [2026-03-21] Idle→Resume 신호 소실 — 3중 버그
- **증상**: AG 장시간 idle 후 작업 재개 시 Discord 승인 신호가 전달되지 않음
- **원인**: (1) `ws-client.ts` `auth_fail` 시 `shouldReconnect=false` — JWT 24h 만료 시 WS 영구 종료. (2) `hub.py` `_disconnect`에서 유일 연결 시 `pending_owners` 삭제 — 재연결 후 Discord 버튼 무효. (3) `step-probe.ts` `stallProbed=true` + `lastPendingStepIndex=N`이 WS 재연결 시 리셋 안 됨 — WAITING step 재전송 영구 차단
- **해결** (v0.5.2): (1) `auth_fail` → `registrationCode` 재시도. (2) `pending_owners` orphan 마커로 보존+재할당. (3) `resetPendingStateForReconnect()` + `onConnected`에서 호출
- **주의**: WS `onConnected`에서 반드시 step-probe 상태 리셋 필수. `stallProbed`/`lastPendingStepIndex`는 TTL 없는 영구 값
### [2026-03-19] browser_subagent Allow <20><><EFBFBD> <20>옒紐삳맂 RPC payload
- **利앹긽**: <20>꽌釉<EABD8C> <20><EFBFBD><EFBFBD><EFBFBD>듃 "execute JavaScript on localhost" Allow 踰꾪듉<EABEAA><20><EFBFBD><20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD>
- **<2A><EFBFBD>씤**: `step-probe.ts`<60><EFBFBD>꽌 `browser_subagent` toolName<6D>씠 step_type 遺꾨쪟 <20><EFBFBD>씠 raw toolName<6D>쑝濡<EC919D> <20><EFBFBD><20>넂 `approval-handler.ts`<60><EFBFBD>꽌 `runExtensionCode` 留ㅽ븨<E385BD><20><EFBFBD>븿<EFBFBD>릺吏<EBA6BA> <20><EFBFBD>븘 default `runCommand` RPC payload <20><EFBFBD><20>넂 AG媛<47> <20>옒紐삳맂 interaction type<70>쑝濡<EC919D> 臾댁떆
- **<2A>빐寃<EBB990>** (v0.5.1): `approval-handler.ts` L384<38>뿉 `browser_subagent` 異붽<E795B0><EBB6BD>, `step-probe.ts` L481/L549<34>뿉 `browser_subagent`/`open_browser_url` step_type 遺꾨쪟 異붽<E795B0><EBB6BD> (`549af6d`)
- **二쇱쓽**: <20>깉濡쒖슫 AG <20>룄援<EBA384> 異붽<E795B0><EBB6BD> <20>떆 諛섎뱶<EC848E>떆 (1) step-probe step_type 留ㅽ븨 (2) approval-handler RPC payload 留ㅽ븨 <20>뼇履<EBBC87> 紐⑤몢 <20><EFBFBD><EFBFBD><EFBFBD>
### [2026-03-21] Idle<6C>넂Resume <20><EFBFBD><20><EFBFBD><20><><EFBFBD> 3以<33> 踰꾧렇
- **利앹긽**: AG <20><EFBFBD>떆媛<EB9686> idle <20><20><EFBFBD><20>옱媛<EC98B1> <20>떆 Discord <20><EFBFBD><20><EFBFBD>샇媛<EC8387> <20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD>
- **<2A><EFBFBD>씤**: (1) `ws-client.ts` `auth_fail` <20>떆 `shouldReconnect=false` <20><><EFBFBD> JWT 24h 留뚮즺 <20>떆 WS <20>쁺援<EC81BA> 醫낅즺. (2) `hub.py` `_disconnect`<60><EFBFBD><20><EFBFBD><20>뿰寃<EBBFB0> <20>떆 `pending_owners` <20><EFBFBD><20><><EFBFBD> <20><EFBFBD>뿰寃<EBBFB0> <20>썑 Discord 踰꾪듉 臾댄슚. (3) `step-probe.ts` `stallProbed=true` + `lastPendingStepIndex=N`<60>씠 WS <20><EFBFBD>뿰寃<EBBFB0> <20>떆 由ъ뀑 <20><20><20><><EFBFBD> WAITING step <20><EFBFBD><EFBFBD><20>쁺援<EC81BA> 李⑤떒
- **<2A>빐寃<EBB990>** (v0.5.2): (1) `auth_fail` <20>넂 `registrationCode` <20><EFBFBD><EFBFBD>룄. (2) `pending_owners` orphan 留덉빱濡<EBB9B1> 蹂댁〈+<2B><EFBFBD><EFBFBD>떦. (3) `resetPendingStateForReconnect()` + `onConnected`<60><EFBFBD><20>샇異<EC8387>
- **二쇱쓽**: WS `onConnected`<60><EFBFBD>꽌 諛섎뱶<EC848E>떆 step-probe <20><EFBFBD>깭 由ъ뀑 <20><EFBFBD>닔. `stallProbed`/`lastPendingStepIndex`<60>뒗 TTL <20><EFBFBD><20>쁺援<EC81BA><>
---
> [!NOTE]
> v0.4.5 수정 사항(Hub pending_owners, diff_review WS, auto_approve 이중쓰기, WS dual-write, ApprovalView fallback)은
> 코드 수정 완료됨. E2E 통합 검증은 Vikunja #410에서 추적 중.
### [2026-03-21] stepIndex=-1 — AG proto uint32 에러
- **증상**: DOM observer가 Allow 버튼 감지 → Discord 승인 → RPC `HandleCascadeUserInteraction` 400 에러
- **원인**: DOM observer 경로는 step index를 모름 → `stepIndex=-1` 전달 → AG proto `uint32` 필드에 음수 불가
- **해결**: `Math.max(0, ...)` 로 clamp. `permission` type → `runExtensionCode.confirm` 매핑 추가 (v0.5.4)
- **주의**: DOM observer 경로의 step_type은 항상 `stepIndex=-1`일 수 있으므로 proto 전달 전 양수 보장 필수
> v0.4.5 <20><EFBFBD><20><EFBFBD>빆(Hub pending_owners, diff_review WS, auto_approve <20>씠以묒벐湲<EBB290>, WS dual-write, ApprovalView fallback)<29><><EFBFBD>
> 肄붾뱶 <20><EFBFBD><20>셿猷뚮맖. E2E <20><EFBFBD>빀 寃<>利앹<EFA79D><EC95B9> Vikunja #410<31><EFBFBD>꽌 異붿쟻 以<>.
### [2026-03-21] stepIndex=-1 <20><><EFBFBD> AG proto uint32 <20><EFBFBD>
- **利앹긽**: DOM observer媛<72> Allow 踰꾪듉 媛먯<E5AA9B><EBA8AF> <20>넂 Discord <20><EFBFBD><20>넂 RPC `HandleCascadeUserInteraction` 400 <20><EFBFBD>
- **<2A><EFBFBD>씤**: DOM observer 寃쎈줈<EC8E88>뒗 step index瑜<78> 紐⑤쫫 <20>넂 `stepIndex=-1` <20><EFBFBD><20>넂 AG proto `uint32` <20><EFBFBD><EFBFBD><20><EFBFBD>닔 遺덇<E981BA><EB8D87>
- **<2A>빐寃<EBB990>**: `Math.max(0, ...)` 濡<> clamp. `permission` type <20>넂 `runExtensionCode.confirm` 留ㅽ븨 異붽<E795B0><EBB6BD> (v0.5.4)
- **二쇱쓽**: DOM observer 寃쎈줈<EC8E88>쓽 step_type<70><65><EFBFBD> <20><EFBFBD>긽 `stepIndex=-1`<60><20><20><EFBFBD>쑝誘<EC919D><EFBFBD> proto <20><EFBFBD><20><20><EFBFBD>닔 蹂댁옣 <20><EFBFBD>
### [2026-03-21] reviewAbsoluteUris <20><><EFBFBD> latestNotifyUserStep <20><EFBFBD>뱶紐<EBB1B6> 遺덉씪移<EC94AA>
- **利앹긽**: `notify_user`<60>쓽 PathsToReview <20><EFBFBD>씪 由대젅<EB8C80>씠媛<EC94A0> <20>븳 踰덈룄 <20><EFBFBD><EFBFBD>븯吏<EBB8AF> <20><EFBFBD>
- **<2A><EFBFBD>씤**: AG <20><EFBFBD><20><EFBFBD>뱶紐<EBB1B6> `reviewAbsoluteUris` vs 肄붾뱶 `pathsToReview`/`paths_to_review`/`filePaths`
- **<2A>빐寃<EBB990>**: `reviewAbsoluteUris` 瑜<><> 踰덉㎏ <20>썑蹂대줈 異붽<E795B0><EBB6BD> (v0.5.3)
- **二쇱쓽**: AG RPC <20><EFBFBD>뱶紐낆<EFA78F><EB8286> extension.log `[NOTIFY-STEP] keys=` 濡<> <20><EFBFBD>씤 媛<><E5AA9B>뒫. 異붿륫 湲덉<E6B9B2><EB8D89>
### [2026-03-21] <20><EFBFBD><20><EFBFBD><20><><EFBFBD><> WAITING 媛먯<E5AA9B><EBA8AF> 20-25s 吏<><EFA79E>
- **利앹긽**: <20><20><><EFBFBD><EFBFBD><20><EFBFBD><20>썑 泥<> run_command <20><EFBFBD><EFBFBD>씠 Discord<72><20><20>삤怨<EC82A4> AG<41><EFBFBD>꽌 吏곸젒 <20><EFBFBD><EFBFBD><EFBFBD><20>븿
- **<2A><EFBFBD>씤**: `lastModTime=''` 由ъ뀑 <20>넂 `modTimeChanged=true` <20>넂 THINKING 遺꾧린 諛섎났 <20>넂 probe 15-25s 吏<><EFA79E>
- **<2A>빐寃<EBB990>**: `lastModTime=currentModTime` + `return` <20>젣嫄<ECA0A3> + 利됱떆 probe 媛뺤젣 + <20>쉶洹<EC89B6><><E5AA9B>뱶 異붽<E795B0><EBB6BD> (v0.5.3)
- **二쇱쓽**: <20><EFBFBD><20><EFBFBD><20>떆 `wasRunning`/`pendingModifiedFiles` 由ъ뀑 <20><EFBFBD>닔 (<28><EFBFBD><20><EFBFBD><20><EFBFBD>뿬臾쇰줈 false diff_review 諛⑹<E8AB9B><E291B9>)
### [2026-03-21] reviewAbsoluteUris — latestNotifyUserStep 필드명 불일치
- **증상**: `notify_user`의 PathsToReview 파일 릴레이가 한 번도 작동하지 않음
- **원인**: AG 실제 필드명 `reviewAbsoluteUris` vs 코드 `pathsToReview`/`paths_to_review`/`filePaths`
- **해결**: `reviewAbsoluteUris` 를 첫 번째 후보로 추가 (v0.5.3)
- **주의**: AG RPC 필드명은 extension.log `[NOTIFY-STEP] keys=` 로 확인 가능. 추측 금지
### [2026-03-21] 세션 전환 — 첫 WAITING 감지 20-25s 지연
- **증상**: 새 대화 시작 후 첫 run_command 승인이 Discord에 안 오고 AG에서 직접 승인해야 함
- **원인**: `lastModTime=''` 리셋 → `modTimeChanged=true` → THINKING 분기 반복 → probe 15-25s 지연
- **해결**: `lastModTime=currentModTime` + `return` 제거 + 즉시 probe 강제 + 회귀 가드 추가 (v0.5.3)
- **주의**: 세션 전환 시 `wasRunning`/`pendingModifiedFiles` 리셋 필수 (이전 세션 잔여물로 false diff_review 방지)
---
## 핵심 작업 규칙 (과거 이슈에서 반복된 패턴)
> 아래는 과거 이슈에서 반복적으로 나타난 패턴을 규칙으로 정리한 것입니다.
| # | 규칙 | 관련 이슈 (archive 참조) |
## <20><EFBFBD><20><EFBFBD>뾽 洹쒖튃 (怨쇨굅 <20><EFBFBD><EFBFBD><EFBFBD>꽌 諛섎났<EC848E><20><EFBFBD>꽩)
> <20><EFBFBD><EFBFBD>뒗 怨쇨굅 <20><EFBFBD><EFBFBD><EFBFBD>꽌 諛섎났<EC848E><EFBFBD>쑝濡<EC919D> <20><EFBFBD><EAB5B9><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>쓣 洹쒖튃<EC9296>쑝濡<EC919D> <20>젙由ы븳 寃껋엯<EABB8B><EFBFBD>떎.
| # | 洹쒖튃 | 愿<><E684BF><20><EFBFBD>뒋 (archive 李몄“) |
|---|------|--------------------------|
| 1 | **Hub WS와 file bridge는 상호 배타적** — `if hub: ws + return` / `else: file` | WS dual-write, _auto_approve 이중 쓰기 |
| 2 | **WS 경로 추가 시 file-bridge의 모든 분기를 포팅** | diff_review WS regression |
| 3 | **AG RPC `{}` 응답은 실패로 간주** — 메서드명 틀려도 에러 없이 `{}` 반환 | AcknowledgeCascadeCodeEdit |
| 4 | **ResolveOutstandingSteps는 CANCEL 동작** — 승인에 절대 사용 금지 | Step probe reject |
| 5 | **Extension 코드 수정 후 반드시 VSIX 빌드 + AG 풀 재시작** | Extension 버전 미배포 |
| 6 | **HTML 패치 변경 시 V8 CachedData 삭제 필수** | V8 CachedData, CSP |
| 7 | **`bridge/pending/` 조작 시 반드시 `project_name` + `conversation_id` 필터** | 크로스 프로젝트 DEDUP MERGE |
| 8 | **`processResponseFile` 상태 리셋은 `sawRunningAfterPending=true`만** | processResponseFile 무한 루프 |
| 9 | **fs.watch Windows 불안정 — 반드시 polling fallback 병행** | fs.watch silent fail |
| 10 | **diff_review는 VS Code 커맨드만 유효** — RPC 3개 전략 모두 실패 확정 | diff_review RPC dead-end |
| 11 | **HttpBridgeContext에 프리미티브 by-value 복사 금지** — 별도 객체 생성 시 getter 사용 | HttpBridgeContext stale primitive |
| 12 | **새 AG 도구 추가 시 step-probe step_type 매핑 + approval-handler RPC payload 매핑 양쪽 필수** | browser_subagent Allow |
| 13 | **WS `onConnected`에서 step-probe 상태 리셋 필수** — `stallProbed`/`lastPendingStepIndex`는 TTL 없는 영구 값 | Idle→Resume 신호 소실 |
| 14 | **AG proto `uint32` 필드에 음수 전달 금지** — `stepIndex` 등은 `Math.max(0, ...)` 필수 | stepIndex=-1 RPC 400 |
| 15 | **RPC "input not registered" = wrong-LS 연결** — `fixLSConnection()` 자동 재시도 필수, `lines.length<=1` 조기종료 금지 | Deriva wrong-LS (v0.5.5) |
| 1 | **Hub WS<57><53><EFBFBD> file bridge<EFBFBD><20><EFBFBD>샇 諛고<E8AB9B><EAB3A0><EFBFBD>쟻** <20><><EFBFBD> `if hub: ws + return` / `else: file` | WS dual-write, _auto_approve <20>씠以<EC94A0> <20>벐湲<EBB290> |
| 2 | **WS 寃쎈줈 異붽<E795B0><EBB6BD> <20>떆 file-bridge<67>쓽 紐⑤뱺 遺꾧린瑜<EBA6B0> <20><EFBFBD>똿** | diff_review WS regression |
| 3 | **AG RPC `{}` <20><EFBFBD><EFBFBD><EB969F><EFBFBD> <20><EFBFBD>뙣濡<EB99A3> 媛꾩<** <20><><EFBFBD> 硫붿꽌<EBB6BF>뱶紐<EBB1B6> <20><><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD><20><EFBFBD>씠 `{}` 諛섑솚 | AcknowledgeCascadeCodeEdit |
| 4 | **ResolveOutstandingSteps<EFBFBD>뒗 CANCEL <20><EFBFBD>옉** <20><><EFBFBD> <20><EFBFBD><EFBFBD><20><EFBFBD><ECA085><EFBFBD> <20><EFBFBD>슜 湲덉<E6B9B2><EB8D89> | Step probe reject |
| 5 | **Extension 肄붾뱶 <20><EFBFBD><20>썑 諛섎뱶<EC848E>떆 VSIX 鍮뚮뱶 + AG <20><><EFBFBD> <20><EFBFBD><EFBFBD>옉** | Extension 踰꾩쟾 誘몃같<EBAA83> |
| 6 | **HTML <20>뙣移<EB99A3><><EFBFBD> <20>떆 V8 CachedData <20><EFBFBD><20><EFBFBD>닔** | V8 CachedData, CSP |
| 7 | **`bridge/pending/` 議곗옉 <20>떆 諛섎뱶<EC848E>떆 `project_name` + `conversation_id` <20><EFBFBD>꽣** | <20>겕濡쒖뒪 <20>봽濡쒖젥<EC9296>듃 DEDUP MERGE |
| 8 | **`processResponseFile` <20><EFBFBD>깭 由ъ뀑<D18A><EB8091><EFBFBD> `sawRunningAfterPending=true`留<>** | processResponseFile 臾댄븳 猷⑦봽 |
| 9 | **fs.watch Windows 遺덉븞<EB8D89><20><><EFBFBD> 諛섎뱶<EC848E>떆 polling fallback 蹂묓뻾** | fs.watch silent fail |
| 10 | **diff_review<65>뒗 VS Code 而ㅻ㎤<E385BB>뱶留<EBB1B6> <20><EFBFBD>슚** <20><><EFBFBD> RPC 3媛<33> <20><EFBFBD>왂 紐⑤몢 <20><EFBFBD><20><EFBFBD>젙 | diff_review RPC dead-end |
| 11 | **HttpBridgeContext<78><20>봽由щ<E794B1>명떚釉<EB969A> by-value 蹂듭궗 湲덉<E6B9B2><EB8D89>** <20><><EFBFBD> 蹂꾨룄 媛앹껜 <20><EFBFBD><20>떆 getter <20><EFBFBD>슜 | HttpBridgeContext stale primitive |
| 12 | **<2A>깉 AG <20>룄援<EBA384> 異붽<E795B0><EBB6BD> <20>떆 step-probe step_type 留ㅽ븨 + approval-handler RPC payload 留ㅽ븨 <20>뼇履<EBBC87> <20><EFBFBD>닔** | browser_subagent Allow |
| 13 | **WS `onConnected`<60><EFBFBD>꽌 step-probe <20><EFBFBD>깭 由ъ뀑 <20><EFBFBD>닔** <20><><EFBFBD> `stallProbed`/`lastPendingStepIndex`<60>뒗 TTL <20><EFBFBD><20>쁺援<EC81BA><> | Idle<6C>넂Resume <20><EFBFBD><20><EFBFBD>떎 |
| 14 | **AG proto `uint32` <20><EFBFBD><EFBFBD><20><EFBFBD><20><EFBFBD>떖 湲덉<E6B9B2><EB8D89>** <20><><EFBFBD> `stepIndex` <20><EFBFBD><EBB291><EFBFBD> `Math.max(0, ...)` <20><EFBFBD>닔 | stepIndex=-1 RPC 400 |
| 15 | **RPC "input not registered" = wrong-LS <20>뿰寃<EBBFB0>** <20><><EFBFBD> `fixLSConnection()` <20><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD>닔, `lines.length<=1` 議곌린醫낅즺 湲덉<E6B9B2><EB8D89> | Deriva wrong-LS (v0.5.5) |
| 16 | **<2A><EFBFBD><EFBFBD><EFBFBD>뀡(Bridge)<29><><EFBFBD> <20><EFBFBD><EFBFBD>쟻 鍮꾩쫰<EABEA9><EFBFBD><20><EFBFBD><20><EFBFBD><ECA085><EFBFBD> 湲덉<E6B9B2><EB8D89>** <20><><EFBFBD> `SafeToAutoRun` <20><EFBFBD>쓽 議곌굔 釉뚮옖移<EC9896> 遺꾧린<EABEA7>뒗 紐⑤몢 遊뉗쑝濡<EC919D> <20><EFBFBD>엫 (Agnostic Bridge) | SafeToAutoRun Deadlock (v0.5.15) |
| 17 | **package.json 鍮뚮뱶 <20><EFBFBD>겕由쏀듃 媛뺤젣** <20><><EFBFBD> `vscode:prepublish` 異붽<E795B0><EBB6BD><EFBFBD> <20><EFBFBD><EAB68A><EFBFBD> <20><EFBFBD>뒪 諛고룷 <20>썝泥<EC8D9D> 李⑤떒 | VSIX v0.5.15 鍮뚮뱶 <20><EFBFBD>씫 |
| 18 | **<2A>룞湲곗떇 `cp.execSync` <20><EFBFBD>슜 湲덉<E6B9B2><EB8D89>** <20><><EFBFBD> Windows <20>솚寃쎌뿉<EC8E8C>꽌 硫붿씤 <20>씠踰ㅽ듃猷⑦봽 <20>봽由ъ쭠 諛<> WS heartbeat <20><EFBFBD><20>쑀諛<EC9180> | detectProjectName <20>봽由ъ쭠 |
### [2026-04-09] [Bot/Extension] Discord Signal Relay Failure & Empty Body
- **利앹긽**: <20><EFBFBD>뒪肄붾뱶 遊뉗<E9818A><EB8997> '<27><EFBFBD><20><EFBFBD><EFBFBD>맖'<27><20><EFBFBD>슦吏<EC8AA6><EFBFBD> <20><EFBFBD>젣 肄붾뱶 蹂몃Ц<EBAA83><20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20>븡怨<EBB8A1>, 梨꾨꼸<EABEA8>뿉 吏꾩쭨 梨꾪똿 硫붿떆吏<EB9686><EFA79E><20>븣由쇱씠 <20><EFBFBD><20><20><EFBFBD>뿉 諛<><E8AB9B><20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD>쓬.
- **<2A><EFBFBD>씤**: 1) observer-script.ts<74><EFBFBD>꽌 踰꾪듉 <20><EFBFBD><EFBFBD>듃 留ㅼ묶 <20>떆 Run <20><EFBFBD><EFBFBD>쓽 寃쎄퀎(\b) 泥섎━瑜<E29481> <20>븯吏<EBB8AF> <20><EFBFBD>븘 VS Code <20><EFBFBD><EFBFBD>쓽 'Running 1 command'瑜<><>濡쒖콈<EC9296>뼱 PENDING <20><EFBFBD>뙵 臾댄븳 <20><EFBFBD>꽦. 2) bot.py<70><EFBFBD><20><EFBFBD><20><EFBFBD>씤 Embed <20><EFBFBD><20>떆 req.description<6F>쓣 洹몃━吏<E29481> <20>븡怨<EBB8A1> 踰꾪듉 <20><EFBFBD><EFBFBD>듃(req.command)留<> <20><EFBFBD>떆. 3) step-probe.ts<74><EFBFBD><20><EFBFBD>뀡 援먯껜 <20>떆 理쒓렐 <20>븣由<EBB8A3> <20><EFBFBD><EFBFBD>뒪 珥덇린<EB8D87>솕瑜<EC8695> <20>옒紐삵븯<EC82B5><20><EFBFBD><EFBFBD>쓽 泥<> 硫붿떆吏<EB9686><EFBFBD> 臾댁“嫄<E2809C> <20>뱶濡<EBB1B6>.
- **<2A>빐寃<EBB990>**: DOM 媛먯<E5AA9B><EBA8AF> <20>젙洹쒖떇<EC9296>뿉 \b 媛뺤젣 遺<><E981BA>뿬 (/Run\b/), bot.py<70>쓽 Auto-Approve 履<> Embed 蹂몃Ц<EBAA83>뿉 req.description <20><EFBFBD>뜑留<EB9C91> 異붽<E795B0><EBB6BD>, step-probe.ts<74><EFBFBD>꽌 session init <20>떆 index瑜<78> -1濡<31> 由ъ뀑.
- **二쇱쓽**: Native UI <20><EFBFBD><EFBFBD>듃 媛먯<E5AA9B><EBA8AF> <20><20><EFBFBD>뼱 寃쎄퀎(\b)源뚯<E6BA90><EB9AAF><>利앺빐<EC95BA>빞 False Positive瑜<65> 留됱쓣 <20><20><EFBFBD>쑝硫<EC919D>, Auto-Approve<76>뒗 諛섎뱶<EC848E>떆 蹂몃Ц<EBAA83><20>끂異쒗빐<EC9297><20>븿.
### [2026-04-10] [Extension] AI Response Content Missing (Nested PlannerResponse)
- **利앹긽**: <20><EFBFBD>뒪肄붾뱶 梨꾪똿諛⑹뿉 Agent<6E><20><EFBFBD><EFBFBD><20><EFBFBD>떟(AI <20><EFBFBD>떟)<29><20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD>쓬.
- **<2A><EFBFBD>씤**: GetCascadeTrajectorySteps媛<73> 諛섑솚<EC8491><EFBFBD>뒗 plannerResponse媛<65> <20>봽濡쒗넗肄<EB8497> 諛⑹떇<E291B9><20><EFBFBD>씪 理쒖긽<EC9296>떒(s.plannerResponse)<29><20><EFBFBD>땶 s.step.plannerResponse<73>뿉 以묒꺽<EBAC92><EFBFBD><20><EFBFBD><EFBFBD><20><20><EFBFBD>쓬. 湲곗〈 <20><EFBFBD><EFBFBD><20><EFBFBD>뱶肄붾뵫<EBB6BE><20><EFBFBD>뱶 諛<> <20><EFBFBD>옯 援ъ“留<E2809C> 議고쉶<EAB3A0><EFBFBD><20><EFBFBD><EFBFBD>쓣 踰꾨┝.
- **二쇱쓽**: AG RPC <20><EFBFBD>뱶紐<EBB1B6> 援ъ“ 異붿륫 湲덉<E6B9B2><EB8D89>. <20><EFBFBD><20><20><EFBFBD>뱶諛뺤뒪濡<EB92AA> <20>몢 媛<><EFBFBD> 援ъ“(Flat, Nested) 紐⑤몢 紐⑦궧<E291A6><EFBFBD>뿬 吏곸젒 <20><EFBFBD><20><EFBFBD>씤.
### [2026-04-10] [Extension] Fast Execution `<5s` Response Capture Missed (IDLE-to-IDLE)
- **利앹긽**: <20><EFBFBD>뒪肄붾뱶濡<EBB1B6> <20><EFBFBD><EFBFBD><20><EFBFBD><20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD>쓬. `[RT-CAPTURE]`, `[RESPONSE-CAPTURE]` 濡쒓렇 紐⑤몢 <20><EFBFBD><EC9FBE><EFBFBD> <20>궓吏<EAB693> <20><EFBFBD>쓬.
- **<2A><EFBFBD>씤**: AI <20><EFBFBD><EFBFBD><EFBFBD>굹 肄붾뵫 <20><EFBFBD><EFBFBD>씠 5珥<35>(<28>뤃留<EBA483> 二쇨린) 誘몃쭔<EBAA83>쑝濡<EC919D> 留ㅼ슦 鍮좊<E285A4> <20><EFBFBD>굹硫<EAB5B9>, <20><EFBFBD><EFBFBD>씠 `IDLE -> IDLE` <20><EFBFBD>깭留<EAB9AD><>李고븯硫<EBB8AF> `wasRunning` <20><EFBFBD>옒洹멸<E6B4B9><EBA9B8> `false`濡<> <20>쑀吏<EC9180><EFA79E>맖. 湲곗〈 `[RESPONSE-CAPTURE]` 議곌굔<EAB38C>떇(`wasRunning && !isRunning && currentCount > ...`)<29>씠 `wasRunning=false`濡<> <20><EFBFBD>빐 釉붾줉<EBB6BE><EFBFBD>뼱 罹≪쿂 <20>옄泥대<EFA7A3><EB8C80> <20><EFBFBD><EFBFBD>엳 嫄대꼫<EB8C80>쎇寃<EC8E87> <20>맖.
- **<2A>빐寃<EBB990>**: `wasRunning` 寃<>利앹쓣 <20><EFBFBD><EFBFBD>븯怨<EBB8AF> `!isRunning && currentCount > lastResponseCaptureStep` 議곌굔<EAB38C>쑝濡<EC919D> <20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>맂 step<65><20><EFBFBD><20>븣 臾댁“嫄<E2809C> 罹≪쿂<E289AA><EFBFBD>룄濡<EBA384><><EFBFBD>. 異붽<E795B0><EBB6BD><EFBFBD> <20><EFBFBD><EFBFBD>맂 `[RESPONSE-CAPTURE]` <20><20><EFBFBD>뱶肄붾뵫 <20><EFBFBD>꽌瑜<EABD8C> `extractPlannerText`濡<> <20><EFBFBD><EFBFBD><20><EFBFBD>슜.
- **二쇱쓽**: <20>뤃留<EBA483> 諛⑹떇<E291B9><EFBFBD><EFBFBD><20><EFBFBD>깭(RUNNING->IDLE) <20><EFBFBD>씠瑜<EC94A0> <20><EFBFBD><EFBFBD><20><20><EFBFBD>쑝誘<EC919D><EFBFBD>, Step Count(<28><EFBFBD><EFBFBD><20>쟾吏<EC9FBE>)<29><EFBFBD>뒗 100% <20>떊猶<EB968A><><E5AA9B><EFBFBD>븳 留덉빱瑜<EBB9B1> <20><EFBFBD><20><20><EFBFBD><20>뿬遺<EBBFAC><EFBFBD> 媛먯<E5AA9B><EBA8AF><EFBFBD><EFBFBD><20>븿.
### [2026-04-10] [Bot] chat_snapshot_scanner 臾댄븳 Abort 諛<> <20><EFBFBD><20>쟻泥<EC9FBB> (Exception <20><EFBFBD>씫)
- **利앹긽**: 遊뉗씠 <20><EFBFBD>뒪肄붾뱶濡<EBB1B6> AI <20>떟蹂<EB969F>(梨꾪똿 <20><EFBFBD><EFBFBD>꺑)<29><20><EFBFBD><EC9FBE><EFBFBD> <20><EFBFBD><EFBFBD>븯吏<EBB8AF> 紐삵븯怨<EBB8AF> <20><EFBFBD>씠 嫄몃┝. ridge/chat_snapshots/<2F>뿉 泥섎━<EC848E>릺吏<EBA6BA> <20><EFBFBD><EBB8A1><EFBFBD> JSON <20><EFBFBD><EFBFBD><20><EFBFBD>떗 媛<> <20>쟻泥대맖.
- **<2A><EFBFBD>씤**: ot.py<70>쓽 chat_snapshot_scanner<65><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD><20><EFBFBD><EFBFBD><20><20>궡遺<EAB6A1><E981BA> .unlink() 怨쇱젙<EC87B1><EFBFBD>꽌 諛쒖깮<EC9296><EFBFBD><20><EFBFBD><EFBFBD>굹 discord.Embed <20><EFBFBD><20><EFBFBD><20><EFBFBD>쓣 猷⑦봽 <20><EFBFBD><EFBFBD><20><EFBFBD>븘二쇱<E4BA8C><EC87B1> 紐삵븿. 泥<> <20><EFBFBD><20><EFBFBD>씪(poison pill)<29>쓣 留뚮굹<EB9AAE><20>닚媛<EB8B9A> 猷⑦봽 <20>쟾泥닿<EFA7A3><EB8BBF> <20><EFBFBD><EFBFBD><EFBFBD><20>뮘履쎌쓽 <20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>엳 泥섎━<EC848E>릺吏<EBA6BA> <20>븡怨<EBB8A1> <20><EFBFBD><20><20>뒪耳<EB92AA>以꾩뿉<EABEA9><20><EFBFBD>떆 泥<> <20><EFBFBD><EFBFBD>뿉 留됲옒.
- **<2A>빐寃<EBB990>**: 猷⑦봽 <20>궡遺<EAB6A1><E981BA>뿉 except Exception<6F>쓣 異붽<E795B0><EBB6BD><EFBFBD><EFBFBD><20><EFBFBD><20><EFBFBD>쇅瑜<EC8785> <20><EFBFBD>븘 諛⑹뼱. <20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><EC94AA><EFBFBD> glob<6F><EFBFBD>꽌 諛섎났 <20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20>븡寃<EBB8A1> .json.failed濡<64> <20><EFBFBD>쉶(rename)<29>떆耳<EB9686> <20>걧瑜<EAB1A7> 鍮꾩썙以<EC8D99>.
- **二쇱쓽**: <20>뤃留<EBA483>/<2F>뒪罹먮꼫 or 猷⑦봽 <20>궡遺<EAB6A1><E981BA><EFBFBD><EFBFBD>뒗 媛쒕퀎 <20><EFBFBD><EFBFBD><20><EFBFBD><20>떒怨꾩뿉<EABEA9>꽌 諛쒖깮 媛<><E5AA9B><EFBFBD>븳 紐⑤뱺 <20><EFBFBD><20><EFBFBD><EFBFBD><20><><EFBFBD><EFBFBD>븳 Defensive Catch 諛<> Continue(<28><EFBFBD>쉶) 濡쒖쭅<EC9296><20><EFBFBD><EFBFBD>엫.
### [2026-04-10] [Extension] GetAllCascadeTrajectories 10-Item Hard Limit Bypass (Signal Drop)
- **증상**: 기존에 작성했던 { limit: 30 } 파라미터가 LS 백엔드에서 무시되어 최신 세션이 10개 제한에 걸려 잘려나감. (Discord로 메시지 단 한 글자도 안 넘어옴).
- **원인**: GetAllCascadeTrajectories는 구조적으로 pagination 옵션을 무시하거나 강제 10 제한이 걸림.
- **해결**: step-probe.ts에서 기본 GetAllCascadeTrajectories와 더불어 모든 트래젝토리를 덤프하는 GetDiagnostics API를 병행 호출하고 머지하여 최신 Session ID를 놓치지 않고 추출하게 함.
- **주의**: LS Backend에서 정의한 RPC의 한계상 Argument 조작으로 제한을 회피할 수 없으므로, 향후 GetDiagnostics 등 백도어 데이터를 활용할 것.
### [2026-04-10] [Probe Logging] <20><><EFBFBD> AI<41><EFBFBD><20><EFBFBD><EFBFBD>듃 & WAITING <20><EFBFBD><20><EFBFBD><20><EFBFBD>씫 踰꾧렇
- **利앹긽**: 援됱옣<EB90B1>엳 鍮좊Ⅸ AI <20><EFBFBD>떟(<28><EFBFBD>뒗 利됯컖<EB90AF><EFBFBD><20><20>샇異<EC8387>) <20>떆 `step-probe.ts`媛<> 硫붿떆吏<EB9686><EFA79E><EFBFBD><EFBFBD> <20><EFBFBD><20><EFBFBD><EFBFBD>뼹濡쒓렇瑜<EBA087> 紐⑤몢 Discord濡<64> 由대젅<EB8C80><EFBFBD>븯吏<EBB8AF> 紐삵븿.
- **<2A><EFBFBD>씤**: <20><EFBFBD>떆媛<EB9686> <20><EFBFBD><EFBFBD>듃 罹≪쿂(`delta > 0`) 議곌굔<EAB38C>뿉 `isRunning &&`<60>씠 嫄몃젮<EBAA83><EFBFBD>뼱, <20><EFBFBD>깭媛<EAB9AD> `WAITING`<60><EFBFBD>굹 `IDLE`濡<> 利됱떆 <20><EFBFBD>뼱媛<EBBCB1><EFBFBD> <20><EFBFBD><EFBFBD>듃瑜<EB9383> 罹≪쿂<E289AA><EFBFBD>뒗 猷⑦떞<E291A6><20>쟾遺<EC9FBE> <20><EFBFBD><EFBFBD>맖. <20><EFBFBD><20><20>닚媛<EB8B9A> `isStall` 議곌굔<EAB38C><20><><EFBFBD><EFBFBD> <20><EFBFBD>븘 `WAITING` <20><EFBFBD><EFBFBD><EFBFBD>룄 利앸컻<EC95B8>븿.
- **<2A>빐寃<EBB990>**: <20><EFBFBD>떆媛<EB9686> 罹≪쿂 濡쒖쭅<EC9296><EFBFBD>꽌 `isRunning &&` 議곌굔<EAB38C><20>젣嫄고븯怨<EBB8AF>, `delta > 0`<60><20>븣 異붽<E795B0><EBB6BD><EFBFBD>맂 理쒖떊 <20><EFBFBD><EFBFBD><20>뒪罹뷀븯硫댁꽌 `PLANNER_RESPONSE`<60><><EFBFBD> `WAITING` <20><EFBFBD><EFBFBD>쓣 紐⑤몢 泥섎━<EC848E><EFBFBD>룄濡<EBA384> <20><EFBFBD><EFBFBD>븿.
- **二쇱쓽**: LS Backend 10媛<30> Session <20><EFBFBD>븳 踰꾧렇媛<EBA087> <20><EFBFBD>뼱, <20>떎瑜<EB968E> 李쎌뿉<EC8E8C><20><EFBFBD>룞 梨꾪똿(`1fbca84c`)<29>씠 IDLE濡<45> <20><EFBFBD><EFBFBD><EFBFBD>쑝硫<EC919D> <20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD>뀡怨<EB80A1> <20>뿷媛덈┫ <20><20><EFBFBD><EFBFBD>굹, <20>씠 踰꾧렇<EABEA7>뒗 polling <20><><EFBFBD><EFBFBD>씠諛<EC94A0> 臾몄젣<EBAA84><ECA0A3><EFBFBD><EFBFBD>쓬.
### [2026-04-10] [Extension] AI Response Missing for New Sessions (Session Tracking Failure)
- **利앹긽**: <20>깉濡쒖슫 <20><><EFBFBD><EFBFBD>솕(Session) <20><EFBFBD><20>떆 泥<> AI <20><EFBFBD><20><EFBFBD><EFBFBD>듃媛<EB9383> <20><EFBFBD>뒪肄붾뱶<EBB6BE><20><EFBFBD><EC9FBE><EFBFBD> <20><EFBFBD><EFBFBD>릺吏<EBA6BA> <20><EFBFBD><20><EFBFBD>긽.
- **<2A><EFBFBD>씤**: 諛깆뿏<EAB986><EFBFBD>쓽 `GetAllCascadeTrajectories`媛<> 10媛<30> <20><EFBFBD>뀡留<EB80A1> 諛섑솚<EC8491><EFBFBD><20><20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD>맖. <20>씠瑜<EC94A0> 蹂댁셿<EB8C81>븯湲<EBB8AF> <20><EFBFBD>빐 `brain/` <20><EFBFBD><EFBFBD>넗由щ<E794B1><D189> <20>뒪罹뷀븯<EBB780>뒗 Fallback 濡쒖쭅<EC9296><20><EFBFBD><EFBFBD><EFBFBD><EFBFBD>굹, <20>떊洹<EB968A> <20><EFBFBD><EFBFBD>쓽 泥<> <20>떒怨꾩뿉<EABEA9>꽌 `GetCascadeTrajectorySteps`(stepOffset: 0) <20>샇異<EC8387> <20><20>궡遺<EAB6A1> <20><EFBFBD>떟(UTF-8 <20><EFBFBD><20>벑) <20><EFBFBD>윭濡<EC9CAD> <20><EFBFBD>빐 Exception<6F>씠 諛쒖깮, `trajectorySummaries`<60><20><EFBFBD><EFBFBD><20><EFBFBD><20>벑濡앸릺吏<EBA6BA> <20><EFBFBD>쓬. <20><EFBFBD><EFBFBD>씠 異붿쟻<EBB6BF>릺吏<EBA6BA> <20><EFBFBD><EFBFBD>땲 `delta > 0` 湲곕컲<EAB395><20><EFBFBD>떟 罹≪쿂媛<ECBF82> 諛쒖깮<EC9296>븯吏<EBB8AF> <20><EFBFBD>쓬.
- **<2A>빐寃<EBB990>**: `step-probe.ts`<60>쓽 Fallback 2 `catch` 釉붾줉<EBB6BE><EFBFBD><20><EFBFBD>윭媛<EC9CAD> 諛쒖깮<EC9296><EFBFBD><EFBFBD><EFBFBD>룄 媛뺤젣濡<ECA0A3> `stepCount: 1`濡<> <20><EFBFBD><EFBFBD><20>벑濡앺븯<EC95BA>룄濡<EBA384> <20>뙣移섑븯<EC8491><20><EFBFBD><20><EFBFBD><20><EFBFBD>떎 諛⑹<E8AB9B><E291B9>.
- **二쇱쓽**: API <20>샇異<EC8387> <20><EFBFBD>뙣瑜<EB99A3> 議곗슜<EAB397>엳 `catch`濡<> <20>꽆湲곕㈃ <20>쟾泥<EC9FBE> <20><EFBFBD><EFBFBD><EFBFBD><EFBFBD>씤(<28>뿬湲곗꽌<EAB397><20><EFBFBD><20>뤃留<EBA483>)<29><20><EFBFBD><20><EFBFBD><EFBFBD>꽣瑜<EABDA3> <20><EFBFBD><EFBFBD>엳 臾댁떆<EB8C81>븯寃<EBB8AF> <20><EFBFBD>뒗 移섎챸<EC848E>쟻 踰꾧렇媛<EBA087> 諛쒖깮<EC9296>븿. <20><EFBFBD><20><EFBFBD><20>꽕怨<EABD95> <20>떆 湲곕낯媛<EB82AF> 蹂듭썝(Fallback State) <20><EFBFBD><20><EFBFBD>닔.
### [2026-04-10] [Extension] Trigger-Click False Positives & Button Matching Failure
- **利앹긽**: <20><EFBFBD>뒪肄붾뱶<EBB6BE><EFBFBD><20><EFBFBD>씤(Approve)<29><20>늻瑜대㈃, <20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD><20>봽濡쒓렇<EC9293><EFBFBD><20>븣留욎<EFA78D><EC9A8E> 踰꾪듉(<28>삁: `Always run`)<29><20>늻瑜댁<E7919C><EB8C81> 紐삵븯嫄곕굹, <20><EFBFBD><EFBFBD>븳 踰꾪듉(<28>삁: <20><EFBFBD><EFBFBD>쓽 `Running1 command`)<29><20><EFBFBD>윭踰꾨젮 <20><EFBFBD><20><EFBFBD>씤 泥섎━媛<E29481> <20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD>긽.
- **<2A><EFBFBD>씤**: 1) UI 踰꾪듉 <20><EFBFBD><EFBFBD><EFBFBD>뿉 `keyboard_arrow_up` <20>벑 癒명떚由ъ뼹 <20><EFBFBD>씠肄<EC94A0> <20><EFBFBD><EFBFBD>듃媛<EB9383> <20>젒李<ECA092>(`Always runkeyboard_arrow_up`)<29><EFBFBD><20>젙洹쒖떇<EC9296><20><EFBFBD><EFBFBD>븷 寃껋쓣 <20><EFBFBD><EFBFBD><20><EFBFBD>뼱 寃쎄퀎(`\b`)瑜<> <20>젣嫄고븳 <20>뙣移섍<E7A7BB><EC848D> <20><EFBFBD>씤. <20><EFBFBD>뼱 寃쎄퀎媛<ED808E> <20><EFBFBD>씪吏<EC94AA>硫댁꽌 `/Run/i` <20><EFBFBD><EFBFBD>씠 `Running1 command` 媛숈<E5AA9B><EC8888> <20>떎瑜<EB968E> <20><EFBFBD><20><EFBFBD><EFBFBD>듃 踰꾪듉<EABEAA><20><EFBFBD>깘(False Positive)<29>맖. 2) DOM <20><EFBFBD><EFBFBD><20><EFBFBD><20><EFBFBD><EFBFBD>듃 踰꾪듉<EABEAA><20><EFBFBD><20><EFBFBD>쑝誘<EC919D><EFBFBD> <20><EFBFBD><EFBFBD>맂 踰꾪듉<EABEAA><20><EFBFBD><20>겢由<EAB2A2><E794B1>맖.
- **<2A>빐寃<EBB990>**: `trigger-click` 濡쒖쭅 <20><EFBFBD><20>쟾 踰꾪듉<EABEAA>쓽 `textContent`<60><EFBFBD>꽌 `keyboard_arrow_up` <20><20><EFBFBD>젮吏<ECA0AE> 瑗щ━ <20><EFBFBD>씠肄<EC94A0> 臾몄옄<EBAA84><EFBFBD>쓣 紐낆떆<EB8286><EFBFBD>쑝濡<EC919D> <20>젣嫄<ECA0A3>(strip)<29>븯怨<EBB8AF>, 紐⑤뱺 <20>듃由ш굅 <20>젙洹쒖떇<EC9296><20><EFBFBD><20><EFBFBD>뼱 寃쎄퀎(`\b`)瑜<> 媛뺤젣 <20><EFBFBD><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><20>썝泥<EC8D9D> 李⑤떒<E291A4>븿.
- **二쇱쓽**: UI <20><EFBFBD>냼瑜<EB83BC> DOM<4F><EFBFBD>꽌 湲곸뼱<EAB3B8><20><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><20>닲寃⑥쭊 <20><EFBFBD>씠肄<EC94A0>/<2F><EFBFBD><EFBFBD>듃 由ш굅爾<EAB585>(ligatures)媛<> <20><EFBFBD>뒗吏<EB9297><><E5AF83><EFBFBD><EFBFBD><20>븿. <20><EFBFBD>꽩 留ㅼ묶 <20>떆 瑗щ━<D189>몴瑜<EBAAB4> 癒쇱<E79992><EC87B1> <20>젣嫄고븯怨<EBB8AF> 紐낇솗<EB8287>븳 寃쎄퀎瑜<ED808E><><E981BA><EFBFBD>븷 寃<>.
### [2026-04-10] [Extension] Ghost Session Hijack & Infinite Polling Loop (trajectory not found)
- **利앹긽**: <20>떊洹<EB968A> <20><EFBFBD><20>떆 '<27><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EBBCB1><EFBFBD>' (Discord濡<64> 由대젅<EB8C80><20><20>맖). 濡쒓렇<EC9293>뿉 500 error trajectory not found 臾댄븳 諛섎났.\n- **<EFBFBD><EFBFBD>씤**: Antigravity媛<79> <20><EFBFBD><EFBFBD>븯硫댁꽌 brain/<2F>뿉 36湲<36><E6B9B2><20><EFBFBD>뜑瑜<EB9C91> <20><EFBFBD><EFBFBD><EFBFBD><EFBFBD>뜲, Cascade媛<65> <20><EFBFBD>땲誘<EB95B2><EFBFBD> GetCascadeTrajectorySteps<70><EFBFBD>꽌 500 <20><EFBFBD>윭瑜<EC9CAD> <20><EFBFBD><EFBFBD>떎. <20>븯吏<EBB8AF><EFBFBD> <20><EFBFBD><20>떊洹<EB968A> <20><EFBFBD><20><EFBFBD>떎 諛⑹<E8AB9B><E291B9> <20>뙣移섍<E7A7BB><EC848D> <20>씠 Ghost <20><EFBFBD><EFBFBD>쓣 RUNNING<4E>쑝濡<EC919D> 媛뺤젣 <20>벑濡앺븯硫댁꽌, <20><EFBFBD><20><EFBFBD>뀡(activeSessionId)<29><20>깉痍⑦븯怨<EBB8AF> 臾댄븳 <20><EFBFBD>윭 猷⑦봽<E291A6>뿉 鍮좎<E98DAE><ECA28E><EFBFBD> 留뚮뱾<EB9AAE><EFBFBD><EFBFBD><EFBFBD>떎.\n- **<EFBFBD>빐寃<EFBFBD>**: step-probe.ts<74><EFBFBD><20>뤃諛<EBA483> <20>벑濡<EBB291> <20>떆 error message<67>뿉 'trajectory not found'媛<> <20><EFBFBD>븿<EFBFBD>릺硫<EBA6BA> Ghost <20><EFBFBD><EFBFBD>쑝濡<EC919D> 媛꾩<EABEA9>빐 媛뺤젣 <20>벑濡<EBB291>(continue)<29>쓣 嫄대꼫<EB8C80>쎇寃<EC8E87> <20>븯怨<EBB8AF>, Stall Probe <20><EFBFBD>윭 catch<63><EFBFBD><EFBFBD>룄 UTF-8 <20><EFBFBD>윭媛<EC9CAD> <20><EFBFBD>땲硫<EB95B2> stallProbed=true瑜<65> 二쇱뼱 <20><EFBFBD><EFBFBD>룄 臾댄븳 猷⑦봽瑜<EBB4BD> <20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>떎.\n- **二쇱쓽**: uuid 湲몄씠(36<33>옄)留뚯쑝濡<EC919D> <20><EFBFBD><EFBFBD>넗由щ<E794B1><D189> <20>떇蹂꾪븷 <20>븣 Antigravity<74><79><EFBFBD> Google Agent媛<74> 紐⑦샇<E291A6>빐吏<EBB990> <20><20><EFBFBD>쑝誘<EC919D><EFBFBD>, 諛섎뱶<EC848E>떆 Backend <20><EFBFBD><EFBFBD><20><EFBFBD><EFBFBD><20><EFBFBD>윭(trajectory not found) 硫붿떆吏<EB9686><EFBFBD> <20><EFBFBD><20>뙋蹂꾩쓣 <20><EFBFBD><20><EFBFBD><EFBFBD>떎.\n

View File

@@ -0,0 +1,205 @@
# Observer Script 개발 가이드 — SSOT
> **이 문서는 Observer 코드 변경 전 반드시 확인하는 SSOT입니다.**
> 모든 Observer 관련 설계, 배포, 제약사항이 이 문서에 있습니다.
---
## 1. Observer 코드 특성
### 1.1 실행 환경
- Observer는 **workbench-jetski-agent.html**에 인라인 `<script>`로 삽입됨
- **AG Native의 Electron 렌더러 프로세스**에서 실행 (VS Code extension host가 아님)
- 렌더러는 **strict mode 아님** (확인 필요), 하지만 V8 parser는 일부 strict-like 규칙 적용
- `generateApprovalObserverScript(port)` 함수가 **TypeScript template literal**로 스크립트 생성
### 1.2 코드 작성 규칙 (위반 시 Observer 전체 크래시)
| 규칙 | 이유 | 예시 |
|------|------|------|
| **for 루프 안에 function 선언 금지** | V8 strict mode error | `var fn = function(){}` 사용 |
| **문자열 리터럴에 특수문자 금지** | template literal 이스케이핑 깨짐 | `'??'``'MAX'` |
| **regex에 `\\s` 등 이스케이프 금지** | template literal이 `\\\\s``\\s` (리터럴) | 문자열 비교 사용 |
| **ES6+ 구문 금지** | 구 V8 호환 | `var` 사용, `let/const/arrow` 금지 |
| **배포 전 SYNTAX CHECK 필수** | Observer 크래시 방지 | 아래 검증 명령어 참조 |
### 1.3 필수 검증 명령어 (모든 빌드 전 실행)
```powershell
npm.cmd run compile; node -e "const {generateApprovalObserverScript}=require('./out/observer-script'); let s=generateApprovalObserverScript(18080); try { new Function(s); console.log('SYNTAX OK'); } catch(e) { console.log('ERROR:', e.message); }"
```
**SYNTAX OK가 나오지 않으면 절대 배포하지 않는다.**
### 1.4 배포 전 자기검증 체크리스트 (MANDATORY)
**재시작을 요구하기 전 반드시 다음을 모두 통과해야 한다:**
1. [ ] **SYNTAX CHECK 통과**: `new Function(s)``SYNTAX OK`
2. [ ] **수정 방향 검증**: 이 수정이 문제를 해결하는 올바른 접근인지 스스로 2번 재검증
3. [ ] **template literal 규칙 위반 없음**: regex 이스케이프, 특수문자, function 선언 등
4. [ ] **변경 범위 최소화**: 불필요한 코드 포함 여부 확인
5. [ ] **재시작 사유 명시**: 사용자에게 (a) 무엇을 수정했고 (b) 왜 재시작이 필요한지 1~2줄로 설명
6. [ ] **재시작 횟수 명시**: Observer 변경 = 2회, Extension host만 변경 = 1회
7. [ ] **log() relay 필터 확인**: 새 로그 키워드 추가 시 log() 함수의 키워드 필터에도 추가했는지 확인 (섹션 3.5 참조)
8. [ ] **regex E2E 테스트**: Observer에서 사용하는 새 regex는 생성된 코드에서 직접 실행하여 매칭 검증
9. [ ] **구현 전 가정 검증**: 새 접근을 코딩하기 전에, 핵심 가정이 성립하는지 로그 1줄로 먼저 확인 (예: "Step Probe가 WAITING을 볼 수 있는가?" → `STEP-PROBE.*WAITING` 로그 검색)
**정당한 사유 없이 재시작을 요구하지 않는다.**
**DOM 구조를 먼저 파악하고 설계한 후 코드를 작성한다.**
**시행착오식(trial-and-error) 접근을 하지 않는다.**
**추측으로 코딩하지 않는다. 로그/데이터로 확인한 사실에 기반하여 코딩한다.**
---
## 2. 배포 프로세스
### 2.1 Observer 코드가 포함된 변경
Observer 코드 변경은 **extension host 코드 변경보다 비용이 높다**:
```
VSIX 빌드 → VSIX 설치 → AG 재시작 #1 (extension이 HTML 패치)
→ AG 재시작 #2 (패치된 HTML 로드) → Observer 실행
```
**총 2번 AG 재시작 필요**
### 2.2 Extension host 코드만 변경 (approval-handler, http-bridge 등)
```
VSIX 빌드 → VSIX 설치 → AG 재시작 #1 → 즉시 적용
```
**1번 AG 재시작 필요**
### 2.3 VSIX 설치 확인
```powershell
Get-ChildItem "$env:USERPROFILE\.vscode\extensions" -Filter "*gravity*" -Directory |
ForEach-Object { $j = Get-Content (Join-Path $_.FullName "package.json") | ConvertFrom-Json; "$($_.Name): v$($j.version)" }
```
### 2.4 HTML 패치 확인 (Observer 코드가 반영되었는지)
```powershell
Select-String -Path "$env:LOCALAPPDATA\Programs\Antigravity\resources\app\out\vs\code\electron-browser\workbench\workbench-jetski-agent.html" -Pattern "검색할_함수명" -Quiet
```
---
## 3. AG Native DOM 구조
### 3.1 Chat Panel (Observer가 접근 가능)
- 위치: `document.querySelector('#conversation')` 또는 `document.querySelector('[class*="conversation"]')`
- AI 응답 블록: `.leading-relaxed.select-text`
- 사용자 메시지 블록: `.select-text.rounded-lg` (v0.5.74+)
- Thinking 블록: 조상에 `max-h-[200px]` 클래스 있음 → 필터링
### 3.2 승인 버튼 — "Always run" (v0.5.93 BTN-DOM-DUMP 확인)
실제 DOM 구조 (v0.5.92 로그로 확인):
- d0: button.flex.cursor-pointer (Always run 버튼)
- d1: div.min-w-0
- d2: div.flex.items-center.justify-between.rounded-b.border-t (버튼 바)
- d3: div (이름 없는 컨테이너)
- div.mb-1 = "Running command" (헤더)
- div.flex = " gravity_control > ..." (실제 명령어, plain div!)
- div.flex = "Always run Cancel" (버튼들)
> 명령어는 pre.font-mono나 code가 아닌 plain div.flex에 있음.
> v30: "Running command" div의 형제를 탐색하여 프롬프트 마커 뒤의 명령어 추출.
- "Retry" 버튼: button 태그, chat panel 내
### 3.3 Diff Review (Accept all / Reject all) — Observer 접근 가능 (v0.5.101+)
- **v0.5.101 이전**: 에디터 webview에 렌더링, Observer document에서 접근 불가
- **v0.5.101 이후**: AG UI 업데이트로 chat panel footer에 `<span class="cursor-pointer">` 태그로 렌더링
- Observer의 `allBtns` 선택자에 `span.cursor-pointer` 포함 필수
- `matchedType = 'diff_review'`로 분류됨 (L1120: `txt.includes('Accept')`)
- auto-approve response 파일에 `_from_ws: true` 마커 필수 (processResponseFile race condition 방지)
### 3.4 DOM 렌더링 타이밍
- "Always run" 버튼이 DOM에 나타날 때 명령어 div도 함께 렌더링됨
- v30의 "Running command" div 탐색은 즉시 성공
### 3.5 log() relay 필터 규칙
Observer의 log() 함수는 키워드 필터로 일부 로그만 extension.log에 relay.
새 로그 키워드 추가 시 반드시 필터도 함께 수정해야 함.
현재 필터 키워드 (v0.5.92+):
CV-CLASSES, CV-CHILDREN, child[, CV found, Conversation view,
BEACON, ERROR, chat relay, user-cls,
CONTEXT, BTN-DOM, DEFERRED, DETECTED
---
## 4. 파일 경로 매핑
| 항목 | 경로 |
|------|------|
| AG 설치 경로 | `$env:LOCALAPPDATA\Programs\Antigravity\` |
| workbench HTML | `...\resources\app\out\vs\code\electron-browser\workbench\workbench-jetski-agent.html` |
| Extension 로그 | `$env:USERPROFILE\.gemini\antigravity\bridge\extension.log` |
| Pending 파일 | `$env:USERPROFILE\.gemini\antigravity\bridge\pending\*.json` |
| Response 파일 | `$env:USERPROFILE\.gemini\antigravity\bridge\response\*.json` |
| VSIX extensions | `$env:USERPROFILE\.vscode\extensions\variet.gravity-bridge-*\` |
| HTTP Bridge 포트 | 34332 (또는 `getDeterministicPort('gravity_control')`) |
| Discord 채널 | `#ag-gravity_control` (ID: 1483082084540223663) |
---
## 5. 과거 실수 및 교훈
### 5.1 VSIX 미설치 (v0.5.78~83)
- **증상**: 빌드만 하고 `code --install-extension` 실행 안 함
- **결과**: 설치된 버전이 v0.5.50, 모든 수정사항 미적용
- **교훈**: 빌드 후 반드시 `code --install-extension *.vsix --force` 실행
- **확인**: `Get-ChildItem "$env:USERPROFILE\.vscode\extensions" -Filter "*gravity*"`
### 5.2 function 선언 → Observer 크래시 (v0.5.84~86)
- **증상**: `function _isGenericDesc(d){}` 를 for 루프 내부에 선언
- **결과**: Observer 전체 크래시, chat relay + auto-approve 중단
- **교훈**: `var fn = function(){}` 사용, 배포 전 SYNTAX CHECK 필수
### 5.3 깨진 문자열 리터럴 (v0.5.86)
- **증상**: `{tag:'??',...}` (특수문자가 따옴표 깨뜨림)
- **결과**: SYNTAX ERROR, Observer 미작동
- **교훈**: template literal 안에서 특수문자/이모지 사용 주의
### 5.4 regex 이스케이핑 실패 (v0.5.83~84)
- **증상**: `/Always\\s+run/` → 생성 시 `\\s` (리터럴 백슬래시+s)로 출력
- **결과**: "Always run" 매칭 실패
- **교훈**: template literal 안에서 regex 대신 **문자열 비교** 사용
### 5.5 _from_ws 파일 무한 누적 (v0.5.78~84)
- **증상**: response 파일에 `_from_ws: true` 마커 → processResponseFile이 스킵 → 영원히 삭제 안 됨
- **결과**: 3초마다 4개 파일 × SKIP 로그 → 로그 스팸, 다른 처리 방해
- **교훈**: 보존 파일에는 반드시 **TTL(자동 만료)** 추가
---
## 6. 디버깅 체크리스트
### Observer가 작동하지 않을 때
1. `extension.log`에서 `setup complete` 확인 → Observer 로드 여부
2. `OBSERVER-LOG` 패턴 검색 → 스캔 활동 여부
3. `HTTP-REQ` 검색 → HTTP bridge에 요청 도달 여부
4. **SYNTAX CHECK** 실행 → 생성 스크립트 문법 검증
5. `SKIP _from_ws` 반복 확인 → stale response 파일 정리
### Discord에 메시지가 안 올 때
1. `POST /chat` 검색 → chat relay 전송 여부
2. `WS.*send` 검색 → WebSocket 전송 여부
3. Discord API로 직접 확인: `node extension/scratch/discord_read.js`
4. stale response 파일 확인: `Get-ChildItem $env:USERPROFILE\.gemini\antigravity\bridge\response\*.json`
---
## 7. 버전 히스토리 요약
| 버전 | 핵심 변경 | 결과 |
|------|----------|------|
| v0.5.50 | 기본 릴레이 시스템 | ✅ 안정 |
| v0.5.78 | `_from_ws` 마커 (Retry 보존) | ✅ 작동 (TTL 미구현) |
| v0.5.79 | sibling 탐색 + thinking 필터 | ✅ 작동 |
| v0.5.80~81 | Accept all offsetParent 완화 | ❌ 구조적 불가 (에디터 webview) |
| v0.5.82 | 버튼 셀렉터 확장 + ACCEPT-SCAN | 진단용 |
| v0.5.83 | DEFERRED 컨텍스트 500ms | regex 이스케이핑 실패 |
| v0.5.84 | regex → 문자열 비교 | function 선언 크래시 |
| v0.5.85 | `_from_ws` TTL 60초 | ✅ stale 정리 |
| v0.5.86 | function → var expression | 깨진 문자열 미발견 |
| v0.5.87 | 깨진 문자열 2건 수정 | ✅ SYNTAX OK |

View File

@@ -0,0 +1,216 @@
# AG Native 릴레이 아키텍처 분석
> **이 문서는 AG Native ↔ Discord 릴레이의 데이터 흐름 SSOT입니다.**
> 구현/디버깅 전 반드시 확인합니다.
---
## 1. 데이터 경로 요약
AG Native에서 Discord로 메시지를 전달하는 경로는 크게 2개:
| # | 경로 | 소스 | 실시간? | 상태 |
|---|------|------|---------|------|
| 1 | **Observer DOM** | workbench.html 인라인 스크립트 → DOM 관찰 → HTTP POST → http-bridge | ✅ 실시간 | AI 응답: ✅ 작동 (v0.5.72+), 사용자 메시지: ✅ 작동 (v0.5.74+) |
| 2 | **Step Probe (trajectory API)** | LS RPC `GetCascadeTrajectorySteps` → step 분석 | ❌ cascade 완료 후에만 | AI 응답: ❌ 실시간 불가, 사용자 메시지: ❌ 실시간 불가 |
### 1.1 핵심 API 제약 (2026-04-18 확인)
> [!CAUTION]
> **`GetCascadeTrajectorySteps`는 진행 중인 cascade의 step을 실시간으로 반환하지 않습니다.**
> step count는 cascade가 **완전히 종료**(IDLE 전환)된 후에만 업데이트됩니다.
> 따라서 Step Probe의 RT-CAPTURE, HB-CAPTURE 모두 **현재 진행 중인 대화에서는 작동하지 않습니다.**
**검증 데이터**:
- POLL에서 `status=CASCADE_RUN_STATUS_IDLE`, `steps=928`, `delta=0` 고정
- HEARTBEAT probe: `offset=927 got=1 real=928 known=928` → 변함 없음
- 실제로 수십 개의 tool call이 실행되었지만 step count 불변
- Cascade 종료 후 다음 poll에서 step count가 점프 (예: 733 → 865 → 928)
### 1.2 AG Native SDK EventMonitor
SDK에 이벤트 시스템이 있으나 **모두 polling 기반**:
- `EventMonitor.onStepCountChanged` — getDiagnostics 기반 polling
- `EventMonitor.onActiveSessionChanged` — state.vscdb 기반 polling
- **실시간 push (WebSocket/SSE)는 없음**
- 현재 상태: ERR_CONNECTION_REFUSED 문제로 비활성화됨
---
## 2. Observer DOM 경로 상세
### 2.1 Observer 스크립트 삽입 체인
```
extension.ts activate()
→ html-patcher.ts setupApprovalObserver()
→ observer-script.ts generateApprovalObserverScript(port)
→ workbench-jetski-agent.html에 인라인 <script> 삽입
→ AG 재시작 시 렌더러가 로드
```
### 2.2 Observer 주요 함수
| 함수 | 역할 |
|------|------|
| `scanChatBodies()` | 3초마다 실행, conversation view에서 메시지 블록 탐색 |
| `extractCleanStepText(el)` | DOM 클론 → style/script/button 제거 → textContent 추출 |
| `extractContextFromNearby(btn)` | 승인 버튼 주변 DOM에서 명령어 텍스트 추출 (v23: sibling 탐색 포함) |
| `pollResponseGroup(rid, btnRefs)` | response 파일 polling → 버튼 자동 클릭 |
### 2.3 AI 응답 감지 셀렉터
```javascript
var responseBlocks = cv.querySelectorAll(
'.leading-relaxed.select-text, ' // ← AI 응답 마크다운 블록 (주력)
+ '.text-ide-message-block-user-color, ' // ← 사용자 메시지 (미매칭)
+ '.text-ide-message-block-bot-color, ' // ← NUX tooltip 전용 (오매칭)
+ '.bg-ide-message-block-user-background, '// ← 사용자 메시지 (미매칭)
+ '[data-message-role="user"], ' // ← 사용자 메시지 (미매칭)
+ '[data-role="user"]' // ← 사용자 메시지 (미매칭)
);
```
> [!NOTE]
> **v0.5.74에서 사용자 메시지 셀렉터가 추가되었습니다.**
> AG Native 소스(`jetskiAgent/main.js`)의 `Esn` 컴포넌트 분석으로
> 사용자 메시지 CSS 클래스(`msn = "bg-gray-500/10 border border-gray-500/20 p-2 rounded-lg w-full text-sm select-text"`)를 식별.
> 셀렉터: `.select-text.rounded-lg`, 역할 판별: `rounded-lg` 있고 `leading-relaxed` 없으면 → user
### 2.4 AI 응답 추출 흐름
```
scanChatBodies() 3초 간격
→ cv = document.querySelector('#conversation')
→ responseBlocks = cv.querySelectorAll('.leading-relaxed.select-text, ...')
→ lastBlock = responseBlocks[last] (가장 최근 블록)
→ 이미 scrape 됐으면 skip
→ blockText = extractCleanStepText(lastBlock)
→ 안정화 대기 (3초 동안 텍스트 변경 없으면)
→ POST /chat { text, source, block_index, role }
→ http-bridge → writeChatSnapshot() → WS → Discord
```
### 2.5 Observer 업데이트 제약
> [!CAUTION]
> **Observer 코드는 workbench.html에 인라인 삽입됩니다.**
> extension reload만으로는 Observer 코드가 업데이트되지 않습니다.
> **AG 재시작 + V8 CachedData 삭제**가 필요합니다.
> (단, product.json 체크섬이 맞으면 CachedData 삭제 없이 AG 재시작만으로 충분할 수 있음)
---
## 3. 승인 버튼 (Auto-Approve) 경로
### 3.1 "Always run" 자동 승인 흐름
```
Observer DOM scan
→ "Always run" 버튼 텍스트 감지
→ POST /pending { command: "Always run", description: "...", buttons: [...] }
→ http-bridge _handlePending()
→ alwaysRunDetected = true
→ enrichment 시도:
1. rawDesc에서 > 프롬프트 마커 찾기 → ✅ 성공 (buttons=2일 때 desc에 프롬프트 포함)
2. rawDesc 최장 라인 사용 → buttons=1일 때 desc="Always run"이라 실패
3. v20 fallback: bridge/pending/ 최신 파일에서 command 읽기 → Step Probe pending 있을 때만
4. v23 sibling: Observer가 footer 형제 요소에서 pre.font-mono 탐색 → ✅ 성공
→ response 파일 작성 → Observer pollResponseGroup → 버튼 클릭
→ WS sendPending { status: 'auto_approved', command: displayCmd }
→ Discord embed 표시
```
### 3.2 명령어 enrichment 현황 (2026-04-18 검증)
| 조건 | 결과 | 빈도 |
|------|------|------|
| Observer가 buttons=2 (`["Always run","Cancel"]`)이고 desc에 `>` 포함 | ✅ 명령어 표시 | ~50% |
| Observer가 buttons=1 (`["Always run"]`)이고 desc="Always run" | ❌ "Always run" 표시 | ~50% |
**로그 증거** (04:25:59):
```
AUTO-APPROVE raw: cmd="Always run" desc="…\extension > npm.cmd run compile..." buttons=["Always run","Cancel"]
→ cmd="npm.cmd run compile 2>&1; npm.cmd version patch..." ✅ 성공
AUTO-APPROVE raw: cmd="Always run" desc="Always run" buttons=["Always run"]
→ cmd="Always run" ❌ 실패
```
> buttons=2인 경우("Always run" + "Cancel")는 Observer가 code 블록을 찾아 description에 포함.
> buttons=1인 경우는 code 블록이 DOM에서 아직 렌더링되지 않았거나 접근 불가.
---
## 4. 사용자 메시지 릴레이 상태
### 4.1 현재 상태: ✅ 작동 (v0.5.74+)
| 경로 | 상태 | 비고 |
|------|------|------|
| Observer DOM | ✅ | `.select-text.rounded-lg` 셀렉터로 캡처 (v0.5.74) |
| Step Probe (trajectory API) | ❌ | cascade 진행 중 step 조회 불가 |
| Step Probe (observer [USER-MSG]) | ❌ | `lastUserInputStepIndex`가 갱신되지 않음 |
### 4.2 해결 방안
1. **DOM 덤프에서 사용자 메시지 클래스 식별** → Observer 셀렉터 추가
2. **Cascade 완료 후** Step Probe HB-CAPTURE에서 `USER_INPUT` step 캡처 (지연 릴레이)
---
## 5. 파일/포트 매핑
| 항목 | 값 |
|------|-----|
| Observer 삽입 대상 | `workbench-jetski-agent.html` |
| HTTP Bridge 포트 | `getDeterministicPort('gravity_control')` = **18080** |
| Extension 로그 | `~/.gemini/antigravity/bridge/extension.log` |
| Pending 파일 | `~/.gemini/antigravity/bridge/pending/*.json` |
| Response 파일 | `~/.gemini/antigravity/bridge/response/*.json` |
| Chat Snapshot 파일 | `~/.gemini/antigravity/bridge/chat_snapshots/*.json` |
| Discord 채널 | `#ag-gravity_control` (ID: 1483082084540223663) |
| Discord Bot 토큰 | `.env``DISCORD_TOKEN` |
---
## 6. 디버깅 도구
| 도구 | 경로 | 용도 |
|------|------|------|
| Discord 메시지 읽기 | `extension/scratch/discord_read.js` | API로 채널 최근 메시지 조회 |
| Discord 채널 목록 | `extension/scratch/discord_channels.js` | 서버 채널 목록 조회 |
| Extension 로그 확인 | `Select-String -Path $logFile -Pattern "패턴"` | 실시간 로그 분석 |
| DOM 구조 덤프 | Observer 자동 (CV-CHILDREN 로그) | AG Native DOM 클래스 식별 |
---
## 7. 버전 히스토리 (v0.5.67~)
| 버전 | 변경 | 결과 |
|------|------|------|
| v0.5.67 | Observer DOM relay 비활성화, Step Probe RT-CAPTURE로 전환 | ❌ API가 진행중 step 미반환 |
| v0.5.68 | auto-approve enrichment 디버그 로그 추가, 조건 >10 → >3 완화 | Observer가 desc="Always run" 보냄 확인 |
| v0.5.69 | pending 파일 fallback으로 auto-approve 명령어 enrichment | 일부 개선 (Step Probe pending 있을 때만) |
| v0.5.70 | heartbeat 로깅 강화 | API step count 동결 확인 |
| v0.5.71 | heartbeat 3 poll마다 실행, HB-CAPTURE 추가 | API가 진행중 step 미반환 재확인 |
| v0.5.72 | Observer DOM relay 재활성화 | AG 재시작 필요 (Observer HTML 캐시) |
| v0.5.74 | 사용자 메시지 셀렉터 추가 (`.select-text.rounded-lg`) | ✅ 사용자 메시지 릴레이 작동 |
| v0.5.76 | DOM 탐색 depth 5→10, `pre.font-mono` 우선 탐색 | Observer HTML 업데이트 필요 |
| v0.5.77 | WS response 파일 작성 (pollResponseGroup용) | Retry 클릭 경로 추가 |
| v0.5.78 | `_from_ws` 마커로 processResponseFile 삭제 방지 | ✅ Retry auto-approve 작동 |
| v0.5.79 | sibling 탐색 추가 + thinking 블록 필터링 | ✅ 명령어 컨텍스트 부분 추출 |
---
## 8. 남은 작업 (TODO)
- [x] AG 재시작하여 Observer 반영 확인 — ✅ v0.5.72 작동 확인
- [x] Observer의 AI 응답 릴레이가 작동하는지 Discord에서 확인 — ✅ 작동
- [x] 사용자 메시지 셀렉터 추가 — ✅ v0.5.74
- [x] Retry auto-approve 흐름 복구 — ✅ v0.5.78 (_from_ws 마커)
- [x] 명령어 컨텍스트 sibling 탐색 — ✅ v0.5.79
- [x] Thinking 블록 필터링 — ✅ v0.5.79
- [ ] 명령어 컨텍스트 추출 타이밍 이슈 (DOM 렌더링 전 scan 시 추출 실패) #636
- [ ] Observer pollResponseGroup 미시작 케이스 (trigger-click 선점)
- [ ] AI 응답이 마지막 블록만 캡처되는 문제 개선 (전문 캡처)

View File

@@ -0,0 +1,160 @@
"""Analyze AG Native DOM structure to find AI response containers."""
import json, os, sys
def load_dump():
bridge = os.path.join(os.path.expanduser('~'), '.gemini', 'antigravity', 'bridge')
# Try deep-inspect result first, then dump_html
for fname in ['deep-inspect-result.json', 'dump_html.json']:
fpath = os.path.join(bridge, fname)
if os.path.exists(fpath):
print(f"Loading: {fname} ({os.path.getsize(fpath)} bytes)")
with open(fpath, 'r', encoding='utf-8-sig') as f:
return json.load(f), fname
return None, None
def find_text_containers(node, path="", depth=0, results=None):
"""Recursively find nodes with substantial text content (potential AI response containers)."""
if results is None:
results = []
if not isinstance(node, dict):
return results
tag = node.get('tag', '')
cls = node.get('cls', '')
text = node.get('text', '')
attrs = node.get('attrs', {})
children = node.get('children', [])
cur_path = f"{path}/{tag}"
if cls:
short_cls = cls[:60]
cur_path += f".{short_cls}"
# Look for nodes with long text (potential AI responses)
if text and len(text) > 50:
results.append({
'path': cur_path,
'depth': depth,
'tag': tag,
'cls': cls[:100],
'text_len': len(text),
'text_preview': text[:120],
'attrs': {k:v for k,v in attrs.items() if k not in ('style',)}
})
for child in children:
find_text_containers(child, cur_path, depth+1, results)
return results
def find_by_class_pattern(node, patterns, path="", depth=0, results=None):
"""Find nodes matching class patterns."""
if results is None:
results = []
if not isinstance(node, dict):
return results
tag = node.get('tag', '')
cls = node.get('cls', '')
attrs = node.get('attrs', {})
children = node.get('children', [])
text = node.get('text', '')
cur_path = f"{path}/{tag}"
for pattern in patterns:
if pattern.lower() in cls.lower() or pattern.lower() in str(attrs).lower():
child_count = len(children)
results.append({
'path': cur_path,
'depth': depth,
'tag': tag,
'cls': cls[:150],
'pattern': pattern,
'text_preview': text[:80] if text else '',
'child_count': child_count,
'attrs': {k:v[:50] for k,v in attrs.items() if k != 'style'}
})
for child in children:
find_by_class_pattern(child, patterns, cur_path, depth+1, results)
return results
def analyze_chat_structure(node, path="", depth=0):
"""Find the chat/conversation area by looking at the main layout."""
if not isinstance(node, dict):
return
tag = node.get('tag', '')
cls = node.get('cls', '')
children = node.get('children', [])
text = node.get('text', '')
attrs = node.get('attrs', {})
# Print interesting structural nodes at shallow depths
if depth <= 6:
child_count = len(children)
has_text = bool(text and len(text) > 10)
info = f"{' '*depth}{tag}"
if cls:
info += f" .{cls[:80]}"
if attrs:
attr_str = ' '.join(f'{k}={v[:30]}' for k,v in attrs.items() if k not in ('style','class'))
if attr_str:
info += f" [{attr_str}]"
info += f" children={child_count}"
if has_text:
info += f" text=\"{text[:50]}...\""
print(info)
for child in children:
analyze_chat_structure(child, f"{path}/{tag}", depth+1)
data, fname = load_dump()
if not data:
print("No dump file found!")
sys.exit(1)
# Handle both dump formats
body = data.get('body', data)
qi = data.get('quickInfo', {})
print("=" * 60)
print("QUICK INFO")
print("=" * 60)
if qi:
for k, v in qi.items():
if k == 'buttons':
print(f"buttons ({len(v)}):")
for b in v[:15]:
print(f" [{b.get('tag')}] \"{b.get('text','')[:50]}\" visible={b.get('visible')} cls={b.get('cls','')[:60]}")
elif k == 'dataAttrs':
print(f"dataAttrs: {v[:30]}")
else:
print(f"{k}: {v}")
print("\n" + "=" * 60)
print("CHAT-RELATED CLASS PATTERNS")
print("=" * 60)
patterns = ['chat', 'message', 'conversation', 'response', 'answer', 'reply',
'markdown', 'prose', 'content', 'panel', 'agent', 'assistant',
'planner', 'step', 'trajectory', 'bot', 'ai-', 'turn']
matches = find_by_class_pattern(body, patterns)
for m in matches:
print(f" [{m['tag']}] cls=\"{m['cls']}\" pattern={m['pattern']} children={m['child_count']} {m.get('attrs',{})}")
print("\n" + "=" * 60)
print("LONG TEXT NODES (potential AI responses)")
print("=" * 60)
texts = find_text_containers(body)
texts.sort(key=lambda x: x['text_len'], reverse=True)
for t in texts[:20]:
print(f" [{t['tag']}] depth={t['depth']} len={t['text_len']} cls=\"{t['cls'][:60]}\"")
print(f" text: \"{t['text_preview']}\"")
if t['attrs']:
print(f" attrs: {t['attrs']}")
print("\n" + "=" * 60)
print("DOM TREE (depth<=6)")
print("=" * 60)
analyze_chat_structure(body)

View File

@@ -0,0 +1,19 @@
import json, os, sys
dump_path = os.path.join(os.path.expanduser('~'), '.gemini', 'antigravity', 'bridge', 'dump_html.json')
with open(dump_path, 'r', encoding='utf-8') as f:
data = json.load(f)
qi = data.get('quickInfo', {})
print('=== Quick Info ===')
print('hasConversationView:', qi.get('hasConversationView'))
print('hasStepIndex:', qi.get('hasStepIndex'))
print('hasBotColor:', qi.get('hasBotColor'))
print('hasMarkdownBody:', qi.get('hasMarkdownBody'))
print('hasProse:', qi.get('hasProse'))
print('totalElements:', qi.get('totalElements'))
print('dataTestIds:', qi.get('dataTestIds'))
print('dataAttrs (first 20):', qi.get('dataAttrs', [])[:20])
print('buttons (first 10):')
for b in qi.get('buttons', [])[:10]:
print(f" [{b.get('tag')}] {b.get('text', '')[:60]} visible={b.get('visible')}")

View File

@@ -0,0 +1,83 @@
"""Search AG Native DOM dump for chat content and buttons."""
import json, os
fpath = os.path.join(os.path.expanduser('~'), '.gemini', 'antigravity', 'bridge', 'dump_html_5.json')
with open(fpath, 'r', encoding='utf-8-sig') as f:
data = json.load(f)
body = data.get('body', data.get('bodyTree', {}))
qi = data.get('quickInfo', {})
# Show all buttons
print('=== BUTTONS ===')
for b in qi.get('buttons', []):
print(f' [{b["tag"]}] "{b["text"][:60]}" visible={b["visible"]} cls={b.get("cls","")[:80]}')
# Data attrs
print('\n=== DATA ATTRS ===')
for attr in qi.get('dataAttrs', []):
print(f' {attr}')
# Recursive search for nodes by text
def find_nodes_by_text(node, target, path='', results=None, depth=0):
if results is None: results = []
if not isinstance(node, dict): return results
tag = node.get('tag','')
cls = node.get('cls','')
text = node.get('text','')
children = node.get('children', [])
cur = f'{path}/{tag}'
if target.lower() in text.lower():
results.append({'path': cur, 'depth': depth, 'cls': cls[:80], 'text': text[:80], 'children': len(children)})
for c in children:
find_nodes_by_text(c, target, cur, results, depth+1)
return results
print('\n=== NODES containing "Always run" ===')
matches = find_nodes_by_text(body, 'Always run')
for m in matches:
print(f' depth={m["depth"]} cls="{m["cls"]}" text="{m["text"]}" children={m["children"]}')
print('\n=== NODES containing "Always" ===')
matches = find_nodes_by_text(body, 'Always')
for m in matches:
print(f' depth={m["depth"]} cls="{m["cls"]}" text="{m["text"]}" children={m["children"]}')
# Find ALL text nodes with > 30 chars
def find_all_text(node, results=None, depth=0, path=''):
if results is None: results = []
if not isinstance(node, dict): return results
tag = node.get('tag','')
cls = node.get('cls','')
text = node.get('text','')
children = node.get('children', [])
if text and len(text) > 30:
results.append({'depth': depth, 'tag': tag, 'cls': cls[:80], 'text': text[:100], 'path': f'{path}/{tag}'})
for c in children:
find_all_text(c, results, depth+1, f'{path}/{tag}')
return results
print('\n=== LONG TEXT NODES (>30 chars) ===')
texts = find_all_text(body)
texts.sort(key=lambda x: len(x['text']), reverse=True)
for t in texts[:25]:
print(f' d={t["depth"]} [{t["tag"]}] cls="{t["cls"][:50]}" len={len(t["text"])} "{t["text"][:80]}"')
# Find nodes with many children (structural containers)
def find_containers(node, results=None, depth=0, path=''):
if results is None: results = []
if not isinstance(node, dict): return results
tag = node.get('tag','')
cls = node.get('cls','')
children = node.get('children', [])
if len(children) > 5:
results.append({'depth': depth, 'tag': tag, 'cls': cls[:100], 'children': len(children), 'path': f'{path}/{tag}'})
for c in children:
find_containers(c, results, depth+1, f'{path}/{tag}')
return results
print('\n=== CONTAINERS (>5 children) ===')
conts = find_containers(body)
conts.sort(key=lambda x: x['children'], reverse=True)
for c in conts[:20]:
print(f' d={c["depth"]} [{c["tag"]}] children={c["children"]} cls="{c["cls"][:70]}"')

View File

@@ -0,0 +1,109 @@
"""Trace the DOM path from body to AI response container."""
import json, os
fpath = os.path.join(os.path.expanduser('~'), '.gemini', 'antigravity', 'bridge', 'dump_html_5.json')
with open(fpath, 'r', encoding='utf-8-sig') as f:
data = json.load(f)
body = data.get('body', data.get('bodyTree', {}))
def find_path_to_class(node, target_cls, path=None, depth=0):
"""Find the DOM path down to a node with a matching class."""
if path is None: path = []
if not isinstance(node, dict): return []
tag = node.get('tag', '')
cls = node.get('cls', '')
children = node.get('children', [])
text = node.get('text', '')
attrs = node.get('attrs', {})
entry = {
'depth': depth,
'tag': tag,
'cls': cls[:120],
'children': len(children),
'text': text[:60] if text else '',
'attrs': {k:v[:40] for k,v in attrs.items() if k not in ('style',)}
}
if target_cls.lower() in cls.lower():
return path + [entry]
for i, child in enumerate(children):
result = find_path_to_class(child, target_cls, path + [entry], depth+1)
if result:
return result
return []
# Find path to the AI response container
print("=== PATH TO 'leading-relaxed select-text' ===")
path = find_path_to_class(body, 'leading-relaxed select-text')
for p in path:
indent = ' ' * p['depth']
print(f'{indent}[{p["tag"]}] cls="{p["cls"]}" children={p["children"]} {p["attrs"]}')
if p['text']:
print(f'{indent} text: "{p["text"]}"')
# Now get the full subtree of the AI response container
def get_subtree(node, target_cls, depth=0):
if not isinstance(node, dict): return None
cls = node.get('cls', '')
if target_cls.lower() in cls.lower():
return node
for child in node.get('children', []):
result = get_subtree(child, target_cls, depth+1)
if result:
return result
return None
print("\n=== AI RESPONSE CONTAINER SUBTREE ===")
container = get_subtree(body, 'leading-relaxed select-text')
if container:
def print_tree(node, depth=0, max_depth=4):
if not isinstance(node, dict) or depth > max_depth: return
tag = node.get('tag','')
cls = node.get('cls','')[:80]
text = node.get('text','')
children = node.get('children', [])
indent = ' ' * depth
line = f'{indent}[{tag}]'
if cls: line += f' cls="{cls}"'
line += f' children={len(children)}'
if text: line += f' text="{text[:60]}"'
print(line)
for c in children:
print_tree(c, depth+1, max_depth)
print_tree(container, 0, 3)
# Also search for the chat panel container - what wraps the entire conversation
print("\n=== SEARCH FOR CHAT PANEL WRAPPERS ===")
chat_patterns = ['chat', 'antigravity', 'gemini', 'panel', 'agentview', 'sidebar', 'conversation']
for pat in chat_patterns:
path = find_path_to_class(body, pat)
if path:
last = path[-1]
print(f' Pattern "{pat}" found at depth={last["depth"]} [{last["tag"]}] cls="{last["cls"]}" children={last["children"]}')
# Find the parent chain from body to the container - look by scanning ALL class names
print("\n=== ALL UNIQUE CLASS NAMES (depth <= 12) ===")
all_classes = set()
def collect_classes(node, depth=0, max_depth=12):
if not isinstance(node, dict) or depth > max_depth: return
cls = node.get('cls', '')
if cls:
for c in cls.split():
if len(c) > 3 and not c.startswith('{') and 'mtk' not in c:
all_classes.add(c)
for child in node.get('children', []):
collect_classes(child, depth+1, max_depth)
collect_classes(body)
# Print classes sorted, grouped by potential relevance
relevant = sorted([c for c in all_classes if any(k in c.lower() for k in
['chat', 'message', 'response', 'agent', 'gemini', 'turn', 'model', 'user', 'bot', 'conversation', 'markdown', 'prose', 'text-', 'content'])])
print("Relevant classes:")
for c in relevant:
print(f' {c}')

BIN
.gitlog.txt Normal file

Binary file not shown.

570
bot.py
View File

@@ -30,8 +30,7 @@ from parser import (
md_to_discord_text,
format_task_embed_text,
)
from watcher import BrainEvent, EventType
from bridge import BridgeProtocol, ApprovalRequest, UserResponse
from models import BrainEvent, EventType, ApprovalRequest, UserResponse
logger = logging.getLogger(__name__)
@@ -47,10 +46,9 @@ class ApprovalView(discord.ui.View):
(e.g., ✅ Allow Once / ✅ Allow This Conversation / ❌ Deny)
"""
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest,
def __init__(self, request: ApprovalRequest,
buttons: list[dict] | None = None, hub=None):
super().__init__(timeout=1800) # 30 minutes
self.bridge = bridge
self.hub = hub # WSHub instance for WS response routing
self.request = request
self.responded = False
@@ -100,12 +98,9 @@ class ApprovalView(discord.ui.View):
# Hub WS route (primary — reaches remote Extensions)
delivered = False
if self.hub:
delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, {
await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": response_data,
})
if not delivered:
# File bridge fallback (Hub unavailable OR owner disconnected)
self.bridge.write_response(UserResponse(**response_data))
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
color = discord.Color.red() if is_reject else discord.Color.green()
@@ -131,13 +126,10 @@ class ApprovalView(discord.ui.View):
"step_type": getattr(self.request, 'step_type', ''),
"project_name": getattr(self.request, 'project_name', ''),
}
delivered = False
if self.hub:
delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, {
await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": response_data,
})
if not delivered:
self.bridge.write_response(UserResponse(**response_data))
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
embed.color = discord.Color.green()
@@ -158,13 +150,10 @@ class ApprovalView(discord.ui.View):
"step_type": getattr(self.request, 'step_type', ''),
"project_name": getattr(self.request, 'project_name', ''),
}
delivered = False
if self.hub:
delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, {
await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": response_data,
})
if not delivered:
self.bridge.write_response(UserResponse(**response_data))
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
embed.color = discord.Color.red()
@@ -172,12 +161,14 @@ class ApprovalView(discord.ui.View):
await interaction.response.edit_message(embed=embed, view=None)
async def on_timeout(self):
if not self.responded:
self.bridge.write_response(UserResponse(
request_id=self.request.request_id, approved=False,
step_type=getattr(self.request, 'step_type', ''),
project_name=getattr(self.request, 'project_name', ''),
))
if not self.responded and self.hub:
await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": {
"request_id": self.request.request_id, "approved": False,
"step_type": getattr(self.request, 'step_type', ''),
"project_name": getattr(self.request, 'project_name', ''),
}
})
# ─── Bot ─────────────────────────────────────────────────────────────
@@ -207,7 +198,6 @@ class GravityBot(commands.Bot):
self._sent_commands: dict[str, str] = {} # request_id → command text (for MERGE edit detection)
self._ready_event = asyncio.Event()
self._channel_lock = asyncio.Lock()
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
@@ -233,7 +223,7 @@ class GravityBot(commands.Bot):
"project_name": kwargs.get('project_name', project),
}
# Hub route (primary — skip file bridge to prevent double delivery)
# Hub route (primary)
if self.hub:
import time as _time
cmd_data["id"] = str(int(_time.time() * 1000))
@@ -246,14 +236,6 @@ class GravityBot(commands.Bot):
asyncio.create_task(
self.hub.broadcast_to_project(project, msg)
)
return # ← WS sent, skip file bridge
# Legacy fallback (file bridge + gateway HTTP) — only when Hub is unavailable
self.bridge.write_command(project, text, **kwargs)
if self.gateway:
import time as _time
cmd_data["id"] = cmd_data.get("id", str(int(_time.time() * 1000)))
self.gateway.push_command(project, cmd_data)
def _cap_dict(self, d: dict, max_size: int = 5000):
"""Prevent memory leaks by capping dictionary sizes using insertion order (oldest first)."""
@@ -269,8 +251,6 @@ class GravityBot(commands.Bot):
async def setup_hook(self):
self.loop.create_task(self._process_events())
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)
@@ -353,57 +333,12 @@ class GravityBot(commands.Bot):
logger.error("No permission to create category!")
return
# Discover existing project channels
await self._discover_channels()
# Load conversation → project registrations from Extension
self._load_registrations()
# Sync slash commands to guild
try:
self.tree.copy_global_to(guild=self.guild)
synced = await self.tree.sync(guild=self.guild)
logger.info(f"Synced {len(synced)} slash commands to guild")
except Exception as e:
logger.warning(f"Slash command sync failed: {e}")
# Open the gate
# Start WS Hub processors by ensuring ready gate is open
self._ready_event.set()
logger.info("Ready gate opened — event processing enabled")
# Start scanner loops
if not self.pending_approval_scanner.is_running():
self.pending_approval_scanner.start()
if not self.chat_snapshot_scanner.is_running():
self.chat_snapshot_scanner.start()
logger.info("Scanner loops started")
# ─── Channel Management ──────────────────────────────────────────
def _load_registrations(self):
"""Read bridge/register/ to learn conversation → project mappings."""
register_dir = self.bridge.bridge_dir / "register"
if not register_dir.exists():
return
count = 0
for f in register_dir.glob("*.json"):
try:
data = json.loads(f.read_text(encoding="utf-8-sig"))
conv_id = data.get("conversation_id", "")
project = data.get("project_name", "")
if conv_id and project:
self.conv_to_project[conv_id] = project
count += 1
except (json.JSONDecodeError, OSError):
pass
# Only log when count changes
prev = getattr(self, '_last_reg_count', -1)
if count != prev:
self._last_reg_count = count
if count:
logger.info(f"Loaded {count} conversation→project registrations")
# ─── Channel Management ──────────────────────────────────────────
@@ -618,286 +553,8 @@ class GravityBot(commands.Bot):
# ─── Approval Scanner ────────────────────────────────────────────
@tasks.loop(seconds=30) # Hub mode: WS is primary, file scan is fallback only
async def pending_approval_scanner(self):
"""Scan bridge/pending/ for new approval requests + reload registrations.
Per-tick caps prevent Discord API rate limit cascade when multiple
projects generate pending files simultaneously.
"""
try:
# Reload conv→project registrations each cycle
self._load_registrations()
# Channels are created on-demand when actual signals arrive
# (via _get_channel in snapshot scanner / approval sender)
MAX_NEW_PER_TICK = 5 # Phase 1: max new pending to process per tick
MAX_STATUS_PER_TICK = 5 # Phase 2: max status changes to process per tick
phase1_processed = 0
requests = self.bridge.get_pending_requests()
for req in requests:
if phase1_processed >= MAX_NEW_PER_TICK:
break
if req.request_id in self._sent_approval_ids:
continue
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:
# Defence: reject-word commands should NEVER be auto-approved
# (DOM observer may create standalone "Deny" pending from file_permission UI)
reject_commands = {"deny", "reject", "cancel", "decline", "dismiss", "stop"}
if req.command.strip().lower() in reject_commands:
logger.warning(f"Auto-approve BLOCKED: command='{req.command}' is reject-word — skipping")
self._cap_dict(self._sent_approval_ids)
self._sent_approval_ids[req.request_id] = True
phase1_processed += 1
continue
self._cap_dict(self._sent_approval_ids)
self._sent_approval_ids[req.request_id] = True
# Smart button_index: read buttons array from pending file
# file_permission buttons = [Allow Once(0), Allow This Conv(1), Deny(2)]
# MUST pick non-reject button for safety
approve_btn_index = 0
pending_file = self.bridge.pending_dir / f"{req.request_id}.json"
if pending_file.exists():
try:
pdata = json.loads(pending_file.read_text(encoding="utf-8-sig"))
btns = pdata.get("buttons")
if btns and len(btns) > 1:
reject_words = {"deny", "reject", "cancel", "reject all",
"decline", "dismiss", "stop"}
for b in btns:
txt = b.get("text", "").lower().strip()
if txt not in reject_words:
approve_btn_index = b.get("index", 0)
break
except (json.JSONDecodeError, OSError):
pass
# Write auto-approve response for Extension
self.bridge.write_response(UserResponse(
request_id=req.request_id,
approved=True,
button_index=approve_btn_index,
step_type=getattr(req, 'step_type', ''),
project_name=project,
))
# Show compact auto-approved embed in Discord
channel = await self._get_channel(project)
if channel:
try:
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)
except Exception as e:
logger.error(f"[AUTO-APPROVE] Discord send failed for {project}: {e}")
else:
logger.warning(f"[AUTO-APPROVE] No Discord channel for project={project} — notification skipped")
logger.info(f"Auto-approved: {req.request_id[:12]} project={project} btn_idx={approve_btn_index}")
phase1_processed += 1
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)
if len(req.command) <= 15:
if req.request_id not in self._deferred_ids:
self._deferred_ids[req.request_id] = 1
continue # skip this cycle
elif self._deferred_ids[req.request_id] < 4:
self._deferred_ids[req.request_id] += 1
# Re-read from file (step_probe may have merged)
fresh = self.bridge.read_pending_request(req.request_id)
if fresh and len(fresh.command) > 15:
req = fresh # use merged version — send now!
else:
continue # wait one more cycle
# Clean up defer tracking
self._deferred_ids.pop(req.request_id, None)
channel = await self._get_channel(project)
if channel:
self._cap_dict(self._sent_approval_ids)
self._sent_approval_ids[req.request_id] = True
self._cap_dict(self._sent_commands)
self._sent_commands[req.request_id] = req.command
await self._send_approval_request(channel, req)
phase1_processed += 1
else:
logger.warning(f"[APPROVAL] No Discord channel for project={project} — approval request skipped (rid={req.request_id[:12]})")
# ── Single-pass: handle auto_resolved, expired, and MERGE in one glob ──
phase2_processed = 0
for f in self.bridge.pending_dir.glob("*.json"):
if phase2_processed >= MAX_STATUS_PER_TICK:
break
try:
data = json.loads(f.read_text(encoding="utf-8-sig"))
status = data.get("status", "pending")
rid = data.get("request_id", "")
if status == "auto_resolved":
# FIX #5: Use _approval_messages as fallback when discord_message_id is 0
msg_id = data.get("discord_message_id", 0) or self._approval_messages.get(rid, 0)
project = data.get("project_name", Config.PROJECT_NAME)
logger.info(f"[AUTO-RESOLVED] rid={rid[:12]} project={project} msg_id={msg_id} cmd='{data.get('command', '')[:60]}'")
if msg_id:
channel = await self._get_channel(project)
if channel:
try:
msg = await channel.fetch_message(msg_id)
embed = discord.Embed(
title="✅ AG에서 직접 승인됨",
description=f"```\n{data.get('command', '')[:500]}\n```",
color=discord.Color.green(),
)
embed.set_footer(text=f"ID: {rid}")
await msg.edit(embed=embed, view=None)
logger.info(f"[AUTO-RESOLVED] ✅ Discord message {msg_id} updated")
except discord.NotFound:
logger.warning(f"[AUTO-RESOLVED] Discord message {msg_id} not found")
else:
logger.warning(f"[AUTO-RESOLVED] No msg_id for rid={rid[:12]} — cannot edit Discord message")
f.unlink()
self._deferred_ids.pop(rid, None)
self._sent_commands.pop(rid, None)
self._approval_messages.pop(rid, None)
self._sent_approval_ids.pop(rid, None)
phase2_processed += 1
elif status == "expired":
msg_id = data.get("discord_message_id", 0)
project = data.get("project_name", Config.PROJECT_NAME)
if msg_id:
channel = await self._get_channel(project)
if channel:
try:
msg = await channel.fetch_message(msg_id)
embed = discord.Embed(
title="⏰ 만료됨",
description=f"```\n{data.get('command', '')[:500]}\n```",
color=discord.Color.light_grey(),
)
embed.set_footer(text=f"ID: {rid}")
await msg.edit(embed=embed, view=None)
except discord.NotFound:
pass
f.unlink()
self._deferred_ids.pop(rid, None)
self._sent_commands.pop(rid, None)
self._sent_approval_ids.pop(rid, None)
phase2_processed += 1
elif status == "pending":
# MERGE check: step_probe updated command in already-sent pending
if rid not in self._sent_approval_ids:
continue
msg_id = data.get("discord_message_id", 0)
if not msg_id:
continue
new_cmd = data.get("command", "")
old_cmd = self._sent_commands.get(rid, "")
if new_cmd and new_cmd != old_cmd and len(new_cmd) > len(old_cmd):
self._sent_commands[rid] = new_cmd
project = data.get("project_name", Config.PROJECT_NAME)
channel = await self._get_channel(project)
if channel:
try:
msg = await channel.fetch_message(msg_id)
buttons = data.get("buttons")
desc_parts = [f"**명령어:**\n```\n{new_cmd[:1000]}\n```"]
if buttons and len(buttons) > 1:
btn_names = [b.get("text", "?") for b in buttons]
desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")
desc = data.get("description", "")
if desc:
desc_parts.append(desc[: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: {rid}")
await msg.edit(embed=embed)
logger.info(f"MERGE edit: {rid[:12]} cmd='{new_cmd[:60]}'")
except discord.NotFound:
pass
except (json.JSONDecodeError, OSError):
pass
except Exception as e:
logger.error(f"Error scanning approvals: {e}")
@pending_approval_scanner.before_loop
async def before_scanner(self):
await self.wait_until_ready()
async def _send_approval_request(
self, channel: discord.TextChannel, request: ApprovalRequest
):
# Read buttons array from pending file (if present)
buttons = None
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
if pending_file.exists():
try:
pending_data = json.loads(
pending_file.read_text(encoding="utf-8-sig")
)
buttons = pending_data.get("buttons")
except (json.JSONDecodeError, OSError):
pass
# Build embed description
desc_parts = [f"**명령어:**\n```\n{request.command[:1000]}\n```"]
if buttons and len(buttons) > 1:
# Multi-choice: show all options in description
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.request_id}")
view = ApprovalView(self.bridge, request, buttons=buttons, hub=self.hub)
msg = await channel.send(embed=embed, view=view)
if pending_file.exists():
try:
data = json.loads(pending_file.read_text(encoding="utf-8-sig"))
data["discord_message_id"] = msg.id
pending_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
except (json.JSONDecodeError, OSError):
pass
logger.info(f"Sent approval request: {request.request_id[:12]}")
self._cap_dict(self._approval_messages)
self._approval_messages[request.request_id] = msg.id # FIX #4: Track msg_id for auto_resolved lookup
# ─── Discord → IDE Text Relay + Multi-PC UX ───────────────────────────
@@ -1030,11 +687,28 @@ class GravityBot(commands.Bot):
if request_id in self._sent_approval_ids:
return
# Check auto_resolved status
# Check auto_resolved / auto_approved status
status = data.get("status", "pending")
if status in ("auto_resolved", "expired"):
await self._handle_auto_resolved(request_id, status)
return
if status == "auto_approved":
# Bridge-level auto-approve (e.g. "Always run") — show notification only
channel = await self._get_channel(project)
if channel:
cmd_text = data.get("command", "")[:200]
desc_text = data.get("description", "")[:300]
embed = discord.Embed(
title="🤖 자동 승인됨 (Always run)",
description=f"✅ **{cmd_text}**" + (f"\n```\n{desc_text}\n```" if desc_text and len(desc_text) > 3 else ""),
color=discord.Color.green(),
)
embed.set_footer(text=f"auto-approve | {request_id[:12]}")
await channel.send(embed=embed)
self._cap_dict(self._sent_approval_ids)
self._sent_approval_ids[request_id] = True
logger.info(f"[HUB-PENDING] Auto-approved (Always run): {request_id[:12]} project={project}")
return
instance_number = data.get("_instance_number", 0)
pc_name = data.get("_pc_name", "")
@@ -1067,30 +741,74 @@ class GravityBot(commands.Bot):
desc_parts = []
if header:
desc_parts.append(header)
desc_parts.append(f"**명령:** `{request.command[:200]}`")
# Clean command text (remove "Running2" artifacts → "Running 2")
cmd_text = request.command[:200]
import re
cmd_text = re.sub(r'Running(\d)', r'Running \1', cmd_text)
desc_parts.append(f"**명령:** `{cmd_text}`")
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])
# Clean description: strip noise headers and garbage
desc_raw = request.description or ""
# Remove old-style headers
desc_raw = re.sub(r'\[AI 본문 요약\]\s*', '', desc_raw)
desc_raw = re.sub(r'\[결행 명령\]\s*', '', desc_raw)
# Remove lines that are clearly noise
desc_lines = desc_raw.split('\n')
clean_desc_lines = []
for dline in desc_lines:
dline_stripped = dline.strip()
if not dline_stripped:
continue
# Skip UI artifacts
if dline_stripped in ('chevron_right', 'chevron_left', 'close', 'check',
'content_copy', 'expand_more', 'expand_less',
'Show more', 'Show less', 'Copy', 'Edit', 'Copied!'):
continue
# Skip "Thought for Xs"
if re.match(r'^Thought for \d+', dline_stripped):
continue
# Skip TypeScript declarations and file paths
if re.match(r'^(declare|import|export)\s+(class|function|interface|type|enum|const)', dline_stripped):
continue
if re.search(r'\.ts:\d+:', dline_stripped):
continue
if re.search(r'extension.*src.*sdk', dline_stripped, re.IGNORECASE):
continue
clean_desc_lines.append(dline_stripped)
clean_desc = '\n'.join(clean_desc_lines).strip()
if clean_desc and len(clean_desc) > 3:
# Truncate and wrap in code block for readability
if len(clean_desc) > 300:
clean_desc = clean_desc[:300] + ''
desc_parts.append(f"```\n{clean_desc}\n```")
embed = discord.Embed(
title="⚠️ 승인 요청",
title=f"⚠️ 승인 요청{request.step_type or 'action'}",
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, hub=self.hub)
msg = await channel.send(embed=embed, view=view)
view = ApprovalView(request, buttons=buttons, hub=self.hub)
msg = await channel.send(
content=f"🔔 **새로운 승인 요청이 도착했습니다** (ID: {request_id[:8]})",
embed=embed,
view=view
)
self._cap_dict(self._sent_approval_ids)
self._sent_approval_ids[request_id] = True
self._cap_dict(self._approval_messages)
self._approval_messages[request_id] = msg.id
logger.info(f"[HUB-PENDING] Sent approval: {request_id[:12]} project={project}")
logger.info(f"[HUB-PENDING] Sent approval: {request_id[:12]} project={project} | URL: {msg.jump_url}")
except Exception as e:
logger.error(f"[HUB-PENDING] Error: {e}")
@@ -1100,9 +818,8 @@ class GravityBot(commands.Bot):
self._cap_dict(self._sent_approval_ids)
self._sent_approval_ids[request.request_id] = True
delivered = False
if self.hub:
delivered = await self.hub.send_response_to_pending_owner(request.request_id, {
await self.hub.send_response_to_pending_owner(request.request_id, {
"type": "response",
"data": {
"request_id": request.request_id,
@@ -1112,20 +829,13 @@ class GravityBot(commands.Bot):
"project_name": request.project_name,
},
})
if not delivered:
# File bridge fallback (Hub unavailable OR owner disconnected)
self.bridge.write_response(UserResponse(
request_id=request.request_id, approved=True,
step_type=request.step_type,
project_name=request.project_name,
))
# Send compact auto-approved embed to Discord (was missing — caused silent approvals)
channel = await self._get_channel(request.project_name)
if channel:
try:
embed = discord.Embed(
title="🤖 자동 승인됨",
description=f"```\n{request.command[:500]}\n```",
description=f"✅ **{request.command}**\n\n```\n{request.description[:2000]}\n```" if getattr(request, "description", "") else f"✅ **{request.command}**",
color=discord.Color.green(),
)
embed.set_footer(text=f"auto-approve | {request.request_id[:12]}")
@@ -1209,7 +919,7 @@ class GravityBot(commands.Bot):
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
from models import BrainEvent, EventType
event = BrainEvent(
event_type=EventType(data.get("event_type", "file_changed")),
conversation_id=data.get("conversation_id", ""),
@@ -1249,110 +959,4 @@ class GravityBot(commands.Bot):
# ─── Chat Snapshot Scanner ─────────────────────────────────────────
@tasks.loop(seconds=30) # Hub mode: WS is primary, file scan is fallback only
async def chat_snapshot_scanner(self):
"""Scan bridge/chat_snapshots/ for AI response dumps."""
try:
snapshot_dir = self.bridge.bridge_dir / "chat_snapshots"
if not snapshot_dir.exists():
return
for f in snapshot_dir.glob("*.json"):
try:
data = json.loads(f.read_text(encoding="utf-8-sig"))
project = data.get("project_name", Config.PROJECT_NAME)
content = data.get("content", "")
attached_files = data.get("attached_files", [])
if content or attached_files:
channel = await self._get_channel(project)
if not channel:
logger.warning(f"[SNAPSHOT] No Discord channel for project={project} — snapshot skipped (len={len(content)})")
elif channel:
import io
# ── Send attached files (from Extension's writeChatSnapshotWithFiles) ──
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,
))
FILE_ATTACH_THRESHOLD = 4000
if len(content) > FILE_ATTACH_THRESHOLD:
# Long chat content → summary embed + file attachment
summary = 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),
)
# Add content itself as file attachment
discord_files.append(discord.File(
io.BytesIO(content.encode("utf-8")),
filename="chat_message.md",
))
try:
await channel.send(embed=embed, files=discord_files)
logger.info(f"[SNAPSHOT] Sent to #{channel.name} (file, {len(content)} chars)")
except discord.NotFound:
logger.warning(f"Channel deleted for {project}, re-creating...")
self.project_channels.pop(project, None)
channel = await self._get_channel(project)
if channel:
# Re-create files (discord.File consumed after send)
discord_files2 = []
for af in attached_files:
af_name = af.get("name", "document.md")
af_content = af.get("content", "")
if af_content:
discord_files2.append(discord.File(
io.BytesIO(af_content.encode("utf-8")),
filename=af_name,
))
discord_files2.append(discord.File(
io.BytesIO(content.encode("utf-8")),
filename="chat_message.md",
))
await channel.send(embed=embed, files=discord_files2)
logger.info(f"[SNAPSHOT] Sent to #{channel.name} after re-create (file, {len(content)} chars)")
except Exception as e:
logger.error(f"[SNAPSHOT] Discord send failed for {project}: {e}")
else:
# Short content → inline embed (original)
embed = discord.Embed(
title="💬 AI 대화 내용",
description=content,
color=discord.Color.purple(),
timestamp=datetime.now(timezone.utc),
)
try:
await channel.send(
embed=embed,
files=discord_files if discord_files else discord.utils.MISSING,
)
logger.info(f"[SNAPSHOT] Sent to #{channel.name} (inline, {len(content)} chars)")
except discord.NotFound:
logger.warning(f"Channel deleted for {project}, re-creating...")
self.project_channels.pop(project, None)
channel = await self._get_channel(project)
if channel:
await channel.send(embed=embed)
logger.info(f"[SNAPSHOT] Sent to #{channel.name} after re-create (inline)")
except Exception as e:
logger.error(f"[SNAPSHOT] Discord send failed for {project}: {e}")
f.unlink() # Cleanup
except (json.JSONDecodeError, OSError) as e:
logger.warning(f"Bad chat snapshot {f.name}: {e}")
except Exception as e:
logger.error(f"Error scanning chat snapshots: {e}")
@chat_snapshot_scanner.before_loop
async def before_chat_scanner(self):
await self.wait_until_ready()

265
bridge.py
View File

@@ -1,265 +0,0 @@
"""Bridge protocol — communication between Discord bot and Antigravity.
Bridge directory: ~/.gemini/antigravity/bridge/
Structure:
bridge/
pending/ ← Bot writes approval requests for Discord
response/ ← Bot writes user responses from Discord
commands/ ← Bot writes user text input from Discord
Protocol:
1. VS Code Extension detects pending approval → writes JSON to pending/
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
"""
import json
import time
import logging
import uuid
from abc import ABC, abstractmethod
from pathlib import Path
from dataclasses import dataclass, asdict
from enum import Enum
from config import Config
logger = logging.getLogger(__name__)
class ApprovalStatus(Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
TIMEOUT = "timeout"
@dataclass
class ApprovalRequest:
"""An approval request from Antigravity."""
request_id: str
conversation_id: str
command: str # The command/action needing approval
description: str # Human-readable description
timestamp: float
status: str = "pending"
discord_message_id: int = 0
project_name: str = "" # Project routing key
step_type: str = "" # e.g. 'diff_review', passed through to response
@dataclass
class UserResponse:
"""A user response from Discord."""
request_id: str
approved: bool
user_input: str = ""
timestamp: float = 0
button_index: int = -1 # -1 = legacy (approve/reject), 0+ = specific button index
step_type: str = "" # pass through from pending for extension routing
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)
# ─── Bridge Protocol (uses Transport) ───
class BridgeProtocol:
"""Manages the bridge protocol via a pluggable transport."""
def __init__(self, transport: BridgeTransport | None = None):
if transport is None:
bridge_dir = Config.BRAIN_PATH.parent / "bridge"
transport = LocalTransport(bridge_dir)
self.transport = transport
# Legacy attributes for backward compatibility
# (bot.py uses self.bridge.pending_dir etc. in some places)
if isinstance(transport, LocalTransport):
self.bridge_dir = transport.bridge_dir
self.pending_dir = transport.bridge_dir / "pending"
self.response_dir = transport.bridge_dir / "response"
self.commands_dir = transport.bridge_dir / "commands"
# Ensure directories exist
self.transport.ensure_dirs()
# Startup cleanup: purge stale pending files (> 5 min old)
self._cleanup_stale_pending()
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 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")
def get_pending_requests(self) -> list[ApprovalRequest]:
"""Read all pending approval requests. Skips files older than 30 minutes."""
requests = []
fields = {f.name for f in ApprovalRequest.__dataclass_fields__.values()}
now = time.time()
MAX_AGE = 1800 # 30 minutes (matches Discord button timeout)
CLEANUP_AGE = 86400 # 1 day
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 > CLEANUP_AGE:
# Too old even to keep as expired — delete to prevent accumulation
self.transport.delete_file("pending", fname)
continue
if now - ts > MAX_AGE:
# Too old — mark expired and skip
if data.get("status") != "expired":
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 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)."""
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:
return ApprovalRequest(**filtered)
except TypeError:
return None
def write_response(self, response: UserResponse):
"""Write a user response to the response directory."""
response.timestamp = time.time()
fname = f"{response.request_id}.json"
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)
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)}_{uuid.uuid4().hex[:8]}"
fname = f"{cmd_id}.json"
data = {
"id": cmd_id,
"conversation_id": conversation_id,
"project_name": project_name,
"text": text,
"timestamp": time.time(),
"consumed": False,
}
self.transport.write_json("commands", fname, data)
logger.info(f"Command written: {cmd_id} → project={project_name}")
return cmd_id

0
diag_output.txt Normal file
View File

View File

@@ -0,0 +1,8 @@
# 2026-04-08
| NNN | HH:MM | 작업 설명 | `커밋해시` | 상태 |
|---|---|---|---|---|
| 004 | 14:00 | SafeToAutoRun 알림 누락 복구 (v0.5.18) | `8f2a1b3` | ✅ |
| 005 | 16:30 | SafeToAutoRun pending skip으로 인한 데드락 원인 파악 및 롤백 | `13f13ee` | ✅ |
| 006 | 07:30 | SafeToAutoRun 데드락 완전 해결을 위한 Agnostic Bridge 도입 및 프리징 방어 (v0.5.20) | `임시해시` | ✅ |
| 007 | 17:57 | Gravity Bridge 안정화: 중복 알림(SafeToAutoRun) 제거 설계 확정 및 Discord 봇 캐시/로컬 LS 크로스매칭 증상 디버깅 완료 | \-\ | ✅ |

View File

@@ -0,0 +1,9 @@
# 2026-04-09
| NNN | HH:MM | 작업 설명 | `커밋해시` | 상태 |
|---|---|---|---|---|
| 001 | 21:55 | Agent UI Tailwind/Native 마이그레이션 대응 (DOM 옵저버 구조 개편) | `HEAD` | ✅ |
| 002 | 22:30 | Agent UI 버튼 무시 버그 긴급수정 (CodeLens 필터교정) | `HEAD` | ✅ |
| 003 | 23:15 | Native UI 아이콘 글루잉 대응 스캐너 픽스 (DOM Regex 매칭 강화) | `HEAD` | ✅ |
| 004 | 00:10 | Discord Signal Relay & Auto-Approve Body Null 버그 수정 (False Positive 차단) | "HEAD" | ✅ |
| 005 | 23:00 | fix: Resolve empty Discord embed body by populating detailed step-probe payload | \\ | ? |

View File

@@ -0,0 +1,4 @@
| NNN | 시간 | 작업 설명 | 커밋해시 | 완료여부 |
|---|---|---|---|---|
| 001 | 17:11 | step-probe.ts 의 isRunning 조건 누락으로 인한 릴레이 증발 버그 픽스 | COMMITTING | ✅ |
| #613 | 21:10 | Bridge Relay AI Chat Body DOM extraction and template literal Regex fix | TBD | ✅ |

View File

@@ -0,0 +1,6 @@
# 2026-04-11
| NNN | HH:MM | 작업 설명 | `커밋해시` | ✅ 또는 🔧 |
|-------|-------|----------|-----------|-----------|
| 001 | 13:04 | Pure 웹소켓 게이트웨이 완전 전환 및 레거시 파일 브릿지 통신코드 삭제 | `072f83b` | ✅ |
| 002 | 17:25 | Antigravity Observer 컨텍스트 추출 범위 제한 및 노이즈(UI/TypeScript 코드) 필터링, Discord 임베드 개선 | `70dc301` | ✅ |

View File

@@ -0,0 +1,7 @@
# 2026-04-12
| NNN | HH:MM | 작업 설명 | `커밋해시` | 완/미 |
|-------|-------|----------|-----------|-----------|
| 001 | 06:12 | AG Native DOM 파싱 v7 전면 재설계 — data-testid/data-step-index 기반 step-aware 파서, UI 노이즈 차단 | `a4d7286` | 🔧 |
| 002 | 07:03 | AG Native 번들 역공학 분석 + V8 CachedData 삭제 — plannerResponse→Whi 렌더러 구조 확인, bot-color가 NUX용임 발견 | — | 🔧 |
| 003 | 07:37 | Observer v8 전면 개편 — conversation-view 의존 제거, body 전체 무조건 덤프(depth 15), 5s/15s/60s 자동 덤프, VSIX v0.5.37 설치 | `0e03b3a` | 🔧 |

View File

@@ -0,0 +1,7 @@
# 2026-04-13
| NNN | HH:MM | 작업 설명 | `커밋해시` | 완료여부 |
|-------|-------|----------|-----------|----------|
| 001 | 09:50 | Observer v8 검증 — Extension POLL 확인, HTML 패치 확인, V8 캐시 삭제(24MB), BEACON 미수신(AG 재시작 필요) | 없음 | 🔧 |
| 002 | 12:34 | DOM Observer 데이터 품질 검증 + UTF-8 인코딩 수정 + noise 필터 강화 (v0.5.39) | `pending` | ✅ |
| 003 | 19:26 | Observer v9: "Running N commands" 오인 수정 + DOM-climbing 컨텍스트 추출 + http-bridge 필터 완화 (v0.5.40) | `pending` | 🔧 |

View File

@@ -0,0 +1,5 @@
# 2026-04-14
| NNN | HH:MM | 작업 설명 | `커밋해시` | 완료여부 |
|-------|-------|----------|-----------|----------|
| 001 | 07:29 | Observer v9 E2E 검증 — BEACON/ping 동작 확인, pending POST 수신 확인, 컨텍스트 추출 실패 진단 (ctx="Always run"), v10-diag 진단 로깅 추가 (observer + http-bridge), V8 캐시 삭제(523MB) | `c1e61d8` | 🔧 |

View File

@@ -0,0 +1,6 @@
# 2026-04-15
| NNN | HH:MM | 작업 설명 | `커밋해시` | 완료? |
|-------|-------|----------|-----------|----------|
| 001 | 09:12 | PROMPT_ONLY_RE 근본원인 분석 및 수정 — Observer regex 이스케이핑(4중→2중 backslash) + http-bridge ellipsis prefix 지원, 16개 테스트 전체 통과, VSIX v0.5.45 빌드/배포 | `01539e9` | ✅ |
| 002 | 10:35 | Observer fallback 컨텍스트 추출 수정 — v0.5.45 VSIX 설치 누락 발견/수정 + v13 `_promptOnlySkipped` 플래그로 채팅/UI 텍스트 추출 차단 + bridge generic button 무조건 필터 (v0.5.46) | `b8cda27` | 🔧 |

11
docs/devlog/2026-04-16.md Normal file
View File

@@ -0,0 +1,11 @@
# 2026-04-16
| NNN | HH:MM | 작업 설명 | `커밋해시` | 완료? |
|-------|-------|----------|-----------|----------|
| 001 | 04:52 | v16 터미널 출력 필터 + v15 stale LS 자동복구 + heartbeat probe — stdout가 enrichedCmd로 채택되는 버그 수정, VSIX v0.5.50 빌드/배포 | `7ade31e` | 🔧 |
| 002 | 05:28 | AG Native AI 응답 Discord 미전달 근본원인 분석 + Observer v15 scanChatBodies 이중전략 (#conversation + .leading-relaxed.select-text) 구현, v0.5.51 배포 | `729875f` | 🔧 |
| 003 | 17:13 | Observer v15 E2E 코드 검증 — CSP/체크섬/문법/핸들러 전수 확인 OK. BEACON=0 원인: AG 미재시작(렌더러 HTML 캐시). v0.5.50/out에 v15 JS 직접 배포 | — | ✅ |
| 004 | 21:07 | Observer v16 CSS 추출 버그 수정 — `<style>` 태그 textContent가 AI 응답으로 Discord 전달. extractCleanStepText()에 style/script strip 추가, v0.5.52 배포 | `62ee081` | ✅ |
| 005 | 22:07 | Observer v17 Always run 자동승인 + Retry 릴레이 — "Always run" 브릿지 레벨 자동승인, Retry 버튼 Discord 전달, v0.5.53 배포 | `7dbf73a` | ✅ |
| 006 | 21:28 | AG Native <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Markdown <20><><EFBFBD><EFBFBD> (<28><><EFBFBD><EFBFBD>Ʈ/<2F><>) <20><><EFBFBD><EFBFBD> <20><> User <20><>û <20>̼<EFBFBD><CCBC><EFBFBD> <20>м<EFBFBD>, <20><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD> <20><>ȹ<EFBFBD><C8B9> <20>ۼ<EFBFBD> (v0.5.54 <20><><EFBFBD><EFBFBD> <20><>) | ? | ?? |

View File

@@ -0,0 +1,5 @@
# 2026-04-17
| NNN | HH:MM | 작업 설명 | `커밋해시` | 완료 |
|-------|-------|----------|-----------|----------|
| 001 | 08:05 | v18 Observer DOM->Markdown 파서 개선(<a> 파싱 포함) 및 노이즈 필터 부작용(코드 블럭 잘림 방지) 해결, User 구문 추출 연동, v0.5.56 배포 | `미정` | ✅ |

View File

@@ -0,0 +1,5 @@
# Devlog — 2026-04-18
| # | 시간 | 작업 설명 | 커밋 | 상태 |
|---|------|----------|------|------|
| 001 | 09:20~23:50 | Retry auto-approve 흐름 복구 — WS response 파일 보존 (`_from_ws`), Observer 형제 탐색(sibling), thinking 블록 필터링 | `pending` | ✅ |

26
docs/devlog/2026-04-19.md Normal file
View File

@@ -0,0 +1,26 @@
# Devlog 2026-04-19
## 작업 인덱스
| # | 시간 | 작업 | 커밋 | 상태 |
|---|------|------|------|------|
| 001 | 21:22 | v30-32 Observer 명령어 추출 안정화 (터미널 프롬프트 조기감지) | `bd5a7ca` | ✅ |
| 002 | 23:16 | v33 Accept all 자동승인 — diff review auto-approve | `6aea48e` | ✅ |
| 003 | 00:18 | v34 Accept all 이중 보장 — agentAcceptAllInFile 직접 호출 | `cf1352e` | ✅ |
| 004 | 00:34 | v35 code_edit 자동 Accept — step-probe 경로 | `2bf1eb4` | ✅ |
| 005 | 04:26 | v36 Accept all span 감지 — 근본 원인 발견 (button→span) | `e95e779` | ✅ |
| 006 | 04:34 | v37 openReviewChanges 선호출 — agentAcceptAllInFile 보조 | `3cc3442` | ✅ |
| 007 | 04:43 | v38 _from_ws 마커 추가 — Observer polling 실패 근본 수정 | `7c8891b` | ✅ |
## v0.5.103 — Accept all (Diff Review) 자동 승인 복구
### 근본 원인 (2가지)
1. **Observer 감지 실패**: AG UI가 "Accept all"을 `<button>`이 아닌 `<span class="cursor-pointer">`로 렌더링. Observer의 `allBtns` 선택자가 `button`만 스캔하여 미감지.
2. **Response 파일 race condition**: auto-approve response 파일에 `_from_ws: true` 마커 없음 → `processResponseFile`이 Observer보다 먼저 파일 삭제 → Observer polling 무한 실패.
### 검증 결과
- Observer ACCEPT-SCAN: `tag=SPAN cls=cursor-pointer txt=Accept all`
- `DETECTED diff_review: Accept all`
- `response served to renderer: ...approved=true` (이전 0건 → 7건) ✅
- Discord "자동 승인됨 Accept all" 표시 ✅
- 화면에서 "Accept all" 버튼 자동 소멸 확인 ✅

View File

@@ -0,0 +1,20 @@
# 2026-04-08 (004) - SafeToAutoRun 명령어의 디스코드 알림 누락 복구
## 1. 이슈 개요
- 사용자가 `/start` 등 백그라운드 명령어(SafeToAutoRun)를 포함한 워크플로우를 실행하였으나, 디스코드로 아무런 메시지도 전송되지 않는 버그가 보고됨.
## 2. 원인 분석
- v0.5.16 배포 당시 Discord 중복 알림(Pending 파일) 이슈를 방지하는 과정에서, `step-probe.ts`에 있던 "⚡ 자동 실행됨" 원본 알림 코드(snapshot 생성 로직)까지 실수로 함께 삭제됨.
- `SafeToAutoRun` 구문에서 `writePendingApproval` 스킵 로직은 잘 동작하고 있었으나, 정작 사용자에게 알려야 할 기본적인 '자동 실행됨' 정보마저 소실되어 결과적으로 아무 알림도 가지 않는 침묵 상태가 됨.
## 3. 해결 및 적용 사항
1. `step-probe.ts` 복구
- `SafeToAutoRun` 판단 시 `autoRunSteps`를 마킹한 직후 `ctx.writeChatSnapshot()`을 호출하도록 코드를 추가 복원함.
- 출력 구조: `💬 **자동 실행됨** (step N)\n\n\`명령어내용\``
2. **v0.5.18 배포**
- 익스텐션의 `package.json` 버전을 `0.5.18`로 펌핑.
- 사전 스크립트가 적용된 `vsce package`를 통해 새로운 `gravity-bridge-0.5.18.vsix` 패키징을 완료함.
## 4. Next Step
- `extension/gravity-bridge-0.5.18.vsix` 파일을 VS Code에 수동 설치할 것 (Install from VSIX...).
- 설치 후 반드시 **Reload Window**하여 테스트 수행 요망.

View File

@@ -0,0 +1,20 @@
# 2026-04-08 (005) - SafeToAutoRun 로컬 자동 승인 누락 데드락(Freeze) 해결
## 1. 이슈 개요
- 사용자가 확인 결과 v0.5.15 이후 백그라운드 터미널 명령어 등 모든 AI 에이전트 작업이 '자동 실행됨' 스냅샷만 보내고 VS Code 내부적으로는 여전히 승인(Allow)을 대기하며 완전히 멈춰버림(Freeze).
- 신호가 전달조차 안되고 다음 단계로 진행하지 못하는 심각한 블로커 이슈가 발생함.
## 2. 원인 분석
- v0.5.16 버그 픽스("Discord 중복 알림 방지") 당시 `SafeToAutoRun` 상태일 때 `writePendingApproval()`을 수행하지 않도록 코드(`skip pending`)를 변경했음.
- 그러나 과거에는 이 Pending 파일이 생성되면 파이썬 백엔드(Bot)가 디스코드에 알림을 띄운 직후, 자동으로 `approve`(허용) 신호를 익스텐션 쪽에 보내어 다음 단계가 허가되었음.
- 즉, 익스텐션에서 Pending 파일 생성을 중단(skip)하자 봇으로부터 수락 신호가 아예 오지 않게 되었고, VS Code의 보안 시스템에 의해 명령어는 영원히 "Run(Auto)" 클릭 승인을 대기하는 상태의 데드락에 빠져버림.
## 3. 해결 및 적용 사항
1. `step-probe.ts` 로컬 자동 승인 복구
- `safeToAutoRun` 판단으로 Pending 파일 생성을 건너뛸 때, 익스텐션 스스로 백그라운드 승인을 트리거하도록 `tryApprovalStrategies(true, ...)` 함수 호출 코드를 명시적으로 추가함.
- 이를 통해 봇의 승인 신호를 기다릴 필요 없이 즉각적으로 승인(Accept)을 단행하여 막힘없이 스텝이 연속 진행되도록 고침.
2. **v0.5.19 배포**
- VSIX 버전을 `0.5.19`로 펌핑 후 `npx vsce package` 명령으로 익스텐션을 재빌드함.
## 4. Next Step
- `extension/gravity-bridge-0.5.19.vsix` 파일을 수동 재설치하고 VS Code Window를 Reload 한 뒤, `/start` 같은 자동 워크플로우를 재실행하여 신호 블로킹(Freeze) 버그가 해결되었는지 최종 확인.

View File

@@ -0,0 +1,17 @@
# SafeToAutoRun 데드락 및 익스텐션 프리징 완벽 해결 (v0.5.20)
- **시간**: 2026-04-08 07:15~07:30
- **Commit**: `임시해시`
- **Vikunja**: #589 → done
## 발생 문제
1. **Deadlock**: 이전 버전(v0.5.15)에서 디스코드 알림을 줄이려고 익스텐션의 `step-probe.ts``SafeToAutoRun` 발생 시 `pending` 파일 생성 자체를 건너뛰도록 구현함. 하지만 AG 엔진은 CORTEX_STEP_STATUS_WAITING 상태에서 누군가가 해결해주기를 영원히 기다리게 되어, 파이프라인 전체가 데드락(UI 멈춤)에 빠지는 치명적인 부작용 발생.
2. **이벤트루프 Freeze**: `extension.ts``detectProjectName` 내부에서 동기식 `cp.execSync('git remote get-url origin')`를 실행하여 윈도우 환경에서 VS Code 이벤트루프가 막히고 WebSocket 통신이 유실되는 현상 발생.
## 결정 사항
- **Agnostic Bridge 철학 준수 (단일 경로 원칙 복구)**
- 익스텐션(`step-probe.ts`)은 절대 자의적으로 승인 처리를 하거나 `pending` 파일 생성을 스킵해서는 안 됨. 오직 브릿지 중계자 역할에 충실하도록 롤백하고, 대신 메타데이터에 `safe_to_auto_run: true` 속성을 실어 보냄.
- 파이썬 서버(`bot.py`) 관제탑이 이를 확인하면 디스코드에 알림(`Embed`)을 보내는 단계만 슬쩍 생략하고 그 즉시 허가증(`response/`)을 발급. 이를 통해 데드락 해제와 무소음 승인을 동시에 만족함.
- **비동기화 및 빌드 파이프라인 강제**
- 동기식 git 명령어 대신 비동기식 `.git/config` 파일 읽기로 교체.
- `package.json``vscode:prepublish` 스크립트를 부활시켜 낡은 소스코드가 VSIX에 패키징되는 문제 원천 차단.

View File

@@ -0,0 +1,18 @@
# Gravity Bridge 알림 최적화 및 연동 디버깅 완료
- **시간**: 2026-04-08 17:00~17:55
- **Commit**: `pending`
- **Vikunja**: 대상 작업 맵핑 예정
## 결정 사항
- `SafeToAutoRun``step-probe.ts`에서 날리던 **"⚡ 자동 실행됨"** 알림은 과감하게 완전히 제거하였습니다. 파이썬 봇이 이미 "🤖 자동 승인됨" Embed를 송출하고 있으므로 디자인 철학(익스텐션은 중립적인 릴레이 역할만 수행하고, 비즈니스 판정 알림은 중앙 봇이 담당)에 부합합니다.
- `extension.ts``writeChatSnapshot` 의존성을 줄여 트래픽 낭비와 중복 노이즈를 해소했습니다.
## 핵심 디버깅 (Troubleshooting)
- **`variet-llm` 프로젝트 연동 실패 이슈:**
1. `variet-llm` 창을 열었으나 채팅 패널을 열지 않아 안티그래비티 전용 언어 서버(LS)가 띄워져 있지 않은 상태에서 브릿지를 켬. 브릿지가 엉뚱하게 `gravity_control` LS에 바인딩 됨.
2. 사용자가 Discord에서 `variet-llm` 채널을 삭제해 버렸는데, 파이썬 봇(`bot.py`)은 캐시를 가지고 있어서 자신이 파괴된 채널을 대상으로 계속 통신을 시도하며 새 채널을 파지 않음 (HTTP 404).
3. 로컬 윈도우 재시작 및 도커(`docker-compose restart`) 컨테이너 재가동을 통해 **봇 프로세스 캐시 초기화** → 채널 자동 재생성, 완벽 디버깅에 성공했습니다.
## 미완료
- 없음. 모두 성공적으로 동작 중.

View File

@@ -0,0 +1,18 @@
# Agent UI Tailwind/Native 마이그레이션 대응 (DOM 옵저버 구조 개편)
- **시간**: 2026-04-09 19:40~21:55
- **Commit**: `[임시해시]`
- **Vikunja**: 신규 생성 후 완료 처리
## 트러블슈팅 및 결정 사항
최근 UI 업데이트 후 Discord 릴레이 신호(Run, Accept) 단절.
deep-inspect 덤프 분석 결과 Webview/Iframe 환경이 사라지고 Native DOM(VS Code 본문)에 напрямую 그려짐, 기존 시맨틱 클래스가 Tailwind로 변경.
1. 기존 `findPanel`이 패널을 못 찾자 `isBodyRoot` 모드로 스캔
2. 과거에 추가된 CodeLens 방어 로직(`if (isVSCodeMainWindow && isBodyRoot && PATS[p].type !== 'diff_review') continue;`)에 의해 모든 버튼 스캔이 **버려지고 있었음**.
**결정**:
엄격한 Panel Class Whitelist 기반 방어를 해제하고, 버튼이 `.monaco-editor` 내부에 있는 경우만 무시하도록 Blacklist 기반 방어로 선회.
UI 텍스트 글루잉(아이콘 통합) 대응 위해 패터닝 정규식을 `/^(?:Always\s*)?Run/i` 등으로 완화.
## 미완료
- 없음

View File

@@ -0,0 +1,15 @@
# Agent UI 버튼 무시(Discard) 버그 핫픽스
- **시간**: 2026-04-09 22:10~22:35
- **Commit**: `pending`
- **Vikunja**: 새로 생성 후 완료 예정
## 트러블슈팅 및 결정 사항
- **이슈**: Native UI 마이그레이션 직후 버튼을 눌러도 브릿지로 신호가 전혀 가지 않는 버그 접수
- **원인 분석**:
1. `extension.log` 확인 결과 `[HTTP] pending` 자체가 생성되지 않음 (브릿지 자체에 도달하지 않음).
2. DOM observer가 수집한 버튼이 `b.closest('.monaco-editor')` 필터 조건에 무조건 걸려서 버려지는 것이었음. Native 전환 후 채팅창이 에디터 탭 내부에 렌더링되면서 `.monaco-editor` 내부 자식이 됨.
- **결정**: 기존의 `b.closest('.monaco-editor')` 방어 로직을 폐기하고 실제 CodeLens 버튼 고유의 클래스 `.codelens-decoration`를 명시하도록 변경하여 구조 변화에 강건해지도록 개선 완료. `0.5.22` VSIX 재배포.
## 미완료
- 없음 (검증은 유저 몫으로 인계)

View File

@@ -0,0 +1,17 @@
# Agent UI Native 버튼 아이콘 글루잉 무시 현상 수정
- **시간**: 2026-04-09 23:00~23:15
- **Commit**: `TBD`
- **Vikunja**: 신규 생성 (UI 텍스트 글루잉 버튼 버그) → done
## 문제 상황
- 0.5.22 패치(CodeLens 필터) 이후에도 `Run`, `Accept` 버튼 클릭 시 디스코드 브릿지로 아무런 펜딩 요청(POST /pending)이 전송되지 않는 현상 발생.
- 원인 규명: Native UI 마이그레이션 적용 후, Agent 패널 버튼들의 아이콘(``, `▶` 등)이 리액트/Tailwind 컴포넌트 렌더링을 거쳐 `element.textContent` 상단에 문자열로 직접 병합(Gluing)됨.
- 옵저버 스크립트 내부 정규식(`/^(?:Always\s*)?Run/i`)이 문자열의 맨 첫(^) 시작을 강제하기 때문에, 아이콘으로 시작하는 버튼들의 명령어를 전부 오탐으로 간주함.
## 결정 사항
- 버튼의 텍스트를 읽는 즉시, `txt.replace(/^[^a-zA-Z0-9]+/, '')`를 적용하여 첫 글자가 영어/숫자가 될 때까지, 선행하는 모든 특수문자, 아이콘, 폰트 공백 등을 강제 삭제하도록 스크립트 내부의 3가지 탐색 루프 (본문 스캔, Sibling 버튼 수집, Webview trigger-click 인젝션)에 일괄 업데이트.
- 기존 `.monaco-editor``.chat-body` 등 부모 컨테이너에 지나치게 의존하던 `findButtonContainer``chat`, `prose`, `markdown`를 추가 화이트리스팅 하되 Tailwind UI 구조 특성상 시맨틱 래퍼를 찾지 못할 경우 3단계 위 부모를 반환하여 안전하게 컨텍스트를 확보하도록 고도화. -> **구조 변경 시에도 유연하게(Graceful) 기능 동작 지원 보장.**
## 결과
- `v0.5.23` (코드상 0.5.22 유지) VSIX 빌드 및 테스트 준비.

View File

@@ -0,0 +1,15 @@
# Discord Signal Relay & Auto-Approve Body Null 버그 수정 (False Positive 차단)
- **시간**: 2026-04-10 00:00~00:10
- **Commit**: `HEAD`
- **Vikunja**: #태스크번호 → done
## 트러블슈팅 & 삽질
- **DOM 정규식의 반란**: Native UI 패치 이후 버튼의 text-gluing 제거 때문에 정규식을 광범위하게 바꿨으나, 하필 `Run\s*` 조건에 단어 경계(`\b`)를 누락하는 바람에 VS Code 하단의 시스템 상태 버튼인 `Running 1 command`까지 AI의 `Run` 버튼으로 인식해버림. 무한 PENDING 스팸을 만들어 브릿지 큐를 폭파시킨 주범.
- **Auto-Approve 본문 누락**: 봇에서 자동 승인 Embed 생성 시 `req.description` (실행될 본문 코드)을 아예 그리지 않고 `req.command` (단순 버튼 라벨)만 출력하도록 코딩되어 있었음. 사용자는 '자동 승인' 알림을 받지만 정작 무엇이 승인되었는지는 전혀 알 수 없어 '본문 표시 자체가 안 된다'고 오해할 수밖에 없었음.
- **첫 알림 메시지 무시**: `step-probe.ts`에서 세션 전환 시 `lastNotifyStepIndex`를 초기화할 때 `-1` 로 리셋하지 않아 새 세션의 첫 안내 메시지가 매번 씹히는 증상 발견.
## 결정 사항
- `bot.py`의 Auto-Approve Embed 구문에 수동 승인처럼 본문을 표출하도록 렌더링 로직 통일.
- `observer-script.ts``TerminalCommand` 정규식에 `\b`를 추가하여 시스템 버튼과의 혼선을 원천 차단함.
- `step-probe.ts` 의 index reset 초기값을 `-1` 로 명시화.

View File

@@ -0,0 +1,13 @@
# step-probe.ts 의 isRunning 조건 누락으로 인한 릴레이 증발 버그 픽스
- **시간**: 2026-04-10 17:11
- **Commit**: `COMMITTING`
- **Vikunja**: #125 → done
## 결정 사항
- AI 응답이 비정상적으로 빠를 경우 `RUNNING` 상태의 2초 polling 창을 우회하여 `IDLE` / `WAITING`로 진입해버리는 버그가 있었습니다.
- 기존에는 `isRunning && currentCount > ...`로만 Real-time Capture가 동작하여 전부 스킵되는 증상 확인.
- `isRunning` 조건을 삭제하고, `delta > 0`인 경우 `GetCascadeTrajectorySteps`를 페치하여 `PLANNER_RESPONSE``WAITING` 스텝을 동시에 처리하도록 개선했습니다.
## 미완료
- 없음.

View File

@@ -0,0 +1,13 @@
# Gravity Bridge 빠른 응답(Fast Execution) 누락 오류 해결
- **시간**: 2026-04-10
- **Commit**: TBD
- **Vikunja**: #607 → done
## 문제 원인
- AI 생성이나 응답 작업이 폴링 간격(5초) 미만으로 끝났을 때, 익스텐션의 폴링 루프는 이전과 동일한 `IDLE` 상태만을 보게 됨.
- `lastResponseCaptureStep` 검사는 마련되어 있었으나, `wasRunning` 플래그 제약(`wasRunning && !isRunning`)으로 인하여 IDLE->IDLE 전이를 거치는 모든 단기응답이 `[RESPONSE-CAPTURE]`를 영구히 건너뛰고 통째로 누락됨.
## 해결 방법
- `wasRunning` 방어 조건을 해제하고, `!isRunning && currentCount > lastResponseCaptureStep` 조건으로 완화 (인덱스 전진 기반 감지로 수정).
- 오래된 하드코딩 파서를 버리고 방벽 파서 역할을 하는 `extractPlannerText`로 갈무리 블록의 AI 응답 추출 로직을 단일화하여 적용.

View File

@@ -0,0 +1,15 @@
# [Bridge] Disable DOM Observer Proactive Pending to Fix Empty Bots
- **시간**: 2026-04-10 16:12
- **Commit**: TBD
- **Vikunja**: TBD
## 문제 원인
- 디스코드에 Running 1 comman d 나 내용 없는 Allow 가 반복적으로 전송되는 문제.
- 원인은 v3 DOM Observer 스크립트가 Native UI에서 발생시키는 빈 껍데기 알림(POST /pending)들이, 정상적으로 본문 정보를 모두 추출해 기다리고 있는 step-probe.ts의 완벽한 Pending을 덮어씌우거나 먼저 처리되어버렸기 때문임.
- 단어 경계(\b) 정규식 필터조차 VS Code 렌더링 시 노드 줄바꿈 이슈 등으로 인해 완벽한 방어가 불가능했음.
## 해결 방법
- observer-script.ts에서 버튼 텍스트를 감지해 능동적으로 Pending을 생성하는 기능(PATS 배열)을 **전면 비활성화(배열 비움)**.
- 이로써, 오직 100% 신뢰 가능한 SDK RPC(step-probe.ts)만이 대기 상태(WAITING)와 명령어 상세 정보를 포착해 Pending을 생성함.
- DOM Observer는 브릿지가 보내는 /trigger-click 폴링 명령어를 받아 실제 물리 클릭만 수행하는 '수동적 렌더러' 역할로 격하됨.

View File

@@ -0,0 +1,14 @@
# GetAllCascadeTrajectories 10-Item Hard Limit Bypass
- **시간**: 2026-04-10
- **Commit**: TBD
- **Vikunja**: TBD
## 결정 사항
- `GetAllCascadeTrajectories` LS API의 `limit` 등 페이지네이션 파라미터가 백엔드에서 무시되어 최신 세션이 10개 제한에 잘려나가는 문제를 확인.
- `DOM observer`가 더 이상 작동하지 않는 상태(Empty 보디 이슈로 비활성화됨)에서, `step-probe.ts`마저 이 10개 한도 밖으로 밀려난 현재 세션(`activeSessionId`)을 발견하지 못해, 발생한 모든 채팅 이벤트 파일이 작성되지 않는 문제("단 한글자도 안 날아옴")의 근본 원인을 특정함.
- `GetDiagnostics` API를 사용하여 내부적으로 저장된 `recentTrajectories` 덤프 전체를 불러와, 기존 `GetAllCascadeTrajectories`의 결과를 병합/보완하도록 변경.
- 이를 통해 아무리 많은 수의 세션이 열려 있어도 현재 사용 중인 세션 ID를 식별 가능.
## 미완료
- 없음.

View File

@@ -0,0 +1,15 @@
# Fix gravity bridge Discord Relay AI Chat Body by patching DOM extraction and Regex literals
- **시간**: 2026-04-10 20:30~21:10
- **Vikunja**: #613 → done
## 트러블슈팅: Typescript 백틱 안의 정규식 리터럴 파괴 현상
- **증상**: JSDOM 가상 모의 환경에서 테스트를 돌려보니, 렌더링 화면이나 타겟 Text가 정확히 매치됨에도 정규식이 조건문에서 `false`를 내뱉으며 Button Matching을 건너뛰는 현상 발생.
- **원인**: `observer-script.ts``.js`로 변환할 때, Typescript 컴파일러가 `return \`...\`` 템플릿 리터럴 내부의 `/^(?:Always\s*)?Allow\b/i` 구문을 해석하면서, `\s`를 일반 문자 `s`로, `\b`를 아스키 특수문자 `Backspace(0x08)`로 직렬화하여 클라이언트에 꽂아버리는 문제가 있었음. 이로 인해 정규식 자체가 오염되어 어떠한 버튼도 매칭하지 못하고 있었음.
- **해결**: `observer-script` 내부의 정규식 리터럴 내부의 이스케이프 문자(`\s`, `\b` 등)를 전부 이중 백슬래시(`\\s`, `\\b`)로 패치하여 브라우저에서 스크립트가 실행될 때 올바른 정규식 파서가 열리도록 수정 보완함.
## 결정 사항: 웹뷰 내 로컬 fetch CSP 패치 통과
- `html-patcher.ts`에서 웹뷰 렌더링 시점에 CSP를 조작하여 `default-src 'none'` 방어막을 뚫고 `connect-src``http://127.0.0.1:* wss://127.0.0.1:*`를 주입하도록 강제 적용함. 이를 통해 Bridge 서버로의 로컬 HTTP 통신이 활성화됨.
## 완료 상태
VSCode VSIX (0.5.27) 빌드 완료 및 릴리스 커밋 패키징 수행.

View File

@@ -0,0 +1,13 @@
# Pure 웹소켓 게이트웨이 전환 (Legacy 파일 브릿지 통신 완전히 제거)
- **시간**: 2026-04-11 11:00~13:00
- **Commit**: `(To be updated)`
- **Vikunja**: #N/A
## 결정 사항
- 기존 VS Code 익스텐션과 로컬 Discord Bot 간에 이루어지던 `.gemini/antigravity/bridge/` 기반 파일 공유 통신 체계를 100% 제거하였습니다.
- 파이썬 봇 서버(`bot.py`) 내부에서 동작하던 물리적인 폴링 디렉토리 스캐너(`pending_approval_scanner``chat_snapshot_scanner`) 파일 디펜던시 루프를 완전히 삭제하고 `Hub` WS 핸들러로 대체했습니다. 봇 패키지에 남아있던 `bridge.py``watcher.py` 또한 사용할 이유가 없어져 레포지토리에서 영구적으로 폐기 구별을 내렸습니다.
## 새로 알게된 사실 혹은 트러블슈팅
- 익스텐션에서 `activeSessionId` 변경 시 `watcher.py` 대신 Node.js 네이티브 `fs.watch` 기반으로 자체적인 `BrainWatcher`를 인하우스로 구현해 `step-probe.ts`에 주입함으로써 파이썬 의존도를 완전히 분리할 수 있었습니다.
- 권한 팝업 중복 처리 역시 폴더 스캔 대신 단순히 인메모리 `lastFilePermissionTime` 단일 변수로 최적화되었습니다.

View File

@@ -0,0 +1,22 @@
# AG Native DOM 파싱 v7 전면 재설계
- **시간**: 2026-04-12 05:49~06:12
- **Commit**: `a4d7286`
## 배경
- AG Native 세션에서 Discord 릴레이가 전혀 동작하지 않음
- SDK `GetCascadeTrajectorySteps``trajectory not found` 반환 — AG Native는 Cascade API에 등록 안 됨
- DOM observer가 UI 노이즈(content_copy, keyboard_arrow_up, Always run, Cancel)를 AI 응답으로 오인
## 결정 사항
- SDK 경로 대신 **DOM이 유일한 데이터 소스**로 확정
- `jetskiAgent/main.js` (11MB) 번들 분석으로 AG Native DOM 구조 역공학:
- `data-testid="conversation-view"` — 대화 최상위 컨테이너
- `data-step-index` — 각 step 식별 속성
- `text-ide-message-block-bot-color` — 봇 메시지 클래스 (확인됨)
- observer-script v6→v7 전면 재설계: step-aware 파싱
## 미완료
- AG 재시작 후 실제 동작 검증 필요 (DOM 덤프 → 셀렉터 미세조정)
- deep-inspect 정상 동작 확인
- Discord에 실제 AI 응답 전달 확인

View File

@@ -0,0 +1,22 @@
# AG Native 번들 역공학 + V8 캐시 정리 + Observer 미작동 원인 규명
- **시간**: 2026-04-12 06:28~07:03
- **Commit**: — (분석/조사 세션, 코드 변경 없음)
## 결정 사항
- `text-ide-message-block-bot-color`는 AI 응답 컨테이너가 **아닌** NUX tooltip 전용 클래스로 확인 → observer 셀렉터에서 제거 필요
- `markdown-body` 클래스도 AG Native에 존재하지 않음 → 폴백 셀렉터 변경 필요
- AI 응답 텍스트는 `plannerResponse` step → `Whi` 렌더러 → `div.px-2.py-1``MarkdownRenderer` 내부에 위치
- `data-step-index`는 디버그 패널에서 확인되었으나 메인 대화 뷰에서의 존재 여부는 라이브 DOM 덤프로 확인 필요
## 새로 알게 된 사실
- AG Native 번들(jetskiAgent/main.js 10.8MB): 전체 step.case→renderer 매핑 확보 (pan 객체)
- Allow/Deny는 `lHr` 컴포넌트, `border-t border-gray-500/25` 클래스
- Observer v7이 HTML에 삽입되었지만 V8 CachedData(50MB) 때문에 실제 렌더러에서 로드되지 않았음
- CachedData 삭제 완료 → AG 리로드 후 observer 작동 예상
## 미완료
- AG 리로드 후 observer 작동 확인
- deep-inspect로 실제 DOM 구조 캡처
- observer-script 셀렉터 미세조정 (bot-color 제거, MarkdownRenderer 타겟팅)
- Discord 릴레이 E2E 검증

View File

@@ -0,0 +1,28 @@
# Observer v8 Electron 실행 보장 + 진단 beacon 추가
- **시간**: 2026-04-12 21:00~21:30
- **Commit**: (이전 세션 크래시 복구 커밋)
## 배경
- 이전 세션(f9491880)에서 Observer v8이 렌더러에서 실행되지 않는 문제 디버깅 중 크래시 발생
- deep-inspect 엔드포인트가 `timeout` 반환 — 인라인 스크립트가 Electron에서 실행 안 됨
- 원인: 인라인 스크립트가 `</html>` 뒤에 삽입되어 Electron이 무시하는 것으로 추정
## 변경 사항
### observer-script.ts
- DIAGNOSTIC BEACON 추가: 스크립트 로드 즉시 `/ping?beacon=1`으로 fetch → 실행 여부 확인 가능
### html-patcher.ts
- 인라인 스크립트 삽입 위치를 `</html>` 앞에서 **`</body>` 앞**으로 변경
- 기존 잘못된 위치의 인라인 블록을 제거 후 재삽입하는 로직 추가
- 이전 패칭에서 발생한 중복 `</html>` 태그 정리 로직 추가
### http-bridge.ts
- 진단용 HTTP 요청 로깅 추가 (폴링 엔드포인트 제외)
## 미완료
- AG 리로드 후 observer 작동 확인 (beacon ping 수신 확인)
- deep-inspect로 실제 DOM 구조 캡처
- observer-script 셀렉터 미세조정 (bot-color 제거, MarkdownRenderer 타겟팅)
- Discord 릴레이 E2E 검증

View File

@@ -0,0 +1,25 @@
# Observer v8 검증 — V8 캐시 차단 재확인
- **시간**: 2026-04-13 09:50~11:00
- **Commit**: 없음 (진단/검증 세션)
- **Vikunja**: #619, #620 (진행 중)
## 진단 결과
1. **Extension POLL**: ✅ 정상 — 세션 `a91b5318` 추적 중, WS 명령 수신 정상
2. **bridge/ 위치**: `~/.gemini/antigravity/bridge/` (프로젝트 루트 아님)
3. **HTML 패치**: ✅ workbench.html + workbench-jetski-agent.html 모두 인라인 스크립트 + BEACON 삽입, `</body>` 앞 위치 정상
4. **Observer 실행**: ❌ BEACON 핑 0건, dom_dumps 디렉토리 없음
5. **V8 CachedData**: 24.42 MB 존재 → 삭제 완료
6. **Discord 릴레이**: ❌ AI 응답 텍스트 추출 불가
## 결정 사항
- Observer 스크립트 미실행의 원인은 V8 CachedData가 패치된 HTML 로드를 차단하는 known-issue와 동일 패턴
- AG가 09:41에 재시작되었지만, 이전 세션에서 삭제한 V8 캐시가 AG 시작과 함께 다시 생성됨
- 해결: V8 캐시 삭제 → AG 재시작 순서 필수 (이번 세션에서 캐시 삭제 완료)
## 미완료
- AG 재시작 후 BEACON 핑 수신 확인
- DOM 덤프 분석: 실제 DOM 구조에서 AI 응답 셀렉터 검증
- 셀렉터 미세조정: bot-color 제거, MarkdownRenderer 타겟팅
- Discord 릴레이 E2E 검증

View File

@@ -0,0 +1,27 @@
# DOM Observer 데이터 품질 검증 + UTF-8/noise 수정
- **시간**: 2026-04-13 12:34~12:52
- **Commit**: `pending`
- **Vikunja**: #619, #620 (진행 중)
## 검증 결과
- DOM Observer v8 **동작 확인**: `pending/`에 45개 시그널 생성됨 (`source: "dom_observer"`)
- 버튼 분류 정상: `command`(30), `permission`(15)
- 명령어/conversation_id/버튼(Allow/Deny/Cancel) 추출 정상
- **한글 인코딩 깨짐** 발견: description 필드에 `[AI 본문 요약]``[AI <20> <20>]`
## 변경 사항 (v0.5.39)
### http-bridge.ts
- 모든 POST 핸들러에 `req.setEncoding('utf8')` 추가
- Node.js HTTP 서버의 Buffer→string latin1 기본 인코딩으로 인한 multi-byte UTF-8 손실 수정
### observer-script.ts
- `cleanLines()`에 인라인 pre-strip 추가: Material 아이콘명 18종을 regex로 `\n`으로 치환
- `Thought for Xs` 패턴 인라인 제거 추가
- `codeText` 추출에 `cleanLines()` 적용 (이전 미적용)
## 미완료
- AG 재시작 후 v0.5.39 적용 검증 (한글 정상 출력 확인)
- DOM dump 추출 검증
- Discord 릴레이 E2E 검증

View File

@@ -0,0 +1,28 @@
# DOM Observer 컨텍스트 추출 수정 — v9 (v0.5.40)
- **시간**: 2026-04-13 19:26~
- **Commit**: `pending`
- **Vikunja**: #619, #620 (진행 중)
## 문제
Discord 승인 요청에 내용이 비어있음:
- command = "Running2 commands" (그룹 헤더 버튼을 잘못 캡처)
- description = 비어있거나 UI 노이즈만 포함
- buttons = "Running2 commands / Always run" (잘못된 구조)
## 변경 사항
### observer-script.ts (v8 → v9)
1. `isActionBtn()`에서 "Running N commands" 패턴 제거 — 이것은 그룹 헤더이며 승인 버튼이 아님
2. `scan()`에서 `^Running\s*\d+\s*commands?$` 명시적 스킵
3. `extractContextFromNearby()` 신규 함수 추가 — `data-step-index` 없이 DOM 트리를 20레벨까지 올라가며 `pre`/`code` 블록에서 실제 명령어 추출
4. `collectSiblingButtons()` 범위를 parent → grandparent → great-grandparent 3레벨로 확대, 그룹 헤더 스킵, 텍스트 기반 dedup 추가
5. `matchedType` 판별에서 `/Running\d/` 패턴 제거
### http-bridge.ts
6. "Run/Always run" 필터에 `ctx.activeSessionId` 체크 추가 — step-probe가 세션 미추적 시 DOM observer 신호 허용
## 미완료
- AG 재시작 후 v0.5.40 적용 검증
- Discord E2E 검증 (실제 명령어/코드 내용 표시 확인)

View File

@@ -0,0 +1,49 @@
# Observer v9 E2E 검증 + v10-diag 진단 추가
- **시간**: 2026-04-14 07:29~07:36
- **Commit**: `pending`
- **Vikunja**: #task-619 → 진행중
## 검증 결과
### ✅ 동작 확인 (정상)
1. **Observer v9 스크립트 실행**: `/ping` 수신 확인 (22:30:13~)
2. **BEACON ping**: `/ping?beacon=1&t=...` 형태로 GET /ping 수신
3. **DOM dump**: `dump_html_5.json` (7858 bytes) 정상 저장
4. **WS Hub 연결**: `wsConnected: true`
5. **세션 감지**: `activeSessionId: 39c51225` (현재 세션)
6. **pending POST**: `/pending``1776119428450_gnpw` 등 다수 수신
### ❌ 실패: 컨텍스트 추출
- 모든 pending에서 `cmd="Always run"`, `btns=1`, `ctx="Always run"`
- `extractContextFromNearby()` 실패: pre/code 요소를 찾지 못함
- `collectSiblingButtons()` 실패: 1개 버튼만 감지 ("Cancel" 미감지)
- `sessionStalled: true` + `lastPendingStepIndex: -1` (trajectory not found)
### 원인 분석 (추정)
- AG Native UI의 "Always run" 버튼 근처에 `<pre>`, `<code>`, `[class*="terminal"]` 요소가 없음
- 실제 명령어 텍스트가 다른 요소 유형(span, div 등)에 담겨있을 가능성
- DOM 구조가 이전 세션에서 확인한 것과 다를 수 있음 (Settings/Launchpad dump vs 대화 dump)
## 수정 사항 (v10-diag)
### observer-script.ts
- `extractContextFromNearby()`: 각 depth에서 tag/class/codeEls 수를 `_debugTrail`에 기록
- depth ≤ 5에서 `span, div, p` 요소의 실제 텍스트도 trail에 포함
- 성공 시 `CONTEXT-OK`, 실패 시 `CONTEXT-FAIL` 로그 출력
- pending payload에 `_debug_trail` 필드 추가
### http-bridge.ts
- `_handlePending()`에서 `_debug_trail` 필드를 `[HTTP-DIAG] trail:` 로그로 출력
## 미완료
1. **AG 재시작 필요** — v10-diag 빌드 완료 + V8 캐시 삭제됨, AG 재시작 후 trail 분석 필요
2. **trail 분석 후 대응**:
- pre/code 대신 실제 명령어가 담긴 요소 유형 파악
- `extractContextFromNearby()`의 셀렉터 확장 (span/div 등)
- `collectSiblingButtons()` 범위 확장 검토
3. **"trajectory not found" 에러** — AG Native 세션이 Cascade trajectory API에 등록되지 않는 근본 문제 (known-issues에 이미 기록)
## 결정 사항
- 진단을 먼저 진행하는 것이 올바른 접근 — blind fix 대신 실제 DOM 구조를 trail로 확인한 후 정확한 셀렉터 수정
- trail 데이터가 extension.log에 기록되므로 AG 재시작 후 즉시 확인 가능

View File

@@ -0,0 +1,27 @@
# PROMPT_ONLY_RE 근본원인 수정 (v0.5.45)
- **시간**: 2026-04-15 09:12~09:57
- **Commit**: `01539e9`
- **Vikunja**: #619 → 진행중
## 결정 사항
### Template Literal 안의 Regex 리터럴 이스케이핑 규칙
**혼동 포인트**: `observer-script.ts`의 전체 코드는 TypeScript template literal 안에 있다. 이 안에서 regex 리터럴(`/pattern/`)을 쓸 때:
- `\\s` (TS 소스 2-backslash) → template 출력 `\s`**JS에서 invalid escape → 원본 보존** → regex `\s` = whitespace class ✅
- `\\\\s` (TS 소스 4-backslash) → template 출력 `\\s`**JS에서 valid escape `\s`** → regex `\s` = whitespace class ✅
**결론**: 2중과 4중 **둘 다 작동**하지만, 4중이 의도적이고 명시적. 그러나 PROMPT_ONLY_RE는 **기존 4중에서 실패하고 있었으므로** 실제 원인은 다른 곳에 있었음 — `(.*[\\/>»$#]\\\\s*)` 패턴 자체가 `>` 다음에 `\\s*` 매칭이 아닌 `\\\\s*` 리터럴 매칭이 되고 있었던 것.
### http-bridge PROMPT_ONLY_RE 단순화
- 기존: `/^[\s\\\/]*[\w_.-]+\s*[>»$#]\s*$/``…`(U+2026 ellipsis) prefix 미지원
- 변경: `/^.*[>»$#]\s*$/` — prompt marker로 끝나는 모든 텍스트 스킵
- 트레이드오프: `echo >` 같은 극단적 edge case에서 false positive → 1% 미만 확률, 허용
## 미완료
- AG 재시작 후 v0.5.45 실제 동작 확인 필요 (현재 v0.5.44 메모리 로드 상태)
- `Running N commands` 4-backslash 패턴은 정상 동작 확인됨 — 그대로 유지

View File

@@ -0,0 +1,30 @@
# Observer fallback 컨텍스트 추출 수정 (v0.5.46)
- **시간**: 2026-04-15 10:35~11:02
- **Commit**: `pending`
- **Vikunja**: #619 → 진행중
## 결정 사항
### `_promptOnlySkipped` 플래그 설계
**문제**: v0.5.45에서 PROMPT_ONLY_RE가 code/pre 요소의 프롬프트 텍스트를 정상 스킵했으나, `extractContextFromNearby()`**fallback 경로**(span/div/p 텍스트 수집)가 DOM 트리를 올라가면서 채팅 본문, UI 라벨, AI 응답을 명령어로 잘못 추출.
**해결 접근**: code 요소가 존재하지만 **모두** PROMPT_ONLY_RE로 스킵된 경우 → 이 터미널 블록에는 실행할 명령어가 없다고 판단 → fallback span/div/p 수집을 통째로 비활성화.
**대안 검토**:
- ❌ fallback 텍스트에 CJK/자연어 필터 추가 → false negative 위험 (한국어 명령어 경로명 등)
- ❌ fallback 수집 depth 제한 → DOM 구조가 바뀌면 다시 깨짐
-**prompt-only 스킵과 fallback 비활성화 연동** → 가장 간결하고 확실
### VSIX 설치 누락 발견
이전 세션(fd78c28e)에서 v0.5.45 VSIX를 빌드했으나 **설치를 하지 않았음**. extensions.json 확인 결과 v0.5.43이 설치되어 있었음. 원인: 이전 세션에서 `code --install-extension` 실행 없이 AG 재시작만 수행.
→ known-issues에 "빌드 후 즉시 install 확인 필수" 주의사항 추가
## 미완료
- AG 재시작 후 v0.5.46 실제 동작 검증 필요
- Discord에 빈 프롬프트/채팅 텍스트가 전송되지 않는지 확인
- 검증 완료 후 devlog에 커밋 해시 업데이트 + Vikunja #619 완료 처리

View File

@@ -0,0 +1,38 @@
# v16 터미널 출력 필터 + v15 Stale LS 자동복구 + Heartbeat Probe (v0.5.50)
- **시간**: 2026-04-16 04:52~05:00
- **Commit**: `7ade31e`
- **Vikunja**: #619 → 진행중
## 결정 사항
### 터미널 출력(stdout)이 명령어로 추출되는 버그 수정 (v16)
**증상**: Discord에 `cmd="No extension.log found"`, `cmd="AG CLI not found..."`, `cmd="Log found: C:\..."` 등 터미널의 **출력** 텍스트가 명령어로 전송됨.
**원인**: Observer의 `extractContextFromNearby()`가 code 블록 2개를 찾음:
1. ci=0: 프롬프트+명령어 (`…\gravity_control > $log = ...`) → JUNK_CODE_RE로 스킵
2. ci=1: 터미널 출력 (`No extension.log found`) → 유효한 code로 판단 → description에 포함
http-bridge enrichment에서 description에 prompt marker(`>`)가 없으면 rawDesc 전체를 enrichedCmd로 채택하여 Discord로 전송.
**해결**: `promptMatch` 실패 시 (description에 `>` 없음) → 터미널 OUTPUT으로 판단하여 즉시 필터. 실제 명령어는 항상 `…\project > command` 형식의 프롬프트를 포함.
### step-probe v15 — Stale LS 자동감지 + Heartbeat Probe
- **Stale LS**: 모든 세션이 5분 이상 오래되면 주기적으로 `fixLSConnection()` 시도
- **Heartbeat Probe**: 매 10 polls마다 `GetCascadeTrajectorySteps`를 직접 호출하여 summary API가 frozen일 때도 step 변화 감지
- **fixLSConnection() fallback**: `--workspace_id` 없는 LS 프로세스(AG 재시작 직후 주로 발생)도 fallback으로 매칭
## 검증 결과
- ✅ Observer v14 동작 중 — POST /pending 신호 정상
- ✅ Generic button 필터 작동 — "Always run" desc="Always run" → 필터됨
- ✅ Command enrichment 작동 — "Always run" → "git diff --stat" 등 정상 추출
- ✅ 다수 명령어(git, cmd /c, Get-Content, code, Remove-Item 등) 정상 추출 확인
- ❌→✅ 터미널 출력 텍스트 누출 버그 발견 → v16에서 수정
## 미완료
- AG 재시작 후 v0.5.50 실제 동작 확인 필요
- v15 stale LS 자동복구 + heartbeat probe 실동작 확인 (장시간 세션 필요)

View File

@@ -0,0 +1,24 @@
# AG Native AI 응답 Discord 릴레이 구현 (Observer v15)
- **시간**: 2026-04-16 04:52~05:28
- **Commit**: `729875f`
- **Vikunja**: #632 → 진행중
## 문제 분석
AG Native 세션에서 AI 대화 응답이 Discord에 전혀 전달되지 않는 근본원인을 규명:
1. **SDK 경로 차단**: `GetCascadeTrajectorySteps(cascadeId)``trajectory not found`. AG Native는 Cascade trajectory API에 미등록 → stepCount=1 고정, delta=0 → RT-CAPTURE 진입 불가
2. **DOM 경로 차단**: `scanChatBodies()``conversation-view`, `data-step-index` 등 Cascade 전용 셀렉터 사용 → AG Native DOM에 전무 → 즉시 return
## 결정 사항
- SDK 경로는 AG 구조적 한계로 사용 불가 → **DOM 경로를 AG Native에 맞게 확장**
- AG Native DOM 분석 결과: `#conversation` (id), `.leading-relaxed.select-text` (AI 응답 영역) 확인
- 기존 Cascade 경로도 유지하여 호환성 보장 (이중 전략)
## 미완료
- **AG Reload Window 필요**: v15 Observer가 workbench.html에 패치되려면 AG 재시작 필수
- **실동작 검증**: Discord에 AI 응답 텍스트가 실제로 수신되는지 end-to-end 확인
- **enrichment 오탐 edge case**: 로그 텍스트 내 `>` 문자가 prompt marker로 오인되는 1건 (빈도 낮음, v17에서 수정 검토)

View File

@@ -0,0 +1,17 @@
# DOM Observer 마크다운 구조 복원 및 사용자 메시지 연동 (v0.5.56)
### 목표
DOM Observer(`observer-script.ts`)가 AI 채팅을 `innerText`로 추출하며 잃어버리는 마크다운 서식을 복원하고, 사용자(User) 메시지도 포착하여 함께 Discord 봇으로 보내기 (#634 이슈).
### 변경 사항
1. **`convertNodeToMarkdown` 파서 확장**:
- AI 채팅창의 DOM Tree를 순회하며 `<h1>`~`<h4>`, `<p>`, `<ul>`, `<ol>`, `<li>`, `<strong>`, `<em>`, `<code>`, `<pre>`, `<blockquote>` 등 대부분의 마크다운 요소를 파싱하는 로직 도입.
- 추가로 `<a>` 태그(Link) 속성을 지원하여 `[text](href)` 형태로 복원하도록 개선.
2. **파괴적인 `cleanLines()` 노이즈 필터 제거**:
- 이전에 사용되던 `cleanLines()``}[공백]`이나 `import` 같은 코드를 UI 노이즈로 오인하여 삭제(Drop)하는 심각한 이슈를 발견. 전체 마크다운 문자열에는 해당 필터를 적용하지 않고 정규식을 통해 `Thought for X s` 형태의 메시지만 지우도록 수정.
3. **User 메시지 대상 추가**:
- `scanChatBodies()`의 탐색 Selector에 `.text-ide-message-block-user-color`, `.bg-ide-message-block-user-background` 등을 추가하여 사용자 메시지 블록도 대상에 포함.
- 데이터 전송 시 `role: 'user'` 정보를 보내고, `http-bridge.ts`에서 이를 구분하여 헤더를 `🧑‍💻 **[DOM 추출] 사용자 요청**`로 지정해 Discord로 릴레이.
### 결과
`v0.5.56` VSIX 배포 준비 완료 (v0.5.54/55 빌드는 테스트 과정 중 건너뜀). AG Native에서 확장 설치 캐시를 리셋하거나 직접 VSIX를 설치하면 적용됨.

View File

@@ -0,0 +1,31 @@
# Retry Auto-Approve 흐름 복구 및 Observer 고도화
- **시간**: 2026-04-18 09:20~23:50
- **Commit**: `pending`
- **Vikunja**: 신규 생성 예정
## 결정 사항
### 1. `_from_ws` 마커 기반 response 파일 보존
- **문제**: WS 응답 핸들러가 response 파일 작성 → processResponseFile이 300ms 후 삭제 → Observer pollResponseGroup 실패
- **선택**: response 파일에 `_from_ws: true` 마커 추가, processResponseFile에서 스킵
- **이유**: pending 파일 생성을 추가하는 것보다 단순하고, WS 핸들러에서 이미 tryApprovalStrategies를 실행하므로 중복 실행 방지도 함께 해결
### 2. 형제(sibling) DOM 탐색
- **문제**: "Always run" 버튼의 조상(parentElement) 탐색으로는 `pre.font-mono` 도달 불가 (footer.parentElement가 null)
- **선택**: 각 depth에서 `node.parentElement.children`을 순회하여 형제 요소의 code 블록 탐색
- **이유**: AG Native DOM 구조에서 명령어는 footer의 형제 요소에 있으므로 조상 탐색만으로는 구조적으로 불가
### 3. Thinking 블록 필터링
- **문제**: AI의 내부 사고 과정이 Discord에 릴레이됨
- **선택**: `max-h-[200px]` 조상 확인으로 thinking 블록 식별
- **이유**: thinking 블록은 접힌 상태에서 max-height가 200px로 제한되는 특징이 있음
## 시행착오
1. depth 5→10 증가만으로 해결 시도 → 실패 (조상이 아닌 형제에 명령어가 있었음)
2. Observer HTML 변경 후 Reload Window만 실행 → 실패 (AG 2번 재시작 필요)
3. response 파일이 삭제되는 원인을 clickTrigger 타이밍으로 오인 → 실제는 processResponseFile의 isDomObserver 판별 실패
## 미완료
- [ ] 명령어 컨텍스트 추출 타이밍 이슈 (DOM 렌더링 전 scan 시 추출 실패)
- [ ] Observer pollResponseGroup이 시작되지 않는 케이스 (POST /pending 이전에 trigger-click 소비)

Binary file not shown.

View File

@@ -105,12 +105,13 @@ function detectProjectName() {
if (folders && folders.length > 0) {
const cwd = folders[0].uri.fsPath;
try {
const remoteUrl = cp.execSync('git remote get-url origin', {
cwd, encoding: 'utf-8', timeout: 2000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore']
}).toString().trim();
const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
if (match && match[1]) {
return match[1].toLowerCase().replace(/[\s\-]+/g, '_');
const gitConfigPath = path.join(cwd, '.git', 'config');
if (fs.existsSync(gitConfigPath)) {
const configContent = fs.readFileSync(gitConfigPath, 'utf8');
const match = configContent.match(/url\s*=\s*.*\/([^\/]+?)(?:\.git)?$/m);
if (match && match[1]) {
return match[1].toLowerCase().replace(/[\s\-]+/g, '_');
}
}
}
catch { }
@@ -119,15 +120,6 @@ function detectProjectName() {
return 'default';
}
// ─── Bridge File I/O ───
function ensureBridgeDir() {
const dirs = ['', 'response', 'commands', 'chat_snapshots'];
for (const d of dirs) {
const p = path.join(bridgePath, d);
if (!fs.existsSync(p)) {
fs.mkdirSync(p, { recursive: true });
}
}
}
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
let activeSessionId = '';
let activeTrajectoryId = '';
@@ -135,39 +127,16 @@ let activeTrajectoryId = '';
const recentDiscordSentTexts = new Map();
function writeChatSnapshot(text) {
try {
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
conversation_id: activeSessionId,
conversation_id: (0, step_probe_1.getActiveSessionId)(),
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
if (activeSessionId) {
(0, step_probe_1.writeRegistration)(activeSessionId);
if ((0, step_probe_1.getActiveSessionId)()) {
(0, step_probe_1.writeRegistration)((0, step_probe_1.getActiveSessionId)());
}
return;
}
// File route (fallback — only when WS is NOT connected)
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) {
fs.mkdirSync(snapshotDir, { recursive: true });
}
const id = Date.now().toString();
const data = {
id,
project_name: projectName,
content: text,
timestamp: Date.now() / 1000,
};
const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`);
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars)`);
logToFile(`[SNAPSHOT] content: ${text.substring(0, 200)}`);
// Lazily register session → project mapping (correct because projectName is per-window)
if (activeSessionId) {
(0, step_probe_1.writeRegistration)(activeSessionId);
}
}
catch (e) {
@@ -176,38 +145,17 @@ function writeChatSnapshot(text) {
}
function writeChatSnapshotWithFiles(text, files) {
try {
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
attached_files: files,
conversation_id: activeSessionId,
conversation_id: (0, step_probe_1.getActiveSessionId)(),
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
if (activeSessionId) {
(0, step_probe_1.writeRegistration)(activeSessionId);
if ((0, step_probe_1.getActiveSessionId)()) {
(0, step_probe_1.writeRegistration)((0, step_probe_1.getActiveSessionId)());
}
return;
}
// File route (fallback — only when WS is NOT connected)
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) {
fs.mkdirSync(snapshotDir, { recursive: true });
}
const id = Date.now().toString();
const data = {
id,
project_name: projectName,
content: text,
attached_files: files,
timestamp: Date.now() / 1000,
};
const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars, ${files.length} files)`);
if (activeSessionId) {
(0, step_probe_1.writeRegistration)(activeSessionId);
}
}
catch (e) {
@@ -295,8 +243,8 @@ async function fixLSConnection() {
logToFile(`[LS-FIX] found ${lines.length} LS process(es), hint="${hint}"`);
// Find the line whose workspace_id matches our workspace (case-insensitive)
let matchedLine = null;
let fallbackLine = null; // v15: LS without workspace_id (AG restart)
for (const line of lines) {
const lower = line.toLowerCase();
// Match workspace_id arg against our hint
const wsMatch = line.match(/--workspace_id[= ](\S+)/i);
if (wsMatch) {
@@ -306,10 +254,25 @@ async function fixLSConnection() {
break;
}
}
else {
// v15: LS without --workspace_id (new AG main LS after restart)
// Skip --enable_lsp processes (secondary/old LSP instances)
if (!line.includes('--enable_lsp') && !fallbackLine) {
fallbackLine = line;
logToFile(`[LS-FIX] found fallback LS (no workspace_id): PID=${line.split('|')[0]?.trim()}`);
}
}
}
if (!matchedLine) {
logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`);
return false;
if (fallbackLine) {
// v15: Use workspace_id-less LS as fallback (common after AG restart)
logToFile(`[LS-FIX] No workspace_id match — using fallback LS`);
matchedLine = fallbackLine;
}
else {
logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`);
return false;
}
}
// Extract port and csrf_token from matched line
const csrfMatch = matchedLine.match(/--csrf_token[= ](\S+)/);
@@ -404,7 +367,6 @@ async function activate(context) {
const config = vscode.workspace.getConfiguration('gravityBridge');
const configPath = config.get('bridgePath');
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
ensureBridgeDir();
console.log(`Gravity Bridge: bridge path: ${bridgePath}`);
// ── WebSocket Hub Connection ──
const hubUrl = process.env.GRAVITY_HUB_URL || config.get('hubUrl') || '';
@@ -435,6 +397,23 @@ async function activate(context) {
return;
}
// Normal approval — tryApprovalStrategies
// v22: ALSO write response file so Observer's pollResponseGroup can click
// the correct button (with exact button_index). Without this, only the
// imprecise pollTriggerClick fallback was used for WS-path responses.
const responseDir = path.join(bridgePath, 'response');
if (!fs.existsSync(responseDir)) {
fs.mkdirSync(responseDir, { recursive: true });
}
const respPayload = {
request_id: data.request_id,
approved,
button_index: data.button_index ?? 0,
step_type: stepType,
project_name: projectName,
_from_ws: true,
};
fs.writeFileSync(path.join(responseDir, `${data.request_id}.json`), JSON.stringify(respPayload), 'utf-8');
logToFile(`[WS-RESPONSE] Response file written for pollResponseGroup: ${data.request_id?.substring(0, 12)}`);
const approvalCtx = (0, step_probe_1.getApprovalContext)();
logToFile(`[WS-RESPONSE] Triggering approval: approved=${approved} session=${approvalCtx.sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${approvalCtx.stepIndex}`);
(0, step_probe_1.tryApprovalStrategies)(approved, approvalCtx.sessionId, stepType, approvalCtx.stepIndex)
@@ -540,6 +519,8 @@ async function activate(context) {
get activeSessionId() { return (0, step_probe_1.getStepProbeContext)().activeSessionId; },
get sessionStalled() { return (0, step_probe_1.getStepProbeContext)().sessionStalled; },
get lastPendingStepIndex() { return (0, step_probe_1.getStepProbeContext)().lastPendingStepIndex; },
writeChatSnapshot,
getLastWaitingCommand: step_probe_1.getLastWaitingCommand,
};
const bridgePort = await (0, http_bridge_1.startHttpBridge)(httpBridgeCtx, sdk);
let localPort = bridgePort;

File diff suppressed because one or more lines are too long

View File

@@ -1,82 +1,380 @@
{
"name": "gravity-bridge",
"version": "0.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gravity-bridge",
"version": "0.5.4",
"dependencies": {
"ws": "^8.19.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/vscode": "^1.100.0",
"typescript": "^5.3.0"
},
"engines": {
"vscode": "^1.100.0"
}
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/vscode": {
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
"integrity": "sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
"name": "gravity-bridge",
"version": "0.5.103",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gravity-bridge",
"version": "0.5.103",
"dependencies": {
"cheerio": "^1.2.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/vscode": "^1.100.0",
"typescript": "^5.3.0"
},
"engines": {
"vscode": "^1.100.0"
}
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/vscode": {
"version": "1.100.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.100.0.tgz",
"integrity": "sha512-4uNyvzHoraXEeCamR3+fzcBlh7Afs4Ifjs4epINyUX/jvdk0uzLnwiDY35UKDKnkCHP5Nu3dljl2H8lR6s+rQw==",
"dev": true,
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz",
"integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -1,88 +1,90 @@
{
"name": "gravity-bridge",
"displayName": "Gravity Bridge",
"description": "Antigravity ↔ Discord 브리지 연동 확장",
"version": "0.5.14",
"publisher": "variet",
"engines": {
"vscode": "^1.100.0"
},
"categories": [
"Other",
"Chat"
"name": "gravity-bridge",
"displayName": "Gravity Bridge",
"description": "Discord-based unified approval system for Antigravity AI interactions.",
"version": "0.5.103",
"publisher": "variet",
"engines": {
"vscode": "^1.100.0"
},
"categories": [
"Other",
"Chat"
],
"activationEvents": [
"onStartupFinished"
],
"main": "./out/extension.js",
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./ && node -e \"const fs=require('fs'),p=require('path');const s=p.join('src','sdk'),d=p.join('out','sdk');if(fs.existsSync(s)){fs.mkdirSync(d,{recursive:true});fs.readdirSync(s).forEach(f=>fs.copyFileSync(p.join(s,f),p.join(d,f)));console.log('SDK copied to out/sdk')};\"",
"watch": "tsc -watch -p ./"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/vscode": "^1.100.0",
"typescript": "^5.3.0"
},
"contributes": {
"chatParticipants": [
{
"id": "gravity-bridge.gravity",
"name": "gravity",
"fullName": "Gravity Bridge",
"description": "?<3F>???<3F>스?<3F><EFBFBD>?Discord<72>??<3F>송 + AI ?<3F>어",
"isSticky": false
}
],
"activationEvents": [
"onStartupFinished"
"commands": [
{
"command": "gravityBridge.start",
"title": "Gravity Bridge: Start"
},
{
"command": "gravityBridge.stop",
"title": "Gravity Bridge: Stop"
},
{
"command": "gravityBridge.approve",
"title": "Gravity Bridge: Approve Pending"
},
{
"command": "gravityBridge.reject",
"title": "Gravity Bridge: Reject Pending"
},
{
"command": "gravityBridge.connect",
"title": "Gravity Bridge: Connect Session"
}
],
"main": "./out/extension.js",
"scripts": {
"compile": "tsc -p ./ && node -e \"const fs=require('fs'),p=require('path');const s=p.join('src','sdk'),d=p.join('out','sdk');if(fs.existsSync(s)){fs.mkdirSync(d,{recursive:true});fs.readdirSync(s).forEach(f=>fs.copyFileSync(p.join(s,f),p.join(d,f)));console.log('SDK copied to out/sdk')};\"",
"watch": "tsc -watch -p ./"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/vscode": "^1.100.0",
"typescript": "^5.3.0"
},
"contributes": {
"chatParticipants": [
{
"id": "gravity-bridge.gravity",
"name": "gravity",
"fullName": "Gravity Bridge",
"description": "대화 히스토리를 Discord로 전송 + AI 제어",
"isSticky": false
}
],
"commands": [
{
"command": "gravityBridge.start",
"title": "Gravity Bridge: Start"
},
{
"command": "gravityBridge.stop",
"title": "Gravity Bridge: Stop"
},
{
"command": "gravityBridge.approve",
"title": "Gravity Bridge: Approve Pending"
},
{
"command": "gravityBridge.reject",
"title": "Gravity Bridge: Reject Pending"
},
{
"command": "gravityBridge.connect",
"title": "Gravity Bridge: Connect Session"
}
],
"configuration": {
"title": "Gravity Bridge",
"properties": {
"gravityBridge.bridgePath": {
"type": "string",
"default": "",
"description": "Bridge 디렉토리 경로 (기본: ~/.gemini/antigravity/bridge)"
},
"gravityBridge.projectName": {
"type": "string",
"default": "",
"description": "프로젝트 이름 (기본: git remote 레포명)"
},
"gravityBridge.hubUrl": {
"type": "string",
"default": "",
"description": "WebSocket Hub URL (예: wss://your-server.com/ws)"
},
"gravityBridge.registrationCode": {
"type": "string",
"default": "",
"description": "Hub 등록 코드 (서버에서 발급)"
}
}
"configuration": {
"title": "Gravity Bridge",
"properties": {
"gravityBridge.bridgePath": {
"type": "string",
"default": "",
"description": "Bridge ?<3F>렉?<3F>리 경로 (기본: ~/.gemini/antigravity/bridge)"
},
"gravityBridge.projectName": {
"type": "string",
"default": "",
"description": "?<3F>로?<3F>트 ?<3F>름 (기본: git remote ?<3F><EFBFBD>?"
},
"gravityBridge.hubUrl": {
"type": "string",
"default": "",
"description": "WebSocket Hub URL (?? wss://your-server.com/ws)"
},
"gravityBridge.registrationCode": {
"type": "string",
"default": "",
"description": "Hub ?<3F>록 코드 (?<3F>버?<3F>서 발급)"
}
},
"dependencies": {
"ws": "^8.19.0"
}
}
}
},
"dependencies": {
"cheerio": "^1.2.0",
"ws": "^8.19.0"
}
}

View File

@@ -0,0 +1,98 @@
const fs = require('fs');
const path = require('path');
// Try all available dumps
const bridgePath = path.join(process.env.USERPROFILE, '.gemini/antigravity/bridge');
const dumpFiles = ['dump_html_1.json', 'dump_html_2.json', 'dump_html_3.json', 'dump_html_4.json', 'dump_html_5.json', 'deep-inspect-result.json', 'deep-inspect-manual.json'];
function printTree(node, indent, maxDepth) {
if (!node || indent > maxDepth) return;
const tag = (node.tag || node.tagName || '?').toLowerCase();
const clsArr = (node.cls || node.className || '').split(' ').filter(c => c.length > 0);
const text = (node.text || node.textContent || '').substring(0, 50).replace(/[\n\r]+/g, ' ');
const childCount = (node.children || []).length;
let line = ' '.repeat(indent) + tag;
if (clsArr.length > 0) line += '.' + clsArr[0];
if (childCount) line += ' [' + childCount + ']';
if (text && childCount === 0) line += ' = "' + text + '"';
console.log(line);
if (node.children) {
for (const c of node.children) printTree(c, indent + 1, maxDepth);
}
}
// Find the conversation area in each dump
function findConvo(node, depth) {
if (!node || depth > 20) return null;
const cls = node.cls || node.className || '';
if (cls.includes('bg-agent-convo-background') || cls.includes('agent-convo')) return node;
if (node.children) {
for (const c of node.children) {
const r = findConvo(c, depth + 1);
if (r) return r;
}
}
return null;
}
// Find buttons (Run, Allow, Always run, Accept)
function findButtons(node, depth, results) {
if (!node || depth > 25) return;
const tag = (node.tag || node.tagName || '').toLowerCase();
const text = (node.text || node.textContent || '');
if (tag === 'button' && /Run|Allow|Accept|Always/i.test(text) && text.length < 50) {
results.push({ text: text.trim(), depth });
}
if (node.children) {
for (const c of node.children) findButtons(c, depth + 1, results);
}
}
// Find pre/code blocks near buttons
function findCodeBlocks(node, depth, results) {
if (!node || depth > 25) return;
const tag = (node.tag || node.tagName || '').toLowerCase();
const cls = node.cls || node.className || '';
if ((tag === 'pre' || tag === 'code') && cls.includes('font-mono')) {
const text = (node.text || node.textContent || '').substring(0, 80);
results.push({ tag, cls: cls.substring(0, 50), text, depth });
}
if (node.children) {
for (const c of node.children) findCodeBlocks(c, depth + 1, results);
}
}
for (const df of dumpFiles) {
const fp = path.join(bridgePath, df);
if (!fs.existsSync(fp)) continue;
try {
const dump = JSON.parse(fs.readFileSync(fp, 'utf8'));
const root = dump.bodyTree || dump.body || dump;
console.log('\n=== ' + df + ' ===');
// Find conversation area
const convo = findConvo(root, 0);
if (convo) {
console.log('>> Conversation area found, tree (depth 12):');
printTree(convo, 0, 12);
}
// Find buttons
const btns = [];
findButtons(root, 0, btns);
if (btns.length > 0) {
console.log('>> Buttons found:');
for (const b of btns) console.log(' d' + b.depth + ': ' + b.text);
}
// Find code blocks
const codes = [];
findCodeBlocks(root, 0, codes);
if (codes.length > 0) {
console.log('>> Code blocks (font-mono):');
for (const c of codes) console.log(' d' + c.depth + ': <' + c.tag + '> cls=' + c.cls + ' text="' + c.text + '"');
}
} catch (e) {
console.log('ERROR reading ' + df + ': ' + e.message);
}
}

View File

@@ -0,0 +1,28 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const dump = JSON.parse(fs.readFileSync(
path.join(os.homedir(), '.gemini', 'antigravity', 'bridge', 'dump_html.json'), 'utf-8'
));
const bodyStr = JSON.stringify(dump.body);
// Find all unique tag names
const tagMatches = bodyStr.match(/"tag":"[a-z0-9]+"/g) || [];
const uniqueTags = [...new Set(tagMatches)];
console.log('=== Unique DOM tags ===');
console.log(uniqueTags.sort().join('\n'));
// Check for pipe characters (markdown table syntax)
console.log('\n=== Pipe | in text content ===');
const pipeMatches = [...bodyStr.matchAll(/"text":"[^"]*\|[^"]*"/g)];
console.log(`Found ${pipeMatches.length} text nodes with pipe |`);
pipeMatches.slice(0, 5).forEach(m => console.log(' ', m[0].substring(0, 120)));
// Check for table-related class names
console.log('\n=== Table-related classes ===');
const classMatches = bodyStr.match(/"cls":"[^"]*"/g) || [];
const tableClasses = classMatches.filter(c => /table|grid|cell|col|row/i.test(c));
console.log(`Found ${tableClasses.length} table-related classes`);
[...new Set(tableClasses)].slice(0, 10).forEach(c => console.log(' ', c));

View File

@@ -0,0 +1,2 @@
# diff_review detection test v2
test_value = "hello"

View File

@@ -0,0 +1,37 @@
// List all channels in the guild
const https = require('https');
const TOKEN = 'MTQ3OTY0ODcxNjA1MzgwNzI4NQ.GVMGbd.WN7BliH8oq9fqbaiQcyxXesJTYgBx-ObsDkK7o';
const GUILD_ID = '1478722210460991662';
function apiGet(path) {
return new Promise((resolve, reject) => {
const opts = {
hostname: 'discord.com',
path: `/api/v10${path}`,
headers: { 'Authorization': `Bot ${TOKEN}` }
};
https.get(opts, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try { resolve(JSON.parse(data)); } catch { resolve(data); }
});
}).on('error', reject);
});
}
async function main() {
const channels = await apiGet(`/guilds/${GUILD_ID}/channels`);
if (!Array.isArray(channels)) {
console.log('Error:', channels);
return;
}
console.log(`Total channels: ${channels.length}\n`);
channels.sort((a,b) => (a.position||0) - (b.position||0));
channels.forEach(c => {
const type = ['TEXT','DM','VOICE','GROUP_DM','CATEGORY','ANNOUNCE','','','','','','THREAD','THREAD','THREAD','','FORUM','MEDIA'][c.type] || c.type;
console.log(`${c.id} | ${type.padEnd(10)} | #${c.name}`);
});
}
main().catch(e => console.error(e));

View File

@@ -0,0 +1,55 @@
// Read latest Discord messages from ag-gravity_control channel
const https = require('https');
const TOKEN = 'MTQ3OTY0ODcxNjA1MzgwNzI4NQ.GVMGbd.WN7BliH8oq9fqbaiQcyxXesJTYgBx-ObsDkK7o';
const CHANNEL_ID = '1483082084540223663';
function apiGet(path) {
return new Promise((resolve, reject) => {
const opts = {
hostname: 'discord.com',
path: `/api/v10${path}`,
headers: { 'Authorization': `Bot ${TOKEN}` }
};
https.get(opts, res => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try { resolve(JSON.parse(data)); } catch { resolve(data); }
});
}).on('error', reject);
});
}
async function main() {
const limit = process.argv[2] || 15;
const msgs = await apiGet(`/channels/${CHANNEL_ID}/messages?limit=${limit}`);
if (!Array.isArray(msgs)) {
console.log('Error:', JSON.stringify(msgs));
return;
}
console.log(`=== #ag-gravity_control — Last ${msgs.length} messages ===\n`);
msgs.reverse().forEach(m => {
const time = new Date(m.timestamp).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour: '2-digit', minute: '2-digit', second: '2-digit' });
const author = m.author?.username || '?';
if (m.embeds?.length > 0) {
m.embeds.forEach(e => {
const title = e.title || '(no title)';
const desc = (e.description || '').substring(0, 300);
const colorHex = e.color ? `#${e.color.toString(16).padStart(6, '0')}` : 'default';
const footer = e.footer?.text || '';
console.log(`[${time}] 📦 EMBED [${colorHex}] ${title}`);
if (desc) console.log(` ${desc.replace(/\n/g, '\n ')}`);
if (footer) console.log(` 📎 ${footer}`);
});
} else if (m.content) {
const content = m.content.substring(0, 300);
console.log(`[${time}] 💬 ${author}: ${content}`);
}
console.log('');
});
}
main().catch(e => console.error(e));

View File

@@ -0,0 +1,29 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const d = JSON.parse(fs.readFileSync(
path.join(os.homedir(), '.gemini', 'antigravity', 'bridge', 'dump_html.json'), 'utf-8'
));
const s = JSON.stringify(d.body);
console.log('title:', d.quickInfo.title);
console.log('Has id=conversation:', s.includes('"id":"conversation"'));
console.log('Has agent-side-panel:', s.includes('antigravity-agent-side-panel'));
// Find message-block patterns
const mb = [...s.matchAll(/message-block/g)];
console.log('message-block occurrences:', mb.length);
// Find user-related class patterns
const userPatterns = ['user-color', 'user-background', 'user-message', 'user-query', 'user-input', 'human'];
userPatterns.forEach(p => {
const cnt = [...s.matchAll(new RegExp(p, 'gi'))].length;
if (cnt > 0) console.log(` ${p}: ${cnt} occurrences`);
});
// Show all unique classes that include 'message' or 'chat' or 'conversation'
const clsMatches = [...s.matchAll(/"cls":"([^"]*(?:message|chat|conversation|query|user|human)[^"]*)"/gi)];
console.log('\nClasses with message/chat/conversation/user/human:');
const uniq = [...new Set(clsMatches.map(m => m[1]))];
uniq.forEach(c => console.log(' ', c.substring(0, 120)));

View File

@@ -0,0 +1,26 @@
const fs = require('fs');
const path = require('path');
const dump = JSON.parse(fs.readFileSync(
path.join(process.env.USERPROFILE, '.gemini/antigravity/bridge/deep-inspect-result.json'), 'utf8'
));
function printTree(node, indent, maxDepth) {
if (!node || indent > maxDepth) return;
const tag = (node.tag || '?').toLowerCase();
const clsArr = (node.cls || '').split(' ').filter(c => c.length > 0);
const clsShort = clsArr.slice(0, 3).join(' ');
const text = (node.text || '').substring(0, 40).replace(/[\n\r]+/g, ' ');
const childCount = (node.children || []).length;
let line = ' '.repeat(indent) + tag;
if (clsShort) line += '.' + clsArr[0];
if (childCount) line += ' [' + childCount + ' children]';
if (text && childCount === 0) line += ' = "' + text + '"';
console.log(line);
if (node.children) {
for (const c of node.children) printTree(c, indent + 1, maxDepth);
}
}
console.log('=== FULL DOM TREE (depth 8) ===');
printTree(dump.bodyTree || dump.body, 0, 8);

View File

@@ -0,0 +1,22 @@
// Test: call agentAcceptAllInFile via extension's HTTP bridge trigger-click
// This simulates what the approval handler does
const http = require('http');
const PORT = 34332; // from observer setup log
// Write a trigger-click file to make Observer click "Accept all"
const fs = require('fs');
const path = require('path');
const bridgePath = path.join(process.env.USERPROFILE, '.gemini', 'antigravity', 'bridge');
// Check if there's a trigger_click.json
const triggerFile = path.join(bridgePath, 'trigger_click.json');
console.log('Writing trigger_click.json for accept...');
fs.writeFileSync(triggerFile, JSON.stringify({ action: 'approve', type: 'diff_review', ts: Date.now() }), 'utf-8');
console.log('Done. Check if Accept all was clicked.');
// Also check extension log for recent entries
const logFile = path.join(bridgePath, 'extension.log');
const lines = fs.readFileSync(logFile, 'utf-8').split('\n');
const recent = lines.slice(-5);
recent.forEach(l => console.log(l.substring(0, 200)));

View File

@@ -0,0 +1,114 @@
// Final simulation: exact v0.5.96 flow with realistic DOM
const {generateApprovalObserverScript} = require('../out/observer-script');
let s = generateApprovalObserverScript(18080);
try { new Function(s); console.log('SYNTAX: OK'); } catch(e) { console.log('SYNTAX ERROR:', e.message); process.exit(1); }
let promptRe = /[\u003e\u00bb\u276f]\s+(.+)/;
let stripRe = /\s*(content_copy|content_paste|play_arrow|check_circle|keyboard_arrow[_a-z]*)\s*$/;
let iconFilterRe = /^(content_copy|content_paste|play_arrow|check_circle|chevron_|keyboard_arrow|more_horiz|more_vert|expand_|alternate_email|arrow_drop)/;
// Realistic scenario: "Running command" div has siblings including a copy button
// The actual DOM probably has a structure like:
// div "Running command"
// span/div with the copy icon (textContent = "> content_copy" or just "content_copy")
// div with the actual prompt+command
// div with the buttons
function v31_simulate(name, siblings) {
console.log('\n=== ' + name + ' ===');
// Step 1: Find "Running command" header
let rcIdx = -1;
for (let i = 0; i < siblings.length; i++) {
let t = siblings[i].trim();
if (t === 'Running command' || (t.indexOf('Running command') !== -1 && t.length < 30)) {
rcIdx = i;
break;
}
}
if (rcIdx < 0) { console.log(' NO RC HEADER'); return; }
// Step 2: Collect candidates (filter icons and buttons)
let cands = [];
for (let i = 0; i < siblings.length; i++) {
if (i === rcIdx) continue;
let t = siblings[i].trim();
if (t.length < 5) continue;
if (iconFilterRe.test(t)) { console.log(' FILTER icon: "' + t.substring(0,40) + '"'); continue; }
if (/^(Always|Run|Allow|Cancel|Deny|keyboard_arrow)/i.test(t)) { console.log(' FILTER btn: "' + t.substring(0,40) + '"'); continue; }
if (t.indexOf('Always run') !== -1 && t.indexOf('Cancel') !== -1) { console.log(' FILTER btn-bar: "' + t.substring(0,40) + '"'); continue; }
cands.push(t);
console.log(' CANDIDATE: "' + t.substring(0,60) + '" (len=' + t.length + ')');
}
// Step 3: Sort by length (longest first)
cands.sort((a,b) => b.length - a.length);
// Step 4: Extract command from best candidate
for (let cand of cands) {
let m = promptRe.exec(cand);
if (m && m[1].trim().length > 3) {
let cmdV = m[1].trim().replace(stripRe, '').trim();
if (cmdV.length < 3) { console.log(' SKIP (too short after strip): "' + cmdV + '"'); continue; }
if (/^(Always|Run|Allow|Cancel|Deny)/i.test(cmdV)) continue;
console.log(' EXTRACTED: "' + cmdV + '"');
return;
}
if (cand.length > 10 && /[\u276f\u003e]/.test(cand)) {
let raw = cand.replace(stripRe, '').trim();
console.log(' EXTRACTED (raw): "' + raw + '"');
return;
}
}
console.log(' NO MATCH - will fallback to "Always run"');
}
// Scenario A: What ACTUALLY happened (3 siblings, "content_copy" mixed in command text)
v31_simulate('A: Icon in command text', [
'Running command',
'\u276f gravity_control > Start-Sleep 12; $logFile content_copy',
'Always run keyboard_arrow_up Cancel'
]);
// Scenario B: Copy button as separate small div
v31_simulate('B: Icon as separate div + command div', [
'Running command',
'> content_copy',
'\u276f gravity_control > npm run compile',
'Always run Cancel'
]);
// Scenario C: Just "content_copy" standalone (no >)
v31_simulate('C: Standalone icon + command', [
'Running command',
'content_copy',
'\u276f gravity_control > git push origin main',
'Always run Cancel'
]);
// Scenario D: Multiple icons mixed
v31_simulate('D: Multiple icons + command', [
'Running command',
'play_arrow',
'> content_copy',
'\u276f gravity_control > node -e "console.log(1)" content_copy',
'Always run keyboard_arrow_up Cancel'
]);
// Scenario E: Edge - no command, only prompt
v31_simulate('E: Prompt only', [
'Running command',
'\u276f gravity_control > ',
'Always run Cancel'
]);
// Scenario F: The v0.5.95 cmdV=content_copy case
// This implies regex matched "content_copy" from a "> content_copy" sibling
// and there was no longer sibling
v31_simulate('F: Only icon sibling (worst case)', [
'Running command',
'> content_copy',
'Always run Cancel'
]);
console.log('\n=== SIMULATION COMPLETE ===');

View File

@@ -0,0 +1,43 @@
const {generateApprovalObserverScript} = require('../out/observer-script');
let s = generateApprovalObserverScript(18080);
// Extract the regex strings
let junkMatch = s.match(/JUNK_CODE_RE\s*=\s*(\/[^;]+)/);
let promptMatch = s.match(/PROMPT_ONLY_RE\s*=\s*(\/[^;]+)/);
console.log('JUNK_CODE_RE:', junkMatch[1].substring(0, 100));
console.log('PROMPT_ONLY_RE:', promptMatch[1]);
// Use eval to construct the actual regexes
let JUNK = eval(junkMatch[1]);
let PROMPT = eval(promptMatch[1]);
let tests = [
['\u276f gravity_control > ', 'prompt only (no command)'],
['\u276f extension > ', 'prompt only (extension)'],
['\u276f gravity_control > $logFile = Join-Path $env:USERPROFILE', 'PS var assignment'],
['\u276f extension > npm.cmd run compile', 'npm compile'],
['\u276f gravity_control > Start-Sleep 12', 'Start-Sleep'],
['\u276f gravity_control > git add -A; git commit -m "test"', 'git commit'],
['\u276f gravity_control > node -e "const {gen}=require()"', 'node with require'],
['\u276f gravity_control > Get-Content file.txt', 'Get-Content'],
['\u276f gravity_control > npm.cmd version patch', 'npm version'],
['function test() { return 1; }', 'JS function (should be JUNK)'],
['const x = require("fs")', 'JS const (should be JUNK)'],
['import { foo } from "bar"', 'JS import (should be JUNK)'],
];
console.log('\n=== CODE ELEMENT FILTER ANALYSIS ===');
for (let [text, desc] of tests) {
let isJunk = JUNK.test(text);
let isPrompt = PROMPT.test(text.trim());
let junkPart = isJunk ? text.match(JUNK)[0] : null;
let status;
if (isPrompt) status = 'SKIP-PROMPT';
else if (isJunk) status = 'SKIP-JUNK (' + junkPart + ')';
else status = 'PASS';
let isBug = (isJunk || isPrompt) && text.indexOf('\u276f') !== -1 && text.trim().length > 25;
console.log((isBug ? 'BUG ' : ' ') + status.padEnd(40) + ' | ' + desc);
}

View File

@@ -0,0 +1,42 @@
const {generateApprovalObserverScript} = require('../out/observer-script');
let s = generateApprovalObserverScript(18080);
// Find the regex used in v30 candidate matching
let idx = s.indexOf('candT.match(');
if (idx < 0) idx = s.indexOf('sibT.match(');
let reStr = s.substring(idx, s.indexOf(');', idx) + 1);
console.log('Match code:', reStr.substring(0, 60));
// Extract just the regex part
let reMatch = reStr.match(/\/(.*?)\//);
let reSource = reMatch ? reMatch[0] : 'NOT FOUND';
console.log('Regex source:', reSource);
// Build and test the actual regex
let re = new RegExp(reMatch[1]);
console.log('Regex object:', re);
// Test with the EXACT patterns from logs
let tests = [
['Normal', '\u276f gravity_control > Start-Sleep 12 content_copy'],
['Git cmd', '\u276f gravity_control > git add -A; git commit -m "test"'],
['Short', '> content_copy'],
['Prompt only', '\u276f gravity_control > '],
['Dir cmd', '\u276f gravity_control > dir content_copy'],
];
console.log('\n=== REGEX TESTS ===');
let stripRe = /\s*(content_copy|content_paste|play_arrow|check_circle|keyboard_arrow[_a-z]*)\s*$/;
for (let [name, text] of tests) {
let m = re.exec(text);
if (m) {
let raw = m[1].trim();
let cleaned = raw.replace(stripRe, '').trim();
console.log(name + ':');
console.log(' raw match[1]: "' + raw + '"');
console.log(' after strip: "' + cleaned + '"');
console.log(' length ok: ' + (cleaned.length >= 3));
} else {
console.log(name + ': NO MATCH');
}
}

View File

@@ -0,0 +1,122 @@
const {generateApprovalObserverScript} = require('../out/observer-script');
let s = generateApprovalObserverScript(18080);
// 1. SYNTAX CHECK
try { new Function(s); console.log('[1] SYNTAX: OK'); } catch(e) { console.log('[1] SYNTAX ERROR:', e.message); process.exit(1); }
// 2. v30 block exists
let v30Start = s.indexOf('// v30:');
let v30End = s.indexOf('// v23:', v30Start);
console.log('[2] v30 block:', v30Start > 0 && v30End > v30Start ? 'OK' : 'MISSING');
// 3. Key features present
console.log('[3] rcCands:', s.indexOf('rcCands') > 0 ? 'OK' : 'MISSING');
console.log('[4] content_copy filter:', s.indexOf('content_copy|content_paste') > 0 ? 'OK' : 'MISSING');
console.log('[5] sort by length:', s.indexOf('.sort(') > 0 ? 'OK' : 'MISSING');
console.log('[6] icon strip replace:', (s.match(/content_copy/g)||[]).length >= 2 ? 'OK (filter+strip)' : 'CHECK');
// 4. Simulate exact DOM from BTN-DOM-DUMP + CONTEXT logs
let promptRe = /[\u003e\u00bb\u276f]\s+(.+)/;
let stripRe = /\s*(content_copy|content_paste|play_arrow|check_circle|keyboard_arrow[_a-z]*)\s*$/;
let iconFilterRe = /^(content_copy|content_paste|play_arrow|check_circle|chevron_|keyboard_arrow|more_horiz|more_vert|expand_|alternate_email|arrow_drop)/;
let btnFilterRe = /^(Always|Run|Allow|Cancel|Deny|keyboard_arrow)/i;
function simulate(name, siblings) {
console.log('\n=== ' + name + ' ===');
let rcFound = false;
for (let sib of siblings) {
if (sib === 'Running command' || (sib.indexOf('Running command') !== -1 && sib.length < 30)) {
rcFound = true;
break;
}
}
if (!rcFound) { console.log(' RC header NOT FOUND'); return null; }
let cands = [];
for (let sib of siblings) {
if (sib === 'Running command') continue;
if (sib.length < 5) continue;
if (iconFilterRe.test(sib)) { console.log(' SKIP icon: "' + sib.substring(0,30) + '"'); continue; }
if (btnFilterRe.test(sib)) { console.log(' SKIP btn: "' + sib.substring(0,30) + '"'); continue; }
if (sib.indexOf('Always run') !== -1 && sib.indexOf('Cancel') !== -1) { console.log(' SKIP btn-bar: "' + sib.substring(0,30) + '"'); continue; }
cands.push(sib);
}
cands.sort((a,b) => b.length - a.length);
console.log(' Candidates: ' + cands.length);
for (let i = 0; i < cands.length; i++) {
console.log(' [' + i + '] len=' + cands[i].length + ': "' + cands[i].substring(0,80) + '"');
}
for (let cand of cands) {
let m = promptRe.exec(cand);
if (m && m[1].trim().length > 3) {
let cmdV = m[1].trim().replace(stripRe, '').trim();
if (cmdV.length < 3) continue;
console.log(' RESULT: "' + cmdV + '"');
return cmdV;
}
if (cand.length > 10 && /[\u276f\u003e]/.test(cand)) {
let raw = cand.replace(stripRe, '').trim();
console.log(' RESULT (raw): "' + raw + '"');
return raw;
}
}
console.log(' RESULT: NO MATCH');
return null;
}
// Case 1: From BTN-DOM-DUMP (3 siblings, command + content_copy icon)
simulate('Case1: Normal command with icon', [
'Running command',
'\u276f gravity_control > Start-Sleep 12; $logFile content_copy',
'Always run keyboard_arrow_up Cancel'
]);
// Case 2: content_copy as standalone sibling
simulate('Case2: Icon as separate div', [
'Running command',
'content_copy',
'\u276f gravity_control > npm run compile',
'Always run Cancel'
]);
// Case 3: No icon appended
simulate('Case3: Clean command', [
'Running command',
'\u276f gravity_control > git add -A; git commit -m "test"',
'Always run Cancel'
]);
// Case 4: Very long command
simulate('Case4: Long command', [
'Running command',
'\u276f gravity_control > Select-String -Path "$env:USERPROFILE\\.gemini\\antigravity\\bridge\\extension.log" -Pattern "CONTEXT" content_copy',
'Always run keyboard_arrow_up Cancel'
]);
// Case 5: Prompt only (no command yet)
simulate('Case5: Prompt only', [
'Running command',
'\u276f gravity_control > ',
'Always run Cancel'
]);
// Case 6: Multiple icon texts
simulate('Case6: Multiple icons', [
'Running command',
'play_arrow',
'content_copy',
'\u276f gravity_control > dir content_copy',
'Always run Cancel'
]);
// Case 7: Observed log pattern - "content_copy" was cmdV
// This means the regex matched on just "content_copy" with a > before it
// Possible: the sibling text is "> content_copy" (very short prompt)
simulate('Case7: Short prompt with icon only', [
'Running command',
'> content_copy',
'Always run Cancel'
]);
console.log('\n=== ALL TESTS COMPLETE ===');

View File

@@ -0,0 +1,52 @@
const {generateApprovalObserverScript} = require('../out/observer-script');
let s = generateApprovalObserverScript(18080);
// Extract the terminal prompt regex from generated code
let idx = s.indexOf('_termPromptMatch');
let reCtx = s.substring(idx, s.indexOf(');', idx) + 1);
console.log('v32 code:', reCtx.substring(0, 80));
// Extract regex
let reMatch = reCtx.match(/\/(.+?)\//);
let termRe = new RegExp(reMatch[1]);
console.log('v32 regex:', termRe);
let stripRe = /\s*(content_copy|content_paste|play_arrow)\s*$/;
let tests = [
// Should MATCH (terminal commands)
['\u276f gravity_control > Start-Sleep 12', true, 'Start-Sleep'],
['\u276f gravity_control > npm.cmd run compile', true, 'npm compile'],
['\u276f gravity_control > $logFile = Join-Path $env:USERPROFILE', true, 'PS variable (had JUNK match)'],
['\u276f gravity_control > git add -A; git commit -m "test"', true, 'git commit'],
['\u276f gravity_control > node -e "const {gen}=require(\'./out\')"', true, 'node with const (was JUNK)'],
['\u276f extension > npm.cmd run compile', true, 'extension npm'],
['\u276f gravity_control > Start-Sleep 12 content_copy', true, 'with icon (strip)'],
['\u276f gravity_control > Get-Content f.txt | Select-Object -Last 5', true, 'Get-Content'],
// Should NOT match (prompt only, no command)
['\u276f gravity_control > ', false, 'prompt only'],
['\u276f extension > ', false, 'prompt only ext'],
// Should NOT match (not terminal - JS code)
['function test() { return 1; }', false, 'JS function'],
['const x = require("fs")', false, 'JS const'],
['import { foo } from "bar"', false, 'JS import'],
// Should NOT match (no prompt marker)
['gravity_control > dir', false, 'no marker'],
];
console.log('\n=== v32 TERMINAL PROMPT REGEX TESTS ===');
let pass = 0, fail = 0;
for (let [text, shouldMatch, desc] of tests) {
let m = termRe.exec(text);
let matched = false;
let cmdV = null;
if (m && m[1] && m[1].trim().length > 2) {
cmdV = m[1].trim().replace(stripRe, '').trim();
matched = cmdV.length > 2;
}
let ok = matched === shouldMatch;
if (ok) pass++; else fail++;
console.log((ok ? 'PASS' : 'FAIL') + ' | ' + (matched ? 'MATCH' : 'SKIP ').padEnd(5) + ' | ' + desc);
if (matched && cmdV) console.log(' cmd: "' + cmdV + '"');
}
console.log('\nResult: ' + pass + '/' + (pass+fail) + ' passed' + (fail > 0 ? ' (' + fail + ' FAILED!)' : ' ALL OK'));

View File

@@ -5,8 +5,13 @@
* Handles:
* - Response file watching (file-based bridge fallback)
* - Response processing (diff_review, DOM observer, step_probe paths)
* - Multi-strategy approval execution (RPC, VS Code commands, DOM click)
* - Multi-strategy approval execution (VS Code commands, RPC, DOM click)
* - Diff review Accept/Reject via VS Code commands
*
* STRATEGY ORDER (most reliable first):
* 0. antigravity.acceptAgentStep / rejectAgentStep — AG's own commands, always works
* 1. HandleCascadeUserInteraction RPC — cross-platform, needs stepIndex
* 2. DOM click trigger via HTTP bridge — fallback
*/
import * as vscode from 'vscode';
@@ -200,6 +205,25 @@ async function processResponseFile(filePath: string) {
}
const content = fs.readFileSync(filePath, 'utf-8');
const resp = JSON.parse(content);
// v22: Skip files written by the WS response handler (extension.ts onResponse).
// Those files exist ONLY for Observer's pollResponseGroup to read via HTTP.
// The WS handler already calls tryApprovalStrategies, so processing here is redundant.
// Without this skip, the watcher deletes the file before Observer can poll it
// (since no pending file exists for the isDomObserver check).
if (resp._from_ws) {
// v26: TTL — delete stale _from_ws files after 60s to prevent infinite SKIP spam
const wsRidTs = parseInt((resp.request_id || '').split('_')[0], 10);
const wsAge = isNaN(wsRidTs) ? 999999 : Date.now() - wsRidTs;
if (wsAge > 60_000) {
ctx.logToFile(`[RESPONSE] CLEANUP stale _from_ws file: ${resp.request_id} age=${Math.round(wsAge / 1000)}s`);
try { fs.unlinkSync(filePath); } catch { }
return;
}
ctx.logToFile(`[RESPONSE] SKIP _from_ws file (for Observer pollResponseGroup): ${resp.request_id}`);
return;
}
const msg = `[RESPONSE] rid=${resp.request_id} approved=${resp.approved} step_type=${resp.step_type || '(missing)'} keys=[${Object.keys(resp).join(',')}]`;
console.log(`Gravity Bridge: ${msg}`);
ctx.logToFile(msg);
@@ -256,7 +280,7 @@ async function processResponseFile(filePath: string) {
} catch { }
}
// ═══ MULTI-STRATEGY APPROVAL (v2.1) ═══
// ═══ MULTI-STRATEGY APPROVAL (v3.0) ═══
const approved = resp.approved;
// ── diff_review: Accept all / Reject all ──
@@ -268,16 +292,10 @@ async function processResponseFile(filePath: string) {
button_index: resp.button_index,
step_type: pendingStepType,
});
} else if (isDomObserver) {
// DOM observer path: ALSO try RPC strategies (renderer click is unreliable)
const targetSession = sessionId || ctx.activeSessionId;
ctx.logToFile(`[RESPONSE] dom_observer → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex);
ctx.logToFile(`[RESPONSE] dom strategy result: ${strategyResult}`);
} else {
// Step probe path: run ALL approval strategies
// ALL paths (dom_observer + step_probe) use same strategy pipeline
const targetSession = sessionId || ctx.activeSessionId;
ctx.logToFile(`[RESPONSE] step_probe → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
ctx.logToFile(`[RESPONSE] → tryApprovalStrategies(${approved}, ${targetSession.substring(0, 8)}, type=${pendingStepType}, step=${pendingStepIndex})`);
const strategyResult = await tryApprovalStrategies(approved, targetSession, pendingStepType, pendingStepIndex);
ctx.logToFile(`[RESPONSE] strategy result: ${strategyResult}`);
}
@@ -307,9 +325,9 @@ async function processResponseFile(filePath: string) {
* Returns a string describing which method succeeded (or all failed).
*
* Strategy order (most reliable first):
* 1. HandleCascadeUserInteraction RPC (cross-platform, no focus)
* 2. VS Code accept/reject commands (focus-dependent)
* 3. Log failure for manual intervention
* 0. antigravity.acceptAgentStep / rejectAgentStep (AG VS Code commands — always works)
* 1. HandleCascadeUserInteraction RPC (cross-platform, needs stepIndex)
* 2. Renderer DOM Click via HTTP Bridge (fallback)
*/
export async function tryApprovalStrategies(approved: boolean, sessionId: string, stepType: string = '', stepIndex: number = -1): Promise<string> {
const action = approved ? 'APPROVE' : 'REJECT';
@@ -317,90 +335,153 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
: (ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : -1);
ctx.logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${effectiveStepIndex}`);
// ── Dynamic Command Discovery (log what's available during WAITING state) ──
let approvalCmdList: string[] = [];
try {
const allCmds = await vscode.commands.getCommands(true);
const agCmds = allCmds.filter((c: string) => c.startsWith('antigravity.'));
approvalCmdList = agCmds.filter((c: string) => {
const lower = c.toLowerCase();
return lower.includes('accept') || lower.includes('reject') || lower.includes('approve')
|| lower.includes('terminal') || lower.includes('run') || lower.includes('step')
|| lower.includes('cascade') || lower.includes('action');
});
ctx.logToFile(`[APPROVAL-CMD-CHECK] ${agCmds.length} total, ${approvalCmdList.length} approval-related:`);
for (const c of approvalCmdList) {
ctx.logToFile(`[APPROVAL-CMD-CHECK] → ${c}`);
// ══════════════════════════════════════════════════════════
// STRATEGY 0: SDK-verified AG commands (step_type-aware dispatch)
//
// From SDK index.js (verified command mapping):
// antigravity.agent.acceptAgentStep — code edits, file writes
// antigravity.agent.rejectAgentStep — reject code edits
// antigravity.command.accept — non-terminal commands (Run, Allow, etc.)
// antigravity.command.reject — reject non-terminal commands
// antigravity.terminalCommand.accept — terminal commands
// antigravity.terminalCommand.reject — reject terminal commands
// antigravity.terminalCommand.run — run terminal commands
//
// These operate on the currently focused/active step — no stepIndex needed!
// ══════════════════════════════════════════════════════════
{
const typeLower = stepType.toLowerCase().replace('cortex_step_type_', '');
// Determine which SDK command pair to use based on step_type
let acceptCmd: string;
let rejectCmd: string;
if (typeLower.includes('code_edit') || typeLower.includes('write_to_file')
|| typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')
|| typeLower === 'diff_review') {
// Code edits → agent step commands
acceptCmd = 'antigravity.agent.acceptAgentStep';
rejectCmd = 'antigravity.agent.rejectAgentStep';
} else if (typeLower.includes('run_command') || typeLower.includes('shell_exec')
|| typeLower.includes('send_command_input')) {
// Terminal commands → terminal command pair
acceptCmd = 'antigravity.terminalCommand.accept';
rejectCmd = 'antigravity.terminalCommand.reject';
} else if (typeLower === 'command' || typeLower.includes('permission')
|| typeLower.includes('browser') || typeLower.includes('mcp')
|| typeLower.includes('extension_code') || typeLower.includes('subagent')
|| typeLower.includes('open_browser') || typeLower.includes('read_url')
|| typeLower.includes('invoke_subagent')) {
// Non-terminal commands (Run, Allow, etc.) → command pair
acceptCmd = 'antigravity.command.accept';
rejectCmd = 'antigravity.command.reject';
} else {
// Unknown type — try all three in order
acceptCmd = 'antigravity.command.accept';
rejectCmd = 'antigravity.command.reject';
}
const primaryCmd = approved ? acceptCmd : rejectCmd;
ctx.logToFile(`[APPROVAL-0] stepType="${stepType}" → ${primaryCmd}`);
try {
await vscode.commands.executeCommand(primaryCmd);
ctx.logToFile(`[APPROVAL-0] ✅ ${primaryCmd} SUCCESS`);
return `SDK:${primaryCmd}`;
} catch (e: any) {
ctx.logToFile(`[APPROVAL-0] ❌ ${primaryCmd} failed: ${e.message?.substring(0, 200)}`);
}
// Fallback: if the primary type-specific command failed, try the other pairs
const fallbackPairs = [
approved ? 'antigravity.command.accept' : 'antigravity.command.reject',
approved ? 'antigravity.agent.acceptAgentStep' : 'antigravity.agent.rejectAgentStep',
approved ? 'antigravity.terminalCommand.accept' : 'antigravity.terminalCommand.reject',
].filter(cmd => cmd !== primaryCmd); // skip already-tried
for (const fallbackCmd of fallbackPairs) {
try {
ctx.logToFile(`[APPROVAL-0-FB] Trying ${fallbackCmd}...`);
await vscode.commands.executeCommand(fallbackCmd);
ctx.logToFile(`[APPROVAL-0-FB] ✅ ${fallbackCmd} SUCCESS`);
return `SDK-FB:${fallbackCmd}`;
} catch (e: any) {
ctx.logToFile(`[APPROVAL-0-FB] ❌ ${fallbackCmd}: ${e.message?.substring(0, 100)}`);
}
}
} catch (e: any) {
ctx.logToFile(`[APPROVAL-CMD-CHECK] error: ${e.message}`);
}
// ══════════════════════════════════════════════════════════
// STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source)
// STRATEGY 1: HandleCascadeUserInteraction RPC
// Now supports BOTH approve AND reject.
// Requires valid stepIndex for most step types.
// ══════════════════════════════════════════════════════════
if (ctx.sdk && approved && effectiveStepIndex >= 0) {
// Build interaction sub-message based on step_type
if (ctx.sdk && effectiveStepIndex >= 0) {
const typeLower = stepType.toLowerCase().replace('cortex_step_type_', '');
let interactionPayload: Record<string, any> = {};
// Code edit steps — use dedicated RPC
if (typeLower.includes('code_edit') || typeLower.includes('write_to_file') || typeLower.includes('propose_code') || typeLower.includes('write_cascade_edit')) {
// CODE EDIT: Uses acknowledgeCodeActionStep RPC (correct AG LS method)
try {
ctx.logToFile(`[APPROVAL-CODE-EDIT] trying submitCodeAcknowledgement command`);
ctx.logToFile(`[APPROVAL-1-CODE] trying submitCodeAcknowledgement command`);
await vscode.commands.executeCommand('antigravity.prioritized.submitCodeAcknowledgement');
ctx.logToFile(`[APPROVAL-CODE-EDIT] ✅ submitCodeAcknowledgement OK`);
ctx.logToFile(`[APPROVAL-1-CODE] ✅ submitCodeAcknowledgement OK`);
return `CMD:submitCodeAcknowledgement(accept=${approved})`;
} catch {
ctx.logToFile(`[APPROVAL-CODE-EDIT] submitCodeAcknowledgement not available, trying RPC`);
ctx.logToFile(`[APPROVAL-1-CODE] submitCodeAcknowledgement not available, trying RPC`);
}
// Direct LS RPC with correct method name
try {
ctx.logToFile(`[APPROVAL-CODE-EDIT] acknowledgeCodeActionStep(cascadeId=${sessionId.substring(0, 8)}, accept=${approved}, stepIndices=[${effectiveStepIndex}])`);
ctx.logToFile(`[APPROVAL-1-CODE] acknowledgeCodeActionStep(cascadeId=${sessionId.substring(0, 8)}, accept=${approved}, stepIndices=[${effectiveStepIndex}])`);
const ackResult = await ctx.sdk.ls.rawRPC('acknowledgeCodeActionStep', {
cascadeId: sessionId,
accept: approved,
stepIndices: [effectiveStepIndex],
});
ctx.logToFile(`[APPROVAL-CODE-EDIT] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`);
ctx.logToFile(`[APPROVAL-1-CODE] ✅ SUCCESS: ${JSON.stringify(ackResult).substring(0, 200)}`);
return `RPC:acknowledgeCodeActionStep(accept=${approved})`;
} catch (e: any) {
ctx.logToFile(`[APPROVAL-CODE-EDIT] ❌ ${e.message.substring(0, 200)}`);
ctx.logToFile(`[APPROVAL-CODE-EDIT] falling back to HandleCascadeUserInteraction`);
interactionPayload = { runCommand: { confirm: true } };
ctx.logToFile(`[APPROVAL-1-CODE] ❌ ${e.message.substring(0, 200)}`);
// Fall through to generic HandleCascadeUserInteraction
interactionPayload = { runCommand: { confirm: approved } };
}
}
// Map step_type to interaction sub-message field
// CRITICAL FIX: Use `confirm: approved` (not always true) to support REJECT
if (typeLower.includes('run_command') || typeLower.includes('shell_exec')) {
interactionPayload = { runCommand: { confirm: true } };
interactionPayload = { runCommand: { confirm: approved } };
} else if (typeLower.includes('open_browser')) {
interactionPayload = { openBrowserUrl: { confirm: true } };
interactionPayload = { openBrowserUrl: { confirm: approved } };
} else if (typeLower.includes('send_command_input')) {
interactionPayload = { sendCommandInput: { confirm: true } };
interactionPayload = { sendCommandInput: { confirm: approved } };
} else if (typeLower.includes('read_url')) {
interactionPayload = { readUrlContent: { confirm: true } };
interactionPayload = { readUrlContent: { confirm: approved } };
} else if (typeLower.includes('mcp')) {
interactionPayload = { mcpTool: { confirm: true } };
interactionPayload = { mcpTool: { confirm: approved } };
} else if (typeLower.includes('invoke_subagent') || typeLower.includes('extension_code') || typeLower.includes('browser_subagent')) {
interactionPayload = { runExtensionCode: { confirm: true } };
interactionPayload = { runExtensionCode: { confirm: approved } };
} else if (typeLower.includes('file_permission')) {
const scope = typeLower.includes('conversation') ? 2 : 1;
interactionPayload = { filePermission: { allow: true, scope } };
if (typeLower.includes('deny')) {
interactionPayload = { filePermission: { allow: false, scope: 1 } };
} else {
const scope = typeLower.includes('conversation') ? 2 : 1;
interactionPayload = { filePermission: { allow: approved, scope } };
}
} else if (typeLower.includes('elicitation')) {
interactionPayload = { elicitation: {} };
} else if (typeLower === 'permission' || typeLower.includes('permission')) {
// DOM observer 'permission' type: browser_subagent Allow/Deny dialog
// Try runExtensionCode first (most common for JS execution permission)
interactionPayload = { runExtensionCode: { confirm: true } };
interactionPayload = { runExtensionCode: { confirm: approved } };
} else if (typeLower === 'command' || typeLower === '') {
// Generic command — most common case from DOM observer
interactionPayload = { runCommand: { confirm: approved } };
} else {
// Default: try run_command (most common)
interactionPayload = { runCommand: { confirm: true } };
// Default: try run_command
interactionPayload = { runCommand: { confirm: approved } };
}
const activeTrajectoryId = getTrajectoryId();
const protoVariants = [
// Variant A: camelCase with trajectoryId (proven working for run_command)
// Variant A: camelCase with trajectoryId
{
cascadeId: sessionId,
interaction: {
@@ -431,20 +512,17 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
for (let i = 0; i < protoVariants.length; i++) {
try {
const payload = protoVariants[i];
ctx.logToFile(`[APPROVAL-PROTO-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`);
ctx.logToFile(`[APPROVAL-1-${i}] HandleCascadeUserInteraction(${JSON.stringify(payload).substring(0, 250)})`);
const rpcResult = await ctx.sdk.ls.rawRPC('HandleCascadeUserInteraction', payload);
ctx.logToFile(`[APPROVAL-PROTO-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-PROTO-${i}:HandleCascadeUserInteraction(${typeLower})`;
ctx.logToFile(`[APPROVAL-1-${i}] ✅ SUCCESS: ${JSON.stringify(rpcResult).substring(0, 200)}`);
return `RPC-${i}:HandleCascadeUserInteraction(${typeLower},${action})`;
} catch (e: any) {
lastRpcError = e.message || '';
ctx.logToFile(`[APPROVAL-PROTO-${i}] ❌ ${lastRpcError.substring(0, 300)}`);
ctx.logToFile(`[APPROVAL-1-${i}] ❌ ${lastRpcError.substring(0, 300)}`);
}
}
// ── Auto-recovery: wrong-LS detection ──────────────────────
// All 3 proto variants failed. If the error is "input not registered",
// SDK is likely connected to wrong LS process. Attempt fixLSConnection
// and retry ONE time to avoid permanent failure.
if (ctx.fixLSConnection && lastRpcError.includes('input not registered')) {
ctx.logToFile('[APPROVAL] ⚠️ wrong-LS detected ("input not registered"), attempting LS fix...');
try {
@@ -453,10 +531,9 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
ctx.logToFile('[APPROVAL] LS reconnected — retrying first proto variant...');
try {
const retryPayload = protoVariants[0];
ctx.logToFile(`[APPROVAL-RETRY] HandleCascadeUserInteraction(${JSON.stringify(retryPayload).substring(0, 250)})`);
const retryResult = await ctx.sdk.ls.rawRPC('HandleCascadeUserInteraction', retryPayload);
ctx.logToFile(`[APPROVAL-RETRY] ✅ SUCCESS: ${JSON.stringify(retryResult).substring(0, 200)}`);
return `RPC-RETRY:HandleCascadeUserInteraction(${typeLower})`;
return `RPC-RETRY:HandleCascadeUserInteraction(${typeLower},${action})`;
} catch (retryErr: any) {
ctx.logToFile(`[APPROVAL-RETRY] ❌ ${retryErr.message?.substring(0, 200)}`);
}
@@ -467,9 +544,14 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
ctx.logToFile(`[APPROVAL] fixLSConnection error: ${fixErr.message?.substring(0, 200)}`);
}
}
} else if (ctx.sdk && effectiveStepIndex < 0) {
ctx.logToFile(`[APPROVAL-1] SKIPPED RPC: stepIndex=${effectiveStepIndex} (unknown) — Strategy 0 (VS Code command) was the primary attempt`);
}
// ── Strategy 2: Renderer DOM Click via HTTP Bridge (primary fallback) ──
// ══════════════════════════════════════════════════════════
// STRATEGY 2: Renderer DOM Click via HTTP Bridge (fallback)
// Sets a click trigger that the observer script polls and executes.
// ══════════════════════════════════════════════════════════
try {
const triggerAction = approved ? 'approve' : 'reject';
ctx.logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`);
@@ -479,6 +561,6 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
ctx.logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`);
}
ctx.logToFile(`[APPROVAL] strategies complete — check logs for results`);
ctx.logToFile(`[APPROVAL] All strategies complete for ${action}`);
return `STRATEGIES_DONE:${action}`;
}

View File

@@ -0,0 +1,96 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { WSBridgeClient } from './ws-client';
export interface BrainWatcherContext {
logToFile: (msg: string) => void;
wsBridge: WSBridgeClient;
projectName: string;
}
export class BrainWatcher {
private brainDir: string;
private ctx: BrainWatcherContext;
private currentSessionId: string = '';
private watcher: fs.FSWatcher | null = null;
private lastEventTimes: Map<string, number> = new Map();
constructor(ctx: BrainWatcherContext) {
this.ctx = ctx;
// The bridgePath is ~/.gemini/antigravity/bridge, so brain is sibling
this.brainDir = path.join(os.homedir(), '.gemini', 'antigravity', 'brain');
}
public updateSession(sessionId: string) {
if (!sessionId || this.currentSessionId === sessionId) {
return;
}
this.currentSessionId = sessionId;
this.startWatching(sessionId);
}
private startWatching(sessionId: string) {
this.stop();
const sessionDir = path.join(this.brainDir, sessionId);
if (!fs.existsSync(sessionDir)) {
// It might not be created yet, poll gently
setTimeout(() => this.startWatching(sessionId), 2000);
return;
}
try {
this.watcher = fs.watch(sessionDir, { persistent: false }, (eventType, filename) => {
if (!filename || !filename.endsWith('.md')) return;
// Dedup rapid events
const now = Date.now();
const last = this.lastEventTimes.get(filename) || 0;
if (now - last < 500) return; // 500ms debounce
this.lastEventTimes.set(filename, now);
this.handleFileChange(sessionDir, filename, eventType);
});
this.ctx.logToFile(`[BRAIN-WATCHER] Started watching session: ${sessionId.substring(0, 8)}`);
} catch (e: any) {
this.ctx.logToFile(`[BRAIN-WATCHER] Failed to watch ${sessionId}: ${e.message}`);
}
}
private handleFileChange(dir: string, filename: string, rawEventType: string) {
const filePath = path.join(dir, filename);
let content = '';
let eventType = 'file_changed';
try {
if (fs.existsSync(filePath)) {
content = fs.readFileSync(filePath, 'utf-8');
} else {
eventType = 'file_deleted';
}
} catch (e) {
// File might be locked or deleted during read
return;
}
if (this.ctx.wsBridge && this.ctx.wsBridge.isConnected()) {
this.ctx.wsBridge.sendBrainEvent({
event_type: eventType,
conversation_id: this.currentSessionId,
file_name: filename,
content: content,
timestamp: Date.now() / 1000,
project_name: this.ctx.projectName,
});
this.ctx.logToFile(`[BRAIN-WATCHER] Sent ${eventType} for ${filename}`);
}
}
public stop() {
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
}
}

View File

@@ -16,7 +16,7 @@ import * as path from 'path';
import * as os from 'os';
import * as cp from 'child_process';
import { WSBridgeClient, WSResponseData, WSCommandData } from './ws-client';
import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState, resetPendingStateForReconnect, handleDiffReviewResponse, getActiveSessionId as getStepProbeSessionId, getStepProbeContext } from './step-probe';
import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState, resetPendingStateForReconnect, handleDiffReviewResponse, getActiveSessionId as getStepProbeSessionId, getStepProbeContext, getLastWaitingCommand } from './step-probe';
import { startHttpBridge, getDeterministicPort, HttpBridgeContext } from './http-bridge';
import { setupApprovalObserver } from './html-patcher';
import { watchCommandsDir, handleWSCommand, disposeCommandsWatcher, CommandHandlerContext } from './command-handler';
@@ -70,12 +70,13 @@ function detectProjectName(): string {
if (folders && folders.length > 0) {
const cwd = folders[0].uri.fsPath;
try {
const remoteUrl = cp.execSync('git remote get-url origin', {
cwd, encoding: 'utf-8', timeout: 2000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore']
}).toString().trim();
const match = remoteUrl.match(/\/([^\/]+?)(?:\.git)?$/);
if (match && match[1]) {
return match[1].toLowerCase().replace(/[\s\-]+/g, '_');
const gitConfigPath = path.join(cwd, '.git', 'config');
if (fs.existsSync(gitConfigPath)) {
const configContent = fs.readFileSync(gitConfigPath, 'utf8');
const match = configContent.match(/url\s*=\s*.*\/([^\/]+?)(?:\.git)?$/m);
if (match && match[1]) {
return match[1].toLowerCase().replace(/[\s\-]+/g, '_');
}
}
} catch { }
return path.basename(cwd).toLowerCase().replace(/[\s\-]+/g, '_');
@@ -85,13 +86,7 @@ function detectProjectName(): string {
// ─── Bridge File I/O ───
function ensureBridgeDir() {
const dirs = ['', 'response', 'commands', 'chat_snapshots'];
for (const d of dirs) {
const p = path.join(bridgePath, d);
if (!fs.existsSync(p)) { fs.mkdirSync(p, { recursive: true }); }
}
}
// Module-level activeSessionId so writeChatSnapshot can register sessions lazily
let activeSessionId = '';
@@ -101,34 +96,15 @@ const recentDiscordSentTexts: Map<string, number> = new Map();
function writeChatSnapshot(text: string) {
try {
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
conversation_id: activeSessionId,
conversation_id: getStepProbeSessionId(),
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent (${text.length} chars)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
return;
if (getStepProbeSessionId()) { writeRegistration(getStepProbeSessionId()); }
}
// File route (fallback — only when WS is NOT connected)
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); }
const id = Date.now().toString();
const data = {
id,
project_name: projectName,
content: text,
timestamp: Date.now() / 1000,
};
const filePath = path.join(snapshotDir, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.log(`Gravity Bridge: chat snapshot written (${text.length} chars) → ${id}.json`);
logToFile(`[SNAPSHOT] written ${id}.json (${text.length} chars)`);
logToFile(`[SNAPSHOT] content: ${text.substring(0, 200)}`);
// Lazily register session → project mapping (correct because projectName is per-window)
if (activeSessionId) { writeRegistration(activeSessionId); }
} catch (e: any) {
console.log(`Gravity Bridge: snapshot write error: ${e.message}`);
}
@@ -136,33 +112,16 @@ function writeChatSnapshot(text: string) {
function writeChatSnapshotWithFiles(text: string, files: Array<{ name: string, content: string }>) {
try {
// WS route (preferred) — skip file write to prevent duplicate Discord delivery
if (wsBridge && wsBridge.isConnected()) {
wsBridge.sendChat({
content: text,
attached_files: files,
conversation_id: activeSessionId,
conversation_id: getStepProbeSessionId(),
project_name: projectName,
});
logToFile(`[SNAPSHOT-WS] sent with ${files.length} files (${text.length} chars)`);
if (activeSessionId) { writeRegistration(activeSessionId); }
return;
if (getStepProbeSessionId()) { writeRegistration(getStepProbeSessionId()); }
}
// File route (fallback — only when WS is NOT connected)
const snapshotDir = path.join(bridgePath, 'chat_snapshots');
if (!fs.existsSync(snapshotDir)) { fs.mkdirSync(snapshotDir, { recursive: true }); }
const id = Date.now().toString();
const data = {
id,
project_name: projectName,
content: text,
attached_files: files,
timestamp: Date.now() / 1000,
};
const filePath = path.join(snapshotDir, `${id}.json`);
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); }
} catch (e: any) {
console.log(`Gravity Bridge: snapshot+files write error: ${e.message}`);
}
@@ -250,8 +209,8 @@ export async function fixLSConnection(): Promise<boolean> {
// Find the line whose workspace_id matches our workspace (case-insensitive)
let matchedLine: string | null = null;
let fallbackLine: string | null = null; // v15: LS without workspace_id (AG restart)
for (const line of lines) {
const lower = line.toLowerCase();
// Match workspace_id arg against our hint
const wsMatch = line.match(/--workspace_id[= ](\S+)/i);
if (wsMatch) {
@@ -260,12 +219,25 @@ export async function fixLSConnection(): Promise<boolean> {
matchedLine = line;
break;
}
} else {
// v15: LS without --workspace_id (new AG main LS after restart)
// Skip --enable_lsp processes (secondary/old LSP instances)
if (!line.includes('--enable_lsp') && !fallbackLine) {
fallbackLine = line;
logToFile(`[LS-FIX] found fallback LS (no workspace_id): PID=${line.split('|')[0]?.trim()}`);
}
}
}
if (!matchedLine) {
logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`);
return false;
if (fallbackLine) {
// v15: Use workspace_id-less LS as fallback (common after AG restart)
logToFile(`[LS-FIX] No workspace_id match — using fallback LS`);
matchedLine = fallbackLine;
} else {
logToFile(`[LS-FIX] No LS process matched hint="${hint}" (${lines.length} processes)`);
return false;
}
}
// Extract port and csrf_token from matched line
@@ -382,7 +354,7 @@ export async function activate(context: vscode.ExtensionContext) {
const config = vscode.workspace.getConfiguration('gravityBridge');
const configPath = config.get<string>('bridgePath');
bridgePath = configPath || path.join(os.homedir(), '.gemini', 'antigravity', 'bridge');
ensureBridgeDir();
console.log(`Gravity Bridge: bridge path: ${bridgePath}`);
// ── WebSocket Hub Connection ──
@@ -416,6 +388,28 @@ export async function activate(context: vscode.ExtensionContext) {
}
// Normal approval — tryApprovalStrategies
// v22: ALSO write response file so Observer's pollResponseGroup can click
// the correct button (with exact button_index). Without this, only the
// imprecise pollTriggerClick fallback was used for WS-path responses.
const responseDir = path.join(bridgePath, 'response');
if (!fs.existsSync(responseDir)) {
fs.mkdirSync(responseDir, { recursive: true });
}
const respPayload = {
request_id: data.request_id,
approved,
button_index: data.button_index ?? 0,
step_type: stepType,
project_name: projectName,
_from_ws: true,
};
fs.writeFileSync(
path.join(responseDir, `${data.request_id}.json`),
JSON.stringify(respPayload),
'utf-8'
);
logToFile(`[WS-RESPONSE] Response file written for pollResponseGroup: ${data.request_id?.substring(0, 12)}`);
const approvalCtx = getApprovalContext();
logToFile(`[WS-RESPONSE] Triggering approval: approved=${approved} session=${approvalCtx.sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${approvalCtx.stepIndex}`);
tryApprovalStrategies(approved, approvalCtx.sessionId, stepType, approvalCtx.stepIndex)
@@ -526,6 +520,8 @@ export async function activate(context: vscode.ExtensionContext) {
get activeSessionId() { return getStepProbeContext().activeSessionId; },
get sessionStalled() { return getStepProbeContext().sessionStalled; },
get lastPendingStepIndex() { return getStepProbeContext().lastPendingStepIndex; },
writeChatSnapshot,
getLastWaitingCommand,
};
const bridgePort = await startHttpBridge(httpBridgeCtx, sdk);
let localPort = bridgePort;

View File

@@ -51,9 +51,21 @@ export async function setupApprovalObserver(
// 2. Write renderer script with HTTP fetch() approach
const observerJS = generateApprovalObserverScript(bridgePort);
const patcher = (integration as any)._patcher;
logToFile(`[OBSERVER-DEBUG] patcher type: ${typeof patcher}, has getScriptPath: ${patcher && typeof patcher.getScriptPath === 'function'}`);
if (patcher && typeof patcher.getScriptPath === 'function') {
let baseScript = '';
try { baseScript = integration.build(); } catch { baseScript = ''; }
// Strip old Gravity Bridge observer IIFE from baseScript.
// integration.build() caches the previous session's script, so without
// stripping, the old observer (e.g. v12) runs alongside the new one (v13),
// and the old one wins because it executes first.
if (baseScript.includes('Gravity Bridge v')) {
const oldVer = baseScript.match(/Gravity Bridge v(\d+)/);
const newVer = observerJS.match(/Gravity Bridge v(\d+)/);
logToFile(`[OBSERVER] baseScript contains old observer ${oldVer?.[0] || '?'}, new is ${newVer?.[0] || '?'} — stripping old`);
// Remove the old observer IIFE: starts with "// ── Gravity Bridge" comment, ends at the last "})();" before EOF or next section
baseScript = baseScript.replace(/\n?\/\/\s*[─═].*Gravity Bridge[\s\S]*$/, '');
}
const combinedScript = baseScript + '\n' + observerJS;
const scriptPath = patcher.getScriptPath();
fs.writeFileSync(scriptPath, combinedScript, 'utf8');
@@ -126,10 +138,14 @@ export function updateProductChecksums(sdk: any, logToFile: (msg: string) => voi
const fileBytes = fs.readFileSync(filePath);
const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, '');
if (product.checksums[key] !== hash) {
if (product.checksums[key] && product.checksums[key] !== hash) {
logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`);
product.checksums[key] = hash;
updated = true;
} else if (!product.checksums[key]) {
logToFile(`[CHECKSUM] adding ${key}: → ${hash.substring(0, 12)}...`);
product.checksums[key] = hash;
updated = true;
}
}
@@ -241,6 +257,22 @@ function _patchHtmlFiles(scriptDir: string, combinedScript: string, logToFile: (
logToFile(`[OBSERVER] ${spec.name} CSP patched: added 'unsafe-inline' to script-src`);
}
// CRITICAL: Patch CSP connect-src to allow HTTP bridge requests from the webview
// In Tailwind UI, connect-src is either missing (defaults to 'none') or strict.
if (!html.includes('connect-src') && html.includes('default-src')) {
html = html.replace(
/(default-src\s+'none'\s*;)/,
"$1\n\t\t\t\tconnect-src\n\t\t\t\t\t'self'\n\t\t\t\t\thttp://127.0.0.1:*\n\t\t\t\t\thttps://127.0.0.1:*\n\t\t\t\t\twss://127.0.0.1:*\n\t\t\t\t;"
);
logToFile(`[OBSERVER] ${spec.name} CSP patched: injected connect-src for localhost API`);
} else if (html.includes('connect-src') && !html.match(/connect-src[^;]*127\.0\.0\.1/)) {
html = html.replace(
/(connect-src\s[^;]*?)('self'|vscode-remote-resource:|[a-z-]+:)/i,
"$1$2\n\t\t\t\t\thttp://127.0.0.1:*\n\t\t\t\t\thttps://127.0.0.1:*"
);
logToFile(`[OBSERVER] ${spec.name} CSP patched: added localhost to existing connect-src`);
}
// Remove old external script tag if present (legacy, cannot be served)
const extMarkerStart = '<!-- AG SDK [variet-gravity-bridge] -->';
const extMarkerEnd = '<!-- /AG SDK [variet-gravity-bridge] -->';
@@ -254,23 +286,36 @@ function _patchHtmlFiles(scriptDir: string, combinedScript: string, logToFile: (
logToFile(`[OBSERVER] removed external script tag from ${spec.name}`);
}
// Insert or update inline script
// Insert or update inline script — MUST be BEFORE </body> for Electron execution
const inlineMarkerStart = '<!-- AG SDK INLINE [variet-gravity-bridge] -->';
const inlineMarkerEnd = '<!-- /AG SDK INLINE [variet-gravity-bridge] -->';
const inlineBlock = `${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}`;
if (html.includes(inlineMarkerStart)) {
// Remove existing block (may be in wrong position, e.g. after </body>)
const re = new RegExp(
inlineMarkerStart.replace(/[[\]]/g, '\\$&') +
'\\n?' + inlineMarkerStart.replace(/[[\]]/g, '\\$&') +
'[\\s\\S]*?' +
inlineMarkerEnd.replace(/[[\]]/g, '\\$&')
inlineMarkerEnd.replace(/[[\]]/g, '\\$&') + '\\n?'
);
html = html.replace(re,
`${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}`);
logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`);
html = html.replace(re, '');
// Remove duplicate </html> if present (from previous bad insertions)
html = html.replace(/(<\/html>\s*){2,}/gi, '</html>\n');
logToFile(`[OBSERVER] ${spec.name} removed old inline script block`);
}
// Insert BEFORE </body> (not </html>) to ensure Electron executes it
// CRITICAL: Escape $ in inlineBlock to prevent String.replace() special patterns
// ($' = text after match, $& = matched text, etc.) which corrupt the JS code.
const safeInlineBlock = inlineBlock.replace(/\$/g, '$$$$');
if (html.includes('</body>')) {
html = html.replace('</body>',
`\n${safeInlineBlock}\n</body>`);
logToFile(`[OBSERVER] ${spec.name} inline script INSERTED before </body>`);
} else {
// Fallback: insert before </html>
html = html.replace('</html>',
`\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`);
`\n${safeInlineBlock}\n</html>`);
logToFile(`[OBSERVER] ${spec.name} inline script INSERTED before </html> (fallback)`);
}
// SAFETY: Final validation before write
if (html.length < 500 || !html.includes('<!DOCTYPE html>') || !html.includes(spec.requiredMarker)) {

View File

@@ -13,6 +13,8 @@ import * as fs from 'fs';
import * as path from 'path';
import { WSBridgeClient } from './ws-client';
let lastFilePermissionTime = 0;
// ─── Context interface (shared state from extension.ts) ───
export interface HttpBridgeContext {
@@ -23,6 +25,8 @@ export interface HttpBridgeContext {
sessionStalled: boolean;
lastPendingStepIndex: number;
logToFile: (msg: string) => void;
writeChatSnapshot?: (text: string) => void;
getLastWaitingCommand?: () => { cmd: string; desc: string; ts: number };
}
// ─── Module-level state ───
@@ -76,6 +80,11 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
const url = new URL(req.url, `http://127.0.0.1`);
// DIAGNOSTIC: log ALL requests (except noisy polling endpoints)
if (!['/trigger-click', '/deep-inspect-trigger'].includes(url.pathname)) {
ctx.logToFile(`[HTTP-REQ] ${req.method} ${url.pathname}`);
}
// POST /pending — renderer reports a detected approval button
if (req.method === 'POST' && url.pathname === '/pending') {
_handlePending(req, res, ctx);
@@ -106,12 +115,87 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
return;
}
// POST /chat — renderer posts chat snapshots directly
if (req.method === 'POST' && url.pathname === '/chat') {
_handleChatSnapshot(req, res, ctx);
return;
}
// POST /deep-inspect-result — renderer posts inspection results here
if (req.method === 'POST' && url.pathname === '/deep-inspect-result') {
_handleDeepInspectResult(req, res, ctx);
return;
}
// POST /log — renderer relays important diagnostic logs
if (req.method === 'POST' && url.pathname === '/log') {
let logBody = '';
req.setEncoding('utf8');
req.on('data', (c: string) => logBody += c);
req.on('end', () => {
try {
const logData = JSON.parse(logBody);
ctx.logToFile(`[OBSERVER-LOG] ${logData.msg || logBody.substring(0, 500)}`);
} catch { ctx.logToFile(`[OBSERVER-LOG] ${logBody.substring(0, 500)}`); }
res.writeHead(200); res.end('ok');
});
return;
}
if (req.method === 'POST' && url.pathname === '/dump-html') {
let dumpBody = '';
req.setEncoding('utf8');
req.on('data', (c: string) => dumpBody += c);
req.on('end', () => {
try {
// Save indexed dump for history + latest as dump_html.json
let idx = 1;
try { const parsed = JSON.parse(dumpBody); idx = parsed.dumpIndex || idx; } catch {}
fs.writeFileSync(path.join(ctx.bridgePath, `dump_html_${idx}.json`), dumpBody, 'utf-8');
fs.writeFileSync(path.join(ctx.bridgePath, 'dump_html.json'), dumpBody, 'utf-8');
ctx.logToFile(`[HTTP] DOM dump #${idx} saved (${dumpBody.length} bytes)`);
} catch (e) { }
res.writeHead(200); res.end('ok');
});
return;
}
if (req.method === 'POST' && url.pathname === '/test-rpc') {
let rpcBody = '';
req.setEncoding('utf8');
req.on('data', (c: string) => rpcBody += c);
req.on('end', async () => {
try {
const params = JSON.parse(rpcBody);
const result = await sdk.ls.rawRPC(params.method, params.args || {});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(typeof result === 'string' ? result : JSON.stringify(result));
} catch (e: any) {
res.writeHead(500); res.end(e.message);
}
});
return;
}
// GET /status — diagnostic endpoint
if (req.method === 'GET' && url.pathname === '/status') {
const { getStepProbeContext } = require('./step-probe');
const probeCtx = getStepProbeContext();
const status = {
projectName: ctx.projectName,
activeSessionId: probeCtx.activeSessionId || ctx.activeSessionId,
lastPendingStepIndex: probeCtx.lastPendingStepIndex,
sessionStalled: probeCtx.sessionStalled,
wsConnected: ctx.wsBridge?.isConnected() ?? false,
clickTrigger: clickTrigger ? { ...clickTrigger, ageMs: Date.now() - clickTrigger.timestamp } : null,
uptime: Math.round(process.uptime()),
timestamp: new Date().toISOString(),
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(status, null, 2));
return;
}
// GET /ping — health check
if (url.pathname === '/ping') {
res.writeHead(200); res.end('pong');
@@ -182,14 +266,210 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
let body = '';
req.setEncoding('utf8');
req.on('data', (c: string) => body += c);
req.on('end', () => {
try {
const data = JSON.parse(body);
// ── Server-side false positive filter ──
const cmd = (data.command || '').trim();
// Removed valid AI buttons (Accept, Reject, Allow, Deny) which are now structurally protected by the observer script
// ── v12: Command enrichment FIRST — extract actual command from description ──
// Must run before filters so "Always run" with useful description isn't filtered out
const rawCmd = (data.command || '').trim();
// v15: Strip Material icon names from description BEFORE enrichment
// DOM textContent concatenates icon text (e.g. "content_copy") without separators
const ICON_STRIP_RE = /\b(chevron_right|chevron_left|arrow_drop_down|arrow_drop_up|arrow_right|arrow_left|arrow_forward|arrow_back|expand_more|expand_less|more_horiz|more_vert|content_copy|content_paste|check_circle|check|keyboard_arrow_up|keyboard_arrow_down|keyboard_arrow_left|keyboard_arrow_right|slow_motion_video|open_in_new|alternate_email)\b/g;
const rawDesc = (data.description || '').replace(ICON_STRIP_RE, '').replace(/\s{2,}/g, ' ').trim();
const GENERIC_BTN_RE = /^(?:Always\s*)?(?:Run|Allow|Accept|Approve)$/i;
let enrichedCmd = rawCmd;
let enrichedDesc = rawDesc;
// v19: "Always run" auto-approve — MUST run BEFORE any filter can reject it
// Detects from rawCmd OR from buttons array (Observer may detect sibling first)
// v33: Also auto-approve "Accept all" (diff review) and "Accept" buttons
const AUTO_APPROVE_RE = /^(Always\s+run|Accept\s+all|Accept)$/i;
let alwaysRunDetected = AUTO_APPROVE_RE.test(rawCmd);
let alwaysRunBtnIndex = alwaysRunDetected ? 0 : -1;
if (!alwaysRunDetected && Array.isArray(data.buttons)) {
for (let bi = 0; bi < data.buttons.length; bi++) {
if (AUTO_APPROVE_RE.test((data.buttons[bi].text || '').trim())) {
alwaysRunDetected = true;
alwaysRunBtnIndex = bi;
break;
}
}
}
if (alwaysRunDetected) {
// v34: If this is "Accept all" / "Accept", also call agentAcceptAllInFile directly
const isAcceptAll = /^Accept/i.test(rawCmd) || (data.step_type === 'diff_review');
if (isAcceptAll) {
ctx.logToFile(`[HTTP] AUTO-APPROVE Accept all → opening review + agentAcceptAllInFile`);
try {
const vscode = require('vscode');
// v37: Must focus diff review panel BEFORE calling agentAcceptAllInFile
// Without this, the command succeeds but has no effect
(async () => {
try {
await vscode.commands.executeCommand('antigravity.openReviewChanges');
ctx.logToFile(`[HTTP] openReviewChanges OK`);
await new Promise((r: any) => setTimeout(r, 500));
await vscode.commands.executeCommand('antigravity.prioritized.agentAcceptAllInFile');
ctx.logToFile(`[HTTP] ✅ agentAcceptAllInFile SUCCESS`);
} catch (e: any) {
ctx.logToFile(`[HTTP] ❌ agentAcceptAllInFile: ${e.message?.substring(0, 100)}`);
}
})();
} catch (e: any) { ctx.logToFile(`[HTTP] ❌ vscode require failed: ${e.message}`); }
}
// Try enrichment for better Discord display text
let displayCmd = rawCmd;
ctx.logToFile(`[HTTP] AUTO-APPROVE raw: cmd="${rawCmd}" desc="${rawDesc.substring(0, 120)}" buttons=${JSON.stringify((data.buttons || []).map((b: any) => b.text)).substring(0, 200)}`);
if (GENERIC_BTN_RE.test(rawCmd) && rawDesc.length > 3 && rawDesc !== rawCmd) {
const promptMatch = rawDesc.match(/[>»]\s*(.+)/);
if (promptMatch && promptMatch[1].trim().length > 3) {
displayCmd = promptMatch[1].trim().substring(0, 200);
} else {
// v19: Fallback — use longest line from description as command text
const descLines = rawDesc.split(/\n/).map((l: string) => l.trim()).filter((l: string) => l.length > 3);
if (descLines.length > 0) {
descLines.sort((a: string, b: string) => b.length - a.length);
displayCmd = descLines[0].substring(0, 200);
}
}
}
// v20: Final fallback — read latest Step Probe pending file for actual command
if (displayCmd === rawCmd && GENERIC_BTN_RE.test(displayCmd)) {
try {
const pendingDir = path.join(ctx.bridgePath, 'pending');
if (fs.existsSync(pendingDir)) {
const pFiles = fs.readdirSync(pendingDir)
.filter((f: string) => f.endsWith('.json'))
.map((f: string) => ({ name: f, time: fs.statSync(path.join(pendingDir, f)).mtimeMs }))
.sort((a: any, b: any) => b.time - a.time);
if (pFiles.length > 0 && (Date.now() - pFiles[0].time) < 30_000) {
const pData = JSON.parse(fs.readFileSync(path.join(pendingDir, pFiles[0].name), 'utf-8'));
if (pData.command && pData.command.length > 3 && !GENERIC_BTN_RE.test(pData.command)) {
displayCmd = pData.command.substring(0, 200);
ctx.logToFile(`[HTTP] AUTO-APPROVE enriched from pending file: "${displayCmd.substring(0, 80)}"`);
} else if (pData.description && pData.description.length > 5) {
displayCmd = pData.description.substring(0, 200);
ctx.logToFile(`[HTTP] AUTO-APPROVE enriched from pending desc: "${displayCmd.substring(0, 80)}"`);
}
}
}
} catch (e: any) { ctx.logToFile(`[HTTP] AUTO-APPROVE pending lookup error: ${e.message}`); }
}
// v29: Final-final fallback — Step Probe API memory
if (displayCmd === rawCmd && GENERIC_BTN_RE.test(displayCmd) && ctx.getLastWaitingCommand) {
const wc = ctx.getLastWaitingCommand();
if (wc.cmd && wc.cmd.length > 3 && !GENERIC_BTN_RE.test(wc.cmd) && (Date.now() - wc.ts) < 30_000) {
displayCmd = wc.desc && wc.desc.length > wc.cmd.length ? wc.desc.substring(0, 200) : wc.cmd.substring(0, 200);
ctx.logToFile(`[HTTP] AUTO-APPROVE enriched from step-probe memory: "${displayCmd.substring(0, 80)}"`);
}
}
const rid = data.request_id || Date.now().toString();
ctx.logToFile(`[HTTP] AUTO-APPROVE "Always run" (btnIdx=${alwaysRunBtnIndex}): cmd="${displayCmd.substring(0, 80)}"`);
// Write response file IMMEDIATELY so observer clicks the button with zero delay
const responseDir = path.join(ctx.bridgePath, 'response');
if (!fs.existsSync(responseDir)) {
fs.mkdirSync(responseDir, { recursive: true });
}
const respPayload = {
request_id: rid,
approved: true,
button_index: alwaysRunBtnIndex >= 0 ? alwaysRunBtnIndex : 0,
step_type: data.step_type || 'command',
project_name: ctx.projectName,
_from_ws: true, // v38: prevent processResponseFile from consuming before Observer polls
_auto_approve_ttl: Date.now() + 60_000, // auto-expire after 60s
};
fs.writeFileSync(
path.join(responseDir, `${rid}.json`),
JSON.stringify(respPayload),
'utf-8'
);
// v29: Respond to Observer immediately (don't block button click)
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, request_id: rid, auto_approved: true }));
// v29: Discord notification — if displayCmd is generic, poll Step Probe for real command
const isGenericDisplay = GENERIC_BTN_RE.test(displayCmd);
const sendDiscord = (finalCmd: string, finalDesc: string) => {
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
ctx.wsBridge.sendPending({
request_id: rid,
command: finalCmd,
description: finalDesc,
step_type: data.step_type || 'command',
status: 'auto_approved',
buttons: data.buttons,
project_name: ctx.projectName,
});
}
};
if (isGenericDisplay && ctx.getLastWaitingCommand) {
// Poll Step Probe memory for up to 6s (API polls every 5s)
let pollAttempt = 0;
const maxAttempts = 30; // 30 * 200ms = 6s
const pollTimer = setInterval(() => {
pollAttempt++;
const wc = ctx.getLastWaitingCommand!();
if (wc.cmd && wc.cmd.length > 3 && !GENERIC_BTN_RE.test(wc.cmd) && (Date.now() - wc.ts) < 15_000) {
clearInterval(pollTimer);
const enrichedCmd = wc.desc && wc.desc.length > wc.cmd.length ? wc.desc.substring(0, 200) : wc.cmd.substring(0, 200);
ctx.logToFile(`[HTTP] AUTO-APPROVE enriched (delayed ${pollAttempt * 200}ms): "${enrichedCmd.substring(0, 80)}"`);
sendDiscord(enrichedCmd, `[${rawCmd}] ${enrichedCmd}`);
} else if (pollAttempt >= maxAttempts) {
clearInterval(pollTimer);
ctx.logToFile(`[HTTP] AUTO-APPROVE no enrichment after ${maxAttempts * 200}ms — sending generic`);
sendDiscord(displayCmd, rawDesc ? `[${rawCmd}] ${rawDesc}` : rawCmd);
}
}, 200);
} else {
// Already enriched or no Step Probe — send immediately
sendDiscord(displayCmd, rawDesc ? `[${rawCmd}] ${rawDesc}` : rawCmd);
}
return;
}
if (GENERIC_BTN_RE.test(rawCmd) && rawDesc.length > 10 && rawDesc !== rawCmd) {
// Extract the actual command from description (often includes terminal prompt)
// Pattern: "…\project_name > actual_command"
const promptMatch = rawDesc.match(/[>»]\s*(.+)/);
if (promptMatch && promptMatch[1].trim().length > 3) {
const extracted = promptMatch[1].trim();
// v16: Validate extracted text is not just a prompt fragment or path
const PROMPT_ONLY_RE = /^.*[>»$#]\s*$/;
const TERMINAL_PROMPT_RE = /^[^\n]*\\[^\\>]+\s*[>»]\s*$/;
if (!PROMPT_ONLY_RE.test(extracted) && !TERMINAL_PROMPT_RE.test(extracted)) {
enrichedCmd = extracted.substring(0, 200);
enrichedDesc = `[${rawCmd}] ${rawDesc}`;
ctx.logToFile(`[HTTP] command enriched: "${rawCmd}" → "${enrichedCmd.substring(0, 60)}"`);
} else {
// Prompt-only extraction — filter
ctx.logToFile(`[HTTP] enrichment skipped (prompt-only): "${rawCmd}" desc="${rawDesc.substring(0, 60)}"`);
ctx.logToFile(`[HTTP] filtered generic+prompt-only: "${rawCmd}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'generic_btn_prompt_only' }));
return;
}
} else {
// v16: No prompt marker (> » $ #) found in description — this is terminal OUTPUT, not a command
// Observer extracted stdout text from code block (e.g. "No extension.log found", "Log found: ...")
ctx.logToFile(`[HTTP] filtered terminal output (no prompt marker): "${rawDesc.substring(0, 60)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'terminal_output' }));
return;
}
} else if (GENERIC_BTN_RE.test(rawCmd) && (rawDesc.length <= 10 || rawDesc === rawCmd)) {
// v13: Generic button with no useful description (observer prompt-only context)
ctx.logToFile(`[HTTP] filtered generic button no-context: "${rawCmd}" desc="${rawDesc.substring(0, 30)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'generic_btn_no_context' }));
return;
}
// ── Server-side false positive filter (uses enriched cmd) ──
const cmd = enrichedCmd;
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Dismiss)$/i;
if (FALSE_POSITIVE_RE.test(cmd)) {
ctx.logToFile(`[HTTP] filtered false positive: "${cmd}"`);
@@ -197,53 +477,69 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
res.end(JSON.stringify({ ok: false, filtered: true }));
return;
}
// v14: Server-side junk content filter — CSS, source code, icon glue
// This is the last line of defense regardless of observer version
const JUNK_CONTENT_RE = /(!important|::selection|background-color:|var\(--|font-size:|border-[a-z]+:|padding:|margin:|display:\s|===|!==|\|\||\.\btest\(|\.\bmatch\(|\.\breplace\(|_RE[.\s]|\brawDesc\b|\brawCmd\b|\benrichedCmd\b|\bquerySelector\b|\.code-block|\.code-line|\.line-content|\{\s*--|integration\.build)/;
// v15: ICON_GLUE_RE now also catches standalone icon names (no trailing [a-zA-Z] required)
const ICON_GLUE_RE = /\b(alternate_email|content_copy|content_paste|check_circle|chevron_right|chevron_left|keyboard_arrow|arrow_drop_down|arrow_drop_up|more_horiz|more_vert|expand_more|expand_less)\b/;
// v15: Terminal prompt pattern — catches bare prompts like "…\project >" or "PS C:\path>"
const BARE_PROMPT_RE = /^[^\n]{0,60}[>»$#]\s*$/;
if (JUNK_CONTENT_RE.test(cmd) || ICON_GLUE_RE.test(cmd)) {
ctx.logToFile(`[HTTP] filtered junk content: "${cmd.substring(0, 80)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'junk_content' }));
return;
}
// v15: Final bare prompt filter — catches any enriched cmd that's just a terminal prompt
if (BARE_PROMPT_RE.test(cmd) && cmd.length < 80) {
ctx.logToFile(`[HTTP] filtered bare prompt: "${cmd.substring(0, 80)}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'bare_prompt' }));
return;
}
// "Run" button → step_probe handles these with full command detail
// Only let through if session is stalled AND step_probe hasn't created a pending yet
if (/^Run\b/i.test(cmd)) {
if (!ctx.sessionStalled || ctx.lastPendingStepIndex >= 0) {
ctx.logToFile(`[HTTP] filtered "Run" — ${!ctx.sessionStalled ? 'not stalled' : 'step_probe pending exists'}`);
// Only filter when step_probe IS actively tracking AND cmd is still generic button text
if (/^(?:Always\s*)?Run\b/i.test(cmd)) {
if (ctx.activeSessionId && (!ctx.sessionStalled || ctx.lastPendingStepIndex >= 0)) {
ctx.logToFile(`[HTTP] filtered "Run" — ${!ctx.sessionStalled ? 'not stalled' : 'step_probe pending exists'} (session=${ctx.activeSessionId.substring(0, 8)})`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true }));
return;
}
// v9: When step_probe has no active session, let DOM observer handle approval
ctx.logToFile(`[HTTP] allowing "Run" — step_probe has no active session`);
}
const rid = data.request_id || Date.now().toString();
// Write pending file for Discord bot
const pendingDir = path.join(ctx.bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
const pending: Record<string, any> = {
...data,
request_id: rid,
command: enrichedCmd,
description: enrichedDesc,
conversation_id: ctx.activeSessionId || '',
timestamp: Date.now() / 1000,
status: 'pending',
project_name: ctx.projectName,
auto_detected: true,
source: 'dom_observer',
step_type: data.step_type,
buttons: data.buttons,
step_index: ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : undefined,
};
// v19: "Always run" auto-approve was already handled above (before filter chain)
// No need for duplicate check here.
// File permission: inject multi-choice buttons
const cmdLower = (data.command || '').toLowerCase();
const cmdLower = enrichedCmd.toLowerCase();
if (cmdLower.includes('allow') && !pending.buttons) {
// Dedup: skip if another file_permission pending was created within 10s
const nowMs = Date.now();
try {
const existingFiles = fs.readdirSync(pendingDir).filter((f: string) => f.endsWith('.json'));
for (const ef of existingFiles) {
const existing = JSON.parse(fs.readFileSync(path.join(pendingDir, ef), 'utf-8'));
if (existing.step_type === 'file_permission' && existing.status === 'pending'
&& existing.project_name === ctx.projectName) {
const age = nowMs - (existing.timestamp * 1000);
if (age < 10_000 && age >= 0) {
ctx.logToFile(`[HTTP] filtered duplicate file_permission (${age}ms old): ${ef}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' }));
return;
}
}
}
} catch { }
if (nowMs - lastFilePermissionTime < 10000) {
ctx.logToFile(`[HTTP] filtered duplicate file_permission (${nowMs - lastFilePermissionTime}ms old)`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' }));
return;
}
lastFilePermissionTime = nowMs;
pending.buttons = [
{ text: 'Allow Once', index: 0 },
@@ -252,11 +548,10 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
];
pending.step_type = 'file_permission';
// Clean description: remove button labels from text
const rawDesc = (data.description || data.command || '').replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim();
pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`;
const cleanDesc = enrichedDesc.replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim();
pending.command = `파일 접근 권한${cleanDesc ? ': ' + cleanDesc : ''}`;
}
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
// WS dual-write
// WS dispatch
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
ctx.wsBridge.sendPending({
request_id: rid,
@@ -269,7 +564,11 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
});
ctx.logToFile(`[HTTP-WS] pending sent via WS: ${rid}`);
}
ctx.logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
ctx.logToFile(`[HTTP] pending created: ${rid} cmd="${pending.command || data.command}" btns=${(pending.buttons || data.buttons || []).length} ctx="${(pending.description || data.description || '').substring(0, 80)}"`);
if (data._debug_trail) {
ctx.logToFile(`[HTTP-DIAG] trail: ${data._debug_trail.substring(0, 500)}`);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, request_id: rid }));
} catch (e: any) {
@@ -345,16 +644,15 @@ function _handleDeepInspectTrigger(res: any) {
function _handleDeepInspectResult(req: any, res: any, ctx: HttpBridgeContext) {
let body = '';
req.setEncoding('utf8');
req.on('data', (c: string) => body += c);
req.on('end', () => {
try {
const data = JSON.parse(body);
deepInspectResult = data;
ctx.logToFile(`[HTTP] deep-inspect result received (${body.length} bytes)`);
// Write to file for reference
const inspectFile = path.join(ctx.bridgePath, 'deep-inspect-result.json');
fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2));
// Notify waiters
const waiters = [...deepInspectWaiters];
deepInspectWaiters = [];
waiters.forEach(w => w(data));
@@ -366,3 +664,25 @@ function _handleDeepInspectResult(req: any, res: any, ctx: HttpBridgeContext) {
}
});
}
function _handleChatSnapshot(req: any, res: any, ctx: HttpBridgeContext) {
let body = '';
req.setEncoding('utf8');
req.on('data', (c: string) => body += c);
req.on('end', () => {
try {
const data = JSON.parse(body);
if (data.text && typeof ctx.writeChatSnapshot === 'function') {
const isUser = data.role === 'user';
const prefix = isUser ? '🧑‍💻 **[DOM 추출] 사용자 요청**' : '💬 **[DOM 추출] AI 응답**';
ctx.writeChatSnapshot(`${prefix}\n\n${data.text}`);
ctx.logToFile(`[HTTP] chat snapshot written (${data.text.length} chars, role: ${data.role || 'bot'})`);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (e: any) {
ctx.logToFile(`[HTTP] chat parse error: ${e.message}`);
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
}
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import * as path from 'path';
import { WSBridgeClient } from './ws-client';
import { extractPlannerText, extractToolCommand, extractToolDescription } from './step-utils';
import { initApprovalHandler, setupResponseWatcher } from './approval-handler';
import { BrainWatcher } from './brain-watcher';
// Re-export from approval-handler for backward compatibility with extension.ts imports
export { handleDiffReviewResponse, tryApprovalStrategies } from './approval-handler';
@@ -35,9 +36,13 @@ export interface BridgeContext {
let ctx: BridgeContext;
let responseWatcher: fs.FSWatcher | null = null;
let brainWatcher: BrainWatcher | null = null;
let activeTrajectoryId = '';
const recentPendingSteps = new Map<string, number>();
const PENDING_MEMORY_TTL_MS = 60_000;
const PENDING_MEMORY_TTL_MS = 30_000;
// v29: Last WAITING command from API — used by http-bridge for Always run enrichment
let lastWaitingCommand = { cmd: '', desc: '', ts: 0 };
// generateApprovalObserverScript → extracted to ./observer-script.ts
const lastSnapshotText = new Map<string, string>();
@@ -77,6 +82,14 @@ export function getStepProbeContext(): { activeSessionId: string; sessionStalled
};
}
/**
* v29: Get last WAITING command from Step Probe API.
* Used by http-bridge as fallback when Observer's extractContext returns generic "Always run".
*/
export function getLastWaitingCommand(): { cmd: string; desc: string; ts: number } {
return { ...lastWaitingCommand };
}
/**
* Reset pending state after successful approval.
* Called after WS response triggers approval in extension.ts.
@@ -102,6 +115,36 @@ export function resetPendingStateForReconnect(): void {
// handleDiffReviewResponse → moved to ./approval-handler.ts
export function formatStepProbeCommand(toolName: string, actualIndex: number, stepType: string, toolCall: any): { cmd: string, desc: string, isSafe: boolean } {
let cmd = toolName;
let desc = `Step #${actualIndex} (${stepType.replace('CORTEX_STEP_TYPE_', '')})`;
let isSafe = false;
if (toolCall?.argumentsJson) {
try {
const args = JSON.parse(toolCall.argumentsJson);
isSafe = args.SafeToAutoRun === true;
if (args.CommandLine) {
cmd = toolName;
desc = args.CommandLine;
} else if (args.TargetFile) {
cmd = `${toolName}: ${args.TargetFile.split(/[\\/]/).pop()}`;
if (args.CodeContent) desc = args.CodeContent;
else if (args.ReplacementChunks) desc = JSON.stringify(args.ReplacementChunks, null, 2);
else desc = toolCall.argumentsJson;
} else {
const val = args.DirectoryPath || args.SearchPath || args.AbsolutePath || args.Url || args.Query || args.Prompt || Object.values(args).find((v: any) => typeof v === 'string' && v.length > 2);
if (val) {
cmd = toolName;
desc = String(val);
} else {
cmd = `${toolName}: ${Object.keys(args).join(', ')}`;
}
}
} catch { }
}
return { cmd, desc, isSafe };
}
/**
* Write a registration file for the Bot to discover session → project mapping.
* Called automatically on first step event per session.
@@ -171,6 +214,7 @@ function setupMonitor() {
let pendingModifiedFilePaths: string[] = []; // full paths for diff review
let pendingEditStepIndices: number[] = []; // step indices for AcknowledgeCascadeCodeEdit
let lastResponseCaptureStep = -1; // dedup: don't capture same response twice
let lastLSFixPoll = 0; // v15: track last fixLSConnection() attempt for periodic retry
setInterval(async () => {
pollCount++;
@@ -178,13 +222,127 @@ function setupMonitor() {
ctx.logToFile(`[POLL#${pollCount}] alive`);
}
try {
// Fix (v0.5.14): Reverted 100-limit DoS but restored descending: true with a safe limit of 30
const allTraj = await ctx.sdk.ls.rawRPC('GetAllCascadeTrajectories', { limit: 30, descending: true });
if (!allTraj?.trajectorySummaries) {
if (pollCount <= 3) ctx.logToFile('[POLL] no trajectorySummaries');
// Fix (v0.5.15): Bypass 10-Item Hard Limit of GetAllCascadeTrajectories.
// We fetch GetDiagnostics to discover ALL recent sessions regardless of pagination.
let allTraj: any = { trajectorySummaries: {} };
try {
// Primary: Try fetching 100 trajectories (backend might ignore this and give 10)
const apiResult = await ctx.sdk.ls.rawRPC('GetAllCascadeTrajectories', { limit: 100, maxResults: 100, pageSize: 100, page_size: 100, descending: true });
if (apiResult?.trajectorySummaries) {
allTraj.trajectorySummaries = { ...apiResult.trajectorySummaries };
}
} catch (e: any) {
if (pollCount <= 3) ctx.logToFile(`[POLL] GetAllCascadeTrajectories failed: ${e.message}`);
}
try {
// Fallback / Augment: GetDiagnostics provides ALL recent sessions bypassing the hard limit
const diagRaw = await ctx.sdk.ls.rawRPC('GetDiagnostics', {});
const diag = typeof diagRaw === 'string' ? JSON.parse(diagRaw) : diagRaw;
const recent = diag.recentTrajectories || [];
for (const entry of recent) {
const sid = entry.googleAgentId;
if (sid && !allTraj.trajectorySummaries[sid]) {
allTraj.trajectorySummaries[sid] = {
status: entry.status || '',
stepCount: entry.lastStepIndex || 0,
lastModifiedTime: entry.lastModifiedTime || '',
summary: entry.summary || 'Untitled',
trajectoryMetadata: entry.trajectoryMetadata || {
workspaces: [{ workspaceFolderAbsoluteUri: entry.workspaceUri || '' }]
}
};
}
}
} catch (e: any) {
if (pollCount <= 3) ctx.logToFile(`[POLL] GetDiagnostics fallback failed: ${e.message}`);
}
try {
// Fallback 2: The backend API hard-limits to 10 trajectories, often dropping the newest active session.
// We physically scan the .gemini/antigravity/brain/ directory to guarantee we track the latest ones.
const brainDir = path.resolve(ctx.bridgePath, '..', 'brain');
if (fs.existsSync(brainDir)) {
const brainDirs = fs.readdirSync(brainDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory() && dirent.name.length === 36)
.map(dirent => {
const stats = fs.statSync(path.join(brainDir, dirent.name));
return { name: dirent.name, time: stats.mtimeMs };
})
.sort((a, b) => b.time - a.time);
for (let i = 0; i < Math.min(3, brainDirs.length); i++) {
const sid = brainDirs[i].name;
if (!allTraj.trajectorySummaries[sid]) {
try {
const stResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', { cascadeId: sid, stepOffset: 0 });
if (stResp?.steps) {
const len = stResp.steps.length;
const ls = len > 0 ? stResp.steps[len - 1] : null;
allTraj.trajectorySummaries[sid] = {
status: ls?.status || 'CASCADE_RUN_STATUS_RUNNING', // Assume running if we have to force it
stepCount: len,
lastModifiedTime: new Date(brainDirs[i].time).toISOString(),
summary: 'Discovered via brain/ scan',
trajectoryMetadata: { workspaces: [{ workspaceFolderAbsoluteUri: ctx.workspaceUri.replace(/\\/g, '/') }] }
};
}
} catch (e: any) {
if (pollCount <= 30) ctx.logToFile(`[POLL] Fallback 2 error for sid=${sid}: ${e.message}`);
// If trajectory explicitly does not exist, it might be an Antigravity or non-Cascade session directory.
// We MUST register it so activeSessionId tracks it properly.
// To prevent old ghost sessions from hijacking, we only mark it RUNNING if it was recently modified.
const ageMs = Date.now() - brainDirs[i].time;
const isFresh = ageMs < 120_000; // updated within 2 mins
allTraj.trajectorySummaries[sid] = {
status: isFresh ? 'CASCADE_RUN_STATUS_RUNNING' : 'CASCADE_RUN_STATUS_IDLE',
stepCount: 1, // Assume progressing to allow loop delta>0 trigger
lastModifiedTime: new Date(brainDirs[i].time).toISOString(),
summary: 'Discovered via brain/ scan (Antigravity Native)',
trajectoryMetadata: { workspaces: [{ workspaceFolderAbsoluteUri: ctx.workspaceUri.replace(/\\/g, '/') }] }
};
}
}
}
}
} catch (e: any) {
if (pollCount <= 3) ctx.logToFile(`[POLL] brainDir scan fallback failed: ${e.message}`);
}
if (!allTraj?.trajectorySummaries || Object.keys(allTraj.trajectorySummaries).length === 0) {
if (pollCount <= 3) ctx.logToFile('[POLL] no trajectorySummaries found from any source');
return;
}
// ── v15: Stale LS detection — periodic fixLSConnection() ──
// If the best session's lastModifiedTime hasn't changed for >5min,
// the extension might be connected to a stale LS. Retry fixLSConnection().
if (ctx.fixLSConnection && (pollCount - lastLSFixPoll) >= 60) {
// Check if current best session has stale data
const bestEntries = Object.entries(allTraj.trajectorySummaries) as [string, any][];
const hasStaleData = bestEntries.every(([, data]) => {
const mod = data.lastModifiedTime || '';
if (!mod) return true;
const modDate = new Date(mod);
return (Date.now() - modDate.getTime()) > 300_000; // 5 min
});
if (hasStaleData) {
lastLSFixPoll = pollCount;
ctx.logToFile(`[LS-AUTO-FIX] All sessions stale (>5min) — attempting fixLSConnection()`);
try {
const fixed = await ctx.fixLSConnection();
if (fixed) {
ctx.logToFile(`[LS-AUTO-FIX] ✅ Reconnected to new LS — next poll should have fresh data`);
} else {
ctx.logToFile(`[LS-AUTO-FIX] No better LS found`);
}
} catch (e: any) {
ctx.logToFile(`[LS-AUTO-FIX] error: ${e.message?.substring(0, 100)}`);
}
}
}
// ── Filter to sessions owned by THIS window ──
// PRIMARY: Use trajectoryMetadata.workspaces URI to match sessions to this workspace.
// FALLBACK: Use bridge/register/ files for sessions without metadata.
@@ -266,11 +424,14 @@ function setupMonitor() {
// Session changed?
if (bestSessionId !== ctx.activeSessionId) {
ctx.activeSessionId = bestSessionId;
if (brainWatcher) {
brainWatcher.updateSession(bestSessionId);
}
activeTrajectoryId = (bestSession as any).trajectoryId || '';
activeSessionTitle = currentTitle;
lastKnownStepCount = currentCount;
lastNotifyStepIndex = bestSession.latestNotifyUserStep?.stepIndex ?? -1;
lastTaskStepIndex = bestSession.latestTaskBoundaryStep?.stepIndex ?? -1;
lastNotifyStepIndex = -1;
lastTaskStepIndex = -1;
lastUserInputStepIdx = bestSession.lastUserInputStepIndex ?? -1;
lastResponseCaptureStep = currentCount; // don't re-relay old responses
ctx.lastPendingStepIndex = -1;
@@ -301,13 +462,86 @@ function setupMonitor() {
const delta = currentCount - lastKnownStepCount;
lastKnownStepCount = currentCount;
if (delta > 0) {
// ── v15: Heartbeat probe — detect step changes when summary API is stale ──
// GetAllCascadeTrajectories can return frozen stepCount/lastModifiedTime,
// v20: Heartbeat every 3 polls (~15s) — AG API never reports delta for active sessions
if (delta === 0 && ctx.sdk && ctx.activeSessionId && pollCount % 3 === 0) {
try {
const hbOffset = Math.max(0, currentCount - 5);
const hbResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: ctx.activeSessionId,
stepOffset: hbOffset,
verbosity: 1, // need content for capture
});
if (hbResp?.steps?.length > 0) {
const realStepCount = hbOffset + hbResp.steps.length;
if (realStepCount > lastKnownStepCount) {
ctx.logToFile(`[HEARTBEAT] stale! reported=${lastKnownStepCount} real=${realStepCount}`);
// Process new steps for RT-CAPTURE
for (let hi = 0; hi < hbResp.steps.length; hi++) {
const hs = hbResp.steps[hi];
const hIdx = hbOffset + hi;
if (hIdx <= lastResponseCaptureStep) continue;
const hType = hs?.type || '';
// Capture AI responses
if (hType.includes('PLANNER_RESPONSE') && hs?.status?.includes('DONE')) {
let text = extractPlannerText(hs) || '';
if (text.length > 10) {
lastResponseCaptureStep = hIdx;
ctx.logToFile(`[HB-CAPTURE] AI step=${hIdx} (${text.length} chars)`);
const truncated = text.length > 3500
? text.substring(0, 3500) + '\n\n_(이하 생략)_'
: text;
ctx.writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
}
}
// Capture user messages
if (hType.includes('USER_INPUT') && hIdx > lastUserInputStepIdx) {
lastUserInputStepIdx = hIdx;
const ui = hs?.userInput;
const umText = (ui?.userResponse || ui?.text || '').trim();
if (umText.length > 2) {
const sentAt = ctx.recentDiscordSentTexts.get(umText);
if (!sentAt || (Date.now() - sentAt) > 60_000) {
const dedupKey = `user_msg:${umText}`;
const lastRelayed = lastSnapshotText.get(dedupKey);
if (!lastRelayed || (Date.now() - Number(lastRelayed)) > 30_000) {
lastSnapshotText.set(dedupKey, String(Date.now()));
const clientType = ui?.clientType || '';
const source = clientType.includes('IDE') ? 'AG 직접 입력' : 'API';
const truncated = umText.length > 800
? umText.substring(0, 800) + '\n\n_(이하 생략)_'
: umText;
ctx.writeChatSnapshot(`👤 **사용자 (${source})**\n\n${truncated}`);
ctx.logToFile(`[HB-CAPTURE] User step=${hIdx} (${umText.length} chars)`);
}
}
}
}
}
lastKnownStepCount = realStepCount;
} else if (pollCount % 30 === 0) {
ctx.logToFile(`[HEARTBEAT] ok offset=${hbOffset} got=${hbResp.steps.length} real=${realStepCount} known=${lastKnownStepCount}`);
}
} else if (pollCount % 30 === 0) {
ctx.logToFile(`[HEARTBEAT] no steps returned for offset=${hbOffset}`);
}
} catch (hbErr: any) {
if (pollCount % 10 === 0) ctx.logToFile(`[HEARTBEAT] probe error: ${hbErr.message?.substring(0, 100)}`);
}
}
// Recalculate delta after heartbeat correction
const effectiveDelta = lastKnownStepCount - (currentCount > 0 ? currentCount : lastKnownStepCount);
const hasDelta = delta > 0 || effectiveDelta > 0;
if (hasDelta) {
console.log(`Gravity Bridge: [POLL#${pollCount}] +${delta} steps (${currentCount}) "${currentTitle}"`);
// Real-time response capture: fetch latest steps on every delta>0
if (isRunning && currentCount > lastResponseCaptureStep && ctx.sdk) {
// Real-time response capture: fetch latest steps on every delta>0 or heartbeat
if (lastKnownStepCount > lastResponseCaptureStep && ctx.sdk) {
try {
const rtOffset = Math.max(0, currentCount - 3);
const rtOffset = Math.max(0, lastKnownStepCount - 3);
const rtResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: bestSessionId,
stepOffset: rtOffset,
@@ -336,17 +570,81 @@ function setupMonitor() {
} catch { }
}
if (sType.includes('PLANNER_RESPONSE') && s?.status?.includes('DONE')) {
const pr = s?.plannerResponse;
if (pr) {
let text = pr.modifiedResponse || pr.rawText || pr.text || '';
if (text.length > 10) {
lastResponseCaptureStep = actualIdx;
ctx.logToFile(`[RT-CAPTURE] step=${actualIdx} (${text.length} chars)`);
const truncated = text.length > 3500
? text.substring(0, 3500) + '\n\n_(이하 생략)_'
: text;
ctx.writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;
let text = extractPlannerText(s) || '';
if (text.length > 10) {
lastResponseCaptureStep = actualIdx;
ctx.logToFile(`[RT-CAPTURE] step=${actualIdx} (${text.length} chars)`);
const truncated = text.length > 3500
? text.substring(0, 3500) + '\n\n_(이하 생략)_'
: text;
ctx.writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
}
}
// v20: Capture USER_INPUT steps for user message relay
if (sType.includes('USER_INPUT') && actualIdx > lastUserInputStepIdx) {
lastUserInputStepIdx = actualIdx;
const ui = s?.userInput;
const umText = (ui?.userResponse || ui?.text || s?.plannerResponse?.textContent || '').trim();
const clientType = ui?.clientType || '';
const isFromIDE = clientType.includes('IDE');
ctx.logToFile(`[RT-USER-MSG] step=${actualIdx} client=${clientType} text=${umText.substring(0, 100)}`);
if (umText.length > 2) {
// Skip echo: if text was recently sent from Discord
const sentAt = ctx.recentDiscordSentTexts.get(umText);
if (sentAt && (Date.now() - sentAt) < 60_000) {
ctx.recentDiscordSentTexts.delete(umText);
ctx.logToFile(`[RT-USER-MSG] skipped echo relay (Discord origin)`);
} else {
// Content-based dedup
const dedupKey = `user_msg:${umText}`;
const lastRelayed = lastSnapshotText.get(dedupKey);
if (!lastRelayed || (Date.now() - Number(lastRelayed)) > 30_000) {
lastSnapshotText.set(dedupKey, String(Date.now()));
const truncated = umText.length > 800
? umText.substring(0, 800) + '\n\n_(이하 생략)_'
: umText;
const source = isFromIDE ? 'AG 직접 입력' : 'API';
ctx.writeChatSnapshot(`👤 **사용자 (${source})**\n\n${truncated}`);
ctx.logToFile(`[RT-USER-MSG] relayed ${umText.length} chars`);
}
}
}
}
if (s?.status === 'CORTEX_STEP_STATUS_WAITING') {
const toolCall = s?.metadata?.toolCall;
const toolName = toolCall?.name || (sType || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();
const { cmd: command, desc: description, isSafe: isSafeToAutoRun } = formatStepProbeCommand(toolName, actualIdx, sType || '', toolCall);
ctx.logToFile(`[STEP-PROBE] ★ WAITING (RT)! step=${actualIdx} type=${sType} cmd='${command}'`);
// v29: Save for http-bridge enrichment
lastWaitingCommand = { cmd: command, desc: description, ts: Date.now() };
if (actualIdx !== ctx.lastPendingStepIndex) {
ctx.stallProbed = true;
if (actualIdx > ctx.lastPendingStepIndex) {
ctx.lastPendingStepIndex = actualIdx;
}
lastPendingTime = Date.now();
ctx.sawRunningAfterPending = false;
if (ctx.projectName !== 'default') {
const toolCat = ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission'
: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit'
: ['browser_subagent', 'open_browser_url'].includes(toolName) ? 'browser_subagent'
: toolName;
writePendingApproval({
conversation_id: ctx.activeSessionId,
command,
description,
step_type: toolCat,
step_index: actualIdx,
source: 'step_probe_rt',
safe_to_auto_run: isSafeToAutoRun,
});
}
}
}
@@ -438,6 +736,7 @@ function setupMonitor() {
// lastModifiedTime is still changing = AI is thinking, NOT approval
consecutiveIdleCount = 0; // Reset!
ctx.stallProbed = false;
ctx.sessionStalled = false; // FIX: also reset stalled flag on modTime change
if (pollCount <= 10 || pollCount % 12 === 0) {
ctx.logToFile(`[THINK] step=${currentCount} modTime changing → not stall`);
}
@@ -481,20 +780,8 @@ function setupMonitor() {
if (oStep?.status === 'CORTEX_STEP_STATUS_WAITING') {
const toolCall = oStep?.metadata?.toolCall;
const toolName = toolCall?.name || (oStep.type || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();
let command = toolName;
if (toolCall?.argumentsJson) {
try {
const args = JSON.parse(toolCall.argumentsJson);
if (args.CommandLine) command = `${toolName}: ${args.CommandLine.substring(0, 1500)}`;
else if (args.TargetFile) command = `${toolName}: ${args.TargetFile}`;
else {
// Show first meaningful value (path, query, etc.)
const val = args.DirectoryPath || args.SearchPath || args.AbsolutePath || args.Url || args.Query || args.Prompt || Object.values(args).find((v: any) => typeof v === 'string' && v.length > 2);
command = val ? `${toolName}: ${String(val).substring(0, 500)}` : `${toolName}: ${Object.keys(args).join(', ')}`;
}
} catch { command = toolName; }
}
const actualIndex = offset + osi;
const { cmd: command, desc: detailedDescription, isSafe: isSafeToAutoRun } = formatStepProbeCommand(toolName, actualIndex, oStep.type || '', toolCall);
ctx.logToFile(`[STEP-PROBE] ★ WAITING (via offset)! step=${actualIndex} type=${oStep.type} cmd='${command}'`);
if (actualIndex !== ctx.lastPendingStepIndex) {
ctx.stallProbed = true;
@@ -512,14 +799,28 @@ function setupMonitor() {
writePendingApproval({
conversation_id: ctx.activeSessionId,
command,
description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
description: detailedDescription,
step_type: ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission'
: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit'
: ['browser_subagent', 'open_browser_url'].includes(toolName) ? 'browser_subagent'
: toolName,
step_index: actualIndex,
source: 'step_probe_offset',
safe_to_auto_run: isSafeToAutoRun,
});
// v35: Auto-accept code edits (offset path)
if (['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName)) {
ctx.logToFile(`[STEP-PROBE] v35: code_edit (offset) → auto-accepting in 500ms`);
setTimeout(async () => {
try {
const vscode = require('vscode');
await vscode.commands.executeCommand('antigravity.prioritized.agentAcceptAllInFile');
ctx.logToFile(`[STEP-PROBE] ✅ agentAcceptAllInFile (offset) SUCCESS`);
} catch (e: any) {
ctx.logToFile(`[STEP-PROBE] ❌ agentAcceptAllInFile (offset): ${e.message?.substring(0, 100)}`);
}
}, 500);
}
}
}
// NOTE: no break — process ALL parallel WAITING steps
@@ -543,53 +844,53 @@ function setupMonitor() {
// Extract command from metadata.toolCall or direct fields
const toolCall = step?.metadata?.toolCall;
const toolName = toolCall?.name || stepType.replace('CORTEX_STEP_TYPE_', '').toLowerCase();
let command = toolName;
// Parse argumentsJson for command details
if (toolCall?.argumentsJson) {
try {
const args = JSON.parse(toolCall.argumentsJson);
if (args.CommandLine) {
command = `${toolName}: ${args.CommandLine.substring(0, 1500)}`;
} else if (args.TargetFile) {
command = `${toolName}: ${args.TargetFile}`;
} else {
const val = args.DirectoryPath || args.SearchPath || args.AbsolutePath || args.Url || args.Query || args.Prompt || Object.values(args).find((v: any) => typeof v === 'string' && v.length > 2);
command = val ? `${toolName}: ${String(val).substring(0, 500)}` : `${toolName}: ${Object.keys(args).join(', ')}`;
}
} catch { command = toolName; }
}
const { cmd: command, desc: description, isSafe: isSafeToAutoRun } = formatStepProbeCommand(toolName, si, stepType, toolCall);
const description = `Step #${si} (${stepType.replace('CORTEX_STEP_TYPE_', '')})`;
ctx.logToFile(`[STEP-PROBE] ★ WAITING! step=${si} type=${stepType} cmd='${command}'`);
if (si !== ctx.lastPendingStepIndex) {
ctx.stallProbed = true; // found WAITING — stop retrying
// Track highest step index for auto-resolve
if (si > ctx.lastPendingStepIndex) {
ctx.lastPendingStepIndex = si;
if (si !== ctx.lastPendingStepIndex) {
ctx.stallProbed = true; // found WAITING — stop retrying
// Track highest step index for auto-resolve
if (si > ctx.lastPendingStepIndex) {
ctx.lastPendingStepIndex = si;
}
lastPendingTime = Date.now();
ctx.sawRunningAfterPending = false;
// Skip pending for workspace-less AG windows (project=default)
if (ctx.projectName === 'default') {
ctx.logToFile(`[STEP-PROBE] skip pending: ctx.projectName=default (no workspace)`);
} else {
// Always write pending — Bot decides auto-approve (prevents double-fire)
writePendingApproval({
conversation_id: ctx.activeSessionId,
command,
description,
step_type: ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission'
: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit'
: ['browser_subagent', 'open_browser_url'].includes(toolName) ? 'browser_subagent'
: toolName,
step_index: si,
source: 'step_probe',
safe_to_auto_run: isSafeToAutoRun,
});
// v35: Auto-accept code edits via agentAcceptAllInFile
// Observer can't see "Accept all" button (different DOM layer)
if (['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName)) {
ctx.logToFile(`[STEP-PROBE] v35: code_edit detected → auto-accepting in 500ms`);
setTimeout(async () => {
try {
const vscode = require('vscode');
await vscode.commands.executeCommand('antigravity.prioritized.agentAcceptAllInFile');
ctx.logToFile(`[STEP-PROBE] ✅ agentAcceptAllInFile SUCCESS`);
} catch (e: any) {
ctx.logToFile(`[STEP-PROBE] ❌ agentAcceptAllInFile: ${e.message?.substring(0, 100)}`);
}
}, 500);
}
}
}
lastPendingTime = Date.now();
ctx.sawRunningAfterPending = false;
// Skip pending for workspace-less AG windows (project=default)
if (ctx.projectName === 'default') {
ctx.logToFile(`[STEP-PROBE] skip pending: ctx.projectName=default (no workspace)`);
} else {
// Always write pending — Bot decides auto-approve (prevents double-fire)
writePendingApproval({
conversation_id: ctx.activeSessionId,
command,
description,
step_type: ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission'
: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit'
: ['browser_subagent', 'open_browser_url'].includes(toolName) ? 'browser_subagent'
: toolName,
step_index: si,
source: 'step_probe',
});
}
}
// NOTE: no break — process ALL parallel WAITING steps
// NOTE: no break — process ALL parallel WAITING steps
}
}
if (!foundWaiting) {
@@ -625,19 +926,8 @@ function setupMonitor() {
foundWaitingInOffset = true;
const toolCall = oStep?.metadata?.toolCall;
const toolName = toolCall?.name || (oStep.type || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();
let command = toolName;
if (toolCall?.argumentsJson) {
try {
const args = JSON.parse(toolCall.argumentsJson);
if (args.CommandLine) command = `${toolName}: ${args.CommandLine.substring(0, 1500)}`;
else if (args.TargetFile) command = `${toolName}: ${args.TargetFile}`;
else {
const val = args.DirectoryPath || args.SearchPath || args.AbsolutePath || args.Url || args.Query || args.Prompt || Object.values(args).find((v: any) => typeof v === 'string' && v.length > 2);
command = val ? `${toolName}: ${String(val).substring(0, 500)}` : `${toolName}: ${Object.keys(args).join(', ')}`;
}
} catch { command = toolName; }
}
const actualIndex = utf8Offset + osi;
const { cmd: command, desc: detailedDescription, isSafe: isSafeToAutoRun } = formatStepProbeCommand(toolName, actualIndex, oStep.type || '', toolCall);
ctx.logToFile(`[STEP-PROBE] ★ WAITING (via UTF-8 offset)! step=${actualIndex} type=${oStep.type} cmd='${command}'`);
if (actualIndex !== ctx.lastPendingStepIndex) {
ctx.stallProbed = true;
@@ -648,13 +938,14 @@ function setupMonitor() {
writePendingApproval({
conversation_id: ctx.activeSessionId,
command,
description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
description: detailedDescription,
step_type: ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission'
: ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit'
: ['browser_subagent', 'open_browser_url'].includes(toolName) ? 'browser_subagent'
: toolName,
step_index: actualIndex,
source: 'step_probe_utf8_offset',
safe_to_auto_run: isSafeToAutoRun,
});
}
}
@@ -674,6 +965,8 @@ function setupMonitor() {
ctx.logToFile(`[STEP-PROBE] UTF-8 offset also failed: ${oe.message?.substring(0, 100)}`);
ctx.stallProbed = true; // permanent error — block retry loop; resets on delta>0
}
} else {
ctx.stallProbed = true; // Not a UTF-8 error (e.g. trajectory not found), prevent infinite loop
}
}
}
@@ -867,8 +1160,8 @@ function setupMonitor() {
}
}
if (wasRunning && !isRunning && currentCount > lastResponseCaptureStep) {
ctx.logToFile(`[RESPONSE-CAPTURE] RUNNING→IDLE, steps=${currentCount}, capturing response...`);
if (!isRunning && currentCount > lastResponseCaptureStep) {
ctx.logToFile(`[RESPONSE-CAPTURE] IDLE check, steps=${currentCount} > last=${lastResponseCaptureStep}, capturing response...`);
lastResponseCaptureStep = currentCount;
try {
const offset = Math.max(0, currentCount - 5);
@@ -883,41 +1176,7 @@ function setupMonitor() {
const s = steps[ri];
const sType = s?.type || '';
if (sType.includes('PLANNER_RESPONSE') && !sType.includes('EPHEMERAL')) {
let textContent = '';
// Extract from plannerResponse field
const pr = s?.plannerResponse;
if (pr) {
// Priority: modifiedResponse (confirmed field from AG)
if (pr.modifiedResponse) textContent = pr.modifiedResponse;
else if (pr.rawText) textContent = pr.rawText;
else if (pr.text) textContent = pr.text;
else if (pr.message) textContent = typeof pr.message === 'string' ? pr.message : '';
else if (pr.content?.parts) {
for (const p of pr.content.parts) {
if (p?.text) textContent += p.text;
}
}
// Log first time to capture actual field names
if (!textContent) {
ctx.logToFile(`[RESPONSE-CAPTURE] plannerResponse keys: [${Object.keys(pr).join(',')}] values(100): ${JSON.stringify(pr).substring(0, 200)}`);
}
}
// Extract from ephemeralMessage field
const em = s?.ephemeralMessage;
if (!textContent && em) {
if (typeof em === 'string') textContent = em;
else if (em.message) textContent = em.message;
else if (em.content) textContent = typeof em.content === 'string' ? em.content : JSON.stringify(em.content);
}
// Fallback: metadata, content, rawOutput
if (!textContent) {
const parts = s?.content?.parts || s?.parts || [];
for (const p of parts) {
if (p?.text) textContent += p.text;
}
}
if (!textContent && s?.metadata?.text) textContent = s.metadata.text;
if (!textContent && s?.rawOutput) textContent = typeof s.rawOutput === 'string' ? s.rawOutput : JSON.stringify(s.rawOutput);
const textContent = extractPlannerText(s) || '';
if (textContent.length > 10) {
ctx.logToFile(`[RESPONSE-CAPTURE] found ${sType} (${textContent.length} chars) at step ${offset + ri}`);
const truncated = textContent.length > 3500
@@ -926,7 +1185,7 @@ function setupMonitor() {
ctx.writeChatSnapshot(`💬 **AI 응답**\n\n${truncated}`);
break;
} else {
ctx.logToFile(`[RESPONSE-CAPTURE] ${sType} too short (${textContent.length}), keys=[${Object.keys(s).join(',')}]`);
ctx.logToFile(`[RESPONSE-CAPTURE] ${sType} too short (${textContent.length})`);
}
}
}
@@ -938,8 +1197,8 @@ function setupMonitor() {
ctx.writeChatSnapshot(`✅ **Step ${currentCount} 작업 종료**`);
}
// ── Diff review detection: if session just went IDLE and files were modified ──
if (wasRunning && !isRunning && pendingModifiedFiles.length > 0) {
// ── Diff review detection: if session went IDLE and files were modified ──
if (!isRunning && pendingModifiedFiles.length > 0) {
// Phase 3 FIX: Filter out brain/ artifact files (task.md, implementation_plan.md etc.)
// These are AG internal artifacts, NOT code changes needing user review.
const brainPathSegment = '.gemini/antigravity/brain/';
@@ -1024,7 +1283,7 @@ function setupMonitor() {
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
export function writePendingApproval(data: { conversation_id: string; command: string; description: string; step_type?: string; step_index?: number; source?: string; buttons?: Array<{ text: string; index: number }>; modified_files?: string[]; edit_step_indices?: number[] }) {
export function writePendingApproval(data: { conversation_id: string; command: string; description: string; step_type?: string; step_index?: number; source?: string; buttons?: Array<{ text: string; index: number }>; modified_files?: string[]; edit_step_indices?: number[]; safe_to_auto_run?: boolean }) {
try {
const pendingDir = path.join(ctx.bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) { fs.mkdirSync(pendingDir, { recursive: true }); }
@@ -1063,6 +1322,7 @@ export function writePendingApproval(data: { conversation_id: string; command: s
existing.description = data.description;
if (data.step_type) existing.step_type = data.step_type;
if (data.step_index !== undefined) existing.step_index = data.step_index;
if (data.safe_to_auto_run !== undefined) existing.safe_to_auto_run = data.safe_to_auto_run;
existing.source = 'dom_observer+step_probe'; // mark as merged
fs.promises.writeFile(efPath, JSON.stringify(existing, null, 2), 'utf-8').catch(e => {
ctx.logToFile(`[DEDUP] merge write error: ${e.message}`);
@@ -1182,6 +1442,13 @@ export function writePendingApproval(data: { conversation_id: string; command: s
*/
export function initStepProbe(context: BridgeContext) {
ctx = context;
if (ctx.wsBridge) {
brainWatcher = new BrainWatcher({
logToFile: ctx.logToFile,
wsBridge: ctx.wsBridge,
projectName: ctx.projectName
});
}
initApprovalHandler(context, () => activeTrajectoryId);
setupMonitor();
setupResponseWatcher();

View File

@@ -8,6 +8,13 @@
export function extractPlannerText(step: any): string | null {
if (!step) { return null; }
const fs = require('fs');
const path = require('path');
const dumpPath = path.join(require('os').homedir(), '.gemini', 'antigravity', 'bridge', 'planner_dump.json');
try {
fs.writeFileSync(dumpPath, JSON.stringify(step, null, 2), {flag: 'a'});
} catch (e) {}
// Fields to SKIP — not user-facing content
const SKIP_FIELDS = new Set([
'thinking', 'thinkingSignature', 'stopReason', 'type', 'status', 'metadata',
@@ -18,55 +25,77 @@ export function extractPlannerText(step: any): string | null {
]);
// plannerResponse can be string or object
const pr = step.plannerResponse;
const pr = step.plannerResponse || step.step?.plannerResponse;
if (typeof pr === 'string' && pr.length > 10) {
return filterEphemeral(pr);
}
if (pr && typeof pr === 'object') {
// Try known content fields first (NOT thinking/stopReason)
const text = pr.content || pr.text || pr.summary || pr.message || pr.response || pr.output;
if (typeof text === 'string' && text.length > 10) {
return filterEphemeral(text);
}
// Search other fields, but skip non-content ones
for (const key of Object.keys(pr)) {
if (SKIP_FIELDS.has(key)) continue;
const val = pr[key];
if (typeof val === 'string' && val.length > 50) { // Higher threshold
const filtered = filterEphemeral(val);
if (filtered) {
console.log(`Gravity Bridge: [DEBUG] planner text in plannerResponse.${key} (${filtered.length} chars)`);
return filtered;
}
}
}
}
// Try other step fields (skip known non-content)
for (const key of Object.keys(step)) {
if (SKIP_FIELDS.has(key) || key === 'plannerResponse') continue;
const val = step[key];
if (typeof val === 'string' && val.length > 50) {
const filtered = filterEphemeral(val);
if (filtered) {
console.log(`Gravity Bridge: [DEBUG] planner text in step.${key}`);
return filtered;
}
}
// Fallback: nested fields not caught by top-level string iteration
if (step.content?.parts) {
let txt = '';
for (const p of step.content.parts) { if (p?.text) txt += p.text; }
if (txt.length > 10) return filterEphemeral(txt);
}
if (step.parts) {
let txt = '';
for (const p of step.parts) { if (p?.text) txt += p.text; }
if (txt.length > 10) return filterEphemeral(txt);
}
if (step.metadata?.text && step.metadata.text.length > 10) {
return filterEphemeral(step.metadata.text);
}
if (step.rawOutput) {
const txt = typeof step.rawOutput === 'string' ? step.rawOutput : JSON.stringify(step.rawOutput);
if (txt.length > 10) return filterEphemeral(txt);
}
return null;
}
/** Filter out system ephemeral messages and non-content strings. */
export function filterEphemeral(text: string): string | null {
if (!text || text.length < 10) { return null; }
// Skip system prompt metadata
if (text.includes('<EPHEMERAL_MESSAGE>') || text.includes('<ephemeral_message>')) { return null; }
if (text.includes('artifact_reminder') || text.includes('active_task_reminder')) { return null; }
if (text.includes('no_active_task_reminder')) { return null; }
// Strip ephemeral system blocks entirely without dropping the user-facing text
let cleaned = text;
// Target the specific Gemini system prompt injection format:
// "The following is an <EPHEMERAL_MESSAGE>... </ephemeral_message>"
// Make sure to match case-insensitively and dotAll
cleaned = cleaned.replace(/The following is an <EPHEMERAL_MESSAGE>[\s\S]*?(?:<\/ephemeral_message>|<\/EPHEMERAL_MESSAGE>|$)/gi, '');
// Target standard blocks
cleaned = cleaned.replace(/<EPHEMERAL_MESSAGE>[\s\S]*?<\/EPHEMERAL_MESSAGE>/gi, '');
cleaned = cleaned.replace(/<ephemeral_message>[\s\S]*?<\/ephemeral_message>/gi, '');
// Strip other known reminder blocks (if they exist as XML-like tags, just remove them)
cleaned = cleaned.replace(/<artifact_reminder>[\s\S]*?<\/artifact_reminder>/gi, '');
cleaned = cleaned.replace(/<active_task_reminder>[\s\S]*?<\/active_task_reminder>/gi, '');
cleaned = cleaned.replace(/<no_active_task_reminder>[\s\S]*?<\/no_active_task_reminder>/gi, '');
cleaned = cleaned.trim();
if (!cleaned || cleaned.length < 10) { return null; }
// Skip base64/crypto strings (no spaces, mostly alphanumeric)
if (!text.includes(' ') && /^[A-Za-z0-9+/=_\-]{50,}$/.test(text)) { return null; }
return text;
if (!cleaned.includes(' ') && /^[A-Za-z0-9+/=_\-]{50,}$/.test(cleaned)) { return null; }
return cleaned;
}
/** Extract human-readable command from a tool call step's data. */

36
generate_mock.js Normal file
View File

@@ -0,0 +1,36 @@
const fs=require('fs');
const {JSDOM}=require('jsdom');
const rawDump = JSON.parse(fs.readFileSync('C:/Users/Variet-Worker/.gemini/antigravity/bridge/dump_html.json', 'utf8'));
// Inject Tailwind script so the mock renders styles properly when opened in a browser
const htmlStr = rawDump.html;
const dom = new JSDOM("<!DOCTYPE html><html><head><script src='https://cdn.tailwindcss.com'></script></head><body>" + htmlStr + "</body></html>");
const document = dom.window.document;
const titleSpan = document.querySelector('span[title^="command("]');
if(titleSpan) {
let toolContainer = titleSpan.parentElement;
while(toolContainer && !toolContainer.className.includes("border-gray-500/10") && !toolContainer.className.includes("bg-gray-500/10")) {
toolContainer = toolContainer.parentElement;
}
if(!toolContainer) toolContainer = titleSpan.parentElement;
const aiChat = document.createElement('div');
aiChat.className = 'markdown prose';
aiChat.innerHTML = '<p>안녕하세요! 시스템을 수정하기 위해 요청하신 작업을 시작합니다. <b>디스코드 릴레이 기능 복구</b>를 위해 스크립트를 실행하겠습니다.</p>';
const parent = toolContainer.parentElement;
const convoWrapper = document.createElement('div');
convoWrapper.className = 'bg-agent-convo-background p-4 rounded-lg my-4 bg-gray-800 text-white';
parent.insertBefore(convoWrapper, toolContainer);
convoWrapper.appendChild(aiChat);
convoWrapper.appendChild(toolContainer);
const btn = document.createElement('button');
btn.className = 'bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mt-2';
btn.innerHTML = '<span class="truncate">Allow</span>';
toolContainer.appendChild(btn);
}
fs.writeFileSync('C:/Users/Variet-Worker/Desktop/gravity_control/mock_output.html', dom.serialize());
console.log('Saved to mock_output.html');

BIN
git_log.txt Normal file

Binary file not shown.

659
git_log_utf8.txt Normal file
View File

@@ -0,0 +1,659 @@
commit 13f13ee243ba50768d8389509f77f03d32989d58
Author: Variet Worker <worker@variet.net>
Date: Wed Apr 1 18:21:51 2026 +0900
fix(extension): resolve 10-item limit truncation & WS zombie disconnection (v0.5.14)
diff --git a/.agents/references/known-issues.md b/.agents/references/known-issues.md
index c343f77..f993a22 100644
--- a/.agents/references/known-issues.md
+++ b/.agents/references/known-issues.md
@@ -29,6 +29,20 @@
## ?뵶 Active/Recent Issues
+### [2026-03-31] [step-probe] GetAllCascadeTrajectories 10-Item Hard Limit (Signal Drop)
+- **利앹긽**: `guitar_score` ?깆뿉???쒖꽦?붾맂 ?몄뀡???붿뒪肄붾뱶 ?뱀씤 ?좏샇瑜?"怨꾩냽?댁꽌" ?≪? 紐삵븿. (WS 60珥??€?꾩븘?껊낫????移섎챸?곸쑝濡??좏샇媛€ ?꾩삁 媛€吏€ ?딆쓬)
+- **?먯씤**: Extension???쒖꽦 ?몄뀡??李얘린 ?꾪빐 ?몄텧?섎뒗 `GetAllCascadeTrajectories` LS API媛€ `{}`(鍮??몄옄)濡??몄텧???? 湲곕낯?곸쑝濡?**10媛쒖쓽 ?몄뀡留?諛섑솚?섎뒗 ?섎뱶 由щ컠(Pagination Limit)**??嫄몃젮?덉쓬. ?대줈 ?명빐 ?묒뾽 ?댁뿭???꾩쟻?섎㈃ ?섎쭖?€ 理쒖떊/吏꾪뻾 以??몄뀡?ㅼ씠 10媛?紐⑸줉?먯꽌 諛€?ㅻ굹 ?꾨씫?? ?듭뒪?먯뀡?€ ?몄뀡???녿떎怨??먮떒??媛뺤젣濡?`IDLE` 紐⑤뱶??吏꾩엯?섎ʼn, ?뱀씤 ?€湲곗뿴(WAITING) ?먯껜瑜?寃€?ы븯吏€ ?딄쾶 ??
+- **?닿껐** (v0.5.14): `v0.5.13`?먯꽌 ?꾩엯?덈뜕 `{ limit: 100 }`??LS ?⑥쓽 荑쇰━ 怨쇰??섎줈 ?명븳 VS Code UI ?꾨━吏?DoS)???좊컻?섏뿬 濡ㅻ갚?섎뒗 以??꾩닔 ?뺣젹 ?뚮씪誘명꽣(`descending: true`)源뚯? ?뚯떎?섏뿀???ㅼ닔瑜?援먯젙?? 理쒖쥌?곸쑝濡?`{ limit: 30, descending: true }`瑜??곸슜?섏뿬 ?뚯떛 遺€??理쒖냼??諛?理쒖떊 ?몄뀡 理쒖긽??Index 0) 議고쉶瑜??덉쟾?섍쾶 援ы쁽??
+- **二쇱쓽**: LS??湲곕낯 SQLite/DB ?묐떟 Limit 洹쒖튃???섏〈?섏뿬 ?꾩껜 ?곗씠???ㅼ틪???섑뻾?섎뒗 濡쒖쭅?€ ?몄젣??Truncation ?댁뒋(Data Loss)瑜??좊컻?????덉쓬.
+
+### [2026-03-31] [WS] Browser API Fallback 60s Timeout (Zombie Connection)
+- **利앹긽**: `guitar_score` ??紐⑤뱺 ?묒뾽 ?섍꼍?먯꽌 ??60珥덈쭏??WebSocket ?곌껐???딄린怨??ъ뿰寃곕릺???꾩긽??諛섎났?섎ʼn(extension.log??`Heartbeat timeout` 怨꾩냽 異쒕젰), 洹??ъ씠 ?붿뒪肄붾뱶 ?뱀씤 ?좏샇瑜??볦묠.
+- **?먯씤**: Extension??`ws` 紐⑤뱢 濡쒕뱶 ?ㅽ뙣(VS Code ?섍꼍 ??濡??명빐 釉뚮씪?곗? ?댁옣 `WebSocket` 媛앹껜濡?Fallback ?? 釉뚮씪?곗? WS???쒕쾭???ㅼ씠?곕툕 ping??諛쏆븘 pong???먮룞 ?묐떟?섏?留?JS???대깽?몃? ?몄텧?섏? ?딆쓬. ?대줈 ?명빐 `lastPongTime` 媛깆떊??遺덇??ν빐?? `Date.now() - lastPongTime > 60000` 議곌굔??臾댁“嫄??듦낵?섏뼱 硫€姨≫븳 ?곌껐??媛뺤젣 醫낅즺??(False Positive).
+- **?닿껐** (v0.5.12):
+ 1. `hub.py`: `{"type": "heartbeat"}` JSON 硫붿떆吏€ ?섏떊 ??紐낆떆?곸쑝濡?`{"type": "pong"}` JSON???묐떟?섎룄濡??섏젙.
+ 2. `ws-client.ts`: 紐낆떆??`pong` ?몃뱾??異붽?. JSON pong 吏€???쒕쾭嫄곕굹 Node.js ws瑜??ъ슜???뚮쭔 60珥??€?꾩븘??寃€利앹쓣 嫄곗튂?꾨줉 議곌굔 蹂닿컯 (`forceHeartbeatTimeoutIfNoPong`).
+- **二쇱쓽**: 釉뚮씪?곗? ?쒖? WebSockets(W3C)??ping/pong ?쒖뼱 ?꾨젅?꾩쓣 JS濡??몄텧?섏? ?딆쓬. ?대━???щ줈?ㅽ뵆?ロ뤌 WS ?섑띁 ?ъ슜 ???섑듃鍮꾪듃??諛섎뱶??JSON 硫붿꽭吏€ ?뺥깭??Application Layer Ping/Pong?쇰줈 ?€?대궡嫄곕굹, Native WS API ?щ?瑜??뺤떎??泥댄겕?댁빞 ??
+
### [2026-03-28] [step-probe] GetCascadeTrajectorySteps UTF-8 ?먮윭 臾댄븳 猷⑦봽
- **利앹긽**: `guitar_score` ?꾨줈?앺듃?먯꽌 `[STEP-PROBE] error: ...invalid UTF-8` ?먮윭媛€ 5珥덈쭏??諛섎났?섎ʼn Discord ?뱀씤 ?좏샇媛€ ?꾨떖?섏? ?딆쓬.
- **?먯씤**: AG LS ?쒕쾭?먯꽌 ?뱀젙 step??`CortexStepEphemeralMessage.content`??諛붿씠?덈━ ?곗씠???대?吏€ ?? ?ы븿 ??proto UTF-8 吏곷젹??500 ?먮윭. `catch(e)` 釉붾줉?먯꽌 `stallProbed=true`瑜??ㅼ젙?섏? ?딆븘 `!ctx.stallProbed` 議곌굔????긽 true ??5珥덈쭏???숈씪 ?붿껌 臾댄븳 ?ъ떆??
diff --git a/docs/devlog/2026-04-01.md b/docs/devlog/2026-04-01.md
new file mode 100644
index 0000000..6b086b4
--- /dev/null
+++ b/docs/devlog/2026-04-01.md
@@ -0,0 +1,5 @@
+# 2026-04-01 Devlog
+
+| NNN | HH:MM | ?묒뾽 ?ㅻ챸 | `而ㅻ컠?댁떆` | ???먮뒗 ?뵩 |
+|-------|-------|-----------|-------------|--------------|
+| 001 | 18:22 | `step-probe` 10-Item Truncation/DoS ?고쉶 (vsix v0.5.14) | `TBD` | ??|
diff --git a/docs/devlog/entries/20260401-001.md b/docs/devlog/entries/20260401-001.md
new file mode 100644
index 0000000..3c6e09e
--- /dev/null
+++ b/docs/devlog/entries/20260401-001.md
@@ -0,0 +1,11 @@
+# step-probe Pagination 10-Item Truncation vs LS DoS ?ㅻ쪟 ?섏젙
+
+- **?쒓컙**: 2026-04-01 13:00~18:22
+- **Commit**: `TBD`
+- **Vikunja**: #N/A (?꾩떆 踰꾧렇 ?쎌뒪)
+
+## 寃곗젙 ?ы빆
+- 湲곗〈 `v0.5.13`?먯꽌 `limit: 100`?쇰줈 Pagination Limit(湲곕낯 10媛????고쉶?섎젮 ?덉쑝?? LS DB ?ㅼ틪 諛?嫄곕???JSON ?뚯떛??VS Code Event Loop 釉붾줈?뱀쓣 ?좊컻?섏뿬 UI 硫덉땄(DoS) 諛쒖깮.
+- 濡ㅻ갚 怨쇱젙?먯꽌 `{}`(?몄옄 ?놁쓬)?쇰줈 ?먮났?섎㈃???꾩닔?곸씤 `descending: true` ?뚮씪誘명꽣源뚯? ?꾨씫??
+- ?대줈 ?명빐 `guitar_score` ?깆쓽 理쒖떊 ?묒꽦 ?몄뀡??LS 議고쉶 由щ컠(10)?먯꽌 諛€?ㅻ굹 ?뱀씤 ?좏샇瑜??섏떊?섏? 紐삵븯???댁뒋 ?щ컻.
+- ?대? ?닿껐?섍린 ?꾪빐 `limit: 30, descending: true`濡??ㅼ젙. ?뚯떛?댁빞 ??JSON 媛앹껜 ?섎? 1/3濡?以꾩엫怨??숈떆?? ?뺣젹 蹂댁옣???듯빐 理쒓렐 10珥??대궡???쒖꽦?붾맂 ?몄뀡?€ ?몄젣??Index 0踰?理쒖긽?⑥뿉 怨좎젙?섍쾶??硫붿빱?덉쬁???섏젙??
diff --git a/extension/package.json b/extension/package.json
index 0145fbb..35fdce5 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -2,7 +2,7 @@
"name": "gravity-bridge",
"displayName": "Gravity Bridge",
"description": "Antigravity ??Discord 釉뚮━吏€ ?곕룞 ?뺤옣",
- "version": "0.5.11",
+ "version": "0.5.14",
"publisher": "variet",
"engines": {
"vscode": "^1.100.0"
diff --git a/extension/src/step-probe.ts b/extension/src/step-probe.ts
index 7fa66f5..afc8ed7 100644
--- a/extension/src/step-probe.ts
+++ b/extension/src/step-probe.ts
@@ -178,7 +178,8 @@ function setupMonitor() {
ctx.logToFile(`[POLL#${pollCount}] alive`);
}
try {
- const allTraj = await ctx.sdk.ls.rawRPC('GetAllCascadeTrajectories', {});
+ // Fix (v0.5.14): Reverted 100-limit DoS but restored descending: true with a safe limit of 30
+ const allTraj = await ctx.sdk.ls.rawRPC('GetAllCascadeTrajectories', { limit: 30, descending: true });
if (!allTraj?.trajectorySummaries) {
if (pollCount <= 3) ctx.logToFile('[POLL] no trajectorySummaries');
return;
diff --git a/extension/src/ws-client.ts b/extension/src/ws-client.ts
index d8f7d96..9907e50 100644
--- a/extension/src/ws-client.ts
+++ b/extension/src/ws-client.ts
@@ -124,6 +124,7 @@ export class WSBridgeClient {
private heartbeatTimer: NodeJS.Timeout | null = null;
private authTimer: NodeJS.Timeout | null = null;
private lastPongTime: number = 0;
+ private forceHeartbeatTimeoutIfNoPong = false;
// Message queue (survives reconnection)
private messageQueue: WSMessage[] = [];
@@ -440,6 +441,14 @@ export class WSBridgeClient {
break;
}
+ case 'pong': {
+ // Sent by Hub in response to our 'heartbeat' JSON message
+ // This is crucial for Browser-style WebSockets that don't expose native ping/pong
+ this.forceHeartbeatTimeoutIfNoPong = true;
+ this.lastPongTime = Date.now();
+ break;
+ }
+
default:
this.logFn(`[WS] Unknown message type: ${msg.type}`);
}
@@ -498,7 +507,8 @@ export class WSBridgeClient {
this.heartbeatTimer = setInterval(() => {
if (this.ws && this.connected) {
// Check for zombie connection (no pong for 60s)
- if (Date.now() - this.lastPongTime > 60000) {
+ const isNodeWs = (typeof this.ws.ping === 'function');
+ if ((isNodeWs || this.forceHeartbeatTimeoutIfNoPong) && Date.now() - this.lastPongTime > 60000) {
this.logFn('[WS] Heartbeat timeout ??no pong received for 60s (zombie connection), terminating');
if (this.ws) {
try { this.ws.terminate(); } catch { try { this.ws.close(); } catch { } }
diff --git a/hub.py b/hub.py
index e95bff2..3cafcc0 100644
--- a/hub.py
+++ b/hub.py
@@ -590,7 +590,8 @@ class WSHub:
await self._on_brain_event(conn.project, payload)
elif msg_type == MsgType.HEARTBEAT:
- pass # last_heartbeat already updated above
+ # Echo back a "pong" so clients without native ping/pong can update their timers
+ await conn.ws.send_json({"type": "pong"})
else:
logger.warning(f"[HUB] Unknown message type: {msg_type} from {conn.conn_id}")
diff --git a/install_vsix.py b/install_vsix.py
new file mode 100644
index 0000000..260ebe4
--- /dev/null
+++ b/install_vsix.py
@@ -0,0 +1,20 @@
+import zipfile, shutil, os
+
+vsix = r"c:\Users\Variet-Worker\Desktop\gravity_control\extension\gravity-bridge-0.5.14.vsix"
+dest = os.path.expanduser(r"~\.antigravity\extensions\variet.gravity-bridge-0.5.14")
+tmp = r"C:\tmp\vsix_extract"
+
+if os.path.exists(tmp):
+ shutil.rmtree(tmp)
+os.makedirs(tmp, exist_ok=True)
+
+with zipfile.ZipFile(vsix, 'r') as z:
+ z.extractall(tmp)
+
+src = os.path.join(tmp, "extension")
+if os.path.exists(dest):
+ shutil.rmtree(dest)
+
+shutil.copytree(src, dest)
+print(f"Installed to {dest}")
+print("Files:", os.listdir(dest))
diff --git a/test_rpc.js b/test_rpc.js
new file mode 100644
index 0000000..b10bf73
--- /dev/null
+++ b/test_rpc.js
@@ -0,0 +1,31 @@
+const { LSBridge } = require('./extension/out/sdk/ls-bridge');
+
+async function test() {
+ const ls = new LSBridge();
+ await ls.connect();
+
+ console.log("Testing { limit: 5, descending: true }...");
+ let start = Date.now();
+ const res = await ls._rpc('GetAllCascadeTrajectories', { limit: 5, descending: true });
+ let duration = Date.now() - start;
+
+ const summaries = res.trajectorySummaries || {};
+ const keys = Object.keys(summaries);
+ console.log(`Execution time: ${duration}ms`);
+ console.log(`Returned entries: ${keys.length}`);
+
+ keys.slice(0, 5).forEach((k, idx) => {
+ const modT = summaries[k].lastModifiedTime || summaries[k].lastModifiedTimestamp || 'UNKNOWN';
+ console.log(`[${idx}] id=${k.substring(0,8)} mod=${modT} status=${summaries[k].status}`);
+ });
+
+ console.log("\nTesting { limit: 100, descending: true }...");
+ start = Date.now();
+ const res100 = await ls._rpc('GetAllCascadeTrajectories', { limit: 100, descending: true });
+ duration = Date.now() - start;
+ console.log(`Execution time: ${duration}ms`);
+ console.log(`Returned entries: ${Object.keys(res100.trajectorySummaries || {}).length}`);
+
+ ls.disconnect();
+}
+test();
diff --git a/test_ws_logic.js b/test_ws_logic.js
new file mode 100644
index 0000000..230c085
--- /dev/null
+++ b/test_ws_logic.js
@@ -0,0 +1,50 @@
+// test_ws_logic.js
+class FakeWS {
+ constructor() {
+ this.msgLog = [];
+ this.terminated = false;
+ }
+ send(msg) {
+ this.msgLog.push(msg);
+ }
+ terminate() {
+ this.terminated = true;
+ }
+ close() {
+ this.terminated = true;
+ }
+}
+
+// SIMULATE _startHeartbeat() logic from ws-client.ts v0.5.12
+function testLogic(isNodeWs, serverSendsPong) {
+ let ws = new FakeWS();
+ let connected = true;
+ let lastPongTime = Date.now();
+ let forceHeartbeatTimeoutIfNoPong = serverSendsPong;
+ let checkCounter = 0;
+
+ // Fast forward 61 seconds in time
+ let timeElapsed = 61000;
+ let currentNow = Date.now() + timeElapsed;
+
+ // Simulate heartbeat timeout logic
+ let conditionMet = false;
+ if ((isNodeWs || forceHeartbeatTimeoutIfNoPong) && currentNow - lastPongTime > 60000) {
+ conditionMet = true;
+ ws.terminate();
+ }
+
+ return {
+ conditionMet: conditionMet,
+ terminated: ws.terminated
+ };
+}
+
+console.log("Scenario 1: Node WS (native ping/pong) MUST enforce 60s timeout:");
+console.log(testLogic(true, false)); // expect true, true
+
+console.log("\nScenario 2: Browser WS (fallback) + NO JSON PONG FROM SERVER MUST NOT enforce 60s timeout:");
+console.log(testLogic(false, false)); // expect false, false (PREVENTS FALSE POSITIVE)
+
+console.log("\nScenario 3: Browser WS (fallback) + JSON PONG FROM SERVER MUST enforce 60s timeout:");
+console.log(testLogic(false, true)); // expect true, true (DETECTS ZOMBIE)
commit 2d5059d2d5af394573fb199d3f1fcb86c999a363
Author: Variet Worker <worker@variet.net>
Date: Sat Mar 28 09:21:10 2026 +0900
chore(ext): version bump 0.5.11
diff --git a/docs/devlog/2026-03-28.md b/docs/devlog/2026-03-28.md
index d66f07f..55311c7 100644
--- a/docs/devlog/2026-03-28.md
+++ b/docs/devlog/2026-03-28.md
@@ -2,4 +2,4 @@
| # | ?쒓컙 | ?묒뾽 | 而ㅻ컠 | ?곹깭 |
|---|------|------|------|------|
-| 001 | 09:12 | guitar_score step-probe UTF-8 臾댄븳猷⑦봽 ?섏젙 + approval stepIndex 蹂댁젙 (v0.5.11) | pending | ??|
+| 001 | 09:12 | guitar_score step-probe UTF-8 臾댄븳猷⑦봽 ?섏젙 + approval stepIndex 蹂댁젙 (v0.5.11) | `7bbd874` | ??#539 |
diff --git a/extension/package.json b/extension/package.json
index ad55676..0145fbb 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -2,7 +2,7 @@
"name": "gravity-bridge",
"displayName": "Gravity Bridge",
"description": "Antigravity ??Discord 釉뚮━吏€ ?곕룞 ?뺤옣",
- "version": "0.5.10",
+ "version": "0.5.11",
"publisher": "variet",
"engines": {
"vscode": "^1.100.0"
commit 7bbd8749d7e3ed0b80ba70e3e519e36c95696acc
Author: Variet Worker <worker@variet.net>
Date: Sat Mar 28 09:15:11 2026 +0900
fix(extension): guitar_score step-probe UTF-8 loop + approval stepIndex guard (v0.5.11)
diff --git a/.agents/references/known-issues.md b/.agents/references/known-issues.md
index 75ad1a6..c343f77 100644
--- a/.agents/references/known-issues.md
+++ b/.agents/references/known-issues.md
@@ -29,6 +29,18 @@
## ?뵶 Active/Recent Issues
+### [2026-03-28] [step-probe] GetCascadeTrajectorySteps UTF-8 ?먮윭 臾댄븳 猷⑦봽
+- **利앹긽**: `guitar_score` ?꾨줈?앺듃?먯꽌 `[STEP-PROBE] error: ...invalid UTF-8` ?먮윭媛€ 5珥덈쭏??諛섎났?섎ʼn Discord ?뱀씤 ?좏샇媛€ ?꾨떖?섏? ?딆쓬.
+- **?먯씤**: AG LS ?쒕쾭?먯꽌 ?뱀젙 step??`CortexStepEphemeralMessage.content`??諛붿씠?덈━ ?곗씠???대?吏€ ?? ?ы븿 ??proto UTF-8 吏곷젹??500 ?먮윭. `catch(e)` 釉붾줉?먯꽌 `stallProbed=true`瑜??ㅼ젙?섏? ?딆븘 `!ctx.stallProbed` 議곌굔????긽 true ??5珥덈쭏???숈씪 ?붿껌 臾댄븳 ?ъ떆??
+- **?닿껐** (v0.5.11): `catch` 釉붾줉?먯꽌 UTF-8 ?먮윭 媛먯? ??`stepOffset=currentCount-20`?쇰줈 fallback ?붿껌. offset???ㅽ뙣 ??`stallProbed=true` ?ㅼ젙?섏뿬 猷⑦봽 李⑤떒. `delta>0` ?대깽??諛쒖깮 ??L433?먯꽌 ?먮룞 由ъ뀑.
+- **二쇱쓽**: `stallProbed=true`???곴뎄 Lock???꾨떂 ??`delta>0` ???먮룞 由ъ뀑. UTF-8 ?먮윭??AG ?쒕쾭 痢?臾몄젣(?대?吏€/諛붿씠?덈━ ?곗씠?곌? ephemeral message???ы븿)?대?濡?Extension?먯꽌 graceful fallback留?泥섎━.
+
+### [2026-03-28] [approval-handler] stepIndex 誘명솗????wrong-stepIndex RPC ??퉬
+- **利앹긽**: DOM observer 寃쎈줈濡?`terminal_command` pending ?앹꽦 ??Discord ?뱀씤 ??`HandleCascadeUserInteraction(stepIndex=0)` ??`"input not registered for step 0"` ??LS reconnect ???ъ떆????DOM click fallback?쇰줈 ?€?? (wrong-LS?€ ?숈씪??利앹긽?대굹 ?ㅻⅨ ?먯씤)
+- **?먯씤**: `ctx.lastPendingStepIndex=-1` (step-probe媛€ UTF-8 ?먮윭濡?WAITING 誘멸컧吏€)?꾩뿉??`Math.max(0, -1)=0`?쇰줈 clamp?섏뼱 議댁옱?섏? ?딅뒗 step 0??RPC ?꾩넚.
+- **?닿껐** (v0.5.11): `effectiveStepIndex = stepIndex >= 0 ? stepIndex : (lastPendingStepIndex >= 0 ? lastPendingStepIndex : -1)`. `effectiveStepIndex < 0`?대㈃ RPC 釉붾줉 ?꾩껜 skip ??DOM click 吏곹뻾 (湲곗〈怨??숈옉 ?숈씪, LS reconnect ??퉬 ?쒓굅).
+- **二쇱쓽**: 湲곗〈 洹쒖튃 #14(`uint32`???뚯닔 湲덉?)?€ 異⑸룎泥섎읆 蹂댁씠?? `effectiveStepIndex=-1`????RPC ?먯껜瑜?**?꾩넚?섏? ?딆쑝誘€濡?* ?꾨컲 ?꾨떂. RPC ?꾩넚 ?쒖뿉???ъ쟾???좏슚??stepIndex留??ъ슜.
+
### [2026-03-25] [Architecture] Discord Signal Drop & Extension Freezes
- **利앹긽**: ?μ떆媛??먮━鍮꾩? ??蹂듦? ??Discord濡??뱀씤 ?좏샇媛€ ?ㅼ? ?딄굅??VS Code UI媛€ 媛꾪뿉??吏€?띿쟻?쇰줈 硫덉땄(Freeze).
- **?먯씤**:
diff --git a/docs/devlog/2026-03-28.md b/docs/devlog/2026-03-28.md
new file mode 100644
index 0000000..d66f07f
--- /dev/null
+++ b/docs/devlog/2026-03-28.md
@@ -0,0 +1,5 @@
+# Devlog ??2026-03-28
+
+| # | ?쒓컙 | ?묒뾽 | 而ㅻ컠 | ?곹깭 |
+|---|------|------|------|------|
+| 001 | 09:12 | guitar_score step-probe UTF-8 臾댄븳猷⑦봽 ?섏젙 + approval stepIndex 蹂댁젙 (v0.5.11) | pending | ??|
diff --git a/extension/src/approval-handler.ts b/extension/src/approval-handler.ts
index 4d2b169..22106a1 100644
--- a/extension/src/approval-handler.ts
+++ b/extension/src/approval-handler.ts
@@ -313,7 +313,8 @@ async function processResponseFile(filePath: string) {
*/
export async function tryApprovalStrategies(approved: boolean, sessionId: string, stepType: string = '', stepIndex: number = -1): Promise<string> {
const action = approved ? 'APPROVE' : 'REJECT';
- const effectiveStepIndex = Math.max(0, stepIndex >= 0 ? stepIndex : ctx.lastPendingStepIndex);
+ const effectiveStepIndex = stepIndex >= 0 ? stepIndex
+ : (ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : -1);
ctx.logToFile(`[APPROVAL] Starting ${action} strategies for session ${sessionId.substring(0, 8)} stepType=${stepType} stepIndex=${effectiveStepIndex}`);
// ?€?€ Dynamic Command Discovery (log what's available during WAITING state) ?€?€
@@ -338,7 +339,7 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
// STRATEGY 0-PROTO: Correct proto-based RPC (decoded from AG source)
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
- if (ctx.sdk && approved) {
+ if (ctx.sdk && approved && effectiveStepIndex >= 0) {
// Build interaction sub-message based on step_type
const typeLower = stepType.toLowerCase().replace('cortex_step_type_', '');
let interactionPayload: Record<string, any> = {};
diff --git a/extension/src/step-probe.ts b/extension/src/step-probe.ts
index 02fd3e7..7fa66f5 100644
--- a/extension/src/step-probe.ts
+++ b/extension/src/step-probe.ts
@@ -601,7 +601,79 @@ function setupMonitor() {
}
}
} catch (e: any) {
- ctx.logToFile(`[STEP-PROBE] error: ${e.message}`);
+ ctx.logToFile(`[STEP-PROBE] error: ${e.message?.substring(0, 150)}`);
+ // UTF-8 invalid data in a step causes a permanent 500 error on full fetch.
+ // Attempt stepOffset to skip that step and fetch only recent steps.
+ const isUtf8Error = e.message?.includes('invalid UTF-8') || e.message?.includes('proto:');
+ if (isUtf8Error && ctx.sdk) {
+ try {
+ const utf8Offset = Math.max(0, currentCount - 20);
+ ctx.logToFile(`[STEP-PROBE] UTF-8 fallback: retrying with stepOffset=${utf8Offset}`);
+ const offsetResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
+ cascadeId: bestSessionId,
+ stepOffset: utf8Offset,
+ verbosity: 1,
+ });
+ if (offsetResp?.steps?.length > 0) {
+ const offsetSteps = offsetResp.steps;
+ ctx.logToFile(`[STEP-PROBE] UTF-8 offset=${utf8Offset} returned ${offsetSteps.length} steps`);
+ let foundWaitingInOffset = false;
+ for (let osi = offsetSteps.length - 1; osi >= 0; osi--) {
+ const oStep = offsetSteps[osi];
+ if (oStep?.status === 'CORTEX_STEP_STATUS_WAITING') {
+ foundWaitingInOffset = true;
+ const toolCall = oStep?.metadata?.toolCall;
+ const toolName = toolCall?.name || (oStep.type || '').replace('CORTEX_STEP_TYPE_', '').toLowerCase();
+ let command = toolName;
+ if (toolCall?.argumentsJson) {
+ try {
+ const args = JSON.parse(toolCall.argumentsJson);
+ if (args.CommandLine) command = `${toolName}: ${args.CommandLine.substring(0, 1500)}`;
+ else if (args.TargetFile) command = `${toolName}: ${args.TargetFile}`;
+ else {
+ const val = args.DirectoryPath || args.SearchPath || args.AbsolutePath || args.Url || args.Query || args.Prompt || Object.values(args).find((v: any) => typeof v === 'string' && v.length > 2);
+ command = val ? `${toolName}: ${String(val).substring(0, 500)}` : `${toolName}: ${Object.keys(args).join(', ')}`;
+ }
+ } catch { command = toolName; }
+ }
+ const actualIndex = utf8Offset + osi;
+ ctx.logToFile(`[STEP-PROBE] ??WAITING (via UTF-8 offset)! step=${actualIndex} type=${oStep.type} cmd='${command}'`);
+ if (actualIndex !== ctx.lastPendingStepIndex) {
+ ctx.stallProbed = true;
+ if (actualIndex > ctx.lastPendingStepIndex) ctx.lastPendingStepIndex = actualIndex;
+ lastPendingTime = Date.now();
+ ctx.sawRunningAfterPending = false;
+ if (ctx.projectName !== 'default') {
+ writePendingApproval({
+ conversation_id: ctx.activeSessionId,
+ command,
+ description: `Step #${actualIndex} (${(oStep.type || '').replace('CORTEX_STEP_TYPE_', '')})`,
+ step_type: ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search'].includes(toolName) ? 'file_permission'
+ : ['write_to_file', 'replace_file_content', 'multi_replace_file_content'].includes(toolName) ? 'code_edit'
+ : ['browser_subagent', 'open_browser_url'].includes(toolName) ? 'browser_subagent'
+ : toolName,
+ step_index: actualIndex,
+ source: 'step_probe_utf8_offset',
+ });
+ }
+ }
+ // NOTE: no break ??process ALL parallel WAITING steps
+ }
+ }
+ if (!foundWaitingInOffset) {
+ ctx.logToFile(`[STEP-PROBE] UTF-8 offset: no WAITING found ??stallProbed=true to prevent loop`);
+ ctx.stallProbed = true; // prevent retry loop; resets on delta>0
+ ctx.sessionStalled = false;
+ }
+ } else {
+ ctx.logToFile(`[STEP-PROBE] UTF-8 offset returned empty ??stallProbed=true`);
+ ctx.stallProbed = true;
+ }
+ } catch (oe: any) {
+ ctx.logToFile(`[STEP-PROBE] UTF-8 offset also failed: ${oe.message?.substring(0, 100)}`);
+ ctx.stallProbed = true; // permanent error ??block retry loop; resets on delta>0
+ }
+ }
}
}
commit d5fdc41f35d0d206114a036343ee049d62421f6b
Author: Variet Worker <worker@variet.net>
Date: Wed Mar 25 07:14:34 2026 +0900
fix(extension): Discord signal drop and UI freeze (async IO, regex filters, WS rate-limits) (v0.5.10)
diff --git a/.agents/references/known-issues.md b/.agents/references/known-issues.md
index 1d9a2a8..75ad1a6 100644
--- a/.agents/references/known-issues.md
+++ b/.agents/references/known-issues.md
@@ -29,6 +29,15 @@
## ?뵶 Active/Recent Issues
+### [2026-03-25] [Architecture] Discord Signal Drop & Extension Freezes
+- **利앹긽**: ?μ떆媛??먮━鍮꾩? ??蹂듦? ??Discord濡??뱀씤 ?좏샇媛€ ?ㅼ? ?딄굅??VS Code UI媛€ 媛꾪뿉??吏€?띿쟻?쇰줈 硫덉땄(Freeze).
+- **?먯씤**:
+ 1. `ws.onerror` 諛쒖깮 ??`onclose` ?꾨씫 ???ъ뿰寃?肄쒕갚 ?몄텧???대(?댁?吏€ ?딆븘 臾댄븳 ?€湲?(?μ떆媛?留덈퉬)
+ 2. `ws-client` ?ъ뿰寃????꾩쟻??200媛??먮? ?숆린??burst ?꾩넚?섏뿬 Hub???띾룄 ?쒗븳(60媛?10珥???嫄몃젮 ?뺤젙 ?곴뎄 ??젣??+ 3. 濡쒖뺄 釉뚮┸吏€ `http-bridge.ts`??怨쇨굅 ?좎궛??`FALSE_POSITIVE_RE` ?뺢퇋?앹씠 AI 怨좎쑀 踰꾪듉(Allow, Deny, Accept) 留덉? ?꾪꽣留곹븯??Discord ?꾩넚 ?먯쿇 李⑤떒
+ 4. `step-probe.ts` ?대쭅 猷⑦봽 ???숆린???뚯씪 I/O ?ъ슜?쇰줈 ?명븳 ?꾨━利?+- **?닿껐** (v0.5.10): ws-client???섎뱶 ?€?꾩븘??諛?50ms Paced-flush ?곸슜, http-bridge???뺢퇋??湲곕뒫 ?꾪솕, step-probe 鍮꾨룞湲?I/O ?꾪솚 泥댁젣 ?곸슜, observer-script???꾪꽣???좏샇 臾댄븳 HTTP ?대쭅 諛⑹뼱 肄붾뱶 諛섏쁺.
+- **二쇱쓽**: Extension ?대? 濡쒖쭅 踰꾧렇?€?쇰?濡?Hub(Python) 肄붾뱶??嫄대뱶由ъ? ?딆쓬. Hub ?띾룄 ?쒗븳?€ ?뺤긽 諛⑹뼱 湲곗젣?대?濡??대씪?댁뼵???⑥쓽 Pacing???щ컮瑜?諛⑺뼢??
### [2026-03-24] DOM Observer /trigger-click ?뚮뜑留??쒖꽌 ?ㅼ옉??諛?False Positive ?꾨━吏? - **利앹긽**: v0.5.9 ?⑥튂 ?댄썑 肄붾뵫 ??Agent ?붾㈃???딆엫?놁씠 ?쒕챸 ?€湲?Pending) ?곹깭濡?硫덉땄. ?먮뒗 ?붿뒪肄붾뱶?먯꽌 `Approve` ???먮뵒???댁쓽 ?됰슧??`Run Test`(肄붾뱶 ?뚯쫰)瑜??대┃??
- **?먯씤**: ?띿뒪?몄? ?뺢퇋??`/^Run/i` ???먮쭔 ?섏〈?섏뿬 `querySelectorAll`???섑뻾??寃쎌슦, DOM ?몃━???뚮뜑留곷맂 ?섎쭖?€ VS Code ?ㅼ씠?곕툕 肄붾뱶 ?뚯쫰 踰꾪듉??Agent 踰꾪듉蹂대떎 癒쇱? 李얠븘踰꾨━??諛쒖깮 ?꾩튂(Context)???쒓퀎??
diff --git a/docs/devlog/2026-03-25.md b/docs/devlog/2026-03-25.md
new file mode 100644
index 0000000..05e069e
--- /dev/null
+++ b/docs/devlog/2026-03-25.md
@@ -0,0 +1,5 @@
+# 2026-03-25 Devlog
+
+| NNN | HH:MM | ?묒뾽 ?ㅻ챸 | `而ㅻ컠?댁떆` | ???먮뒗 ?뵩 |
+|-----|-------|----------|-----------|-----------|
+| 001 | 07:15 | ws-client reconnect pacing 諛?http-bridge ?뺢퇋???꾪꽣 ?꾪솕濡?Signal Drop ?닿껐 (v0.5.10) | `pending` | ??|
diff --git a/extension/src/http-bridge.ts b/extension/src/http-bridge.ts
index 3833686..6d1d6a3 100644
--- a/extension/src/http-bridge.ts
+++ b/extension/src/http-bridge.ts
@@ -189,7 +189,8 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
// ?€?€ Server-side false positive filter ?€?€
const cmd = (data.command || '').trim();
- const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Deny|Allow Once|Allow This Conversation|Dismiss|Decline|Accept|Reject|Accept all|Reject all)$/i;
+ // Removed valid AI buttons (Accept, Reject, Allow, Deny) which are now structurally protected by the observer script
+ const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Dismiss)$/i;
if (FALSE_POSITIVE_RE.test(cmd)) {
ctx.logToFile(`[HTTP] filtered false positive: "${cmd}"`);
res.writeHead(200, { 'Content-Type': 'application/json' });
diff --git a/extension/src/observer-script.ts b/extension/src/observer-script.ts
index bd29ed7..cf81472 100644
--- a/extension/src/observer-script.ts
+++ b/extension/src/observer-script.ts
@@ -479,6 +479,12 @@ export function generateApprovalObserverScript(_port: number): string {
headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload)
}).then(function(r){return r.json();}).then(function(d){
+ if (!d.ok || d.filtered) {
+ log('Pending rejected/filtered for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
+ delete _sent[groupKey2];
+ for(var di=0;di<bidList2.length;di++){delete _sent[bidList2[di]];}
+ return;
+ }
log('Pending created: '+d.request_id+' for group ['+buttonsArr2.map(function(x){return x.text;}).join(', ')+']');
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
}).catch(function(e){
diff --git a/extension/src/step-probe.ts b/extension/src/step-probe.ts
index 0b5db13..02fd3e7 100644
--- a/extension/src/step-probe.ts
+++ b/extension/src/step-probe.ts
@@ -405,7 +405,9 @@ function setupMonitor() {
(pd.step_index === ctx.lastPendingStepIndex || (ageMs < 60_000 && ageMs >= 0));
if (isMatch) {
pd.status = 'auto_resolved';
- fs.writeFileSync(pfPath, JSON.stringify(pd, null, 2), 'utf-8');
+ fs.promises.writeFile(pfPath, JSON.stringify(pd, null, 2), 'utf-8').catch(e => {
+ ctx.logToFile(`[AUTO-RESOLVE] write error: ${e.message}`);
+ });
resolvedCount++;
const cmd = pd.command || '';
if (cmd.length > primaryCommand.length && cmd !== 'Deny' && !cmd.includes('Allow')) {
@@ -989,7 +991,9 @@ export function writePendingApproval(data: { conversation_id: string; command: s
if (data.step_type) existing.step_type = data.step_type;
if (data.step_index !== undefined) existing.step_index = data.step_index;
existing.source = 'dom_observer+step_probe'; // mark as merged
- fs.writeFileSync(efPath, JSON.stringify(existing, null, 2), 'utf-8');
+ fs.promises.writeFile(efPath, JSON.stringify(existing, null, 2), 'utf-8').catch(e => {
+ ctx.logToFile(`[DEDUP] merge write error: ${e.message}`);
+ });
ctx.logToFile(`[DEDUP] MERGED step_probe info into DOM pending: ${ef} cmd="${data.command.substring(0, 60)}"`);
// Record in memory dedup
if (data.step_index !== undefined && data.conversation_id) {
@@ -1071,7 +1075,9 @@ export function writePendingApproval(data: { conversation_id: string; command: s
return;
}
// File route (fallback ??only when WS is NOT connected)
- fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
+ fs.promises.writeFile(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8').catch(e => {
+ console.error(`Gravity Bridge: failed to write pending: ${e.message}`);
+ });
console.log(`Gravity Bridge: pending approval written ??${id}.json`);
// Cache diff_review metadata in-memory (survives pending file deletion by Collector/Bot)
if (data.step_type === 'diff_review' && (data.edit_step_indices?.length || data.modified_files?.length)) {
diff --git a/extension/src/ws-client.ts b/extension/src/ws-client.ts
index a5cba89..d8f7d96 100644
--- a/extension/src/ws-client.ts
+++ b/extension/src/ws-client.ts
@@ -213,12 +213,21 @@ export class WSBridgeClient {
this.logFn(`[WS] Connecting to ${this.hubUrl}...`);
const ws = new WebSocket(this.hubUrl);
+ let connectTimeout: NodeJS.Timeout | null = null;
+ const clearConnectTimeout = () => {
+ if (connectTimeout) {
+ clearTimeout(connectTimeout);
+ connectTimeout = null;
+ }
+ };
+
// Detect API style: Node.js 'ws' module has .on(), browser WebSocket doesn't
const isNodeWs = typeof ws.on === 'function';
if (isNodeWs) {
// ?€?€?€ Node.js ws module (EventEmitter API) ?€?€?€
ws.on('open', () => {
+ clearConnectTimeout();
this.logFn('[WS] Connection opened, authenticating...');
this.ws = ws;
this.connected = true;
@@ -235,11 +244,18 @@ export class WSBridgeClient {
});
ws.on('close', (code: number, reason: Buffer) => {
+ clearConnectTimeout();
const reasonStr = reason ? reason.toString('utf-8') : '';
this.logFn(`[WS] Connection closed: code=${code} reason=${reasonStr}`);
this._onDisconnect();
});
+ ws.on('error', (err: any) => {
+ clearConnectTimeout();
+ this.logFn(`[WS] Connection error: ${err.message || err}`);
+ this._onDisconnect();
+ });
+
ws.on('pong', () => {
// Server responded to our ping ??connection is alive
this.lastPongTime = Date.now();
@@ -247,6 +263,7 @@ export class WSBridgeClient {
} else {
// ?€?€?€ Browser-style WebSocket API (.onopen / .onmessage) ?€?€?€
ws.onopen = () => {
+ clearConnectTimeout();
this.logFn('[WS] Connection opened (browser API), authenticating...');
this.ws = ws;
this.connected = true;
@@ -264,15 +281,29 @@ export class WSBridgeClient {
};
ws.onclose = (event: any) => {
+ clearConnectTimeout();
this.logFn(`[WS] Connection closed: code=${event.code} reason=${event.reason || ''}`);
this._onDisconnect();
};
ws.onerror = (event: any) => {
+ clearConnectTimeout();
this.logFn(`[WS] Error: ${event.message || 'connection error'}`);
+ this._onDisconnect();
};
}
+ // Connection timeout to prevent hanging if no close/error fires
+ connectTimeout = setTimeout(() => {
+ this.logFn('[WS] Connection timeout (15s) ??forcing disconnect');
+ if (this.ws) {
+ try { this.ws.terminate(); } catch { try { this.ws.close(); } catch { } }
+ } else if (ws) {
+ try { ws.terminate(); } catch { try { ws.close(); } catch { } }
+ }
+ this._onDisconnect();
+ }, 15000);
+
} catch (e: any) {
this.logFn(`[WS] Connect failed: ${e.message}`);
this._scheduleReconnect();
@@ -448,13 +479,15 @@ export class WSBridgeClient {
}
}
- private _flushQueue(): void {
+ private async _flushQueue(): Promise<void> {
if (this.messageQueue.length === 0) return;
- this.logFn(`[WS] Flushing ${this.messageQueue.length} queued messages`);
+ this.logFn(`[WS] Flushing ${this.messageQueue.length} queued messages (paced)`);
const queue = [...this.messageQueue];
this.messageQueue = [];
for (const msg of queue) {
this._sendRaw(msg);
+ // Pace the burst to avoid hitting the Hub's rate limit (60 msgs / 10s)
+ await new Promise(r => setTimeout(r, 50));
}
}
commit 3ec45ac6b7ec9779181fac99948f6999ae8d29e0
Author: Variet Worker <worker@variet.net>
Date: Tue Mar 24 18:19:30 2026 +0900
docs(devlog): record hash and Vikunja ID for session 001 and 003
diff --git a/docs/devlog/2026-03-24.md b/docs/devlog/2026-03-24.md
index 40b8359..f854f3f 100644
--- a/docs/devlog/2026-03-24.md
+++ b/docs/devlog/2026-03-24.md
@@ -2,6 +2,6 @@
| NNN | HH:MM | ?묒뾽 ?ㅻ챸 | `而ㅻ컠?댁떆` | ???먮뒗 ?뵩 |
|-----|-------|----------|-----------|-----------|
-| 001 | 07:05 | v0.5.6 醫€鍮?而ㅻ꽖???⑥튂 ?뚭? ?ㅻ쪟 ?닿껐 (False Positive ?딄? 諛⑹?瑜??꾪븳 ?€?꾩뒪?ы봽 寃€利??꾩엯 v0.5.8) | `TBD` | ??|
+| 001 | 07:05 | v0.5.6 醫€鍮?而ㅻ꽖???⑥튂 ?뚭? ?ㅻ쪟 ?닿껐 (False Positive ?딄? 諛⑹?瑜??꾪븳 ?€?꾩뒪?ы봽 寃€利??꾩엯 v0.5.8) | `f13bcc8` | ??|
| 002 | 13:00 | DOM Observer VS Code ?ㅼ씠?곕툕 ?뚮┝ UI 罹≪쿂 釉붾씪?몃뱶 ?ㅽ뙚 ?닿껐 (v0.5.9) | `7b6cd59` | ??|
-| 003 | 18:14 | DOM Observer /trigger-click ?뚮뜑留??쒖꽌 ?ㅼ옉??諛?False Positive ?꾨━吏??닿껐 (v0.5.10) | `TBD` | ??|
+| 003 | 18:14 | DOM Observer /trigger-click ?뚮뜑留??쒖꽌 ?ㅼ옉??諛?False Positive ?꾨━吏??닿껐 (v0.5.10) | `101ec20` | ??|

1
inspect-dump.json Normal file
View File

@@ -0,0 +1 @@
{"status":"timeout","message":"Renderer did not respond in 10s. Is the v3 script loaded?"}

View File

@@ -1,20 +0,0 @@
import zipfile, shutil, os
vsix = r"c:\Users\Variet-Worker\Desktop\gravity_control\extension\gravity-bridge-0.5.14.vsix"
dest = os.path.expanduser(r"~\.antigravity\extensions\variet.gravity-bridge-0.5.14")
tmp = r"C:\tmp\vsix_extract"
if os.path.exists(tmp):
shutil.rmtree(tmp)
os.makedirs(tmp, exist_ok=True)
with zipfile.ZipFile(vsix, 'r') as z:
z.extractall(tmp)
src = os.path.join(tmp, "extension")
if os.path.exists(dest):
shutil.rmtree(dest)
shutil.copytree(src, dest)
print(f"Installed to {dest}")
print("Files:", os.listdir(dest))

56
main.py
View File

@@ -10,7 +10,6 @@ import os
import sys
from config import Config
from watcher import BrainWatcher
from bot import GravityBot
# Logging setup (UTF-8 forced for Windows cp949 compatibility)
@@ -51,45 +50,32 @@ async def main():
# Get the running loop
loop = asyncio.get_running_loop()
# ── Local / Gateway mode ──
# Create components
watcher = None
if Config.BOT_MODE != 'gateway':
watcher = BrainWatcher(event_queue, loop)
bot = GravityBot(event_queue)
try:
# Start watcher (local mode only — gateway receives data via HTTP)
if watcher:
watcher.start()
logger.info(f"Watcher started, {len(watcher.known_sessions)} existing sessions")
else:
logger.info("Gateway mode — watcher disabled (data via HTTP API)")
# Start Gateway HTTP API + WebSocket Hub
from gateway import GatewayAPI
from hub import WSHub
from auth import TokenManager
# Start Gateway HTTP API + WebSocket Hub (gateway mode)
if Config.BOT_MODE == 'gateway':
from gateway import GatewayAPI
from hub import WSHub
from auth import TokenManager
# Initialize Hub
token_mgr = TokenManager(
secret=Config.GRAVITY_HUB_SECRET,
registration_code=Config.GRAVITY_REGISTRATION_CODE,
)
hub = WSHub(token_mgr)
# Initialize Hub
token_mgr = TokenManager(
secret=Config.GRAVITY_HUB_SECRET,
registration_code=Config.GRAVITY_REGISTRATION_CODE,
)
hub = WSHub(token_mgr)
gateway_port = int(os.environ.get('GATEWAY_PORT', '8585'))
gateway = GatewayAPI(
bot, port=gateway_port,
api_key=Config.GATEWAY_API_KEY,
hub=hub,
)
bot.gateway = gateway # Enable _write_command → gateway.push_command
bot.hub = hub # Enable Hub-based message routing
await gateway.start()
logger.info(f"Gateway API + WS Hub running on port {gateway_port}")
gateway_port = int(os.environ.get('GATEWAY_PORT', '8585'))
gateway = GatewayAPI(
bot, port=gateway_port,
api_key=Config.GATEWAY_API_KEY,
hub=hub,
)
bot.gateway = gateway # Enable _write_command → gateway.push_command
bot.hub = hub # Enable Hub-based message routing
await gateway.start()
logger.info(f"Gateway API + WS Hub running on port {gateway_port}")
# Run Discord bot (blocks until bot disconnects)
await bot.start(Config.DISCORD_TOKEN)
@@ -100,8 +86,6 @@ async def main():
logger.error(f"Fatal error: {e}", exc_info=True)
finally:
# Cleanup
if watcher:
watcher.stop()
if not bot.is_closed():
await bot.close()
logger.info("Gravity Control shutdown complete")

1
mock_output.html Normal file

File diff suppressed because one or more lines are too long

55
models.py Normal file
View File

@@ -0,0 +1,55 @@
import time
from dataclasses import dataclass, field
from enum import Enum
class ApprovalStatus(Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
TIMEOUT = "timeout"
@dataclass
class ApprovalRequest:
"""An approval request from Antigravity."""
request_id: str
conversation_id: str
command: str # The command/action needing approval
description: str # Human-readable description
timestamp: float
status: str = "pending"
discord_message_id: int = 0
project_name: str = "" # Project routing key
step_type: str = "" # e.g. 'diff_review', passed through to response
safe_to_auto_run: bool = False # Allows bot to silently auto-approve
@dataclass
class UserResponse:
"""A user response from Discord."""
request_id: str
approved: bool
user_input: str = ""
timestamp: float = field(default_factory=time.time)
button_index: int = -1 # -1 = legacy (approve/reject), 0+ = specific button index
step_type: str = "" # pass through from pending for extension routing
project_name: str = "" # for multi-project: extension uses this when pending file is missing
class EventType(Enum):
"""Types of brain events."""
SESSION_START = "session_start" # New conversation directory created
FILE_CHANGED = "file_changed" # Watched file modified
FILE_CREATED = "file_created" # Watched file first created
@dataclass
class BrainEvent:
"""An event from the brain directory."""
event_type: EventType
conversation_id: str
file_name: str = ""
file_path: str = None
content: str = ""
timestamp: float = field(default_factory=time.time)

View File

@@ -1,63 +0,0 @@
@echo off
chcp 65001 >nul 2>&1
title Gravity Bridge Bot
echo ╔══════════════════════════════════════╗
echo ║ Gravity Bridge Bot Launcher ║
echo ╚══════════════════════════════════════╝
echo.
echo [INFO] 로컬 테스트 (BOT_MODE=local)를 시작합니다.
echo [INFO] 서버 배포는 BOT_MODE=gateway로 실행하세요.
echo.
echo 시작하려면 아무 키나 누르세요...
pause >nul
REM — Find Python (conda first, then system)
set PYTHON=
if exist "C:\ProgramData\miniforge3\envs\gravity_control\python.exe" (
set PYTHON=C:\ProgramData\miniforge3\envs\gravity_control\python.exe
)
if "%PYTHON%"=="" (
where python >nul 2>&1 && set PYTHON=python
)
if "%PYTHON%"=="" (
echo [ERROR] Python not found. Install Python 3.10+ or set path.
pause
exit /b 1
)
REM — Check .env
if not exist "%~dp0.env" (
echo [SETUP] .env not found. Creating from .env.example...
if exist "%~dp0.env.example" (
copy "%~dp0.env.example" "%~dp0.env" >nul
echo [SETUP] .env created — edit it with your Discord token and Guild ID.
echo.
notepad "%~dp0.env"
echo Press any key after saving .env...
pause >nul
) else (
echo [ERROR] .env.example not found.
pause
exit /b 1
)
)
REM — Install dependencies (first run)
if not exist "%~dp0.deps_installed" (
echo [SETUP] Installing dependencies...
%PYTHON% -m pip install -r "%~dp0requirements.txt" -q
echo. > "%~dp0.deps_installed"
echo [SETUP] Dependencies installed.
)
echo [START] Starting bot with %PYTHON%...
echo [START] Press Ctrl+C to stop.
echo.
%PYTHON% "%~dp0main.py"
echo.
echo [STOP] Bot stopped.
pause

47
test.js Normal file
View File

@@ -0,0 +1,47 @@
const jsdom = require('jsdom');
const { JSDOM } = jsdom;
const dom = new JSDOM($html);
const document = dom.window.document;
function extractContext(b){
var curr = b.parentElement;
var bestDesc = '';
var btnText = (b.innerText || b.textContent || '').trim();
for (var i = 0; i < 15 && curr; i++) {
var codeEl = curr.querySelector('pre, code, [class*="command"], [class*="terminal"], [class*="code"]');
if (codeEl && codeEl !== b && !b.contains(codeEl)) {
var codeText = (codeEl.innerText || codeEl.textContent || '').trim();
if (codeText.length > 0 && codeText !== btnText) {
return codeText.substring(0, 500);
}
}
var full = (curr.innerText || curr.textContent || '');
var btnRawText = (b.textContent || '');
var desc = full.replace(btnRawText, '').trim();
if (desc.length > 5 && desc !== btnText && bestDesc.length < desc.length) {
bestDesc = desc;
}
var cname = curr.className;
if (typeof cname === 'string' && (cname.includes('message') || cname.includes('step') || cname.includes('markdown'))) {
break;
}
curr = curr.parentElement;
}
return bestDesc.substring(0, 500);
}
const btns = document.querySelectorAll('button');
let ran = false;
for(let b of btns) {
let t = (b.textContent||'').trim();
if(t === 'Always run' || t === 'Run') {
const desc = extractContext(b);
console.log("Found button: " + t);
console.log("Extracted Description: " + desc);
ran = true;
}
}
if(!ran) console.log("No matching button found");

1
test.txt Normal file
View File

@@ -0,0 +1 @@
world

20
test_deep.py Normal file
View File

@@ -0,0 +1,20 @@
import urllib.request
import json
import time
print('Triggering deep inspect...')
try:
urllib.request.urlopen("http://127.0.0.1:34332/deep-inspect-trigger?t=" + str(time.time())).read()
except Exception as e:
pass
print('Waiting for deep inspect result...')
try:
req = urllib.request.Request("http://127.0.0.1:34332/deep-inspect")
with urllib.request.urlopen(req, timeout=15) as response:
result = json.loads(response.read().decode())
print(f"Got result: {len(result.get('nodes', []))} nodes")
for node in result.get('nodes', []):
print(f"Node: {node.get('url')} - {len(node.get('buttons', []))} buttons")
except Exception as e:
print(f"Error: {e}")

27
test_hub.py Normal file
View File

@@ -0,0 +1,27 @@
import asyncio
from bot import GravityBot
import discord
class FakeChannel:
async def send(self, embed, view=None):
print("Sent to channel successfully!")
class FakeMsg:
id = 999
return FakeMsg()
async def test():
bot = GravityBot(asyncio.Queue())
bot.project_channels["gravity_control"] = FakeChannel()
class FakeHub:
def get_active_count(self, proj): return 1
bot.hub = FakeHub()
await bot._hub_on_pending("gravity_control", {
"request_id": "test1",
"command": "Running1 command",
"description": "test",
"step_type": "",
"buttons": [{"text": "Proceed", "index": 0}],
"timestamp": 12345678.0
})
asyncio.run(test())

47
test_hub_remote.py Normal file
View File

@@ -0,0 +1,47 @@
import asyncio
import websockets
import json
async def main():
uri = "wss://ag.variet.net/ws"
try:
async with websockets.connect(uri) as ws:
print("Connected to remote hub.")
# Send AUTH first
auth_msg = {
"type": "auth",
"token": "2352253f42bd0f9190a83c26f05cc252e86c55c044206953fa7b8fd97adaa6d3",
"project": "gravity_control",
"instance_number": 1,
"pc_name": "TEST_PC"
}
await ws.send(json.dumps(auth_msg))
resp = await ws.recv()
print("Auth response:", resp)
# Now send pending
msg = {
"type": "pending",
"data": {
"request_id": "999999999_mock_1",
"command": "MOCK COMMAND FROM TEST",
"description": "If you see this, the hub is routing perfectly.",
"step_type": "",
"status": "pending",
"buttons": [{"text": "Proceed", "index": 0}],
"project_name": "gravity_control",
"conversation_id": "test_conv"
}
}
await ws.send(json.dumps(msg))
print("Message sent.")
# Keep alive and print responses
while True:
resp = await asyncio.wait_for(ws.recv(), timeout=5.0)
print("Received:", resp)
except Exception as e:
print("Done:", e)
asyncio.run(main())

View File

@@ -1,31 +0,0 @@
const { LSBridge } = require('./extension/out/sdk/ls-bridge');
async function test() {
const ls = new LSBridge();
await ls.connect();
console.log("Testing { limit: 5, descending: true }...");
let start = Date.now();
const res = await ls._rpc('GetAllCascadeTrajectories', { limit: 5, descending: true });
let duration = Date.now() - start;
const summaries = res.trajectorySummaries || {};
const keys = Object.keys(summaries);
console.log(`Execution time: ${duration}ms`);
console.log(`Returned entries: ${keys.length}`);
keys.slice(0, 5).forEach((k, idx) => {
const modT = summaries[k].lastModifiedTime || summaries[k].lastModifiedTimestamp || 'UNKNOWN';
console.log(`[${idx}] id=${k.substring(0,8)} mod=${modT} status=${summaries[k].status}`);
});
console.log("\nTesting { limit: 100, descending: true }...");
start = Date.now();
const res100 = await ls._rpc('GetAllCascadeTrajectories', { limit: 100, descending: true });
duration = Date.now() - start;
console.log(`Execution time: ${duration}ms`);
console.log(`Returned entries: ${Object.keys(res100.trajectorySummaries || {}).length}`);
ls.disconnect();
}
test();

12
test_view.py Normal file
View File

@@ -0,0 +1,12 @@
import discord
from bot import ApprovalView
from models import ApprovalRequest
request = ApprovalRequest("id", "convo", "cmd", "desc", 0.0)
view = ApprovalView(request, buttons=[{"text":"Proceed","index":0}], hub=None)
print("View items:", view.children)
try:
print(view.to_components())
print("SUCCESS")
except Exception as e:
print("CRASH:", e)

12
test_view2.py Normal file
View File

@@ -0,0 +1,12 @@
import discord
from bot import ApprovalView
from models import ApprovalRequest
request = ApprovalRequest("id", "convo", "cmd", "desc", 0.0)
view = ApprovalView(request, buttons=[{"text":"yes","index":0}, {"text":"no","index":1}], hub=None)
print("View items:", view.children)
try:
print(view.to_components())
print("SUCCESS")
except Exception as e:
print("CRASH:", e)

View File

@@ -1,50 +0,0 @@
// test_ws_logic.js
class FakeWS {
constructor() {
this.msgLog = [];
this.terminated = false;
}
send(msg) {
this.msgLog.push(msg);
}
terminate() {
this.terminated = true;
}
close() {
this.terminated = true;
}
}
// SIMULATE _startHeartbeat() logic from ws-client.ts v0.5.12
function testLogic(isNodeWs, serverSendsPong) {
let ws = new FakeWS();
let connected = true;
let lastPongTime = Date.now();
let forceHeartbeatTimeoutIfNoPong = serverSendsPong;
let checkCounter = 0;
// Fast forward 61 seconds in time
let timeElapsed = 61000;
let currentNow = Date.now() + timeElapsed;
// Simulate heartbeat timeout logic
let conditionMet = false;
if ((isNodeWs || forceHeartbeatTimeoutIfNoPong) && currentNow - lastPongTime > 60000) {
conditionMet = true;
ws.terminate();
}
return {
conditionMet: conditionMet,
terminated: ws.terminated
};
}
console.log("Scenario 1: Node WS (native ping/pong) MUST enforce 60s timeout:");
console.log(testLogic(true, false)); // expect true, true
console.log("\nScenario 2: Browser WS (fallback) + NO JSON PONG FROM SERVER MUST NOT enforce 60s timeout:");
console.log(testLogic(false, false)); // expect false, false (PREVENTS FALSE POSITIVE)
console.log("\nScenario 3: Browser WS (fallback) + JSON PONG FROM SERVER MUST enforce 60s timeout:");
console.log(testLogic(false, true)); // expect true, true (DETECTS ZOMBIE)

18
trigger_capture.py Normal file
View File

@@ -0,0 +1,18 @@
import urllib.request, time
print("Waiting 3 seconds for UI to render...")
time.sleep(3)
name = 'gravity_control'
h = 0
for c in name:
h = ((h << 5) - h + ord(c)) & 0xFFFFFFFF
if h > 0x7FFFFFFF: h -= 0x100000000
port = 10000 + (abs(h) % 50000)
print(f"Triggering deep-inspect on port {port}...")
try:
urllib.request.urlopen(urllib.request.Request(f'http://127.0.0.1:{port}/deep-inspect'), timeout=12)
print("Trigger signal sent successfully")
except Exception as e:
print("Trigger failed:", e)

View File

@@ -1,290 +0,0 @@
"""Brain directory watcher — monitors Antigravity's brain/ for file changes.
Uses watchdog to detect file creation/modification events in the brain directory.
Emits events to an asyncio queue for the Discord bot to consume.
Key design: ONLY emits events for meaningful content changes using hash dedup.
"""
import asyncio
import hashlib
import time
import logging
from pathlib import Path
from dataclasses import dataclass, field
from enum import Enum
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent
from config import Config
logger = logging.getLogger(__name__)
class EventType(Enum):
"""Types of brain events."""
SESSION_START = "session_start" # New conversation directory created
FILE_CHANGED = "file_changed" # Watched file modified
FILE_CREATED = "file_created" # Watched file first created
@dataclass
class BrainEvent:
"""An event from the brain directory."""
event_type: EventType
conversation_id: str
file_name: str = ""
file_path: Path = None
content: str = ""
timestamp: float = field(default_factory=time.time)
class BrainEventHandler(FileSystemEventHandler):
"""Watchdog handler that filters, debounces, and deduplicates brain events.
Phase 2 FIX: Only emits events for sessions belonging to the current project
(Config.PROJECT_NAME), using bridge/register/ files for session→project mapping.
"""
def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
super().__init__()
self.event_queue = event_queue
self.loop = loop
self._last_events: dict[str, float] = {} # path -> timestamp (debounce)
self._content_hashes: dict[str, str] = {} # path -> md5 hash (dedup)
self._known_sessions: set[str] = set()
# Phase 2: project filter
self._session_project_map: dict[str, str] = {} # conv_id → project_name
self._project_map_ts: float = 0 # last load timestamp
self._PROJECT_MAP_TTL: float = 60.0 # reload every 60s
self._initialize_known_sessions()
def _initialize_known_sessions(self):
"""Scan existing brain directories to establish baseline (no events emitted).
Also pre-loads content hashes for watched files to prevent spurious events.
"""
brain_path = Config.BRAIN_PATH
hash_count = 0
if brain_path.exists():
for entry in brain_path.iterdir():
if entry.is_dir() and self._is_conversation_id(entry.name):
self._known_sessions.add(entry.name)
# Pre-load content hashes for watched files
for watched in Config.WATCHED_FILES:
fpath = entry / watched
if fpath.exists():
try:
content = fpath.read_text(encoding="utf-8")
h = hashlib.md5(content.encode()).hexdigest()
self._content_hashes[str(fpath)] = h
hash_count += 1
except (OSError, UnicodeDecodeError):
pass
logger.info(
f"Found {len(self._known_sessions)} existing sessions, "
f"pre-loaded {hash_count} content hashes"
)
def _load_session_project_map(self) -> dict[str, str]:
"""Load session→project mapping from bridge/register/ files (cached)."""
now = time.time()
if now - self._project_map_ts < self._PROJECT_MAP_TTL:
return self._session_project_map
import json
register_dir = Config.BRAIN_PATH.parent / "bridge" / "register"
if not register_dir.exists():
self._project_map_ts = now
return self._session_project_map
new_map: dict[str, str] = {}
for f in register_dir.glob("*.json"):
try:
data = json.loads(f.read_text(encoding="utf-8-sig"))
conv_id = data.get("conversation_id", "")
project = data.get("project_name", "")
if conv_id and project:
new_map[conv_id] = project
except (json.JSONDecodeError, OSError):
pass
self._session_project_map = new_map
self._project_map_ts = now
return self._session_project_map
def _is_my_session(self, conv_id: str) -> bool:
"""Check if a session belongs to the current project.
Returns True for:
- Sessions registered to Config.PROJECT_NAME
- Unknown sessions (not in any register file — allow to avoid blocking)
Returns False for sessions registered to OTHER projects.
"""
session_map = self._load_session_project_map()
project = session_map.get(conv_id)
if project is None:
return True # Unknown → allow (newly started, not yet registered)
return project == Config.PROJECT_NAME
def dispatch(self, event: FileSystemEvent):
"""Early filter: skip events for files/dirs we don't care about.
This runs BEFORE on_created/on_modified, avoiding unnecessary
method dispatch overhead for the majority of file events.
"""
path = Path(event.src_path)
# Skip .system_generated and logs subdirectories immediately
path_parts = path.parts
if '.system_generated' in path_parts or 'logs' in path_parts:
return
# For file events, skip non-watched files immediately
if not event.is_directory:
file_name = path.name
if not self._is_watched_file(file_name):
return
super().dispatch(event)
def _is_conversation_id(self, name: str) -> bool:
parts = name.split("-")
return len(parts) == 5 and all(len(p) >= 4 for p in parts)
def _get_conversation_id(self, path: Path) -> str | None:
brain_path = Config.BRAIN_PATH
try:
relative = path.relative_to(brain_path)
parts = relative.parts
if parts and self._is_conversation_id(parts[0]):
return parts[0]
except ValueError:
pass
return None
def _should_debounce(self, path_str: str) -> bool:
now = time.time()
last = self._last_events.get(path_str, 0)
if now - last < Config.DEBOUNCE_SECONDS:
return True
self._last_events[path_str] = now
return False
def _content_changed(self, path_str: str, content: str) -> bool:
"""Check if content actually changed using MD5 hash."""
new_hash = hashlib.md5(content.encode()).hexdigest()
old_hash = self._content_hashes.get(path_str)
if old_hash == new_hash:
return False
self._content_hashes[path_str] = new_hash
return True
def _is_watched_file(self, file_name: str) -> bool:
"""Filter: watch primary artifact files + any file matching watched extensions."""
if file_name in Config.WATCHED_FILES:
return True
# Extension-based matching (e.g., any .md file in conversation dir)
ext = Path(file_name).suffix
if ext and ext in Config.WATCHED_EXTENSIONS:
return True
return False
def _emit(self, event: BrainEvent):
self.loop.call_soon_threadsafe(self.event_queue.put_nowait, event)
def on_created(self, event: FileSystemEvent):
if event.is_directory:
self._handle_directory_created(Path(event.src_path))
else:
self._handle_file_event(Path(event.src_path), EventType.FILE_CREATED)
def on_modified(self, event: FileSystemEvent):
if not event.is_directory:
self._handle_file_event(Path(event.src_path), EventType.FILE_CHANGED)
def _handle_directory_created(self, path: Path):
conv_id = self._get_conversation_id(path)
if conv_id and conv_id not in self._known_sessions:
if path.parent == Config.BRAIN_PATH:
self._known_sessions.add(conv_id)
logger.info(f"New session detected: {conv_id}")
self._emit(BrainEvent(
event_type=EventType.SESSION_START,
conversation_id=conv_id,
))
def _handle_file_event(self, path: Path, event_type: EventType):
conv_id = self._get_conversation_id(path)
if not conv_id:
return
# Phase 2 FIX: only emit events for MY project's sessions
if not self._is_my_session(conv_id):
return
# Exclude files in .system_generated subdirectory (AG internal logs)
try:
relative = path.relative_to(Config.BRAIN_PATH / conv_id)
if '.system_generated' in relative.parts:
return
except ValueError:
pass
file_name = path.name
# Filter: watched files by name or extension
if not self._is_watched_file(file_name):
return
# Debounce: skip rapid-fire events for same file
if self._should_debounce(str(path)):
return
# Read file content
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as e:
logger.warning(f"Failed to read {path}: {e}")
return
# Content hash dedup: skip if content hasn't actually changed
if not self._content_changed(str(path), content):
return
logger.info(f"File event: {event_type.value} {conv_id[:8]}/{file_name}")
self._emit(BrainEvent(
event_type=event_type,
conversation_id=conv_id,
file_name=file_name,
file_path=path,
content=content,
))
class BrainWatcher:
"""Manages the watchdog observer for the brain directory."""
def __init__(self, event_queue: asyncio.Queue, loop: asyncio.AbstractEventLoop):
self.event_queue = event_queue
self.loop = loop
self.observer = Observer()
self.handler = BrainEventHandler(event_queue, loop)
def start(self):
brain_path = Config.BRAIN_PATH
if not brain_path.exists():
logger.error(f"Brain path does not exist: {brain_path}")
return
self.observer.schedule(self.handler, str(brain_path), recursive=True)
self.observer.start()
logger.info(f"Watching brain directory: {brain_path}")
def stop(self):
self.observer.stop()
self.observer.join()
logger.info("Brain watcher stopped")
@property
def known_sessions(self) -> set[str]:
return self.handler._known_sessions