Compare commits

...

36 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
30 changed files with 2127 additions and 145 deletions

View File

@@ -21,6 +21,56 @@
> 鍮꾩듂븳 臾몄젣媛 옱諛쒗븯硫 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 응답 텍스트로 추출됨.

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,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,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 소비)

View File

@@ -397,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)
@@ -503,6 +520,7 @@ async function activate(context) {
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,12 +1,12 @@
{
"name": "gravity-bridge",
"version": "0.5.34",
"version": "0.5.103",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gravity-bridge",
"version": "0.5.34",
"version": "0.5.103",
"dependencies": {
"cheerio": "^1.2.0",
"ws": "^8.19.0"

View File

@@ -2,7 +2,7 @@
"name": "gravity-bridge",
"displayName": "Gravity Bridge",
"description": "Discord-based unified approval system for Antigravity AI interactions.",
"version": "0.5.54",
"version": "0.5.103",
"publisher": "variet",
"engines": {
"vscode": "^1.100.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

@@ -205,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);

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';
@@ -388,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)
@@ -499,6 +521,7 @@ export async function activate(context: vscode.ExtensionContext) {
get sessionStalled() { return getStepProbeContext().sessionStalled; },
get lastPendingStepIndex() { return getStepProbeContext().lastPendingStepIndex; },
writeChatSnapshot,
getLastWaitingCommand,
};
const bridgePort = await startHttpBridge(httpBridgeCtx, sdk);
let localPort = bridgePort;

View File

@@ -26,6 +26,7 @@ export interface HttpBridgeContext {
lastPendingStepIndex: number;
logToFile: (msg: string) => void;
writeChatSnapshot?: (text: string) => void;
getLastWaitingCommand?: () => { cmd: string; desc: string; ts: number };
}
// ─── Module-level state ───
@@ -126,6 +127,21 @@ export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<numbe
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');
@@ -266,6 +282,155 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
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"
@@ -361,44 +526,8 @@ function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
buttons: data.buttons,
step_index: ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : undefined,
};
// v17: "Always run" auto-approve — click button immediately without Discord roundtrip
// rawCmd is the original button text before enrichment. "Always run" means the user
// already trusts this command pattern, so we auto-approve at the bridge level.
if (/^Always\s+run$/i.test(rawCmd)) {
ctx.logToFile(`[HTTP] AUTO-APPROVE "Always run": enriched="${enrichedCmd.substring(0, 80)}"`);
// Write response file so observer's pollResponseGroup picks it up and clicks the button
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: 0, // "Always run" is always the first button
step_type: data.step_type || 'command',
project_name: ctx.projectName,
};
fs.writeFileSync(
path.join(responseDir, `${rid}.json`),
JSON.stringify(respPayload),
'utf-8'
);
// Notify Discord (non-interactive "자동 승인" embed)
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
ctx.wsBridge.sendPending({
request_id: rid,
command: enrichedCmd || rawCmd,
description: enrichedDesc,
step_type: pending.step_type,
status: 'auto_approved',
buttons: pending.buttons,
project_name: ctx.projectName,
});
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, request_id: rid, auto_approved: true }));
return;
}
// 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 = enrichedCmd.toLowerCase();
if (cmdLower.includes('allow') && !pending.buttons) {
@@ -544,8 +673,10 @@ function _handleChatSnapshot(req: any, res: any, ctx: HttpBridgeContext) {
try {
const data = JSON.parse(body);
if (data.text && typeof ctx.writeChatSnapshot === 'function') {
ctx.writeChatSnapshot(`💬 **[DOM 추출] AI 응답**\n\n${data.text}`);
ctx.logToFile(`[HTTP] chat snapshot written (${data.text.length} chars)`);
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 }));

View File

@@ -1,6 +1,6 @@
export function generateApprovalObserverScript(_port: number): string {
return `
// ── Gravity Bridge v17: Always Run Auto-Approve + Retry Detection ──
// ?€?€ Gravity Bridge v17: Always Run Auto-Approve + Retry Detection ?€?€
// v17: "Always run" auto-approve at bridge level + Retry button relay to Discord
(function(){
'use strict';
@@ -9,8 +9,14 @@ export function generateApprovalObserverScript(_port: number): string {
var THROTTLE_MS=500;
var CLEANUP_MS=300000;
function log(m){console.log('[GB Observer] '+m);}
log('v17 Script loaded — Always Run Auto-Approve + Retry Detection');
function log(m){
console.log('[GB Observer] '+m);
// v19: Relay important logs to extension via HTTP so they appear in extension.log
if (BASE && (m.indexOf('CV-CLASSES')!==-1 || m.indexOf('CV-CHILDREN')!==-1 || m.indexOf('child[')!==-1 || m.indexOf('CV found')!==-1 || m.indexOf('Conversation view')!==-1 || m.indexOf('BEACON')!==-1 || m.indexOf('ERROR')!==-1 || m.indexOf('chat relay')!==-1 || m.indexOf('user-cls')!==-1 || m.indexOf('CONTEXT')!==-1 || m.indexOf('BTN-DOM')!==-1 || m.indexOf('DEFERRED')!==-1 || m.indexOf('DETECTED')!==-1 || m.indexOf('ACCEPT')!==-1)) {
try { fetch(BASE+'/log', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({msg:m.substring(0,2000)})}); } catch(e){}
}
}
log('v17 Script loaded ??Always Run Auto-Approve + Retry Detection');
// DIAGNOSTIC BEACON: immediate POST to confirm script execution in renderer
try {
@@ -33,7 +39,7 @@ export function generateApprovalObserverScript(_port: number): string {
}
}
// ── Noise filter: lines that are UI artifacts, not real content ──
// ?€?€ Noise filter: lines that are UI artifacts, not real content ?€?€
var NOISE_RE = new RegExp(
'^(' +
'chevron_right|chevron_left|arrow_drop_down|arrow_drop_up|arrow_right|arrow_left|' +
@@ -108,10 +114,10 @@ export function generateApprovalObserverScript(_port: number): string {
return type+'|'+txt+'|'+idx;
}
// ══════════════════════════════════════════════════════════════════
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
// v7: STEP-AWARE CONTEXT EXTRACTION
// Find the closest [data-step-index] ancestor, extract step info
// ══════════════════════════════════════════════════════════════════
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
function getStepContainer(el) {
var node = el;
@@ -123,9 +129,9 @@ export function generateApprovalObserverScript(_port: number): string {
}
// v14: Climb DOM tree to find context near the button
// STRICT SCOPE: Only 5 levels up beyond that we're in unrelated UI territory.
// STRICT SCOPE: Only 5 levels up ??beyond that we're in unrelated UI territory.
// JUNK FILTERS: CSS rules, source code, Material icon gluing are all rejected.
// NO FALLBACK: span/div/p text collection is removed entirely it always grabs chat/UI text.
// NO FALLBACK: span/div/p text collection is removed entirely ??it always grabs chat/UI text.
var PROMPT_ONLY_RE = /^[^\\n]*[\\/\>\\xbb$#]\\s*$/;
// v14: Detect CSS rules, JS source code, or extension internals in code text
var JUNK_CODE_RE = /(!important|::selection|background-color:|var\\(--|font-size:|border-[a-z]|padding:|margin:|display:\\s|\\{[^}]*:[^}]*\\}|===|!==|\\|\\||\\bfunction\\s*\\(|\\bconst\\s+\\w+\\s*=|\\bvar\\s+\\w+\\s*=|\\bif\\s*\\(|\\breturn\\b|\\bimport\\s|\\bexport\\s|\\bclass\\s+\\w|\\bnew\\s+\\w|\\.test\\(|\\.match\\(|\\.replace\\(|_RE[.\\s]|\\brawDesc\\b|\\brawCmd\\b|\\benrichedCmd\\b|\\bquerySelector)/;
@@ -138,15 +144,31 @@ export function generateApprovalObserverScript(_port: number): string {
var _bestCodeHeader = '';
var _sawCodeEls = false;
var _allSkipped = true;
for (var depth = 0; depth < 5 && node; depth++) {
// v22: Increased from 5 to 10 ??AG Native command display (SRi) can be many levels up
for (var depth = 0; depth < 10 && node; depth++) {
if (!node.querySelector) { _debugTrail.push('d'+depth+':noQS'); node = node.parentElement; continue; }
var codeEls = node.querySelectorAll('pre, code, [class*="terminal"]');
// v22: Prioritize pre.font-mono (AG Native command line display from SRi component)
var codeEls = node.querySelectorAll('pre.font-mono, pre, code, [class*="terminal"]');
_debugTrail.push('d'+depth+':tag='+((node.tagName||'?').toLowerCase())+',cls='+(((typeof node.className==='string')?node.className:'').substring(0,60))+',codeEls='+codeEls.length);
for (var ci = 0; ci < codeEls.length; ci++) {
var codeText = cleanLines((codeEls[ci].textContent || '').trim().substring(0, 500));
if (!codeText || codeText.length <= 5) continue;
if (/^Running\\s*\\d/i.test(codeText)) continue;
_sawCodeEls = true;
// v32: Terminal prompt detection — extract command BEFORE JUNK/PROMPT filters
// PS/bash commands can contain JS keywords (return, function, const) → false JUNK matches
var _termPromptMatch = codeText.match(/^[\\u276f\\u00bb]\\s+[^\\n]*[\\u003e]\\s+(.+)/);
if (_termPromptMatch && _termPromptMatch[1].trim().length > 2) {
var _termCmd = _termPromptMatch[1].trim();
_termCmd = _termCmd.replace(/\\s*(content_copy|content_paste|play_arrow)\\s*$/, '').trim();
if (_termCmd.length > 2) {
_allSkipped = false;
_bestCodeText = 'Running command: ' + _termCmd;
log('CONTEXT-OK d='+depth+' src=terminal-prompt cmd='+_termCmd.substring(0,80));
_lastContextDebug = _debugTrail.join(' ');
return _bestCodeText;
}
}
if (PROMPT_ONLY_RE.test(codeText.trim())) {
_debugTrail.push('skip_prompt_ci='+ci+':'+codeText.substring(0,30));
continue;
@@ -181,6 +203,84 @@ export function generateApprovalObserverScript(_port: number): string {
_lastContextDebug = _debugTrail.join(' > ');
return parts.join(' \u2014 ');
}
// v30: Command text is in plain divs near "Running command" header, not pre/code
var rcDivs = node.querySelectorAll('div');
// v30 diagnostic: log what we find at each depth where code was skipped
if (_sawCodeEls && rcDivs.length > 0 && depth <= 5) {
var rcSample = [];
for (var rdi = 0; rdi < Math.min(rcDivs.length, 8); rdi++) {
var rdt = (rcDivs[rdi].textContent || '').trim().substring(0,40);
var rdc = rcDivs[rdi].children ? rcDivs[rdi].children.length : 0;
rcSample.push('ch'+rdc+':"'+rdt+'"');
}
log('CONTEXT-v30-SCAN d='+depth+' divs='+rcDivs.length+' ['+rcSample.join(', ')+']');
}
for (var rci = 0; rci < rcDivs.length; rci++) {
var rcEl = rcDivs[rci];
var rcChildCount = rcEl.children ? rcEl.children.length : 0;
var rcTxt = (rcEl.textContent || '').trim();
if ((rcTxt === 'Running command' || (rcChildCount === 0 && rcTxt.indexOf('Running command') !== -1 && rcTxt.length < 30)) && rcEl.parentElement) {
var rcP = rcEl.parentElement;
var rcCands = [];
for (var rcsi = 0; rcsi < rcP.children.length; rcsi++) {
if (rcP.children[rcsi] === rcEl) continue;
var sibT = (rcP.children[rcsi].textContent || '').trim();
if (sibT.length < 5) continue;
if (/^(content_copy|content_paste|play_arrow|check_circle|chevron_|keyboard_arrow|more_horiz|more_vert|expand_|alternate_email|arrow_drop)/.test(sibT)) continue;
if (/^(Always|Run|Allow|Cancel|Deny|keyboard_arrow)/i.test(sibT)) continue;
if (sibT.indexOf('Always run') !== -1 && sibT.indexOf('Cancel') !== -1) continue;
rcCands.push(sibT);
}
rcCands.sort(function(a,b){ return b.length - a.length; });
log('CONTEXT-v30 RC d='+depth+' cands='+rcCands.length+(rcCands.length>0?' best="'+rcCands[0].substring(0,60)+'"':''));
for (var rcci = 0; rcci < rcCands.length; rcci++) {
var candT = rcCands[rcci];
var pM = candT.match(/[\\u003e\\u00bb\\u276f]\\s+(.+)/);
if (pM && pM[1].trim().length > 3) {
var cmdV = pM[1].trim();
cmdV = cmdV.replace(/\\s*(content_copy|content_paste|play_arrow|check_circle|keyboard_arrow[_a-z]*)\\s*$/, '').trim();
if (cmdV.length < 3) continue;
if (/^(Always|Run|Allow|Cancel|Deny)/i.test(cmdV)) continue;
log('CONTEXT-OK d='+depth+' src=running-cmd cmdV='+cmdV.substring(0,80));
_lastContextDebug = _debugTrail.join(' ');
return 'Running command: ' + cmdV.substring(0, 300);
}
if (candT.length > 10 && /[\\u276f\\u003e]/.test(candT)) {
var rawC = candT.replace(/\\s*(content_copy|content_paste|play_arrow)\\s*$/, '').trim();
log('CONTEXT-OK d='+depth+' src=running-cmd-raw rawC='+rawC.substring(0,80));
_lastContextDebug = _debugTrail.join(' ');
return rawC.substring(0, 300);
}
}
}
}
// v23: Also search sibling elements at each level
// AG Native's command display (pre.font-mono) is a SIBLING of footer, not ancestor
if (node && node.parentElement) {
var siblings = node.parentElement.children;
for (var si = 0; si < siblings.length; si++) {
if (siblings[si] === node) continue;
if (!siblings[si].querySelector) continue;
var sibCodeEls = siblings[si].querySelectorAll('pre.font-mono, pre, code');
for (var sci = 0; sci < sibCodeEls.length; sci++) {
var sibCode = cleanLines((sibCodeEls[sci].textContent || '').trim().substring(0, 500));
if (!sibCode || sibCode.length <= 5) continue;
if (JUNK_CODE_RE.test(sibCode) || ICON_GLUE_RE.test(sibCode)) continue;
if (PROMPT_ONLY_RE.test(sibCode.trim())) continue;
_debugTrail.push('sibling_d'+depth+':tag='+siblings[si].tagName.toLowerCase()+',code='+sibCode.substring(0,40));
_bestCodeText = sibCode;
_allSkipped = false;
// Found in sibling ??return immediately
var sibParts = [];
var sibHdr = siblings[si].querySelector('h1, h2, h3, [class*="header"], [class*="title"], [class*="cursor-pointer"]');
if (sibHdr) sibParts.push(cleanLines((sibHdr.textContent || '').trim().substring(0, 200)));
sibParts.push(sibCode);
log('CONTEXT-OK d='+depth+' src=sibling trail='+_debugTrail.join(' > '));
_lastContextDebug = _debugTrail.join(' > ');
return sibParts.join(' \u2014 ');
}
}
}
node = node.parentElement;
}
if (_sawCodeEls && _allSkipped) {
@@ -199,7 +299,7 @@ export function generateApprovalObserverScript(_port: number): string {
function extractStepContext(btn) {
var stepEl = getStepContainer(btn);
if (!stepEl) {
// v9 FALLBACK: no data-step-index climb DOM for pre/code blocks
// v9 FALLBACK: no data-step-index ??climb DOM for pre/code blocks
return extractContextFromNearby(btn);
}
@@ -235,7 +335,7 @@ export function generateApprovalObserverScript(_port: number): string {
if (codeText && !headerText.includes(codeText.substring(0, 20))) parts.push(codeText);
if (ariaLabel && ariaLabel.length > 5 && !headerText.includes(ariaLabel)) parts.push(ariaLabel);
var result = parts.join(' ');
var result = parts.join(' ??');
if (!result) result = cleanButtonText(btn);
return 'Step #' + stepIdx + ': ' + result;
}
@@ -251,7 +351,7 @@ export function generateApprovalObserverScript(_port: number): string {
for(var i=0; i<ACTION_WORDS.length; i++) {
if(txt.indexOf(ACTION_WORDS[i]) !== -1) return true;
}
// v9: Removed "Running N commands" it's a group header, not an approval button
// v9: Removed "Running N commands" ??it's a group header, not an approval button
return false;
}
function isRejectBtn(txt) {
@@ -331,17 +431,18 @@ export function generateApprovalObserverScript(_port: number): string {
startObserver();
});
// ══════════════════════════════════════════════════════════════════
// v8: FULL DOM STRUCTURE DUMP (unconditional no selector dependency)
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
// v8: FULL DOM STRUCTURE DUMP (unconditional ??no selector dependency)
// Dumps entire document.body tree to /dump-html for real DOM analysis
// Auto-triggers at 5s, 15s, 60s after load to capture React-rendered state
// ══════════════════════════════════════════════════════════════════
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
var _dumpCount=0;
var MAX_DUMPS=5;
var MAX_DUMPS=8;
var _conversationDumpCount=0;
function walkNode(el, depth, maxDepth, maxChildren) {
if (depth > maxDepth) return {tag:'',text:'depth limit'};
if (depth > maxDepth) return {tag:'MAX',text:'depth limit'};
if (!el || !el.tagName) return null;
var info = {
tag: el.tagName ? el.tagName.toLowerCase() : '#text',
@@ -377,7 +478,7 @@ export function generateApprovalObserverScript(_port: number): string {
if (childInfo) info.children.push(childInfo);
}
if (el.children.length > limit) {
info.children.push({tag: '', text: '+' + (el.children.length - limit) + ' more children'});
info.children.push({tag: 'TRUNC', text: '+' + (el.children.length - limit) + ' more children'});
}
}
return info;
@@ -472,11 +573,11 @@ export function generateApprovalObserverScript(_port: number): string {
setTimeout(function(){ log('Auto-dump @60s'); dumpDOMStructure(); }, 60000);
}
// ══════════════════════════════════════════════════════════════════
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
// v15: AG-NATIVE + CASCADE DUAL CHAT BODY SCANNING
// AG Native: #conversation > ... > .leading-relaxed.select-text
// Cascade: [data-testid="conversation-view"] > [data-step-index]
// ══════════════════════════════════════════════════════════════════
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
var _lastScrapedStepIndex = -1;
var _lastStepText = '';
@@ -484,15 +585,118 @@ export function generateApprovalObserverScript(_port: number): string {
var _lastStepTextSent = false;
var _lastResponseBlockCount = 0; // track number of response blocks for AG Native
function convertNodeToMarkdown(node) {
if (!node) return '';
if (node.nodeType === 3) return node.textContent; // Text node
if (node.nodeType !== 1) return ''; // Skip other node types
var tag = node.tagName.toLowerCase();
// Skip hidden or UI elements
if (tag === 'style' || tag === 'script' || tag === 'noscript' || tag === 'button' || tag === 'svg') return '';
var cls = '';
if (typeof node.className === 'string') cls = node.className;
else if (node.className && node.className.baseVal) cls = node.className.baseVal;
if (cls && (cls.indexOf('google-symbols') !== -1 || cls.indexOf('material-icons') !== -1 || cls.indexOf('copy') !== -1 || cls.indexOf('codicon') !== -1)) return '';
var childrenMd = '';
for (var i = 0; i < node.childNodes.length; i++) {
childrenMd += convertNodeToMarkdown(node.childNodes[i]);
}
// TABLE: Discord doesn't support markdown tables, so convert to fixed-width code block
if (tag === 'table') {
var rows = node.querySelectorAll('tr');
if (!rows || rows.length === 0) return childrenMd;
var grid = [];
var colWidths = [];
for (var ri = 0; ri < rows.length; ri++) {
var cells = rows[ri].querySelectorAll('th, td');
var row = [];
for (var ci = 0; ci < cells.length; ci++) {
var cellText = (cells[ci].textContent || '').trim();
row.push(cellText);
if (!colWidths[ci] || cellText.length > colWidths[ci]) colWidths[ci] = cellText.length;
}
grid.push(row);
}
// Build fixed-width text
var tbl = '';
for (var ri2 = 0; ri2 < grid.length; ri2++) {
var line = '';
for (var ci2 = 0; ci2 < colWidths.length; ci2++) {
var cell = grid[ri2][ci2] || '';
var pad = colWidths[ci2] - cell.length;
var padding = '';
for (var pi = 0; pi < pad; pi++) padding += ' ';
line += (ci2 > 0 ? ' | ' : '') + cell + padding;
}
tbl += line + '\\n';
// Add separator after header row (first row)
if (ri2 === 0) {
var sep = '';
for (var si2 = 0; si2 < colWidths.length; si2++) {
var dashes = '';
for (var di = 0; di < colWidths[si2]; di++) dashes += '-';
sep += (si2 > 0 ? '-+-' : '') + dashes;
}
tbl += sep + '\\n';
}
}
return '\\n' + String.fromCharCode(96,96,96) + '\\n' + tbl + String.fromCharCode(96,96,96) + '\\n';
}
switch (tag) {
case 'h1': return '\\n# ' + childrenMd.trim() + '\\n';
case 'h2': return '\\n## ' + childrenMd.trim() + '\\n';
case 'h3': return '\\n### ' + childrenMd.trim() + '\\n';
case 'h4': return '\\n#### ' + childrenMd.trim() + '\\n';
case 'p': return '\\n' + childrenMd.trim() + '\\n';
case 'div':
// Treat specific divs as blocks if they end up behaving like paragraphs
if (cls.indexOf('block') !== -1 || cls.indexOf('message') !== -1) return '\\n' + childrenMd.trim() + '\\n';
return childrenMd;
case 'br': return '\\n';
case 'strong':
case 'b': return '**' + childrenMd + '**';
case 'em':
case 'i': return '*' + childrenMd + '*';
case 'a':
var href = node.getAttribute('href') || '';
return '[' + childrenMd + '](' + href + ')';
case 'code': return (node.parentNode && node.parentNode.tagName === 'PRE') ? childrenMd : (String.fromCharCode(96) + childrenMd + String.fromCharCode(96));
case 'pre': return '\\n' + String.fromCharCode(96,96,96) + '\\n' + childrenMd.trim() + '\\n' + String.fromCharCode(96,96,96) + '\\n';
case 'li':
var prefix = '- ';
if (node.parentNode && node.parentNode.tagName.toLowerCase() === 'ol') {
var idx = 1;
var curr = node.previousSibling;
while(curr) { if (curr.nodeType === 1 && curr.tagName.toLowerCase() === 'li') idx++; curr = curr.previousSibling; }
prefix = idx + '. ';
}
return '\\n' + prefix + childrenMd.trim();
case 'ul':
case 'ol': return '\\n' + childrenMd + '\\n';
case 'blockquote': return '\\n> ' + childrenMd.trim().split('\\n').join('\\n> ') + '\\n';
// Table sub-elements: already handled by the table case above via querySelectorAll
case 'thead':
case 'tbody':
case 'tfoot':
case 'tr':
case 'th':
case 'td': return '';
default: return childrenMd;
}
}
function extractCleanStepText(stepEl) {
if (!stepEl) return '';
// Clone the step element so we can strip UI elements without affecting the DOM
var clone = stepEl.cloneNode(true);
// v16: Remove style/script/noscript elements FIRST — AG Native markdown injects <style> blocks
// that contain CSS rules (e.g. remark-github-blockquote-alert/alert.css) whose textContent
// gets captured as AI response text
// v16: Remove style/script/noscript elements FIRST
var styleEls = clone.querySelectorAll('style, script, noscript, link[rel="stylesheet"]');
for (var si = 0; si < styleEls.length; si++) {
if (styleEls[si].parentNode) styleEls[si].parentNode.removeChild(styleEls[si]);
@@ -518,34 +722,90 @@ export function generateApprovalObserverScript(_port: number): string {
// Try to get text from markdown rendering area first
// AG Native uses .leading-relaxed.select-text, Cascade uses .markdown-body/.prose
var mdEl = clone.querySelector('.markdown-body, .prose, [class*="markdown"], [class*="rendered"]');
var mdEl = clone.querySelector('.markdown-body, .prose, [class*="markdown"], [class*="rendered"]') || clone;
// Use our custom DOM-to-Markdown parser instead of innerText
var rawText = convertNodeToMarkdown(mdEl).trim();
// v18 FIX: DO NOT apply cleanLines to full markdown content, it destroys valid code blocks
// Safely remove "Thought for X" lines only
rawText = rawText.replace(/Thought for \\d+s?/gi, '');
rawText = rawText.replace(/Thought for a few seconds/gi, '');
// v18 FIX: Temporarily attach to DOM to force layout computation for .innerText
// Without this, .innerText on unattached node behaves exactly like .textContent (loses block newlines)
var container = document.createElement('div');
container.style.position = 'absolute';
container.style.left = '-9999px';
container.style.top = '-9999px';
container.style.opacity = '0';
container.style.width = '800px';
container.appendChild(clone);
document.body.appendChild(container);
var targetEl = mdEl || clone;
var rawText = '';
try {
if (targetEl.innerText && targetEl.innerText.trim().length > 10) {
rawText = targetEl.innerText.trim();
} else {
// Fallback: get all text but filter aggressively
rawText = (targetEl.innerText || targetEl.textContent || '').trim();
}
} finally {
if (container.parentNode) container.parentNode.removeChild(container);
// Cleanup multiple empty lines
var lines = rawText.split('\\n');
var finalLines = [];
var lastEmpty = false;
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/\\s+$/, '');
if (line.length === 0) {
if (!lastEmpty && finalLines.length > 0) {
finalLines.push('');
lastEmpty = true;
}
} else {
finalLines.push(line);
lastEmpty = false;
}
}
// Apply line-by-line noise filter
return cleanLines(rawText).substring(0, 3500);
// v19: Post-process ??wrap markdown table patterns in code blocks for Discord
// AG Native renders tables as divs, not <table> HTML, so DOM-level handler can't catch them.
// Detect consecutive lines with pipe separators (| col1 | col2 |) and wrap in code block fences
var bt = String.fromCharCode(96, 96, 96);
var result = [];
var tableBlock = [];
var inCodeBlock = false;
for (var fi = 0; fi < finalLines.length; fi++) {
var fl = finalLines[fi];
// Track existing code blocks to avoid double-wrapping
if (fl.trim().indexOf(bt) === 0) {
inCodeBlock = !inCodeBlock;
// Flush any pending table block before code block marker
if (tableBlock.length > 0) {
result.push(bt);
for (var ti = 0; ti < tableBlock.length; ti++) result.push(tableBlock[ti]);
result.push(bt);
tableBlock = [];
}
result.push(fl);
continue;
}
if (inCodeBlock) {
result.push(fl);
continue;
}
// Detect table row: has at least 2 pipe characters and content between them
var pipeCount = 0;
for (var pc = 0; pc < fl.length; pc++) { if (fl.charAt(pc) === '|') pipeCount++; }
var isTableRow = pipeCount >= 2 && fl.trim().charAt(0) === '|';
var isSeparator = isTableRow && /^[\\s|:-]+$/.test(fl.trim());
if (isTableRow) {
tableBlock.push(fl);
} else {
// Flush table block if it had enough rows (header + separator + data)
if (tableBlock.length >= 2) {
result.push(bt);
for (var ti2 = 0; ti2 < tableBlock.length; ti2++) result.push(tableBlock[ti2]);
result.push(bt);
} else {
// Not a real table, push lines back normally
for (var ti3 = 0; ti3 < tableBlock.length; ti3++) result.push(tableBlock[ti3]);
}
tableBlock = [];
result.push(fl);
}
}
// Flush trailing table block
if (tableBlock.length >= 2) {
result.push(bt);
for (var ti4 = 0; ti4 < tableBlock.length; ti4++) result.push(tableBlock[ti4]);
result.push(bt);
} else {
for (var ti5 = 0; ti5 < tableBlock.length; ti5++) result.push(tableBlock[ti5]);
}
return result.join('\\n').substring(0, 3500);
}
function scanChatBodies() {
@@ -554,29 +814,108 @@ export function generateApprovalObserverScript(_port: number): string {
// One-time DOM dump
dumpDOMStructure();
// ── STRATEGY 1: AG Native #conversation or .antigravity-agent-side-panel ──
// ?€?€ STRATEGY 1: AG Native ??#conversation or .antigravity-agent-side-panel ?€?€
var cv = document.querySelector('#conversation');
if (!cv) {
cv = document.querySelector('.antigravity-agent-side-panel');
}
// v19: Fallback ??find conversation by tracing from known content elements
if (!cv) {
var probe = document.querySelector('.leading-relaxed.select-text') || document.querySelector('.text-ide-message-block-bot-color');
if (probe) {
// Walk up to find a reasonable container (has overflow-y or is big enough)
var p = probe.parentElement;
for (var pi2 = 0; pi2 < 10 && p && p !== document.body; pi2++) {
var pCls = (typeof p.className === 'string') ? p.className : '';
if (pCls.indexOf('overflow') !== -1 || p.children.length > 3) {
cv = p;
break;
}
p = p.parentElement;
}
if (!cv && probe.parentElement) cv = probe.parentElement.parentElement || probe.parentElement;
}
}
if (cv) {
// AG Native path: find AI response blocks by class pattern
// DOM structure: #conversation > ... > .leading-relaxed.select-text (AI response text)
var responseBlocks = cv.querySelectorAll('.leading-relaxed.select-text');
// v20: Dump CV structure for first 3 scans to ensure we capture it (even with stale HTML cache)
if (_conversationDumpCount < 3) {
_conversationDumpCount++;
log('CV found via: ' + (cv.id || (typeof cv.className === 'string' ? cv.className : cv.tagName) || 'unknown').substring(0, 100));
// Log all unique class names under #conversation for selector discovery
var allCvEls = cv.querySelectorAll('*');
var clsSet = {};
for (var ci2 = 0; ci2 < allCvEls.length; ci2++) {
var cn = allCvEls[ci2].className;
if (typeof cn === 'string' && cn.length > 0) {
var parts = cn.split(/\\s+/);
for (var pi = 0; pi < parts.length; pi++) {
if (parts[pi].length > 3 && !clsSet[parts[pi]]) clsSet[parts[pi]] = true;
}
}
}
var clsList = Object.keys(clsSet).sort().join(', ');
log('CV-CLASSES (' + Object.keys(clsSet).length + '): ' + clsList.substring(0, 1500));
// v19: Log direct children to discover message block structure
var cvKids = cv.children;
log('CV-CHILDREN (' + cvKids.length + '):');
for (var ck = 0; ck < Math.min(cvKids.length, 15); ck++) {
var kid = cvKids[ck];
var kidCls = (typeof kid.className === 'string') ? kid.className : '';
var kidText = (kid.textContent || '').trim().substring(0, 60);
log(' child[' + ck + '] tag=' + kid.tagName + ' cls=' + kidCls.substring(0, 120) + ' text=' + kidText);
}
// v22: Deep-dive into gap-8 container to find individual message blocks
var msgContainer = cv.querySelector('.gap-8') || cv.children[0];
if (msgContainer) {
var msgKids = msgContainer.children;
log('MSG-BLOCKS (' + msgKids.length + '):');
for (var mk = 0; mk < Math.min(msgKids.length, 30); mk++) {
var mb = msgKids[mk];
var mbCls = (typeof mb.className === 'string') ? mb.className : '';
var mbText = (mb.textContent || '').trim().substring(0, 80);
var hasLeadingRelaxed = mb.querySelector('.leading-relaxed.select-text') ? 'Y' : 'N';
var firstChildCls = (mb.children[0] && typeof mb.children[0].className === 'string') ? mb.children[0].className : '';
log(' msg[' + mk + '] cls=' + mbCls.substring(0, 120) + ' lr=' + hasLeadingRelaxed + ' fc=' + firstChildCls.substring(0, 80) + ' text=' + mbText.substring(0, 60));
}
}
// Force a dump with conversation context
dumpDOMStructure();
}
// v22: AI response = .leading-relaxed.select-text, User message = .select-text.rounded-lg (Esn component, msn class)
// Source: jetskiAgent/main.js ??msn="bg-gray-500/10 border border-gray-500/20 p-2 rounded-lg w-full text-sm select-text"
var responseBlocks = cv.querySelectorAll('.leading-relaxed.select-text, .select-text.rounded-lg');
if (responseBlocks.length > 0) {
// Process the LAST (most recent) response block
var lastBlock = responseBlocks[responseBlocks.length - 1];
// v22: Filter out thinking/reasoning blocks ??they have ancestor with max-h-[200px]
// These are internal AI reasoning and should NOT be relayed to Discord
var filteredBlocks = [];
for (var fbi = 0; fbi < responseBlocks.length; fbi++) {
var isThinking = false;
var ancestor = responseBlocks[fbi].parentElement;
for (var depth = 0; ancestor && depth < 5; depth++) {
var aCls = (typeof ancestor.className === 'string') ? ancestor.className : '';
if (aCls.indexOf('max-h-[200px]') !== -1 || aCls.indexOf('max-h-[150px]') !== -1) {
isThinking = true;
break;
}
ancestor = ancestor.parentElement;
}
if (!isThinking) filteredBlocks.push(responseBlocks[fbi]);
}
if (filteredBlocks.length === 0) return;
// Process the LAST (most recent) non-thinking response block
var lastBlock = filteredBlocks[filteredBlocks.length - 1];
// Skip if already scraped
if (lastBlock.dataset.agChatScraped === 'true' || lastBlock.dataset.agChatScraped === 'pending') {
// Check for NEW blocks since last scrape
if (responseBlocks.length > _lastResponseBlockCount) {
// New block appeared process it
for (var rbi = responseBlocks.length - 1; rbi >= 0; rbi--) {
if (responseBlocks[rbi].dataset.agChatScraped !== 'true' && responseBlocks[rbi].dataset.agChatScraped !== 'pending') {
lastBlock = responseBlocks[rbi];
if (filteredBlocks.length > _lastResponseBlockCount) {
// New block appeared ??process it
for (var rbi = filteredBlocks.length - 1; rbi >= 0; rbi--) {
if (filteredBlocks[rbi].dataset.agChatScraped !== 'true' && filteredBlocks[rbi].dataset.agChatScraped !== 'pending') {
lastBlock = filteredBlocks[rbi];
break;
}
}
@@ -587,13 +926,26 @@ export function generateApprovalObserverScript(_port: number): string {
}
var blockText = extractCleanStepText(lastBlock);
if (blockText && blockText.length > 30) {
// QUALITY CHECK: Skip if the text is mostly short lines (UI artifacts)
var lines = blockText.split('\\n').filter(function(l) { return l.trim().length > 0; });
var longLines = lines.filter(function(l) { return l.trim().length > 20; });
if (longLines.length === 0) {
log('AG-Native: skipped (no long lines, likely UI noise)');
return;
var clsStr = (typeof lastBlock.className === 'string') ? lastBlock.className : '';
// v19: Log block classes for user message selector discovery
var parentCls = lastBlock.parentElement ? ((typeof lastBlock.parentElement.className === 'string') ? lastBlock.parentElement.className : '') : '';
var grandCls = (lastBlock.parentElement && lastBlock.parentElement.parentElement) ? ((typeof lastBlock.parentElement.parentElement.className === 'string') ? lastBlock.parentElement.parentElement.className : '') : '';
log('user-cls-debug block=' + clsStr.substring(0, 150) + ' | parent=' + parentCls.substring(0, 150) + ' | grand=' + grandCls.substring(0, 150) + ' | text=' + (blockText||'').substring(0, 50));
// v22: Detect user message: has select-text + rounded-lg but NOT leading-relaxed
var isUser = (clsStr.indexOf('rounded-lg') !== -1 && clsStr.indexOf('leading-relaxed') === -1) || clsStr.indexOf('user-color') !== -1;
var role = isUser ? 'user' : 'bot';
// Bot messages often start empty and stream in. User messages are usually immediate.
if (blockText && (blockText.length > 30 || isUser && blockText.length > 0)) {
// QUALITY CHECK: Skip if the text is mostly short lines (UI artifacts), BUT skip this check for user messages
if (!isUser) {
var lines = blockText.split('\\n').filter(function(l) { return l.trim().length > 0; });
var longLines = lines.filter(function(l) { return l.trim().length > 20; });
if (longLines.length === 0) {
log('AG-Native: skipped (no long lines, likely UI noise)');
return;
}
}
// Wait for content to stabilize (3s no change)
@@ -605,28 +957,32 @@ export function generateApprovalObserverScript(_port: number): string {
}
if (_lastStepTextSent) return;
if (Date.now() - _lastStepTextTime < 3000) return; // Still waiting
// Bot needs 3s to stabilize, User just needs 500ms
var waitTime = isUser ? 500 : 3000;
if (Date.now() - _lastStepTextTime < waitTime) return; // Still waiting
// Content is stable — send it
// v21: DOM-based chat relay RE-ENABLED ??GetCascadeTrajectorySteps does NOT
// return steps for in-progress cascades, making Step Probe RT-CAPTURE useless.
// Observer DOM extraction is the ONLY real-time path for AI response relay.
_lastStepTextSent = true;
_lastResponseBlockCount = responseBlocks.length;
_lastResponseBlockCount = filteredBlocks.length;
lastBlock.dataset.agChatScraped = 'pending';
log('AG-Native chat relay: blocks=' + responseBlocks.length + ' text=' + blockText.length + ' chars');
(function(el, txt, count) {
log('AG-Native chat relay [' + role + ']: blocks=' + filteredBlocks.length + ' text=' + blockText.length + ' chars');
(function(el, txt, count, r) {
fetch(BASE + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: txt, source: 'ag_native_block_' + count, block_index: count })
body: JSON.stringify({ text: txt, source: 'ag_native_block_' + count, block_index: count, role: r })
}).then(function() { el.dataset.agChatScraped = 'true'; log('AG-Native chat sent OK'); })
.catch(function(e) { el.dataset.agChatScraped = 'false'; log('AG-Native chat send error: ' + e.message); });
})(lastBlock, blockText, responseBlocks.length);
})(lastBlock, blockText, filteredBlocks.length, role);
}
return; // AG Native path handled don't fall through to Cascade path
return; // AG Native path handled ??don't fall through to Cascade path
}
}
// ── STRATEGY 2: Cascade [data-testid="conversation-view"] ──
// ?€?€ STRATEGY 2: Cascade ??[data-testid="conversation-view"] ?€?€
cv = document.querySelector('[data-testid="conversation-view"]');
if (!cv) {
// FALLBACK: Try older selectors
@@ -698,7 +1054,7 @@ export function generateApprovalObserverScript(_port: number): string {
if (_lastStepTextSent) continue;
if (Date.now() - _lastStepTextTime < 3000) break; // Still waiting
// Content is stable send it
// Content is stable ??send it
_lastStepTextSent = true;
_lastScrapedStepIndex = stepIdx;
stepEl.dataset.agChatScraped = 'pending';
@@ -716,34 +1072,52 @@ export function generateApprovalObserverScript(_port: number): string {
}
}
// ══════════════════════════════════════════════════════════════════
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
// BUTTON SCANNING (approval detection)
// ══════════════════════════════════════════════════════════════════
// ?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧?먥븧
function scan(){
if(!_ready)return;
scanChatBodies();
var now=Date.now();
var allBtns=document.querySelectorAll('button');
var allBtns=document.querySelectorAll('button, [role="button"], a.monaco-button, .monaco-text-button, vscode-button, span.cursor-pointer');
if(!allBtns.length)return;
// v25: One-shot debug ??find Accept/Reject elements in ANY tag (run once per 30s)
if (!scan._lastAcceptScan || now - scan._lastAcceptScan > 30000) {
scan._lastAcceptScan = now;
var allEls = document.querySelectorAll('button, a, div, span, [role="button"]');
for (var ai = 0; ai < allEls.length; ai++) {
var aTxt = (allEls[ai].textContent || '').trim();
if (aTxt.length > 2 && aTxt.length < 30 && /Accept|Reject all/i.test(aTxt)) {
log('ACCEPT-SCAN tag=' + allEls[ai].tagName + ' cls=' + (allEls[ai].className || '').substring(0,80) + ' txt=' + aTxt.substring(0,40) + ' oP=' + !!allEls[ai].offsetParent + ' dis=' + allEls[ai].disabled + ' hid=' + allEls[ai].hidden);
}
}
}
for(var j=0;j<allBtns.length;j++){
var b=allBtns[j];
if(b.disabled||b.hidden||(!b.offsetParent&&b.style.display!=='fixed'))continue;
// v24: Visibility check moved after txt extraction (see isDiffReviewBtn below)
var txt=cleanButtonText(b);
if(txt.length <= 1) continue;
// v9: Skip group header buttons not approval buttons
// v9: Skip group header buttons ??not approval buttons
if (/^Running\\s*\\d+\\s*commands?$/i.test(txt)) continue;
// v24: Relaxed visibility check ??Accept all/Reject all buttons in AG Native
// editor bottom bar may have offsetParent===null (different rendering layer)
var isDiffReviewBtn = txt.includes('Accept') || txt === 'Reject all';
if(!isDiffReviewBtn && (b.disabled||b.hidden||(!b.offsetParent&&b.style.display!=='fixed')))continue;
if(!isActionBtn(txt)) continue;
// Skip inline code lens buttons
if (b.closest('.codelens-decoration') && !txt.includes('Accept')) {
continue;
}
var matchedType = txt.includes('Accept') ? 'diff_review' : (txt === 'Retry' ? 'retry' : (txt.includes('Run') || txt.includes('Allow') ? 'command' : 'permission'));
var txtLow = txt.toLowerCase();
var matchedType = txt.includes('Accept') ? 'diff_review' : (txt === 'Retry' ? 'retry' : (txtLow.includes('run') || txtLow.includes('allow') ? 'command' : 'permission'));
// v7: Use step-index for more unique group key
var stepContainer = getStepContainer(b);
@@ -768,12 +1142,88 @@ export function generateApprovalObserverScript(_port: number): string {
var desc=extractContext(b);
// v28: One-shot DOM structure dump for button context analysis
if (!window._btnDomDumped && txtLow.includes('run')) {
window._btnDomDumped = true;
var dumpLines = [];
var cur = b;
for (var dd = 0; dd < 10 && cur; dd++) {
var childSummary = [];
if (cur.children) {
for (var ci2 = 0; ci2 < Math.min(cur.children.length, 8); ci2++) {
var ch = cur.children[ci2];
var chTag = (ch.tagName || '?').toLowerCase();
var chCls = (typeof ch.className === 'string' ? ch.className : '').substring(0, 40);
var chText = (ch.textContent || '').substring(0, 30).replace(/\\n/g, ' ');
childSummary.push(chTag + '.' + chCls.split(' ')[0] + '=' + chText);
}
}
var sibSummary = [];
if (cur.parentElement) {
for (var si2 = 0; si2 < Math.min(cur.parentElement.children.length, 6); si2++) {
var sib = cur.parentElement.children[si2];
var sibTag = (sib.tagName || '?').toLowerCase();
var sibCls = (typeof sib.className === 'string' ? sib.className : '').substring(0, 30);
sibSummary.push(sibTag + '.' + sibCls.split(' ')[0] + (sib === cur ? '*' : ''));
}
}
dumpLines.push('d' + dd + ':' + (cur.tagName || '?').toLowerCase() + ' cls=' + (typeof cur.className === 'string' ? cur.className : '').substring(0, 50) + ' | children=[' + childSummary.join(', ') + '] | siblings=[' + sibSummary.join(', ') + ']');
cur = cur.parentElement;
}
log('BTN-DOM-DUMP txt=' + txt + ' desc=' + desc.substring(0, 40));
for (var ddi = 0; ddi < dumpLines.length; ddi++) {
log('BTN-DOM-DUMP ' + dumpLines[ddi]);
}
}
var rid=now.toString()+'_'+Math.random().toString(36).substring(2,6);
_sent[groupKey]={rid:rid,ts:now};
for(var mk=0;mk<bidList.length;mk++)_sent[bidList[mk]]={rid:rid,ts:now};
log('DETECTED '+matchedType+': '+txt+' ['+desc.substring(0,80)+'] step='+stepIdx);
// v26: Deferred context (string match, not regex)
var _isGenericDesc = function(d) {
var t = d.trim().toLowerCase();
return t === 'always run' || t === 'run' || t === 'allow' || t === 'accept' || t === 'retry' || t === txt.toLowerCase();
};
// v26: Deferred context ??if desc is generic ("Always run", button text only),
// delay 500ms and re-extract to allow DOM rendering to complete
var isGenericDesc = _isGenericDesc(desc);
if (isGenericDesc && matchedType === 'command') {
log('DEFERRED-CONTEXT: desc="' + desc.substring(0,30) + '" ??waiting 500ms for DOM render');
(function(b2, rid2, btnRefs2, bidList2, groupKey2, txt2, type2, buttonsArr2) {
setTimeout(function() {
var retryDesc = extractContext(b2);
var finalDesc = _isGenericDesc(retryDesc) ? desc : retryDesc;
log('DEFERRED-RESULT: "' + finalDesc.substring(0,80) + '"');
var payload = {
request_id: rid2,
command: txt2,
description: finalDesc,
step_type: type2,
buttons: buttonsArr2,
_debug_trail: _lastContextDebug || ''
};
fetch(BASE+'/pending',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(payload)
}).then(function(r){return r.json();}).then(function(d){
if (!d.ok || d.filtered) {
delete _sent[groupKey2];
for(var di=0;di<bidList2.length;di++)delete _sent[bidList2[di]];
return;
}
pollResponseGroup(d.request_id,btnRefs2,bidList2,groupKey2);
}).catch(function(e){
delete _sent[groupKey2];
for(var di=0;di<bidList2.length;di++)delete _sent[bidList2[di]];
});
}, 500);
})(b, rid, btnRefs, bidList, groupKey, txt, matchedType, buttonsArr);
} else {
// Original immediate send path
log('DETECTED '+matchedType+': '+txt+' ['+desc.substring(0,80)+'] step='+stepIdx);
(function(rid2,btnRefs2,bidList2,groupKey2,txt2,desc2,type2,buttonsArr2){
var payload={
@@ -800,6 +1250,7 @@ export function generateApprovalObserverScript(_port: number): string {
for(var di=0;di<bidList2.length;di++)delete _sent[bidList2[di]];
});
})(rid,btnRefs,bidList,groupKey,txt,desc,matchedType,buttonsArr);
} // end else (immediate send)
break;
}
@@ -883,7 +1334,7 @@ export function generateApprovalObserverScript(_port: number): string {
function startObserver(){
if(_obs)return;
log('startObserver() scheduling auto-dumps and mutation observer');
log('startObserver() ??scheduling auto-dumps and mutation observer');
scheduleAutoDumps();
new MutationObserver(function(mutations){
for(var i=0;i<mutations.length;i++){
@@ -895,7 +1346,7 @@ export function generateApprovalObserverScript(_port: number): string {
}).observe(document.body,{childList:true,subtree:true});
setInterval(scheduleScan,3000);
// ── TRIGGER-CLICK POLLING ──
// ?€?€ TRIGGER-CLICK POLLING ?€?€
(function pollTriggerClick(){
if(_ready&&BASE){
fetch(BASE+'/trigger-click?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
@@ -918,12 +1369,12 @@ export function generateApprovalObserverScript(_port: number): string {
setTimeout(pollTriggerClick, 2000);
})();
// ── DEEP-INSPECT POLLING (v8: full body dump) ──
// ?€?€ DEEP-INSPECT POLLING (v8: full body dump) ?€?€
(function pollDeepInspect(){
if(_ready&&BASE){
fetch(BASE+'/deep-inspect-trigger?t='+Date.now()).then(function(r){return r.json();}).then(function(d){
if(!d.inspect)return;
log('Deep inspect triggered full body dump');
log('Deep inspect triggered ??full body dump');
// Force a fresh DOM dump
_dumpCount = Math.max(0, _dumpCount - 1); // allow one more dump
dumpDOMStructure();
@@ -973,3 +1424,4 @@ export function generateApprovalObserverScript(_port: number): string {
})();
`;
}

View File

@@ -41,6 +41,9 @@ let activeTrajectoryId = '';
const recentPendingSteps = new Map<string, number>();
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>();
@@ -79,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.
@@ -453,28 +464,70 @@ function setupMonitor() {
// ── v15: Heartbeat probe — detect step changes when summary API is stale ──
// GetAllCascadeTrajectories can return frozen stepCount/lastModifiedTime,
// preventing delta>0 from ever firing. Every 10 polls (~50s), directly
// probe GetCascadeTrajectorySteps to get the REAL latest step count.
if (delta === 0 && ctx.sdk && ctx.activeSessionId && pollCount % 10 === 0) {
// 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 - 1);
const hbOffset = Math.max(0, currentCount - 5);
const hbResp = await ctx.sdk.ls.rawRPC('GetCascadeTrajectorySteps', {
cascadeId: ctx.activeSessionId,
stepOffset: hbOffset,
verbosity: 0, // minimal — just need step count
verbosity: 1, // need content for capture
});
if (hbResp?.steps?.length > 0) {
const realStepCount = hbOffset + hbResp.steps.length;
if (realStepCount > lastKnownStepCount) {
ctx.logToFile(`[HEARTBEAT] summary stale! reported=${lastKnownStepCount} real=${realStepCount} — correcting`);
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;
// Trigger RT-CAPTURE by re-entering delta>0 path below
// We set currentCount so delta recalculation works
} 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) {
// Non-critical — will retry next heartbeat
if (pollCount <= 30) ctx.logToFile(`[HEARTBEAT] probe error: ${hbErr.message?.substring(0, 60)}`);
if (pollCount % 10 === 0) ctx.logToFile(`[HEARTBEAT] probe error: ${hbErr.message?.substring(0, 100)}`);
}
}
@@ -528,12 +581,46 @@ function setupMonitor() {
}
}
// 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;
@@ -721,6 +808,19 @@ function setupMonitor() {
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
@@ -774,6 +874,20 @@ function setupMonitor() {
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);
}
}
}
// NOTE: no break — process ALL parallel WAITING steps