feat(bridge): 승인 감지 최적화 — latestToolCallStep 즉시 감지 + DOM scan 확장

- latestToolCallStep RPC 기반 즉시 감지 (30초 stall → 5초 poll)
- DOM scan 범위: findPanel() → document.body 확장
- Accept all/Reject all 리뷰 바 패턴 추가
- Stall detection을 100초 fallback으로 약화
- extractToolCommand/extractToolDescription 헬퍼 추가
- known-issues 5건 신규 추가
- start/services workflow: Python 전체 경로 + services.md 로딩

#task-258 #task-262
This commit is contained in:
2026-03-08 20:21:11 +09:00
parent 8ed1ece87a
commit 810fbcc114
6 changed files with 303 additions and 40 deletions

View File

@@ -117,3 +117,30 @@
- **원인**: Electron 렌더러 프로세스에서 동기 XHR이 보안 정책에 의해 차단됨
- **해결**: `fetch()` + `AbortSignal.timeout(2000)` 비동기 방식으로 교체 (`tryPingAsync`)
- **주의**: `async/await` 사용 불가 (ES5 환경). `.then()` 체이닝으로 구현
### [2026-03-08] DOM Observer — Run 버튼 감지 불가 (webview iframe 격리)
- **증상**: Allow Once/Allow This Conversation는 감지되나 Run/Accept 버튼은 감지 안 됨
- **원인**: Trust/permission 버튼은 워크벤치 외부 DOM에 렌더링, Run/Accept는 **Antigravity 채팅 webview iframe** 내부의 별도 DOM에 렌더링. 렌더러 스크립트의 `document.querySelector()`는 iframe 내부 접근 불가
- **해결**: Run 버튼은 DOM Observer가 아닌 **`latestToolCallStep` RPC 기반** 즉시 감지로 대체
- **주의**: webview iframe에 스크립트 주입은 Electron `executeJavaScript()`로 가능하나, 현재 RPC 방식이 더 안정적
### [2026-03-08] Accept all/Reject all 리뷰 바 — agent 패널 밖 DOM
- **증상**: 코드 변경 리뷰 바(Accept all/Reject all)가 DOM Observer에 감지 안 됨
- **원인**: `scan()` 함수가 `findPanel()` (`.antigravity-agent-side-panel` 등) 내부만 검색. 리뷰 바는 에디터/notification 영역에 렌더링되어 패널 밖에 있음
- **해결**: `scan()` 검색 범위를 `document.body` 전체로 확장, `Accept all` / `Reject all` 패턴 추가
- **주의**: 패널+body 이중 검색 시 dedupe 필요 (같은 버튼이 두 번 잡힐 수 있음)
### [2026-03-08] latestToolCallStep — 즉시 승인 감지 (stall 30초 대체)
- **증상**: 이전 stall-based 감지는 RUNNING+delta=0+modTime frozen 30초 후에야 pending 생성
- **원인**: `GetAllCascadeTrajectories` 응답의 `latestToolCallStep` 필드를 전혀 활용하지 않았음
- **해결**: `latestToolCallStep.step`의 status 필드에서 `WAITING` 여부를 직접 체크 → 5초 poll 1회에 즉시 감지. Stall detection은 100초 fallback으로 유지
- **주의**: `latestToolCallStep`의 정확한 protobuf 구조(status 필드 위치)는 런타임 덤프로 확정 필요 — 첫 실행 시 `[TOOL-STEP]` 로그 확인
### [2026-03-08] Extension 재설치 안전성 — 자동 패치 메커니즘
- **증상**: Antigravity 삭제 후 재설치 시 렌더러 스크립트가 동작 안 함
- **원인**: 재설치 시 HTML/product.json이 원본으로 리셋됨
- **해결**: Extension의 `setupApprovalObserver()`**자동으로** 모든 패치를 수행:
1. `workbench.html` + `workbench-jetski-agent.html` 인라인 스크립트 삽입
2. `product.json` SHA256 체크섬 자동 업데이트
3. HTTP bridge 서버 시작 + 결정론적 포트
- **주의**: 패치 후 반드시 **Antigravity 풀 재시작** 필요 (Reload Window 불가). Extension VSIX만 설치하면 수동 패치 불필요

