Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04f33e584c | ||
|
|
8d76a57fe8 | ||
|
|
8db2bd3893 | ||
|
|
555abbc0d6 | ||
|
|
3b129f11c4 | ||
|
|
2cab36f06d | ||
|
|
fd357e490b | ||
|
|
55157bceaf | ||
|
|
5608bd0ef9 | ||
|
|
abd90bbc9c | ||
|
|
7fe85a11da | ||
|
|
8e62514eef | ||
|
|
dddb920061 | ||
|
|
787e247a08 | ||
|
|
6f229a86e3 | ||
|
|
5fd59afacf | ||
|
|
3d273ff853 | ||
|
|
6a565ee126 | ||
|
|
0bf853d9ef | ||
|
|
16393b2554 | ||
|
|
d450c4f966 | ||
|
|
4b3b581901 | ||
|
|
af03a89e0c | ||
|
|
7bfca25958 | ||
|
|
d444e62b20 | ||
|
|
07e2e907c5 | ||
|
|
36b8576c78 | ||
|
|
5ba1d9f3c3 | ||
|
|
efe37d4cfc | ||
|
|
c662f9c240 | ||
|
|
12c0b7b6c0 | ||
|
|
f007437991 | ||
|
|
f6bdc45fe7 | ||
|
|
01f935f074 | ||
|
|
550322cb0c | ||
|
|
14f785925c | ||
|
|
6449a00f46 | ||
|
|
3fd9e95579 | ||
|
|
78047dfd7d | ||
|
|
9986841f9b | ||
|
|
b422e2f94f | ||
|
|
e74cc82bcf | ||
|
|
ea46ba6c60 | ||
|
|
d67f97158a | ||
|
|
e140dc74c6 | ||
|
|
24a7f333a2 | ||
|
|
80cfe87390 | ||
|
|
5733291a0f | ||
|
|
b5d56246f6 | ||
|
|
245acdabad | ||
|
|
49fb046363 | ||
|
|
ce6a09b891 | ||
|
|
2fad28d552 | ||
|
|
698cdb6744 | ||
|
|
9ec20d4cb2 | ||
|
|
9ba0015530 | ||
|
|
f6dd6e3c7f | ||
|
|
595f4b6dd5 | ||
|
|
4891a0e6f2 | ||
|
|
dd645994b2 | ||
|
|
fcdfcd3186 | ||
|
|
c12f73f774 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,3 +25,6 @@ yarn.lock
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
test-injection/
|
||||
notepad.md
|
||||
oauth-success.html
|
||||
|
||||
27
.opencode/background-tasks.json
Normal file
27
.opencode/background-tasks.json
Normal file
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"id": "bg_wzsdt60b",
|
||||
"sessionID": "ses_4f3e89f0dffeooeXNVx5QCifse",
|
||||
"parentSessionID": "ses_4f3e8d141ffeyfJ1taVVOdQTzx",
|
||||
"parentMessageID": "msg_b0c172ee1001w2B52VSZrP08PJ",
|
||||
"description": "Explore opencode in codebase",
|
||||
"agent": "explore",
|
||||
"status": "completed",
|
||||
"startedAt": "2025-12-11T06:26:57.395Z",
|
||||
"completedAt": "2025-12-11T06:27:36.778Z"
|
||||
},
|
||||
{
|
||||
"id": "bg_392b9c9b",
|
||||
"sessionID": "ses_4f38ebf4fffeJZBocIn3UVv7vE",
|
||||
"parentSessionID": "ses_4f38eefa0ffeKV0pVNnwT37P5L",
|
||||
"parentMessageID": "msg_b0c7110d2001TMBlPeEYIrByvs",
|
||||
"description": "Test explore agent",
|
||||
"agent": "explore",
|
||||
"status": "running",
|
||||
"startedAt": "2025-12-11T08:05:07.378Z",
|
||||
"progress": {
|
||||
"toolCalls": 0,
|
||||
"lastUpdate": "2025-12-11T08:05:07.378Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -34,6 +34,7 @@ oh-my-opencode/
|
||||
| Modify LSP behavior | `src/tools/lsp/` | client.ts for connection logic |
|
||||
| AST-Grep patterns | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi |
|
||||
| Terminal features | `src/features/terminal/` | title.ts |
|
||||
| Google Antigravity auth | `src/auth/antigravity/` | OAuth plugin for Google models |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
|
||||
182
README.ko.md
182
README.ko.md
@@ -8,13 +8,14 @@
|
||||
- [LLM Agent를 위한 안내](#llm-agent를-위한-안내)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [기능](#기능)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
- [Tools](#tools)
|
||||
- [내장 LSP Tools](#내장-lsp-tools)
|
||||
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
|
||||
- [Grep](#grep)
|
||||
- [내장 MCPs](#내장-mcps)
|
||||
- [Background Task](#background-task)
|
||||
- [Hooks](#hooks)
|
||||
- [Claude Code 호환성](#claude-code-호환성)
|
||||
- [기타 편의 기능](#기타-편의-기능)
|
||||
- [설정](#설정)
|
||||
@@ -40,9 +41,10 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
|
||||
|
||||
- **모델 설정이 필요합니다**
|
||||
- 이 플러그인은 [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, Anthropic 의 모델을 사용합니다.
|
||||
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요. (Anthropic 플랜은 유료입니다)
|
||||
- OpenAI 모델들을 사용하기 위해 이 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 추천합니다. (OpenAI 플랜은 유료입니다)
|
||||
- Gemini, GPT 를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 추천합니다. (Gemini 는 구독하지 않아도 사용 할 수 있습니다)
|
||||
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요.
|
||||
- OpenAI 모델 (ChatGPT Plus/Pro)을 사용하기 위해 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 설치하세요.
|
||||
- Google Gemini 모델을 위해 `oh-my-opencode.json`에서 `google_auth: true`를 활성화하세요 (**내장 Antigravity OAuth**).
|
||||
- 다른 프로바이더를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 대안으로 사용할 수 있습니다.
|
||||
- **사실 밑의 내용은 읽지 않아도 됩니다. 그냥 설치하시면 됩니다. 모델 설정하고나면 추가로 공부하거나 알 필요가 없고, 설치하고 나면 그냥 더 좋아집니다.**
|
||||
- **이 문서도 그냥 Claude Code, OpenCode, Cursor에 붙여넣고, 설치하고, 사용자의 개입이 필요할 때 알려달라고 프롬프팅하세요. 그럼 그냥 깔립니다.**
|
||||
- 당신이 LLM Agent 라면, 필요한 플러그인과 의존성을 설치하고, tmux 를 통해 opencode auth login 을 실행하여 사용자의 구성을 도와주세요.
|
||||
@@ -108,11 +110,63 @@ cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있
|
||||
|
||||
### 4단계: 인증정보 설정
|
||||
|
||||
plugin 에 oh my opencode 를 추가했다면, tmux 를 사용하여 다음의 내용을 진행주세요.
|
||||
plugin 에 oh my opencode 를 추가했다면, tmux 를 사용하여 사용자의 인증 설정을 도와주세요.
|
||||
|
||||
1. tmux 를 사용하여 셸 열기
|
||||
2. opencode auth login > Provider Anthropic > Login method Claude Pro/Max > 사용자에게 oauth 화면에서 인증하라고 안내
|
||||
3. 사용자에게 macOS 일 경우 VibeProxy, 아닐 경우 CLIProxyAPI 를 사용하여 OpenAI / Google 인증을 완료하라고 안내
|
||||
#### 4.1 Anthropic (Claude)
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: Anthropic 선택
|
||||
# Login method: Claude Pro/Max 선택
|
||||
# 브라우저에서 OAuth 플로우 완료
|
||||
```
|
||||
|
||||
#### 4.2 Google Gemini (내장 Antigravity OAuth)
|
||||
|
||||
먼저 `~/.config/opencode/oh-my-opencode.json` (또는 프로젝트 레벨의 `.opencode/oh-my-opencode.json`)에서 Google auth를 활성화:
|
||||
|
||||
```json
|
||||
{
|
||||
"google_auth": true
|
||||
}
|
||||
```
|
||||
|
||||
그 후 인증:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: Google 선택
|
||||
# Login method: OAuth with Google (Antigravity) 선택
|
||||
# 브라우저에서 OAuth 플로우 완료
|
||||
```
|
||||
|
||||
#### 4.3 OpenAI (ChatGPT Plus/Pro)
|
||||
|
||||
먼저 opencode-openai-codex-auth 플러그인 추가:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.1.0"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**중요**: `opencode.json`에 전체 모델 설정도 구성해야 합니다. [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json)에서 provider/models 설정을 복사하세요. 자세한 설정 방법은 [opencode-openai-codex-auth 문서](https://github.com/numman-ali/opencode-openai-codex-auth)를 참고하세요.
|
||||
|
||||
그 후 인증:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: OpenAI 선택
|
||||
# Login method: ChatGPT Plus/Pro (Codex Subscription) 선택
|
||||
# 브라우저에서 OAuth 플로우 완료
|
||||
```
|
||||
|
||||
#### 4.4 대안: 프록시 기반 인증
|
||||
|
||||
프록시 기반 인증을 선호하는 사용자를 위해 [VibeProxy](https://github.com/automazeio/vibeproxy) (macOS) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 대안으로 사용할 수 있습니다.
|
||||
|
||||
### ⚠️ 주의
|
||||
|
||||
@@ -143,35 +197,6 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
|
||||
## 기능
|
||||
|
||||
### Hooks
|
||||
|
||||
- **Todo Continuation Enforcer**: 에이전트가 멈추기 전 모든 TODO 항목을 완료하도록 강제합니다. LLM의 고질적인 "중도 포기" 문제를 방지합니다.
|
||||
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
|
||||
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
|
||||
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
|
||||
- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다:
|
||||
- **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입
|
||||
- **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가
|
||||
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
|
||||
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
|
||||
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
||||
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # 프로젝트 전체 컨텍스트
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src 전용 컨텍스트
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # 컴포넌트 전용 컨텍스트
|
||||
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
|
||||
```
|
||||
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
|
||||
- **Directory README.md Injector**: 파일을 읽을 때 `README.md` 내용을 자동으로 주입합니다. AGENTS.md Injector와 동일하게 동작하며, 파일 디렉토리부터 프로젝트 루트까지 탐색합니다. LLM 에이전트에게 프로젝트 문서 컨텍스트를 제공합니다. 각 디렉토리의 README는 세션당 한 번만 주입됩니다.
|
||||
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
|
||||
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
|
||||
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
|
||||
- **Grep Output Truncator**: Grep 검색 결과가 너무 길어 컨텍스트를 장악해버리는 것을 방지하기 위해, 과도한 출력을 자동으로 자릅니다.
|
||||
|
||||
### Agents
|
||||
|
||||
- **oracle** (`openai/gpt-5.2`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.2의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
@@ -190,6 +215,12 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
|
||||
에이전트의 모델, 프롬프트, 권한은 `oh-my-opencode.json`에서 커스텀할 수 있습니다. 자세한 내용은 [설정](#설정)을 참고하세요.
|
||||
|
||||
#### 서브 에이전트 오케스트레이션 (omo_task)
|
||||
|
||||
`omo_task` 도구를 사용하면 에이전트(`oracle`, `frontend-ui-ux-engineer` 등)가 `explore`나 `librarian`을 서브 에이전트로 호출하여 특정 작업을 위임할 수 있습니다. 이를 통해 에이전트가 작업을 진행하기 전에 전문화된 다른 에이전트에게 정보를 요청하는 강력한 워크플로우가 가능합니다.
|
||||
|
||||
> **참고**: 무한 재귀를 방지하기 위해 `explore`와 `librarian` 에이전트는 `omo_task` 도구를 직접 사용할 수 없습니다.
|
||||
|
||||
### Tools
|
||||
|
||||
#### 내장 LSP Tools
|
||||
@@ -238,6 +269,71 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
}
|
||||
```
|
||||
|
||||
#### Background Task
|
||||
|
||||
장시간 실행되는 작업이나 복잡한 분석을 메인 세션을 차단하지 않고 백그라운드에서 실행합니다. 작업이 완료되면 시스템이 자동으로 알림을 보냅니다.
|
||||
|
||||
- **background_task**: 백그라운드 에이전트 작업을 시작합니다. 설명, 프롬프트, 에이전트 타입을 지정하면 즉시 task ID를 반환합니다.
|
||||
- **background_output**: 작업 진행 상황 확인(`block=false`) 또는 결과 대기(`block=true`). 최대 10분까지 커스텀 타임아웃을 지원합니다.
|
||||
- **background_cancel**: task ID로 실행 중인 백그라운드 작업을 취소합니다.
|
||||
|
||||
주요 기능:
|
||||
- **비동기 실행**: 복잡한 분석이나 연구 작업을 백그라운드에서 처리하면서 다른 작업 계속 가능
|
||||
- **자동 알림**: 백그라운드 작업 완료 시 메인 세션에 자동 알림
|
||||
- **상태 추적**: 도구 호출 횟수, 마지막 사용 도구 등 실시간 진행 상황 모니터링
|
||||
- **세션 격리**: 각 작업은 독립된 세션에서 실행
|
||||
|
||||
사용 예시:
|
||||
```
|
||||
1. 시작: background_task → task_id="bg_abc123" 반환
|
||||
2. 다른 작업 계속 진행
|
||||
3. 시스템 알림: "Task bg_abc123 completed"
|
||||
4. 결과 조회: background_output(task_id="bg_abc123") → 전체 결과 획득
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
- **Todo Continuation Enforcer**: 에이전트가 멈추기 전 모든 TODO 항목을 완료하도록 강제합니다. LLM의 고질적인 "중도 포기" 문제를 방지합니다.
|
||||
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
|
||||
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
|
||||
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
|
||||
- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다:
|
||||
- **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입
|
||||
- **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가
|
||||
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
|
||||
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
|
||||
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
||||
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # 프로젝트 전체 컨텍스트
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src 전용 컨텍스트
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # 컴포넌트 전용 컨텍스트
|
||||
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
|
||||
```
|
||||
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
|
||||
- **Directory README.md Injector**: 파일을 읽을 때 `README.md` 내용을 자동으로 주입합니다. AGENTS.md Injector와 동일하게 동작하며, 파일 디렉토리부터 프로젝트 루트까지 탐색합니다. LLM 에이전트에게 프로젝트 문서 컨텍스트를 제공합니다. 각 디렉토리의 README는 세션당 한 번만 주입됩니다.
|
||||
- **Rules Injector**: 파일을 읽을 때 `.claude/rules/` 디렉토리의 규칙을 자동으로 주입합니다.
|
||||
- 파일 디렉토리부터 프로젝트 루트까지 상향 탐색하며, `~/.claude/rules/` (사용자) 경로도 포함합니다.
|
||||
- `.md` 및 `.mdc` 파일을 지원합니다.
|
||||
- Frontmatter의 `globs` 필드(glob 패턴)를 기반으로 매칭합니다.
|
||||
- 항상 적용되어야 하는 규칙을 위한 `alwaysApply: true` 옵션을 지원합니다.
|
||||
- 규칙 파일 구조 예시:
|
||||
```markdown
|
||||
---
|
||||
globs: ["*.ts", "src/**/*.js"]
|
||||
description: "TypeScript/JavaScript coding rules"
|
||||
---
|
||||
- Use PascalCase for interface names
|
||||
- Use camelCase for function names
|
||||
```
|
||||
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
|
||||
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
|
||||
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
|
||||
- **Grep Output Truncator**: Grep 검색 결과가 너무 길어 컨텍스트를 장악해버리는 것을 방지하기 위해, 과도한 출력을 자동으로 자릅니다.
|
||||
|
||||
### Claude Code 호환성
|
||||
|
||||
Oh My OpenCode는 Claude Code 설정과 완벽하게 호환됩니다. Claude Code를 사용하셨다면, 기존 설정을 그대로 사용할 수 있습니다.
|
||||
@@ -345,6 +441,18 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
Google Gemini 모델을 위한 내장 Antigravity OAuth를 활성화합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"google_auth": true
|
||||
}
|
||||
```
|
||||
|
||||
활성화하면 `opencode auth login` 실행 시 Google 프로바이더에서 "OAuth with Google (Antigravity)" 로그인 옵션이 표시됩니다.
|
||||
|
||||
### Agents
|
||||
|
||||
내장 에이전트 설정을 오버라이드할 수 있습니다:
|
||||
|
||||
178
README.md
178
README.md
@@ -8,13 +8,14 @@ English | [한국어](README.ko.md)
|
||||
- [For LLM Agents](#for-llm-agents)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [Features](#features)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
- [Tools](#tools)
|
||||
- [Built-in LSP Tools](#built-in-lsp-tools)
|
||||
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
|
||||
- [Grep](#grep)
|
||||
- [Built-in MCPs](#built-in-mcps)
|
||||
- [Background Task](#background-task)
|
||||
- [Hooks](#hooks)
|
||||
- [Claude Code Compatibility](#claude-code-compatibility)
|
||||
- [Other Features](#other-features)
|
||||
- [Configuration](#configuration)
|
||||
@@ -40,8 +41,9 @@ Even if you don't, invest a little time. Boost your skills and productivity by m
|
||||
- **Model Setup Required**
|
||||
- This plugin utilizes models from [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, and Anthropic.
|
||||
- For Anthropic models, use [OpenCode's built-in Claude Code Max Plan login](https://opencode.ai/docs/providers/#anthropic).
|
||||
- For OpenAI models, I recommend the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
|
||||
- For Gemini and GPT, use [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
|
||||
- For OpenAI models (ChatGPT Plus/Pro), install the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
|
||||
- For Google Gemini models, enable `google_auth: true` in `oh-my-opencode.json` for **built-in Antigravity OAuth**.
|
||||
- For other providers, [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) remain available as alternatives.
|
||||
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
|
||||
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It just installs.**
|
||||
- If you are an LLM Agent, install the required plugins and dependencies, then run `opencode auth login` via tmux to assist the user with configuration.
|
||||
@@ -107,11 +109,63 @@ cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugi
|
||||
|
||||
### Step 4: Configure Authentication
|
||||
|
||||
Once `oh-my-opencode` is added to the plugin list, use `tmux` to proceed with the following:
|
||||
Once `oh-my-opencode` is added to the plugin list, use `tmux` to assist the user with authentication setup.
|
||||
|
||||
1. Open a shell using `tmux`.
|
||||
2. Run `opencode auth login` > Select Provider `Anthropic` > Select Login method `Claude Pro/Max` > Instruct the user to authenticate via the OAuth screen.
|
||||
3. Recommend the setup: [VibeProxy](https://github.com/automazeio/vibeproxy) for macOS users, or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) for others.
|
||||
#### 4.1 Anthropic (Claude)
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Select Provider: Anthropic
|
||||
# Select Login method: Claude Pro/Max
|
||||
# Complete OAuth flow in browser
|
||||
```
|
||||
|
||||
#### 4.2 Google Gemini (Built-in Antigravity OAuth)
|
||||
|
||||
First, enable Google auth in `~/.config/opencode/oh-my-opencode.json` (or `.opencode/oh-my-opencode.json` for project-level):
|
||||
|
||||
```json
|
||||
{
|
||||
"google_auth": true
|
||||
}
|
||||
```
|
||||
|
||||
Then authenticate:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Select Provider: Google
|
||||
# Select Login method: OAuth with Google (Antigravity)
|
||||
# Complete OAuth flow in browser
|
||||
```
|
||||
|
||||
#### 4.3 OpenAI (ChatGPT Plus/Pro)
|
||||
|
||||
First, add the opencode-openai-codex-auth plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.1.0"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: You must also configure the full model settings in your `opencode.json`. Copy the provider/models configuration from [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json). See the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth) for detailed setup instructions.
|
||||
|
||||
Then authenticate:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Select Provider: OpenAI
|
||||
# Select Login method: ChatGPT Plus/Pro (Codex Subscription)
|
||||
# Complete OAuth flow in browser
|
||||
```
|
||||
|
||||
#### 4.4 Alternative: Proxy-based Authentication
|
||||
|
||||
For users who prefer proxy-based authentication, [VibeProxy](https://github.com/automazeio/vibeproxy) (macOS) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) remain available as alternatives.
|
||||
|
||||
### ⚠️ Warning
|
||||
|
||||
@@ -141,34 +195,6 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
||||
|
||||
## Features
|
||||
|
||||
### Hooks
|
||||
|
||||
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
||||
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
|
||||
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
||||
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
|
||||
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
|
||||
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
|
||||
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
|
||||
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
|
||||
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
|
||||
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, collecting **all** `AGENTS.md` files along the path hierarchy. This enables nested, directory-specific instructions:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # Project-wide context
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src-specific context
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # Component-specific context
|
||||
│ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files
|
||||
```
|
||||
When reading `Button.tsx`, the hook injects contexts in order: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. Each directory's context is injected only once per session. Inspired by Claude Code's CLAUDE.md feature.
|
||||
- **Directory README.md Injector**: Automatically injects `README.md` contents when reading files. Works identically to the AGENTS.md Injector, searching upward from the file's directory to project root. Provides project documentation context to the LLM agent. Each directory's README is injected only once per session.
|
||||
- **Think Mode**: Automatic extended thinking detection and mode switching. Detects when user requests deep thinking (e.g., "think deeply", "ultrathink") and dynamically adjusts model settings for enhanced reasoning.
|
||||
- **Anthropic Auto Compact**: Automatically compacts conversation history when approaching context limits for Anthropic models.
|
||||
- **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully.
|
||||
- **Grep Output Truncator**: Prevents grep output from overwhelming the context by truncating excessively long results.
|
||||
|
||||
### Agents
|
||||
- **oracle** (`openai/gpt-5.2`): The architect. Expert in code reviews and strategy. Uses GPT-5.2 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
|
||||
- **librarian** (`anthropic/claude-haiku-4-5`): Multi-repo analysis, documentation lookup, and implementation examples. Haiku is chosen for its speed, competence, excellent tool usage, and cost-efficiency. Inspired by AmpCode.
|
||||
@@ -186,6 +212,12 @@ Each agent is automatically invoked by the main agent, but you can also explicit
|
||||
|
||||
Agent models, prompts, and permissions can be customized via `oh-my-opencode.json`. See [Configuration](#configuration) for details.
|
||||
|
||||
#### Subagent Orchestration (omo_task)
|
||||
|
||||
The `omo_task` tool allows agents (like `oracle`, `frontend-ui-ux-engineer`) to spawn `explore` or `librarian` as subagents to delegate specific tasks. This enables powerful workflows where an agent can "ask" another specialized agent to gather information before proceeding.
|
||||
|
||||
> **Note**: To prevent infinite recursion, `explore` and `librarian` agents cannot use the `omo_task` tool themselves.
|
||||
|
||||
### Tools
|
||||
|
||||
#### Built-in LSP Tools
|
||||
@@ -236,6 +268,70 @@ Don't need these? Disable them via `oh-my-opencode.json`:
|
||||
}
|
||||
```
|
||||
|
||||
#### Background Task
|
||||
|
||||
Run long-running or complex tasks in the background without blocking your main session. The system automatically notifies you when tasks complete.
|
||||
|
||||
- **background_task**: Launch a background agent task. Specify description, prompt, and agent type. Returns immediately with a task ID.
|
||||
- **background_output**: Check task progress (`block=false`) or wait for results (`block=true`). Supports custom timeout up to 10 minutes.
|
||||
- **background_cancel**: Cancel a running background task by task ID.
|
||||
|
||||
Key capabilities:
|
||||
- **Async Execution**: Offload complex analysis or research while you continue working
|
||||
- **Auto Notification**: System notifies the main session when background tasks complete
|
||||
- **Status Tracking**: Real-time progress with tool call counts and last tool used
|
||||
- **Session Isolation**: Each task runs in an independent session
|
||||
|
||||
Example workflow:
|
||||
```
|
||||
1. Launch: background_task → returns task_id="bg_abc123"
|
||||
2. Continue working on other tasks
|
||||
3. System notification: "Task bg_abc123 completed"
|
||||
4. Retrieve: background_output(task_id="bg_abc123") → get full results
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
||||
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
|
||||
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
||||
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
|
||||
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
|
||||
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
|
||||
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
|
||||
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
|
||||
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
|
||||
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, collecting **all** `AGENTS.md` files along the path hierarchy. This enables nested, directory-specific instructions:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # Project-wide context
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src-specific context
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # Component-specific context
|
||||
│ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files
|
||||
```
|
||||
When reading `Button.tsx`, the hook injects contexts in order: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. Each directory's context is injected only once per session. Inspired by Claude Code's CLAUDE.md feature.
|
||||
- **Directory README.md Injector**: Automatically injects `README.md` contents when reading files. Works identically to the AGENTS.md Injector, searching upward from the file's directory to project root. Provides project documentation context to the LLM agent. Each directory's README is injected only once per session.
|
||||
- **Rules Injector**: Automatically injects rules from `.claude/rules/` directory when reading files.
|
||||
- Searches upward from the file's directory to project root, plus `~/.claude/rules/` (user).
|
||||
- Supports `.md` and `.mdc` files.
|
||||
- Frontmatter-based matching with `globs` field (glob patterns).
|
||||
- `alwaysApply: true` option for rules that should always apply.
|
||||
- Example rule file structure:
|
||||
```markdown
|
||||
---
|
||||
globs: ["*.ts", "src/**/*.js"]
|
||||
description: "TypeScript/JavaScript coding rules"
|
||||
---
|
||||
- Use PascalCase for interface names
|
||||
- Use camelCase for function names
|
||||
```
|
||||
- **Think Mode**: Automatic extended thinking detection and mode switching. Detects when user requests deep thinking (e.g., "think deeply", "ultrathink") and dynamically adjusts model settings for enhanced reasoning.
|
||||
- **Anthropic Auto Compact**: Automatically compacts conversation history when approaching context limits for Anthropic models.
|
||||
- **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully.
|
||||
- **Grep Output Truncator**: Prevents grep output from overwhelming the context by truncating excessively long results.
|
||||
|
||||
### Claude Code Compatibility
|
||||
|
||||
Oh My OpenCode provides seamless Claude Code configuration compatibility. If you've been using Claude Code, your existing setup works out of the box.
|
||||
@@ -343,6 +439,18 @@ Schema autocomplete is supported:
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
Enable built-in Antigravity OAuth for Google Gemini models:
|
||||
|
||||
```json
|
||||
{
|
||||
"google_auth": true
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, `opencode auth login` will show "OAuth with Google (Antigravity)" as a login option for the Google provider.
|
||||
|
||||
### Agents
|
||||
|
||||
Override built-in agent settings:
|
||||
|
||||
1037
ai-todolist.md
Normal file
1037
ai-todolist.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -155,6 +155,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"google_auth": {
|
||||
"type": "boolean",
|
||||
"description": "Enable built-in Antigravity OAuth for Google Gemini models. When true, adds 'OAuth with Google (Antigravity)' login option.",
|
||||
"default": false
|
||||
},
|
||||
"lsp": {
|
||||
"type": "object",
|
||||
"description": "Additional LSP server configurations specific to Oh My OpenCode.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Command and arguments to start the LSP server"
|
||||
},
|
||||
"extensions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "File extensions this server handles (e.g., [\".ts\", \".tsx\"])"
|
||||
},
|
||||
"priority": {
|
||||
"type": "number",
|
||||
"description": "Server priority (higher = preferred)"
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Environment variables for the LSP server"
|
||||
},
|
||||
"initialization": {
|
||||
"type": "object",
|
||||
"description": "Custom initialization options"
|
||||
},
|
||||
"disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Disable this LSP server"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"claude_code": {
|
||||
"type": "object",
|
||||
"description": "Toggle Claude Code compatibility features on/off. All default to true (enabled).",
|
||||
|
||||
46
bun.lock
46
bun.lock
@@ -7,12 +7,16 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.4",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"@code-yeongyu/comment-checker": "^0.5.0",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.150",
|
||||
"hono": "^4.10.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -64,11 +68,23 @@
|
||||
|
||||
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
|
||||
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.4", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-vsbdLMQYJJNDV/baTDnNqqg/MZwA+9nz7TE6Mybj8zjZVTCn4ZivH4hAdD5p4fLxhGZEJ5x1UDmXA6pAGA7lHA=="],
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.5.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-rKD2qQnTVUacsVQtpu3I5Sxi09X/XpOwS9fcmbUv1yfUL6llraaPuLmmxMBMRcmm7Zu31yEPVKCeUkVODfRL1g=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
|
||||
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.128", "", {}, "sha512-Kow3Ivg8bR8dNRp8C0LwF9e8+woIrwFgw3ZALycwCfqS/UujDkJiBeYHdr1l/07GSHP9sZPmvJ6POuvfZ923EA=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.150", "", { "dependencies": { "@opencode-ai/sdk": "1.0.150", "zod": "4.1.8" } }, "sha512-XmY3yydk120GBv2KeLxSZlElFx4Zx9TYLa3bS9X1TxXot42UeoMLEi3Xa46yboYnWwp4bC9Fu+Gd1E7hypG8Jw=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.150", "", {}, "sha512-Nz9Di8UD/GK01w3N+jpiGNB733pYkNY8RNLbuE/HUxEGSP5apbXBY0IdhbW7859sXZZK38kF1NqOx4UxwBf4Bw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
|
||||
|
||||
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
|
||||
|
||||
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="],
|
||||
|
||||
@@ -92,16 +108,30 @@
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-u5eZHKq6TPJSE282KyBOicGQ2trkFml0RoUfqkPOJVo7TXGrsGYYzdsugZRnVQY/WEmnxGtBy4T3PAaPqgQViA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
|
||||
|
||||
"arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="],
|
||||
|
||||
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
"bun": ["bun@1.3.3", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.3", "@oven/bun-darwin-x64": "1.3.3", "@oven/bun-darwin-x64-baseline": "1.3.3", "@oven/bun-linux-aarch64": "1.3.3", "@oven/bun-linux-aarch64-musl": "1.3.3", "@oven/bun-linux-x64": "1.3.3", "@oven/bun-linux-x64-baseline": "1.3.3", "@oven/bun-linux-x64-musl": "1.3.3", "@oven/bun-linux-x64-musl-baseline": "1.3.3", "@oven/bun-windows-x64": "1.3.3", "@oven/bun-windows-x64-baseline": "1.3.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-2hJ4ocTZ634/Ptph4lysvO+LbbRZq8fzRvMwX0/CqaLBxrF2UB5D1LdMB8qGcdtCer4/VR9Bx5ORub0yn+yzmw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
|
||||
|
||||
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"oh-my-opencode": ["oh-my-opencode@0.1.30", "", { "dependencies": { "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@code-yeongyu/comment-checker": "^0.4.1", "@opencode-ai/plugin": "^1.0.7", "xdg-basedir": "^5.1.0", "zod": "^4.1.8" }, "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-pXGGgL/7Jcz3yuGJJTI72BKern2egwfRz2LQZTBq+jl+pNCybOvGvXtFmR+WGlF8O3ZjL1wIHypBbIVuHOBzxg=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
@@ -110,6 +140,12 @@
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
|
||||
"oh-my-opencode/@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
|
||||
|
||||
"oh-my-opencode/@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
|
||||
|
||||
"oh-my-opencode/@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.128", "", {}, "sha512-Kow3Ivg8bR8dNRp8C0LwF9e8+woIrwFgw3ZALycwCfqS/UujDkJiBeYHdr1l/07GSHP9sZPmvJ6POuvfZ923EA=="],
|
||||
}
|
||||
}
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.3.4",
|
||||
"version": "0.4.3",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -13,10 +13,14 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./google-auth": {
|
||||
"types": "./dist/google-auth.d.ts",
|
||||
"import": "./dist/google-auth.js"
|
||||
},
|
||||
"./schema.json": "./dist/oh-my-opencode.schema.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema",
|
||||
"build": "bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema",
|
||||
"build:schema": "bun run script/build-schema.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "bun run clean && bun run build",
|
||||
@@ -45,11 +49,15 @@
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.5.0",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"@opencode-ai/plugin": "^1.0.150",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"hono": "^4.10.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { librarianAgent } from "./librarian"
|
||||
import { exploreAgent } from "./explore"
|
||||
import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
|
||||
import { documentWriterAgent } from "./document-writer"
|
||||
import { deepMerge } from "../shared"
|
||||
|
||||
const allBuiltinAgents: Record<AgentName, AgentConfig> = {
|
||||
oracle: oracleAgent,
|
||||
@@ -18,16 +19,7 @@ function mergeAgentConfig(
|
||||
base: AgentConfig,
|
||||
override: AgentOverrideConfig
|
||||
): AgentConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
tools: override.tools !== undefined
|
||||
? { ...(base.tools ?? {}), ...override.tools }
|
||||
: base.tools,
|
||||
permission: override.permission !== undefined
|
||||
? { ...(base.permission ?? {}), ...override.permission }
|
||||
: base.permission,
|
||||
}
|
||||
return deepMerge(base, override as Partial<AgentConfig>)
|
||||
}
|
||||
|
||||
export function createBuiltinAgents(
|
||||
|
||||
74
src/auth/antigravity/constants.ts
Normal file
74
src/auth/antigravity/constants.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Antigravity OAuth configuration constants.
|
||||
* Values sourced from cliproxyapi/sdk/auth/antigravity.go
|
||||
*
|
||||
* ## Logging Policy
|
||||
*
|
||||
* All console logging in antigravity modules follows a consistent policy:
|
||||
*
|
||||
* - **Debug logs**: Guard with `if (process.env.ANTIGRAVITY_DEBUG === "1")`
|
||||
* - Includes: info messages, warnings, non-fatal errors
|
||||
* - Enable debugging: `ANTIGRAVITY_DEBUG=1 opencode`
|
||||
*
|
||||
* - **Fatal errors**: None currently. All errors are handled by returning
|
||||
* appropriate error responses to OpenCode's auth system.
|
||||
*
|
||||
* This policy ensures production silence while enabling verbose debugging
|
||||
* when needed for troubleshooting OAuth flows.
|
||||
*/
|
||||
|
||||
// OAuth 2.0 Client Credentials
|
||||
export const ANTIGRAVITY_CLIENT_ID =
|
||||
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
export const ANTIGRAVITY_CLIENT_SECRET = "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"
|
||||
|
||||
// OAuth Callback
|
||||
export const ANTIGRAVITY_CALLBACK_PORT = 51121
|
||||
export const ANTIGRAVITY_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_CALLBACK_PORT}/oauth-callback`
|
||||
|
||||
// OAuth Scopes
|
||||
export const ANTIGRAVITY_SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
] as const
|
||||
|
||||
// API Endpoint Fallbacks (order: daily → autopush → prod)
|
||||
export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com", // dev
|
||||
"https://autopush-cloudcode-pa.sandbox.googleapis.com", // staging
|
||||
"https://cloudcode-pa.googleapis.com", // prod
|
||||
] as const
|
||||
|
||||
// API Version
|
||||
export const ANTIGRAVITY_API_VERSION = "v1internal"
|
||||
|
||||
// Request Headers
|
||||
export const ANTIGRAVITY_HEADERS = {
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
} as const
|
||||
|
||||
// Default Project ID (fallback when loadCodeAssist API fails)
|
||||
// From opencode-antigravity-auth reference implementation
|
||||
export const ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
|
||||
|
||||
|
||||
|
||||
// Google OAuth endpoints
|
||||
export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
export const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo"
|
||||
|
||||
// Token refresh buffer (refresh 60 seconds before expiry)
|
||||
export const ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS = 60_000
|
||||
|
||||
// Default thought signature to skip validation (CLIProxyAPI approach)
|
||||
export const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
||||
475
src/auth/antigravity/fetch.ts
Normal file
475
src/auth/antigravity/fetch.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Antigravity Fetch Interceptor
|
||||
*
|
||||
* Creates a custom fetch function that:
|
||||
* - Checks token expiration and auto-refreshes
|
||||
* - Rewrites URLs to Antigravity endpoints
|
||||
* - Applies request transformation (including tool normalization)
|
||||
* - Applies response transformation (including thinking extraction)
|
||||
* - Implements endpoint fallback (daily → autopush → prod)
|
||||
*
|
||||
* **Body Type Assumption:**
|
||||
* This interceptor assumes `init.body` is a JSON string (OpenAI format).
|
||||
* Non-string bodies (ReadableStream, Blob, FormData, URLSearchParams, etc.)
|
||||
* are passed through unchanged to the original fetch to avoid breaking
|
||||
* other requests that may not be OpenAI-format API calls.
|
||||
*
|
||||
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable.
|
||||
*/
|
||||
|
||||
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_DEFAULT_PROJECT_ID } from "./constants"
|
||||
import { fetchProjectContext, clearProjectContextCache } from "./project"
|
||||
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token"
|
||||
import { transformRequest } from "./request"
|
||||
import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
|
||||
import {
|
||||
transformResponse,
|
||||
transformStreamingResponse,
|
||||
isStreamingResponse,
|
||||
extractSignatureFromSsePayload,
|
||||
} from "./response"
|
||||
import { normalizeToolsForGemini, type OpenAITool } from "./tools"
|
||||
import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking"
|
||||
import {
|
||||
getThoughtSignature,
|
||||
setThoughtSignature,
|
||||
getOrCreateSessionId,
|
||||
} from "./thought-signature-store"
|
||||
import type { AntigravityTokens } from "./types"
|
||||
|
||||
/**
|
||||
* Auth interface matching OpenCode's auth system
|
||||
*/
|
||||
interface Auth {
|
||||
access?: string
|
||||
refresh?: string
|
||||
expires?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Client interface for auth operations
|
||||
*/
|
||||
interface AuthClient {
|
||||
set(providerId: string, auth: Auth): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logging helper
|
||||
* Only logs when ANTIGRAVITY_DEBUG=1
|
||||
*/
|
||||
function debugLog(message: string): void {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log(`[antigravity-fetch] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableError(status: number): boolean {
|
||||
if (status === 0) return true
|
||||
if (status === 429) return true
|
||||
if (status >= 500 && status < 600) return true
|
||||
return false
|
||||
}
|
||||
|
||||
async function isRetryableResponse(response: Response): Promise<boolean> {
|
||||
if (isRetryableError(response.status)) return true
|
||||
if (response.status === 403) {
|
||||
try {
|
||||
const text = await response.clone().text()
|
||||
if (text.includes("SUBSCRIPTION_REQUIRED") || text.includes("Gemini Code Assist license")) {
|
||||
debugLog(`[RETRY] 403 SUBSCRIPTION_REQUIRED detected, will retry with next endpoint`)
|
||||
return true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
interface AttemptFetchOptions {
|
||||
endpoint: string
|
||||
url: string
|
||||
init: RequestInit
|
||||
accessToken: string
|
||||
projectId: string
|
||||
sessionId: string
|
||||
modelName?: string
|
||||
thoughtSignature?: string
|
||||
}
|
||||
|
||||
async function attemptFetch(
|
||||
options: AttemptFetchOptions
|
||||
): Promise<Response | null | "pass-through"> {
|
||||
const { endpoint, url, init, accessToken, projectId, sessionId, modelName, thoughtSignature } =
|
||||
options
|
||||
debugLog(`Trying endpoint: ${endpoint}`)
|
||||
|
||||
try {
|
||||
const rawBody = init.body
|
||||
|
||||
if (rawBody !== undefined && typeof rawBody !== "string") {
|
||||
debugLog(`Non-string body detected (${typeof rawBody}), signaling pass-through`)
|
||||
return "pass-through"
|
||||
}
|
||||
|
||||
let parsedBody: Record<string, unknown> = {}
|
||||
if (rawBody) {
|
||||
try {
|
||||
parsedBody = JSON.parse(rawBody) as Record<string, unknown>
|
||||
} catch {
|
||||
parsedBody = {}
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(`[BODY] Keys: ${Object.keys(parsedBody).join(", ")}`)
|
||||
debugLog(`[BODY] Has contents: ${!!parsedBody.contents}, Has messages: ${!!parsedBody.messages}`)
|
||||
if (parsedBody.contents) {
|
||||
const contents = parsedBody.contents as Array<Record<string, unknown>>
|
||||
debugLog(`[BODY] contents length: ${contents.length}`)
|
||||
contents.forEach((c, i) => {
|
||||
debugLog(`[BODY] contents[${i}].role: ${c.role}, parts: ${JSON.stringify(c.parts).substring(0, 200)}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (parsedBody.tools && Array.isArray(parsedBody.tools)) {
|
||||
const normalizedTools = normalizeToolsForGemini(parsedBody.tools as OpenAITool[])
|
||||
if (normalizedTools) {
|
||||
parsedBody.tools = normalizedTools
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOpenAIMessages(parsedBody)) {
|
||||
debugLog(`[CONVERT] Converting OpenAI messages to Gemini contents`)
|
||||
parsedBody = convertRequestBody(parsedBody, thoughtSignature)
|
||||
debugLog(`[CONVERT] After conversion - Has contents: ${!!parsedBody.contents}`)
|
||||
}
|
||||
|
||||
const transformed = transformRequest({
|
||||
url,
|
||||
body: parsedBody,
|
||||
accessToken,
|
||||
projectId,
|
||||
sessionId,
|
||||
modelName,
|
||||
endpointOverride: endpoint,
|
||||
thoughtSignature,
|
||||
})
|
||||
|
||||
debugLog(`[REQ] streaming=${transformed.streaming}, url=${transformed.url}`)
|
||||
|
||||
const response = await fetch(transformed.url, {
|
||||
method: init.method || "POST",
|
||||
headers: transformed.headers,
|
||||
body: JSON.stringify(transformed.body),
|
||||
signal: init.signal,
|
||||
})
|
||||
|
||||
debugLog(
|
||||
`[RESP] status=${response.status} content-type=${response.headers.get("content-type") ?? ""} url=${response.url}`
|
||||
)
|
||||
|
||||
if (!response.ok && (await isRetryableResponse(response))) {
|
||||
debugLog(`Endpoint failed: ${endpoint} (status: ${response.status}), trying next`)
|
||||
return null
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
debugLog(
|
||||
`Endpoint failed: ${endpoint} (${error instanceof Error ? error.message : "Unknown error"}), trying next`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface GeminiResponsePart {
|
||||
thoughtSignature?: string
|
||||
thought_signature?: string
|
||||
functionCall?: Record<string, unknown>
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GeminiResponseCandidate {
|
||||
content?: {
|
||||
parts?: GeminiResponsePart[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GeminiResponseBody {
|
||||
candidates?: GeminiResponseCandidate[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function extractSignatureFromResponse(parsed: GeminiResponseBody): string | undefined {
|
||||
if (!parsed.candidates || !Array.isArray(parsed.candidates)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const candidate of parsed.candidates) {
|
||||
const parts = candidate.content?.parts
|
||||
if (!parts || !Array.isArray(parts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
const sig = part.thoughtSignature || part.thought_signature
|
||||
if (sig && typeof sig === "string") {
|
||||
return sig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function transformResponseWithThinking(
|
||||
response: Response,
|
||||
modelName: string,
|
||||
fetchInstanceId: string
|
||||
): Promise<Response> {
|
||||
const streaming = isStreamingResponse(response)
|
||||
|
||||
let result
|
||||
if (streaming) {
|
||||
result = await transformStreamingResponse(response)
|
||||
} else {
|
||||
result = await transformResponse(response)
|
||||
}
|
||||
|
||||
if (streaming) {
|
||||
return result.response
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await result.response.clone().text()
|
||||
debugLog(`[TSIG][RESP] Response text length: ${text.length}`)
|
||||
|
||||
const parsed = JSON.parse(text) as GeminiResponseBody
|
||||
debugLog(`[TSIG][RESP] Parsed keys: ${Object.keys(parsed).join(", ")}`)
|
||||
debugLog(`[TSIG][RESP] Has candidates: ${!!parsed.candidates}, count: ${parsed.candidates?.length ?? 0}`)
|
||||
|
||||
const signature = extractSignatureFromResponse(parsed)
|
||||
debugLog(`[TSIG][RESP] Signature extracted: ${signature ? signature.substring(0, 30) + "..." : "NONE"}`)
|
||||
if (signature) {
|
||||
setThoughtSignature(fetchInstanceId, signature)
|
||||
debugLog(`[TSIG][STORE] Stored signature for ${fetchInstanceId}`)
|
||||
} else {
|
||||
debugLog(`[TSIG][WARN] No signature found in response!`)
|
||||
}
|
||||
|
||||
if (shouldIncludeThinking(modelName)) {
|
||||
const thinkingResult = extractThinkingBlocks(parsed)
|
||||
if (thinkingResult.hasThinking) {
|
||||
const transformed = transformResponseThinking(parsed)
|
||||
return new Response(JSON.stringify(transformed), {
|
||||
status: result.response.status,
|
||||
statusText: result.response.statusText,
|
||||
headers: result.response.headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return result.response
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Antigravity fetch interceptor
|
||||
*
|
||||
* Factory function that creates a custom fetch function for Antigravity API.
|
||||
* Handles token management, request/response transformation, and endpoint fallback.
|
||||
*
|
||||
* @param getAuth - Async function to retrieve current auth state
|
||||
* @param client - Auth client for saving updated tokens
|
||||
* @param providerId - Provider identifier (e.g., "google")
|
||||
* @param clientId - Optional custom client ID for token refresh (defaults to ANTIGRAVITY_CLIENT_ID)
|
||||
* @param clientSecret - Optional custom client secret for token refresh (defaults to ANTIGRAVITY_CLIENT_SECRET)
|
||||
* @returns Custom fetch function compatible with standard fetch signature
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const customFetch = createAntigravityFetch(
|
||||
* () => auth(),
|
||||
* client,
|
||||
* "google",
|
||||
* "custom-client-id",
|
||||
* "custom-client-secret"
|
||||
* )
|
||||
*
|
||||
* // Use like standard fetch
|
||||
* const response = await customFetch("https://api.example.com/chat", {
|
||||
* method: "POST",
|
||||
* body: JSON.stringify({ messages: [...] })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createAntigravityFetch(
|
||||
getAuth: () => Promise<Auth>,
|
||||
client: AuthClient,
|
||||
providerId: string,
|
||||
clientId?: string,
|
||||
clientSecret?: string
|
||||
): (url: string, init?: RequestInit) => Promise<Response> {
|
||||
let cachedTokens: AntigravityTokens | null = null
|
||||
let cachedProjectId: string | null = null
|
||||
const fetchInstanceId = crypto.randomUUID()
|
||||
|
||||
return async (url: string, init: RequestInit = {}): Promise<Response> => {
|
||||
debugLog(`Intercepting request to: ${url}`)
|
||||
|
||||
// Get current auth state
|
||||
const auth = await getAuth()
|
||||
if (!auth.access || !auth.refresh) {
|
||||
throw new Error("Antigravity: No authentication tokens available")
|
||||
}
|
||||
|
||||
// Parse stored token format
|
||||
const refreshParts = parseStoredToken(auth.refresh)
|
||||
|
||||
// Build initial token state
|
||||
if (!cachedTokens) {
|
||||
cachedTokens = {
|
||||
type: "antigravity",
|
||||
access_token: auth.access,
|
||||
refresh_token: refreshParts.refreshToken,
|
||||
expires_in: auth.expires ? Math.floor((auth.expires - Date.now()) / 1000) : 3600,
|
||||
timestamp: auth.expires ? auth.expires - 3600 * 1000 : Date.now(),
|
||||
}
|
||||
} else {
|
||||
// Update with fresh values
|
||||
cachedTokens.access_token = auth.access
|
||||
cachedTokens.refresh_token = refreshParts.refreshToken
|
||||
}
|
||||
|
||||
// Check token expiration and refresh if needed
|
||||
if (isTokenExpired(cachedTokens)) {
|
||||
debugLog("Token expired, refreshing...")
|
||||
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret)
|
||||
|
||||
// Update cached tokens
|
||||
cachedTokens = {
|
||||
type: "antigravity",
|
||||
access_token: newTokens.access_token,
|
||||
refresh_token: newTokens.refresh_token,
|
||||
expires_in: newTokens.expires_in,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
// Clear project context cache on token refresh
|
||||
clearProjectContextCache()
|
||||
|
||||
// Format and save new tokens
|
||||
const formattedRefresh = formatTokenForStorage(
|
||||
newTokens.refresh_token,
|
||||
refreshParts.projectId || "",
|
||||
refreshParts.managedProjectId
|
||||
)
|
||||
|
||||
await client.set(providerId, {
|
||||
access: newTokens.access_token,
|
||||
refresh: formattedRefresh,
|
||||
expires: Date.now() + newTokens.expires_in * 1000,
|
||||
})
|
||||
|
||||
debugLog("Token refreshed successfully")
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch project ID via loadCodeAssist (CLIProxyAPI approach)
|
||||
if (!cachedProjectId) {
|
||||
const projectContext = await fetchProjectContext(cachedTokens.access_token)
|
||||
cachedProjectId = projectContext.cloudaicompanionProject || ""
|
||||
debugLog(`[PROJECT] Fetched project ID: "${cachedProjectId}"`)
|
||||
}
|
||||
|
||||
const projectId = cachedProjectId
|
||||
debugLog(`[PROJECT] Using project ID: "${projectId}"`)
|
||||
|
||||
// Extract model name from request body
|
||||
let modelName: string | undefined
|
||||
if (init.body) {
|
||||
try {
|
||||
const body =
|
||||
typeof init.body === "string"
|
||||
? (JSON.parse(init.body) as Record<string, unknown>)
|
||||
: (init.body as unknown as Record<string, unknown>)
|
||||
if (typeof body.model === "string") {
|
||||
modelName = body.model
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
const maxEndpoints = Math.min(ANTIGRAVITY_ENDPOINT_FALLBACKS.length, 3)
|
||||
const sessionId = getOrCreateSessionId(fetchInstanceId)
|
||||
const thoughtSignature = getThoughtSignature(fetchInstanceId)
|
||||
debugLog(`[TSIG][GET] sessionId=${sessionId}, signature=${thoughtSignature ? thoughtSignature.substring(0, 20) + "..." : "none"}`)
|
||||
|
||||
for (let i = 0; i < maxEndpoints; i++) {
|
||||
const endpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i]
|
||||
|
||||
const response = await attemptFetch({
|
||||
endpoint,
|
||||
url,
|
||||
init,
|
||||
accessToken: cachedTokens.access_token,
|
||||
projectId,
|
||||
sessionId,
|
||||
modelName,
|
||||
thoughtSignature,
|
||||
})
|
||||
|
||||
if (response === "pass-through") {
|
||||
debugLog("Non-string body detected, passing through with auth headers")
|
||||
const headersWithAuth = {
|
||||
...init.headers,
|
||||
Authorization: `Bearer ${cachedTokens.access_token}`,
|
||||
}
|
||||
return fetch(url, { ...init, headers: headersWithAuth })
|
||||
}
|
||||
|
||||
if (response) {
|
||||
debugLog(`Success with endpoint: ${endpoint}`)
|
||||
const transformedResponse = await transformResponseWithThinking(
|
||||
response,
|
||||
modelName || "",
|
||||
fetchInstanceId
|
||||
)
|
||||
return transformedResponse
|
||||
}
|
||||
}
|
||||
|
||||
// All endpoints failed
|
||||
const errorMessage = `All Antigravity endpoints failed after ${maxEndpoints} attempts`
|
||||
debugLog(errorMessage)
|
||||
|
||||
// Return error response
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: "endpoint_failure",
|
||||
code: "all_endpoints_failed",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type export for createAntigravityFetch return type
|
||||
*/
|
||||
export type AntigravityFetch = (url: string, init?: RequestInit) => Promise<Response>
|
||||
13
src/auth/antigravity/index.ts
Normal file
13
src/auth/antigravity/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
export * from "./oauth"
|
||||
export * from "./token"
|
||||
export * from "./project"
|
||||
export * from "./request"
|
||||
export * from "./response"
|
||||
export * from "./tools"
|
||||
export * from "./thinking"
|
||||
export * from "./thought-signature-store"
|
||||
export * from "./message-converter"
|
||||
export * from "./fetch"
|
||||
export * from "./plugin"
|
||||
206
src/auth/antigravity/message-converter.ts
Normal file
206
src/auth/antigravity/message-converter.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* OpenAI → Gemini message format converter
|
||||
*
|
||||
* Converts OpenAI-style messages to Gemini contents format,
|
||||
* injecting thoughtSignature into functionCall parts.
|
||||
*/
|
||||
|
||||
import { SKIP_THOUGHT_SIGNATURE_VALIDATOR } from "./constants"
|
||||
|
||||
function debugLog(message: string): void {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log(`[antigravity-converter] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenAIMessage {
|
||||
role: "system" | "user" | "assistant" | "tool"
|
||||
content?: string | OpenAIContentPart[]
|
||||
tool_calls?: OpenAIToolCall[]
|
||||
tool_call_id?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface OpenAIContentPart {
|
||||
type: string
|
||||
text?: string
|
||||
image_url?: { url: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface OpenAIToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
interface GeminiPart {
|
||||
text?: string
|
||||
functionCall?: {
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
functionResponse?: {
|
||||
name: string
|
||||
response: Record<string, unknown>
|
||||
}
|
||||
inlineData?: {
|
||||
mimeType: string
|
||||
data: string
|
||||
}
|
||||
thought_signature?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GeminiContent {
|
||||
role: "user" | "model"
|
||||
parts: GeminiPart[]
|
||||
}
|
||||
|
||||
export function convertOpenAIToGemini(
|
||||
messages: OpenAIMessage[],
|
||||
thoughtSignature?: string
|
||||
): GeminiContent[] {
|
||||
debugLog(`Converting ${messages.length} messages, signature: ${thoughtSignature ? "present" : "none"}`)
|
||||
|
||||
const contents: GeminiContent[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "system") {
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [{ text: typeof msg.content === "string" ? msg.content : "" }],
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
const parts = convertContentToParts(msg.content)
|
||||
contents.push({ role: "user", parts })
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
const parts: GeminiPart[] = []
|
||||
|
||||
if (msg.content) {
|
||||
parts.push(...convertContentToParts(msg.content))
|
||||
}
|
||||
|
||||
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
for (const toolCall of msg.tool_calls) {
|
||||
let args: Record<string, unknown> = {}
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments)
|
||||
} catch {
|
||||
args = {}
|
||||
}
|
||||
|
||||
const part: GeminiPart = {
|
||||
functionCall: {
|
||||
name: toolCall.function.name,
|
||||
args,
|
||||
},
|
||||
}
|
||||
|
||||
// Always inject signature: use provided or default to skip validator (CLIProxyAPI approach)
|
||||
part.thoughtSignature = thoughtSignature || SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
||||
debugLog(`Injected signature into functionCall: ${toolCall.function.name} (${thoughtSignature ? "provided" : "default"})`)
|
||||
|
||||
parts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
contents.push({ role: "model", parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
let response: Record<string, unknown> = {}
|
||||
try {
|
||||
response = typeof msg.content === "string"
|
||||
? JSON.parse(msg.content)
|
||||
: { result: msg.content }
|
||||
} catch {
|
||||
response = { result: msg.content }
|
||||
}
|
||||
|
||||
const toolName = msg.name || "unknown"
|
||||
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
response,
|
||||
},
|
||||
}],
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(`Converted to ${contents.length} content blocks`)
|
||||
return contents
|
||||
}
|
||||
|
||||
function convertContentToParts(content: string | OpenAIContentPart[] | undefined): GeminiPart[] {
|
||||
if (!content) {
|
||||
return [{ text: "" }]
|
||||
}
|
||||
|
||||
if (typeof content === "string") {
|
||||
return [{ text: content }]
|
||||
}
|
||||
|
||||
const parts: GeminiPart[] = []
|
||||
for (const part of content) {
|
||||
if (part.type === "text" && part.text) {
|
||||
parts.push({ text: part.text })
|
||||
} else if (part.type === "image_url" && part.image_url?.url) {
|
||||
const url = part.image_url.url
|
||||
if (url.startsWith("data:")) {
|
||||
const match = url.match(/^data:([^;]+);base64,(.+)$/)
|
||||
if (match) {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: match[1],
|
||||
data: match[2],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [{ text: "" }]
|
||||
}
|
||||
|
||||
export function hasOpenAIMessages(body: Record<string, unknown>): boolean {
|
||||
return Array.isArray(body.messages) && body.messages.length > 0
|
||||
}
|
||||
|
||||
export function convertRequestBody(
|
||||
body: Record<string, unknown>,
|
||||
thoughtSignature?: string
|
||||
): Record<string, unknown> {
|
||||
if (!hasOpenAIMessages(body)) {
|
||||
debugLog("No messages array found, returning body as-is")
|
||||
return body
|
||||
}
|
||||
|
||||
const messages = body.messages as OpenAIMessage[]
|
||||
const contents = convertOpenAIToGemini(messages, thoughtSignature)
|
||||
|
||||
const converted = { ...body }
|
||||
delete converted.messages
|
||||
converted.contents = contents
|
||||
|
||||
debugLog(`Converted body: messages → contents (${contents.length} blocks)`)
|
||||
return converted
|
||||
}
|
||||
361
src/auth/antigravity/oauth.ts
Normal file
361
src/auth/antigravity/oauth.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Antigravity OAuth 2.0 flow implementation with PKCE.
|
||||
* Handles Google OAuth for Antigravity authentication.
|
||||
*/
|
||||
import { generatePKCE } from "@openauthjs/openauth/pkce"
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_CLIENT_ID,
|
||||
ANTIGRAVITY_CLIENT_SECRET,
|
||||
ANTIGRAVITY_REDIRECT_URI,
|
||||
ANTIGRAVITY_SCOPES,
|
||||
ANTIGRAVITY_CALLBACK_PORT,
|
||||
GOOGLE_AUTH_URL,
|
||||
GOOGLE_TOKEN_URL,
|
||||
GOOGLE_USERINFO_URL,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AntigravityTokenExchangeResult,
|
||||
AntigravityUserInfo,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* PKCE pair containing verifier and challenge.
|
||||
*/
|
||||
export interface PKCEPair {
|
||||
/** PKCE verifier - used during token exchange */
|
||||
verifier: string
|
||||
/** PKCE challenge - sent in auth URL */
|
||||
challenge: string
|
||||
/** Challenge method - always "S256" */
|
||||
method: string
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth state encoded in the auth URL.
|
||||
* Contains the PKCE verifier for later retrieval.
|
||||
*/
|
||||
export interface OAuthState {
|
||||
/** PKCE verifier */
|
||||
verifier: string
|
||||
/** Optional project ID */
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from building an OAuth authorization URL.
|
||||
*/
|
||||
export interface AuthorizationResult {
|
||||
/** Full OAuth URL to open in browser */
|
||||
url: string
|
||||
/** PKCE verifier to use during code exchange */
|
||||
verifier: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from the OAuth callback server.
|
||||
*/
|
||||
export interface CallbackResult {
|
||||
/** Authorization code from Google */
|
||||
code: string
|
||||
/** State parameter from callback */
|
||||
state: string
|
||||
/** Error message if any */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE verifier and challenge pair.
|
||||
* Uses @openauthjs/openauth for cryptographically secure generation.
|
||||
*
|
||||
* @returns PKCE pair with verifier, challenge, and method
|
||||
*/
|
||||
export async function generatePKCEPair(): Promise<PKCEPair> {
|
||||
const pkce = await generatePKCE()
|
||||
return {
|
||||
verifier: pkce.verifier,
|
||||
challenge: pkce.challenge,
|
||||
method: pkce.method,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode OAuth state into a URL-safe base64 string.
|
||||
*
|
||||
* @param state - OAuth state object
|
||||
* @returns Base64URL encoded state
|
||||
*/
|
||||
function encodeState(state: OAuthState): string {
|
||||
const json = JSON.stringify(state)
|
||||
return Buffer.from(json, "utf8").toString("base64url")
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode OAuth state from a base64 string.
|
||||
*
|
||||
* @param encoded - Base64URL or Base64 encoded state
|
||||
* @returns Decoded OAuth state
|
||||
*/
|
||||
export function decodeState(encoded: string): OAuthState {
|
||||
// Handle both base64url and standard base64
|
||||
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padded = normalized.padEnd(
|
||||
normalized.length + ((4 - (normalized.length % 4)) % 4),
|
||||
"="
|
||||
)
|
||||
const json = Buffer.from(padded, "base64").toString("utf8")
|
||||
const parsed = JSON.parse(json)
|
||||
|
||||
if (typeof parsed.verifier !== "string") {
|
||||
throw new Error("Missing PKCE verifier in state")
|
||||
}
|
||||
|
||||
return {
|
||||
verifier: parsed.verifier,
|
||||
projectId:
|
||||
typeof parsed.projectId === "string" ? parsed.projectId : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildAuthURL(
|
||||
projectId?: string,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
port: number = ANTIGRAVITY_CALLBACK_PORT
|
||||
): Promise<AuthorizationResult> {
|
||||
const pkce = await generatePKCEPair()
|
||||
|
||||
const state: OAuthState = {
|
||||
verifier: pkce.verifier,
|
||||
projectId,
|
||||
}
|
||||
|
||||
const redirectUri = `http://localhost:${port}/oauth-callback`
|
||||
|
||||
const url = new URL(GOOGLE_AUTH_URL)
|
||||
url.searchParams.set("client_id", clientId)
|
||||
url.searchParams.set("redirect_uri", redirectUri)
|
||||
url.searchParams.set("response_type", "code")
|
||||
url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" "))
|
||||
url.searchParams.set("state", encodeState(state))
|
||||
url.searchParams.set("code_challenge", pkce.challenge)
|
||||
url.searchParams.set("code_challenge_method", "S256")
|
||||
url.searchParams.set("access_type", "offline")
|
||||
url.searchParams.set("prompt", "consent")
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
verifier: pkce.verifier,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens.
|
||||
*
|
||||
* @param code - Authorization code from OAuth callback
|
||||
* @param verifier - PKCE verifier from initial auth request
|
||||
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
|
||||
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
|
||||
* @returns Token exchange result with access and refresh tokens
|
||||
*/
|
||||
export async function exchangeCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET,
|
||||
port: number = ANTIGRAVITY_CALLBACK_PORT
|
||||
): Promise<AntigravityTokenExchangeResult> {
|
||||
const redirectUri = `http://localhost:${port}/oauth-callback`
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
})
|
||||
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Token exchange failed: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_in: data.expires_in,
|
||||
token_type: data.token_type,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user info from Google's userinfo API.
|
||||
*
|
||||
* @param accessToken - Valid access token
|
||||
* @returns User info containing email
|
||||
*/
|
||||
export async function fetchUserInfo(
|
||||
accessToken: string
|
||||
): Promise<AntigravityUserInfo> {
|
||||
const response = await fetch(`${GOOGLE_USERINFO_URL}?alt=json`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
email?: string
|
||||
name?: string
|
||||
picture?: string
|
||||
}
|
||||
|
||||
return {
|
||||
email: data.email || "",
|
||||
name: data.name,
|
||||
picture: data.picture,
|
||||
}
|
||||
}
|
||||
|
||||
export interface CallbackServerHandle {
|
||||
port: number
|
||||
waitForCallback: () => Promise<CallbackResult>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export function startCallbackServer(
|
||||
timeoutMs: number = 5 * 60 * 1000
|
||||
): CallbackServerHandle {
|
||||
let server: ReturnType<typeof Bun.serve> | null = null
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let resolveCallback: ((result: CallbackResult) => void) | null = null
|
||||
let rejectCallback: ((error: Error) => void) | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
if (server) {
|
||||
server.stop()
|
||||
server = null
|
||||
}
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request: Request): Response {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname === "/oauth-callback") {
|
||||
const code = url.searchParams.get("code") || ""
|
||||
const state = url.searchParams.get("state") || ""
|
||||
const error = url.searchParams.get("error") || undefined
|
||||
|
||||
let responseBody: string
|
||||
if (code && !error) {
|
||||
responseBody =
|
||||
"<html><body><h1>Login successful</h1><p>You can close this window.</p></body></html>"
|
||||
} else {
|
||||
responseBody =
|
||||
"<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>"
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
cleanup()
|
||||
if (resolveCallback) {
|
||||
resolveCallback({ code, state, error })
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return new Response(responseBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
const actualPort = server.port as number
|
||||
|
||||
const waitForCallback = (): Promise<CallbackResult> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolveCallback = resolve
|
||||
rejectCallback = reject
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error("OAuth callback timeout"))
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
port: actualPort,
|
||||
waitForCallback,
|
||||
close: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
export async function performOAuthFlow(
|
||||
projectId?: string,
|
||||
openBrowser?: (url: string) => Promise<void>,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
|
||||
): Promise<{
|
||||
tokens: AntigravityTokenExchangeResult
|
||||
userInfo: AntigravityUserInfo
|
||||
verifier: string
|
||||
}> {
|
||||
const serverHandle = startCallbackServer()
|
||||
|
||||
try {
|
||||
const auth = await buildAuthURL(projectId, clientId, serverHandle.port)
|
||||
|
||||
if (openBrowser) {
|
||||
await openBrowser(auth.url)
|
||||
}
|
||||
|
||||
const callback = await serverHandle.waitForCallback()
|
||||
|
||||
if (callback.error) {
|
||||
throw new Error(`OAuth error: ${callback.error}`)
|
||||
}
|
||||
|
||||
if (!callback.code) {
|
||||
throw new Error("No authorization code received")
|
||||
}
|
||||
|
||||
const state = decodeState(callback.state)
|
||||
if (state.verifier !== auth.verifier) {
|
||||
throw new Error("PKCE verifier mismatch - possible CSRF attack")
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(callback.code, auth.verifier, clientId, clientSecret, serverHandle.port)
|
||||
const userInfo = await fetchUserInfo(tokens.access_token)
|
||||
|
||||
return { tokens, userInfo, verifier: auth.verifier }
|
||||
} catch (err) {
|
||||
serverHandle.close()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
295
src/auth/antigravity/plugin.ts
Normal file
295
src/auth/antigravity/plugin.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Google Antigravity Auth Plugin for OpenCode
|
||||
*
|
||||
* Provides OAuth authentication for Google models via Antigravity API.
|
||||
* This plugin integrates with OpenCode's auth system to enable:
|
||||
* - OAuth 2.0 with PKCE flow for Google authentication
|
||||
* - Automatic token refresh
|
||||
* - Request/response transformation for Antigravity API
|
||||
*
|
||||
* @example
|
||||
* ```json
|
||||
* // opencode.json
|
||||
* {
|
||||
* "plugin": ["oh-my-opencode"],
|
||||
* "provider": {
|
||||
* "google": {
|
||||
* "options": {
|
||||
* "clientId": "custom-client-id",
|
||||
* "clientSecret": "custom-client-secret"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Auth, Provider } from "@opencode-ai/sdk"
|
||||
import type { AuthHook, AuthOuathResult, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET } from "./constants"
|
||||
import {
|
||||
buildAuthURL,
|
||||
exchangeCode,
|
||||
startCallbackServer,
|
||||
fetchUserInfo,
|
||||
decodeState,
|
||||
} from "./oauth"
|
||||
import { createAntigravityFetch } from "./fetch"
|
||||
import { fetchProjectContext } from "./project"
|
||||
import { formatTokenForStorage } from "./token"
|
||||
|
||||
/**
|
||||
* Provider ID for Google models
|
||||
* Antigravity is an auth method for Google, not a separate provider
|
||||
*/
|
||||
const GOOGLE_PROVIDER_ID = "google"
|
||||
|
||||
/**
|
||||
* Type guard to check if auth is OAuth type
|
||||
*/
|
||||
function isOAuthAuth(
|
||||
auth: Auth
|
||||
): auth is { type: "oauth"; access: string; refresh: string; expires: number } {
|
||||
return auth.type === "oauth"
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Google Antigravity OAuth plugin for OpenCode.
|
||||
*
|
||||
* This factory function creates an auth plugin that:
|
||||
* 1. Provides OAuth flow for Google authentication
|
||||
* 2. Creates a custom fetch interceptor for Antigravity API
|
||||
* 3. Handles token management and refresh
|
||||
*
|
||||
* @param input - Plugin input containing the OpenCode client
|
||||
* @returns Hooks object with auth configuration
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Used by OpenCode automatically when plugin is loaded
|
||||
* const hooks = await createGoogleAntigravityAuthPlugin({ client, ... })
|
||||
* ```
|
||||
*/
|
||||
export async function createGoogleAntigravityAuthPlugin({
|
||||
client,
|
||||
}: PluginInput): Promise<{ auth: AuthHook }> {
|
||||
// Cache for custom credentials from provider.options
|
||||
// These are populated by loader() and used by authorize()
|
||||
// Falls back to defaults if loader hasn't been called yet
|
||||
let cachedClientId: string = ANTIGRAVITY_CLIENT_ID
|
||||
let cachedClientSecret: string = ANTIGRAVITY_CLIENT_SECRET
|
||||
|
||||
const authHook: AuthHook = {
|
||||
/**
|
||||
* Provider identifier - must be "google" as Antigravity is
|
||||
* an auth method for Google models, not a separate provider
|
||||
*/
|
||||
provider: GOOGLE_PROVIDER_ID,
|
||||
|
||||
/**
|
||||
* Loader function called when auth is needed.
|
||||
* Reads credentials from provider.options and creates custom fetch.
|
||||
*
|
||||
* @param auth - Function to retrieve current auth state
|
||||
* @param provider - Provider configuration including options
|
||||
* @returns Object with custom fetch function
|
||||
*/
|
||||
loader: async (
|
||||
auth: () => Promise<Auth>,
|
||||
provider: Provider
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const currentAuth = await auth()
|
||||
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log("[antigravity-plugin] loader called")
|
||||
console.log("[antigravity-plugin] auth type:", currentAuth?.type)
|
||||
console.log("[antigravity-plugin] auth keys:", Object.keys(currentAuth || {}))
|
||||
}
|
||||
|
||||
if (!isOAuthAuth(currentAuth)) {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log("[antigravity-plugin] NOT OAuth auth, returning empty")
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log("[antigravity-plugin] OAuth auth detected, creating custom fetch")
|
||||
}
|
||||
|
||||
cachedClientId =
|
||||
(provider.options?.clientId as string) || ANTIGRAVITY_CLIENT_ID
|
||||
cachedClientSecret =
|
||||
(provider.options?.clientSecret as string) || ANTIGRAVITY_CLIENT_SECRET
|
||||
|
||||
// Log if using custom credentials (for debugging)
|
||||
if (
|
||||
process.env.ANTIGRAVITY_DEBUG === "1" &&
|
||||
(cachedClientId !== ANTIGRAVITY_CLIENT_ID ||
|
||||
cachedClientSecret !== ANTIGRAVITY_CLIENT_SECRET)
|
||||
) {
|
||||
console.log(
|
||||
"[antigravity-plugin] Using custom credentials from provider.options"
|
||||
)
|
||||
}
|
||||
|
||||
// Create adapter for client.auth.set that matches fetch.ts AuthClient interface
|
||||
const authClient = {
|
||||
set: async (
|
||||
providerId: string,
|
||||
authData: { access?: string; refresh?: string; expires?: number }
|
||||
) => {
|
||||
await client.auth.set({
|
||||
body: {
|
||||
type: "oauth",
|
||||
access: authData.access || "",
|
||||
refresh: authData.refresh || "",
|
||||
expires: authData.expires || 0,
|
||||
},
|
||||
path: { id: providerId },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Create auth getter that returns compatible format for fetch.ts
|
||||
const getAuth = async (): Promise<{
|
||||
access?: string
|
||||
refresh?: string
|
||||
expires?: number
|
||||
}> => {
|
||||
const authState = await auth()
|
||||
if (isOAuthAuth(authState)) {
|
||||
return {
|
||||
access: authState.access,
|
||||
refresh: authState.refresh,
|
||||
expires: authState.expires,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const antigravityFetch = createAntigravityFetch(
|
||||
getAuth,
|
||||
authClient,
|
||||
GOOGLE_PROVIDER_ID,
|
||||
cachedClientId,
|
||||
cachedClientSecret
|
||||
)
|
||||
|
||||
return {
|
||||
fetch: antigravityFetch,
|
||||
apiKey: "antigravity-oauth",
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Authentication methods available for this provider.
|
||||
* Only OAuth is supported - no prompts for credentials.
|
||||
*/
|
||||
methods: [
|
||||
{
|
||||
type: "oauth",
|
||||
label: "OAuth with Google (Antigravity)",
|
||||
// NO prompts - credentials come from provider.options or defaults
|
||||
// OAuth flow starts immediately when user selects this method
|
||||
|
||||
/**
|
||||
* Starts the OAuth authorization flow.
|
||||
* Opens browser for Google OAuth and waits for callback.
|
||||
*
|
||||
* @returns Authorization result with URL and callback
|
||||
*/
|
||||
authorize: async (): Promise<AuthOuathResult> => {
|
||||
const serverHandle = startCallbackServer()
|
||||
const { url, verifier } = await buildAuthURL(undefined, cachedClientId, serverHandle.port)
|
||||
|
||||
return {
|
||||
url,
|
||||
instructions:
|
||||
"Complete the sign-in in your browser. We'll automatically detect when you're done.",
|
||||
method: "auto",
|
||||
|
||||
callback: async () => {
|
||||
try {
|
||||
const result = await serverHandle.waitForCallback()
|
||||
|
||||
if (result.error) {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.error(`[antigravity-plugin] OAuth error: ${result.error}`)
|
||||
}
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
|
||||
if (!result.code) {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.error("[antigravity-plugin] No authorization code received")
|
||||
}
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
|
||||
const state = decodeState(result.state)
|
||||
if (state.verifier !== verifier) {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.error("[antigravity-plugin] PKCE verifier mismatch")
|
||||
}
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(result.code, verifier, cachedClientId, cachedClientSecret, serverHandle.port)
|
||||
|
||||
try {
|
||||
const userInfo = await fetchUserInfo(tokens.access_token)
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log(`[antigravity-plugin] Authenticated as: ${userInfo.email}`)
|
||||
}
|
||||
} catch {
|
||||
// User info is optional
|
||||
}
|
||||
|
||||
const projectContext = await fetchProjectContext(tokens.access_token)
|
||||
|
||||
const formattedRefresh = formatTokenForStorage(
|
||||
tokens.refresh_token,
|
||||
projectContext.cloudaicompanionProject || "",
|
||||
projectContext.managedProjectId
|
||||
)
|
||||
|
||||
return {
|
||||
type: "success" as const,
|
||||
access: tokens.access_token,
|
||||
refresh: formattedRefresh,
|
||||
expires: Date.now() + tokens.expires_in * 1000,
|
||||
}
|
||||
} catch (error) {
|
||||
serverHandle.close()
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.error(
|
||||
`[antigravity-plugin] OAuth flow failed: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`
|
||||
)
|
||||
}
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
auth: authHook,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default export for OpenCode plugin system
|
||||
*/
|
||||
export default createGoogleAntigravityAuthPlugin
|
||||
|
||||
/**
|
||||
* Named export for explicit imports
|
||||
*/
|
||||
export const GoogleAntigravityAuthPlugin = createGoogleAntigravityAuthPlugin
|
||||
159
src/auth/antigravity/project.ts
Normal file
159
src/auth/antigravity/project.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Antigravity project context management.
|
||||
* Handles fetching GCP project ID via Google's loadCodeAssist API.
|
||||
*/
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_DEFAULT_PROJECT_ID,
|
||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||
ANTIGRAVITY_API_VERSION,
|
||||
ANTIGRAVITY_HEADERS,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AntigravityProjectContext,
|
||||
AntigravityLoadCodeAssistResponse,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* In-memory cache for project context per access token.
|
||||
* Prevents redundant API calls for the same token.
|
||||
*/
|
||||
const projectContextCache = new Map<string, AntigravityProjectContext>()
|
||||
|
||||
/**
|
||||
* Client metadata for loadCodeAssist API request.
|
||||
* Matches cliproxyapi implementation.
|
||||
*/
|
||||
const CODE_ASSIST_METADATA = {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Extracts the project ID from a cloudaicompanionProject field.
|
||||
* Handles both string and object formats.
|
||||
*
|
||||
* @param project - The cloudaicompanionProject value from API response
|
||||
* @returns Extracted project ID string, or undefined if not found
|
||||
*/
|
||||
function extractProjectId(
|
||||
project: string | { id: string } | undefined
|
||||
): string | undefined {
|
||||
if (!project) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Handle string format
|
||||
if (typeof project === "string") {
|
||||
const trimmed = project.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
// Handle object format { id: string }
|
||||
if (typeof project === "object" && "id" in project) {
|
||||
const id = project.id
|
||||
if (typeof id === "string") {
|
||||
const trimmed = id.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the loadCodeAssist API to get project context.
|
||||
* Tries each endpoint in the fallback list until one succeeds.
|
||||
*
|
||||
* @param accessToken - Valid OAuth access token
|
||||
* @returns API response or null if all endpoints fail
|
||||
*/
|
||||
async function callLoadCodeAssistAPI(
|
||||
accessToken: string
|
||||
): Promise<AntigravityLoadCodeAssistResponse | null> {
|
||||
const requestBody = {
|
||||
metadata: CODE_ASSIST_METADATA,
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ANTIGRAVITY_HEADERS["User-Agent"],
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"],
|
||||
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
|
||||
}
|
||||
|
||||
// Try each endpoint in the fallback list
|
||||
for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
|
||||
const url = `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Try next endpoint on failure
|
||||
continue
|
||||
}
|
||||
|
||||
const data =
|
||||
(await response.json()) as AntigravityLoadCodeAssistResponse
|
||||
return data
|
||||
} catch {
|
||||
// Network or parsing error, try next endpoint
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// All endpoints failed
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch project context from Google's loadCodeAssist API.
|
||||
* Extracts the cloudaicompanionProject from the response.
|
||||
*
|
||||
* @param accessToken - Valid OAuth access token
|
||||
* @returns Project context with cloudaicompanionProject ID
|
||||
*/
|
||||
export async function fetchProjectContext(
|
||||
accessToken: string
|
||||
): Promise<AntigravityProjectContext> {
|
||||
const cached = projectContextCache.get(accessToken)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const response = await callLoadCodeAssistAPI(accessToken)
|
||||
const projectId = response
|
||||
? extractProjectId(response.cloudaicompanionProject)
|
||||
: undefined
|
||||
|
||||
const result: AntigravityProjectContext = {
|
||||
cloudaicompanionProject: projectId || "",
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
projectContextCache.set(accessToken, result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the project context cache.
|
||||
* Call this when tokens are refreshed or invalidated.
|
||||
*
|
||||
* @param accessToken - Optional specific token to clear, or clears all if not provided
|
||||
*/
|
||||
export function clearProjectContextCache(accessToken?: string): void {
|
||||
if (accessToken) {
|
||||
projectContextCache.delete(accessToken)
|
||||
} else {
|
||||
projectContextCache.clear()
|
||||
}
|
||||
}
|
||||
303
src/auth/antigravity/request.ts
Normal file
303
src/auth/antigravity/request.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Antigravity request transformer.
|
||||
* Transforms OpenAI-format requests to Antigravity format.
|
||||
* Does NOT handle tool normalization (handled by tools.ts in Task 9).
|
||||
*/
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_HEADERS,
|
||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||
ANTIGRAVITY_API_VERSION,
|
||||
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
|
||||
} from "./constants"
|
||||
import type { AntigravityRequestBody } from "./types"
|
||||
|
||||
/**
|
||||
* Result of request transformation including URL, headers, and body.
|
||||
*/
|
||||
export interface TransformedRequest {
|
||||
/** Transformed URL for Antigravity API */
|
||||
url: string
|
||||
/** Request headers including Authorization and Antigravity-specific headers */
|
||||
headers: Record<string, string>
|
||||
/** Transformed request body in Antigravity format */
|
||||
body: AntigravityRequestBody
|
||||
/** Whether this is a streaming request */
|
||||
streaming: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Antigravity-specific request headers.
|
||||
* Includes Authorization, User-Agent, X-Goog-Api-Client, and Client-Metadata.
|
||||
*
|
||||
* @param accessToken - OAuth access token for Authorization header
|
||||
* @returns Headers object with all required Antigravity headers
|
||||
*/
|
||||
export function buildRequestHeaders(accessToken: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ANTIGRAVITY_HEADERS["User-Agent"],
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"],
|
||||
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model name from request body.
|
||||
* OpenAI-format requests include model in the body.
|
||||
*
|
||||
* @param body - Request body that may contain a model field
|
||||
* @returns Model name or undefined if not found
|
||||
*/
|
||||
export function extractModelFromBody(
|
||||
body: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const model = body.model
|
||||
if (typeof model === "string" && model.trim()) {
|
||||
return model.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model name from URL path.
|
||||
* Handles Google Generative Language API format: /models/{model}:{action}
|
||||
*
|
||||
* @param url - Request URL to parse
|
||||
* @returns Model name or undefined if not found
|
||||
*/
|
||||
export function extractModelFromUrl(url: string): string | undefined {
|
||||
// Match Google's API format: /models/gemini-3-pro:generateContent
|
||||
const match = url.match(/\/models\/([^:]+):/)
|
||||
if (match && match[1]) {
|
||||
return match[1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the action type from the URL path.
|
||||
* E.g., generateContent, streamGenerateContent
|
||||
*
|
||||
* @param url - Request URL to parse
|
||||
* @returns Action name or undefined if not found
|
||||
*/
|
||||
export function extractActionFromUrl(url: string): string | undefined {
|
||||
// Match Google's API format: /models/gemini-3-pro:generateContent
|
||||
const match = url.match(/\/models\/[^:]+:(\w+)/)
|
||||
if (match && match[1]) {
|
||||
return match[1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is targeting Google's Generative Language API.
|
||||
*
|
||||
* @param url - URL to check
|
||||
* @returns true if this is a Google Generative Language API request
|
||||
*/
|
||||
export function isGenerativeLanguageRequest(url: string): boolean {
|
||||
return url.includes("generativelanguage.googleapis.com")
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Antigravity API URL for the given action.
|
||||
*
|
||||
* @param baseEndpoint - Base Antigravity endpoint URL (from fallbacks)
|
||||
* @param action - API action (e.g., generateContent, streamGenerateContent)
|
||||
* @param streaming - Whether to append SSE query parameter
|
||||
* @returns Formatted Antigravity API URL
|
||||
*/
|
||||
export function buildAntigravityUrl(
|
||||
baseEndpoint: string,
|
||||
action: string,
|
||||
streaming: boolean
|
||||
): string {
|
||||
const query = streaming ? "?alt=sse" : ""
|
||||
return `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:${action}${query}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first available Antigravity endpoint.
|
||||
* Can be used with fallback logic in fetch.ts.
|
||||
*
|
||||
* @returns Default (first) Antigravity endpoint
|
||||
*/
|
||||
export function getDefaultEndpoint(): string {
|
||||
return ANTIGRAVITY_ENDPOINT_FALLBACKS[0]
|
||||
}
|
||||
|
||||
function generateRequestId(): string {
|
||||
return `agent-${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
export function wrapRequestBody(
|
||||
body: Record<string, unknown>,
|
||||
projectId: string,
|
||||
modelName: string,
|
||||
sessionId: string
|
||||
): AntigravityRequestBody {
|
||||
const requestPayload = { ...body }
|
||||
delete requestPayload.model
|
||||
|
||||
return {
|
||||
project: projectId,
|
||||
model: modelName,
|
||||
userAgent: "antigravity",
|
||||
requestId: generateRequestId(),
|
||||
request: {
|
||||
...requestPayload,
|
||||
sessionId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface ContentPart {
|
||||
functionCall?: Record<string, unknown>
|
||||
thoughtSignature?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ContentBlock {
|
||||
role?: string
|
||||
parts?: ContentPart[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function debugLog(message: string): void {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log(`[antigravity-request] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function injectThoughtSignatureIntoFunctionCalls(
|
||||
body: Record<string, unknown>,
|
||||
signature: string | undefined
|
||||
): Record<string, unknown> {
|
||||
// Always use skip validator as fallback (CLIProxyAPI approach)
|
||||
const effectiveSignature = signature || SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
||||
debugLog(`[TSIG][INJECT] signature=${effectiveSignature.substring(0, 30)}... (${signature ? "provided" : "default"})`)
|
||||
debugLog(`[TSIG][INJECT] body keys: ${Object.keys(body).join(", ")}`)
|
||||
|
||||
const contents = body.contents as ContentBlock[] | undefined
|
||||
if (!contents || !Array.isArray(contents)) {
|
||||
debugLog(`[TSIG][INJECT] No contents array! Has messages: ${!!body.messages}`)
|
||||
return body
|
||||
}
|
||||
|
||||
debugLog(`[TSIG][INJECT] Found ${contents.length} content blocks`)
|
||||
let injectedCount = 0
|
||||
const modifiedContents = contents.map((content) => {
|
||||
if (!content.parts || !Array.isArray(content.parts)) {
|
||||
return content
|
||||
}
|
||||
|
||||
const modifiedParts = content.parts.map((part) => {
|
||||
if (part.functionCall && !part.thoughtSignature) {
|
||||
injectedCount++
|
||||
return {
|
||||
...part,
|
||||
thoughtSignature: effectiveSignature,
|
||||
}
|
||||
}
|
||||
return part
|
||||
})
|
||||
|
||||
return { ...content, parts: modifiedParts }
|
||||
})
|
||||
|
||||
debugLog(`[TSIG][INJECT] injected signature into ${injectedCount} functionCall(s)`)
|
||||
return { ...body, contents: modifiedContents }
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if request is for streaming.
|
||||
* Checks both action name and request body for stream flag.
|
||||
*
|
||||
* @param url - Request URL
|
||||
* @param body - Request body
|
||||
* @returns true if streaming is requested
|
||||
*/
|
||||
export function isStreamingRequest(
|
||||
url: string,
|
||||
body: Record<string, unknown>
|
||||
): boolean {
|
||||
// Check URL action
|
||||
const action = extractActionFromUrl(url)
|
||||
if (action === "streamGenerateContent") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check body for stream flag
|
||||
if (body.stream === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export interface TransformRequestOptions {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
accessToken: string
|
||||
projectId: string
|
||||
sessionId: string
|
||||
modelName?: string
|
||||
endpointOverride?: string
|
||||
thoughtSignature?: string
|
||||
}
|
||||
|
||||
export function transformRequest(options: TransformRequestOptions): TransformedRequest {
|
||||
const {
|
||||
url,
|
||||
body,
|
||||
accessToken,
|
||||
projectId,
|
||||
sessionId,
|
||||
modelName,
|
||||
endpointOverride,
|
||||
thoughtSignature,
|
||||
} = options
|
||||
|
||||
const effectiveModel =
|
||||
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-preview"
|
||||
|
||||
const streaming = isStreamingRequest(url, body)
|
||||
const action = streaming ? "streamGenerateContent" : "generateContent"
|
||||
|
||||
const endpoint = endpointOverride || getDefaultEndpoint()
|
||||
const transformedUrl = buildAntigravityUrl(endpoint, action, streaming)
|
||||
|
||||
const headers = buildRequestHeaders(accessToken)
|
||||
if (streaming) {
|
||||
headers["Accept"] = "text/event-stream"
|
||||
}
|
||||
|
||||
const bodyWithSignature = injectThoughtSignatureIntoFunctionCalls(body, thoughtSignature)
|
||||
const wrappedBody = wrapRequestBody(bodyWithSignature, projectId, effectiveModel, sessionId)
|
||||
|
||||
return {
|
||||
url: transformedUrl,
|
||||
headers,
|
||||
body: wrappedBody,
|
||||
streaming,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare request headers for streaming responses.
|
||||
* Adds Accept header for SSE format.
|
||||
*
|
||||
* @param headers - Existing headers object
|
||||
* @returns Headers with streaming support
|
||||
*/
|
||||
export function addStreamingHeaders(
|
||||
headers: Record<string, string>
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...headers,
|
||||
Accept: "text/event-stream",
|
||||
}
|
||||
}
|
||||
598
src/auth/antigravity/response.ts
Normal file
598
src/auth/antigravity/response.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Antigravity Response Handler
|
||||
* Transforms Antigravity/Gemini API responses to OpenAI-compatible format
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Non-streaming response transformation
|
||||
* - SSE streaming response transformation (buffered - see transformStreamingResponse)
|
||||
* - Error response handling with retry-after extraction
|
||||
* - Usage metadata extraction from x-antigravity-* headers
|
||||
*/
|
||||
|
||||
import type { AntigravityError, AntigravityUsage } from "./types"
|
||||
|
||||
/**
|
||||
* Usage metadata extracted from Antigravity response headers
|
||||
*/
|
||||
export interface AntigravityUsageMetadata {
|
||||
cachedContentTokenCount?: number
|
||||
totalTokenCount?: number
|
||||
promptTokenCount?: number
|
||||
candidatesTokenCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform result with response and metadata
|
||||
*/
|
||||
export interface TransformResult {
|
||||
response: Response
|
||||
usage?: AntigravityUsageMetadata
|
||||
retryAfterMs?: number
|
||||
error?: AntigravityError
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract usage metadata from Antigravity response headers
|
||||
*
|
||||
* Antigravity sets these headers:
|
||||
* - x-antigravity-cached-content-token-count
|
||||
* - x-antigravity-total-token-count
|
||||
* - x-antigravity-prompt-token-count
|
||||
* - x-antigravity-candidates-token-count
|
||||
*
|
||||
* @param headers - Response headers
|
||||
* @returns Usage metadata if found
|
||||
*/
|
||||
export function extractUsageFromHeaders(headers: Headers): AntigravityUsageMetadata | undefined {
|
||||
const cached = headers.get("x-antigravity-cached-content-token-count")
|
||||
const total = headers.get("x-antigravity-total-token-count")
|
||||
const prompt = headers.get("x-antigravity-prompt-token-count")
|
||||
const candidates = headers.get("x-antigravity-candidates-token-count")
|
||||
|
||||
// Return undefined if no usage headers found
|
||||
if (!cached && !total && !prompt && !candidates) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const usage: AntigravityUsageMetadata = {}
|
||||
|
||||
if (cached) {
|
||||
const parsed = parseInt(cached, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
usage.cachedContentTokenCount = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if (total) {
|
||||
const parsed = parseInt(total, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
usage.totalTokenCount = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
const parsed = parseInt(prompt, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
usage.promptTokenCount = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates) {
|
||||
const parsed = parseInt(candidates, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
usage.candidatesTokenCount = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(usage).length > 0 ? usage : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract retry-after value from error response
|
||||
*
|
||||
* Antigravity returns retry info in error.details array:
|
||||
* {
|
||||
* error: {
|
||||
* details: [{
|
||||
* "@type": "type.googleapis.com/google.rpc.RetryInfo",
|
||||
* "retryDelay": "5.123s"
|
||||
* }]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Also checks standard Retry-After header.
|
||||
*
|
||||
* @param response - Response object (for headers)
|
||||
* @param errorBody - Parsed error body (optional)
|
||||
* @returns Retry after value in milliseconds, or undefined
|
||||
*/
|
||||
export function extractRetryAfterMs(
|
||||
response: Response,
|
||||
errorBody?: Record<string, unknown>,
|
||||
): number | undefined {
|
||||
// First, check standard Retry-After header
|
||||
const retryAfterHeader = response.headers.get("Retry-After")
|
||||
if (retryAfterHeader) {
|
||||
const seconds = parseFloat(retryAfterHeader)
|
||||
if (!isNaN(seconds) && seconds > 0) {
|
||||
return Math.ceil(seconds * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Check retry-after-ms header (set by some transformers)
|
||||
const retryAfterMsHeader = response.headers.get("retry-after-ms")
|
||||
if (retryAfterMsHeader) {
|
||||
const ms = parseInt(retryAfterMsHeader, 10)
|
||||
if (!isNaN(ms) && ms > 0) {
|
||||
return ms
|
||||
}
|
||||
}
|
||||
|
||||
// Check error body for RetryInfo
|
||||
if (!errorBody) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const error = errorBody.error as Record<string, unknown> | undefined
|
||||
if (!error?.details || !Array.isArray(error.details)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const retryInfo = (error.details as Array<Record<string, unknown>>).find(
|
||||
(detail) => detail["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
|
||||
)
|
||||
|
||||
if (!retryInfo?.retryDelay || typeof retryInfo.retryDelay !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Parse retryDelay format: "5.123s"
|
||||
const match = retryInfo.retryDelay.match(/^([\d.]+)s$/)
|
||||
if (match?.[1]) {
|
||||
const seconds = parseFloat(match[1])
|
||||
if (!isNaN(seconds) && seconds > 0) {
|
||||
return Math.ceil(seconds * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error response body and extract useful details
|
||||
*
|
||||
* @param text - Raw response text
|
||||
* @returns Parsed error or undefined
|
||||
*/
|
||||
export function parseErrorBody(text: string): AntigravityError | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
|
||||
// Handle error wrapper
|
||||
if (parsed.error && typeof parsed.error === "object") {
|
||||
const errorObj = parsed.error as Record<string, unknown>
|
||||
return {
|
||||
message: String(errorObj.message || "Unknown error"),
|
||||
type: errorObj.type ? String(errorObj.type) : undefined,
|
||||
code: errorObj.code as string | number | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle direct error message
|
||||
if (parsed.message && typeof parsed.message === "string") {
|
||||
return {
|
||||
message: parsed.message,
|
||||
type: parsed.type ? String(parsed.type) : undefined,
|
||||
code: parsed.code as string | number | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch {
|
||||
// If not valid JSON, return generic error
|
||||
return {
|
||||
message: text || "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a non-streaming Antigravity response to OpenAI-compatible format
|
||||
*
|
||||
* For non-streaming responses:
|
||||
* - Parses the response body
|
||||
* - Unwraps the `response` field if present (Antigravity wraps responses)
|
||||
* - Extracts usage metadata from headers
|
||||
* - Handles error responses
|
||||
*
|
||||
* Note: Does NOT handle thinking block extraction (Task 10)
|
||||
* Note: Does NOT handle tool normalization (Task 9)
|
||||
*
|
||||
* @param response - Fetch Response object
|
||||
* @returns TransformResult with transformed response and metadata
|
||||
*/
|
||||
export async function transformResponse(response: Response): Promise<TransformResult> {
|
||||
const headers = new Headers(response.headers)
|
||||
const usage = extractUsageFromHeaders(headers)
|
||||
|
||||
// Handle error responses
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
const error = parseErrorBody(text)
|
||||
const retryAfterMs = extractRetryAfterMs(response, error ? { error } : undefined)
|
||||
|
||||
// Parse to get full error body for retry-after extraction
|
||||
let errorBody: Record<string, unknown> | undefined
|
||||
try {
|
||||
errorBody = JSON.parse(text) as Record<string, unknown>
|
||||
} catch {
|
||||
errorBody = { error: { message: text } }
|
||||
}
|
||||
|
||||
const retryMs = extractRetryAfterMs(response, errorBody) ?? retryAfterMs
|
||||
|
||||
// Set retry headers if found
|
||||
if (retryMs) {
|
||||
headers.set("Retry-After", String(Math.ceil(retryMs / 1000)))
|
||||
headers.set("retry-after-ms", String(retryMs))
|
||||
}
|
||||
|
||||
return {
|
||||
response: new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
retryAfterMs: retryMs,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle successful response
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
const isJson = contentType.includes("application/json")
|
||||
|
||||
if (!isJson) {
|
||||
// Return non-JSON responses as-is
|
||||
return { response, usage }
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await response.text()
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
|
||||
// Antigravity wraps response in { response: { ... } }
|
||||
// Unwrap if present
|
||||
let transformedBody: unknown = parsed
|
||||
if (parsed.response !== undefined) {
|
||||
transformedBody = parsed.response
|
||||
}
|
||||
|
||||
return {
|
||||
response: new Response(JSON.stringify(transformedBody), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, return original response
|
||||
return { response, usage }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single SSE data line
|
||||
*
|
||||
* Antigravity SSE format:
|
||||
* data: { "response": { ... actual data ... } }
|
||||
*
|
||||
* OpenAI SSE format:
|
||||
* data: { ... actual data ... }
|
||||
*
|
||||
* @param line - SSE data line
|
||||
* @returns Transformed line
|
||||
*/
|
||||
function transformSseLine(line: string): string {
|
||||
if (!line.startsWith("data:")) {
|
||||
return line
|
||||
}
|
||||
|
||||
const json = line.slice(5).trim()
|
||||
if (!json || json === "[DONE]") {
|
||||
return line
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>
|
||||
|
||||
// Unwrap { response: { ... } } wrapper
|
||||
if (parsed.response !== undefined) {
|
||||
return `data: ${JSON.stringify(parsed.response)}`
|
||||
}
|
||||
|
||||
return line
|
||||
} catch {
|
||||
// If parsing fails, return original line
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform SSE streaming payload
|
||||
*
|
||||
* Processes each line in the SSE stream:
|
||||
* - Unwraps { response: { ... } } wrapper from data lines
|
||||
* - Preserves other SSE control lines (event:, id:, retry:, empty lines)
|
||||
*
|
||||
* Note: Does NOT extract thinking blocks (Task 10)
|
||||
*
|
||||
* @param payload - Raw SSE payload text
|
||||
* @returns Transformed SSE payload
|
||||
*/
|
||||
export function transformStreamingPayload(payload: string): string {
|
||||
return payload
|
||||
.split("\n")
|
||||
.map(transformSseLine)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function createSseTransformStream(): TransformStream<Uint8Array, Uint8Array> {
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
let buffer = ""
|
||||
|
||||
return new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
buffer += decoder.decode(chunk, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
const transformed = transformSseLine(line)
|
||||
controller.enqueue(encoder.encode(transformed + "\n"))
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
if (buffer) {
|
||||
const transformed = transformSseLine(buffer)
|
||||
controller.enqueue(encoder.encode(transformed))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a streaming SSE response from Antigravity to OpenAI format.
|
||||
*
|
||||
* Uses TransformStream to process SSE chunks incrementally as they arrive.
|
||||
* Each line is transformed immediately and yielded to the client.
|
||||
*
|
||||
* @param response - The SSE response from Antigravity API
|
||||
* @returns TransformResult with transformed streaming response
|
||||
*/
|
||||
export async function transformStreamingResponse(response: Response): Promise<TransformResult> {
|
||||
const headers = new Headers(response.headers)
|
||||
const usage = extractUsageFromHeaders(headers)
|
||||
|
||||
// Handle error responses
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
const error = parseErrorBody(text)
|
||||
|
||||
let errorBody: Record<string, unknown> | undefined
|
||||
try {
|
||||
errorBody = JSON.parse(text) as Record<string, unknown>
|
||||
} catch {
|
||||
errorBody = { error: { message: text } }
|
||||
}
|
||||
|
||||
const retryAfterMs = extractRetryAfterMs(response, errorBody)
|
||||
|
||||
if (retryAfterMs) {
|
||||
headers.set("Retry-After", String(Math.ceil(retryAfterMs / 1000)))
|
||||
headers.set("retry-after-ms", String(retryAfterMs))
|
||||
}
|
||||
|
||||
return {
|
||||
response: new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
retryAfterMs,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
// Check content type
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
const isEventStream =
|
||||
contentType.includes("text/event-stream") || response.url.includes("alt=sse")
|
||||
|
||||
if (!isEventStream) {
|
||||
// Not SSE, delegate to non-streaming transform
|
||||
// Clone response since we need to read it
|
||||
const text = await response.text()
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
let transformedBody: unknown = parsed
|
||||
if (parsed.response !== undefined) {
|
||||
transformedBody = parsed.response
|
||||
}
|
||||
return {
|
||||
response: new Response(JSON.stringify(transformedBody), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
response: new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return { response, usage }
|
||||
}
|
||||
|
||||
headers.delete("content-length")
|
||||
headers.delete("content-encoding")
|
||||
headers.set("content-type", "text/event-stream; charset=utf-8")
|
||||
|
||||
const transformStream = createSseTransformStream()
|
||||
const transformedBody = response.body.pipeThrough(transformStream)
|
||||
|
||||
return {
|
||||
response: new Response(transformedBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response is a streaming SSE response
|
||||
*
|
||||
* @param response - Fetch Response object
|
||||
* @returns True if response is SSE stream
|
||||
*/
|
||||
export function isStreamingResponse(response: Response): boolean {
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
return contentType.includes("text/event-stream") || response.url.includes("alt=sse")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thought signature from SSE payload text
|
||||
*
|
||||
* Looks for thoughtSignature in SSE events:
|
||||
* data: { "response": { "candidates": [{ "content": { "parts": [{ "thoughtSignature": "..." }] } }] } }
|
||||
*
|
||||
* Returns the last found signature (most recent in the stream).
|
||||
*
|
||||
* @param payload - SSE payload text
|
||||
* @returns Last thought signature if found
|
||||
*/
|
||||
export function extractSignatureFromSsePayload(payload: string): string | undefined {
|
||||
const lines = payload.split("\n")
|
||||
let lastSignature: string | undefined
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const json = line.slice(5).trim()
|
||||
if (!json || json === "[DONE]") {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>
|
||||
|
||||
// Check in response wrapper (Antigravity format)
|
||||
const response = (parsed.response || parsed) as Record<string, unknown>
|
||||
const candidates = response.candidates as Array<Record<string, unknown>> | undefined
|
||||
|
||||
if (candidates && Array.isArray(candidates)) {
|
||||
for (const candidate of candidates) {
|
||||
const content = candidate.content as Record<string, unknown> | undefined
|
||||
const parts = content?.parts as Array<Record<string, unknown>> | undefined
|
||||
|
||||
if (parts && Array.isArray(parts)) {
|
||||
for (const part of parts) {
|
||||
const sig = (part.thoughtSignature || part.thought_signature) as string | undefined
|
||||
if (sig && typeof sig === "string") {
|
||||
lastSignature = sig
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue to next line if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
return lastSignature
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract usage from SSE payload text
|
||||
*
|
||||
* Looks for usageMetadata in SSE events:
|
||||
* data: { "usageMetadata": { ... } }
|
||||
*
|
||||
* @param payload - SSE payload text
|
||||
* @returns Usage if found
|
||||
*/
|
||||
export function extractUsageFromSsePayload(payload: string): AntigravityUsage | undefined {
|
||||
const lines = payload.split("\n")
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const json = line.slice(5).trim()
|
||||
if (!json || json === "[DONE]") {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>
|
||||
|
||||
// Check for usageMetadata at top level
|
||||
if (parsed.usageMetadata && typeof parsed.usageMetadata === "object") {
|
||||
const meta = parsed.usageMetadata as Record<string, unknown>
|
||||
return {
|
||||
prompt_tokens: typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : 0,
|
||||
completion_tokens:
|
||||
typeof meta.candidatesTokenCount === "number" ? meta.candidatesTokenCount : 0,
|
||||
total_tokens: typeof meta.totalTokenCount === "number" ? meta.totalTokenCount : 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Check for usage in response wrapper
|
||||
if (parsed.response && typeof parsed.response === "object") {
|
||||
const resp = parsed.response as Record<string, unknown>
|
||||
if (resp.usageMetadata && typeof resp.usageMetadata === "object") {
|
||||
const meta = resp.usageMetadata as Record<string, unknown>
|
||||
return {
|
||||
prompt_tokens: typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : 0,
|
||||
completion_tokens:
|
||||
typeof meta.candidatesTokenCount === "number" ? meta.candidatesTokenCount : 0,
|
||||
total_tokens: typeof meta.totalTokenCount === "number" ? meta.totalTokenCount : 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for standard OpenAI-style usage
|
||||
if (parsed.usage && typeof parsed.usage === "object") {
|
||||
const u = parsed.usage as Record<string, unknown>
|
||||
return {
|
||||
prompt_tokens: typeof u.prompt_tokens === "number" ? u.prompt_tokens : 0,
|
||||
completion_tokens: typeof u.completion_tokens === "number" ? u.completion_tokens : 0,
|
||||
total_tokens: typeof u.total_tokens === "number" ? u.total_tokens : 0,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue to next line if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
571
src/auth/antigravity/thinking.ts
Normal file
571
src/auth/antigravity/thinking.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* Antigravity Thinking Block Handler (Gemini only)
|
||||
*
|
||||
* Handles extraction and transformation of thinking/reasoning blocks
|
||||
* from Gemini responses. Thinking blocks contain the model's internal
|
||||
* reasoning process, available in `-high` model variants.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Extract thinking blocks from Gemini response format
|
||||
* - Detect thinking-capable model variants (`-high` suffix)
|
||||
* - Format thinking blocks for OpenAI-compatible output
|
||||
*
|
||||
* Note: This is Gemini-only. Claude models are NOT handled by Antigravity.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a single thinking/reasoning block extracted from Gemini response
|
||||
*/
|
||||
export interface ThinkingBlock {
|
||||
/** The thinking/reasoning text content */
|
||||
text: string
|
||||
/** Optional signature for signed thinking blocks (required for multi-turn) */
|
||||
signature?: string
|
||||
/** Index of the thinking block in sequence */
|
||||
index?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw part structure from Gemini response candidates
|
||||
*/
|
||||
export interface GeminiPart {
|
||||
/** Text content of the part */
|
||||
text?: string
|
||||
/** Whether this part is a thinking/reasoning block */
|
||||
thought?: boolean
|
||||
/** Signature for signed thinking blocks */
|
||||
thoughtSignature?: string
|
||||
/** Type field for Anthropic-style format */
|
||||
type?: string
|
||||
/** Signature field for Anthropic-style format */
|
||||
signature?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini response candidate structure
|
||||
*/
|
||||
export interface GeminiCandidate {
|
||||
/** Content containing parts */
|
||||
content?: {
|
||||
/** Role of the content (e.g., "model", "assistant") */
|
||||
role?: string
|
||||
/** Array of content parts */
|
||||
parts?: GeminiPart[]
|
||||
}
|
||||
/** Index of the candidate */
|
||||
index?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini response structure for thinking block extraction
|
||||
*/
|
||||
export interface GeminiResponse {
|
||||
/** Response ID */
|
||||
id?: string
|
||||
/** Array of response candidates */
|
||||
candidates?: GeminiCandidate[]
|
||||
/** Direct content (some responses use this instead of candidates) */
|
||||
content?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
signature?: string
|
||||
}>
|
||||
/** Model used for response */
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of thinking block extraction
|
||||
*/
|
||||
export interface ThinkingExtractionResult {
|
||||
/** Extracted thinking blocks */
|
||||
thinkingBlocks: ThinkingBlock[]
|
||||
/** Combined thinking text for convenience */
|
||||
combinedThinking: string
|
||||
/** Whether any thinking blocks were found */
|
||||
hasThinking: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default thinking budget in tokens for thinking-enabled models
|
||||
*/
|
||||
export const DEFAULT_THINKING_BUDGET = 16000
|
||||
|
||||
/**
|
||||
* Check if a model variant should include thinking blocks
|
||||
*
|
||||
* Returns true for model variants with `-high` suffix, which have
|
||||
* extended thinking capability enabled.
|
||||
*
|
||||
* Examples:
|
||||
* - `gemini-3-pro-high` → true
|
||||
* - `gemini-2.5-pro-high` → true
|
||||
* - `gemini-3-pro-preview` → false
|
||||
* - `gemini-2.5-pro` → false
|
||||
*
|
||||
* @param model - Model identifier string
|
||||
* @returns True if model should include thinking blocks
|
||||
*/
|
||||
export function shouldIncludeThinking(model: string): boolean {
|
||||
if (!model || typeof model !== "string") {
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerModel = model.toLowerCase()
|
||||
|
||||
// Check for -high suffix (primary indicator of thinking capability)
|
||||
if (lowerModel.endsWith("-high")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check for explicit thinking in model name
|
||||
if (lowerModel.includes("thinking")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is thinking-capable (broader check)
|
||||
*
|
||||
* This is a broader check than shouldIncludeThinking - it detects models
|
||||
* that have thinking capability, even if not explicitly requesting thinking output.
|
||||
*
|
||||
* @param model - Model identifier string
|
||||
* @returns True if model supports thinking/reasoning
|
||||
*/
|
||||
export function isThinkingCapableModel(model: string): boolean {
|
||||
if (!model || typeof model !== "string") {
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerModel = model.toLowerCase()
|
||||
|
||||
return (
|
||||
lowerModel.includes("thinking") ||
|
||||
lowerModel.includes("gemini-3") ||
|
||||
lowerModel.endsWith("-high")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a part is a thinking/reasoning block
|
||||
*
|
||||
* Detects both Gemini-style (thought: true) and Anthropic-style
|
||||
* (type: "thinking" or type: "reasoning") formats.
|
||||
*
|
||||
* @param part - Content part to check
|
||||
* @returns True if part is a thinking block
|
||||
*/
|
||||
function isThinkingPart(part: GeminiPart): boolean {
|
||||
// Gemini-style: thought flag
|
||||
if (part.thought === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Anthropic-style: type field
|
||||
if (part.type === "thinking" || part.type === "reasoning") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a thinking part has a valid signature
|
||||
*
|
||||
* Signatures are required for multi-turn conversations with Claude models.
|
||||
* Gemini uses `thoughtSignature`, Anthropic uses `signature`.
|
||||
*
|
||||
* @param part - Thinking part to check
|
||||
* @returns True if part has valid signature
|
||||
*/
|
||||
function hasValidSignature(part: GeminiPart): boolean {
|
||||
// Gemini-style signature
|
||||
if (part.thought === true && part.thoughtSignature) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Anthropic-style signature
|
||||
if ((part.type === "thinking" || part.type === "reasoning") && part.signature) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thinking blocks from a Gemini response
|
||||
*
|
||||
* Parses the response structure to identify and extract all thinking/reasoning
|
||||
* content. Supports both Gemini-style (thought: true) and Anthropic-style
|
||||
* (type: "thinking") formats.
|
||||
*
|
||||
* @param response - Gemini response object
|
||||
* @returns Extraction result with thinking blocks and metadata
|
||||
*/
|
||||
export function extractThinkingBlocks(response: GeminiResponse): ThinkingExtractionResult {
|
||||
const thinkingBlocks: ThinkingBlock[] = []
|
||||
|
||||
// Handle candidates array (standard Gemini format)
|
||||
if (response.candidates && Array.isArray(response.candidates)) {
|
||||
for (const candidate of response.candidates) {
|
||||
const parts = candidate.content?.parts
|
||||
if (!parts || !Array.isArray(parts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
if (!part || typeof part !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isThinkingPart(part)) {
|
||||
const block: ThinkingBlock = {
|
||||
text: part.text || "",
|
||||
index: thinkingBlocks.length,
|
||||
}
|
||||
|
||||
// Extract signature if present
|
||||
if (part.thought === true && part.thoughtSignature) {
|
||||
block.signature = part.thoughtSignature
|
||||
} else if (part.signature) {
|
||||
block.signature = part.signature
|
||||
}
|
||||
|
||||
thinkingBlocks.push(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle direct content array (Anthropic-style response)
|
||||
if (response.content && Array.isArray(response.content)) {
|
||||
for (let i = 0; i < response.content.length; i++) {
|
||||
const item = response.content[i]
|
||||
if (!item || typeof item !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.type === "thinking" || item.type === "reasoning") {
|
||||
thinkingBlocks.push({
|
||||
text: item.text || "",
|
||||
signature: item.signature,
|
||||
index: thinkingBlocks.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all thinking text
|
||||
const combinedThinking = thinkingBlocks.map((b) => b.text).join("\n\n")
|
||||
|
||||
return {
|
||||
thinkingBlocks,
|
||||
combinedThinking,
|
||||
hasThinking: thinkingBlocks.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format thinking blocks for OpenAI-compatible output
|
||||
*
|
||||
* Converts Gemini thinking block format to OpenAI's expected structure.
|
||||
* OpenAI expects thinking content as special message blocks or annotations.
|
||||
*
|
||||
* Output format:
|
||||
* ```
|
||||
* [
|
||||
* { type: "reasoning", text: "thinking content...", signature?: "..." },
|
||||
* ...
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* @param thinking - Array of thinking blocks to format
|
||||
* @returns OpenAI-compatible formatted array
|
||||
*/
|
||||
export function formatThinkingForOpenAI(
|
||||
thinking: ThinkingBlock[],
|
||||
): Array<{ type: "reasoning"; text: string; signature?: string }> {
|
||||
if (!thinking || !Array.isArray(thinking) || thinking.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return thinking.map((block) => {
|
||||
const formatted: { type: "reasoning"; text: string; signature?: string } = {
|
||||
type: "reasoning",
|
||||
text: block.text || "",
|
||||
}
|
||||
|
||||
if (block.signature) {
|
||||
formatted.signature = block.signature
|
||||
}
|
||||
|
||||
return formatted
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform thinking parts in a candidate to OpenAI format
|
||||
*
|
||||
* Modifies candidate content parts to use OpenAI-style reasoning format
|
||||
* while preserving the rest of the response structure.
|
||||
*
|
||||
* @param candidate - Gemini candidate to transform
|
||||
* @returns Transformed candidate with reasoning-formatted thinking
|
||||
*/
|
||||
export function transformCandidateThinking(candidate: GeminiCandidate): GeminiCandidate {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return candidate
|
||||
}
|
||||
|
||||
const content = candidate.content
|
||||
if (!content || typeof content !== "object" || !Array.isArray(content.parts)) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
const thinkingTexts: string[] = []
|
||||
const transformedParts = content.parts.map((part) => {
|
||||
if (part && typeof part === "object" && part.thought === true) {
|
||||
thinkingTexts.push(part.text || "")
|
||||
// Transform to reasoning format
|
||||
return {
|
||||
...part,
|
||||
type: "reasoning" as const,
|
||||
thought: undefined, // Remove Gemini-specific field
|
||||
}
|
||||
}
|
||||
return part
|
||||
})
|
||||
|
||||
const result: GeminiCandidate & { reasoning_content?: string } = {
|
||||
...candidate,
|
||||
content: { ...content, parts: transformedParts },
|
||||
}
|
||||
|
||||
// Add combined reasoning content for convenience
|
||||
if (thinkingTexts.length > 0) {
|
||||
result.reasoning_content = thinkingTexts.join("\n\n")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform Anthropic-style thinking blocks to reasoning format
|
||||
*
|
||||
* Converts `type: "thinking"` blocks to `type: "reasoning"` for consistency.
|
||||
*
|
||||
* @param content - Array of content blocks
|
||||
* @returns Transformed content array
|
||||
*/
|
||||
export function transformAnthropicThinking(
|
||||
content: Array<{ type?: string; text?: string; signature?: string }>,
|
||||
): Array<{ type?: string; text?: string; signature?: string }> {
|
||||
if (!content || !Array.isArray(content)) {
|
||||
return content
|
||||
}
|
||||
|
||||
return content.map((block) => {
|
||||
if (block && typeof block === "object" && block.type === "thinking") {
|
||||
return {
|
||||
type: "reasoning",
|
||||
text: block.text || "",
|
||||
...(block.signature ? { signature: block.signature } : {}),
|
||||
}
|
||||
}
|
||||
return block
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out unsigned thinking blocks
|
||||
*
|
||||
* Claude API requires signed thinking blocks for multi-turn conversations.
|
||||
* This function removes thinking blocks without valid signatures.
|
||||
*
|
||||
* @param parts - Array of content parts
|
||||
* @returns Filtered array without unsigned thinking blocks
|
||||
*/
|
||||
export function filterUnsignedThinkingBlocks(parts: GeminiPart[]): GeminiPart[] {
|
||||
if (!parts || !Array.isArray(parts)) {
|
||||
return parts
|
||||
}
|
||||
|
||||
return parts.filter((part) => {
|
||||
if (!part || typeof part !== "object") {
|
||||
return true
|
||||
}
|
||||
|
||||
// If it's a thinking part, only keep it if signed
|
||||
if (isThinkingPart(part)) {
|
||||
return hasValidSignature(part)
|
||||
}
|
||||
|
||||
// Keep all non-thinking parts
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform entire response thinking parts
|
||||
*
|
||||
* Main transformation function that handles both Gemini-style and
|
||||
* Anthropic-style thinking blocks in a response.
|
||||
*
|
||||
* @param response - Response object to transform
|
||||
* @returns Transformed response with standardized reasoning format
|
||||
*/
|
||||
export function transformResponseThinking(response: GeminiResponse): GeminiResponse {
|
||||
if (!response || typeof response !== "object") {
|
||||
return response
|
||||
}
|
||||
|
||||
const result: GeminiResponse = { ...response }
|
||||
|
||||
// Transform candidates (Gemini-style)
|
||||
if (Array.isArray(result.candidates)) {
|
||||
result.candidates = result.candidates.map(transformCandidateThinking)
|
||||
}
|
||||
|
||||
// Transform direct content (Anthropic-style)
|
||||
if (Array.isArray(result.content)) {
|
||||
result.content = transformAnthropicThinking(result.content)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking configuration for requests
|
||||
*/
|
||||
export interface ThinkingConfig {
|
||||
/** Token budget for thinking/reasoning */
|
||||
thinkingBudget?: number
|
||||
/** Whether to include thoughts in response */
|
||||
includeThoughts?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize thinking configuration
|
||||
*
|
||||
* Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0.
|
||||
*
|
||||
* @param config - Raw thinking configuration
|
||||
* @returns Normalized configuration or undefined
|
||||
*/
|
||||
export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined {
|
||||
if (!config || typeof config !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const record = config as Record<string, unknown>
|
||||
const budgetRaw = record.thinkingBudget ?? record.thinking_budget
|
||||
const includeRaw = record.includeThoughts ?? record.include_thoughts
|
||||
|
||||
const thinkingBudget =
|
||||
typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined
|
||||
const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined
|
||||
|
||||
const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0
|
||||
const finalInclude = enableThinking ? (includeThoughts ?? false) : false
|
||||
|
||||
// Return undefined if no meaningful config
|
||||
if (
|
||||
!enableThinking &&
|
||||
finalInclude === false &&
|
||||
thinkingBudget === undefined &&
|
||||
includeThoughts === undefined
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalized: ThinkingConfig = {}
|
||||
if (thinkingBudget !== undefined) {
|
||||
normalized.thinkingBudget = thinkingBudget
|
||||
}
|
||||
if (finalInclude !== undefined) {
|
||||
normalized.includeThoughts = finalInclude
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thinking configuration from request payload
|
||||
*
|
||||
* Supports both Gemini-style thinkingConfig and Anthropic-style thinking options.
|
||||
*
|
||||
* @param requestPayload - Request body
|
||||
* @param generationConfig - Generation config from request
|
||||
* @param extraBody - Extra body options
|
||||
* @returns Extracted thinking configuration or undefined
|
||||
*/
|
||||
export function extractThinkingConfig(
|
||||
requestPayload: Record<string, unknown>,
|
||||
generationConfig?: Record<string, unknown>,
|
||||
extraBody?: Record<string, unknown>,
|
||||
): ThinkingConfig | undefined {
|
||||
// Check for explicit thinkingConfig
|
||||
const thinkingConfig =
|
||||
generationConfig?.thinkingConfig ?? extraBody?.thinkingConfig ?? requestPayload.thinkingConfig
|
||||
|
||||
if (thinkingConfig && typeof thinkingConfig === "object") {
|
||||
const config = thinkingConfig as Record<string, unknown>
|
||||
return {
|
||||
includeThoughts: Boolean(config.includeThoughts),
|
||||
thinkingBudget:
|
||||
typeof config.thinkingBudget === "number" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Anthropic-style "thinking" option: { type: "enabled", budgetTokens: N }
|
||||
const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking
|
||||
if (anthropicThinking && typeof anthropicThinking === "object") {
|
||||
const thinking = anthropicThinking as Record<string, unknown>
|
||||
if (thinking.type === "enabled" || thinking.budgetTokens) {
|
||||
return {
|
||||
includeThoughts: true,
|
||||
thinkingBudget:
|
||||
typeof thinking.budgetTokens === "number"
|
||||
? thinking.budgetTokens
|
||||
: DEFAULT_THINKING_BUDGET,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve final thinking configuration based on model and context
|
||||
*
|
||||
* Handles special cases like Claude models requiring signed thinking blocks
|
||||
* for multi-turn conversations.
|
||||
*
|
||||
* @param userConfig - User-provided thinking configuration
|
||||
* @param isThinkingModel - Whether model supports thinking
|
||||
* @param isClaudeModel - Whether model is Claude (not used in Antigravity, but kept for compatibility)
|
||||
* @param hasAssistantHistory - Whether conversation has assistant history
|
||||
* @returns Final thinking configuration
|
||||
*/
|
||||
export function resolveThinkingConfig(
|
||||
userConfig: ThinkingConfig | undefined,
|
||||
isThinkingModel: boolean,
|
||||
isClaudeModel: boolean,
|
||||
hasAssistantHistory: boolean,
|
||||
): ThinkingConfig | undefined {
|
||||
// Claude models with history need signed thinking blocks
|
||||
// Since we can't guarantee signatures, disable thinking
|
||||
if (isClaudeModel && hasAssistantHistory) {
|
||||
return { includeThoughts: false, thinkingBudget: 0 }
|
||||
}
|
||||
|
||||
// Enable thinking by default for thinking-capable models
|
||||
if (isThinkingModel && !userConfig) {
|
||||
return { includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }
|
||||
}
|
||||
|
||||
return userConfig
|
||||
}
|
||||
97
src/auth/antigravity/thought-signature-store.ts
Normal file
97
src/auth/antigravity/thought-signature-store.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Thought Signature Store
|
||||
*
|
||||
* Stores and retrieves thought signatures for multi-turn conversations.
|
||||
* Gemini 3 Pro requires thought_signature on function call content blocks
|
||||
* in subsequent requests to maintain reasoning continuity.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Store the latest thought signature per session
|
||||
* - Provide signature for injection into function call requests
|
||||
* - Clear signatures when sessions end
|
||||
*/
|
||||
|
||||
/**
|
||||
* In-memory store for thought signatures indexed by session ID
|
||||
*/
|
||||
const signatureStore = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* In-memory store for session IDs per fetch instance
|
||||
* Used to maintain consistent sessionId across multi-turn conversations
|
||||
*/
|
||||
const sessionIdStore = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Store a thought signature for a session
|
||||
*
|
||||
* @param sessionKey - Unique session identifier (typically fetch instance ID)
|
||||
* @param signature - The thought signature from model response
|
||||
*/
|
||||
export function setThoughtSignature(sessionKey: string, signature: string): void {
|
||||
if (sessionKey && signature) {
|
||||
signatureStore.set(sessionKey, signature)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the stored thought signature for a session
|
||||
*
|
||||
* @param sessionKey - Unique session identifier
|
||||
* @returns The stored signature or undefined if not found
|
||||
*/
|
||||
export function getThoughtSignature(sessionKey: string): string | undefined {
|
||||
return signatureStore.get(sessionKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the thought signature for a session
|
||||
*
|
||||
* @param sessionKey - Unique session identifier
|
||||
*/
|
||||
export function clearThoughtSignature(sessionKey: string): void {
|
||||
signatureStore.delete(sessionKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store or retrieve a persistent session ID for a fetch instance
|
||||
*
|
||||
* @param fetchInstanceId - Unique identifier for the fetch instance
|
||||
* @param sessionId - Optional session ID to store (if not provided, returns existing or generates new)
|
||||
* @returns The session ID for this fetch instance
|
||||
*/
|
||||
export function getOrCreateSessionId(fetchInstanceId: string, sessionId?: string): string {
|
||||
if (sessionId) {
|
||||
sessionIdStore.set(fetchInstanceId, sessionId)
|
||||
return sessionId
|
||||
}
|
||||
|
||||
const existing = sessionIdStore.get(fetchInstanceId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
|
||||
const newSessionId = `-${n}`
|
||||
sessionIdStore.set(fetchInstanceId, newSessionId)
|
||||
return newSessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the session ID for a fetch instance
|
||||
*
|
||||
* @param fetchInstanceId - Unique identifier for the fetch instance
|
||||
*/
|
||||
export function clearSessionId(fetchInstanceId: string): void {
|
||||
sessionIdStore.delete(fetchInstanceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored data for a fetch instance (signature + session ID)
|
||||
*
|
||||
* @param fetchInstanceId - Unique identifier for the fetch instance
|
||||
*/
|
||||
export function clearFetchInstanceData(fetchInstanceId: string): void {
|
||||
signatureStore.delete(fetchInstanceId)
|
||||
sessionIdStore.delete(fetchInstanceId)
|
||||
}
|
||||
119
src/auth/antigravity/token.ts
Normal file
119
src/auth/antigravity/token.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Antigravity token management utilities.
|
||||
* Handles token expiration checking, refresh, and storage format parsing.
|
||||
*/
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_CLIENT_ID,
|
||||
ANTIGRAVITY_CLIENT_SECRET,
|
||||
ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS,
|
||||
GOOGLE_TOKEN_URL,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AntigravityRefreshParts,
|
||||
AntigravityTokenExchangeResult,
|
||||
AntigravityTokens,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Check if the access token is expired.
|
||||
* Includes a 60-second safety buffer to refresh before actual expiration.
|
||||
*
|
||||
* @param tokens - The Antigravity tokens to check
|
||||
* @returns true if the token is expired or will expire within the buffer period
|
||||
*/
|
||||
export function isTokenExpired(tokens: AntigravityTokens): boolean {
|
||||
// Calculate when the token expires (timestamp + expires_in in ms)
|
||||
// timestamp is in milliseconds, expires_in is in seconds
|
||||
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
|
||||
|
||||
// Check if current time is past (expiration - buffer)
|
||||
return Date.now() >= expirationTime - ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token.
|
||||
* Exchanges the refresh token for a new access token via Google's OAuth endpoint.
|
||||
*
|
||||
* @param refreshToken - The refresh token to use
|
||||
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
|
||||
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
|
||||
* @returns Token exchange result with new access token, or throws on error
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
|
||||
): Promise<AntigravityTokenExchangeResult> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
})
|
||||
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "Unknown error")
|
||||
throw new Error(
|
||||
`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
// Google may return a new refresh token, fall back to the original
|
||||
refresh_token: data.refresh_token || refreshToken,
|
||||
expires_in: data.expires_in,
|
||||
token_type: data.token_type,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stored token string into its component parts.
|
||||
* Storage format: `refreshToken|projectId|managedProjectId`
|
||||
*
|
||||
* @param stored - The pipe-separated stored token string
|
||||
* @returns Parsed refresh parts with refreshToken, projectId, and optional managedProjectId
|
||||
*/
|
||||
export function parseStoredToken(stored: string): AntigravityRefreshParts {
|
||||
const parts = stored.split("|")
|
||||
const [refreshToken, projectId, managedProjectId] = parts
|
||||
|
||||
return {
|
||||
refreshToken: refreshToken || "",
|
||||
projectId: projectId || undefined,
|
||||
managedProjectId: managedProjectId || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format token components for storage.
|
||||
* Creates a pipe-separated string: `refreshToken|projectId|managedProjectId`
|
||||
*
|
||||
* @param refreshToken - The refresh token
|
||||
* @param projectId - The GCP project ID
|
||||
* @param managedProjectId - Optional managed project ID for enterprise users
|
||||
* @returns Formatted string for storage
|
||||
*/
|
||||
export function formatTokenForStorage(
|
||||
refreshToken: string,
|
||||
projectId: string,
|
||||
managedProjectId?: string
|
||||
): string {
|
||||
return `${refreshToken}|${projectId}|${managedProjectId || ""}`
|
||||
}
|
||||
243
src/auth/antigravity/tools.ts
Normal file
243
src/auth/antigravity/tools.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Antigravity Tool Normalization
|
||||
* Converts tools between OpenAI and Gemini formats.
|
||||
*
|
||||
* OpenAI format:
|
||||
* { "type": "function", "function": { "name": "x", "description": "...", "parameters": {...} } }
|
||||
*
|
||||
* Gemini format:
|
||||
* { "functionDeclarations": [{ "name": "x", "description": "...", "parameters": {...} }] }
|
||||
*
|
||||
* Note: This is for Gemini models ONLY. Claude models are not supported via Antigravity.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAI function tool format
|
||||
*/
|
||||
export interface OpenAITool {
|
||||
type: string
|
||||
function?: {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini function declaration format
|
||||
*/
|
||||
export interface GeminiFunctionDeclaration {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini tools format (array of functionDeclarations)
|
||||
*/
|
||||
export interface GeminiTools {
|
||||
functionDeclarations: GeminiFunctionDeclaration[]
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI tool call in response
|
||||
*/
|
||||
export interface OpenAIToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini function call in response
|
||||
*/
|
||||
export interface GeminiFunctionCall {
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini function response format
|
||||
*/
|
||||
export interface GeminiFunctionResponse {
|
||||
name: string
|
||||
response: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini tool result containing function calls
|
||||
*/
|
||||
export interface GeminiToolResult {
|
||||
functionCall?: GeminiFunctionCall
|
||||
functionResponse?: GeminiFunctionResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize OpenAI-format tools to Gemini format.
|
||||
* Converts an array of OpenAI tools to Gemini's functionDeclarations format.
|
||||
*
|
||||
* - Handles `function` type tools with name, description, parameters
|
||||
* - Logs warning for unsupported tool types (does NOT silently drop them)
|
||||
* - Creates a single object with functionDeclarations array
|
||||
*
|
||||
* @param tools - Array of OpenAI-format tools
|
||||
* @returns Gemini-format tools object with functionDeclarations, or undefined if no valid tools
|
||||
*/
|
||||
export function normalizeToolsForGemini(
|
||||
tools: OpenAITool[]
|
||||
): GeminiTools | undefined {
|
||||
if (!tools || tools.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const functionDeclarations: GeminiFunctionDeclaration[] = []
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!tool || typeof tool !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
const toolType = tool.type ?? "function"
|
||||
if (toolType === "function" && tool.function) {
|
||||
const declaration: GeminiFunctionDeclaration = {
|
||||
name: tool.function.name,
|
||||
}
|
||||
|
||||
if (tool.function.description) {
|
||||
declaration.description = tool.function.description
|
||||
}
|
||||
|
||||
if (tool.function.parameters) {
|
||||
declaration.parameters = tool.function.parameters
|
||||
} else {
|
||||
declaration.parameters = { type: "object", properties: {} }
|
||||
}
|
||||
|
||||
functionDeclarations.push(declaration)
|
||||
} else if (toolType !== "function" && process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.warn(
|
||||
`[antigravity-tools] Unsupported tool type: "${toolType}". Tool will be skipped.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Return undefined if no valid function declarations
|
||||
if (functionDeclarations.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { functionDeclarations }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Gemini tool results (functionCall) back to OpenAI tool_call format.
|
||||
* Handles both functionCall (request) and functionResponse (result) formats.
|
||||
*
|
||||
* Gemini functionCall format:
|
||||
* { "name": "tool_name", "args": { ... } }
|
||||
*
|
||||
* OpenAI tool_call format:
|
||||
* { "id": "call_xxx", "type": "function", "function": { "name": "tool_name", "arguments": "..." } }
|
||||
*
|
||||
* @param results - Array of Gemini tool results containing functionCall or functionResponse
|
||||
* @returns Array of OpenAI-format tool calls
|
||||
*/
|
||||
export function normalizeToolResultsFromGemini(
|
||||
results: GeminiToolResult[]
|
||||
): OpenAIToolCall[] {
|
||||
if (!results || results.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const toolCalls: OpenAIToolCall[] = []
|
||||
let callCounter = 0
|
||||
|
||||
for (const result of results) {
|
||||
// Handle functionCall (tool invocation from model)
|
||||
if (result.functionCall) {
|
||||
callCounter++
|
||||
const toolCall: OpenAIToolCall = {
|
||||
id: `call_${Date.now()}_${callCounter}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: result.functionCall.name,
|
||||
arguments: JSON.stringify(result.functionCall.args ?? {}),
|
||||
},
|
||||
}
|
||||
toolCalls.push(toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single Gemini functionCall to OpenAI tool_call format.
|
||||
* Useful for streaming responses where each chunk may contain a function call.
|
||||
*
|
||||
* @param functionCall - Gemini function call
|
||||
* @param id - Optional tool call ID (generates one if not provided)
|
||||
* @returns OpenAI-format tool call
|
||||
*/
|
||||
export function convertFunctionCallToToolCall(
|
||||
functionCall: GeminiFunctionCall,
|
||||
id?: string
|
||||
): OpenAIToolCall {
|
||||
return {
|
||||
id: id ?? `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: functionCall.name,
|
||||
arguments: JSON.stringify(functionCall.args ?? {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool array contains any function-type tools.
|
||||
*
|
||||
* @param tools - Array of OpenAI-format tools
|
||||
* @returns true if there are function tools to normalize
|
||||
*/
|
||||
export function hasFunctionTools(tools: OpenAITool[]): boolean {
|
||||
if (!tools || tools.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return tools.some((tool) => tool.type === "function" && tool.function)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract function declarations from already-normalized Gemini tools.
|
||||
* Useful when tools may already be in Gemini format.
|
||||
*
|
||||
* @param tools - Tools that may be in Gemini or OpenAI format
|
||||
* @returns Array of function declarations
|
||||
*/
|
||||
export function extractFunctionDeclarations(
|
||||
tools: unknown
|
||||
): GeminiFunctionDeclaration[] {
|
||||
if (!tools || typeof tools !== "object") {
|
||||
return []
|
||||
}
|
||||
|
||||
// Check if already in Gemini format
|
||||
const geminiTools = tools as Record<string, unknown>
|
||||
if (
|
||||
Array.isArray(geminiTools.functionDeclarations) &&
|
||||
geminiTools.functionDeclarations.length > 0
|
||||
) {
|
||||
return geminiTools.functionDeclarations as GeminiFunctionDeclaration[]
|
||||
}
|
||||
|
||||
// Check if it's an array of OpenAI tools
|
||||
if (Array.isArray(tools)) {
|
||||
const normalized = normalizeToolsForGemini(tools as OpenAITool[])
|
||||
return normalized?.functionDeclarations ?? []
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
185
src/auth/antigravity/types.ts
Normal file
185
src/auth/antigravity/types.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Antigravity Auth Type Definitions
|
||||
* Matches cliproxyapi/sdk/auth/antigravity.go token format exactly
|
||||
*/
|
||||
|
||||
/**
|
||||
* Token storage format for Antigravity authentication
|
||||
* Matches Go metadata structure: type, access_token, refresh_token, expires_in, timestamp, email, project_id
|
||||
*/
|
||||
export interface AntigravityTokens {
|
||||
/** Always "antigravity" for this auth type */
|
||||
type: "antigravity"
|
||||
/** OAuth access token from Google */
|
||||
access_token: string
|
||||
/** OAuth refresh token from Google */
|
||||
refresh_token: string
|
||||
/** Token expiration time in seconds */
|
||||
expires_in: number
|
||||
/** Unix timestamp in milliseconds when tokens were obtained */
|
||||
timestamp: number
|
||||
/** ISO 8601 formatted expiration datetime (optional, for display) */
|
||||
expired?: string
|
||||
/** User's email address from Google userinfo */
|
||||
email?: string
|
||||
/** GCP project ID from loadCodeAssist API */
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Project context returned from loadCodeAssist API
|
||||
* Used to get cloudaicompanionProject for API calls
|
||||
*/
|
||||
export interface AntigravityProjectContext {
|
||||
/** GCP project ID for Cloud AI Companion */
|
||||
cloudaicompanionProject?: string
|
||||
/** Managed project ID for enterprise users (optional) */
|
||||
managedProjectId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for loadCodeAssist API request
|
||||
*/
|
||||
export interface AntigravityClientMetadata {
|
||||
/** IDE type identifier */
|
||||
ideType: "IDE_UNSPECIFIED" | string
|
||||
/** Platform identifier */
|
||||
platform: "PLATFORM_UNSPECIFIED" | string
|
||||
/** Plugin type - typically "GEMINI" */
|
||||
pluginType: "GEMINI" | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for loadCodeAssist API
|
||||
*/
|
||||
export interface AntigravityLoadCodeAssistRequest {
|
||||
metadata: AntigravityClientMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from loadCodeAssist API
|
||||
*/
|
||||
export interface AntigravityLoadCodeAssistResponse {
|
||||
/** Project ID - can be string or object with id field */
|
||||
cloudaicompanionProject?: string | { id: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body format for Antigravity API calls
|
||||
* Wraps the actual request with project and model context
|
||||
*/
|
||||
export interface AntigravityRequestBody {
|
||||
/** GCP project ID */
|
||||
project: string
|
||||
/** Model identifier (e.g., "gemini-3-pro-preview") */
|
||||
model: string
|
||||
/** User agent identifier */
|
||||
userAgent: string
|
||||
/** Unique request ID */
|
||||
requestId: string
|
||||
/** The actual request payload */
|
||||
request: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Response format from Antigravity API
|
||||
* Follows OpenAI-compatible structure with Gemini extensions
|
||||
*/
|
||||
export interface AntigravityResponse {
|
||||
/** Response ID */
|
||||
id?: string
|
||||
/** Object type (e.g., "chat.completion") */
|
||||
object?: string
|
||||
/** Creation timestamp */
|
||||
created?: number
|
||||
/** Model used for response */
|
||||
model?: string
|
||||
/** Response choices */
|
||||
choices?: AntigravityResponseChoice[]
|
||||
/** Token usage statistics */
|
||||
usage?: AntigravityUsage
|
||||
/** Error information if request failed */
|
||||
error?: AntigravityError
|
||||
}
|
||||
|
||||
/**
|
||||
* Single response choice in Antigravity response
|
||||
*/
|
||||
export interface AntigravityResponseChoice {
|
||||
/** Choice index */
|
||||
index: number
|
||||
/** Message content */
|
||||
message?: {
|
||||
role: "assistant"
|
||||
content?: string
|
||||
tool_calls?: AntigravityToolCall[]
|
||||
}
|
||||
/** Delta for streaming responses */
|
||||
delta?: {
|
||||
role?: "assistant"
|
||||
content?: string
|
||||
tool_calls?: AntigravityToolCall[]
|
||||
}
|
||||
/** Finish reason */
|
||||
finish_reason?: "stop" | "tool_calls" | "length" | "content_filter" | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call in Antigravity response
|
||||
*/
|
||||
export interface AntigravityToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token usage statistics
|
||||
*/
|
||||
export interface AntigravityUsage {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response from Antigravity API
|
||||
*/
|
||||
export interface AntigravityError {
|
||||
message: string
|
||||
type?: string
|
||||
code?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* Token exchange result from Google OAuth
|
||||
* Matches antigravityTokenResponse in Go
|
||||
*/
|
||||
export interface AntigravityTokenExchangeResult {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User info from Google userinfo API
|
||||
*/
|
||||
export interface AntigravityUserInfo {
|
||||
email: string
|
||||
name?: string
|
||||
picture?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed refresh token parts
|
||||
* Format: refreshToken|projectId|managedProjectId
|
||||
*/
|
||||
export interface AntigravityRefreshParts {
|
||||
refreshToken: string
|
||||
projectId?: string
|
||||
managedProjectId?: string
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
disabled_agents: z.array(AgentNameSchema).optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||
google_auth: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||
|
||||
2
src/features/background-agent/index.ts
Normal file
2
src/features/background-agent/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager } from "./manager"
|
||||
354
src/features/background-agent/manager.ts
Normal file
354
src/features/background-agent/manager.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type {
|
||||
BackgroundTask,
|
||||
LaunchInput,
|
||||
} from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getMainSessionID } from "../claude-code-session-state"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface MessagePartInfo {
|
||||
sessionID?: string
|
||||
type?: string
|
||||
tool?: string
|
||||
}
|
||||
|
||||
interface EventProperties {
|
||||
sessionID?: string
|
||||
info?: { id?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Event {
|
||||
type: string
|
||||
properties?: EventProperties
|
||||
}
|
||||
|
||||
export class BackgroundManager {
|
||||
private tasks: Map<string, BackgroundTask>
|
||||
private notifications: Map<string, BackgroundTask[]>
|
||||
private client: OpencodeClient
|
||||
private directory: string
|
||||
private pollingInterval?: Timer
|
||||
|
||||
constructor(ctx: PluginInput) {
|
||||
this.tasks = new Map()
|
||||
this.notifications = new Map()
|
||||
this.client = ctx.client
|
||||
this.directory = ctx.directory
|
||||
}
|
||||
|
||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||
const createResult = await this.client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
throw new Error(`Failed to create background session: ${createResult.error}`)
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
|
||||
sessionID,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: input.parentMessageID,
|
||||
description: input.description,
|
||||
agent: input.agent,
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
this.startPolling()
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID })
|
||||
|
||||
this.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
tools: {
|
||||
background_task: false,
|
||||
background_output: false,
|
||||
background_cancel: false,
|
||||
call_omo_agent: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] promptAsync error:", error)
|
||||
const existingTask = this.findBySession(sessionID)
|
||||
if (existingTask) {
|
||||
existingTask.status = "error"
|
||||
existingTask.error = String(error)
|
||||
existingTask.completedAt = new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
getTask(id: string): BackgroundTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
||||
const result: BackgroundTask[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.parentSessionID === sessionID) {
|
||||
result.push(task)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
findBySession(sessionID: string): BackgroundTask | undefined {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.sessionID === sessionID) {
|
||||
return task
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
const props = event.properties
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
if (!props || typeof props !== "object" || !("sessionID" in props)) return
|
||||
const partInfo = props as unknown as MessagePartInfo
|
||||
const sessionID = partInfo?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task) return
|
||||
|
||||
if (partInfo?.type === "tool" || partInfo?.tool) {
|
||||
if (!task.progress) {
|
||||
task.progress = {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
}
|
||||
task.progress.toolCalls += 1
|
||||
task.progress.lastTool = partInfo.tool
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
this.markForNotification(task)
|
||||
this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via session.idle event:", task.id)
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const info = props?.info
|
||||
if (!info || typeof info.id !== "string") return
|
||||
const sessionID = info.id
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task) return
|
||||
|
||||
if (task.status === "running") {
|
||||
task.status = "cancelled"
|
||||
task.completedAt = new Date()
|
||||
task.error = "Session deleted"
|
||||
}
|
||||
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
markForNotification(task: BackgroundTask): void {
|
||||
const queue = this.notifications.get(task.parentSessionID) ?? []
|
||||
queue.push(task)
|
||||
this.notifications.set(task.parentSessionID, queue)
|
||||
}
|
||||
|
||||
getPendingNotifications(sessionID: string): BackgroundTask[] {
|
||||
return this.notifications.get(sessionID) ?? []
|
||||
}
|
||||
|
||||
clearNotifications(sessionID: string): void {
|
||||
this.notifications.delete(sessionID)
|
||||
}
|
||||
|
||||
private clearNotificationsForTask(taskId: string): void {
|
||||
for (const [sessionID, tasks] of this.notifications.entries()) {
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
if (filtered.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else {
|
||||
this.notifications.set(sessionID, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
if (this.pollingInterval) return
|
||||
|
||||
this.pollingInterval = setInterval(() => {
|
||||
this.pollRunningTasks()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval)
|
||||
this.pollingInterval = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private notifyParentSession(task: BackgroundTask): void {
|
||||
const duration = this.formatDuration(task.startedAt, task.completedAt)
|
||||
|
||||
log("[background-agent] notifyParentSession called for task:", task.id)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
if (tuiClient.tui?.showToast) {
|
||||
tuiClient.tui.showToast({
|
||||
body: {
|
||||
title: "Background Task Completed",
|
||||
message: `Task "${task.description}" finished in ${duration}.`,
|
||||
variant: "success",
|
||||
duration: 5000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.`
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (!mainSessionID) {
|
||||
log("[background-agent] No main session ID available, relying on pending queue")
|
||||
return
|
||||
}
|
||||
|
||||
log("[background-agent] Sending notification to main session:", mainSessionID)
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.client.session.prompt({
|
||||
path: { id: mainSessionID },
|
||||
body: {
|
||||
parts: [{ type: "text", text: message }],
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
})
|
||||
this.clearNotificationsForTask(task.id)
|
||||
log("[background-agent] Successfully sent prompt to main session")
|
||||
} catch (error) {
|
||||
log("[background-agent] prompt failed:", String(error))
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
private formatDuration(start: Date, end?: Date): string {
|
||||
const duration = (end ?? new Date()).getTime() - start.getTime()
|
||||
const seconds = Math.floor(duration / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
private hasRunningTasks(): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === "running") return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async pollRunningTasks(): Promise<void> {
|
||||
const statusResult = await this.client.session.status()
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status !== "running") continue
|
||||
|
||||
try {
|
||||
const sessionStatus = allStatuses[task.sessionID]
|
||||
|
||||
if (!sessionStatus) {
|
||||
log("[background-agent] Session not found in status:", task.sessionID)
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionStatus.type === "idle") {
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
this.markForNotification(task)
|
||||
this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via polling:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const messagesResult = await this.client.session.messages({
|
||||
path: { id: task.sessionID },
|
||||
})
|
||||
|
||||
if (!messagesResult.error && messagesResult.data) {
|
||||
const messages = messagesResult.data as Array<{
|
||||
info?: { role?: string }
|
||||
parts?: Array<{ type?: string; tool?: string; name?: string }>
|
||||
}>
|
||||
const assistantMsgs = messages.filter(
|
||||
(m) => m.info?.role === "assistant"
|
||||
)
|
||||
|
||||
let toolCalls = 0
|
||||
let lastTool: string | undefined
|
||||
|
||||
for (const msg of assistantMsgs) {
|
||||
const parts = msg.parts ?? []
|
||||
for (const part of parts) {
|
||||
if (part.type === "tool_use" || part.tool) {
|
||||
toolCalls++
|
||||
lastTool = part.tool || part.name || "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!task.progress) {
|
||||
task.progress = { toolCalls: 0, lastUpdate: new Date() }
|
||||
}
|
||||
task.progress.toolCalls = toolCalls
|
||||
task.progress.lastTool = lastTool
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
} catch (error) {
|
||||
log("[background-agent] Poll error for task:", { taskId: task.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.hasRunningTasks()) {
|
||||
this.stopPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/features/background-agent/types.ts
Normal file
34
src/features/background-agent/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type BackgroundTaskStatus =
|
||||
| "running"
|
||||
| "completed"
|
||||
| "error"
|
||||
| "cancelled"
|
||||
|
||||
export interface TaskProgress {
|
||||
toolCalls: number
|
||||
lastTool?: string
|
||||
lastUpdate: Date
|
||||
}
|
||||
|
||||
export interface BackgroundTask {
|
||||
id: string
|
||||
sessionID: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
description: string
|
||||
agent: string
|
||||
status: BackgroundTaskStatus
|
||||
startedAt: Date
|
||||
completedAt?: Date
|
||||
result?: string
|
||||
error?: string
|
||||
progress?: TaskProgress
|
||||
}
|
||||
|
||||
export interface LaunchInput {
|
||||
description: string
|
||||
prompt: string
|
||||
agent: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
}
|
||||
8
src/google-auth.ts
Normal file
8
src/google-auth.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"
|
||||
|
||||
const GoogleAntigravityAuthPlugin: Plugin = async (ctx) => {
|
||||
return createGoogleAntigravityAuthPlugin(ctx)
|
||||
}
|
||||
|
||||
export default GoogleAntigravityAuthPlugin
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AutoCompactState } from "./types"
|
||||
import type { AutoCompactState, RetryState } from "./types"
|
||||
import { RETRY_CONFIG } from "./types"
|
||||
|
||||
type Client = {
|
||||
session: {
|
||||
@@ -11,9 +12,34 @@ type Client = {
|
||||
}
|
||||
tui: {
|
||||
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
|
||||
showToast: (opts: {
|
||||
body: { title: string; message: string; variant: string; duration: number }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function calculateRetryDelay(attempt: number): number {
|
||||
const delay = RETRY_CONFIG.initialDelayMs * Math.pow(RETRY_CONFIG.backoffFactor, attempt - 1)
|
||||
return Math.min(delay, RETRY_CONFIG.maxDelayMs)
|
||||
}
|
||||
|
||||
function shouldRetry(retryState: RetryState | undefined): boolean {
|
||||
if (!retryState) return true
|
||||
return retryState.attempt < RETRY_CONFIG.maxAttempts
|
||||
}
|
||||
|
||||
function getOrCreateRetryState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string
|
||||
): RetryState {
|
||||
let state = autoCompactState.retryStateBySession.get(sessionID)
|
||||
if (!state) {
|
||||
state = { attempt: 0, lastAttemptTime: 0 }
|
||||
autoCompactState.retryStateBySession.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export async function getLastAssistant(
|
||||
sessionID: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -42,6 +68,12 @@ export async function getLastAssistant(
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionState(autoCompactState: AutoCompactState, sessionID: string): void {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
autoCompactState.errorDataBySession.delete(sessionID)
|
||||
autoCompactState.retryStateBySession.delete(sessionID)
|
||||
}
|
||||
|
||||
export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
@@ -50,6 +82,27 @@ export async function executeCompact(
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<void> {
|
||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
|
||||
|
||||
if (!shouldRetry(retryState)) {
|
||||
clearSessionState(autoCompactState, sessionID)
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact Failed",
|
||||
message: `Failed after ${RETRY_CONFIG.maxAttempts} attempts. Please try manual compact.`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
retryState.attempt++
|
||||
retryState.lastAttemptTime = Date.now()
|
||||
|
||||
try {
|
||||
const providerID = msg.providerID as string | undefined
|
||||
const modelID = msg.modelID as string | undefined
|
||||
@@ -61,14 +114,30 @@ export async function executeCompact(
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
clearSessionState(autoCompactState, sessionID)
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).tui.submitPrompt({ query: { directory } })
|
||||
} catch {}
|
||||
}, 500)
|
||||
}
|
||||
} catch {
|
||||
const delay = calculateRetryDelay(retryState.attempt)
|
||||
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
autoCompactState.errorDataBySession.delete(sessionID)
|
||||
} catch {}
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact Retry",
|
||||
message: `Attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts} failed. Retrying in ${Math.round(delay / 1000)}s...`,
|
||||
variant: "warning",
|
||||
duration: delay,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
setTimeout(() => {
|
||||
executeCompact(sessionID, msg, autoCompactState, client, directory)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ function createAutoCompactState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
retryStateBySession: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
||||
if (sessionInfo?.id) {
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,7 +7,20 @@ export interface ParsedTokenLimitError {
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export interface RetryState {
|
||||
attempt: number
|
||||
lastAttemptTime: number
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
retryStateBySession: Map<string, RetryState>
|
||||
}
|
||||
|
||||
export const RETRY_CONFIG = {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 2000,
|
||||
backoffFactor: 2,
|
||||
maxDelayMs: 30000,
|
||||
} as const
|
||||
|
||||
18
src/hooks/auto-update-checker/cache.ts
Normal file
18
src/hooks/auto-update-checker/cache.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as fs from "node:fs"
|
||||
import { VERSION_FILE } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function invalidateCache(): boolean {
|
||||
try {
|
||||
if (fs.existsSync(VERSION_FILE)) {
|
||||
fs.unlinkSync(VERSION_FILE)
|
||||
log(`[auto-update-checker] Cache invalidated: ${VERSION_FILE}`)
|
||||
return true
|
||||
}
|
||||
log("[auto-update-checker] Version file not found, nothing to invalidate")
|
||||
return false
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to invalidate cache:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
119
src/hooks/auto-update-checker/checker.ts
Normal file
119
src/hooks/auto-update-checker/checker.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types"
|
||||
import {
|
||||
PACKAGE_NAME,
|
||||
NPM_REGISTRY_URL,
|
||||
NPM_FETCH_TIMEOUT,
|
||||
INSTALLED_PACKAGE_JSON,
|
||||
USER_OPENCODE_CONFIG,
|
||||
} from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function isLocalDevMode(directory: string): boolean {
|
||||
const projectConfig = path.join(directory, ".opencode", "opencode.json")
|
||||
|
||||
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(content) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function findPluginEntry(directory: string): string | null {
|
||||
const projectConfig = path.join(directory, ".opencode", "opencode.json")
|
||||
|
||||
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(content) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getCachedVersion(): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(INSTALLED_PACKAGE_JSON)) return null
|
||||
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
return pkg.version ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
|
||||
|
||||
try {
|
||||
const response = await fetch(NPM_REGISTRY_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = (await response.json()) as NpmDistTags
|
||||
return data.latest ?? null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
|
||||
if (isLocalDevMode(directory)) {
|
||||
log("[auto-update-checker] Local dev mode detected, skipping update check")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true }
|
||||
}
|
||||
|
||||
const pluginEntry = findPluginEntry(directory)
|
||||
if (!pluginEntry) {
|
||||
log("[auto-update-checker] Plugin not found in config")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false }
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion()
|
||||
if (!currentVersion) {
|
||||
log("[auto-update-checker] No cached version found")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false }
|
||||
}
|
||||
|
||||
const latestVersion = await getLatestVersion()
|
||||
if (!latestVersion) {
|
||||
log("[auto-update-checker] Failed to fetch latest version")
|
||||
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false }
|
||||
}
|
||||
|
||||
const needsUpdate = currentVersion !== latestVersion
|
||||
log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
|
||||
|
||||
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false }
|
||||
}
|
||||
40
src/hooks/auto-update-checker/constants.ts
Normal file
40
src/hooks/auto-update-checker/constants.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
|
||||
export const PACKAGE_NAME = "oh-my-opencode"
|
||||
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
|
||||
export const NPM_FETCH_TIMEOUT = 5000
|
||||
|
||||
/**
|
||||
* OpenCode plugin cache directory
|
||||
* - Linux/macOS: ~/.cache/opencode/
|
||||
* - Windows: %LOCALAPPDATA%/opencode/
|
||||
*/
|
||||
function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
|
||||
}
|
||||
return path.join(os.homedir(), ".cache", "opencode")
|
||||
}
|
||||
|
||||
export const CACHE_DIR = getCacheDir()
|
||||
export const VERSION_FILE = path.join(CACHE_DIR, "version")
|
||||
export const INSTALLED_PACKAGE_JSON = path.join(
|
||||
CACHE_DIR,
|
||||
"node_modules",
|
||||
PACKAGE_NAME,
|
||||
"package.json"
|
||||
)
|
||||
|
||||
/**
|
||||
* OpenCode config file locations (priority order)
|
||||
*/
|
||||
function getUserConfigDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
|
||||
}
|
||||
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
|
||||
}
|
||||
|
||||
export const USER_CONFIG_DIR = getUserConfigDir()
|
||||
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
|
||||
56
src/hooks/auto-update-checker/index.ts
Normal file
56
src/hooks/auto-update-checker/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { checkForUpdate } from "./checker"
|
||||
import { invalidateCache } from "./cache"
|
||||
import { PACKAGE_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function createAutoUpdateCheckerHook(ctx: PluginInput) {
|
||||
let hasChecked = false
|
||||
|
||||
return {
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type !== "session.created") return
|
||||
if (hasChecked) return
|
||||
|
||||
const props = event.properties as { info?: { parentID?: string } } | undefined
|
||||
if (props?.info?.parentID) return
|
||||
|
||||
hasChecked = true
|
||||
|
||||
try {
|
||||
const result = await checkForUpdate(ctx.directory)
|
||||
|
||||
if (result.isLocalDev) {
|
||||
log("[auto-update-checker] Skipped: local development mode")
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.needsUpdate) {
|
||||
log("[auto-update-checker] No update needed")
|
||||
return
|
||||
}
|
||||
|
||||
invalidateCache()
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: `${PACKAGE_NAME} Update`,
|
||||
message: `v${result.latestVersion} available (current: v${result.currentVersion}). Restart OpenCode to apply.`,
|
||||
variant: "info" as const,
|
||||
duration: 8000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
log(`[auto-update-checker] Update notification sent: v${result.currentVersion} → v${result.latestVersion}`)
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Error during update check:", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type { UpdateCheckResult } from "./types"
|
||||
export { checkForUpdate } from "./checker"
|
||||
export { invalidateCache } from "./cache"
|
||||
22
src/hooks/auto-update-checker/types.ts
Normal file
22
src/hooks/auto-update-checker/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export interface NpmDistTags {
|
||||
latest: string
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export interface OpencodeConfig {
|
||||
plugin?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PackageJson {
|
||||
version: string
|
||||
name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
needsUpdate: boolean
|
||||
currentVersion: string | null
|
||||
latestVersion: string | null
|
||||
isLocalDev: boolean
|
||||
}
|
||||
22
src/hooks/background-notification/index.ts
Normal file
22
src/hooks/background-notification/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
interface Event {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: Event
|
||||
}
|
||||
|
||||
export function createBackgroundNotificationHook(manager: BackgroundManager) {
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
manager.handleEvent(event)
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export type { BackgroundNotificationHookConfig } from "./types"
|
||||
5
src/hooks/background-notification/types.ts
Normal file
5
src/hooks/background-notification/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { BackgroundTask } from "../../features/background-agent"
|
||||
|
||||
export interface BackgroundNotificationHookConfig {
|
||||
formatNotification?: (tasks: BackgroundTask[]) => string
|
||||
}
|
||||
@@ -52,11 +52,10 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
if (lastAssistant.providerID !== "anthropic") return
|
||||
|
||||
const totalInputTokens = assistantMessages.reduce((sum, m) => {
|
||||
const inputTokens = m.tokens?.input ?? 0
|
||||
const cacheReadTokens = m.tokens?.cache?.read ?? 0
|
||||
return sum + inputTokens + cacheReadTokens
|
||||
}, 0)
|
||||
// Use only the last assistant message's input tokens
|
||||
// This reflects the ACTUAL current context window usage (post-compaction)
|
||||
const lastTokens = lastAssistant.tokens
|
||||
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||
|
||||
const actualUsagePercentage = totalInputTokens / ANTHROPIC_ACTUAL_LIMIT
|
||||
|
||||
|
||||
@@ -98,11 +98,11 @@ export function createGrepOutputTruncatorHook(ctx: PluginInput) {
|
||||
|
||||
if (assistantMessages.length === 0) return
|
||||
|
||||
const totalInputTokens = assistantMessages.reduce((sum, m) => {
|
||||
const inputTokens = m.tokens?.input ?? 0
|
||||
const cacheReadTokens = m.tokens?.cache?.read ?? 0
|
||||
return sum + inputTokens + cacheReadTokens
|
||||
}, 0)
|
||||
// Use only the last assistant message's input tokens
|
||||
// This reflects the ACTUAL current context window usage (post-compaction)
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
const lastTokens = lastAssistant.tokens
|
||||
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||
|
||||
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - totalInputTokens
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
||||
export { createTodoContinuationEnforcer, type TodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
||||
export { createContextWindowMonitorHook } from "./context-window-monitor";
|
||||
export { createSessionNotification } from "./session-notification";
|
||||
export { createSessionRecoveryHook } from "./session-recovery";
|
||||
export { createSessionRecoveryHook, type SessionRecoveryHook } from "./session-recovery";
|
||||
export { createCommentCheckerHooks } from "./comment-checker";
|
||||
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
|
||||
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
|
||||
@@ -10,3 +10,6 @@ export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detec
|
||||
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
|
||||
export { createThinkModeHook } from "./think-mode";
|
||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||
export { createRulesInjectorHook } from "./rules-injector";
|
||||
export { createBackgroundNotificationHook } from "./background-notification"
|
||||
export { createAutoUpdateCheckerHook } from "./auto-update-checker";
|
||||
|
||||
23
src/hooks/rules-injector/constants.ts
Normal file
23
src/hooks/rules-injector/constants.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector");
|
||||
|
||||
export const PROJECT_MARKERS = [
|
||||
".git",
|
||||
"pyproject.toml",
|
||||
"package.json",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
".venv",
|
||||
];
|
||||
|
||||
export const PROJECT_RULE_SUBDIRS: [string, string][] = [
|
||||
[".cursor", "rules"],
|
||||
[".claude", "rules"],
|
||||
];
|
||||
|
||||
export const USER_RULE_DIR = ".claude/rules";
|
||||
|
||||
export const RULE_EXTENSIONS = [".md", ".mdc"];
|
||||
237
src/hooks/rules-injector/finder.ts
Normal file
237
src/hooks/rules-injector/finder.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import {
|
||||
PROJECT_MARKERS,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
RULE_EXTENSIONS,
|
||||
USER_RULE_DIR,
|
||||
} from "./constants";
|
||||
|
||||
/**
|
||||
* Candidate rule file with metadata for filtering and sorting
|
||||
*/
|
||||
export interface RuleFileCandidate {
|
||||
/** Absolute path to the rule file */
|
||||
path: string;
|
||||
/** Real path after symlink resolution (for duplicate detection) */
|
||||
realPath: string;
|
||||
/** Whether this is a global/user-level rule */
|
||||
isGlobal: boolean;
|
||||
/** Directory distance from current file (9999 for global rules) */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find project root by walking up from startPath.
|
||||
* Checks for PROJECT_MARKERS (.git, pyproject.toml, package.json, etc.)
|
||||
*
|
||||
* @param startPath - Starting path to search from (file or directory)
|
||||
* @returns Project root path or null if not found
|
||||
*/
|
||||
export function findProjectRoot(startPath: string): string | null {
|
||||
let current: string;
|
||||
|
||||
try {
|
||||
const stat = statSync(startPath);
|
||||
current = stat.isDirectory() ? startPath : dirname(startPath);
|
||||
} catch {
|
||||
current = dirname(startPath);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
for (const marker of PROJECT_MARKERS) {
|
||||
const markerPath = join(current, marker);
|
||||
if (existsSync(markerPath)) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all rule files (*.md, *.mdc) in a directory
|
||||
*
|
||||
* @param dir - Directory to search
|
||||
* @param results - Array to accumulate results
|
||||
*/
|
||||
function findRuleFilesRecursive(dir: string, results: string[]): void {
|
||||
if (!existsSync(dir)) return;
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
findRuleFilesRecursive(fullPath, results);
|
||||
} else if (entry.isFile()) {
|
||||
const isRuleFile = RULE_EXTENSIONS.some((ext) =>
|
||||
entry.name.endsWith(ext),
|
||||
);
|
||||
if (isRuleFile) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Permission denied or other errors - silently skip
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve symlinks safely with fallback to original path
|
||||
*
|
||||
* @param filePath - Path to resolve
|
||||
* @returns Real path or original path if resolution fails
|
||||
*/
|
||||
function safeRealpathSync(filePath: string): string {
|
||||
try {
|
||||
return realpathSync(filePath);
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate directory distance between a rule file and current file.
|
||||
* Distance is based on common ancestor within project root.
|
||||
*
|
||||
* @param rulePath - Path to the rule file
|
||||
* @param currentFile - Path to the current file being edited
|
||||
* @param projectRoot - Project root for relative path calculation
|
||||
* @returns Distance (0 = same directory, higher = further)
|
||||
*/
|
||||
export function calculateDistance(
|
||||
rulePath: string,
|
||||
currentFile: string,
|
||||
projectRoot: string | null,
|
||||
): number {
|
||||
if (!projectRoot) {
|
||||
return 9999;
|
||||
}
|
||||
|
||||
try {
|
||||
const ruleDir = dirname(rulePath);
|
||||
const currentDir = dirname(currentFile);
|
||||
|
||||
const ruleRel = relative(projectRoot, ruleDir);
|
||||
const currentRel = relative(projectRoot, currentDir);
|
||||
|
||||
// Handle paths outside project root
|
||||
if (ruleRel.startsWith("..") || currentRel.startsWith("..")) {
|
||||
return 9999;
|
||||
}
|
||||
|
||||
const ruleParts = ruleRel ? ruleRel.split("/") : [];
|
||||
const currentParts = currentRel ? currentRel.split("/") : [];
|
||||
|
||||
// Find common prefix length
|
||||
let common = 0;
|
||||
for (let i = 0; i < Math.min(ruleParts.length, currentParts.length); i++) {
|
||||
if (ruleParts[i] === currentParts[i]) {
|
||||
common++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Distance is how many directories up from current file to common ancestor
|
||||
return currentParts.length - common;
|
||||
} catch {
|
||||
return 9999;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all rule files for a given context.
|
||||
* Searches from currentFile upward to projectRoot for rule directories,
|
||||
* then user-level directory (~/.claude/rules).
|
||||
*
|
||||
* IMPORTANT: This searches EVERY directory from file to project root.
|
||||
* Not just the project root itself.
|
||||
*
|
||||
* @param projectRoot - Project root path (or null if outside any project)
|
||||
* @param homeDir - User home directory
|
||||
* @param currentFile - Current file being edited (for distance calculation)
|
||||
* @returns Array of rule file candidates sorted by distance
|
||||
*/
|
||||
export function findRuleFiles(
|
||||
projectRoot: string | null,
|
||||
homeDir: string,
|
||||
currentFile: string,
|
||||
): RuleFileCandidate[] {
|
||||
const candidates: RuleFileCandidate[] = [];
|
||||
const seenRealPaths = new Set<string>();
|
||||
|
||||
// Search from current file's directory up to project root
|
||||
let currentDir = dirname(currentFile);
|
||||
let distance = 0;
|
||||
|
||||
while (true) {
|
||||
// Search rule directories in current directory
|
||||
for (const [parent, subdir] of PROJECT_RULE_SUBDIRS) {
|
||||
const ruleDir = join(currentDir, parent, subdir);
|
||||
const files: string[] = [];
|
||||
findRuleFilesRecursive(ruleDir, files);
|
||||
|
||||
for (const filePath of files) {
|
||||
const realPath = safeRealpathSync(filePath);
|
||||
if (seenRealPaths.has(realPath)) continue;
|
||||
seenRealPaths.add(realPath);
|
||||
|
||||
candidates.push({
|
||||
path: filePath,
|
||||
realPath,
|
||||
isGlobal: false,
|
||||
distance,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stop at project root or filesystem root
|
||||
if (projectRoot && currentDir === projectRoot) break;
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir) break;
|
||||
currentDir = parentDir;
|
||||
distance++;
|
||||
}
|
||||
|
||||
// Search user-level rule directory (~/.claude/rules)
|
||||
const userRuleDir = join(homeDir, USER_RULE_DIR);
|
||||
const userFiles: string[] = [];
|
||||
findRuleFilesRecursive(userRuleDir, userFiles);
|
||||
|
||||
for (const filePath of userFiles) {
|
||||
const realPath = safeRealpathSync(filePath);
|
||||
if (seenRealPaths.has(realPath)) continue;
|
||||
seenRealPaths.add(realPath);
|
||||
|
||||
candidates.push({
|
||||
path: filePath,
|
||||
realPath,
|
||||
isGlobal: true,
|
||||
distance: 9999, // Global rules always have max distance
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by distance (closest first, then global rules last)
|
||||
candidates.sort((a, b) => {
|
||||
if (a.isGlobal !== b.isGlobal) {
|
||||
return a.isGlobal ? 1 : -1;
|
||||
}
|
||||
return a.distance - b.distance;
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
150
src/hooks/rules-injector/index.ts
Normal file
150
src/hooks/rules-injector/index.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { findProjectRoot, findRuleFiles } from "./finder";
|
||||
import {
|
||||
createContentHash,
|
||||
isDuplicateByContentHash,
|
||||
isDuplicateByRealPath,
|
||||
shouldApplyRule,
|
||||
} from "./matcher";
|
||||
import { parseRuleFrontmatter } from "./parser";
|
||||
import {
|
||||
clearInjectedRules,
|
||||
loadInjectedRules,
|
||||
saveInjectedRules,
|
||||
} from "./storage";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface RuleToInject {
|
||||
relativePath: string;
|
||||
matchReason: string;
|
||||
content: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"];
|
||||
|
||||
export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<
|
||||
string,
|
||||
{ contentHashes: Set<string>; realPaths: Set<string> }
|
||||
>();
|
||||
|
||||
function getSessionCache(sessionID: string): {
|
||||
contentHashes: Set<string>;
|
||||
realPaths: Set<string>;
|
||||
} {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedRules(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function resolveFilePath(title: string): string | null {
|
||||
if (!title) return null;
|
||||
if (title.startsWith("/")) return title;
|
||||
return resolve(ctx.directory, title);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput
|
||||
) => {
|
||||
if (!TRACKED_TOOLS.includes(input.tool.toLowerCase())) return;
|
||||
|
||||
const filePath = resolveFilePath(output.title);
|
||||
if (!filePath) return;
|
||||
|
||||
const projectRoot = findProjectRoot(filePath);
|
||||
const cache = getSessionCache(input.sessionID);
|
||||
const home = homedir();
|
||||
|
||||
const ruleFileCandidates = findRuleFiles(projectRoot, home, filePath);
|
||||
const toInject: RuleToInject[] = [];
|
||||
|
||||
for (const candidate of ruleFileCandidates) {
|
||||
if (isDuplicateByRealPath(candidate.realPath, cache.realPaths)) continue;
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(candidate.path, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
|
||||
const matchResult = shouldApplyRule(metadata, filePath, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
|
||||
const contentHash = createContentHash(body);
|
||||
if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue;
|
||||
|
||||
const relativePath = projectRoot
|
||||
? relative(projectRoot, candidate.path)
|
||||
: candidate.path;
|
||||
|
||||
toInject.push({
|
||||
relativePath,
|
||||
matchReason: matchResult.reason ?? "matched",
|
||||
content: body,
|
||||
distance: candidate.distance,
|
||||
});
|
||||
|
||||
cache.realPaths.add(candidate.realPath);
|
||||
cache.contentHashes.add(contentHash);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
toInject.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
for (const rule of toInject) {
|
||||
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${rule.content}`;
|
||||
}
|
||||
|
||||
saveInjectedRules(input.sessionID, cache);
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedRules(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedRules(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
63
src/hooks/rules-injector/matcher.ts
Normal file
63
src/hooks/rules-injector/matcher.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createHash } from "crypto"
|
||||
import { relative } from "node:path"
|
||||
import picomatch from "picomatch"
|
||||
import type { RuleMetadata } from "./types"
|
||||
|
||||
export interface MatchResult {
|
||||
applies: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rule should apply to the current file based on metadata
|
||||
*/
|
||||
export function shouldApplyRule(
|
||||
metadata: RuleMetadata,
|
||||
currentFilePath: string,
|
||||
projectRoot: string | null
|
||||
): MatchResult {
|
||||
if (metadata.alwaysApply === true) {
|
||||
return { applies: true, reason: "alwaysApply" }
|
||||
}
|
||||
|
||||
const globs = metadata.globs
|
||||
if (!globs) {
|
||||
return { applies: false }
|
||||
}
|
||||
|
||||
const patterns = Array.isArray(globs) ? globs : [globs]
|
||||
if (patterns.length === 0) {
|
||||
return { applies: false }
|
||||
}
|
||||
|
||||
const relativePath = projectRoot ? relative(projectRoot, currentFilePath) : currentFilePath
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (picomatch.isMatch(relativePath, pattern, { dot: true, bash: true })) {
|
||||
return { applies: true, reason: `glob: ${pattern}` }
|
||||
}
|
||||
}
|
||||
|
||||
return { applies: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if realPath already exists in cache (symlink deduplication)
|
||||
*/
|
||||
export function isDuplicateByRealPath(realPath: string, cache: Set<string>): boolean {
|
||||
return cache.has(realPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SHA-256 hash of content, truncated to 16 chars
|
||||
*/
|
||||
export function createContentHash(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex").slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content hash already exists in cache
|
||||
*/
|
||||
export function isDuplicateByContentHash(hash: string, cache: Set<string>): boolean {
|
||||
return cache.has(hash)
|
||||
}
|
||||
211
src/hooks/rules-injector/parser.ts
Normal file
211
src/hooks/rules-injector/parser.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { RuleMetadata } from "./types";
|
||||
|
||||
export interface RuleFrontmatterResult {
|
||||
metadata: RuleMetadata;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from rule file content
|
||||
* Supports:
|
||||
* - Single string: globs: "**\/*.py"
|
||||
* - Inline array: globs: ["**\/*.py", "src/**\/*.ts"]
|
||||
* - Multi-line array:
|
||||
* globs:
|
||||
* - "**\/*.py"
|
||||
* - "src/**\/*.ts"
|
||||
* - Comma-separated: globs: "**\/*.py, src/**\/*.ts"
|
||||
* - Claude Code 'paths' field (alias for globs)
|
||||
*/
|
||||
export function parseRuleFrontmatter(content: string): RuleFrontmatterResult {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
||||
const match = content.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { metadata: {}, body: content };
|
||||
}
|
||||
|
||||
const yamlContent = match[1];
|
||||
const body = match[2];
|
||||
|
||||
try {
|
||||
const metadata = parseYamlContent(yamlContent);
|
||||
return { metadata, body };
|
||||
} catch {
|
||||
return { metadata: {}, body: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML content without external library
|
||||
*/
|
||||
function parseYamlContent(yamlContent: string): RuleMetadata {
|
||||
const lines = yamlContent.split("\n");
|
||||
const metadata: RuleMetadata = {};
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const colonIndex = line.indexOf(":");
|
||||
|
||||
if (colonIndex === -1) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
const rawValue = line.slice(colonIndex + 1).trim();
|
||||
|
||||
if (key === "description") {
|
||||
metadata.description = parseStringValue(rawValue);
|
||||
} else if (key === "alwaysApply") {
|
||||
metadata.alwaysApply = rawValue === "true";
|
||||
} else if (key === "globs" || key === "paths") {
|
||||
const { value, consumed } = parseArrayOrStringValue(rawValue, lines, i);
|
||||
// Merge paths into globs (Claude Code compatibility)
|
||||
if (key === "paths") {
|
||||
metadata.globs = mergeGlobs(metadata.globs, value);
|
||||
} else {
|
||||
metadata.globs = mergeGlobs(metadata.globs, value);
|
||||
}
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string value, removing surrounding quotes
|
||||
*/
|
||||
function parseStringValue(value: string): string {
|
||||
if (!value) return "";
|
||||
|
||||
// Remove surrounding quotes
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse array or string value from YAML
|
||||
* Returns the parsed value and number of lines consumed
|
||||
*/
|
||||
function parseArrayOrStringValue(
|
||||
rawValue: string,
|
||||
lines: string[],
|
||||
currentIndex: number
|
||||
): { value: string | string[]; consumed: number } {
|
||||
// Case 1: Inline array ["a", "b", "c"]
|
||||
if (rawValue.startsWith("[")) {
|
||||
return { value: parseInlineArray(rawValue), consumed: 1 };
|
||||
}
|
||||
|
||||
// Case 2: Multi-line array (value is empty, next lines start with " - ")
|
||||
if (!rawValue || rawValue === "") {
|
||||
const arrayItems: string[] = [];
|
||||
let consumed = 1;
|
||||
|
||||
for (let j = currentIndex + 1; j < lines.length; j++) {
|
||||
const nextLine = lines[j];
|
||||
|
||||
// Check if this is an array item (starts with whitespace + dash)
|
||||
const arrayMatch = nextLine.match(/^\s+-\s*(.*)$/);
|
||||
if (arrayMatch) {
|
||||
const itemValue = parseStringValue(arrayMatch[1].trim());
|
||||
if (itemValue) {
|
||||
arrayItems.push(itemValue);
|
||||
}
|
||||
consumed++;
|
||||
} else if (nextLine.trim() === "") {
|
||||
// Skip empty lines within array
|
||||
consumed++;
|
||||
} else {
|
||||
// Not an array item, stop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (arrayItems.length > 0) {
|
||||
return { value: arrayItems, consumed };
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: Comma-separated patterns in single string
|
||||
const stringValue = parseStringValue(rawValue);
|
||||
if (stringValue.includes(",")) {
|
||||
const items = stringValue
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
return { value: items, consumed: 1 };
|
||||
}
|
||||
|
||||
// Case 4: Single string value
|
||||
return { value: stringValue, consumed: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline JSON-like array: ["a", "b", "c"]
|
||||
*/
|
||||
function parseInlineArray(value: string): string[] {
|
||||
// Remove brackets
|
||||
const content = value.slice(1, value.lastIndexOf("]")).trim();
|
||||
if (!content) return [];
|
||||
|
||||
const items: string[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
let quoteChar = "";
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const char = content[i];
|
||||
|
||||
if (!inQuote && (char === '"' || char === "'")) {
|
||||
inQuote = true;
|
||||
quoteChar = char;
|
||||
} else if (inQuote && char === quoteChar) {
|
||||
inQuote = false;
|
||||
quoteChar = "";
|
||||
} else if (!inQuote && char === ",") {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) {
|
||||
items.push(parseStringValue(trimmed));
|
||||
}
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last item
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) {
|
||||
items.push(parseStringValue(trimmed));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two globs values (for combining paths and globs)
|
||||
*/
|
||||
function mergeGlobs(
|
||||
existing: string | string[] | undefined,
|
||||
newValue: string | string[]
|
||||
): string | string[] {
|
||||
if (!existing) return newValue;
|
||||
|
||||
const existingArray = Array.isArray(existing) ? existing : [existing];
|
||||
const newArray = Array.isArray(newValue) ? newValue : [newValue];
|
||||
|
||||
return [...existingArray, ...newArray];
|
||||
}
|
||||
59
src/hooks/rules-injector/storage.ts
Normal file
59
src/hooks/rules-injector/storage.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { RULES_INJECTOR_STORAGE } from "./constants";
|
||||
import type { InjectedRulesData } from "./types";
|
||||
|
||||
function getStoragePath(sessionID: string): string {
|
||||
return join(RULES_INJECTOR_STORAGE, `${sessionID}.json`);
|
||||
}
|
||||
|
||||
export function loadInjectedRules(sessionID: string): {
|
||||
contentHashes: Set<string>;
|
||||
realPaths: Set<string>;
|
||||
} {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (!existsSync(filePath))
|
||||
return { contentHashes: new Set(), realPaths: new Set() };
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const data: InjectedRulesData = JSON.parse(content);
|
||||
return {
|
||||
contentHashes: new Set(data.injectedHashes),
|
||||
realPaths: new Set(data.injectedRealPaths ?? []),
|
||||
};
|
||||
} catch {
|
||||
return { contentHashes: new Set(), realPaths: new Set() };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInjectedRules(
|
||||
sessionID: string,
|
||||
data: { contentHashes: Set<string>; realPaths: Set<string> }
|
||||
): void {
|
||||
if (!existsSync(RULES_INJECTOR_STORAGE)) {
|
||||
mkdirSync(RULES_INJECTOR_STORAGE, { recursive: true });
|
||||
}
|
||||
|
||||
const storageData: InjectedRulesData = {
|
||||
sessionID,
|
||||
injectedHashes: [...data.contentHashes],
|
||||
injectedRealPaths: [...data.realPaths],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
writeFileSync(getStoragePath(sessionID), JSON.stringify(storageData, null, 2));
|
||||
}
|
||||
|
||||
export function clearInjectedRules(sessionID: string): void {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
43
src/hooks/rules-injector/types.ts
Normal file
43
src/hooks/rules-injector/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Rule file metadata (Claude Code style frontmatter)
|
||||
* @see https://docs.anthropic.com/en/docs/claude-code/settings#rule-files
|
||||
*/
|
||||
export interface RuleMetadata {
|
||||
description?: string;
|
||||
globs?: string | string[];
|
||||
alwaysApply?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule information with path context and content
|
||||
*/
|
||||
export interface RuleInfo {
|
||||
/** Absolute path to the rule file */
|
||||
path: string;
|
||||
/** Path relative to project root */
|
||||
relativePath: string;
|
||||
/** Directory distance from target file (0 = same dir) */
|
||||
distance: number;
|
||||
/** Rule file content (without frontmatter) */
|
||||
content: string;
|
||||
/** SHA-256 hash of content for deduplication */
|
||||
contentHash: string;
|
||||
/** Parsed frontmatter metadata */
|
||||
metadata: RuleMetadata;
|
||||
/** Why this rule matched (e.g., "alwaysApply", "glob: *.ts", "path match") */
|
||||
matchReason: string;
|
||||
/** Real path after symlink resolution (for duplicate detection) */
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session storage for injected rules tracking
|
||||
*/
|
||||
export interface InjectedRulesData {
|
||||
sessionID: string;
|
||||
/** Content hashes of already injected rules */
|
||||
injectedHashes: string[];
|
||||
/** Real paths of already injected rules (for symlink deduplication) */
|
||||
injectedRealPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findEmptyMessageByIndex,
|
||||
findMessageByIndexNeedingThinking,
|
||||
findMessagesWithOrphanThinking,
|
||||
findMessagesWithThinkingBlocks,
|
||||
injectTextPart,
|
||||
@@ -70,7 +71,10 @@ function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
|
||||
(message.includes("first block") ||
|
||||
message.includes("must start with") ||
|
||||
message.includes("preceeding") ||
|
||||
(message.includes("expected") && message.includes("found")))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
@@ -125,8 +129,17 @@ async function recoverThinkingBlockOrder(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData,
|
||||
_directory: string
|
||||
_directory: string,
|
||||
error: unknown
|
||||
): Promise<boolean> {
|
||||
const targetIndex = extractMessageIndex(error)
|
||||
if (targetIndex !== null) {
|
||||
const targetMessageID = findMessageByIndexNeedingThinking(sessionID, targetIndex)
|
||||
if (targetMessageID) {
|
||||
return prependThinkingPart(sessionID, targetMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
|
||||
|
||||
if (orphanMessages.length === 0) {
|
||||
@@ -203,14 +216,26 @@ async function recoverEmptyContentMessage(
|
||||
// All error types have dedicated recovery functions (recoverToolResultMissing,
|
||||
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
|
||||
|
||||
export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
export interface SessionRecoveryHook {
|
||||
handleSessionRecovery: (info: MessageInfo) => Promise<boolean>
|
||||
isRecoverableError: (error: unknown) => boolean
|
||||
setOnAbortCallback: (callback: (sessionID: string) => void) => void
|
||||
setOnRecoveryCompleteCallback: (callback: (sessionID: string) => void) => void
|
||||
}
|
||||
|
||||
export function createSessionRecoveryHook(ctx: PluginInput): SessionRecoveryHook {
|
||||
const processingErrors = new Set<string>()
|
||||
let onAbortCallback: ((sessionID: string) => void) | null = null
|
||||
let onRecoveryCompleteCallback: ((sessionID: string) => void) | null = null
|
||||
|
||||
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
|
||||
onAbortCallback = callback
|
||||
}
|
||||
|
||||
const setOnRecoveryCompleteCallback = (callback: (sessionID: string) => void): void => {
|
||||
onRecoveryCompleteCallback = callback
|
||||
}
|
||||
|
||||
const isRecoverableError = (error: unknown): boolean => {
|
||||
return detectErrorType(error) !== null
|
||||
}
|
||||
@@ -229,12 +254,12 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
processingErrors.add(assistantMsgID)
|
||||
|
||||
try {
|
||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
|
||||
if (onAbortCallback) {
|
||||
onAbortCallback(sessionID)
|
||||
onAbortCallback(sessionID) // Mark recovering BEFORE abort
|
||||
}
|
||||
|
||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
@@ -275,7 +300,7 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
if (errorType === "tool_result_missing") {
|
||||
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "thinking_block_order") {
|
||||
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory)
|
||||
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
|
||||
} else if (errorType === "thinking_disabled_violation") {
|
||||
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "empty_content_message") {
|
||||
@@ -288,6 +313,11 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
return false
|
||||
} finally {
|
||||
processingErrors.delete(assistantMsgID)
|
||||
|
||||
// Always notify recovery complete, regardless of success or failure
|
||||
if (sessionID && onRecoveryCompleteCallback) {
|
||||
onRecoveryCompleteCallback(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,5 +325,6 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
handleSessionRecovery,
|
||||
isRecoverableError,
|
||||
setOnAbortCallback,
|
||||
setOnRecoveryCompleteCallback,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +123,6 @@ export function findEmptyMessages(sessionID: string): string[] {
|
||||
const emptyIds: string[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
if (!messageHasContent(msg.id)) {
|
||||
emptyIds.push(msg.id)
|
||||
}
|
||||
@@ -136,13 +134,25 @@ export function findEmptyMessages(sessionID: string): string[] {
|
||||
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= messages.length) return null
|
||||
// Try multiple indices to handle system message offset
|
||||
// API includes system message at index 0, storage may not
|
||||
const indicesToTry = [targetIndex, targetIndex - 1]
|
||||
|
||||
for (const idx of indicesToTry) {
|
||||
if (idx < 0 || idx >= messages.length) continue
|
||||
|
||||
const targetMsg = messages[targetIndex]
|
||||
if (targetMsg.role !== "assistant") return null
|
||||
if (messageHasContent(targetMsg.id)) return null
|
||||
const targetMsg = messages[idx]
|
||||
|
||||
// NOTE: Do NOT skip last assistant message here
|
||||
// If API returned an error, this message is NOT the final assistant message
|
||||
// (the API only allows empty content for the ACTUAL final assistant message)
|
||||
|
||||
if (!messageHasContent(targetMsg.id)) {
|
||||
return targetMsg.id
|
||||
}
|
||||
}
|
||||
|
||||
return targetMsg.id
|
||||
return null
|
||||
}
|
||||
|
||||
export function findFirstEmptyMessage(sessionID: string): string | null {
|
||||
@@ -154,13 +164,9 @@ export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
if (hasThinking) {
|
||||
@@ -179,8 +185,8 @@ export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
// NOTE: Removed isLastMessage skip - recovery needs to fix last message too
|
||||
// when "thinking must start with" errors occur on final assistant message
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
if (parts.length === 0) continue
|
||||
@@ -188,10 +194,11 @@ export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
||||
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const firstPart = sortedParts[0]
|
||||
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
||||
|
||||
if (hasThinking && !firstIsThinking) {
|
||||
// NOTE: Changed condition - if first part is not thinking, it's orphan
|
||||
// regardless of whether thinking blocks exist elsewhere in the message
|
||||
if (!firstIsThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
@@ -246,3 +253,25 @@ export function stripThinkingParts(messageID: string): boolean {
|
||||
|
||||
return anyRemoved
|
||||
}
|
||||
|
||||
export function findMessageByIndexNeedingThinking(sessionID: string, targetIndex: number): string | null {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= messages.length) return null
|
||||
|
||||
const targetMsg = messages[targetIndex]
|
||||
if (targetMsg.role !== "assistant") return null
|
||||
|
||||
const parts = readParts(targetMsg.id)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const firstPart = sortedParts[0]
|
||||
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
||||
|
||||
if (!firstIsThinking) {
|
||||
return targetMsg.id
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export interface TodoContinuationEnforcer {
|
||||
handler: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
markRecovering: (sessionID: string) => void
|
||||
markRecoveryComplete: (sessionID: string) => void
|
||||
}
|
||||
|
||||
interface Todo {
|
||||
content: string
|
||||
status: string
|
||||
@@ -32,13 +38,22 @@ function detectInterrupt(error: unknown): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
export function createTodoContinuationEnforcer(ctx: PluginInput): TodoContinuationEnforcer {
|
||||
const remindedSessions = new Set<string>()
|
||||
const interruptedSessions = new Set<string>()
|
||||
const errorSessions = new Set<string>()
|
||||
const recoveringSessions = new Set<string>()
|
||||
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const markRecovering = (sessionID: string): void => {
|
||||
recoveringSessions.add(sessionID)
|
||||
}
|
||||
|
||||
const markRecoveryComplete = (sessionID: string): void => {
|
||||
recoveringSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.error") {
|
||||
@@ -73,6 +88,11 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
const timer = setTimeout(async () => {
|
||||
pendingTimers.delete(sessionID)
|
||||
|
||||
// Check if session is in recovery mode - if so, skip entirely without clearing state
|
||||
if (recoveringSessions.has(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
|
||||
|
||||
interruptedSessions.delete(sessionID)
|
||||
@@ -111,7 +131,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
remindedSessions.add(sessionID)
|
||||
|
||||
// Re-check if abort occurred during the delay/fetch
|
||||
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
|
||||
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID) || recoveringSessions.has(sessionID)) {
|
||||
remindedSessions.delete(sessionID)
|
||||
return
|
||||
}
|
||||
@@ -158,6 +178,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
remindedSessions.delete(sessionInfo.id)
|
||||
interruptedSessions.delete(sessionInfo.id)
|
||||
errorSessions.delete(sessionInfo.id)
|
||||
recoveringSessions.delete(sessionInfo.id)
|
||||
|
||||
// Cancel pending continuation
|
||||
const timer = pendingTimers.get(sessionInfo.id)
|
||||
@@ -168,4 +189,10 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handler,
|
||||
markRecovering,
|
||||
markRecoveryComplete,
|
||||
}
|
||||
}
|
||||
|
||||
67
src/index.ts
67
src/index.ts
@@ -12,7 +12,11 @@ import {
|
||||
createThinkModeHook,
|
||||
createClaudeCodeHooksHook,
|
||||
createAnthropicAutoCompactHook,
|
||||
createRulesInjectorHook,
|
||||
createBackgroundNotificationHook,
|
||||
createAutoUpdateCheckerHook,
|
||||
} from "./hooks";
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||
import {
|
||||
loadUserCommands,
|
||||
loadProjectCommands,
|
||||
@@ -35,10 +39,11 @@ import {
|
||||
getCurrentSessionTitle,
|
||||
} from "./features/claude-code-session-state";
|
||||
import { updateTerminalTitle } from "./features/terminal";
|
||||
import { builtinTools } from "./tools";
|
||||
import { builtinTools, createCallOmoAgent, createBackgroundTools } from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { createBuiltinMcps } from "./mcp";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||
import { log } from "./shared/logger";
|
||||
import { log, deepMerge } from "./shared";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
@@ -85,10 +90,7 @@ function mergeConfigs(
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
agents:
|
||||
override.agents !== undefined
|
||||
? { ...(base.agents ?? {}), ...override.agents }
|
||||
: base.agents,
|
||||
agents: deepMerge(base.agents, override.agents),
|
||||
disabled_agents: [
|
||||
...new Set([
|
||||
...(base.disabled_agents ?? []),
|
||||
@@ -101,10 +103,7 @@ function mergeConfigs(
|
||||
...(override.disabled_mcps ?? []),
|
||||
]),
|
||||
],
|
||||
claude_code:
|
||||
override.claude_code !== undefined || base.claude_code !== undefined
|
||||
? { ...(base.claude_code ?? {}), ...(override.claude_code ?? {}) }
|
||||
: undefined,
|
||||
claude_code: deepMerge(base.claude_code, override.claude_code),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,6 +146,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
|
||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
|
||||
const sessionRecovery = createSessionRecoveryHook(ctx);
|
||||
|
||||
// Wire up recovery state tracking between session-recovery and todo-continuation-enforcer
|
||||
// This prevents the continuation enforcer from injecting prompts during active recovery
|
||||
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
|
||||
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
|
||||
|
||||
const commentChecker = createCommentCheckerHooks();
|
||||
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
|
||||
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
|
||||
@@ -157,14 +162,33 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
||||
});
|
||||
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
|
||||
const rulesInjector = createRulesInjectorHook(ctx);
|
||||
const autoUpdateChecker = createAutoUpdateCheckerHook(ctx);
|
||||
|
||||
updateTerminalTitle({ sessionId: "main" });
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
|
||||
const backgroundNotificationHook = createBackgroundNotificationHook(backgroundManager);
|
||||
const backgroundTools = createBackgroundTools(backgroundManager, ctx.client);
|
||||
|
||||
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
|
||||
|
||||
const googleAuthHooks = pluginConfig.google_auth
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
|
||||
return {
|
||||
tool: builtinTools,
|
||||
...(googleAuthHooks ? { auth: googleAuthHooks.auth } : {}),
|
||||
|
||||
tool: {
|
||||
...builtinTools,
|
||||
...backgroundTools,
|
||||
call_omo_agent: callOmoAgent,
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
await claudeCodeHooks["chat.message"]?.(input, output)
|
||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||
},
|
||||
|
||||
config: async (config) => {
|
||||
@@ -186,6 +210,19 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
...config.tools,
|
||||
};
|
||||
|
||||
if (config.agent.explore) {
|
||||
config.agent.explore.tools = {
|
||||
...config.agent.explore.tools,
|
||||
call_omo_agent: false,
|
||||
};
|
||||
}
|
||||
if (config.agent.librarian) {
|
||||
config.agent.librarian.tools = {
|
||||
...config.agent.librarian.tools,
|
||||
call_omo_agent: false,
|
||||
};
|
||||
}
|
||||
|
||||
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
|
||||
? await loadMcpConfigs()
|
||||
: { servers: {} };
|
||||
@@ -215,11 +252,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
},
|
||||
|
||||
event: async (input) => {
|
||||
await autoUpdateChecker.event(input);
|
||||
await claudeCodeHooks.event(input);
|
||||
await todoContinuationEnforcer(input);
|
||||
await backgroundNotificationHook.event(input);
|
||||
await todoContinuationEnforcer.handler(input);
|
||||
await contextWindowMonitor.event(input);
|
||||
await directoryAgentsInjector.event(input);
|
||||
await directoryReadmeInjector.event(input);
|
||||
await rulesInjector.event(input);
|
||||
await thinkMode.event(input);
|
||||
await anthropicAutoCompact.event(input);
|
||||
|
||||
@@ -339,6 +379,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await commentChecker["tool.execute.after"](input, output);
|
||||
await directoryAgentsInjector["tool.execute.after"](input, output);
|
||||
await directoryReadmeInjector["tool.execute.after"](input, output);
|
||||
await rulesInjector["tool.execute.after"](input, output);
|
||||
await emptyTaskResponseDetector["tool.execute.after"](input, output);
|
||||
|
||||
if (input.sessionID === getMainSessionID()) {
|
||||
|
||||
53
src/shared/deep-merge.ts
Normal file
53
src/shared/deep-merge.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
const MAX_DEPTH = 50;
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merges two objects, with override values taking precedence.
|
||||
* - Objects are recursively merged
|
||||
* - Arrays are replaced (not concatenated)
|
||||
* - undefined values in override do not overwrite base values
|
||||
*
|
||||
* @example
|
||||
* deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 })
|
||||
* // => { a: 1, b: { c: 10, d: 3 }, e: 5 }
|
||||
*/
|
||||
export function deepMerge<T extends Record<string, unknown>>(base: T, override: Partial<T>, depth?: number): T;
|
||||
export function deepMerge<T extends Record<string, unknown>>(base: T | undefined, override: T | undefined, depth?: number): T | undefined;
|
||||
export function deepMerge<T extends Record<string, unknown>>(
|
||||
base: T | undefined,
|
||||
override: T | undefined,
|
||||
depth = 0
|
||||
): T | undefined {
|
||||
if (!base && !override) return undefined;
|
||||
if (!base) return override;
|
||||
if (!override) return base;
|
||||
if (depth > MAX_DEPTH) return override ?? base;
|
||||
|
||||
const result = { ...base } as Record<string, unknown>;
|
||||
|
||||
for (const key of Object.keys(override)) {
|
||||
if (DANGEROUS_KEYS.has(key)) continue;
|
||||
|
||||
const baseValue = base[key];
|
||||
const overrideValue = override[key];
|
||||
|
||||
if (overrideValue === undefined) continue;
|
||||
|
||||
if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
|
||||
result[key] = deepMerge(baseValue, overrideValue, depth + 1);
|
||||
} else {
|
||||
result[key] = overrideValue;
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from "./snake-case"
|
||||
export * from "./tool-name"
|
||||
export * from "./pattern-matcher"
|
||||
export * from "./hook-disabled"
|
||||
export * from "./deep-merge"
|
||||
|
||||
41
src/tools/background-task/constants.ts
Normal file
41
src/tools/background-task/constants.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const BACKGROUND_TASK_DESCRIPTION = `Launch a background agent task that runs asynchronously.
|
||||
|
||||
The task runs in a separate session while you continue with other work. The system will notify you when the task completes.
|
||||
|
||||
Use this for:
|
||||
- Long-running research tasks
|
||||
- Complex analysis that doesn't need immediate results
|
||||
- Parallel workloads to maximize throughput
|
||||
|
||||
Arguments:
|
||||
- description: Short task description (shown in status)
|
||||
- prompt: Full detailed prompt for the agent
|
||||
- agent: Agent type to use (any agent allowed)
|
||||
|
||||
Returns immediately with task ID and session info. Use \`background_output\` to check progress or retrieve results.`
|
||||
|
||||
export const BACKGROUND_OUTPUT_DESCRIPTION = `Get output from a background task.
|
||||
|
||||
Arguments:
|
||||
- task_id: Required task ID to get output from
|
||||
- block: If true, wait for task completion. If false (default), return current status immediately.
|
||||
- timeout: Max wait time in ms when blocking (default: 60000, max: 600000)
|
||||
|
||||
Returns:
|
||||
- When not blocking: Returns current status with task ID, description, agent, status, duration, and progress info
|
||||
- When blocking: Waits for completion, then returns full result
|
||||
|
||||
IMPORTANT: The system automatically notifies the main session when background tasks complete.
|
||||
You typically don't need block=true - just use block=false to check status, and the system will notify you when done.
|
||||
|
||||
Use this to:
|
||||
- Check task progress (block=false) - returns full status info, NOT empty
|
||||
- Wait for and retrieve task result (block=true) - only when you explicitly need to wait
|
||||
- Set custom timeout for long tasks`
|
||||
|
||||
export const BACKGROUND_CANCEL_DESCRIPTION = `Cancel a running background task.
|
||||
|
||||
Only works for tasks with status "running". Aborts the background session and marks the task as cancelled.
|
||||
|
||||
Arguments:
|
||||
- taskId: Required task ID to cancel.`
|
||||
8
src/tools/background-task/index.ts
Normal file
8
src/tools/background-task/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createBackgroundTask,
|
||||
createBackgroundOutput,
|
||||
createBackgroundCancel,
|
||||
} from "./tools"
|
||||
|
||||
export type * from "./types"
|
||||
export * from "./constants"
|
||||
255
src/tools/background-task/tools.ts
Normal file
255
src/tools/background-task/tools.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { tool, type PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
|
||||
import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types"
|
||||
import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
function formatDuration(start: Date, end?: Date): string {
|
||||
const duration = (end ?? new Date()).getTime() - start.getTime()
|
||||
const seconds = Math.floor(duration / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
} else {
|
||||
return `${seconds}s`
|
||||
}
|
||||
}
|
||||
|
||||
export function createBackgroundTask(manager: BackgroundManager) {
|
||||
return tool({
|
||||
description: BACKGROUND_TASK_DESCRIPTION,
|
||||
args: {
|
||||
description: tool.schema.string().describe("Short task description (shown in status)"),
|
||||
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
|
||||
agent: tool.schema.string().describe("Agent type to use (any agent allowed)"),
|
||||
},
|
||||
async execute(args: BackgroundTaskArgs, toolContext) {
|
||||
try {
|
||||
const task = await manager.launch({
|
||||
description: args.description,
|
||||
prompt: args.prompt,
|
||||
agent: args.agent,
|
||||
parentSessionID: toolContext.sessionID,
|
||||
parentMessageID: toolContext.messageID,
|
||||
})
|
||||
|
||||
return `Background task launched successfully.
|
||||
|
||||
Task ID: ${task.id}
|
||||
Session ID: ${task.sessionID}
|
||||
Description: ${task.description}
|
||||
Agent: ${task.agent}
|
||||
Status: ${task.status}
|
||||
|
||||
The system will notify you when the task completes.
|
||||
Use \`background_output\` tool with task_id="${task.id}" to check progress:
|
||||
- block=false (default): Check status immediately - returns full status info
|
||||
- block=true: Wait for completion (rarely needed since system notifies)`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `❌ Failed to launch background task: ${message}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function formatTaskStatus(task: BackgroundTask): string {
|
||||
const duration = formatDuration(task.startedAt, task.completedAt)
|
||||
const progress = task.progress
|
||||
? `\nTool calls: ${task.progress.toolCalls}\nLast tool: ${task.progress.lastTool ?? "N/A"}`
|
||||
: ""
|
||||
|
||||
return `Task Status
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Agent: ${task.agent}
|
||||
Status: ${task.status}
|
||||
Duration: ${duration}${progress}
|
||||
|
||||
Session ID: ${task.sessionID}`
|
||||
}
|
||||
|
||||
async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): Promise<string> {
|
||||
const messagesResult = await client.session.messages({
|
||||
path: { id: task.sessionID },
|
||||
})
|
||||
|
||||
if (messagesResult.error) {
|
||||
return `Error fetching messages: ${messagesResult.error}`
|
||||
}
|
||||
|
||||
// Handle both SDK response structures: direct array or wrapped in .data
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messages = ((messagesResult as any).data ?? messagesResult) as Array<{
|
||||
info?: { role?: string }
|
||||
parts?: Array<{ type?: string; text?: string }>
|
||||
}>
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${formatDuration(task.startedAt, task.completedAt)}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No messages found)`
|
||||
}
|
||||
|
||||
const assistantMessages = messages.filter(
|
||||
(m) => m.info?.role === "assistant"
|
||||
)
|
||||
|
||||
if (assistantMessages.length === 0) {
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${formatDuration(task.startedAt, task.completedAt)}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No assistant response found)`
|
||||
}
|
||||
|
||||
const lastMessage = assistantMessages[assistantMessages.length - 1]
|
||||
const textParts = lastMessage?.parts?.filter(
|
||||
(p) => p.type === "text"
|
||||
) ?? []
|
||||
const textContent = textParts
|
||||
.map((p) => p.text ?? "")
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n")
|
||||
|
||||
const duration = formatDuration(task.startedAt, task.completedAt)
|
||||
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${duration}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
${textContent || "(No text output)"}`
|
||||
}
|
||||
|
||||
export function createBackgroundOutput(manager: BackgroundManager, client: OpencodeClient) {
|
||||
return tool({
|
||||
description: BACKGROUND_OUTPUT_DESCRIPTION,
|
||||
args: {
|
||||
task_id: tool.schema.string().describe("Task ID to get output from"),
|
||||
block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."),
|
||||
timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"),
|
||||
},
|
||||
async execute(args: BackgroundOutputArgs) {
|
||||
try {
|
||||
const task = manager.getTask(args.task_id)
|
||||
if (!task) {
|
||||
return `Task not found: ${args.task_id}`
|
||||
}
|
||||
|
||||
const shouldBlock = args.block === true
|
||||
const timeoutMs = Math.min(args.timeout ?? 60000, 600000)
|
||||
|
||||
// Non-blocking: return status immediately
|
||||
if (!shouldBlock) {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
// Already completed: return result immediately
|
||||
if (task.status === "completed") {
|
||||
return await formatTaskResult(task, client)
|
||||
}
|
||||
|
||||
// Error or cancelled: return status immediately
|
||||
if (task.status === "error" || task.status === "cancelled") {
|
||||
return formatTaskStatus(task)
|
||||
}
|
||||
|
||||
// Blocking: poll until completion or timeout
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
await delay(1000)
|
||||
|
||||
const currentTask = manager.getTask(args.task_id)
|
||||
if (!currentTask) {
|
||||
return `Task was deleted: ${args.task_id}`
|
||||
}
|
||||
|
||||
if (currentTask.status === "completed") {
|
||||
return await formatTaskResult(currentTask, client)
|
||||
}
|
||||
|
||||
if (currentTask.status === "error" || currentTask.status === "cancelled") {
|
||||
return formatTaskStatus(currentTask)
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout exceeded: return current status
|
||||
const finalTask = manager.getTask(args.task_id)
|
||||
if (!finalTask) {
|
||||
return `Task was deleted: ${args.task_id}`
|
||||
}
|
||||
return `Timeout exceeded (${timeoutMs}ms). Task still ${finalTask.status}.\n\n${formatTaskStatus(finalTask)}`
|
||||
} catch (error) {
|
||||
return `Error getting output: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createBackgroundCancel(manager: BackgroundManager, client: OpencodeClient) {
|
||||
return tool({
|
||||
description: BACKGROUND_CANCEL_DESCRIPTION,
|
||||
args: {
|
||||
taskId: tool.schema.string().describe("Task ID to cancel"),
|
||||
},
|
||||
async execute(args: BackgroundCancelArgs) {
|
||||
try {
|
||||
const task = manager.getTask(args.taskId)
|
||||
if (!task) {
|
||||
return `❌ Task not found: ${args.taskId}`
|
||||
}
|
||||
|
||||
if (task.status !== "running") {
|
||||
return `❌ Cannot cancel task: current status is "${task.status}".
|
||||
Only running tasks can be cancelled.`
|
||||
}
|
||||
|
||||
// Fire-and-forget: abort 요청을 보내고 await 하지 않음
|
||||
// await 하면 메인 세션까지 abort 되는 문제 발생
|
||||
client.session.abort({
|
||||
path: { id: task.sessionID },
|
||||
}).catch(() => {})
|
||||
|
||||
task.status = "cancelled"
|
||||
task.completedAt = new Date()
|
||||
|
||||
return `✅ Task cancelled successfully
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Session ID: ${task.sessionID}
|
||||
Status: ${task.status}`
|
||||
} catch (error) {
|
||||
return `❌ Error cancelling task: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
15
src/tools/background-task/types.ts
Normal file
15
src/tools/background-task/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface BackgroundTaskArgs {
|
||||
description: string
|
||||
prompt: string
|
||||
agent: string
|
||||
}
|
||||
|
||||
export interface BackgroundOutputArgs {
|
||||
task_id: string
|
||||
block?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface BackgroundCancelArgs {
|
||||
taskId: string
|
||||
}
|
||||
24
src/tools/call-omo-agent/constants.ts
Normal file
24
src/tools/call-omo-agent/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const ALLOWED_AGENTS = ["explore", "librarian"] as const
|
||||
|
||||
export const CALL_OMO_AGENT_DESCRIPTION = `Launch a new agent to handle complex, multi-step tasks autonomously.
|
||||
|
||||
This is a restricted version of the Task tool that only allows spawning explore and librarian agents.
|
||||
|
||||
Available agent types:
|
||||
{agents}
|
||||
|
||||
When using this tool, you must specify a subagent_type parameter to select which agent type to use.
|
||||
|
||||
**IMPORTANT: run_in_background parameter is REQUIRED**
|
||||
- \`run_in_background=true\`: Task runs asynchronously in background. Returns immediately with task_id.
|
||||
The system will notify you when the task completes.
|
||||
Use \`background_output\` tool with task_id to check progress (block=false returns full status info).
|
||||
- \`run_in_background=false\`: Task runs synchronously. Waits for completion and returns full result.
|
||||
|
||||
Usage notes:
|
||||
1. Launch multiple agents concurrently whenever possible, to maximize performance
|
||||
2. When the agent is done, it will return a single message back to you
|
||||
3. Each agent invocation is stateless unless you provide a session_id
|
||||
4. Your prompt should contain a highly detailed task description for the agent to perform autonomously
|
||||
5. Clearly tell the agent whether you expect it to write code or just to do research
|
||||
6. For long-running research tasks, use run_in_background=true to avoid blocking`
|
||||
3
src/tools/call-omo-agent/index.ts
Normal file
3
src/tools/call-omo-agent/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
export { createCallOmoAgent } from "./tools"
|
||||
167
src/tools/call-omo-agent/tools.ts
Normal file
167
src/tools/call-omo-agent/tools.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { tool, type PluginInput } from "@opencode-ai/plugin"
|
||||
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function createCallOmoAgent(
|
||||
ctx: PluginInput,
|
||||
backgroundManager: BackgroundManager
|
||||
) {
|
||||
const agentDescriptions = ALLOWED_AGENTS.map(
|
||||
(name) => `- ${name}: Specialized agent for ${name} tasks`
|
||||
).join("\n")
|
||||
const description = CALL_OMO_AGENT_DESCRIPTION.replace("{agents}", agentDescriptions)
|
||||
|
||||
return tool({
|
||||
description,
|
||||
args: {
|
||||
description: tool.schema.string().describe("A short (3-5 words) description of the task"),
|
||||
prompt: tool.schema.string().describe("The task for the agent to perform"),
|
||||
subagent_type: tool.schema
|
||||
.enum(ALLOWED_AGENTS)
|
||||
.describe("The type of specialized agent to use for this task (explore or librarian only)"),
|
||||
run_in_background: tool.schema
|
||||
.boolean()
|
||||
.describe("REQUIRED. true: run asynchronously (use background_output to get results), false: run synchronously and wait for completion"),
|
||||
session_id: tool.schema.string().describe("Existing Task session to continue").optional(),
|
||||
},
|
||||
async execute(args: CallOmoAgentArgs, toolContext) {
|
||||
log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`)
|
||||
|
||||
if (!ALLOWED_AGENTS.includes(args.subagent_type as typeof ALLOWED_AGENTS[number])) {
|
||||
return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.`
|
||||
}
|
||||
|
||||
if (args.run_in_background) {
|
||||
if (args.session_id) {
|
||||
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
|
||||
}
|
||||
return await executeBackground(args, toolContext, backgroundManager)
|
||||
}
|
||||
|
||||
return await executeSync(args, toolContext, ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function executeBackground(
|
||||
args: CallOmoAgentArgs,
|
||||
toolContext: { sessionID: string; messageID: string },
|
||||
manager: BackgroundManager
|
||||
): Promise<string> {
|
||||
try {
|
||||
const task = await manager.launch({
|
||||
description: args.description,
|
||||
prompt: args.prompt,
|
||||
agent: args.subagent_type,
|
||||
parentSessionID: toolContext.sessionID,
|
||||
parentMessageID: toolContext.messageID,
|
||||
})
|
||||
|
||||
return `Background agent task launched successfully.
|
||||
|
||||
Task ID: ${task.id}
|
||||
Session ID: ${task.sessionID}
|
||||
Description: ${task.description}
|
||||
Agent: ${task.agent} (subagent)
|
||||
Status: ${task.status}
|
||||
|
||||
Use \`background_output\` tool with task_id="${task.id}" to check progress or retrieve results.
|
||||
- block=false: Check status without waiting
|
||||
- block=true (default): Wait for completion and get result`
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return `Failed to launch background agent task: ${message}`
|
||||
}
|
||||
}
|
||||
|
||||
async function executeSync(
|
||||
args: CallOmoAgentArgs,
|
||||
toolContext: { sessionID: string },
|
||||
ctx: PluginInput
|
||||
): Promise<string> {
|
||||
let sessionID: string
|
||||
|
||||
if (args.session_id) {
|
||||
log(`[call_omo_agent] Using existing session: ${args.session_id}`)
|
||||
const sessionResult = await ctx.client.session.get({
|
||||
path: { id: args.session_id },
|
||||
})
|
||||
if (sessionResult.error) {
|
||||
log(`[call_omo_agent] Session get error:`, sessionResult.error)
|
||||
return `Error: Failed to get existing session: ${sessionResult.error}`
|
||||
}
|
||||
sessionID = args.session_id
|
||||
} else {
|
||||
log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`)
|
||||
const createResult = await ctx.client.session.create({
|
||||
body: {
|
||||
parentID: toolContext.sessionID,
|
||||
title: `${args.description} (@${args.subagent_type} subagent)`,
|
||||
},
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
log(`[call_omo_agent] Session create error:`, createResult.error)
|
||||
return `Error: Failed to create session: ${createResult.error}`
|
||||
}
|
||||
|
||||
sessionID = createResult.data.id
|
||||
log(`[call_omo_agent] Created session: ${sessionID}`)
|
||||
}
|
||||
|
||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: args.subagent_type,
|
||||
tools: {
|
||||
task: false,
|
||||
call_omo_agent: false,
|
||||
},
|
||||
parts: [{ type: "text", text: args.prompt }],
|
||||
},
|
||||
})
|
||||
|
||||
log(`[call_omo_agent] Prompt sent, fetching messages...`)
|
||||
|
||||
const messagesResult = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
if (messagesResult.error) {
|
||||
log(`[call_omo_agent] Messages error:`, messagesResult.error)
|
||||
return `Error: Failed to get messages: ${messagesResult.error}`
|
||||
}
|
||||
|
||||
const messages = messagesResult.data
|
||||
log(`[call_omo_agent] Got ${messages.length} messages`)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lastAssistantMessage = messages
|
||||
.filter((m: any) => m.info.role === "assistant")
|
||||
.sort((a: any, b: any) => (b.info.time?.created || 0) - (a.info.time?.created || 0))[0]
|
||||
|
||||
if (!lastAssistantMessage) {
|
||||
log(`[call_omo_agent] No assistant message found`)
|
||||
log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2))
|
||||
return `Error: No assistant response found\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
|
||||
log(`[call_omo_agent] Found assistant message with ${lastAssistantMessage.parts.length} parts`)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const textParts = lastAssistantMessage.parts.filter((p: any) => p.type === "text")
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const responseText = textParts.map((p: any) => p.text).join("\n")
|
||||
|
||||
log(`[call_omo_agent] Got response, length: ${responseText.length}`)
|
||||
|
||||
const output =
|
||||
responseText + "\n\n" + ["<task_metadata>", `session_id: ${sessionID}`, "</task_metadata>"].join("\n")
|
||||
|
||||
return output
|
||||
}
|
||||
27
src/tools/call-omo-agent/types.ts
Normal file
27
src/tools/call-omo-agent/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ALLOWED_AGENTS } from "./constants"
|
||||
|
||||
export type AllowedAgentType = (typeof ALLOWED_AGENTS)[number]
|
||||
|
||||
export interface CallOmoAgentArgs {
|
||||
description: string
|
||||
prompt: string
|
||||
subagent_type: string
|
||||
run_in_background: boolean
|
||||
session_id?: string
|
||||
}
|
||||
|
||||
export interface CallOmoAgentSyncResult {
|
||||
title: string
|
||||
metadata: {
|
||||
summary?: Array<{
|
||||
id: string
|
||||
tool: string
|
||||
state: {
|
||||
status: string
|
||||
title?: string
|
||||
}
|
||||
}>
|
||||
sessionId: string
|
||||
}
|
||||
output: string
|
||||
}
|
||||
@@ -22,6 +22,27 @@ import { glob } from "./glob"
|
||||
import { slashcommand } from "./slashcommand"
|
||||
import { skill } from "./skill"
|
||||
|
||||
import {
|
||||
createBackgroundTask,
|
||||
createBackgroundOutput,
|
||||
createBackgroundCancel,
|
||||
} from "./background-task"
|
||||
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
export { createCallOmoAgent } from "./call-omo-agent"
|
||||
|
||||
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient) {
|
||||
return {
|
||||
background_task: createBackgroundTask(manager),
|
||||
background_output: createBackgroundOutput(manager, client),
|
||||
background_cancel: createBackgroundCancel(manager, client),
|
||||
}
|
||||
}
|
||||
|
||||
export const builtinTools = {
|
||||
lsp_hover,
|
||||
lsp_goto_definition,
|
||||
|
||||
Reference in New Issue
Block a user