refactor(extension): split extension.ts into 3 modules - http-bridge, html-patcher, command-handler (#398)
This commit is contained in:
@@ -49,10 +49,16 @@ gravity_control/
|
|||||||
│
|
│
|
||||||
├── ── Extension 측 (TypeScript) ──
|
├── ── Extension 측 (TypeScript) ──
|
||||||
├── extension/src/
|
├── extension/src/
|
||||||
│ ├── extension.ts # 메인: SDK init, activate, HTTP bridge (1,290줄)
|
│ ├── extension.ts # 메인: SDK init, activate, 오케스트레이션 (650줄)
|
||||||
│ ├── step-probe.ts # 폴링 + 응답 처리 + 승인 전략 (1,435줄)
|
│ ├── step-probe.ts # 폴링 + 응답 처리 + 승인 전략 (1,479줄)
|
||||||
│ │ # setupMonitor(), processResponseFile(),
|
│ │ # setupMonitor(), processResponseFile(),
|
||||||
│ │ # writePendingApproval(), tryApprovalStrategies()
|
│ │ # writePendingApproval(), tryApprovalStrategies()
|
||||||
|
│ ├── http-bridge.ts # HTTP 서버 (Renderer↔Extension Host 통신) (280줄)
|
||||||
|
│ │ # startHttpBridge(), getDeterministicPort()
|
||||||
|
│ ├── html-patcher.ts # AG HTML 패치 + product.json 체크섬 (280줄)
|
||||||
|
│ │ # setupApprovalObserver(), updateProductChecksums()
|
||||||
|
│ ├── command-handler.ts # Discord→AG 명령어 처리 (175줄)
|
||||||
|
│ │ # watchCommandsDir(), handleWSCommand()
|
||||||
│ ├── observer-script.ts # DOM Observer 스크립트 생성 (698줄)
|
│ ├── observer-script.ts # DOM Observer 스크립트 생성 (698줄)
|
||||||
│ │ # generateApprovalObserverScript()
|
│ │ # generateApprovalObserverScript()
|
||||||
│ ├── ws-client.ts # WSBridgeClient: Hub 연결, 재연결, 큐 (505줄)
|
│ ├── ws-client.ts # WSBridgeClient: Hub 연결, 재연결, 큐 (505줄)
|
||||||
@@ -122,17 +128,49 @@ gravity_control/
|
|||||||
| Hub 연동 | on_hub_pending, on_hub_chat, on_hub_register 핸들러 |
|
| Hub 연동 | on_hub_pending, on_hub_chat, on_hub_register 핸들러 |
|
||||||
| IDLE 알림 | AI step 종료 시 Discord 알림 |
|
| IDLE 알림 | AI step 종료 시 Discord 알림 |
|
||||||
|
|
||||||
### 3.4 Extension (extension.ts) — VS Code 확장
|
### 3.4 Extension (extension.ts) — VS Code 확장 (오케스트레이터)
|
||||||
|
|
||||||
| 기능 | 설명 |
|
| 기능 | 설명 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| AG SDK 연동 | getCascadeStatus, getAllTrajectories RPC |
|
| AG SDK 연동 | getCascadeStatus, getAllTrajectories RPC |
|
||||||
| 세션 감지 | activeSessionId 자동 추적 |
|
| 세션 감지 | activeSessionId 자동 추적 |
|
||||||
| 프로젝트 자동 감지 | git remote URL 기반 |
|
| 프로젝트 자동 감지 | git remote URL 기반 |
|
||||||
| HTTP bridge | 파일 기반 pending/response 읽기쓰기 (레거시) |
|
| 모듈 초기화 | HTTP bridge, observer, command handler 시작 |
|
||||||
| WS bridge | WSBridgeClient 통한 Hub 연결 (우선) |
|
| WS bridge | WSBridgeClient 통한 Hub 연결 (우선) |
|
||||||
| Status bar | SDK 상태 + 연결 상태 표시 |
|
| Status bar | SDK 상태 + 연결 상태 표시 |
|
||||||
|
|
||||||
|
### 3.4a HTTP Bridge (http-bridge.ts)
|
||||||
|
|
||||||
|
`HttpBridgeContext` 인터페이스로 extension.ts의 공유 상태 참조:
|
||||||
|
|
||||||
|
| 기능 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| POST /pending | Renderer가 발견한 승인 버튼 보고 |
|
||||||
|
| GET /response/:rid | Renderer가 Discord 응답 폴링 |
|
||||||
|
| GET /trigger-click | Extension→Renderer 클릭 트리거 |
|
||||||
|
| GET/POST /deep-inspect* | DOM 심층 검사 |
|
||||||
|
| getDeterministicPort | 프로젝트명 기반 결정적 포트 |
|
||||||
|
|
||||||
|
### 3.4b HTML Patcher (html-patcher.ts)
|
||||||
|
|
||||||
|
| 기능 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| setupApprovalObserver | AG Workbench HTML 파일에 observer 스크립트 인라인 삽입 |
|
||||||
|
| updateProductChecksums | product.json SHA256 체크섬 업데이트 (vscode-file:// 프로토콜용) |
|
||||||
|
| CSP 패치 | script-src에 'unsafe-inline' 추가 |
|
||||||
|
| .orig 백업 | 최초 패치 전 원본 백업, 손상 시 자동 복구 |
|
||||||
|
|
||||||
|
### 3.4c Command Handler (command-handler.ts)
|
||||||
|
|
||||||
|
`CommandHandlerContext` 인터페이스로 extension.ts 상태 참조:
|
||||||
|
|
||||||
|
| 기능 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| watchCommandsDir | commands/ 디렉토리 fs.watch + 3s 폴링 |
|
||||||
|
| handleWSCommand | WS Hub 경유 명령어 처리 |
|
||||||
|
| !stop, !auto | AG 에이전트 제어 명령어 |
|
||||||
|
| 텍스트 전달 | Discord → AG `sendPromptToAgentPanel` |
|
||||||
|
|
||||||
### 3.5 Step Probe (step-probe.ts) — 상태 폴링
|
### 3.5 Step Probe (step-probe.ts) — 상태 폴링
|
||||||
|
|
||||||
`BridgeContext` 인터페이스로 extension.ts와 상태 공유:
|
`BridgeContext` 인터페이스로 extension.ts와 상태 공유:
|
||||||
@@ -146,7 +184,7 @@ gravity_control/
|
|||||||
| setupResponseWatcher | response/ 디렉토리 파일 감시 |
|
| setupResponseWatcher | response/ 디렉토리 파일 감시 |
|
||||||
|
|
||||||
**BridgeContext 필드** (14개):
|
**BridgeContext 필드** (14개):
|
||||||
`bridgePath`, `projectName`, `sdk`, `wsBridge`, `logToFile`, `autoApproveEnabled`, `activeSessionId`, `sentPendingIds`, `deterministicPort`, `recentDiscordSentTexts`, `writeChatSnapshot`, `writeChatSnapshotWithFiles`, `workspaceUri`, `diffReviewMetadata`
|
`bridgePath`, `projectName`, `sdk`, `wsBridge`, `logToFile`, `autoApproveEnabled`, `activeSessionId`, `setClickTrigger`, `recentDiscordSentTexts`, `writeChatSnapshot`, `writeChatSnapshotWithFiles`, `workspaceUri`, `diffReviewMetadata`, `sessionStalled`, `lastPendingStepIndex`, `stallProbed`, `sawRunningAfterPending`
|
||||||
|
|
||||||
### 3.6 WS Client (ws-client.ts) — Hub 클라이언트
|
### 3.6 WS Client (ws-client.ts) — Hub 클라이언트
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
| 010 | 06:50~07:39 | 문서 전면 재작성 + 서버 배포 + WS 호환 수정 | `6ea3211` | ✅ |
|
| 010 | 06:50~07:39 | 문서 전면 재작성 + 서버 배포 + WS 호환 수정 | `6ea3211` | ✅ |
|
||||||
| 011 | 07:44~08:18 | VSIX v0.4.0 E2E 사전 검증 + WS 프록시 수정 | — | 🔧 |
|
| 011 | 07:44~08:18 | VSIX v0.4.0 E2E 사전 검증 + WS 프록시 수정 | — | 🔧 |
|
||||||
| 012 | 09:00~17:44 | VSIX E2E: workspaceUri, 이중발송, ApprovalRequest, ApprovalView WS, 응답 라우팅 | `2eea5fa` | ✅ |
|
| 012 | 09:00~17:44 | VSIX E2E: workspaceUri, 이중발송, ApprovalRequest, ApprovalView WS, 응답 라우팅 | `2eea5fa` | ✅ |
|
||||||
|
| 013 | 18:05~18:45 | Extension 모듈 분리 #398: http-bridge, html-patcher, command-handler 추출 (1296→650줄) | `2d8266e` | ✅ |
|
||||||
|
|
||||||
### #010 상세
|
### #010 상세
|
||||||
- **문서**: architecture.md(250줄), tech-stack.md(100줄), conventions.md(100줄) 전면 재작성 + Wiki 동기화
|
- **문서**: architecture.md(250줄), tech-stack.md(100줄), conventions.md(100줄) 전면 재작성 + Wiki 동기화
|
||||||
|
|||||||
189
extension/src/command-handler.ts
Normal file
189
extension/src/command-handler.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Command Handler — Discord → AG command processing.
|
||||||
|
*
|
||||||
|
* Extracted from extension.ts to reduce file size.
|
||||||
|
* Handles commands from both:
|
||||||
|
* - File-based bridge (legacy/fallback): fs.watch + polling
|
||||||
|
* - WebSocket Hub (primary): direct callbacks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// ─── Context interface (shared state from extension.ts) ───
|
||||||
|
|
||||||
|
export interface CommandHandlerContext {
|
||||||
|
bridgePath: string;
|
||||||
|
projectName: string;
|
||||||
|
sdk: any;
|
||||||
|
autoApproveEnabled: boolean;
|
||||||
|
logToFile: (msg: string) => void;
|
||||||
|
/** Called when auto-approve is toggled; extension.ts updates its own state */
|
||||||
|
onAutoApproveChanged: (enabled: boolean) => void;
|
||||||
|
/** Track recently sent Discord→AG texts to avoid echo relay */
|
||||||
|
recentDiscordSentTexts: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── File-based command watcher ───
|
||||||
|
|
||||||
|
let commandsWatcher: fs.FSWatcher | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch the commands directory for Discord→AG command files.
|
||||||
|
* Uses fs.watch + polling fallback (fs.watch unreliable on Windows).
|
||||||
|
*/
|
||||||
|
export function watchCommandsDir(ctx: CommandHandlerContext) {
|
||||||
|
const cmdDir = path.join(ctx.bridgePath, 'commands');
|
||||||
|
|
||||||
|
// Process existing files
|
||||||
|
const processAllCommands = () => {
|
||||||
|
try {
|
||||||
|
for (const f of fs.readdirSync(cmdDir)) {
|
||||||
|
if (f.endsWith('.json')) {
|
||||||
|
_processCommandFile(path.join(cmdDir, f), ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
};
|
||||||
|
|
||||||
|
processAllCommands();
|
||||||
|
|
||||||
|
// Watch for new files (may not fire reliably on Windows)
|
||||||
|
try {
|
||||||
|
commandsWatcher = fs.watch(cmdDir, (event, filename) => {
|
||||||
|
if (filename && filename.endsWith('.json') && event === 'rename') {
|
||||||
|
const fp = path.join(cmdDir, filename);
|
||||||
|
if (fs.existsSync(fp)) {
|
||||||
|
setTimeout(() => _processCommandFile(fp, ctx), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
// Polling fallback: fs.watch on Windows can silently fail
|
||||||
|
setInterval(() => {
|
||||||
|
processAllCommands();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispose the file watcher on deactivate */
|
||||||
|
export function disposeCommandsWatcher() {
|
||||||
|
if (commandsWatcher) {
|
||||||
|
commandsWatcher.close();
|
||||||
|
commandsWatcher = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WebSocket command handler ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a command received via WebSocket Hub.
|
||||||
|
* Same logic as file-based processing but without file I/O.
|
||||||
|
*/
|
||||||
|
export function handleWSCommand(ctx: CommandHandlerContext, data: { text?: string; action?: string; project_name?: string }) {
|
||||||
|
const text = data.text || '';
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
// Project filtering (WS already routes by project, but double-check)
|
||||||
|
if (data.project_name && data.project_name !== ctx.projectName) {
|
||||||
|
ctx.logToFile(`[WS-CMD] Ignoring command for ${data.project_name} (we are ${ctx.projectName})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === '!stop') {
|
||||||
|
ctx.logToFile('[WS-CMD] !stop — cancelling AG task');
|
||||||
|
if (ctx.sdk) {
|
||||||
|
try { ctx.sdk.cascade.cancelCurrentTask(); } catch { }
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.startsWith('!auto')) {
|
||||||
|
const parts = text.split(' ');
|
||||||
|
const enabled = parts[1] !== 'off';
|
||||||
|
ctx.onAutoApproveChanged(enabled);
|
||||||
|
ctx.logToFile(`[WS-CMD] auto_approve=${enabled}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// General text → send as user message to AG
|
||||||
|
ctx.logToFile(`[WS-CMD] Sending text to AG: ${text.substring(0, 80)}`);
|
||||||
|
if (ctx.sdk) {
|
||||||
|
try {
|
||||||
|
ctx.sdk.cascade.sendPrompt(text);
|
||||||
|
} catch (e: any) {
|
||||||
|
ctx.logToFile(`[WS-CMD] SDK sendPrompt error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private ───
|
||||||
|
|
||||||
|
function _processCommandFile(filePath: string, ctx: CommandHandlerContext) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const cmd = JSON.parse(content);
|
||||||
|
|
||||||
|
// Skip already consumed commands
|
||||||
|
if (cmd.consumed) {
|
||||||
|
try { fs.unlinkSync(filePath); } catch { }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore commands for other projects
|
||||||
|
if (cmd.project_name && cmd.project_name !== ctx.projectName) {
|
||||||
|
console.log(`Gravity Bridge: skipping command for "${cmd.project_name}" (we are "${ctx.projectName}")`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bot writes 'text' field, not 'message'
|
||||||
|
const text = cmd.text || cmd.message || '';
|
||||||
|
const action = cmd.action || '';
|
||||||
|
|
||||||
|
console.log(`Gravity Bridge: command — text="${text}" action="${action}"`);
|
||||||
|
|
||||||
|
if (action === 'approve' && ctx.sdk) {
|
||||||
|
ctx.sdk.cascade.acceptStep().catch((e: any) =>
|
||||||
|
console.log(`Gravity Bridge: approve error: ${e.message}`)
|
||||||
|
);
|
||||||
|
} else if (action === 'reject' && ctx.sdk) {
|
||||||
|
ctx.sdk.cascade.rejectStep().catch((e: any) =>
|
||||||
|
console.log(`Gravity Bridge: reject error: ${e.message}`)
|
||||||
|
);
|
||||||
|
} else if (action === 'approve_terminal' && ctx.sdk) {
|
||||||
|
ctx.sdk.cascade.acceptTerminalCommand().catch((e: any) =>
|
||||||
|
console.log(`Gravity Bridge: approve_terminal error: ${e.message}`)
|
||||||
|
);
|
||||||
|
} else if (text === '!stop') {
|
||||||
|
// Cancel current operation
|
||||||
|
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
|
||||||
|
.then(() => console.log('Gravity Bridge: ✅ stop sent'),
|
||||||
|
() => { });
|
||||||
|
} else if (text.startsWith('!auto')) {
|
||||||
|
// Auto-approve mode toggle
|
||||||
|
let enabled: boolean;
|
||||||
|
if (text === '!auto on') {
|
||||||
|
enabled = true;
|
||||||
|
} else if (text === '!auto off') {
|
||||||
|
enabled = false;
|
||||||
|
} else {
|
||||||
|
// Toggle if no explicit on/off
|
||||||
|
enabled = !ctx.autoApproveEnabled;
|
||||||
|
}
|
||||||
|
ctx.onAutoApproveChanged(enabled);
|
||||||
|
ctx.logToFile(`[AUTO] auto-approve toggled → ${enabled}`);
|
||||||
|
} else if (text) {
|
||||||
|
// Send message to Antigravity — use VS Code command (most reliable)
|
||||||
|
ctx.recentDiscordSentTexts.set(text.trim(), Date.now());
|
||||||
|
vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', text)
|
||||||
|
.then(() => console.log(`Gravity Bridge: ✅ sent "${text.substring(0, 50)}" via sendPromptToAgentPanel`),
|
||||||
|
(e: any) => console.log(`Gravity Bridge: sendPrompt failed: ${e.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove processed command file
|
||||||
|
try { fs.unlinkSync(filePath); } catch { }
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`Gravity Bridge: command processing error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,10 +15,11 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as cp from 'child_process';
|
import * as cp from 'child_process';
|
||||||
import * as crypto from 'crypto';
|
|
||||||
import { WSBridgeClient, WSResponseData, WSCommandData } from './ws-client';
|
import { WSBridgeClient, WSResponseData, WSCommandData } from './ws-client';
|
||||||
import { generateApprovalObserverScript } from './observer-script';
|
|
||||||
import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState } from './step-probe';
|
import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState } from './step-probe';
|
||||||
|
import { startHttpBridge, getDeterministicPort, HttpBridgeContext } from './http-bridge';
|
||||||
|
import { setupApprovalObserver } from './html-patcher';
|
||||||
|
import { watchCommandsDir, handleWSCommand, disposeCommandsWatcher, CommandHandlerContext } from './command-handler';
|
||||||
|
|
||||||
// ─── File-based logging (AI can read directly) ───
|
// ─── File-based logging (AI can read directly) ───
|
||||||
function logToFile(msg: string) {
|
function logToFile(msg: string) {
|
||||||
@@ -46,9 +47,7 @@ let projectName: string;
|
|||||||
let workspaceUri: string = ''; // filesystem path of the workspace folder (for session filtering)
|
let workspaceUri: string = ''; // filesystem path of the workspace folder (for session filtering)
|
||||||
let isActive = false;
|
let isActive = false;
|
||||||
let autoApproveEnabled = false; // toggled via !auto from Discord
|
let autoApproveEnabled = false; // toggled via !auto from Discord
|
||||||
let deterministicPort = 0; // derived from projectName, consistent across restarts
|
|
||||||
let watcher: fs.FSWatcher | null = null;
|
let watcher: fs.FSWatcher | null = null;
|
||||||
let commandsWatcher: fs.FSWatcher | null = null;
|
|
||||||
let wsBridge: WSBridgeClient | null = null; // WebSocket Hub connection
|
let wsBridge: WSBridgeClient | null = null; // WebSocket Hub connection
|
||||||
|
|
||||||
const sentPendingIds = new Set<string>();
|
const sentPendingIds = new Set<string>();
|
||||||
@@ -171,107 +170,7 @@ function writeChatSnapshotWithFiles(text: string, files: Array<{name: string, co
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─── Command File Watcher (Discord → Antigravity) ───
|
// ─── Command handling extracted to ./command-handler.ts ───
|
||||||
|
|
||||||
function processCommandFile(filePath: string) {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
|
||||||
const cmd = JSON.parse(content);
|
|
||||||
|
|
||||||
// Skip already consumed commands
|
|
||||||
if (cmd.consumed) {
|
|
||||||
try { fs.unlinkSync(filePath); } catch { }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ignore commands for other projects
|
|
||||||
if (cmd.project_name && cmd.project_name !== projectName) {
|
|
||||||
console.log(`Gravity Bridge: skipping command for "${cmd.project_name}" (we are "${projectName}")`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bot writes 'text' field, not 'message'
|
|
||||||
const text = cmd.text || cmd.message || '';
|
|
||||||
const action = cmd.action || '';
|
|
||||||
|
|
||||||
console.log(`Gravity Bridge: command — text="${text}" action="${action}"`);
|
|
||||||
|
|
||||||
if (action === 'approve' && sdk) {
|
|
||||||
sdk.cascade.acceptStep().catch((e: any) =>
|
|
||||||
console.log(`Gravity Bridge: approve error: ${e.message}`)
|
|
||||||
);
|
|
||||||
} else if (action === 'reject' && sdk) {
|
|
||||||
sdk.cascade.rejectStep().catch((e: any) =>
|
|
||||||
console.log(`Gravity Bridge: reject error: ${e.message}`)
|
|
||||||
);
|
|
||||||
} else if (action === 'approve_terminal' && sdk) {
|
|
||||||
sdk.cascade.acceptTerminalCommand().catch((e: any) =>
|
|
||||||
console.log(`Gravity Bridge: approve_terminal error: ${e.message}`)
|
|
||||||
);
|
|
||||||
} else if (text === '!stop') {
|
|
||||||
// Cancel current operation
|
|
||||||
vscode.commands.executeCommand('antigravity.agent.rejectAgentStep')
|
|
||||||
.then(() => console.log('Gravity Bridge: ✅ stop sent'),
|
|
||||||
() => { });
|
|
||||||
} else if (text.startsWith('!auto')) {
|
|
||||||
// Auto-approve mode toggle
|
|
||||||
if (text === '!auto on') {
|
|
||||||
autoApproveEnabled = true;
|
|
||||||
} else if (text === '!auto off') {
|
|
||||||
autoApproveEnabled = false;
|
|
||||||
} else {
|
|
||||||
// Toggle if no explicit on/off
|
|
||||||
autoApproveEnabled = !autoApproveEnabled;
|
|
||||||
}
|
|
||||||
logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`);
|
|
||||||
} else if (text) {
|
|
||||||
// Send message to Antigravity — use VS Code command (most reliable)
|
|
||||||
recentDiscordSentTexts.set(text.trim(), Date.now());
|
|
||||||
vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', text)
|
|
||||||
.then(() => console.log(`Gravity Bridge: ✅ sent "${text.substring(0, 50)}" via sendPromptToAgentPanel`),
|
|
||||||
(e: any) => console.log(`Gravity Bridge: sendPrompt failed: ${e.message}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove processed command file
|
|
||||||
try { fs.unlinkSync(filePath); } catch { }
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log(`Gravity Bridge: command processing error: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchCommandsDir() {
|
|
||||||
const cmdDir = path.join(bridgePath, 'commands');
|
|
||||||
|
|
||||||
// Process existing files
|
|
||||||
const processAllCommands = () => {
|
|
||||||
try {
|
|
||||||
for (const f of fs.readdirSync(cmdDir)) {
|
|
||||||
if (f.endsWith('.json')) {
|
|
||||||
processCommandFile(path.join(cmdDir, f));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
};
|
|
||||||
|
|
||||||
processAllCommands();
|
|
||||||
|
|
||||||
// Watch for new files (may not fire reliably on Windows)
|
|
||||||
try {
|
|
||||||
commandsWatcher = fs.watch(cmdDir, (event, filename) => {
|
|
||||||
if (filename && filename.endsWith('.json') && event === 'rename') {
|
|
||||||
const fp = path.join(cmdDir, filename);
|
|
||||||
if (fs.existsSync(fp)) {
|
|
||||||
setTimeout(() => processCommandFile(fp), 200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
// Polling fallback: fs.watch on Windows can silently fail
|
|
||||||
setInterval(() => {
|
|
||||||
processAllCommands();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── SDK Integration ───
|
// ─── SDK Integration ───
|
||||||
|
|
||||||
@@ -454,579 +353,17 @@ async function fixLSConnection(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Approval Observer via SDK IntegrationManager ───
|
// ─── Approval Observer + Product.json Checksums extracted to ./html-patcher.ts ───
|
||||||
|
|
||||||
async function setupApprovalObserver() {
|
// ─── HTTP Bridge Server extracted to ./http-bridge.ts ───
|
||||||
if (!sdk) { logToFile('[OBSERVER] no SDK'); return; }
|
|
||||||
try {
|
|
||||||
const integration = sdk.integration;
|
|
||||||
if (!integration) { logToFile('[OBSERVER] sdk.integration unavailable'); return; }
|
|
||||||
|
|
||||||
// 1. Start HTTP bridge server in Extension Host
|
// Shared state for HTTP bridge context (module-level, referenced by BridgeContext too)
|
||||||
const bridgePort = await startObserverHttpBridge();
|
let sessionStalled = false;
|
||||||
if (!bridgePort) { logToFile('[OBSERVER] HTTP bridge failed'); return; }
|
let lastPendingStepIndex = -1;
|
||||||
|
let stallProbed = false;
|
||||||
|
let sawRunningAfterPending = true;
|
||||||
|
|
||||||
// 2. Register a TOP_BAR button so build() works
|
// ─── Step Probe, Response Watcher, Approval Strategies → extracted to ./step-probe.ts ───
|
||||||
try {
|
|
||||||
integration.register({
|
|
||||||
id: 'gravity_bridge_status',
|
|
||||||
point: 'topBar',
|
|
||||||
icon: '🌉',
|
|
||||||
tooltip: 'Gravity Bridge Active',
|
|
||||||
});
|
|
||||||
} catch { /* already registered */ }
|
|
||||||
|
|
||||||
// 3. Write renderer script with HTTP fetch() approach
|
|
||||||
const observerJS = generateApprovalObserverScript(bridgePort);
|
|
||||||
const patcher = (integration as any)._patcher;
|
|
||||||
if (patcher && typeof patcher.getScriptPath === 'function') {
|
|
||||||
let baseScript = '';
|
|
||||||
try { baseScript = integration.build(); } catch { baseScript = ''; }
|
|
||||||
const combinedScript = baseScript + '\n' + observerJS;
|
|
||||||
const scriptPath = patcher.getScriptPath();
|
|
||||||
fs.writeFileSync(scriptPath, combinedScript, 'utf8');
|
|
||||||
logToFile(`[OBSERVER] script written → ${scriptPath} (port=${bridgePort})`);
|
|
||||||
if (!integration.isInstalled()) {
|
|
||||||
patcher.install(combinedScript);
|
|
||||||
logToFile('[OBSERVER] patcher.install() called (needs reload)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch BOTH HTML files with inline script injection.
|
|
||||||
// CRITICAL: vscode-file:// does NOT serve custom .js files (silent 404),
|
|
||||||
// so we MUST inline the script directly into BOTH HTML files.
|
|
||||||
// workbench.html — loaded by DevTools/standard mode
|
|
||||||
// workbench-jetski-agent.html — loaded by AG agent mode
|
|
||||||
const scriptDir = path.dirname(scriptPath);
|
|
||||||
// Each HTML file has DIFFERENT CSS/JS entry points — they are NOT interchangeable:
|
|
||||||
// workbench.html → workbench.desktop.main.css + workbench.js
|
|
||||||
// workbench-jetski-agent.html → tw-base.tailwind.css + jetskiMain.tailwind.css + jetskiAgent.js
|
|
||||||
// Cross-restoring between them causes CSS to not load → layout broken (elements visible but all shifted left).
|
|
||||||
const htmlFileSpecs = [
|
|
||||||
{
|
|
||||||
name: 'workbench.html',
|
|
||||||
requiredMarker: 'workbench.desktop.main.css', // CSS unique to this file
|
|
||||||
requiredScript: 'workbench.js', // JS entry point
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'workbench-jetski-agent.html',
|
|
||||||
requiredMarker: 'jetskiMain.tailwind.css', // CSS unique to this file
|
|
||||||
requiredScript: 'jetskiAgent.js', // JS entry point
|
|
||||||
},
|
|
||||||
];
|
|
||||||
// ── FIX #1: File lock to prevent multi-instance HTML patching race ──
|
|
||||||
const lockFile = path.join(scriptDir, '.patch-lock');
|
|
||||||
let lockAcquired = false;
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(lockFile)) {
|
|
||||||
const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs;
|
|
||||||
if (lockAge < 30_000) {
|
|
||||||
logToFile(`[OBSERVER] another instance is patching (lock age=${Math.round(lockAge/1000)}s) — skipping`);
|
|
||||||
return; // Exit setupApprovalObserver entirely
|
|
||||||
}
|
|
||||||
logToFile(`[OBSERVER] stale lock (age=${Math.round(lockAge/1000)}s) — force-acquiring`);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, ts: Date.now() }), 'utf-8');
|
|
||||||
lockAcquired = true;
|
|
||||||
} catch (lockErr: any) {
|
|
||||||
logToFile(`[OBSERVER] lock acquire error: ${lockErr.message} — proceeding anyway`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const spec of htmlFileSpecs) {
|
|
||||||
const htmlPath = path.join(scriptDir, spec.name);
|
|
||||||
const backupPath = htmlPath + '.orig';
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(htmlPath)) {
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} not found — skipping`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
||||||
|
|
||||||
// ── BACKUP: Save original before first-ever patch ──
|
|
||||||
// Only backup if the file looks valid AND hasn't been backed up yet.
|
|
||||||
if (!fs.existsSync(backupPath)
|
|
||||||
&& html.length >= 500
|
|
||||||
&& html.includes('<!DOCTYPE html>')
|
|
||||||
&& html.includes(spec.requiredMarker)) {
|
|
||||||
fs.writeFileSync(backupPath, html, 'utf8');
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} backed up to .orig (${html.length} bytes)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SAFETY: Refuse to patch if file is corrupt, empty, or wrong type ──
|
|
||||||
// Race condition: another extension instance may be mid-write (0-byte).
|
|
||||||
// Wrong type: restored from the other HTML file (different CSS/JS refs).
|
|
||||||
const isCorrupt = html.length < 500 || !html.includes('<!DOCTYPE html>');
|
|
||||||
const isWrongType = !isCorrupt && !html.includes(spec.requiredMarker);
|
|
||||||
|
|
||||||
if (isCorrupt || isWrongType) {
|
|
||||||
const reason = isCorrupt
|
|
||||||
? `corrupt/empty (${html.length} bytes)`
|
|
||||||
: `wrong type (missing ${spec.requiredMarker})`;
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} detected ${reason}`);
|
|
||||||
|
|
||||||
// Try to restore from backup
|
|
||||||
if (fs.existsSync(backupPath)) {
|
|
||||||
const backup = fs.readFileSync(backupPath, 'utf8');
|
|
||||||
if (backup.length >= 500
|
|
||||||
&& backup.includes('<!DOCTYPE html>')
|
|
||||||
&& backup.includes(spec.requiredMarker)) {
|
|
||||||
fs.writeFileSync(htmlPath, backup, 'utf8');
|
|
||||||
html = backup;
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} RESTORED from .orig backup (${backup.length} bytes) ✅`);
|
|
||||||
} else {
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} .orig backup also invalid — SKIPPING`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} no .orig backup available — SKIPPING to prevent further damage`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRITICAL: Patch CSP to allow inline scripts.
|
|
||||||
// Default CSP has script-src 'self' 'unsafe-eval' blob: — NO 'unsafe-inline'.
|
|
||||||
// Without 'unsafe-inline', all inline <script> tags are silently blocked.
|
|
||||||
if (html.includes('script-src') && !html.match(/script-src[^;]*'unsafe-inline'/)) {
|
|
||||||
html = html.replace(
|
|
||||||
/(script-src\s[^;]*?)('self')/,
|
|
||||||
"$1$2\n\t\t\t\t\t'unsafe-inline'"
|
|
||||||
);
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} CSP patched: added 'unsafe-inline' to script-src`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove old external script tag if present (legacy, cannot be served)
|
|
||||||
const extMarkerStart = '<!-- AG SDK [variet-gravity-bridge] -->';
|
|
||||||
const extMarkerEnd = '<!-- /AG SDK [variet-gravity-bridge] -->';
|
|
||||||
if (html.includes(extMarkerStart)) {
|
|
||||||
const extRe = new RegExp(
|
|
||||||
'\\n?' + extMarkerStart.replace(/[[\]]/g, '\\$&') +
|
|
||||||
'[\\s\\S]*?' +
|
|
||||||
extMarkerEnd.replace(/[[\]]/g, '\\$&') + '\\n?'
|
|
||||||
);
|
|
||||||
html = html.replace(extRe, '');
|
|
||||||
logToFile(`[OBSERVER] removed external script tag from ${spec.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert or update inline script
|
|
||||||
const inlineMarkerStart = '<!-- AG SDK INLINE [variet-gravity-bridge] -->';
|
|
||||||
const inlineMarkerEnd = '<!-- /AG SDK INLINE [variet-gravity-bridge] -->';
|
|
||||||
|
|
||||||
if (html.includes(inlineMarkerStart)) {
|
|
||||||
const re = new RegExp(
|
|
||||||
inlineMarkerStart.replace(/[[\]]/g, '\\$&') +
|
|
||||||
'[\\s\\S]*?' +
|
|
||||||
inlineMarkerEnd.replace(/[[\]]/g, '\\$&')
|
|
||||||
);
|
|
||||||
html = html.replace(re,
|
|
||||||
`${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}`);
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`);
|
|
||||||
} else {
|
|
||||||
html = html.replace('</html>',
|
|
||||||
`\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`);
|
|
||||||
}
|
|
||||||
// SAFETY: Final validation before write
|
|
||||||
if (html.length < 500 || !html.includes('<!DOCTYPE html>') || !html.includes(spec.requiredMarker)) {
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} WOULD BE CORRUPT after patching (${html.length} bytes) — ABORTING write`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
||||||
} catch (e: any) {
|
|
||||||
logToFile(`[OBSERVER] ${spec.name} patch error: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release patch lock
|
|
||||||
if (lockAcquired) {
|
|
||||||
try { fs.unlinkSync(lockFile); } catch { }
|
|
||||||
logToFile('[OBSERVER] patch lock released');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Update product.json checksums so vscode-file:// serves our patched files
|
|
||||||
updateProductChecksums();
|
|
||||||
|
|
||||||
try { integration.enableAutoRepair(); } catch { }
|
|
||||||
setInterval(() => { try { integration.signalActive(); } catch { } }, 30_000);
|
|
||||||
|
|
||||||
logToFile(`[OBSERVER] setup complete (HTTP bridge on port ${bridgePort})`);
|
|
||||||
console.log(`Gravity Bridge: ✅ Approval observer installed (port ${bridgePort})`);
|
|
||||||
} catch (err: any) {
|
|
||||||
logToFile(`[OBSERVER] setup error: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Product.json Checksum Auto-Update ───
|
|
||||||
// vscode-file:// protocol validates SHA256 checksums in product.json.
|
|
||||||
// If a file's checksum doesn't match, Electron serves the ORIGINAL cached version.
|
|
||||||
// This function recalculates checksums for files we modify (HTML files with <script> tags).
|
|
||||||
|
|
||||||
function updateProductChecksums() {
|
|
||||||
try {
|
|
||||||
// Find product.json (2 levels up from workbench dir: resources/app/product.json)
|
|
||||||
const patcher = (sdk?.integration as any)?._patcher;
|
|
||||||
if (!patcher || typeof patcher.getWorkbenchDir !== 'function') {
|
|
||||||
logToFile('[CHECKSUM] no patcher/workbenchDir — skipping');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const workbenchDir = patcher.getWorkbenchDir();
|
|
||||||
// workbenchDir = .../resources/app/out/vs/code/electron-browser/workbench
|
|
||||||
// product.json = .../resources/app/product.json (5 levels up from workbench)
|
|
||||||
const appDir = path.resolve(workbenchDir, '..', '..', '..', '..', '..');
|
|
||||||
const productJsonPath = path.join(appDir, 'product.json');
|
|
||||||
|
|
||||||
if (!fs.existsSync(productJsonPath)) {
|
|
||||||
logToFile(`[CHECKSUM] product.json not found at ${productJsonPath}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read product.json (may have BOM)
|
|
||||||
let raw = fs.readFileSync(productJsonPath, 'utf8');
|
|
||||||
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.substring(1);
|
|
||||||
const product = JSON.parse(raw);
|
|
||||||
|
|
||||||
if (!product.checksums) {
|
|
||||||
logToFile('[CHECKSUM] no checksums section in product.json');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Files we may modify or create (relative key in product.json → absolute path)
|
|
||||||
// CRITICAL: vscode-file:// only serves files with valid checksums in product.json.
|
|
||||||
// Custom JS files MUST be added here or they'll silently 404.
|
|
||||||
const filesToCheck: Record<string, string> = {
|
|
||||||
'vs/code/electron-browser/workbench/workbench.html': path.join(workbenchDir, 'workbench.html'),
|
|
||||||
'vs/code/electron-browser/workbench/workbench-jetski-agent.html': path.join(workbenchDir, 'workbench-jetski-agent.html'),
|
|
||||||
'vs/code/electron-browser/workbench/ag-sdk-variet-gravity-bridge.js': path.join(workbenchDir, 'ag-sdk-variet-gravity-bridge.js'),
|
|
||||||
};
|
|
||||||
|
|
||||||
let updated = false;
|
|
||||||
for (const [key, filePath] of Object.entries(filesToCheck)) {
|
|
||||||
if (!fs.existsSync(filePath)) continue;
|
|
||||||
|
|
||||||
const fileBytes = fs.readFileSync(filePath);
|
|
||||||
const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, '');
|
|
||||||
|
|
||||||
if (product.checksums[key] !== hash) {
|
|
||||||
logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`);
|
|
||||||
product.checksums[key] = hash;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updated) {
|
|
||||||
fs.writeFileSync(productJsonPath, JSON.stringify(product, null, '\t'), 'utf8');
|
|
||||||
logToFile('[CHECKSUM] product.json updated ✅');
|
|
||||||
} else {
|
|
||||||
logToFile('[CHECKSUM] all checksums already match ✅');
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
logToFile(`[CHECKSUM] error: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── HTTP Bridge Server (Extension Host → Renderer communication) ───
|
|
||||||
|
|
||||||
let observerHttpServer: any = null;
|
|
||||||
const pendingResponses = new Map<string, { approved: boolean } | null>();
|
|
||||||
|
|
||||||
// Click trigger: extension sets this, renderer polls and clicks button
|
|
||||||
let clickTrigger: { action: 'approve' | 'reject'; timestamp: number } | null = null;
|
|
||||||
let sessionStalled = false; // true when session is stalled waiting for approval
|
|
||||||
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step (module-level for tryApprovalStrategies)
|
|
||||||
let stallProbed = false; // prevent repeated step probes during same stall (module-level for processResponseFile reset)
|
|
||||||
let sawRunningAfterPending = true; // gate: must see delta>0 before next pending (module-level for processResponseFile)
|
|
||||||
|
|
||||||
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
|
|
||||||
let deepInspectRequested = false;
|
|
||||||
let deepInspectResult: any = null;
|
|
||||||
let deepInspectWaiters: Array<(data: any) => void> = [];
|
|
||||||
|
|
||||||
/** Derive a deterministic port from project name (range 10000-60000) */
|
|
||||||
function getDeterministicPort(name: string): number {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < name.length; i++) {
|
|
||||||
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return 10000 + (Math.abs(hash) % 50000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startObserverHttpBridge(): Promise<number> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
try {
|
|
||||||
const http = require('http');
|
|
||||||
const server = http.createServer((req: any, res: any) => {
|
|
||||||
// CORS headers for renderer fetch()
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
||||||
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
|
|
||||||
|
|
||||||
const url = new URL(req.url, `http://127.0.0.1`);
|
|
||||||
|
|
||||||
// POST /pending — renderer reports a detected approval button
|
|
||||||
if (req.method === 'POST' && url.pathname === '/pending') {
|
|
||||||
let body = '';
|
|
||||||
req.on('data', (c: string) => body += c);
|
|
||||||
req.on('end', () => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(body);
|
|
||||||
|
|
||||||
// ── Server-side false positive filter ──
|
|
||||||
const cmd = (data.command || '').trim();
|
|
||||||
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Deny|Allow Once|Allow This Conversation|Dismiss|Decline|Accept|Reject|Accept all|Reject all)$/i;
|
|
||||||
if (FALSE_POSITIVE_RE.test(cmd)) {
|
|
||||||
logToFile(`[HTTP] filtered false positive: "${cmd}"`);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ ok: false, filtered: true }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// "Run" button → step_probe handles these with full command detail
|
|
||||||
// Only let through if session is stalled AND step_probe hasn't created a pending yet
|
|
||||||
if (/^Run$/i.test(cmd)) {
|
|
||||||
if (!sessionStalled || lastPendingStepIndex >= 0) {
|
|
||||||
logToFile(`[HTTP] filtered "Run" — ${!sessionStalled ? 'not stalled' : 'step_probe pending exists'}`);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ ok: false, filtered: true }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rid = data.request_id || Date.now().toString();
|
|
||||||
// Write pending file for Discord bot
|
|
||||||
const pendingDir = path.join(bridgePath, 'pending');
|
|
||||||
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
|
|
||||||
const pending: Record<string, any> = {
|
|
||||||
...data,
|
|
||||||
request_id: rid,
|
|
||||||
conversation_id: activeSessionId || '',
|
|
||||||
timestamp: Date.now() / 1000,
|
|
||||||
status: 'pending',
|
|
||||||
project_name: projectName,
|
|
||||||
auto_detected: true,
|
|
||||||
source: 'dom_observer',
|
|
||||||
step_index: lastPendingStepIndex >= 0 ? lastPendingStepIndex : undefined,
|
|
||||||
};
|
|
||||||
// File permission: inject multi-choice buttons
|
|
||||||
const cmdLower = (data.command || '').toLowerCase();
|
|
||||||
if (cmdLower.includes('allow') && !pending.buttons) {
|
|
||||||
// Dedup: skip if another file_permission pending was created within 10s
|
|
||||||
const nowMs = Date.now();
|
|
||||||
try {
|
|
||||||
const existingFiles = fs.readdirSync(pendingDir).filter((f: string) => f.endsWith('.json'));
|
|
||||||
for (const ef of existingFiles) {
|
|
||||||
const existing = JSON.parse(fs.readFileSync(path.join(pendingDir, ef), 'utf-8'));
|
|
||||||
if (existing.step_type === 'file_permission' && existing.status === 'pending'
|
|
||||||
&& existing.project_name === projectName) {
|
|
||||||
const age = nowMs - (existing.timestamp * 1000);
|
|
||||||
if (age < 10_000 && age >= 0) {
|
|
||||||
logToFile(`[HTTP] filtered duplicate file_permission (${age}ms old): ${ef}`);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
pending.buttons = [
|
|
||||||
{ text: 'Allow Once', index: 0 },
|
|
||||||
{ text: 'Allow This Conversation', index: 1 },
|
|
||||||
{ text: 'Deny', index: 2 },
|
|
||||||
];
|
|
||||||
pending.step_type = 'file_permission';
|
|
||||||
// Clean description: remove button labels from text
|
|
||||||
const rawDesc = (data.description || data.command || '').replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim();
|
|
||||||
pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`;
|
|
||||||
}
|
|
||||||
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
|
|
||||||
// WS dual-write
|
|
||||||
if (wsBridge && wsBridge.isConnected()) {
|
|
||||||
wsBridge.sendPending({
|
|
||||||
request_id: rid,
|
|
||||||
command: pending.command || data.command || '',
|
|
||||||
description: pending.description || data.description || '',
|
|
||||||
step_type: pending.step_type,
|
|
||||||
status: 'pending',
|
|
||||||
buttons: pending.buttons,
|
|
||||||
project_name: projectName,
|
|
||||||
});
|
|
||||||
logToFile(`[HTTP-WS] pending sent via WS: ${rid}`);
|
|
||||||
}
|
|
||||||
logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ ok: true, request_id: rid }));
|
|
||||||
} catch (e: any) {
|
|
||||||
logToFile(`[HTTP] pending error: ${e.message}`);
|
|
||||||
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /response/:rid — renderer polls for Discord approval
|
|
||||||
if (req.method === 'GET' && url.pathname.startsWith('/response/')) {
|
|
||||||
const rid = url.pathname.split('/')[2];
|
|
||||||
const respFile = path.join(bridgePath, 'response', `${rid}.json`);
|
|
||||||
if (fs.existsSync(respFile)) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(respFile, 'utf8'));
|
|
||||||
logToFile(`[HTTP] response served to renderer: ${rid} approved=${data.approved}`);
|
|
||||||
// Delay deletion: processResponseFile (response watcher) may need to read it too.
|
|
||||||
// The watcher fires with 300ms delay, so 2s is safe.
|
|
||||||
setTimeout(() => {
|
|
||||||
try { fs.unlinkSync(respFile); } catch { }
|
|
||||||
}, 2000);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
} catch {
|
|
||||||
res.writeHead(200); res.end(JSON.stringify({ waiting: true }));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res.writeHead(200); res.end(JSON.stringify({ waiting: true }));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /trigger-click — renderer polls to check if extension wants a click
|
|
||||||
if (req.method === 'GET' && url.pathname === '/trigger-click') {
|
|
||||||
if (clickTrigger && (Date.now() - clickTrigger.timestamp) < 30000) {
|
|
||||||
const trigger = clickTrigger;
|
|
||||||
clickTrigger = null; // consume once
|
|
||||||
logToFile(`[HTTP] trigger-click consumed: ${trigger.action}`);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ action: trigger.action }));
|
|
||||||
} else {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ action: null }));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /deep-inspect — trigger deep DOM inspection from renderer
|
|
||||||
if (req.method === 'GET' && url.pathname === '/deep-inspect') {
|
|
||||||
deepInspectRequested = true;
|
|
||||||
logToFile('[HTTP] deep-inspect triggered — waiting for renderer result...');
|
|
||||||
// Wait up to 10s for renderer to POST result
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
deepInspectWaiters = deepInspectWaiters.filter(w => w !== waiter);
|
|
||||||
if (deepInspectResult) {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(deepInspectResult));
|
|
||||||
} else {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ status: 'timeout', message: 'Renderer did not respond in 10s. Is the v3 script loaded?' }));
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
const waiter = (data: any) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
};
|
|
||||||
deepInspectWaiters.push(waiter);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /deep-inspect-trigger — renderer polls this
|
|
||||||
if (req.method === 'GET' && url.pathname === '/deep-inspect-trigger') {
|
|
||||||
const requested = deepInspectRequested;
|
|
||||||
deepInspectRequested = false;
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ inspect: requested }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /deep-inspect-result — renderer posts inspection results here
|
|
||||||
if (req.method === 'POST' && url.pathname === '/deep-inspect-result') {
|
|
||||||
let body = '';
|
|
||||||
req.on('data', (c: string) => body += c);
|
|
||||||
req.on('end', () => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(body);
|
|
||||||
deepInspectResult = data;
|
|
||||||
logToFile(`[HTTP] deep-inspect result received (${body.length} bytes)`);
|
|
||||||
// Write to file for reference
|
|
||||||
const inspectFile = path.join(bridgePath, 'deep-inspect-result.json');
|
|
||||||
fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2));
|
|
||||||
// Notify waiters
|
|
||||||
const waiters = [...deepInspectWaiters];
|
|
||||||
deepInspectWaiters = [];
|
|
||||||
waiters.forEach(w => w(data));
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ ok: true }));
|
|
||||||
} catch (e: any) {
|
|
||||||
logToFile(`[HTTP] deep-inspect-result parse error: ${e.message}`);
|
|
||||||
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /ping — health check
|
|
||||||
if (url.pathname === '/ping') {
|
|
||||||
res.writeHead(200); res.end('pong');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(404); res.end('not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen on deterministic port (derived from projectName), fallback to random
|
|
||||||
deterministicPort = getDeterministicPort(projectName);
|
|
||||||
const tryListen = (targetPort: number) => {
|
|
||||||
server.listen(targetPort, '127.0.0.1', () => {
|
|
||||||
const port = server.address().port;
|
|
||||||
observerHttpServer = server;
|
|
||||||
logToFile(`[HTTP] bridge server started on port ${port}`);
|
|
||||||
|
|
||||||
// Write port to shared ports JSON (multi-bridge support)
|
|
||||||
const patcher = (sdk.integration as any)?._patcher;
|
|
||||||
if (patcher && typeof patcher.getWorkbenchDir === 'function') {
|
|
||||||
const workbenchDir = patcher.getWorkbenchDir();
|
|
||||||
const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
|
|
||||||
let portsData: Record<string, number> = {};
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(portsFile)) {
|
|
||||||
portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8'));
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
portsData[projectName] = port;
|
|
||||||
fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8');
|
|
||||||
logToFile(`[HTTP] ports JSON updated → ${portsFile} (${projectName}=${port})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(port);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
server.on('error', (e: any) => {
|
|
||||||
if (e.code === 'EADDRINUSE' && deterministicPort > 0) {
|
|
||||||
logToFile(`[HTTP] deterministic port ${deterministicPort} in use, trying random...`);
|
|
||||||
deterministicPort = 0;
|
|
||||||
const server2 = require('http').createServer(server._events.request);
|
|
||||||
observerHttpServer = server2;
|
|
||||||
server2.on('error', (e2: any) => {
|
|
||||||
logToFile(`[HTTP] random port also failed: ${e2.message}`);
|
|
||||||
resolve(0);
|
|
||||||
});
|
|
||||||
server2.listen(0, '127.0.0.1', () => {
|
|
||||||
const port = server2.address().port;
|
|
||||||
logToFile(`[HTTP] bridge server started on RANDOM port ${port}`);
|
|
||||||
resolve(port);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logToFile(`[HTTP] server error: ${e.message}`);
|
|
||||||
resolve(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
tryListen(deterministicPort);
|
|
||||||
} catch (e: any) {
|
|
||||||
logToFile(`[HTTP] server failed: ${e.message}`);
|
|
||||||
resolve(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step Probe, Response Watcher, Approval Strategies → extracted to ./step-probe.ts ───
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1144,7 +481,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
sessionStalled,
|
sessionStalled,
|
||||||
lastPendingStepIndex,
|
lastPendingStepIndex,
|
||||||
stallProbed,
|
stallProbed,
|
||||||
stallProbed,
|
sawRunningAfterPending,
|
||||||
|
setClickTrigger: (action: 'approve' | 'reject') => {
|
||||||
|
const { setClickTrigger: setTrigger } = require('./http-bridge');
|
||||||
|
setTrigger(action);
|
||||||
},
|
},
|
||||||
logToFile,
|
logToFile,
|
||||||
workspaceUri,
|
workspaceUri,
|
||||||
@@ -1152,7 +492,17 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
recentDiscordSentTexts,
|
recentDiscordSentTexts,
|
||||||
writeChatSnapshot,
|
writeChatSnapshot,
|
||||||
writeChatSnapshotWithFiles,
|
writeChatSnapshotWithFiles,
|
||||||
writeChatSnapshotWithFiles,
|
} as BridgeContext);
|
||||||
|
// Start HTTP bridge, then setup observer
|
||||||
|
const httpBridgeCtx: HttpBridgeContext = {
|
||||||
|
bridgePath, projectName, activeSessionId, wsBridge,
|
||||||
|
sessionStalled, lastPendingStepIndex, logToFile,
|
||||||
|
};
|
||||||
|
const bridgePort = await startHttpBridge(httpBridgeCtx, sdk);
|
||||||
|
if (bridgePort) {
|
||||||
|
await setupApprovalObserver(sdk, bridgePort, logToFile);
|
||||||
|
} else {
|
||||||
|
logToFile('[OBSERVER] HTTP bridge failed — skipping observer setup');
|
||||||
}
|
}
|
||||||
statusBar.text = '$(check) Bridge';
|
statusBar.text = '$(check) Bridge';
|
||||||
statusBar.tooltip = `Gravity Bridge: ${projectName} (POLL + Observer active)`;
|
statusBar.tooltip = `Gravity Bridge: ${projectName} (POLL + Observer active)`;
|
||||||
@@ -1181,7 +531,11 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
console.log('Gravity Bridge: SDK not available, file-based mode only');
|
console.log('Gravity Bridge: SDK not available, file-based mode only');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Watch commands directory
|
||||||
|
watchCommandsDir({
|
||||||
|
bridgePath, projectName, sdk, autoApproveEnabled, logToFile,
|
||||||
|
onAutoApproveChanged: (enabled: boolean) => { autoApproveEnabled = enabled; },
|
||||||
|
recentDiscordSentTexts,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Response watcher is now initialized by initStepProbe() above
|
// Response watcher is now initialized by initStepProbe() above
|
||||||
@@ -1227,7 +581,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
context.subscriptions.push({
|
context.subscriptions.push({
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
if (sdk) { try { sdk.dispose(); } catch { } }
|
if (sdk) { try { sdk.dispose(); } catch { } }
|
||||||
if (sdk) { try { sdk.dispose(); } catch { } }
|
if (watcher) { watcher.close(); }
|
||||||
disposeCommandsWatcher();
|
disposeCommandsWatcher();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
291
extension/src/html-patcher.ts
Normal file
291
extension/src/html-patcher.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* HTML Patcher — AG workbench HTML patching + product.json checksum update.
|
||||||
|
*
|
||||||
|
* Extracted from extension.ts to reduce file size.
|
||||||
|
* Handles:
|
||||||
|
* - Injecting the approval observer script into AG's workbench HTML files
|
||||||
|
* - Patching CSP to allow inline scripts
|
||||||
|
* - Managing .orig backups and .patch-lock
|
||||||
|
* - Updating product.json checksums for vscode-file:// protocol
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import { generateApprovalObserverScript } from './observer-script';
|
||||||
|
|
||||||
|
// ─── Types ───
|
||||||
|
|
||||||
|
interface HtmlFileSpec {
|
||||||
|
name: string;
|
||||||
|
requiredMarker: string;
|
||||||
|
requiredScript: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the approval observer by patching AG's workbench HTML files
|
||||||
|
* with an inline script that monitors DOM for approval buttons.
|
||||||
|
*/
|
||||||
|
export async function setupApprovalObserver(
|
||||||
|
sdk: any,
|
||||||
|
bridgePort: number,
|
||||||
|
logToFile: (msg: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!sdk) { logToFile('[OBSERVER] no SDK'); return; }
|
||||||
|
try {
|
||||||
|
const integration = sdk.integration;
|
||||||
|
if (!integration) { logToFile('[OBSERVER] sdk.integration unavailable'); return; }
|
||||||
|
|
||||||
|
// 1. Register a TOP_BAR button so build() works
|
||||||
|
try {
|
||||||
|
integration.register({
|
||||||
|
id: 'gravity_bridge_status',
|
||||||
|
point: 'topBar',
|
||||||
|
icon: '🌉',
|
||||||
|
tooltip: 'Gravity Bridge Active',
|
||||||
|
});
|
||||||
|
} catch { /* already registered */ }
|
||||||
|
|
||||||
|
// 2. Write renderer script with HTTP fetch() approach
|
||||||
|
const observerJS = generateApprovalObserverScript(bridgePort);
|
||||||
|
const patcher = (integration as any)._patcher;
|
||||||
|
if (patcher && typeof patcher.getScriptPath === 'function') {
|
||||||
|
let baseScript = '';
|
||||||
|
try { baseScript = integration.build(); } catch { baseScript = ''; }
|
||||||
|
const combinedScript = baseScript + '\n' + observerJS;
|
||||||
|
const scriptPath = patcher.getScriptPath();
|
||||||
|
fs.writeFileSync(scriptPath, combinedScript, 'utf8');
|
||||||
|
logToFile(`[OBSERVER] script written → ${scriptPath} (port=${bridgePort})`);
|
||||||
|
if (!integration.isInstalled()) {
|
||||||
|
patcher.install(combinedScript);
|
||||||
|
logToFile('[OBSERVER] patcher.install() called (needs reload)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch BOTH HTML files with inline script injection.
|
||||||
|
const scriptDir = path.dirname(scriptPath);
|
||||||
|
_patchHtmlFiles(scriptDir, combinedScript, logToFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update product.json checksums
|
||||||
|
updateProductChecksums(sdk, logToFile);
|
||||||
|
|
||||||
|
try { integration.enableAutoRepair(); } catch { }
|
||||||
|
setInterval(() => { try { integration.signalActive(); } catch { } }, 30_000);
|
||||||
|
|
||||||
|
logToFile(`[OBSERVER] setup complete (HTTP bridge on port ${bridgePort})`);
|
||||||
|
console.log(`Gravity Bridge: ✅ Approval observer installed (port ${bridgePort})`);
|
||||||
|
} catch (err: any) {
|
||||||
|
logToFile(`[OBSERVER] setup error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update product.json checksums so vscode-file:// serves our patched files.
|
||||||
|
* Without valid checksums, Electron serves the ORIGINAL cached version.
|
||||||
|
*/
|
||||||
|
export function updateProductChecksums(sdk: any, logToFile: (msg: string) => void): void {
|
||||||
|
try {
|
||||||
|
// Find product.json (2 levels up from workbench dir: resources/app/product.json)
|
||||||
|
const patcher = (sdk?.integration as any)?._patcher;
|
||||||
|
if (!patcher || typeof patcher.getWorkbenchDir !== 'function') {
|
||||||
|
logToFile('[CHECKSUM] no patcher/workbenchDir — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const workbenchDir = patcher.getWorkbenchDir();
|
||||||
|
const appDir = path.resolve(workbenchDir, '..', '..', '..', '..', '..');
|
||||||
|
const productJsonPath = path.join(appDir, 'product.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(productJsonPath)) {
|
||||||
|
logToFile(`[CHECKSUM] product.json not found at ${productJsonPath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read product.json (may have BOM)
|
||||||
|
let raw = fs.readFileSync(productJsonPath, 'utf8');
|
||||||
|
if (raw.charCodeAt(0) === 0xFEFF) raw = raw.substring(1);
|
||||||
|
const product = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (!product.checksums) {
|
||||||
|
logToFile('[CHECKSUM] no checksums section in product.json');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files we may modify or create (relative key in product.json → absolute path)
|
||||||
|
const filesToCheck: Record<string, string> = {
|
||||||
|
'vs/code/electron-browser/workbench/workbench.html': path.join(workbenchDir, 'workbench.html'),
|
||||||
|
'vs/code/electron-browser/workbench/workbench-jetski-agent.html': path.join(workbenchDir, 'workbench-jetski-agent.html'),
|
||||||
|
'vs/code/electron-browser/workbench/ag-sdk-variet-gravity-bridge.js': path.join(workbenchDir, 'ag-sdk-variet-gravity-bridge.js'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let updated = false;
|
||||||
|
for (const [key, filePath] of Object.entries(filesToCheck)) {
|
||||||
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
|
||||||
|
const fileBytes = fs.readFileSync(filePath);
|
||||||
|
const hash = crypto.createHash('sha256').update(fileBytes).digest('base64').replace(/=+$/, '');
|
||||||
|
|
||||||
|
if (product.checksums[key] !== hash) {
|
||||||
|
logToFile(`[CHECKSUM] updating ${key}: ${product.checksums[key].substring(0, 12)}... → ${hash.substring(0, 12)}...`);
|
||||||
|
product.checksums[key] = hash;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
fs.writeFileSync(productJsonPath, JSON.stringify(product, null, '\t'), 'utf8');
|
||||||
|
logToFile('[CHECKSUM] product.json updated ✅');
|
||||||
|
} else {
|
||||||
|
logToFile('[CHECKSUM] all checksums already match ✅');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logToFile(`[CHECKSUM] error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Private ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch both AG workbench HTML files with inline observer script.
|
||||||
|
* CRITICAL: vscode-file:// does NOT serve custom .js files (silent 404),
|
||||||
|
* so we MUST inline the script directly into BOTH HTML files.
|
||||||
|
*/
|
||||||
|
function _patchHtmlFiles(scriptDir: string, combinedScript: string, logToFile: (msg: string) => void): void {
|
||||||
|
// Each HTML file has DIFFERENT CSS/JS entry points — they are NOT interchangeable
|
||||||
|
const htmlFileSpecs: HtmlFileSpec[] = [
|
||||||
|
{
|
||||||
|
name: 'workbench.html',
|
||||||
|
requiredMarker: 'workbench.desktop.main.css',
|
||||||
|
requiredScript: 'workbench.js',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'workbench-jetski-agent.html',
|
||||||
|
requiredMarker: 'jetskiMain.tailwind.css',
|
||||||
|
requiredScript: 'jetskiAgent.js',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── File lock to prevent multi-instance HTML patching race ──
|
||||||
|
const lockFile = path.join(scriptDir, '.patch-lock');
|
||||||
|
let lockAcquired = false;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(lockFile)) {
|
||||||
|
const lockAge = Date.now() - fs.statSync(lockFile).mtimeMs;
|
||||||
|
if (lockAge < 30_000) {
|
||||||
|
logToFile(`[OBSERVER] another instance is patching (lock age=${Math.round(lockAge / 1000)}s) — skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logToFile(`[OBSERVER] stale lock (age=${Math.round(lockAge / 1000)}s) — force-acquiring`);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, ts: Date.now() }), 'utf-8');
|
||||||
|
lockAcquired = true;
|
||||||
|
} catch (lockErr: any) {
|
||||||
|
logToFile(`[OBSERVER] lock acquire error: ${lockErr.message} — proceeding anyway`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const spec of htmlFileSpecs) {
|
||||||
|
const htmlPath = path.join(scriptDir, spec.name);
|
||||||
|
const backupPath = htmlPath + '.orig';
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(htmlPath)) {
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} not found — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let html = fs.readFileSync(htmlPath, 'utf8');
|
||||||
|
|
||||||
|
// ── BACKUP: Save original before first-ever patch ──
|
||||||
|
if (!fs.existsSync(backupPath)
|
||||||
|
&& html.length >= 500
|
||||||
|
&& html.includes('<!DOCTYPE html>')
|
||||||
|
&& html.includes(spec.requiredMarker)) {
|
||||||
|
fs.writeFileSync(backupPath, html, 'utf8');
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} backed up to .orig (${html.length} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SAFETY: Refuse to patch if file is corrupt, empty, or wrong type ──
|
||||||
|
const isCorrupt = html.length < 500 || !html.includes('<!DOCTYPE html>');
|
||||||
|
const isWrongType = !isCorrupt && !html.includes(spec.requiredMarker);
|
||||||
|
|
||||||
|
if (isCorrupt || isWrongType) {
|
||||||
|
const reason = isCorrupt
|
||||||
|
? `corrupt/empty (${html.length} bytes)`
|
||||||
|
: `wrong type (missing ${spec.requiredMarker})`;
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} detected ${reason}`);
|
||||||
|
|
||||||
|
// Try to restore from backup
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
const backup = fs.readFileSync(backupPath, 'utf8');
|
||||||
|
if (backup.length >= 500
|
||||||
|
&& backup.includes('<!DOCTYPE html>')
|
||||||
|
&& backup.includes(spec.requiredMarker)) {
|
||||||
|
fs.writeFileSync(htmlPath, backup, 'utf8');
|
||||||
|
html = backup;
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} RESTORED from .orig backup (${backup.length} bytes) ✅`);
|
||||||
|
} else {
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} .orig backup also invalid — SKIPPING`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} no .orig backup available — SKIPPING to prevent further damage`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Patch CSP to allow inline scripts.
|
||||||
|
if (html.includes('script-src') && !html.match(/script-src[^;]*'unsafe-inline'/)) {
|
||||||
|
html = html.replace(
|
||||||
|
/(script-src\s[^;]*?)('self')/,
|
||||||
|
"$1$2\n\t\t\t\t\t'unsafe-inline'"
|
||||||
|
);
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} CSP patched: added 'unsafe-inline' to script-src`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old external script tag if present (legacy, cannot be served)
|
||||||
|
const extMarkerStart = '<!-- AG SDK [variet-gravity-bridge] -->';
|
||||||
|
const extMarkerEnd = '<!-- /AG SDK [variet-gravity-bridge] -->';
|
||||||
|
if (html.includes(extMarkerStart)) {
|
||||||
|
const extRe = new RegExp(
|
||||||
|
'\\n?' + extMarkerStart.replace(/[[\]]/g, '\\$&') +
|
||||||
|
'[\\s\\S]*?' +
|
||||||
|
extMarkerEnd.replace(/[[\]]/g, '\\$&') + '\\n?'
|
||||||
|
);
|
||||||
|
html = html.replace(extRe, '');
|
||||||
|
logToFile(`[OBSERVER] removed external script tag from ${spec.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert or update inline script
|
||||||
|
const inlineMarkerStart = '<!-- AG SDK INLINE [variet-gravity-bridge] -->';
|
||||||
|
const inlineMarkerEnd = '<!-- /AG SDK INLINE [variet-gravity-bridge] -->';
|
||||||
|
|
||||||
|
if (html.includes(inlineMarkerStart)) {
|
||||||
|
const re = new RegExp(
|
||||||
|
inlineMarkerStart.replace(/[[\]]/g, '\\$&') +
|
||||||
|
'[\\s\\S]*?' +
|
||||||
|
inlineMarkerEnd.replace(/[[\]]/g, '\\$&')
|
||||||
|
);
|
||||||
|
html = html.replace(re,
|
||||||
|
`${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}`);
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} inline script UPDATED`);
|
||||||
|
} else {
|
||||||
|
html = html.replace('</html>',
|
||||||
|
`\n${inlineMarkerStart}\n<script>\n${combinedScript}\n</script>\n${inlineMarkerEnd}\n</html>`);
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} inline script INSERTED`);
|
||||||
|
}
|
||||||
|
// SAFETY: Final validation before write
|
||||||
|
if (html.length < 500 || !html.includes('<!DOCTYPE html>') || !html.includes(spec.requiredMarker)) {
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} WOULD BE CORRUPT after patching (${html.length} bytes) — ABORTING write`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(htmlPath, html, 'utf8');
|
||||||
|
} catch (e: any) {
|
||||||
|
logToFile(`[OBSERVER] ${spec.name} patch error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release patch lock
|
||||||
|
if (lockAcquired) {
|
||||||
|
try { fs.unlinkSync(lockFile); } catch { }
|
||||||
|
logToFile('[OBSERVER] patch lock released');
|
||||||
|
}
|
||||||
|
}
|
||||||
367
extension/src/http-bridge.ts
Normal file
367
extension/src/http-bridge.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* HTTP Bridge Server — Extension Host ↔ Renderer communication.
|
||||||
|
*
|
||||||
|
* Extracted from extension.ts to reduce file size.
|
||||||
|
* Provides an HTTP server that the AG renderer (DOM observer script) uses to:
|
||||||
|
* - Report detected approval buttons (POST /pending)
|
||||||
|
* - Poll for Discord responses (GET /response/:rid)
|
||||||
|
* - Receive click triggers (GET /trigger-click)
|
||||||
|
* - Deep DOM inspection (GET/POST /deep-inspect*)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { WSBridgeClient } from './ws-client';
|
||||||
|
|
||||||
|
// ─── Context interface (shared state from extension.ts) ───
|
||||||
|
|
||||||
|
export interface HttpBridgeContext {
|
||||||
|
bridgePath: string;
|
||||||
|
projectName: string;
|
||||||
|
activeSessionId: string;
|
||||||
|
wsBridge: WSBridgeClient | null;
|
||||||
|
sessionStalled: boolean;
|
||||||
|
lastPendingStepIndex: number;
|
||||||
|
logToFile: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Module-level state ───
|
||||||
|
|
||||||
|
let observerHttpServer: any = null;
|
||||||
|
const pendingResponses = new Map<string, { approved: boolean } | null>();
|
||||||
|
|
||||||
|
// Click trigger: extension sets this, renderer polls and clicks button
|
||||||
|
let clickTrigger: { action: 'approve' | 'reject'; timestamp: number } | null = null;
|
||||||
|
|
||||||
|
// Deep inspect trigger: curl sets this, renderer picks it up and POSTs results back
|
||||||
|
let deepInspectRequested = false;
|
||||||
|
let deepInspectResult: any = null;
|
||||||
|
let deepInspectWaiters: Array<(data: any) => void> = [];
|
||||||
|
|
||||||
|
// ─── Public API ───
|
||||||
|
|
||||||
|
/** Set click trigger (called from step-probe when approval needed) */
|
||||||
|
export function setClickTrigger(action: 'approve' | 'reject') {
|
||||||
|
clickTrigger = { action, timestamp: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the HTTP bridge server instance */
|
||||||
|
export function getHttpServer(): any {
|
||||||
|
return observerHttpServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive a deterministic port from project name (range 10000-60000) */
|
||||||
|
export function getDeterministicPort(name: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return 10000 + (Math.abs(hash) % 50000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the HTTP bridge server.
|
||||||
|
* Returns the port number (0 if failed).
|
||||||
|
*/
|
||||||
|
export function startHttpBridge(ctx: HttpBridgeContext, sdk: any): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const http = require('http');
|
||||||
|
const server = http.createServer((req: any, res: any) => {
|
||||||
|
// CORS headers for renderer fetch()
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
|
||||||
|
|
||||||
|
const url = new URL(req.url, `http://127.0.0.1`);
|
||||||
|
|
||||||
|
// POST /pending — renderer reports a detected approval button
|
||||||
|
if (req.method === 'POST' && url.pathname === '/pending') {
|
||||||
|
_handlePending(req, res, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /response/:rid — renderer polls for Discord approval
|
||||||
|
if (req.method === 'GET' && url.pathname.startsWith('/response/')) {
|
||||||
|
_handleGetResponse(req, res, url, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /trigger-click — renderer polls to check if extension wants a click
|
||||||
|
if (req.method === 'GET' && url.pathname === '/trigger-click') {
|
||||||
|
_handleTriggerClick(res, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /deep-inspect — trigger deep DOM inspection from renderer
|
||||||
|
if (req.method === 'GET' && url.pathname === '/deep-inspect') {
|
||||||
|
_handleDeepInspect(res, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /deep-inspect-trigger — renderer polls this
|
||||||
|
if (req.method === 'GET' && url.pathname === '/deep-inspect-trigger') {
|
||||||
|
_handleDeepInspectTrigger(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /deep-inspect-result — renderer posts inspection results here
|
||||||
|
if (req.method === 'POST' && url.pathname === '/deep-inspect-result') {
|
||||||
|
_handleDeepInspectResult(req, res, ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /ping — health check
|
||||||
|
if (url.pathname === '/ping') {
|
||||||
|
res.writeHead(200); res.end('pong');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404); res.end('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen on deterministic port (derived from projectName), fallback to random
|
||||||
|
let detPort = getDeterministicPort(ctx.projectName);
|
||||||
|
const tryListen = (targetPort: number) => {
|
||||||
|
server.listen(targetPort, '127.0.0.1', () => {
|
||||||
|
const port = server.address().port;
|
||||||
|
observerHttpServer = server;
|
||||||
|
ctx.logToFile(`[HTTP] bridge server started on port ${port}`);
|
||||||
|
|
||||||
|
// Write port to shared ports JSON (multi-bridge support)
|
||||||
|
const patcher = (sdk.integration as any)?._patcher;
|
||||||
|
if (patcher && typeof patcher.getWorkbenchDir === 'function') {
|
||||||
|
const workbenchDir = patcher.getWorkbenchDir();
|
||||||
|
const portsFile = path.join(workbenchDir, 'ag-bridge-ports.json');
|
||||||
|
let portsData: Record<string, number> = {};
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(portsFile)) {
|
||||||
|
portsData = JSON.parse(fs.readFileSync(portsFile, 'utf-8'));
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
portsData[ctx.projectName] = port;
|
||||||
|
fs.writeFileSync(portsFile, JSON.stringify(portsData), 'utf-8');
|
||||||
|
ctx.logToFile(`[HTTP] ports JSON updated → ${portsFile} (${ctx.projectName}=${port})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(port);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
server.on('error', (e: any) => {
|
||||||
|
if (e.code === 'EADDRINUSE' && detPort > 0) {
|
||||||
|
ctx.logToFile(`[HTTP] deterministic port ${detPort} in use, trying random...`);
|
||||||
|
detPort = 0;
|
||||||
|
const server2 = require('http').createServer(server._events.request);
|
||||||
|
observerHttpServer = server2;
|
||||||
|
server2.on('error', (e2: any) => {
|
||||||
|
ctx.logToFile(`[HTTP] random port also failed: ${e2.message}`);
|
||||||
|
resolve(0);
|
||||||
|
});
|
||||||
|
server2.listen(0, '127.0.0.1', () => {
|
||||||
|
const port = server2.address().port;
|
||||||
|
ctx.logToFile(`[HTTP] bridge server started on RANDOM port ${port}`);
|
||||||
|
resolve(port);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.logToFile(`[HTTP] server error: ${e.message}`);
|
||||||
|
resolve(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tryListen(detPort);
|
||||||
|
} catch (e: any) {
|
||||||
|
ctx.logToFile(`[HTTP] server failed: ${e.message}`);
|
||||||
|
resolve(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Route Handlers (private) ───
|
||||||
|
|
||||||
|
function _handlePending(req: any, res: any, ctx: HttpBridgeContext) {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c: string) => body += c);
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
|
||||||
|
// ── Server-side false positive filter ──
|
||||||
|
const cmd = (data.command || '').trim();
|
||||||
|
const FALSE_POSITIVE_RE = /^(Proceed|Continue|Open|Close|OK|Yes|No|Save|Undo|Redo|Back|Next|More|Less|Got it|Deny|Allow Once|Allow This Conversation|Dismiss|Decline|Accept|Reject|Accept all|Reject all)$/i;
|
||||||
|
if (FALSE_POSITIVE_RE.test(cmd)) {
|
||||||
|
ctx.logToFile(`[HTTP] filtered false positive: "${cmd}"`);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: false, filtered: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// "Run" button → step_probe handles these with full command detail
|
||||||
|
// Only let through if session is stalled AND step_probe hasn't created a pending yet
|
||||||
|
if (/^Run$/i.test(cmd)) {
|
||||||
|
if (!ctx.sessionStalled || ctx.lastPendingStepIndex >= 0) {
|
||||||
|
ctx.logToFile(`[HTTP] filtered "Run" — ${!ctx.sessionStalled ? 'not stalled' : 'step_probe pending exists'}`);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: false, filtered: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rid = data.request_id || Date.now().toString();
|
||||||
|
// Write pending file for Discord bot
|
||||||
|
const pendingDir = path.join(ctx.bridgePath, 'pending');
|
||||||
|
if (!fs.existsSync(pendingDir)) fs.mkdirSync(pendingDir, { recursive: true });
|
||||||
|
const pending: Record<string, any> = {
|
||||||
|
...data,
|
||||||
|
request_id: rid,
|
||||||
|
conversation_id: ctx.activeSessionId || '',
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
status: 'pending',
|
||||||
|
project_name: ctx.projectName,
|
||||||
|
auto_detected: true,
|
||||||
|
source: 'dom_observer',
|
||||||
|
step_index: ctx.lastPendingStepIndex >= 0 ? ctx.lastPendingStepIndex : undefined,
|
||||||
|
};
|
||||||
|
// File permission: inject multi-choice buttons
|
||||||
|
const cmdLower = (data.command || '').toLowerCase();
|
||||||
|
if (cmdLower.includes('allow') && !pending.buttons) {
|
||||||
|
// Dedup: skip if another file_permission pending was created within 10s
|
||||||
|
const nowMs = Date.now();
|
||||||
|
try {
|
||||||
|
const existingFiles = fs.readdirSync(pendingDir).filter((f: string) => f.endsWith('.json'));
|
||||||
|
for (const ef of existingFiles) {
|
||||||
|
const existing = JSON.parse(fs.readFileSync(path.join(pendingDir, ef), 'utf-8'));
|
||||||
|
if (existing.step_type === 'file_permission' && existing.status === 'pending'
|
||||||
|
&& existing.project_name === ctx.projectName) {
|
||||||
|
const age = nowMs - (existing.timestamp * 1000);
|
||||||
|
if (age < 10_000 && age >= 0) {
|
||||||
|
ctx.logToFile(`[HTTP] filtered duplicate file_permission (${age}ms old): ${ef}`);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: false, filtered: true, reason: 'dedup_file_permission' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
pending.buttons = [
|
||||||
|
{ text: 'Allow Once', index: 0 },
|
||||||
|
{ text: 'Allow This Conversation', index: 1 },
|
||||||
|
{ text: 'Deny', index: 2 },
|
||||||
|
];
|
||||||
|
pending.step_type = 'file_permission';
|
||||||
|
// Clean description: remove button labels from text
|
||||||
|
const rawDesc = (data.description || data.command || '').replace(/Deny|Allow Once|Allow This Conversation/gi, '').trim();
|
||||||
|
pending.command = `파일 접근 권한${rawDesc ? ': ' + rawDesc : ''}`;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2));
|
||||||
|
// WS dual-write
|
||||||
|
if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
|
||||||
|
ctx.wsBridge.sendPending({
|
||||||
|
request_id: rid,
|
||||||
|
command: pending.command || data.command || '',
|
||||||
|
description: pending.description || data.description || '',
|
||||||
|
step_type: pending.step_type,
|
||||||
|
status: 'pending',
|
||||||
|
buttons: pending.buttons,
|
||||||
|
project_name: ctx.projectName,
|
||||||
|
});
|
||||||
|
ctx.logToFile(`[HTTP-WS] pending sent via WS: ${rid}`);
|
||||||
|
}
|
||||||
|
ctx.logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true, request_id: rid }));
|
||||||
|
} catch (e: any) {
|
||||||
|
ctx.logToFile(`[HTTP] pending error: ${e.message}`);
|
||||||
|
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleGetResponse(_req: any, res: any, url: URL, ctx: HttpBridgeContext) {
|
||||||
|
const rid = url.pathname.split('/')[2];
|
||||||
|
const respFile = path.join(ctx.bridgePath, 'response', `${rid}.json`);
|
||||||
|
if (fs.existsSync(respFile)) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(respFile, 'utf8'));
|
||||||
|
ctx.logToFile(`[HTTP] response served to renderer: ${rid} approved=${data.approved}`);
|
||||||
|
// Delay deletion: processResponseFile (response watcher) may need to read it too.
|
||||||
|
// The watcher fires with 300ms delay, so 2s is safe.
|
||||||
|
setTimeout(() => {
|
||||||
|
try { fs.unlinkSync(respFile); } catch { }
|
||||||
|
}, 2000);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
} catch {
|
||||||
|
res.writeHead(200); res.end(JSON.stringify({ waiting: true }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.writeHead(200); res.end(JSON.stringify({ waiting: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleTriggerClick(res: any, ctx: HttpBridgeContext) {
|
||||||
|
if (clickTrigger && (Date.now() - clickTrigger.timestamp) < 30000) {
|
||||||
|
const trigger = clickTrigger;
|
||||||
|
clickTrigger = null; // consume once
|
||||||
|
ctx.logToFile(`[HTTP] trigger-click consumed: ${trigger.action}`);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ action: trigger.action }));
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ action: null }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleDeepInspect(res: any, ctx: HttpBridgeContext) {
|
||||||
|
deepInspectRequested = true;
|
||||||
|
ctx.logToFile('[HTTP] deep-inspect triggered — waiting for renderer result...');
|
||||||
|
// Wait up to 10s for renderer to POST result
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
deepInspectWaiters = deepInspectWaiters.filter(w => w !== waiter);
|
||||||
|
if (deepInspectResult) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(deepInspectResult));
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ status: 'timeout', message: 'Renderer did not respond in 10s. Is the v3 script loaded?' }));
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
const waiter = (data: any) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
deepInspectWaiters.push(waiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleDeepInspectTrigger(res: any) {
|
||||||
|
const requested = deepInspectRequested;
|
||||||
|
deepInspectRequested = false;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ inspect: requested }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleDeepInspectResult(req: any, res: any, ctx: HttpBridgeContext) {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c: string) => body += c);
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
deepInspectResult = data;
|
||||||
|
ctx.logToFile(`[HTTP] deep-inspect result received (${body.length} bytes)`);
|
||||||
|
// Write to file for reference
|
||||||
|
const inspectFile = path.join(ctx.bridgePath, 'deep-inspect-result.json');
|
||||||
|
fs.writeFileSync(inspectFile, JSON.stringify(data, null, 2));
|
||||||
|
// Notify waiters
|
||||||
|
const waiters = [...deepInspectWaiters];
|
||||||
|
deepInspectWaiters = [];
|
||||||
|
waiters.forEach(w => w(data));
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
} catch (e: any) {
|
||||||
|
ctx.logToFile(`[HTTP] deep-inspect-result parse error: ${e.message}`);
|
||||||
|
res.writeHead(400); res.end(JSON.stringify({ error: e.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export interface BridgeContext {
|
|||||||
lastPendingStepIndex: number;
|
lastPendingStepIndex: number;
|
||||||
stallProbed: boolean;
|
stallProbed: boolean;
|
||||||
sawRunningAfterPending: boolean;
|
sawRunningAfterPending: boolean;
|
||||||
clickTrigger: { action: 'approve' | 'reject'; timestamp: number } | null;
|
setClickTrigger: (action: 'approve' | 'reject') => void;
|
||||||
logToFile: (msg: string) => void;
|
logToFile: (msg: string) => void;
|
||||||
workspaceUri: string;
|
workspaceUri: string;
|
||||||
diffReviewMetadata: Map<string, { edit_step_indices: number[]; modified_files: string[] }>;
|
diffReviewMetadata: Map<string, { edit_step_indices: number[]; modified_files: string[] }>;
|
||||||
@@ -1453,9 +1453,9 @@ export async function tryApprovalStrategies(approved: boolean, sessionId: string
|
|||||||
// ── Strategy 2: Renderer DOM Click via HTTP Bridge (primary fallback) ──
|
// ── Strategy 2: Renderer DOM Click via HTTP Bridge (primary fallback) ──
|
||||||
try {
|
try {
|
||||||
const triggerAction = approved ? 'approve' : 'reject';
|
const triggerAction = approved ? 'approve' : 'reject';
|
||||||
ctx.logToFile(`[APPROVAL-2] Setting ctx.clickTrigger=${triggerAction} for renderer DOM click`);
|
ctx.logToFile(`[APPROVAL-2] Setting clickTrigger=${triggerAction} for renderer DOM click`);
|
||||||
ctx.clickTrigger = { action: triggerAction as 'approve' | 'reject', timestamp: Date.now() };
|
ctx.setClickTrigger(triggerAction as 'approve' | 'reject');
|
||||||
ctx.logToFile(`[APPROVAL-2] ✅ ctx.clickTrigger set — renderer will poll and click within 2s`);
|
ctx.logToFile(`[APPROVAL-2] ✅ clickTrigger set — renderer will poll and click within 2s`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ctx.logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`);
|
ctx.logToFile(`[APPROVAL-2] ❌ FAIL: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user