View File

@@ -18,3 +18,5 @@
| 14 | 17:01~17:38 | **근본 원인 발견**: product.json 체크섬 불일치 → vscode-file:// 원본 캐시 서빙. 체크섬 수동 업데이트로 수정 | - | 🔧 |
| 15 | 17:50~18:30 | **v0.3.5**: 포트 디스커버리 수정 (결정론적 포트 + 하드코딩), 인라인 스크립트 전환 (`<script src>``<script>inline</script>`), product.json 자동 체크섬 업데이트 | - | 🔧 |
| 16 | 19:00~19:48 | 렌더러 스크립트 로딩 디버깅: sync XHR→async fetch 변환, 설치경로 불일치 발견, vscode-file:// 커스텀 파일 서빙 불가 확인, Electron 풀 재시작 필요 발견 | - | 🔧 |
| 17 | 19:53~20:00 | **AG 재시작 성공**: GB Observer Bridge connected (port 34332), Allow Once/Allow This Conversation 감지 정상 동작 확인 | - | ✅ |
| 18 | 20:00~20:15 | **승인 감지 최적화**: latestToolCallStep 즉시 감지 (30초→5초), DOM scan 범위 확장 (Accept all/Reject all), stall→100초 fallback | - | 🔧 |

View File

@@ -0,0 +1,36 @@
# 승인 감지 최적화 + GB Observer 동작 확인
- **시간**: 2026-03-08 19:53~20:15
- **Commit**: (이 세션)
- **Vikunja**: #262 → done (인라인 스크립트 재시작 테스트 완료), #258 → 미완료
## 핵심 발견
### 1. GB Observer 재시작 성공
- Antigravity 풀 재시작 후 인라인 스크립트 정상 동작 확인
- `[GB Observer] Bridge connected on port 34332` + `v2 Observer active`
- **Allow Once / Allow This Conversation** 버튼 즉시 감지 + pending 생성 정상
### 2. Run 버튼 미감지 원인 (webview iframe 격리)
- Trust/permission 버튼 → 워크벤치 외부 DOM → Observer가 감지 ✅
- Run/Accept 버튼 → Antigravity 채팅 **webview iframe** 내부 DOM → `document.querySelector()` 접근 불가 ❌
- **해결**: `latestToolCallStep` RPC 기반 즉시 감지로 대체
### 3. Accept all/Reject all 리뷰 바
- 코드 변경 리뷰 바는 agent 패널(`findPanel()`) 밖 → `document.body` 전체 스캔으로 확장
### 4. Extension 재설치 안전성 확인
- `setupApprovalObserver()`가 HTML 패치 + 체크섬 업데이트 **전자동** 수행
- Extension VSIX만 설치하면 수동 패치 불필요
- 단, 패치 후 **Antigravity 풀 재시작** 필수 (Electron 메인 프로세스 체크섬 캐시)
## 코드 변경
- `extension.ts`: latestToolCallStep 즉시 감지, DOM scan 범위 확장, stall→100초 fallback
- `.agents/workflows/start.md`: services.md 로딩 + Python 전체 경로
- `.agents/workflows/services.md`: Python 전체 경로
- `.agents/references/known-issues.md`: 5건 신규 추가
## 미완료
- **latestToolCallStep protobuf 구조 확정** — 첫 실행 시 `[TOOL-STEP]` 덤프 로그로 status 필드 위치 확인 필요
- **Accept all 리뷰 바 실제 감지 테스트** — body 스캔 후 올바르게 잡히는지 검증
- **다음 세션**: AG 재시작 → tool call 발생 → 콘솔에서 구조 확인 → 필요 시 필드 매핑 수정

View File

