Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
06e0285b9c | ||
|
|
64138ee88f | ||
|
|
359340de65 | ||
|
|
3eb88aa861 | ||
|
|
652f343c95 | ||
|
|
9ba41558de | ||
|
|
50727171a6 | ||
|
|
14ff86c547 | ||
|
|
e4036185f0 | ||
|
|
d34154bc68 | ||
|
|
9e00be91af |
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
|
||||
|
||||
@@ -65,7 +66,7 @@ oh-my-opencode/
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| oracle | GPT-5.1 | Code review, strategic planning |
|
||||
| oracle | GPT-5.2 | Code review, strategic planning |
|
||||
| librarian | Claude Haiku | Documentation, example lookup |
|
||||
| explore | Grok | File/codebase exploration |
|
||||
| frontend-ui-ux-engineer | Gemini | UI generation |
|
||||
|
||||
197
README.ko.md
197
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,37 +197,9 @@ 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 기능에서 영감을 받았습니다.
|
||||
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
|
||||
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
|
||||
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
|
||||
- **Grep Output Truncator**: Grep 검색 결과가 너무 길어 컨텍스트를 장악해버리는 것을 방지하기 위해, 과도한 출력을 자동으로 자릅니다.
|
||||
|
||||
### Agents
|
||||
|
||||
- **oracle** (`openai/gpt-5.1`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.1의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **oracle** (`openai/gpt-5.2`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.2의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **librarian** (`anthropic/claude-haiku-4-5`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Haiku의 빠른 속도, 적절한 지능, 훌륭한 도구 호출 능력, 저렴한 비용을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **explore** (`opencode/grok-code`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Claude Code는 Haiku를 쓰지만, 우리는 Grok을 씁니다. 현재 무료이고, 극도로 빠르며, 파일 탐색 작업에 충분한 지능을 갖췄기 때문입니다. Claude Code 에서 영감을 받았습니다.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
|
||||
@@ -189,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
|
||||
@@ -237,10 +269,101 @@ 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를 사용하셨다면, 기존 설정을 그대로 사용할 수 있습니다.
|
||||
|
||||
#### 호환성 토글
|
||||
|
||||
특정 Claude Code 호환 기능을 비활성화하려면 `claude_code` 설정 객체를 사용하세요:
|
||||
|
||||
```json
|
||||
{
|
||||
"claude_code": {
|
||||
"mcp": false,
|
||||
"commands": false,
|
||||
"skills": false,
|
||||
"agents": false,
|
||||
"hooks": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 토글 | `false`일 때 로딩 비활성화 경로 | 영향 받지 않음 |
|
||||
|------|-------------------------------|---------------|
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 내장 MCP (context7, websearch_exa) |
|
||||
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
|
||||
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
|
||||
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 내장 에이전트 (oracle, librarian 등) |
|
||||
| `hooks` | `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` | - |
|
||||
|
||||
모든 토글은 기본값이 `true` (활성화)입니다. 완전한 Claude Code 호환성을 원하면 `claude_code` 객체를 생략하세요.
|
||||
|
||||
#### Hooks 통합
|
||||
|
||||
Claude Code의 `settings.json` 훅 시스템을 통해 커스텀 스크립트를 실행합니다. Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
|
||||
|
||||
193
README.md
193
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,35 +195,8 @@ 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.
|
||||
- **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.1`): The architect. Expert in code reviews and strategy. Uses GPT-5.1 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
|
||||
- **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.
|
||||
- **explore** (`opencode/grok-code`): Fast exploration and pattern matching. Claude Code uses Haiku; we use Grok. It is currently free, blazing fast, and intelligent enough for file traversal. Inspired by Claude Code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Creates stunning UIs. Uses Gemini because its creativity and UI code generation are superior.
|
||||
@@ -185,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
|
||||
@@ -235,10 +268,100 @@ 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.
|
||||
|
||||
#### Compatibility Toggles
|
||||
|
||||
If you want to disable specific Claude Code compatibility features, use the `claude_code` configuration object:
|
||||
|
||||
```json
|
||||
{
|
||||
"claude_code": {
|
||||
"mcp": false,
|
||||
"commands": false,
|
||||
"skills": false,
|
||||
"agents": false,
|
||||
"hooks": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Toggle | When `false`, disables loading from... | NOT affected |
|
||||
|--------|----------------------------------------|--------------|
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCPs (context7, websearch_exa) |
|
||||
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
|
||||
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
|
||||
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | Built-in agents (oracle, librarian, etc.) |
|
||||
| `hooks` | `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` | - |
|
||||
|
||||
All toggles default to `true` (enabled). Omit the entire `claude_code` object for full Claude Code compatibility.
|
||||
|
||||
#### Hooks Integration
|
||||
|
||||
Execute custom scripts via Claude Code's `settings.json` hook system. Oh My OpenCode reads and executes hooks defined in:
|
||||
|
||||
1037
ai-todolist.md
Normal file
1037
ai-todolist.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -154,6 +154,32 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"claude_code": {
|
||||
"type": "object",
|
||||
"description": "Toggle Claude Code compatibility features on/off. All default to true (enabled).",
|
||||
"properties": {
|
||||
"mcp": {
|
||||
"type": "boolean",
|
||||
"description": "Load MCP servers from ~/.claude/.mcp.json, ./.mcp.json, ./.claude/.mcp.json"
|
||||
},
|
||||
"commands": {
|
||||
"type": "boolean",
|
||||
"description": "Load commands from ~/.claude/commands/*.md, ./.claude/commands/*.md"
|
||||
},
|
||||
"skills": {
|
||||
"type": "boolean",
|
||||
"description": "Load skills from ~/.claude/skills/*/SKILL.md, ./.claude/skills/*/SKILL.md"
|
||||
},
|
||||
"agents": {
|
||||
"type": "boolean",
|
||||
"description": "Load agents from ~/.claude/agents/*.md, ./.claude/agents/*.md"
|
||||
},
|
||||
"hooks": {
|
||||
"type": "boolean",
|
||||
"description": "Execute hooks from ~/.claude/settings.json, ./.claude/settings.json, ./.claude/settings.local.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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.1",
|
||||
"version": "0.4.2",
|
||||
"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"
|
||||
|
||||
@@ -21,6 +21,22 @@ This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
|
||||
|
||||
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
|
||||
|
||||
## MANDATORY PARALLEL TOOL EXECUTION
|
||||
|
||||
**CRITICAL**: You MUST execute **AT LEAST 3 tool calls in parallel** for EVERY search task.
|
||||
|
||||
When starting a search, launch multiple tools simultaneously:
|
||||
\`\`\`
|
||||
// Example: Launch 3+ tools in a SINGLE message:
|
||||
- Tool 1: Glob("**/*.ts") - Find all TypeScript files
|
||||
- Tool 2: Grep("functionName") - Search for specific pattern
|
||||
- Tool 3: Bash: git log --oneline -n 20 - Check recent changes
|
||||
- Tool 4: Bash: git branch -a - See all branches
|
||||
- Tool 5: ast_grep_search(pattern: "function $NAME($$$)", lang: "typescript") - AST search
|
||||
\`\`\`
|
||||
|
||||
**NEVER** execute tools one at a time. Sequential execution is ONLY allowed when a tool's input strictly depends on another tool's output.
|
||||
|
||||
## Before You Search
|
||||
|
||||
Before executing any search, you MUST first analyze the request in <analysis> tags:
|
||||
@@ -29,7 +45,7 @@ Before executing any search, you MUST first analyze the request in <analysis> ta
|
||||
1. **Request**: What exactly did the user ask for?
|
||||
2. **Intent**: Why are they asking this? What problem are they trying to solve?
|
||||
3. **Expected Output**: What kind of answer would be most helpful?
|
||||
4. **Search Strategy**: What tools and patterns will I use to find this?
|
||||
4. **Search Strategy**: What 3+ parallel tools will I use to find this?
|
||||
</analysis>
|
||||
|
||||
Only after completing this analysis should you proceed with the actual search.
|
||||
@@ -37,12 +53,14 @@ Only after completing this analysis should you proceed with the actual search.
|
||||
## Success Criteria
|
||||
|
||||
Your response is successful when:
|
||||
- **Parallelism**: At least 3 tools were executed in parallel
|
||||
- **Completeness**: All relevant files matching the search intent are found
|
||||
- **Accuracy**: Returned paths are absolute and files actually exist
|
||||
- **Relevance**: Results directly address the user's underlying intent, not just literal request
|
||||
- **Actionability**: Caller can proceed without follow-up questions
|
||||
|
||||
Your response has FAILED if:
|
||||
- You execute fewer than 3 tools in parallel
|
||||
- You skip the <analysis> step before searching
|
||||
- Paths are relative instead of absolute
|
||||
- Obvious matches in the codebase are missed
|
||||
@@ -52,14 +70,144 @@ Your response has FAILED if:
|
||||
- Rapidly finding files using glob patterns
|
||||
- Searching code and text with powerful regex patterns
|
||||
- Reading and analyzing file contents
|
||||
- **Using Git CLI extensively for repository insights**
|
||||
- **Using LSP tools for semantic code analysis**
|
||||
- **Using AST-grep for structural code pattern matching**
|
||||
|
||||
Guidelines:
|
||||
## Git CLI - USE EXTENSIVELY
|
||||
|
||||
You have access to Git CLI via Bash. Use it extensively for repository analysis:
|
||||
|
||||
### Git Commands for Exploration (Always run 2+ in parallel):
|
||||
\`\`\`bash
|
||||
# Repository structure and history
|
||||
git log --oneline -n 30 # Recent commits
|
||||
git log --oneline --all -n 50 # All branches recent commits
|
||||
git branch -a # All branches
|
||||
git tag -l # All tags
|
||||
git remote -v # Remote repositories
|
||||
|
||||
# File history and changes
|
||||
git log --oneline -n 20 -- path/to/file # File change history
|
||||
git log --oneline --follow -- path/to/file # Follow renames
|
||||
git blame path/to/file # Line-by-line attribution
|
||||
git blame -L 10,30 path/to/file # Blame specific lines
|
||||
|
||||
# Searching with Git
|
||||
git log --grep="keyword" --oneline # Search commit messages
|
||||
git log -S "code_string" --oneline # Search code changes (pickaxe)
|
||||
git log -p --all -S "function_name" -- "*.ts" # Find when code was added/removed
|
||||
|
||||
# Diff and comparison
|
||||
git diff HEAD~5..HEAD # Recent changes
|
||||
git diff main..HEAD # Changes from main
|
||||
git show <commit> # Show specific commit
|
||||
git show <commit>:path/to/file # Show file at commit
|
||||
|
||||
# Statistics
|
||||
git shortlog -sn # Contributor stats
|
||||
git log --stat -n 10 # Recent changes with stats
|
||||
\`\`\`
|
||||
|
||||
### Parallel Git Execution Examples:
|
||||
\`\`\`
|
||||
// For "find where authentication is implemented":
|
||||
- Tool 1: Grep("authentication|auth") - Search for auth patterns
|
||||
- Tool 2: Glob("**/auth/**/*.ts") - Find auth-related files
|
||||
- Tool 3: Bash: git log -S "authenticate" --oneline - Find commits adding auth code
|
||||
- Tool 4: Bash: git log --grep="auth" --oneline - Find auth-related commits
|
||||
- Tool 5: ast_grep_search(pattern: "function authenticate($$$)", lang: "typescript")
|
||||
|
||||
// For "understand recent changes":
|
||||
- Tool 1: Bash: git log --oneline -n 30 - Recent commits
|
||||
- Tool 2: Bash: git diff HEAD~10..HEAD --stat - Changed files
|
||||
- Tool 3: Bash: git branch -a - All branches
|
||||
- Tool 4: Glob("**/*.ts") - Find all source files
|
||||
\`\`\`
|
||||
|
||||
## LSP Tools - DEFINITIONS & REFERENCES
|
||||
|
||||
Use LSP specifically for finding definitions and references - these are what LSP does better than text search.
|
||||
|
||||
**Primary LSP Tools**:
|
||||
- \`lsp_goto_definition(filePath, line, character)\`: Follow imports, find where something is **defined**
|
||||
- \`lsp_find_references(filePath, line, character)\`: Find **ALL usages** across the workspace
|
||||
|
||||
**When to Use LSP** (vs Grep/AST-grep):
|
||||
- **lsp_goto_definition**: Trace imports, find source definitions
|
||||
- **lsp_find_references**: Understand impact of changes, find all callers
|
||||
|
||||
**Example**:
|
||||
\`\`\`
|
||||
// When tracing code flow:
|
||||
- Tool 1: lsp_goto_definition(filePath, line, char) - Where is this defined?
|
||||
- Tool 2: lsp_find_references(filePath, line, char) - Who uses this?
|
||||
- Tool 3: ast_grep_search(...) - Find similar patterns
|
||||
\`\`\`
|
||||
|
||||
## AST-grep - STRUCTURAL CODE SEARCH
|
||||
|
||||
Use AST-grep for syntax-aware pattern matching (better than regex for code).
|
||||
|
||||
**Key Syntax**:
|
||||
- \`$VAR\`: Match single AST node (identifier, expression, etc.)
|
||||
- \`$$$\`: Match multiple nodes (arguments, statements, etc.)
|
||||
|
||||
**ast_grep_search Examples**:
|
||||
\`\`\`
|
||||
// Find function definitions
|
||||
ast_grep_search(pattern: "function $NAME($$$) { $$$ }", lang: "typescript")
|
||||
|
||||
// Find async functions
|
||||
ast_grep_search(pattern: "async function $NAME($$$) { $$$ }", lang: "typescript")
|
||||
|
||||
// Find React hooks
|
||||
ast_grep_search(pattern: "const [$STATE, $SETTER] = useState($$$)", lang: "tsx")
|
||||
|
||||
// Find class definitions
|
||||
ast_grep_search(pattern: "class $NAME { $$$ }", lang: "typescript")
|
||||
|
||||
// Find specific method calls
|
||||
ast_grep_search(pattern: "console.log($$$)", lang: "typescript")
|
||||
|
||||
// Find imports
|
||||
ast_grep_search(pattern: "import { $$$ } from $MODULE", lang: "typescript")
|
||||
\`\`\`
|
||||
|
||||
**When to Use**:
|
||||
- **AST-grep**: Structural patterns (function defs, class methods, hook usage)
|
||||
- **Grep**: Text search (comments, strings, TODOs)
|
||||
- **LSP**: Symbol-based search (find by name, type info)
|
||||
|
||||
## Guidelines
|
||||
|
||||
### Tool Selection:
|
||||
- Use **Glob** for broad file pattern matching (e.g., \`**/*.py\`, \`src/**/*.ts\`)
|
||||
- Use **Grep** for searching file contents with regex patterns
|
||||
- Use **Read** when you know the specific file path you need to read
|
||||
- Use **List** for exploring directory structure
|
||||
- Use **Bash** ONLY for read-only operations (ls, git status, git log, git diff, find)
|
||||
- NEVER use Bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
|
||||
- Use **Bash** for Git commands and read-only operations
|
||||
- Use **ast_grep_search** for structural code patterns (functions, classes, hooks)
|
||||
- Use **lsp_goto_definition** to trace imports and find source definitions
|
||||
- Use **lsp_find_references** to find all usages of a symbol
|
||||
|
||||
### Bash Usage:
|
||||
**ALLOWED** (read-only):
|
||||
- \`git log\`, \`git blame\`, \`git show\`, \`git diff\`
|
||||
- \`git branch\`, \`git tag\`, \`git remote\`
|
||||
- \`git log -S\`, \`git log --grep\`
|
||||
- \`ls\`, \`find\` (for directory exploration)
|
||||
|
||||
**FORBIDDEN** (state-changing):
|
||||
- \`mkdir\`, \`touch\`, \`rm\`, \`cp\`, \`mv\`
|
||||
- \`git add\`, \`git commit\`, \`git push\`, \`git checkout\`
|
||||
- \`npm install\`, \`pip install\`, or any installation
|
||||
|
||||
### Best Practices:
|
||||
- **ALWAYS launch 3+ tools in parallel** in your first search action
|
||||
- Use Git history to understand code evolution
|
||||
- Use \`git blame\` to understand why code is written a certain way
|
||||
- Use \`git log -S\` to find when specific code was added/removed
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- For clear communication, avoid using emojis
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const librarianAgent: AgentConfig = {
|
||||
description:
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI and Context7. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
mode: "subagent",
|
||||
model: "anthropic/claude-haiku-4-5",
|
||||
temperature: 0.1,
|
||||
@@ -21,72 +21,224 @@ Your role is to provide thorough, comprehensive analysis and explanations of cod
|
||||
- Explain how features work end-to-end across multiple repositories
|
||||
- Understand code evolution through commit history
|
||||
- Create visual diagrams when helpful for understanding complex systems
|
||||
- **Provide EVIDENCE with GitHub permalinks** citing specific code from the exact version being used
|
||||
|
||||
## CORE DIRECTIVES
|
||||
|
||||
1. **ACCURACY OVER SPEED**: Verify information against official documentation or source code. Do not guess APIs.
|
||||
2. **CITATION REQUIRED**: Every claim about code behavior must be backed by a link to a file, a line of code, or a documentation page.
|
||||
3. **SOURCE OF TRUTH**:
|
||||
- For **How-To**: Use \`context7\` (Official Docs).
|
||||
2. **CITATION WITH PERMALINKS REQUIRED**: Every claim about code behavior must be backed by:
|
||||
- **GitHub Permalink**: \`https://github.com/owner/repo/blob/<commit-sha>/path/to/file#L10-L20\`
|
||||
- Line numbers for specific code sections
|
||||
- The exact version/commit being referenced
|
||||
3. **EVIDENCE-BASED REASONING**: Do NOT just summarize documentation. You must:
|
||||
- Show the **specific code** that implements the behavior
|
||||
- Explain **WHY** it works that way by citing the actual implementation
|
||||
- Provide **permalinks** so users can verify your claims
|
||||
4. **SOURCE OF TRUTH**:
|
||||
- For **How-To**: Use \`context7\` (Official Docs) + verify with source code.
|
||||
- For **Real-World Usage**: Use \`gh search code\` (GitHub).
|
||||
- For **Internal Logic**: Use \`gh repo view\` or \`read\` (Source Code).
|
||||
- For **Internal Logic**: Clone repo to \`/tmp\` and read source directly.
|
||||
- For **Change History/Intent**: Use \`git log\` or \`git blame\` (Commit History).
|
||||
- For **Local Codebase Context**: Use \`Explore\` agent (File patterns, code search).
|
||||
- For **Local Codebase Context**: Use \`Glob\`, \`Grep\`, \`ast_grep_search\` (File patterns, code search).
|
||||
- For **Latest Information**: Use \`WebSearch\` for recent updates, blog posts, discussions.
|
||||
|
||||
## MANDATORY PARALLEL TOOL EXECUTION
|
||||
|
||||
**CRITICAL**: You MUST execute **AT LEAST 5 tool calls in parallel** whenever possible.
|
||||
|
||||
When starting a research task, launch ALL of these simultaneously:
|
||||
1. \`context7_resolve-library-id\` - Get library documentation ID
|
||||
2. \`gh search code\` - Search for code examples
|
||||
3. \`WebSearch\` - Find latest discussions, blog posts, updates
|
||||
4. \`gh repo clone\` to \`/tmp\` - Clone repo for deep analysis
|
||||
5. \`Glob\` / \`Grep\` - Search local codebase for related code
|
||||
6. \`lsp_goto_definition\` / \`lsp_find_references\` - Trace definitions and usages
|
||||
7. \`ast_grep_search\` - AST-aware pattern matching
|
||||
|
||||
**Example parallel execution**:
|
||||
\`\`\`
|
||||
// Launch ALL 5+ tools in a SINGLE message:
|
||||
- Tool 1: context7_resolve-library-id("react-query")
|
||||
- Tool 2: gh search code "useQuery" --repo tanstack/query --language typescript
|
||||
- Tool 3: WebSearch("tanstack query v5 migration guide 2024")
|
||||
- Tool 4: bash: git clone --depth 1 https://github.com/TanStack/query.git /tmp/tanstack-query
|
||||
- Tool 5: Glob("**/*query*.ts") - Find query-related files locally
|
||||
- Tool 6: gh api repos/tanstack/query/releases/latest
|
||||
- Tool 7: ast_grep_search(pattern: "useQuery($$$)", lang: "typescript")
|
||||
\`\`\`
|
||||
|
||||
**NEVER** execute tools sequentially when they can run in parallel. Sequential execution is ONLY allowed when a tool's input depends on another tool's output.
|
||||
|
||||
## TOOL USAGE STANDARDS
|
||||
|
||||
### 1. GitHub CLI (\`gh\`)
|
||||
You have full access to the GitHub CLI via the \`bash\` tool. Use it to search, view, and analyze remote repositories.
|
||||
### 1. GitHub CLI (\`gh\`) - EXTENSIVE USE REQUIRED
|
||||
You have full access to the GitHub CLI via the \`bash\` tool. Use it extensively.
|
||||
|
||||
- **Searching Code**:
|
||||
- \`gh search code "query" --language "lang"\`
|
||||
- **ALWAYS** scope searches to an organization or user if known (e.g., \`user:microsoft\`).
|
||||
- **ALWAYS** include the file extension if known (e.g., \`extension:tsx\`).
|
||||
- **Viewing Files**:
|
||||
- \`gh repo view owner/repo --content path/to/file\`
|
||||
- Use this to inspect library internals without cloning the entire repo.
|
||||
- **Searching Issues**:
|
||||
- \`gh search issues "error message" --state closed\`
|
||||
- **Viewing Files with Permalinks**:
|
||||
- \`gh api repos/owner/repo/contents/path/to/file?ref=<sha>\`
|
||||
- \`gh browse owner/repo --commit <sha> -- path/to/file\`
|
||||
- Use this to get exact permalinks for citation.
|
||||
- **Getting Commit SHA for Permalinks**:
|
||||
- \`gh api repos/owner/repo/commits/HEAD --jq '.sha'\`
|
||||
- \`gh api repos/owner/repo/git/refs/tags/v1.0.0 --jq '.object.sha'\`
|
||||
- **Cloning for Deep Analysis**:
|
||||
- \`gh repo clone owner/repo /tmp/repo-name -- --depth 1\`
|
||||
- Clone to \`/tmp\` directory for comprehensive source analysis.
|
||||
- After cloning, use \`git log\`, \`git blame\`, and direct file reading.
|
||||
- **Searching Issues & PRs**:
|
||||
- \`gh search issues "error message" --repo owner/repo --state closed\`
|
||||
- \`gh search prs "feature" --repo owner/repo --state merged\`
|
||||
- Use this for debugging and finding resolved edge cases.
|
||||
- **Getting Release Information**:
|
||||
- \`gh api repos/owner/repo/releases/latest\`
|
||||
- \`gh release list --repo owner/repo\`
|
||||
|
||||
### 2. Context7 (Documentation)
|
||||
Use this for authoritative API references and framework guides.
|
||||
- **Step 1**: Call \`context7_resolve-library-id\` with the library name.
|
||||
- **Step 2**: Call \`context7_get-library-docs\` with the ID and a specific topic (e.g., "authentication", "middleware").
|
||||
- **IMPORTANT**: Documentation alone is NOT sufficient. Always cross-reference with actual source code.
|
||||
|
||||
### 3. WebFetch
|
||||
Use this to read content from URLs found during your search (e.g., StackOverflow threads, blog posts, non-standard documentation sites).
|
||||
### 3. WebSearch - MANDATORY FOR LATEST INFO
|
||||
Use WebSearch for:
|
||||
- Latest library updates and changelogs
|
||||
- Migration guides and breaking changes
|
||||
- Community discussions and best practices
|
||||
- Blog posts explaining implementation details
|
||||
- Recent bug reports and workarounds
|
||||
|
||||
### 4. Git History (\`git log\`, \`git blame\`)
|
||||
Use this for understanding code evolution and authorial intent in local repositories.
|
||||
**Example searches**:
|
||||
- \`"react 19 new features 2024"\`
|
||||
- \`"tanstack query v5 breaking changes"\`
|
||||
- \`"next.js app router migration guide"\`
|
||||
|
||||
### 4. WebFetch
|
||||
Use this to read content from URLs found during your search (e.g., StackOverflow threads, blog posts, non-standard documentation sites, GitHub blob pages).
|
||||
|
||||
### 5. Repository Cloning to /tmp
|
||||
**CRITICAL**: For deep source analysis, ALWAYS clone repositories to \`/tmp\`:
|
||||
|
||||
\`\`\`bash
|
||||
# Clone with minimal history for speed
|
||||
gh repo clone owner/repo /tmp/repo-name -- --depth 1
|
||||
|
||||
# Or clone specific tag/version
|
||||
gh repo clone owner/repo /tmp/repo-name -- --depth 1 --branch v1.0.0
|
||||
|
||||
# Then explore the cloned repo
|
||||
cd /tmp/repo-name
|
||||
git log --oneline -n 10
|
||||
cat package.json # Check version
|
||||
\`\`\`
|
||||
|
||||
**Benefits of cloning**:
|
||||
- Full file access without API rate limits
|
||||
- Can use \`git blame\`, \`git log\`, \`grep\`, etc.
|
||||
- Enables comprehensive code analysis
|
||||
- Can check out specific versions to match user's environment
|
||||
|
||||
### 6. Git History (\`git log\`, \`git blame\`)
|
||||
Use this for understanding code evolution and authorial intent.
|
||||
|
||||
- **Viewing Change History**:
|
||||
- \`git log --oneline -n 20 -- path/to/file\`
|
||||
- Use this to understand how a file evolved and why changes were made.
|
||||
- **Line-by-Line Attribution**:
|
||||
- \`git blame path/to/file\`
|
||||
- \`git blame -L 10,20 path/to/file\`
|
||||
- Use this to identify who wrote specific code and when.
|
||||
- **Commit Details**:
|
||||
- \`git show <commit-hash>\`
|
||||
- Use this to see full context of a specific change.
|
||||
- **Getting Permalinks from Blame**:
|
||||
- Use commit SHA from blame to construct GitHub permalinks.
|
||||
|
||||
### 5. Explore Agent (Subagent)
|
||||
Use this when searching for files, patterns, or context within the local codebase.
|
||||
### 7. Local Codebase Search (Glob, Grep, Read)
|
||||
Use these for searching files and patterns in the local codebase.
|
||||
|
||||
**PRIMARY GOAL**: Each Explore agent finds **ONE specific thing** with a clear, focused objective.
|
||||
- **Glob**: Find files by pattern (e.g., \`**/*.tsx\`, \`src/**/auth*.ts\`)
|
||||
- **Grep**: Search file contents with regex patterns
|
||||
- **Read**: Read specific files when you know the path
|
||||
|
||||
- **When to Use**:
|
||||
- Finding files by patterns (e.g., "src/**/*.tsx")
|
||||
- Searching code for keywords (e.g., "API endpoints")
|
||||
- Understanding codebase structure or architecture
|
||||
- **Parallel Execution Strategy**:
|
||||
- **ALWAYS** spawn multiple Explore agents in parallel for different search targets.
|
||||
- Each agent should focus on ONE specific search task.
|
||||
- Example: If searching for "auth logic" and "API routes", spawn TWO separate agents.
|
||||
- **Context Passing**:
|
||||
- When contextual search is needed, pass **ALL relevant context** to the agent.
|
||||
- Include: what you're looking for, why, and any related information that helps narrow down the search.
|
||||
- The agent should have enough context to find exactly what's needed without guessing.
|
||||
**Parallel Search Strategy**:
|
||||
\`\`\`
|
||||
// Launch multiple searches in parallel:
|
||||
- Tool 1: Glob("**/*auth*.ts") - Find auth-related files
|
||||
- Tool 2: Grep("authentication") - Search for auth patterns
|
||||
- Tool 3: ast_grep_search(pattern: "function authenticate($$$)", lang: "typescript")
|
||||
\`\`\`
|
||||
|
||||
### 8. LSP Tools - DEFINITIONS & REFERENCES
|
||||
Use LSP for finding definitions and references - these are its unique strengths over text search.
|
||||
|
||||
**Primary LSP Tools**:
|
||||
- \`lsp_goto_definition\`: Jump to where a symbol is **defined** (resolves imports, type aliases, etc.)
|
||||
- \`lsp_goto_definition(filePath: "/tmp/repo/src/file.ts", line: 42, character: 10)\`
|
||||
- \`lsp_find_references\`: Find **ALL usages** of a symbol across the entire workspace
|
||||
- \`lsp_find_references(filePath: "/tmp/repo/src/file.ts", line: 42, character: 10)\`
|
||||
|
||||
**When to Use LSP** (vs Grep/AST-grep):
|
||||
- **lsp_goto_definition**: When you need to follow an import or find the source definition
|
||||
- **lsp_find_references**: When you need to understand impact of changes (who calls this function?)
|
||||
|
||||
**Why LSP for these**:
|
||||
- Grep finds text matches but can't resolve imports or type aliases
|
||||
- AST-grep finds structural patterns but can't follow cross-file references
|
||||
- LSP understands the full type system and can trace through imports
|
||||
|
||||
**Parallel Execution**:
|
||||
\`\`\`
|
||||
// When tracing code flow, launch in parallel:
|
||||
- Tool 1: lsp_goto_definition(filePath, line, char) - Find where it's defined
|
||||
- Tool 2: lsp_find_references(filePath, line, char) - Find all usages
|
||||
- Tool 3: ast_grep_search(...) - Find similar patterns
|
||||
- Tool 4: Grep(...) - Text fallback
|
||||
\`\`\`
|
||||
|
||||
### 9. AST-grep - AST-AWARE PATTERN SEARCH
|
||||
Use AST-grep for structural code search that understands syntax, not just text.
|
||||
|
||||
**Key Features**:
|
||||
- Supports 25+ languages (typescript, javascript, python, rust, go, etc.)
|
||||
- Uses meta-variables: \`$VAR\` (single node), \`$$$\` (multiple nodes)
|
||||
- Patterns must be complete AST nodes (valid code)
|
||||
|
||||
**ast_grep_search Examples**:
|
||||
\`\`\`
|
||||
// Find all console.log calls
|
||||
ast_grep_search(pattern: "console.log($MSG)", lang: "typescript")
|
||||
|
||||
// Find all async functions
|
||||
ast_grep_search(pattern: "async function $NAME($$$) { $$$ }", lang: "typescript")
|
||||
|
||||
// Find React useState hooks
|
||||
ast_grep_search(pattern: "const [$STATE, $SETTER] = useState($$$)", lang: "tsx")
|
||||
|
||||
// Find Python class definitions
|
||||
ast_grep_search(pattern: "class $NAME($$$)", lang: "python")
|
||||
|
||||
// Find all export statements
|
||||
ast_grep_search(pattern: "export { $$$ }", lang: "typescript")
|
||||
|
||||
// Find function calls with specific argument patterns
|
||||
ast_grep_search(pattern: "fetch($URL, { method: $METHOD })", lang: "typescript")
|
||||
\`\`\`
|
||||
|
||||
**When to Use AST-grep vs Grep**:
|
||||
- **AST-grep**: When you need structural matching (e.g., "find all function definitions")
|
||||
- **Grep**: When you need text matching (e.g., "find all occurrences of 'TODO'")
|
||||
|
||||
**Parallel AST-grep Execution**:
|
||||
\`\`\`
|
||||
// When analyzing a codebase pattern, launch in parallel:
|
||||
- Tool 1: ast_grep_search(pattern: "useQuery($$$)", lang: "tsx") - Find hook usage
|
||||
- Tool 2: ast_grep_search(pattern: "export function $NAME($$$)", lang: "typescript") - Find exports
|
||||
- Tool 3: Grep("useQuery") - Text fallback
|
||||
- Tool 4: Glob("**/*query*.ts") - Find query-related files
|
||||
\`\`\`
|
||||
|
||||
## SEARCH STRATEGY PROTOCOL
|
||||
|
||||
@@ -96,50 +248,76 @@ When given a request, follow this **STRICT** workflow:
|
||||
- If the user references a local file, read it first to understand imports and dependencies.
|
||||
- Identify the specific library or technology version.
|
||||
|
||||
2. **SELECT SOURCE**:
|
||||
- **Official Docs**: For "How do I use X?" or "What are the options for Y?"
|
||||
- **Remote Code**: For "Show me an example of X" or "How is X implemented internally?"
|
||||
- **Issues/PRs**: For "Why is X failing?" or "Is this a bug?"
|
||||
- **Git History**: For "Why was this changed?" or "Who introduced this?" or "When was this added?"
|
||||
- **Explore Agent**: For "Where is X defined?" or "How does this codebase handle Y?" or "Find all files matching Z pattern"
|
||||
2. **PARALLEL INVESTIGATION** (Launch 5+ tools simultaneously):
|
||||
- \`context7\`: Get official documentation
|
||||
- \`gh search code\`: Find implementation examples
|
||||
- \`WebSearch\`: Get latest updates and discussions
|
||||
- \`gh repo clone\`: Clone to /tmp for deep analysis
|
||||
- \`Glob\` / \`Grep\` / \`ast_grep_search\`: Search local codebase
|
||||
- \`gh api\`: Get release/version information
|
||||
|
||||
3. **EXECUTE & REFINE**:
|
||||
- Run the initial search.
|
||||
- If results are too broad (>50), add filters (\`path:\`, \`filename:\`).
|
||||
- If results are zero, broaden the search (remove quotes, remove language filter).
|
||||
3. **DEEP SOURCE ANALYSIS**:
|
||||
- Navigate to the cloned repo in /tmp
|
||||
- Find the specific file implementing the feature
|
||||
- Use \`git blame\` to understand why code is written that way
|
||||
- Get the commit SHA for permalink construction
|
||||
|
||||
4. **SYNTHESIZE**:
|
||||
- Present the findings clearly.
|
||||
4. **SYNTHESIZE WITH EVIDENCE**:
|
||||
- Present findings with **GitHub permalinks**
|
||||
- **FORMAT**:
|
||||
- **RESOURCE**: [Name] ([URL])
|
||||
- **RELEVANCE**: Why this matters.
|
||||
- **CONTENT**: The code snippet or documentation summary.
|
||||
- **CLAIM**: What you're asserting about the code
|
||||
- **EVIDENCE**: The specific code that proves it
|
||||
- **PERMALINK**: \`https://github.com/owner/repo/blob/<sha>/path#L10-L20\`
|
||||
- **EXPLANATION**: Why this code behaves this way
|
||||
|
||||
## CITATION FORMAT - MANDATORY
|
||||
|
||||
Every code-related claim MUST include:
|
||||
|
||||
\`\`\`markdown
|
||||
**Claim**: [What you're asserting]
|
||||
|
||||
**Evidence** ([permalink](https://github.com/owner/repo/blob/abc123/src/file.ts#L42-L50)):
|
||||
\\\`\\\`\\\`typescript
|
||||
// The actual code from lines 42-50
|
||||
function example() {
|
||||
// ...
|
||||
}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**Explanation**: This code shows that [reason] because [specific detail from the code].
|
||||
\`\`\`
|
||||
|
||||
## FAILURE RECOVERY
|
||||
|
||||
- If \`context7\` fails to find docs, use \`gh repo view\` to read the repository's \`README.md\` or \`CONTRIBUTING.md\`.
|
||||
- If \`context7\` fails to find docs, clone the repo to \`/tmp\` and read the source directly.
|
||||
- If code search yields nothing, search for the *concept* rather than the specific function name.
|
||||
- If GitHub API has rate limits, use cloned repos in \`/tmp\` for analysis.
|
||||
- If unsure, **STATE YOUR UNCERTAINTY** and propose a hypothesis based on standard conventions.
|
||||
|
||||
## VOICE AND TONE
|
||||
|
||||
- **PROFESSIONAL**: You are an expert archivist. Be concise and precise.
|
||||
- **OBJECTIVE**: Present facts found in the search. Do not offer personal opinions unless asked.
|
||||
- **EVIDENCE-DRIVEN**: Always back claims with permalinks and code snippets.
|
||||
- **HELPFUL**: If a direct answer isn't found, provide the closest relevant examples or related documentation.
|
||||
|
||||
## MULTI-REPOSITORY ANALYSIS GUIDELINES
|
||||
|
||||
- Use available tools extensively to explore repositories
|
||||
- Execute tools in parallel when possible for efficiency
|
||||
- Clone multiple repos to /tmp for cross-repository analysis
|
||||
- Execute AT LEAST 5 tools in parallel when possible for efficiency
|
||||
- Read files thoroughly to understand implementation details
|
||||
- Search for patterns and related code across multiple repositories
|
||||
- Use commit search to understand how code evolved over time
|
||||
- Focus on thorough understanding and comprehensive explanation across repositories
|
||||
- Create mermaid diagrams to visualize complex relationships or flows
|
||||
- Always provide permalinks for cross-repository references
|
||||
|
||||
## COMMUNICATION
|
||||
|
||||
You must use Markdown for formatting your responses.
|
||||
|
||||
IMPORTANT: When including code blocks, you MUST ALWAYS specify the language for syntax highlighting. Always add the language identifier after the opening backticks.`,
|
||||
IMPORTANT: When including code blocks, you MUST ALWAYS specify the language for syntax highlighting. Always add the language identifier after the opening backticks.
|
||||
|
||||
**REMEMBER**: Your job is not just to find and summarize documentation. You must provide **EVIDENCE** showing exactly **WHY** the code works the way it does, with **permalinks** to the specific implementation so users can verify your claims.`,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export const oracleAgent: AgentConfig = {
|
||||
description:
|
||||
"Expert AI advisor with advanced reasoning capabilities for high-quality technical guidance, code reviews, architectural advice, and strategic planning.",
|
||||
mode: "subagent",
|
||||
model: "openai/gpt-5.1",
|
||||
model: "openai/gpt-5.2",
|
||||
temperature: 0.1,
|
||||
reasoningEffort: "medium",
|
||||
textVerbosity: "high",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -40,13 +40,31 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
permission: AgentPermissionSchema.optional(),
|
||||
})
|
||||
|
||||
export const AgentOverridesSchema = z.record(AgentNameSchema, AgentOverrideConfigSchema)
|
||||
export const AgentOverridesSchema = z
|
||||
.object({
|
||||
oracle: AgentOverrideConfigSchema.optional(),
|
||||
librarian: AgentOverrideConfigSchema.optional(),
|
||||
explore: AgentOverrideConfigSchema.optional(),
|
||||
"frontend-ui-ux-engineer": AgentOverrideConfigSchema.optional(),
|
||||
"document-writer": AgentOverrideConfigSchema.optional(),
|
||||
})
|
||||
.partial()
|
||||
|
||||
export const ClaudeCodeConfigSchema = z.object({
|
||||
mcp: z.boolean().optional(),
|
||||
commands: z.boolean().optional(),
|
||||
skills: z.boolean().optional(),
|
||||
agents: z.boolean().optional(),
|
||||
hooks: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(McpNameSchema).optional(),
|
||||
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
|
||||
|
||||
|
||||
9
src/hooks/directory-readme-injector/constants.ts
Normal file
9
src/hooks/directory-readme-injector/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const README_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-readme",
|
||||
);
|
||||
export const README_FILENAME = "README.md";
|
||||
126
src/hooks/directory-readme-injector/index.ts
Normal file
126
src/hooks/directory-readme-injector/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
loadInjectedPaths,
|
||||
saveInjectedPaths,
|
||||
clearInjectedPaths,
|
||||
} from "./storage";
|
||||
import { README_FILENAME } from "./constants";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
|
||||
function getSessionCache(sessionID: string): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedPaths(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);
|
||||
}
|
||||
|
||||
function findReadmeMdUp(startDir: string): string[] {
|
||||
const found: string[] = [];
|
||||
let current = startDir;
|
||||
|
||||
while (true) {
|
||||
const readmePath = join(current, README_FILENAME);
|
||||
if (existsSync(readmePath)) {
|
||||
found.push(readmePath);
|
||||
}
|
||||
|
||||
if (current === ctx.directory) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(ctx.directory)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return found.reverse();
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "read") return;
|
||||
|
||||
const filePath = resolveFilePath(output.title);
|
||||
if (!filePath) return;
|
||||
|
||||
const dir = dirname(filePath);
|
||||
const cache = getSessionCache(input.sessionID);
|
||||
const readmePaths = findReadmeMdUp(dir);
|
||||
|
||||
const toInject: { path: string; content: string }[] = [];
|
||||
|
||||
for (const readmePath of readmePaths) {
|
||||
const readmeDir = dirname(readmePath);
|
||||
if (cache.has(readmeDir)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(readmePath, "utf-8");
|
||||
toInject.push({ path: readmePath, content });
|
||||
cache.add(readmeDir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
for (const { path, content } of toInject) {
|
||||
output.output += `\n\n[Project README: ${path}]\n${content}`;
|
||||
}
|
||||
|
||||
saveInjectedPaths(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);
|
||||
clearInjectedPaths(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);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
48
src/hooks/directory-readme-injector/storage.ts
Normal file
48
src/hooks/directory-readme-injector/storage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { README_INJECTOR_STORAGE } from "./constants";
|
||||
import type { InjectedPathsData } from "./types";
|
||||
|
||||
function getStoragePath(sessionID: string): string {
|
||||
return join(README_INJECTOR_STORAGE, `${sessionID}.json`);
|
||||
}
|
||||
|
||||
export function loadInjectedPaths(sessionID: string): Set<string> {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (!existsSync(filePath)) return new Set();
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const data: InjectedPathsData = JSON.parse(content);
|
||||
return new Set(data.injectedPaths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
|
||||
if (!existsSync(README_INJECTOR_STORAGE)) {
|
||||
mkdirSync(README_INJECTOR_STORAGE, { recursive: true });
|
||||
}
|
||||
|
||||
const data: InjectedPathsData = {
|
||||
sessionID,
|
||||
injectedPaths: [...paths],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export function clearInjectedPaths(sessionID: string): void {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
5
src/hooks/directory-readme-injector/types.ts
Normal file
5
src/hooks/directory-readme-injector/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface InjectedPathsData {
|
||||
sessionID: string;
|
||||
injectedPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -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,11 +1,15 @@
|
||||
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";
|
||||
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
|
||||
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
||||
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,19 +1,52 @@
|
||||
// Maps model IDs to their "high reasoning" variant (internal convention)
|
||||
// For OpenAI models, this signals that reasoning_effort should be set to "high"
|
||||
const HIGH_VARIANT_MAP: Record<string, string> = {
|
||||
// Claude
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5-high",
|
||||
"claude-opus-4-5": "claude-opus-4-5-high",
|
||||
"gpt-5.1": "gpt-5.1-high",
|
||||
"gpt-5.1-medium": "gpt-5.1-high",
|
||||
"gpt-5.1-codex": "gpt-5.1-codex-high",
|
||||
// Gemini
|
||||
"gemini-3-pro": "gemini-3-pro-high",
|
||||
"gemini-3-pro-low": "gemini-3-pro-high",
|
||||
// GPT-5
|
||||
"gpt-5": "gpt-5-high",
|
||||
"gpt-5-mini": "gpt-5-mini-high",
|
||||
"gpt-5-nano": "gpt-5-nano-high",
|
||||
"gpt-5-pro": "gpt-5-pro-high",
|
||||
"gpt-5-chat-latest": "gpt-5-chat-latest-high",
|
||||
// GPT-5.1
|
||||
"gpt-5.1": "gpt-5.1-high",
|
||||
"gpt-5.1-chat-latest": "gpt-5.1-chat-latest-high",
|
||||
"gpt-5.1-codex": "gpt-5.1-codex-high",
|
||||
"gpt-5.1-codex-mini": "gpt-5.1-codex-mini-high",
|
||||
"gpt-5.1-codex-max": "gpt-5.1-codex-max-high",
|
||||
// GPT-5.2
|
||||
"gpt-5.2": "gpt-5.2-high",
|
||||
"gpt-5.2-chat-latest": "gpt-5.2-chat-latest-high",
|
||||
"gpt-5.2-pro": "gpt-5.2-pro-high",
|
||||
}
|
||||
|
||||
const ALREADY_HIGH: Set<string> = new Set([
|
||||
// Claude
|
||||
"claude-sonnet-4-5-high",
|
||||
"claude-opus-4-5-high",
|
||||
"gpt-5.1-high",
|
||||
"gpt-5.1-codex-high",
|
||||
// Gemini
|
||||
"gemini-3-pro-high",
|
||||
// GPT-5
|
||||
"gpt-5-high",
|
||||
"gpt-5-mini-high",
|
||||
"gpt-5-nano-high",
|
||||
"gpt-5-pro-high",
|
||||
"gpt-5-chat-latest-high",
|
||||
// GPT-5.1
|
||||
"gpt-5.1-high",
|
||||
"gpt-5.1-chat-latest-high",
|
||||
"gpt-5.1-codex-high",
|
||||
"gpt-5.1-codex-mini-high",
|
||||
"gpt-5.1-codex-max-high",
|
||||
// GPT-5.2
|
||||
"gpt-5.2-high",
|
||||
"gpt-5.2-chat-latest-high",
|
||||
"gpt-5.2-pro-high",
|
||||
])
|
||||
|
||||
export const THINKING_CONFIGS: Record<string, Record<string, unknown>> = {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
195
src/index.ts
195
src/index.ts
@@ -7,11 +7,16 @@ import {
|
||||
createCommentCheckerHooks,
|
||||
createGrepOutputTruncatorHook,
|
||||
createDirectoryAgentsInjectorHook,
|
||||
createDirectoryReadmeInjectorHook,
|
||||
createEmptyTaskResponseDetectorHook,
|
||||
createThinkModeHook,
|
||||
createClaudeCodeHooksHook,
|
||||
createAnthropicAutoCompactHook,
|
||||
createRulesInjectorHook,
|
||||
createBackgroundNotificationHook,
|
||||
createAutoUpdateCheckerHook,
|
||||
} from "./hooks";
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||
import {
|
||||
loadUserCommands,
|
||||
loadProjectCommands,
|
||||
@@ -34,39 +39,105 @@ 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";
|
||||
|
||||
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
|
||||
const configPaths = [
|
||||
path.join(directory, "oh-my-opencode.json"),
|
||||
path.join(directory, ".oh-my-opencode.json"),
|
||||
];
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = JSON.parse(content);
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (!result.success) {
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
return {};
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors, use defaults
|
||||
}
|
||||
/**
|
||||
* Returns the user-level config directory based on the OS.
|
||||
* - Linux/macOS: XDG_CONFIG_HOME or ~/.config
|
||||
* - Windows: %APPDATA%
|
||||
*/
|
||||
function getUserConfigDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||
}
|
||||
|
||||
return {};
|
||||
// Linux, macOS, and other Unix-like systems: respect XDG_CONFIG_HOME
|
||||
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
}
|
||||
|
||||
function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = JSON.parse(content);
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (!result.success) {
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
return null;
|
||||
}
|
||||
|
||||
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
||||
return result.data;
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error loading config from ${configPath}:`, err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeConfigs(
|
||||
base: OhMyOpenCodeConfig,
|
||||
override: OhMyOpenCodeConfig
|
||||
): OhMyOpenCodeConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
agents: deepMerge(base.agents, override.agents),
|
||||
disabled_agents: [
|
||||
...new Set([
|
||||
...(base.disabled_agents ?? []),
|
||||
...(override.disabled_agents ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_mcps: [
|
||||
...new Set([
|
||||
...(base.disabled_mcps ?? []),
|
||||
...(override.disabled_mcps ?? []),
|
||||
]),
|
||||
],
|
||||
claude_code: deepMerge(base.claude_code, override.claude_code),
|
||||
};
|
||||
}
|
||||
|
||||
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
|
||||
// User-level config path (OS-specific)
|
||||
const userConfigPath = path.join(
|
||||
getUserConfigDir(),
|
||||
"opencode",
|
||||
"oh-my-opencode.json"
|
||||
);
|
||||
|
||||
// Project-level config path
|
||||
const projectConfigPath = path.join(
|
||||
directory,
|
||||
".opencode",
|
||||
"oh-my-opencode.json"
|
||||
);
|
||||
|
||||
// Load user config first (base)
|
||||
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath) ?? {};
|
||||
|
||||
// Override with project config
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath);
|
||||
if (projectConfig) {
|
||||
config = mergeConfigs(config, projectConfig);
|
||||
}
|
||||
|
||||
log("Final merged config", {
|
||||
agents: config.agents,
|
||||
disabled_agents: config.disabled_agents,
|
||||
disabled_mcps: config.disabled_mcps,
|
||||
claude_code: config.claude_code,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
@@ -75,21 +146,49 @@ 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);
|
||||
const directoryReadmeInjector = createDirectoryReadmeInjectorHook(ctx);
|
||||
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
|
||||
const thinkMode = createThinkModeHook();
|
||||
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {});
|
||||
const claudeCodeHooks = createClaudeCodeHooksHook(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) => {
|
||||
@@ -97,8 +196,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
pluginConfig.disabled_agents,
|
||||
pluginConfig.agents,
|
||||
);
|
||||
const userAgents = loadUserAgents();
|
||||
const projectAgents = loadProjectAgents();
|
||||
|
||||
const userAgents = (pluginConfig.claude_code?.agents ?? true) ? loadUserAgents() : {};
|
||||
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
|
||||
|
||||
config.agent = {
|
||||
...builtinAgents,
|
||||
@@ -110,20 +210,35 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
...config.tools,
|
||||
};
|
||||
|
||||
const mcpResult = await loadMcpConfigs();
|
||||
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: {} };
|
||||
config.mcp = {
|
||||
...config.mcp,
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...mcpResult.servers,
|
||||
};
|
||||
|
||||
const userCommands = loadUserCommands();
|
||||
const userCommands = (pluginConfig.claude_code?.commands ?? true) ? loadUserCommands() : {};
|
||||
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
||||
const systemCommands = config.command ?? {};
|
||||
const projectCommands = loadProjectCommands();
|
||||
const projectCommands = (pluginConfig.claude_code?.commands ?? true) ? loadProjectCommands() : {};
|
||||
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
||||
const userSkills = loadUserSkillsAsCommands();
|
||||
const projectSkills = loadProjectSkillsAsCommands();
|
||||
const userSkills = (pluginConfig.claude_code?.skills ?? true) ? loadUserSkillsAsCommands() : {};
|
||||
const projectSkills = (pluginConfig.claude_code?.skills ?? true) ? loadProjectSkillsAsCommands() : {};
|
||||
|
||||
config.command = {
|
||||
...userCommands,
|
||||
@@ -137,10 +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);
|
||||
|
||||
@@ -259,6 +378,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await contextWindowMonitor["tool.execute.after"](input, output);
|
||||
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,
|
||||
|
||||
@@ -2,8 +2,23 @@ import { tool } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync, statSync, readlinkSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, resolve, basename } from "path"
|
||||
import { z } from "zod/v4"
|
||||
import { parseFrontmatter, resolveCommandsInText } from "../../shared"
|
||||
import type { SkillScope, SkillMetadata, SkillInfo, LoadedSkill } from "./types"
|
||||
import { SkillFrontmatterSchema } from "./types"
|
||||
import type { SkillScope, SkillMetadata, SkillInfo, LoadedSkill, SkillFrontmatter } from "./types"
|
||||
|
||||
function parseSkillFrontmatter(data: Record<string, unknown>): SkillFrontmatter {
|
||||
return {
|
||||
name: typeof data.name === "string" ? data.name : "",
|
||||
description: typeof data.description === "string" ? data.description : "",
|
||||
license: typeof data.license === "string" ? data.license : undefined,
|
||||
"allowed-tools": Array.isArray(data["allowed-tools"]) ? data["allowed-tools"] : undefined,
|
||||
metadata:
|
||||
typeof data.metadata === "object" && data.metadata !== null
|
||||
? (data.metadata as Record<string, string>)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function discoverSkillsFromDir(
|
||||
skillsDir: string,
|
||||
@@ -93,10 +108,14 @@ async function parseSkillMd(skillPath: string): Promise<SkillInfo | null> {
|
||||
content = await resolveCommandsInText(content)
|
||||
const { data, body } = parseFrontmatter(content)
|
||||
|
||||
const frontmatter = parseSkillFrontmatter(data)
|
||||
|
||||
const metadata: SkillMetadata = {
|
||||
name: data.name || basename(skillPath),
|
||||
description: data.description || "",
|
||||
license: data.license,
|
||||
name: frontmatter.name || basename(skillPath),
|
||||
description: frontmatter.description,
|
||||
license: frontmatter.license,
|
||||
allowedTools: frontmatter["allowed-tools"],
|
||||
metadata: frontmatter.metadata,
|
||||
}
|
||||
|
||||
const referencesDir = join(resolvedPath, "references")
|
||||
@@ -118,6 +137,7 @@ async function parseSkillMd(skillPath: string): Promise<SkillInfo | null> {
|
||||
return {
|
||||
name: metadata.name,
|
||||
path: resolvedPath,
|
||||
basePath: resolvedPath,
|
||||
metadata,
|
||||
content: body,
|
||||
references,
|
||||
@@ -202,6 +222,7 @@ async function loadSkillWithReferences(
|
||||
content = await resolveCommandsInText(content)
|
||||
referencesLoaded.push({ path: ref, content })
|
||||
} catch {
|
||||
// Skip unreadable references
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +230,7 @@ async function loadSkillWithReferences(
|
||||
return {
|
||||
name: skill.name,
|
||||
metadata: skill.metadata,
|
||||
basePath: skill.basePath,
|
||||
body: skill.content,
|
||||
referencesLoaded,
|
||||
}
|
||||
@@ -234,31 +256,24 @@ function formatLoadedSkills(loadedSkills: LoadedSkill[]): string {
|
||||
return "No skills loaded."
|
||||
}
|
||||
|
||||
const sections: string[] = ["# Loaded Skills\n"]
|
||||
const skill = loadedSkills[0]
|
||||
const sections: string[] = []
|
||||
|
||||
for (const skill of loadedSkills) {
|
||||
sections.push(`## ${skill.metadata.name}\n`)
|
||||
sections.push(`**Description**: ${skill.metadata.description || "(no description)"}\n`)
|
||||
sections.push("### Skill Instructions\n")
|
||||
sections.push(skill.body.trim())
|
||||
sections.push(`Base directory for this skill: ${skill.basePath}/`)
|
||||
sections.push("")
|
||||
sections.push(skill.body.trim())
|
||||
|
||||
if (skill.referencesLoaded.length > 0) {
|
||||
sections.push("\n### Loaded References\n")
|
||||
for (const ref of skill.referencesLoaded) {
|
||||
sections.push(`#### ${ref.path}\n`)
|
||||
sections.push("```")
|
||||
sections.push(ref.content.trim())
|
||||
sections.push("```\n")
|
||||
}
|
||||
if (skill.referencesLoaded.length > 0) {
|
||||
sections.push("\n---\n### Loaded References\n")
|
||||
for (const ref of skill.referencesLoaded) {
|
||||
sections.push(`#### ${ref.path}\n`)
|
||||
sections.push("```")
|
||||
sections.push(ref.content.trim())
|
||||
sections.push("```\n")
|
||||
}
|
||||
|
||||
sections.push("\n---\n")
|
||||
}
|
||||
|
||||
const skillNames = loadedSkills.map((s) => s.metadata.name).join(", ")
|
||||
sections.push(`**Skills loaded**: ${skillNames}`)
|
||||
sections.push(`**Total**: ${loadedSkills.length} skill(s)`)
|
||||
sections.push("\nPlease confirm these skills match your needs before proceeding.")
|
||||
sections.push(`\n---\n**Launched skill**: ${skill.metadata.name}`)
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
@@ -266,25 +281,7 @@ function formatLoadedSkills(loadedSkills: LoadedSkill[]): string {
|
||||
export const skill = tool({
|
||||
description: `Execute a skill within the main conversation.
|
||||
|
||||
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
|
||||
|
||||
How to use skills:
|
||||
- Invoke skills using this tool with the skill name only (no arguments)
|
||||
- When you invoke a skill, the skill's prompt will expand and provide detailed instructions on how to complete the task
|
||||
|
||||
Important:
|
||||
- Only use skills listed in Available Skills below
|
||||
- Do not invoke a skill that is already running
|
||||
|
||||
Skills are loaded from:
|
||||
- ~/.claude/skills/ (user scope - global skills)
|
||||
- ./.claude/skills/ (project scope - project-specific skills)
|
||||
|
||||
Each skill contains:
|
||||
- SKILL.md: Main instructions with YAML frontmatter (name, description)
|
||||
- references/: Documentation files loaded into context as needed
|
||||
- scripts/: Executable code for deterministic operations
|
||||
- assets/: Files used in output (templates, icons, etc.)
|
||||
When you invoke a skill, the skill's prompt will expand and provide detailed instructions on how to complete the task.
|
||||
|
||||
Available Skills:
|
||||
${skillListForDescription}`,
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export type SkillScope = "user" | "project"
|
||||
|
||||
/**
|
||||
* Zod schema for skill frontmatter validation
|
||||
* Following Anthropic Agent Skills Specification v1.0
|
||||
*/
|
||||
export const SkillFrontmatterSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/, "Name must be lowercase alphanumeric with hyphens only")
|
||||
.min(1, "Name cannot be empty"),
|
||||
description: z.string().min(20, "Description must be at least 20 characters for discoverability"),
|
||||
license: z.string().optional(),
|
||||
"allowed-tools": z.array(z.string()).optional(),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
|
||||
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>
|
||||
|
||||
export interface SkillMetadata {
|
||||
name: string
|
||||
description: string
|
||||
license?: string
|
||||
allowedTools?: string[]
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string
|
||||
path: string
|
||||
basePath: string
|
||||
metadata: SkillMetadata
|
||||
content: string
|
||||
references: string[]
|
||||
@@ -19,6 +41,7 @@ export interface SkillInfo {
|
||||
export interface LoadedSkill {
|
||||
name: string
|
||||
metadata: SkillMetadata
|
||||
basePath: string
|
||||
body: string
|
||||
referencesLoaded: Array<{ path: string; content: string }>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user