@@ -605,7 +605,8 @@ function generateApprovalObserverScript(_port) {
// ── Button patterns to detect (order matters: first match wins per scan) ──
var PATS=[
{re:/^Run$/i, type:'terminal_command'},
{re:/^Accept/i, type:'agent_step'},
{re:/^Accept all$/i, type:'diff_review'},
{re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'},
{re:/^Continue$/i, type:'continue'},
@@ -613,7 +614,7 @@ function generateApprovalObserverScript(_port) {
];
// Reject button patterns for finding the counterpart
var REJECT_RE=[/^reject$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
// ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
function btnId(b,type){
@@ -679,11 +680,18 @@ function generateApprovalObserverScript(_port) {
var now=Date.now();
var panel=findPanel();
if(!panel)return;
// Expand search: panel-scoped first, then full body for review bars
var searchRoots=[];
if(panel)searchRoots.push(panel);
// Always also scan body for diff review bar (Accept all/Reject all)
// which lives outside the agent panel in the editor notification area
if(document.body)searchRoots.push(document.body);
if(!searchRoots.length)return;
// Find ALL buttons in the panel
var allBtns=panel.querySelectorAll('button');
if(!allBtns.length)return;
var seen={}; // dedupe buttons across search roots
for(var r=0;r<searchRoots.length;r++){
var allBtns=searchRoots[r].querySelectorAll('button');
if(!allBtns.length)continue;
for(var j=0;j<allBtns.length;j++){
var b=allBtns[j];
@@ -736,6 +744,7 @@ function generateApprovalObserverScript(_port) {
// Process ONE button per scan cycle (avoid flooding)
return;
}
} // end searchRoots loop
}
// ── Poll for Discord response ──
@@ -903,6 +912,8 @@ function setupMonitor() {
let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1;
let lastTaskStepIndex = -1;
let lastToolStepIndex = -1; // track latestToolCallStep for instant detection
let toolStepDumped = false; // dump structure once for debugging
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
let lastPendingTime = 0; // cooldown: minimum gap between pendings
@@ -975,16 +986,56 @@ function setupMonitor() {
if (pollCount <= 10 || pollCount % 6 === 0 || delta > 0) {
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
}
// ── Stall-based approval detection ──
// INSIGHT: Both thinking and approval show RUNNING+delta=0.
// DIFFERENTIATOR: lastModifiedTime
// - Thinking: lastModifiedTime KEEPS CHANGING (server actively processing)
// - Approval wait: lastModifiedTime FROZEN (server idle, waiting for user)
// DEBUG: dump session keys on first poll to find modTime field
// ── PRIMARY: Instant tool-call-based approval detection ──
// latestToolCallStep contains the most recent tool call with its status.
// If status includes WAITING, the AI is waiting for user approval.
const toolStep = bestSession.latestToolCallStep;
if (toolStep && toolStep.stepIndex > lastToolStepIndex) {
const stepData = toolStep.step || {};
// Dump structure once for debugging (keys + first 800 chars)
if (!toolStepDumped) {
logToFile(`[TOOL-STEP] keys: ${JSON.stringify(Object.keys(toolStep))}`);
logToFile(`[TOOL-STEP] step keys: ${JSON.stringify(Object.keys(stepData))}`);
logToFile(`[TOOL-STEP] full: ${JSON.stringify(toolStep).substring(0, 800)}`);
toolStepDumped = true;
}
// Check for WAITING status in various possible field locations
const stepStatus = String(stepData.status || toolStep.status || stepData.stepStatus || '').toLowerCase();
const isWaiting = stepStatus.includes('waiting') || stepStatus.includes('pending');
if (isWaiting && toolStep.stepIndex !== lastPendingStepIndex) {
// INSTANT detection! Extract tool call details
const command = extractToolCommand(stepData);
const description = extractToolDescription(stepData, currentTitle, toolStep.stepIndex);
logToFile(`[TOOL-DETECT] INSTANT! step=${toolStep.stepIndex} status='${stepStatus}' cmd='${command}'`);
lastPendingStepIndex = toolStep.stepIndex;
lastPendingTime = Date.now();
sawRunningAfterPending = false;
writePendingApproval({
conversation_id: activeSessionId,
command,
description,
step_type: 'tool_call',
step_index: toolStep.stepIndex,
});
}
else if (!isWaiting) {
// Tool call exists but not waiting — log for diagnostics
if (toolStep.stepIndex > lastToolStepIndex) {
logToFile(`[TOOL-STEP] step=${toolStep.stepIndex} status='${stepStatus}' (not waiting)`);
}
}
lastToolStepIndex = toolStep.stepIndex;
}
// ── FALLBACK: Stall-based approval detection ──
// Kept as safety net for edge cases where latestToolCallStep doesn't report WAITING.
// Threshold raised from 6 (30s) to 20 (100s) since primary detection above handles most cases.
// DEBUG: dump session keys on first poll
if (pollCount === 1) {
const keys = Object.keys(bestSession).filter(k => !['latestNotifyUserStep', 'latestTaskBoundaryStep', 'latestToolCallStep'].includes(k));
logToFile(`[DEBUG] session keys: ${keys.join(', ')}`);
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}, lastModifiedTimestamp=${bestSession.lastModifiedTimestamp}, modifiedTime=${bestSession.modifiedTime}`);
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}`);
if (toolStep)
logToFile(`[DEBUG] toolStep present, stepIndex=${toolStep.stepIndex}`);
}
const currentModTime = bestSession.lastModifiedTime || bestSession.lastModifiedTimestamp || bestSession.modifiedTime || '';
const modTimeChanged = currentModTime !== lastModTime;
@@ -1013,17 +1064,17 @@ function setupMonitor() {
lastModTime = currentModTime;
const now = Date.now();
const cooldownOk = (now - lastPendingTime) > 60_000;
if (consecutiveIdleCount >= 6 && sawRunningAfterPending && cooldownOk) {
// 6 polls × 5s = 30 seconds of FROZEN stall = approval waiting
if (consecutiveIdleCount >= 20 && sawRunningAfterPending && cooldownOk) {
// 20 polls × 5s = 100 seconds — fallback only
lastPendingStepIndex = currentCount;
lastPendingTime = now;
sawRunningAfterPending = false;
const command = `Stall at step ${currentCount}`;
const description = `승인 대기 감지 (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
logToFile(`[STALL] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
const command = `Stall at step ${currentCount} (fallback)`;
const description = `승인 대기 감지 — fallback (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
logToFile(`[STALL-FALLBACK] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
writePendingApproval({ conversation_id: activeSessionId, command, description });
}
else if (consecutiveIdleCount === 6) {
else if (consecutiveIdleCount === 20) {
const reasons = [];
if (!sawRunningAfterPending)
reasons.push('needDelta>0');
@@ -1269,6 +1320,49 @@ function filterEphemeral(text) {
}
return text;
}
/** Extract human-readable command from a tool call step's data. */
function extractToolCommand(stepData) {
// Try common step data shapes from protobuf
if (stepData.runCommand) {
return stepData.runCommand.commandLine || stepData.runCommand.command || 'Run Command';
}
if (stepData.writeToFile) {
const target = stepData.writeToFile.targetFile || stepData.writeToFile.filePath || 'file';
return `Write: ${target.split(/[\\/]/).pop()}`;
}
if (stepData.codeAction) {
const fp = stepData.codeAction.filePath || '';
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
}
if (stepData.replaceFileContent || stepData.multiReplaceFileContent) {
const d = stepData.replaceFileContent || stepData.multiReplaceFileContent;
const fp = d.targetFile || d.filePath || '';
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
}
if (stepData.sendCommandInput) {
return `Send Input: ${(stepData.sendCommandInput.input || '').substring(0, 50)}`;
}
// Generic fallback: use first key name
const keys = Object.keys(stepData).filter(k => k !== 'status' && k !== 'stepStatus');
return keys.length > 0 ? keys[0] : 'Unknown tool call';
}
/** Extract description from a tool call step for Discord display. */
function extractToolDescription(stepData, sessionTitle, stepIndex) {
const parts = [`Step #${stepIndex}`, `Session: "${sessionTitle}"`];
// Try to get code/command content for context
if (stepData.runCommand) {
const cmd = stepData.runCommand.commandLine || stepData.runCommand.command || '';
if (cmd)
parts.push(`Command: ${cmd.substring(0, 200)}`);
}
if (stepData.writeToFile?.targetFile) {
parts.push(`File: ${stepData.writeToFile.targetFile}`);
}
if (stepData.codeAction?.filePath) {
parts.push(`File: ${stepData.codeAction.filePath}`);
}
return parts.join('\n');
}
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
function writePendingApproval(data) {
try {
@@ -1286,6 +1380,8 @@ function writePendingApproval(data) {
status: 'pending',
discord_message_id: 0,
project_name: projectName,
...(data.step_type ? { step_type: data.step_type } : {}),
...(data.step_index !== undefined ? { step_index: data.step_index } : {}),
};
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
console.log(`Gravity Bridge: pending approval written → ${id}.json`);

File diff suppressed because one or more lines are too long

View File

@@ -576,7 +576,8 @@ function generateApprovalObserverScript(_port: number): string {
// ── Button patterns to detect (order matters: first match wins per scan) ──
var PATS=[
{re:/^Run$/i, type:'terminal_command'},
{re:/^Accept/i, type:'agent_step'},
{re:/^Accept all$/i, type:'diff_review'},
{re:/^Accept$/i, type:'agent_step'},
{re:/^Allow/i, type:'permission'},
{re:/^Approve/i, type:'agent_step'},
{re:/^Continue$/i, type:'continue'},
@@ -584,7 +585,7 @@ function generateApprovalObserverScript(_port: number): string {
];
// Reject button patterns for finding the counterpart
var REJECT_RE=[/^reject$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i];
// ── Stable button fingerprint (no getBoundingClientRect — scroll-safe) ──
function btnId(b,type){
@@ -650,11 +651,18 @@ function generateApprovalObserverScript(_port: number): string {
var now=Date.now();
var panel=findPanel();
if(!panel)return;
// Expand search: panel-scoped first, then full body for review bars
var searchRoots=[];
if(panel)searchRoots.push(panel);
// Always also scan body for diff review bar (Accept all/Reject all)
// which lives outside the agent panel in the editor notification area
if(document.body)searchRoots.push(document.body);
if(!searchRoots.length)return;
// Find ALL buttons in the panel
var allBtns=panel.querySelectorAll('button');
if(!allBtns.length)return;
var seen={}; // dedupe buttons across search roots
for(var r=0;r<searchRoots.length;r++){
var allBtns=searchRoots[r].querySelectorAll('button');
if(!allBtns.length)continue;
for(var j=0;j<allBtns.length;j++){
var b=allBtns[j];
@@ -707,6 +715,7 @@ function generateApprovalObserverScript(_port: number): string {
// Process ONE button per scan cycle (avoid flooding)
return;
}
} // end searchRoots loop
}
// ── Poll for Discord response ──
@@ -873,6 +882,8 @@ function setupMonitor() {
let lastKnownStepCount = 0;
let lastNotifyStepIndex = -1;
let lastTaskStepIndex = -1;
let lastToolStepIndex = -1; // track latestToolCallStep for instant detection
let toolStepDumped = false; // dump structure once for debugging
let lastPendingStepIndex = -1; // dedup: don't re-create pending for same step
let consecutiveIdleCount = 0; // debounce: require N consecutive stall polls
let lastPendingTime = 0; // cooldown: minimum gap between pendings
@@ -951,17 +962,62 @@ function setupMonitor() {
logToFile(`[POLL#${pollCount}] status=${statusStr} steps=${currentCount} delta=${delta}`);
}
// ── Stall-based approval detection ──
// INSIGHT: Both thinking and approval show RUNNING+delta=0.
// DIFFERENTIATOR: lastModifiedTime
// - Thinking: lastModifiedTime KEEPS CHANGING (server actively processing)
// - Approval wait: lastModifiedTime FROZEN (server idle, waiting for user)
// ── PRIMARY: Instant tool-call-based approval detection ──
// latestToolCallStep contains the most recent tool call with its status.
// If status includes WAITING, the AI is waiting for user approval.
const toolStep = bestSession.latestToolCallStep;
if (toolStep && toolStep.stepIndex > lastToolStepIndex) {
const stepData = toolStep.step || {};
// Dump structure once for debugging (keys + first 800 chars)
if (!toolStepDumped) {
logToFile(`[TOOL-STEP] keys: ${JSON.stringify(Object.keys(toolStep))}`);
logToFile(`[TOOL-STEP] step keys: ${JSON.stringify(Object.keys(stepData))}`);
logToFile(`[TOOL-STEP] full: ${JSON.stringify(toolStep).substring(0, 800)}`);
toolStepDumped = true;
}
// DEBUG: dump session keys on first poll to find modTime field
// Check for WAITING status in various possible field locations
const stepStatus = String(
stepData.status || toolStep.status || stepData.stepStatus || ''
).toLowerCase();
const isWaiting = stepStatus.includes('waiting') || stepStatus.includes('pending');
if (isWaiting && toolStep.stepIndex !== lastPendingStepIndex) {
// INSTANT detection! Extract tool call details
const command = extractToolCommand(stepData);
const description = extractToolDescription(stepData, currentTitle, toolStep.stepIndex);
logToFile(`[TOOL-DETECT] INSTANT! step=${toolStep.stepIndex} status='${stepStatus}' cmd='${command}'`);
lastPendingStepIndex = toolStep.stepIndex;
lastPendingTime = Date.now();
sawRunningAfterPending = false;
writePendingApproval({
conversation_id: activeSessionId,
command,
description,
step_type: 'tool_call',
step_index: toolStep.stepIndex,
});
} else if (!isWaiting) {
// Tool call exists but not waiting — log for diagnostics
if (toolStep.stepIndex > lastToolStepIndex) {
logToFile(`[TOOL-STEP] step=${toolStep.stepIndex} status='${stepStatus}' (not waiting)`);
}
}
lastToolStepIndex = toolStep.stepIndex;
}
// ── FALLBACK: Stall-based approval detection ──
// Kept as safety net for edge cases where latestToolCallStep doesn't report WAITING.
// Threshold raised from 6 (30s) to 20 (100s) since primary detection above handles most cases.
// DEBUG: dump session keys on first poll
if (pollCount === 1) {
const keys = Object.keys(bestSession).filter(k => !['latestNotifyUserStep', 'latestTaskBoundaryStep', 'latestToolCallStep'].includes(k));
logToFile(`[DEBUG] session keys: ${keys.join(', ')}`);
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}, lastModifiedTimestamp=${(bestSession as any).lastModifiedTimestamp}, modifiedTime=${(bestSession as any).modifiedTime}`);
logToFile(`[DEBUG] lastModifiedTime=${bestSession.lastModifiedTime}`);
if (toolStep) logToFile(`[DEBUG] toolStep present, stepIndex=${toolStep.stepIndex}`);
}
const currentModTime = bestSession.lastModifiedTime || (bestSession as any).lastModifiedTimestamp || (bestSession as any).modifiedTime || '';
@@ -993,18 +1049,18 @@ function setupMonitor() {
const now = Date.now();
const cooldownOk = (now - lastPendingTime) > 60_000;
if (consecutiveIdleCount >= 6 && sawRunningAfterPending && cooldownOk) {
// 6 polls × 5s = 30 seconds of FROZEN stall = approval waiting
if (consecutiveIdleCount >= 20 && sawRunningAfterPending && cooldownOk) {
// 20 polls × 5s = 100 seconds — fallback only
lastPendingStepIndex = currentCount;
lastPendingTime = now;
sawRunningAfterPending = false;
const command = `Stall at step ${currentCount}`;
const description = `승인 대기 감지 (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
const command = `Stall at step ${currentCount} (fallback)`;
const description = `승인 대기 감지 — fallback (${consecutiveIdleCount * 5}초 정지), Title: "${currentTitle}"`;
logToFile(`[STALL] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
logToFile(`[STALL-FALLBACK] step=${currentCount} frozenCount=${consecutiveIdleCount} → pending`);
writePendingApproval({ conversation_id: activeSessionId, command, description });
} else if (consecutiveIdleCount === 6) {
} else if (consecutiveIdleCount === 20) {
const reasons = [];
if (!sawRunningAfterPending) reasons.push('needDelta>0');
if (!cooldownOk) reasons.push(`cooldown(${Math.round((60000 - (now - lastPendingTime)) / 1000)}s)`);
@@ -1239,8 +1295,52 @@ function filterEphemeral(text: string): string | null {
return text;
}
/** Extract human-readable command from a tool call step's data. */
function extractToolCommand(stepData: any): string {
// Try common step data shapes from protobuf
if (stepData.runCommand) {
return stepData.runCommand.commandLine || stepData.runCommand.command || 'Run Command';
}
if (stepData.writeToFile) {
const target = stepData.writeToFile.targetFile || stepData.writeToFile.filePath || 'file';
return `Write: ${target.split(/[\\/]/).pop()}`;
}
if (stepData.codeAction) {
const fp = stepData.codeAction.filePath || '';
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
}
if (stepData.replaceFileContent || stepData.multiReplaceFileContent) {
const d = stepData.replaceFileContent || stepData.multiReplaceFileContent;
const fp = d.targetFile || d.filePath || '';
return `Edit: ${fp.split(/[\\/]/).pop() || 'file'}`;
}
if (stepData.sendCommandInput) {
return `Send Input: ${(stepData.sendCommandInput.input || '').substring(0, 50)}`;
}
// Generic fallback: use first key name
const keys = Object.keys(stepData).filter(k => k !== 'status' && k !== 'stepStatus');
return keys.length > 0 ? keys[0] : 'Unknown tool call';
}
/** Extract description from a tool call step for Discord display. */
function extractToolDescription(stepData: any, sessionTitle: string, stepIndex: number): string {
const parts = [`Step #${stepIndex}`, `Session: "${sessionTitle}"`];
// Try to get code/command content for context
if (stepData.runCommand) {
const cmd = stepData.runCommand.commandLine || stepData.runCommand.command || '';
if (cmd) parts.push(`Command: ${cmd.substring(0, 200)}`);
}
if (stepData.writeToFile?.targetFile) {
parts.push(`File: ${stepData.writeToFile.targetFile}`);
}
if (stepData.codeAction?.filePath) {
parts.push(`File: ${stepData.codeAction.filePath}`);
}
return parts.join('\n');
}
/** Write a pending approval file matching Bot's ApprovalRequest dataclass. */
function writePendingApproval(data: { conversation_id: string; command: string; description: string }) {
function writePendingApproval(data: { conversation_id: string; command: string; description: string; step_type?: string; step_index?: number }) {
try {
const pendingDir = path.join(bridgePath, 'pending');
if (!fs.existsSync(pendingDir)) { fs.mkdirSync(pendingDir, { recursive: true }); }
@@ -1254,6 +1354,8 @@ function writePendingApproval(data: { conversation_id: string; command: string;
status: 'pending',
discord_message_id: 0,
project_name: projectName,
...(data.step_type ? { step_type: data.step_type } : {}),
...(data.step_index !== undefined ? { step_index: data.step_index } : {}),
};
fs.writeFileSync(path.join(pendingDir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
console.log(`Gravity Bridge: pending approval written → ${id}.json`);