Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f26e99ee7 | ||
|
|
b405494808 | ||
|
|
839a4c5316 | ||
|
|
08d43efdb0 | ||
|
|
061a5f5132 | ||
|
|
d4acd23630 | ||
|
|
c77c9ceb53 | ||
|
|
8c2625cfb0 | ||
|
|
3ced20d1ab | ||
|
|
fb02cc9e95 | ||
|
|
80ee52fe3b | ||
|
|
2f7e188cb5 | ||
|
|
f8be01c6dd | ||
|
|
0dbec08923 | ||
|
|
691fa8b815 | ||
|
|
a73d806d4e | ||
|
|
a424f81cd5 | ||
|
|
1187a02020 | ||
|
|
3074434887 | ||
|
|
6bb2854162 | ||
|
|
e08904a27a | ||
|
|
0188d69233 | ||
|
|
2c74f608f0 | ||
|
|
baefd16b3f | ||
|
|
b1b4578906 | ||
|
|
9d20a5b11c | ||
|
|
d2d8d1a782 | ||
|
|
10bdb6c694 | ||
|
|
5f243e2d3a | ||
|
|
82a47ff928 | ||
|
|
c06f38693e | ||
|
|
6e9cb7ecd8 | ||
|
|
b731399edf | ||
|
|
0a28f6a790 | ||
|
|
4e529b74e0 | ||
|
|
90eec0a369 | ||
|
|
3b5d18e6bf | ||
|
|
67aeb9cb8c | ||
|
|
b1c1f02172 | ||
|
|
2b39d119cd | ||
|
|
afa2ece847 | ||
|
|
390c25197f | ||
|
|
9e07b143df | ||
|
|
ad95880198 | ||
|
|
86088d3a6e | ||
|
|
ae8a6c5eb8 | ||
|
|
db538c7e6b | ||
|
|
dfed2abd3e | ||
|
|
300a3fdc14 | ||
|
|
c993cf007f | ||
|
|
3d7de0a050 | ||
|
|
8e19ffdce4 | ||
|
|
456d9cea65 | ||
|
|
30f893b766 | ||
|
|
c905e1cb7a | ||
|
|
d3e2b36e3d | ||
|
|
5f0b6d49f5 | ||
|
|
b45408dd9c | ||
|
|
6c8527f29b | ||
|
|
cd4da93bf2 | ||
|
|
71b2f1518a | ||
|
|
dcda8769cc | ||
|
|
a94fbadd57 | ||
|
|
23b49c4a5c | ||
|
|
b4973954e3 | ||
|
|
6d50fbe563 | ||
|
|
9850dd0f6e | ||
|
|
34aaef2219 | ||
|
|
faca80caa9 | ||
|
|
0c3fbd724b | ||
|
|
c7455708f8 | ||
|
|
bffa1ad43d | ||
|
|
6560dedd4c | ||
|
|
b7e32a99f2 | ||
|
|
a06e656565 | ||
|
|
30ed086c40 | ||
|
|
7c15b06da7 | ||
|
|
0e7ee2ac30 | ||
|
|
ca93d2f0fe | ||
|
|
3ab4529bc7 | ||
|
|
9d3e152b19 |
122
.github/workflows/publish-platform.yml
vendored
122
.github/workflows/publish-platform.yml
vendored
@@ -28,16 +28,20 @@ permissions:
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish-platform:
|
||||
# Use windows-latest for Windows to avoid cross-compilation segfault (oven-sh/bun#18416)
|
||||
# Fixes: #873, #844
|
||||
# =============================================================================
|
||||
# Job 1: Build binaries for all platforms
|
||||
# - Windows builds on windows-latest (avoid bun cross-compile segfault)
|
||||
# - All other platforms build on ubuntu-latest
|
||||
# - Uploads compressed artifacts for the publish job
|
||||
# =============================================================================
|
||||
build:
|
||||
runs-on: ${{ matrix.platform == 'windows-x64' && 'windows-latest' || 'ubuntu-latest' }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
max-parallel: 7
|
||||
matrix:
|
||||
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
|
||||
steps:
|
||||
@@ -47,11 +51,6 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
env:
|
||||
@@ -63,15 +62,20 @@ jobs:
|
||||
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
|
||||
VERSION="${{ inputs.version }}"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
|
||||
# Convert platform name for output (replace - with _)
|
||||
PLATFORM_KEY="${{ matrix.platform }}"
|
||||
PLATFORM_KEY="${PLATFORM_KEY//-/_}"
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "skip_${PLATFORM_KEY}=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ ${PKG_NAME}@${VERSION} already published"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "skip_${PLATFORM_KEY}=false" >> $GITHUB_OUTPUT
|
||||
echo "→ ${PKG_NAME}@${VERSION} needs publishing"
|
||||
fi
|
||||
|
||||
- name: Update version
|
||||
- name: Update version in package.json
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
@@ -99,15 +103,109 @@ jobs:
|
||||
fi
|
||||
|
||||
bun build src/cli/index.ts --compile --minify --target=$TARGET --outfile=$OUTPUT
|
||||
|
||||
echo "Built binary:"
|
||||
ls -lh "$OUTPUT"
|
||||
|
||||
- name: Compress binary
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
PLATFORM="${{ matrix.platform }}"
|
||||
cd packages/${PLATFORM}
|
||||
|
||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
||||
# Windows: use 7z (pre-installed on windows-latest)
|
||||
7z a -tzip ../../binary-${PLATFORM}.zip bin/ package.json
|
||||
else
|
||||
# Unix: use tar.gz
|
||||
tar -czvf ../../binary-${PLATFORM}.tar.gz bin/ package.json
|
||||
fi
|
||||
|
||||
cd ../..
|
||||
echo "Compressed artifact:"
|
||||
ls -lh binary-${PLATFORM}.*
|
||||
|
||||
- name: Upload artifact
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-${{ matrix.platform }}
|
||||
path: |
|
||||
binary-${{ matrix.platform }}.tar.gz
|
||||
binary-${{ matrix.platform }}.zip
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
# =============================================================================
|
||||
# Job 2: Publish all platforms using OIDC/Provenance
|
||||
# - Runs on ubuntu-latest for ALL platforms (just downloading artifacts)
|
||||
# - Uses npm Trusted Publishing (OIDC) - no NODE_AUTH_TOKEN needed
|
||||
# - Fresh OIDC token at publish time avoids timeout issues
|
||||
# =============================================================================
|
||||
publish:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
|
||||
steps:
|
||||
- name: Check if already published
|
||||
id: check
|
||||
run: |
|
||||
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
|
||||
VERSION="${{ inputs.version }}"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ ${PKG_NAME}@${VERSION} already published, skipping"
|
||||
else
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "→ ${PKG_NAME}@${VERSION} will be published"
|
||||
fi
|
||||
|
||||
- name: Download artifact
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: binary-${{ matrix.platform }}
|
||||
path: .
|
||||
|
||||
- name: Extract artifact
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
PLATFORM="${{ matrix.platform }}"
|
||||
mkdir -p packages/${PLATFORM}
|
||||
|
||||
if [ "$PLATFORM" = "windows-x64" ]; then
|
||||
unzip binary-${PLATFORM}.zip -d packages/${PLATFORM}/
|
||||
else
|
||||
tar -xzvf binary-${PLATFORM}.tar.gz -C packages/${PLATFORM}/
|
||||
fi
|
||||
|
||||
echo "Extracted contents:"
|
||||
ls -la packages/${PLATFORM}/
|
||||
ls -la packages/${PLATFORM}/bin/
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish ${{ matrix.platform }}
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
cd packages/${{ matrix.platform }}
|
||||
|
||||
TAG_ARG=""
|
||||
if [ -n "${{ inputs.dist_tag }}" ]; then
|
||||
TAG_ARG="--tag ${{ inputs.dist_tag }}"
|
||||
fi
|
||||
npm publish --access public $TAG_ARG
|
||||
|
||||
npm publish --access public --provenance $TAG_ARG
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: false
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
timeout-minutes: 15
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,3 +33,4 @@ yarn.lock
|
||||
test-injection/
|
||||
notepad.md
|
||||
oauth-success.html
|
||||
.188e87dbff6e7fd9-00000000.bun-build
|
||||
|
||||
10
AGENTS.md
10
AGENTS.md
@@ -98,13 +98,13 @@ oh-my-opencode/
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
|
||||
| Atlas | anthropic/claude-opus-4-5 | Master orchestrator |
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro) |
|
||||
| Atlas | anthropic/claude-sonnet-4-5 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| oracle | openai/gpt-5.2 | Consultation, debugging |
|
||||
| librarian | opencode/big-pickle | Docs, GitHub search |
|
||||
| explore | opencode/gpt-5-nano | Fast codebase grep |
|
||||
| librarian | zai-coding-plan/glm-4.7 | Docs, GitHub search (fallback: glm-4.7-free) |
|
||||
| explore | anthropic/claude-haiku-4-5 | Fast codebase grep (fallback: gpt-5-mini → gpt-5-nano) |
|
||||
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
|
||||
## COMMANDS
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
|
||||
- Oracle: 設計、デバッグ (GPT 5.2 Medium)
|
||||
- Frontend UI/UX Engineer: フロントエンド開発 (Gemini 3 Pro)
|
||||
- Librarian: 公式ドキュメント、オープンソース実装、コードベース探索 (Claude Sonnet 4.5)
|
||||
- Explore: 超高速コードベース探索 (Contextual Grep) (Grok Code)
|
||||
- Explore: 超高速コードベース探索 (Contextual Grep) (Claude Haiku 4.5)
|
||||
- Full LSP / AstGrep Support: 決定的にリファクタリングしましょう。
|
||||
- Todo Continuation Enforcer: 途中で諦めたら、続行を強制します。これがシジフォスに岩を転がし続けさせる秘訣です。
|
||||
- Comment Checker: AIが過剰なコメントを付けないようにします。シジフォスが生成したコードは、人間が書いたものと区別がつかないべきです。
|
||||
|
||||
@@ -197,7 +197,7 @@ Hey please read this readme and tell me why it is different from other agent har
|
||||
- Oracle: 디자인, 디버깅 (GPT 5.2 Medium)
|
||||
- Frontend UI/UX Engineer: 프론트엔드 개발 (Gemini 3 Pro)
|
||||
- Librarian: 공식 문서, 오픈 소스 구현, 코드베이스 탐색 (Claude Sonnet 4.5)
|
||||
- Explore: 엄청나게 빠른 코드베이스 탐색 (Contextual Grep) (Grok Code)
|
||||
- Explore: 엄청나게 빠른 코드베이스 탐색 (Contextual Grep) (Claude Haiku 4.5)
|
||||
- 완전한 LSP / AstGrep 지원: 결정적으로 리팩토링합니다.
|
||||
- TODO 연속 강제: 에이전트가 중간에 멈추면 계속하도록 강제합니다. **이것이 Sisyphus가 그 바위를 굴리게 하는 것입니다.**
|
||||
- 주석 검사기: AI가 과도한 주석을 추가하는 것을 방지합니다. Sisyphus가 생성한 코드는 인간이 작성한 것과 구별할 수 없어야 합니다.
|
||||
|
||||
@@ -196,7 +196,7 @@ Meet our main agent: Sisyphus (Opus 4.5 High). Below are the tools Sisyphus uses
|
||||
- Oracle: Design, debugging (GPT 5.2 Medium)
|
||||
- Frontend UI/UX Engineer: Frontend development (Gemini 3 Pro)
|
||||
- Librarian: Official docs, open source implementations, codebase exploration (Claude Sonnet 4.5)
|
||||
- Explore: Blazing fast codebase exploration (Contextual Grep) (Grok Code)
|
||||
- Explore: Blazing fast codebase exploration (Contextual Grep) (Claude Haiku 4.5)
|
||||
- Full LSP / AstGrep Support: Refactor decisively.
|
||||
- Todo Continuation Enforcer: Forces the agent to continue if it quits halfway. **This is what keeps Sisyphus rolling that boulder.**
|
||||
- Comment Checker: Prevents AI from adding excessive comments. Code generated by Sisyphus should be indistinguishable from human-written code.
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
- Oracle:设计、调试 (GPT 5.2 Medium)
|
||||
- Frontend UI/UX Engineer:前端开发 (Gemini 3 Pro)
|
||||
- Librarian:官方文档、开源实现、代码库探索 (Claude Sonnet 4.5)
|
||||
- Explore:极速代码库探索(上下文感知 Grep)(Grok Code)
|
||||
- Explore:极速代码库探索(上下文感知 Grep)(Claude Haiku 4.5)
|
||||
- 完整 LSP / AstGrep 支持:果断重构。
|
||||
- Todo 继续执行器:如果智能体中途退出,强制它继续。**这就是让 Sisyphus 继续推动巨石的关键。**
|
||||
- 注释检查器:防止 AI 添加过多注释。Sisyphus 生成的代码应该与人类编写的代码无法区分。
|
||||
|
||||
31
bun.lock
31
bun.lock
@@ -18,6 +18,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"vscode-jsonrpc": "^8.2.0",
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.1.2",
|
||||
"oh-my-opencode-darwin-x64": "3.1.2",
|
||||
"oh-my-opencode-linux-arm64": "3.1.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.2",
|
||||
"oh-my-opencode-linux-x64": "3.1.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.2",
|
||||
"oh-my-opencode-windows-x64": "3.1.2",
|
||||
"oh-my-opencode-darwin-arm64": "3.1.6",
|
||||
"oh-my-opencode-darwin-x64": "3.1.6",
|
||||
"oh-my-opencode-linux-arm64": "3.1.6",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.6",
|
||||
"oh-my-opencode-linux-x64": "3.1.6",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.6",
|
||||
"oh-my-opencode-windows-x64": "3.1.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -225,6 +226,20 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.6", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KK+ptnkBigvDYbRtF/B5izEC4IoXDS8mAnRHWFBSCINhzQR2No6AtEcwijd6vKBPR+/r71ofq/8mTsIeb1PEVQ=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.6", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UkPI/RUi7INarFasBUZ4Rous6RUQXsU2nr0V8KFJp+70END43D/96dDUwX+zmPtpDhD+DfWkejuwzqfkZJ2ZDQ=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gvmvgh7WtTtcHiCbG7z43DOYfY/jrf2S6TX/jBMX2/e1AGkcLKwz30NjGhZxeK5SyzxRVypgfZZK1IuriRgbdA=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-j3R76pmQ4HGVGFJUMMCeF/1lO3Jg7xFdpcBUKCeFh42N1jMgn1aeyxkAaJYB9RwCF/p6+P8B6gVDLCEDu2mxjA=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VDdo0tHCOr5nm7ajd652u798nPNOLRSTcPOnVh6vIPddkZ+ujRke+enOKOw9Pd5e+4AkthqHBwFXNm2VFgnEKg=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-hBG/dhsr8PZelUlYsPBruSLnelB9ocB7H92I+S9svTpDVo67rAmXOoR04twKQ9TeCO4ShOa6hhMhbQnuI8fgNw=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.6", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-c8Awp03p2DsbS0G589nzveRCeJPgJRJ0vQrha4ChRmmo31Qc5OSmJ5xuMaF8L4nM+/trbTgAQMFMtCMLgtC8IQ=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
@@ -289,6 +304,8 @@
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
@@ -23,6 +23,7 @@ A Category is an agent configuration preset optimized for specific domains.
|
||||
|----------|---------------|-----------|
|
||||
| `visual-engineering` | `google/gemini-3-pro` | Frontend, UI/UX, design, styling, animation |
|
||||
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions requiring extensive analysis |
|
||||
| `deep` | `openai/gpt-5.2-codex` (medium) | Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding. |
|
||||
| `artistry` | `google/gemini-3-pro` (max) | Highly creative/artistic tasks, novel ideas |
|
||||
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications |
|
||||
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
|
||||
|
||||
@@ -134,7 +134,41 @@ bunx oh-my-opencode run [prompt]
|
||||
|
||||
---
|
||||
|
||||
## 6. `auth` - Authentication Management
|
||||
## 6. `mcp oauth` - MCP OAuth Management
|
||||
|
||||
Manages OAuth 2.1 authentication for remote MCP servers.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Login to an OAuth-protected MCP server
|
||||
bunx oh-my-opencode mcp oauth login <server-name> --server-url https://api.example.com
|
||||
|
||||
# Login with explicit client ID and scopes
|
||||
bunx oh-my-opencode mcp oauth login my-api --server-url https://api.example.com --client-id my-client --scopes "read,write"
|
||||
|
||||
# Remove stored OAuth tokens
|
||||
bunx oh-my-opencode mcp oauth logout <server-name>
|
||||
|
||||
# Check OAuth token status
|
||||
bunx oh-my-opencode mcp oauth status [server-name]
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--server-url <url>` | MCP server URL (required for login) |
|
||||
| `--client-id <id>` | OAuth client ID (optional if server supports Dynamic Client Registration) |
|
||||
| `--scopes <scopes>` | Comma-separated OAuth scopes |
|
||||
|
||||
### Token Storage
|
||||
|
||||
Tokens are stored in `~/.config/opencode/mcp-oauth.json` with `0600` permissions (owner read/write only). Key format: `{serverHost}/{resource}`.
|
||||
|
||||
---
|
||||
|
||||
## 7. `auth` - Authentication Management
|
||||
|
||||
Manages Google Antigravity OAuth authentication. Required for using Gemini models.
|
||||
|
||||
@@ -153,7 +187,7 @@ bunx oh-my-opencode auth status
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration Files
|
||||
## 8. Configuration Files
|
||||
|
||||
The CLI searches for configuration files in the following locations (in priority order):
|
||||
|
||||
@@ -183,7 +217,7 @@ Configuration files support **JSONC (JSON with Comments)** format. You can use c
|
||||
|
||||
---
|
||||
|
||||
## 8. Troubleshooting
|
||||
## 9. Troubleshooting
|
||||
|
||||
### "OpenCode version too old" Error
|
||||
|
||||
@@ -213,7 +247,7 @@ bunx oh-my-opencode doctor --category authentication
|
||||
|
||||
---
|
||||
|
||||
## 9. Non-Interactive Mode
|
||||
## 10. Non-Interactive Mode
|
||||
|
||||
Use the `--no-tui` option for CI/CD environments.
|
||||
|
||||
@@ -227,7 +261,7 @@ bunx oh-my-opencode doctor --json > doctor-report.json
|
||||
|
||||
---
|
||||
|
||||
## 10. Developer Information
|
||||
## 11. Developer Information
|
||||
|
||||
### CLI Structure
|
||||
|
||||
|
||||
@@ -163,7 +163,39 @@ Override built-in agent settings:
|
||||
}
|
||||
```
|
||||
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`, `category`, `variant`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `providerOptions`.
|
||||
|
||||
### Additional Agent Options
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------------- | ------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `category` | string | Category name to inherit model and other settings from category defaults |
|
||||
| `variant` | string | Model variant (e.g., `max`, `high`, `medium`, `low`, `xhigh`) |
|
||||
| `maxTokens` | number | Maximum tokens for response. Passed directly to OpenCode SDK. |
|
||||
| `thinking` | object | Extended thinking configuration for Anthropic models. See [Thinking Options](#thinking-options) below. |
|
||||
| `reasoningEffort` | string | OpenAI reasoning effort level. Values: `low`, `medium`, `high`, `xhigh`. |
|
||||
| `textVerbosity` | string | Text verbosity level. Values: `low`, `medium`, `high`. |
|
||||
| `providerOptions` | object | Provider-specific options passed directly to OpenCode SDK. |
|
||||
|
||||
#### Thinking Options (Anthropic)
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
"budgetTokens": 200000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------- | ------- | ------- | -------------------------------------------- |
|
||||
| `type` | string | - | `enabled` or `disabled` |
|
||||
| `budgetTokens`| number | - | Maximum budget tokens for extended thinking |
|
||||
|
||||
Use `prompt_append` to add extra instructions without replacing the default system prompt:
|
||||
|
||||
@@ -213,7 +245,7 @@ Or disable via `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or
|
||||
}
|
||||
```
|
||||
|
||||
Available agents: `oracle`, `librarian`, `explore`, `multimodal-looker`
|
||||
Available agents: `sisyphus`, `prometheus`, `oracle`, `librarian`, `explore`, `multimodal-looker`, `metis`, `momus`, `atlas`
|
||||
|
||||
## Built-in Skills
|
||||
|
||||
@@ -232,6 +264,105 @@ Disable built-in skills via `disabled_skills` in `~/.config/opencode/oh-my-openc
|
||||
|
||||
Available built-in skills: `playwright`, `agent-browser`, `git-master`
|
||||
|
||||
## Skills Configuration
|
||||
|
||||
Configure advanced skills settings including custom skill sources, enabling/disabling specific skills, and defining custom skills.
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"sources": [
|
||||
{ "path": "./custom-skills", "recursive": true },
|
||||
"https://example.com/skill.yaml"
|
||||
],
|
||||
"enable": ["my-custom-skill"],
|
||||
"disable": ["other-skill"],
|
||||
"my-skill": {
|
||||
"description": "Custom skill description",
|
||||
"template": "Custom prompt template",
|
||||
"from": "source-file.ts",
|
||||
"model": "custom/model",
|
||||
"agent": "custom-agent",
|
||||
"subtask": true,
|
||||
"argument-hint": "usage hint",
|
||||
"license": "MIT",
|
||||
"compatibility": ">= 3.0.0",
|
||||
"metadata": {
|
||||
"author": "Your Name"
|
||||
},
|
||||
"allowed-tools": ["tool1", "tool2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Sources
|
||||
|
||||
Load skills from local directories or remote URLs:
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"sources": [
|
||||
{ "path": "./custom-skills", "recursive": true },
|
||||
{ "path": "./single-skill.yaml" },
|
||||
"https://example.com/skill.yaml",
|
||||
"https://raw.githubusercontent.com/user/repo/main/skills/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ----------- | ------- | ---------------------------------------------- |
|
||||
| `path` | - | Local file/directory path or remote URL |
|
||||
| `recursive` | `false` | Recursively load from directory |
|
||||
| `glob` | - | Glob pattern for file selection |
|
||||
|
||||
### Enable/Disable Skills
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"enable": ["skill-1", "skill-2"],
|
||||
"disable": ["disabled-skill"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Skill Definition
|
||||
|
||||
Define custom skills directly in your config:
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------------- | ------- | ------------------------------------------------------------------------------------ |
|
||||
| `description` | - | Human-readable description of the skill |
|
||||
| `template` | - | Custom prompt template for the skill |
|
||||
| `from` | - | Source file to load template from |
|
||||
| `model` | - | Override model for this skill |
|
||||
| `agent` | - | Override agent for this skill |
|
||||
| `subtask` | `false` | Whether to run as a subtask |
|
||||
| `argument-hint` | - | Hint for how to use the skill |
|
||||
| `license` | - | Skill license |
|
||||
| `compatibility` | - | Required oh-my-opencode version compatibility |
|
||||
| `metadata` | - | Additional metadata as key-value pairs |
|
||||
| `allowed-tools` | - | Array of tools this skill is allowed to use |
|
||||
|
||||
**Example: Custom skill**
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"data-analyst": {
|
||||
"description": "Specialized for data analysis tasks",
|
||||
"template": "You are a data analyst. Focus on statistical analysis, visualization, and data interpretation.",
|
||||
"model": "openai/gpt-5.2",
|
||||
"allowed-tools": ["read", "bash", "lsp_diagnostics"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Browser Automation
|
||||
|
||||
Choose between two browser automation providers:
|
||||
@@ -555,6 +686,7 @@ Configure concurrency limits for background agent tasks. This controls how many
|
||||
{
|
||||
"background_task": {
|
||||
"defaultConcurrency": 5,
|
||||
"staleTimeoutMs": 180000,
|
||||
"providerConcurrency": {
|
||||
"anthropic": 3,
|
||||
"openai": 5,
|
||||
@@ -571,6 +703,7 @@ Configure concurrency limits for background agent tasks. This controls how many
|
||||
| Option | Default | Description |
|
||||
| --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `defaultConcurrency` | - | Default maximum concurrent background tasks for all providers/models |
|
||||
| `staleTimeoutMs` | `180000` | Stale timeout in milliseconds - interrupt tasks with no activity for this duration (minimum: 60000 = 1 minute) |
|
||||
| `providerConcurrency` | - | Per-provider concurrency limits. Keys are provider names (e.g., `anthropic`, `openai`, `google`) |
|
||||
| `modelConcurrency` | - | Per-model concurrency limits. Keys are full model names (e.g., `anthropic/claude-opus-4-5`). Overrides provider limits. |
|
||||
|
||||
@@ -692,7 +825,14 @@ Add your own categories or override built-in ones:
|
||||
}
|
||||
```
|
||||
|
||||
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`.
|
||||
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`, `description`, `is_unstable_agent`.
|
||||
|
||||
### Additional Category Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------ | ------- | ------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `description` | string | - | Human-readable description of the category's purpose. Shown in delegate_task prompt. |
|
||||
| `is_unstable_agent`| boolean | `false` | Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini models. |
|
||||
|
||||
## Model Resolution System
|
||||
|
||||
@@ -754,15 +894,15 @@ Each agent has a defined provider priority chain. The system tries providers in
|
||||
|
||||
| Agent | Model (no prefix) | Provider Priority Chain |
|
||||
|-------|-------------------|-------------------------|
|
||||
| **Sisyphus** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **oracle** | `gpt-5.2` | openai → anthropic → google → github-copilot → opencode |
|
||||
| **librarian** | `big-pickle` | opencode → github-copilot → anthropic |
|
||||
| **explore** | `gpt-5-nano` | anthropic → opencode |
|
||||
| **multimodal-looker** | `gemini-3-flash` | google → openai → zai-coding-plan → anthropic → opencode |
|
||||
| **Prometheus (Planner)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **Metis (Plan Consultant)** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **Momus (Plan Reviewer)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **Atlas** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **Sisyphus** | `claude-opus-4-5` | anthropic → kimi-for-coding → zai-coding-plan → openai → google |
|
||||
| **oracle** | `gpt-5.2` | openai → google → anthropic |
|
||||
| **librarian** | `glm-4.7` | zai-coding-plan → opencode → anthropic |
|
||||
| **explore** | `claude-haiku-4-5` | anthropic → github-copilot → opencode |
|
||||
| **multimodal-looker** | `gemini-3-flash` | google → openai → zai-coding-plan → kimi-for-coding → anthropic → opencode |
|
||||
| **Prometheus (Planner)** | `claude-opus-4-5` | anthropic → kimi-for-coding → openai → google |
|
||||
| **Metis (Plan Consultant)** | `claude-opus-4-5` | anthropic → kimi-for-coding → openai → google |
|
||||
| **Momus (Plan Reviewer)** | `gpt-5.2` | openai → anthropic → google |
|
||||
| **Atlas** | `claude-sonnet-4-5` | anthropic → kimi-for-coding → openai → google |
|
||||
|
||||
### Category Provider Chains
|
||||
|
||||
@@ -770,13 +910,14 @@ Categories follow the same resolution logic:
|
||||
|
||||
| Category | Model (no prefix) | Provider Priority Chain |
|
||||
|----------|-------------------|-------------------------|
|
||||
| **visual-engineering** | `gemini-3-pro` | google → openai → anthropic → github-copilot → opencode |
|
||||
| **ultrabrain** | `gpt-5.2-codex` | openai → anthropic → google → github-copilot → opencode |
|
||||
| **artistry** | `gemini-3-pro` | google → openai → anthropic → github-copilot → opencode |
|
||||
| **quick** | `claude-haiku-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **unspecified-low** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **unspecified-high** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
|
||||
| **writing** | `gemini-3-flash` | google → openai → anthropic → github-copilot → opencode |
|
||||
| **visual-engineering** | `gemini-3-pro` | google → anthropic → zai-coding-plan |
|
||||
| **ultrabrain** | `gpt-5.2-codex` | openai → google → anthropic |
|
||||
| **deep** | `gpt-5.2-codex` | openai → anthropic → google |
|
||||
| **artistry** | `gemini-3-pro` | google → anthropic → openai |
|
||||
| **quick** | `claude-haiku-4-5` | anthropic → google → opencode |
|
||||
| **unspecified-low** | `claude-sonnet-4-5` | anthropic → openai → google |
|
||||
| **unspecified-high** | `claude-opus-4-5` | anthropic → openai → google |
|
||||
| **writing** | `gemini-3-flash` | google → anthropic → zai-coding-plan → openai |
|
||||
|
||||
### Checking Your Configuration
|
||||
|
||||
@@ -826,12 +967,93 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
|
||||
}
|
||||
```
|
||||
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `start-work`
|
||||
|
||||
**Note on `directory-agents-injector`**: This hook is **automatically disabled** when running on OpenCode 1.1.37+ because OpenCode now has native support for dynamically resolving AGENTS.md files from subdirectories (PR #10678). This prevents duplicate AGENTS.md injection. For older OpenCode versions, the hook remains active to provide the same functionality.
|
||||
|
||||
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
|
||||
|
||||
## Disabled Commands
|
||||
|
||||
Disable specific built-in commands via `disabled_commands` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_commands": ["init-deep", "start-work"]
|
||||
}
|
||||
```
|
||||
|
||||
Available commands: `init-deep`, `start-work`
|
||||
|
||||
## Comment Checker
|
||||
|
||||
Configure comment-checker hook behavior. The comment checker warns when excessive comments are added to code.
|
||||
|
||||
```json
|
||||
{
|
||||
"comment_checker": {
|
||||
"custom_prompt": "Your custom warning message. Use {{comments}} placeholder for detected comments XML."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------- | ------- | -------------------------------------------------------------------------- |
|
||||
| `custom_prompt` | - | Custom warning message to replace the default. Use `{{comments}}` placeholder. |
|
||||
|
||||
## Notification
|
||||
|
||||
Configure notification behavior for background task completion.
|
||||
|
||||
```json
|
||||
{
|
||||
"notification": {
|
||||
"force_enable": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------- | ------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `force_enable` | `false` | Force enable session-notification even if external notification plugins are detected. Default: `false`. |
|
||||
|
||||
## Sisyphus Tasks & Swarm
|
||||
|
||||
Configure Sisyphus Tasks and Swarm systems for advanced task management and multi-agent orchestration.
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus": {
|
||||
"tasks": {
|
||||
"enabled": false,
|
||||
"storage_path": ".sisyphus/tasks",
|
||||
"claude_code_compat": false
|
||||
},
|
||||
"swarm": {
|
||||
"enabled": false,
|
||||
"storage_path": ".sisyphus/teams",
|
||||
"ui_mode": "toast"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tasks Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------- | ------------------ | ------------------------------------------------------------------------- |
|
||||
| `enabled` | `false` | Enable Sisyphus Tasks system |
|
||||
| `storage_path` | `.sisyphus/tasks` | Storage path for tasks (relative to project root) |
|
||||
| `claude_code_compat` | `false` | Enable Claude Code path compatibility mode |
|
||||
|
||||
### Swarm Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------- | ------------------ | -------------------------------------------------------------- |
|
||||
| `enabled` | `false` | Enable Sisyphus Swarm system for multi-agent orchestration |
|
||||
| `storage_path` | `.sisyphus/teams` | Storage path for teams (relative to project root) |
|
||||
| `ui_mode` | `toast` | UI mode: `toast` (notifications), `tmux` (panes), or `both` |
|
||||
|
||||
## MCPs
|
||||
|
||||
Exa, Context7 and grep.app MCP enabled by default.
|
||||
@@ -873,6 +1095,38 @@ Add LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json`
|
||||
|
||||
Each server supports: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| -------------- | -------- | ------- | ---------------------------------------------------------------------- |
|
||||
| `command` | array | - | Command to start the LSP server (executable + args) |
|
||||
| `extensions` | array | - | File extensions this server handles (e.g., `[".ts", ".tsx"]`) |
|
||||
| `priority` | number | - | Server priority when multiple servers match a file |
|
||||
| `env` | object | - | Environment variables for the LSP server (key-value pairs) |
|
||||
| `initialization`| object | - | Custom initialization options passed to the LSP server |
|
||||
| `disabled` | boolean | `false` | Whether to disable this LSP server |
|
||||
|
||||
**Example with advanced options:**
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"typescript-language-server": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"priority": 10,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--max-old-space-size=4096"
|
||||
},
|
||||
"initialization": {
|
||||
"preferences": {
|
||||
"includeInlayParameterNameHints": "all",
|
||||
"includeInlayFunctionParameterTypeHints": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Experimental
|
||||
|
||||
Opt-in experimental features that may change or be removed in future versions. Use with caution.
|
||||
@@ -882,7 +1136,29 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
"experimental": {
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true
|
||||
"auto_resume": true,
|
||||
"dynamic_context_pruning": {
|
||||
"enabled": false,
|
||||
"notification": "detailed",
|
||||
"turn_protection": {
|
||||
"enabled": true,
|
||||
"turns": 3
|
||||
},
|
||||
"protected_tools": ["task", "todowrite", "lsp_rename"],
|
||||
"strategies": {
|
||||
"deduplication": {
|
||||
"enabled": true
|
||||
},
|
||||
"supersede_writes": {
|
||||
"enabled": true,
|
||||
"aggressive": false
|
||||
},
|
||||
"purge_errors": {
|
||||
"enabled": true,
|
||||
"turns": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -891,7 +1167,72 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `truncate_all_tool_outputs` | `false` | Truncates ALL tool outputs instead of just whitelisted tools (Grep, Glob, LSP, AST-grep). Tool output truncator is enabled by default - disable via `disabled_hooks`. |
|
||||
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts last user message and continues. |
|
||||
| `dynamic_context_pruning` | See below | Dynamic context pruning configuration for managing context window usage automatically. See [Dynamic Context Pruning](#dynamic-context-pruning) below. |
|
||||
|
||||
### Dynamic Context Pruning
|
||||
|
||||
Dynamic context pruning automatically manages context window by intelligently pruning old tool outputs. This feature helps maintain performance in long sessions.
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"dynamic_context_pruning": {
|
||||
"enabled": false,
|
||||
"notification": "detailed",
|
||||
"turn_protection": {
|
||||
"enabled": true,
|
||||
"turns": 3
|
||||
},
|
||||
"protected_tools": ["task", "todowrite", "todoread", "lsp_rename", "session_read", "session_write", "session_search"],
|
||||
"strategies": {
|
||||
"deduplication": {
|
||||
"enabled": true
|
||||
},
|
||||
"supersede_writes": {
|
||||
"enabled": true,
|
||||
"aggressive": false
|
||||
},
|
||||
"purge_errors": {
|
||||
"enabled": true,
|
||||
"turns": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ----------------- | ------- | ----------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `false` | Enable dynamic context pruning |
|
||||
| `notification` | `detailed` | Notification level: `off`, `minimal`, or `detailed` |
|
||||
| `turn_protection` | See below | Turn protection settings - prevent pruning recent tool outputs |
|
||||
|
||||
#### Turn Protection
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------- | ------- | ------------------------------------------------------------ |
|
||||
| `enabled` | `true` | Enable turn protection |
|
||||
| `turns` | `3` | Number of recent turns to protect from pruning (1-10) |
|
||||
|
||||
#### Protected Tools
|
||||
|
||||
Tools that should never be pruned (default):
|
||||
|
||||
```json
|
||||
["task", "todowrite", "todoread", "lsp_rename", "session_read", "session_write", "session_search"]
|
||||
```
|
||||
|
||||
#### Pruning Strategies
|
||||
|
||||
| Strategy | Option | Default | Description |
|
||||
| ------------------- | ------------ | ------- | ---------------------------------------------------------------------------- |
|
||||
| **deduplication** | `enabled` | `true` | Remove duplicate tool calls (same tool + same args) |
|
||||
| **supersede_writes**| `enabled` | `true` | Prune write inputs when file subsequently read |
|
||||
| | `aggressive` | `false` | Aggressive mode: prune any write if ANY subsequent read |
|
||||
| **purge_errors** | `enabled` | `true` | Prune errored tool inputs after N turns |
|
||||
| | `turns` | `5` | Number of turns before pruning errors (1-20) |
|
||||
|
||||
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.
|
||||
|
||||
|
||||
@@ -10,19 +10,19 @@ Oh-My-OpenCode provides 10 specialized AI agents. Each has distinct expertise, o
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| **Sisyphus** | `anthropic/claude-opus-4-5` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). |
|
||||
| **Sisyphus** | `anthropic/claude-opus-4-5` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro. |
|
||||
| **oracle** | `openai/gpt-5.2` | Architecture decisions, code review, debugging. Read-only consultation - stellar logical reasoning and deep analysis. Inspired by AmpCode. |
|
||||
| **librarian** | `opencode/big-pickle` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Inspired by AmpCode. |
|
||||
| **explore** | `opencode/gpt-5-nano` | Fast codebase exploration and contextual grep. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code. |
|
||||
| **multimodal-looker** | `google/gemini-3-flash` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Saves tokens by having another agent process media. |
|
||||
| **librarian** | `zai-coding-plan/glm-4.7` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Fallback: glm-4.7-free → claude-sonnet-4-5. |
|
||||
| **explore** | `anthropic/claude-haiku-4-5` | Fast codebase exploration and contextual grep. Fallback: gpt-5-mini → gpt-5-nano. |
|
||||
| **multimodal-looker** | `google/gemini-3-flash` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Fallback: gpt-5.2 → glm-4.6v → kimi-k2.5 → claude-haiku-4-5 → gpt-5-nano. |
|
||||
|
||||
### Planning Agents
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| **Prometheus** | `anthropic/claude-opus-4-5` | Strategic planner with interview mode. Creates detailed work plans through iterative questioning. |
|
||||
| **Metis** | `anthropic/claude-sonnet-4-5` | Plan consultant - pre-planning analysis. Identifies hidden intentions, ambiguities, and AI failure points. |
|
||||
| **Momus** | `anthropic/claude-sonnet-4-5` | Plan reviewer - validates plans against clarity, verifiability, and completeness standards. |
|
||||
| **Prometheus** | `anthropic/claude-opus-4-5` | Strategic planner with interview mode. Creates detailed work plans through iterative questioning. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
|
||||
| **Metis** | `anthropic/claude-opus-4-5` | Plan consultant - pre-planning analysis. Identifies hidden intentions, ambiguities, and AI failure points. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
|
||||
| **Momus** | `openai/gpt-5.2` | Plan reviewer - validates plans against clarity, verifiability, and completeness standards. Fallback: gpt-5.2 → claude-opus-4-5 → gemini-3-pro. |
|
||||
|
||||
### Invoking Agents
|
||||
|
||||
@@ -521,6 +521,37 @@ mcp:
|
||||
|
||||
The `skill_mcp` tool invokes these operations with full schema discovery.
|
||||
|
||||
#### OAuth-Enabled MCPs
|
||||
|
||||
Skills can define OAuth-protected remote MCP servers. OAuth 2.1 with full RFC compliance (RFC 9728, 8414, 8707, 7591) is supported:
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: My API skill
|
||||
mcp:
|
||||
my-api:
|
||||
url: https://api.example.com/mcp
|
||||
oauth:
|
||||
clientId: ${CLIENT_ID}
|
||||
scopes: ["read", "write"]
|
||||
---
|
||||
```
|
||||
|
||||
When a skill MCP has `oauth` configured:
|
||||
- **Auto-discovery**: Fetches `/.well-known/oauth-protected-resource` (RFC 9728), falls back to `/.well-known/oauth-authorization-server` (RFC 8414)
|
||||
- **Dynamic Client Registration**: Auto-registers with servers supporting RFC 7591 (clientId becomes optional)
|
||||
- **PKCE**: Mandatory for all flows
|
||||
- **Resource Indicators**: Auto-generated from MCP URL per RFC 8707
|
||||
- **Token Storage**: Persisted in `~/.config/opencode/mcp-oauth.json` (chmod 0600)
|
||||
- **Auto-refresh**: Tokens refresh on 401; step-up authorization on 403 with `WWW-Authenticate`
|
||||
- **Dynamic Port**: OAuth callback server uses an auto-discovered available port
|
||||
|
||||
Pre-authenticate via CLI:
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode mcp oauth login <server-name> --server-url https://api.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Injection
|
||||
|
||||
@@ -213,7 +213,7 @@ If Z.ai is the only provider available, all agents will use GLM models:
|
||||
|
||||
#### OpenCode Zen
|
||||
|
||||
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-5`, `opencode/gpt-5.2`, `opencode/gpt-5-nano`, and `opencode/big-pickle`.
|
||||
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-5`, `opencode/gpt-5.2`, `opencode/gpt-5-nano`, and `opencode/glm-4.7-free`.
|
||||
|
||||
When OpenCode Zen is the best available provider (no native or Copilot), these models are used:
|
||||
|
||||
@@ -222,7 +222,7 @@ When OpenCode Zen is the best available provider (no native or Copilot), these m
|
||||
| **Sisyphus** | `opencode/claude-opus-4-5` |
|
||||
| **Oracle** | `opencode/gpt-5.2` |
|
||||
| **Explore** | `opencode/gpt-5-nano` |
|
||||
| **Librarian** | `opencode/big-pickle` |
|
||||
| **Librarian** | `opencode/glm-4.7-free` |
|
||||
|
||||
##### Setup
|
||||
|
||||
@@ -278,7 +278,7 @@ After everything is set up and working, ask the user:
|
||||
|
||||
If the user agrees, run:
|
||||
```bash
|
||||
gh repo star code-yeongyu/oh-my-opencode
|
||||
gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true
|
||||
```
|
||||
|
||||
**Important**: Only run the command if the user explicitly says yes. Never run it automatically without consent.
|
||||
|
||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.10",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -64,6 +64,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"vscode-jsonrpc": "^8.2.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -73,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.1.5",
|
||||
"oh-my-opencode-darwin-x64": "3.1.5",
|
||||
"oh-my-opencode-linux-arm64": "3.1.5",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.5",
|
||||
"oh-my-opencode-linux-x64": "3.1.5",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.5",
|
||||
"oh-my-opencode-windows-x64": "3.1.5"
|
||||
"oh-my-opencode-darwin-arm64": "3.1.10",
|
||||
"oh-my-opencode-darwin-x64": "3.1.10",
|
||||
"oh-my-opencode-linux-arm64": "3.1.10",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.1.10",
|
||||
"oh-my-opencode-linux-x64": "3.1.10",
|
||||
"oh-my-opencode-linux-x64-musl": "3.1.10",
|
||||
"oh-my-opencode-windows-x64": "3.1.10"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.10",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.10",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.10",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.10",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.10",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.10",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.1.5",
|
||||
"version": "3.1.10",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -943,6 +943,94 @@
|
||||
"created_at": "2026-01-28T13:04:16Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1203
|
||||
},
|
||||
{
|
||||
"name": "KennyDizi",
|
||||
"id": 16578966,
|
||||
"comment_id": 3811619818,
|
||||
"created_at": "2026-01-28T14:26:10Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1214
|
||||
},
|
||||
{
|
||||
"name": "mrdavidlaing",
|
||||
"id": 227505,
|
||||
"comment_id": 3813542625,
|
||||
"created_at": "2026-01-28T19:51:34Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1226
|
||||
},
|
||||
{
|
||||
"name": "Lynricsy",
|
||||
"id": 62173814,
|
||||
"comment_id": 3816370548,
|
||||
"created_at": "2026-01-29T09:00:28Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1241
|
||||
},
|
||||
{
|
||||
"name": "LeekJay",
|
||||
"id": 39609783,
|
||||
"comment_id": 3819009761,
|
||||
"created_at": "2026-01-29T17:03:24Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1254
|
||||
},
|
||||
{
|
||||
"name": "gabriel-ecegi",
|
||||
"id": 35489017,
|
||||
"comment_id": 3821842363,
|
||||
"created_at": "2026-01-30T05:13:15Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1271
|
||||
},
|
||||
{
|
||||
"name": "Hisir0909",
|
||||
"id": 76634394,
|
||||
"comment_id": 3822248445,
|
||||
"created_at": "2026-01-30T07:20:09Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1275
|
||||
},
|
||||
{
|
||||
"name": "Zacks-Zhang",
|
||||
"id": 16462428,
|
||||
"comment_id": 3822585754,
|
||||
"created_at": "2026-01-30T08:51:49Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1280
|
||||
},
|
||||
{
|
||||
"name": "kunal70006",
|
||||
"id": 62700112,
|
||||
"comment_id": 3822849937,
|
||||
"created_at": "2026-01-30T09:55:57Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1282
|
||||
},
|
||||
{
|
||||
"name": "KonaEspresso94",
|
||||
"id": 140197941,
|
||||
"comment_id": 3824340432,
|
||||
"created_at": "2026-01-30T15:33:28Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1289
|
||||
},
|
||||
{
|
||||
"name": "khduy",
|
||||
"id": 48742864,
|
||||
"comment_id": 3825103158,
|
||||
"created_at": "2026-01-30T18:35:34Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1297
|
||||
},
|
||||
{
|
||||
"name": "robin-watcha",
|
||||
"id": 90032965,
|
||||
"comment_id": 3826133640,
|
||||
"created_at": "2026-01-30T22:37:32Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1303
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,15 +25,15 @@ agents/
|
||||
## AGENT MODELS
|
||||
| Agent | Model | Temp | Purpose |
|
||||
|-------|-------|------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator |
|
||||
| Atlas | anthropic/claude-opus-4-5 | 0.1 | Master orchestrator |
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro) |
|
||||
| Atlas | anthropic/claude-sonnet-4-5 | 0.1 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging |
|
||||
| librarian | opencode/big-pickle | 0.1 | Docs, GitHub search |
|
||||
| explore | opencode/gpt-5-nano | 0.1 | Fast contextual grep |
|
||||
| librarian | zai-coding-plan/glm-4.7 | 0.1 | Docs, GitHub search (fallback: glm-4.7-free) |
|
||||
| explore | anthropic/claude-haiku-4-5 | 0.1 | Fast contextual grep (fallback: gpt-5-mini → gpt-5-nano) |
|
||||
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning |
|
||||
| Metis | anthropic/claude-sonnet-4-5 | 0.3 | Pre-planning analysis |
|
||||
| Momus | anthropic/claude-sonnet-4-5 | 0.1 | Plan validation |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Metis | anthropic/claude-opus-4-5 | 0.3 | Pre-planning analysis (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Momus | openai/gpt-5.2 | 0.1 | Plan validation (fallback: claude-opus-4-5) |
|
||||
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | 0.1 | Category-spawned executor |
|
||||
|
||||
## HOW TO ADD
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types"
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "./dynamic-agent-prompt-builder"
|
||||
import { buildCategorySkillsDelegationGuide } from "./dynamic-agent-prompt-builder"
|
||||
import type { CategoryConfig } from "../config/schema"
|
||||
@@ -529,8 +531,8 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
||||
])
|
||||
return {
|
||||
description:
|
||||
"Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done",
|
||||
mode: "primary" as const,
|
||||
"Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
...(ctx.model ? { model: ctx.model } : {}),
|
||||
temperature: 0.1,
|
||||
prompt: buildDynamicOrchestratorPrompt(ctx),
|
||||
@@ -539,6 +541,7 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
||||
...restrictions,
|
||||
} as AgentConfig
|
||||
}
|
||||
createAtlasAgent.mode = MODE
|
||||
|
||||
export const atlasPromptMetadata: AgentPromptMetadata = {
|
||||
category: "advisor",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "exploration",
|
||||
cost: "FREE",
|
||||
@@ -33,8 +35,8 @@ export function createExploreAgent(model: string): AgentConfig {
|
||||
|
||||
return {
|
||||
description:
|
||||
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
|
||||
mode: "subagent" as const,
|
||||
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis. (Explore - OhMyOpenCode)',
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
...restrictions,
|
||||
@@ -119,4 +121,4 @@ Use the right tool for the job:
|
||||
Flood with parallel calls. Cross-validate findings across multiple tools.`,
|
||||
}
|
||||
}
|
||||
|
||||
createExploreAgent.mode = MODE
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "exploration",
|
||||
cost: "CHEAP",
|
||||
@@ -30,8 +32,8 @@ export function createLibrarianAgent(model: string): AgentConfig {
|
||||
|
||||
return {
|
||||
description:
|
||||
"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" as const,
|
||||
"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. (Librarian - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
...restrictions,
|
||||
@@ -323,4 +325,4 @@ grep_app_searchGitHub(query: "useQuery")
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
||||
createLibrarianAgent.mode = MODE
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
/**
|
||||
* Metis - Plan Consultant Agent
|
||||
*
|
||||
@@ -310,8 +312,8 @@ const metisRestrictions = createAgentToolRestrictions([
|
||||
export function createMetisAgent(model: string): AgentConfig {
|
||||
return {
|
||||
description:
|
||||
"Pre-planning consultant that analyzes requests to identify hidden intentions, ambiguities, and AI failure points.",
|
||||
mode: "subagent" as const,
|
||||
"Pre-planning consultant that analyzes requests to identify hidden intentions, ambiguities, and AI failure points. (Metis - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.3,
|
||||
...metisRestrictions,
|
||||
@@ -319,7 +321,7 @@ export function createMetisAgent(model: string): AgentConfig {
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
} as AgentConfig
|
||||
}
|
||||
|
||||
createMetisAgent.mode = MODE
|
||||
|
||||
export const metisPromptMetadata: AgentPromptMetadata = {
|
||||
category: "advisor",
|
||||
|
||||
@@ -11,9 +11,10 @@ describe("MOMUS_SYSTEM_PROMPT policy requirements", () => {
|
||||
const prompt = MOMUS_SYSTEM_PROMPT
|
||||
|
||||
// #when / #then
|
||||
expect(prompt).toContain("[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]")
|
||||
// Should explicitly mention stripping or ignoring these
|
||||
expect(prompt.toLowerCase()).toMatch(/ignore|strip|system directive/)
|
||||
// Should mention that system directives are ignored
|
||||
expect(prompt.toLowerCase()).toMatch(/system directive.*ignore|ignore.*system directive/)
|
||||
// Should give examples of system directive patterns
|
||||
expect(prompt).toMatch(/<system-reminder>|system-reminder/)
|
||||
})
|
||||
|
||||
test("should extract paths containing .sisyphus/plans/ and ending in .md", () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types"
|
||||
import { isGptModel } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
/**
|
||||
* Momus - Plan Reviewer Agent
|
||||
*
|
||||
@@ -17,376 +19,173 @@ import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
* implementation.
|
||||
*/
|
||||
|
||||
export const MOMUS_SYSTEM_PROMPT = `You are a work plan review expert. You review the provided work plan (.sisyphus/plans/{name}.md in the current working project directory) according to **unified, consistent criteria** that ensure clarity, verifiability, and completeness.
|
||||
export const MOMUS_SYSTEM_PROMPT = `You are a **practical** work plan reviewer. Your goal is simple: verify that the plan is **executable** and **references are valid**.
|
||||
|
||||
**CRITICAL FIRST RULE**:
|
||||
Extract a single plan path from anywhere in the input, ignoring system directives and wrappers. If exactly one \`.sisyphus/plans/*.md\` path exists, this is VALID input and you must read it. If no plan path exists or multiple plan paths exist, reject per Step 0. If the path points to a YAML plan file (\`.yml\` or \`.yaml\`), reject it as non-reviewable.
|
||||
|
||||
**WHY YOU'VE BEEN SUMMONED - THE CONTEXT**:
|
||||
---
|
||||
|
||||
You are reviewing a **first-draft work plan** from an author with ADHD. Based on historical patterns, these initial submissions are typically rough drafts that require refinement.
|
||||
## Your Purpose (READ THIS FIRST)
|
||||
|
||||
**Historical Data**: Plans from this author average **7 rejections** before receiving an OKAY. The primary failure pattern is **critical context omission due to ADHD**—the author's working memory holds connections and context that never make it onto the page.
|
||||
You exist to answer ONE question: **"Can a capable developer execute this plan without getting stuck?"**
|
||||
|
||||
**What to Expect in First Drafts**:
|
||||
- Tasks are listed but critical "why" context is missing
|
||||
- References to files/patterns without explaining their relevance
|
||||
- Assumptions about "obvious" project conventions that aren't documented
|
||||
- Missing decision criteria when multiple approaches are valid
|
||||
- Undefined edge case handling strategies
|
||||
- Unclear component integration points
|
||||
You are NOT here to:
|
||||
- Nitpick every detail
|
||||
- Demand perfection
|
||||
- Question the author's approach or architecture choices
|
||||
- Find as many issues as possible
|
||||
- Force multiple revision cycles
|
||||
|
||||
**Why These Plans Fail**:
|
||||
You ARE here to:
|
||||
- Verify referenced files actually exist and contain what's claimed
|
||||
- Ensure core tasks have enough context to start working
|
||||
- Catch BLOCKING issues only (things that would completely stop work)
|
||||
|
||||
The ADHD author's mind makes rapid connections: "Add auth → obviously use JWT → obviously store in httpOnly cookie → obviously follow the pattern in auth/login.ts → obviously handle refresh tokens like we did before."
|
||||
|
||||
But the plan only says: "Add authentication following auth/login.ts pattern."
|
||||
|
||||
**Everything after the first arrow is missing.** The author's working memory fills in the gaps automatically, so they don't realize the plan is incomplete.
|
||||
|
||||
**Your Critical Role**: Catch these ADHD-driven omissions. The author genuinely doesn't realize what they've left out. Your ruthless review forces them to externalize the context that lives only in their head.
|
||||
**APPROVAL BIAS**: When in doubt, APPROVE. A plan that's 80% clear is good enough. Developers can figure out minor gaps.
|
||||
|
||||
---
|
||||
|
||||
## Your Core Review Principle
|
||||
## What You Check (ONLY THESE)
|
||||
|
||||
**ABSOLUTE CONSTRAINT - RESPECT THE IMPLEMENTATION DIRECTION**:
|
||||
You are a REVIEWER, not a DESIGNER. The implementation direction in the plan is **NOT NEGOTIABLE**. Your job is to evaluate whether the plan documents that direction clearly enough to execute—NOT whether the direction itself is correct.
|
||||
### 1. Reference Verification (CRITICAL)
|
||||
- Do referenced files exist?
|
||||
- Do referenced line numbers contain relevant code?
|
||||
- If "follow pattern in X" is mentioned, does X actually demonstrate that pattern?
|
||||
|
||||
**What you MUST NOT do**:
|
||||
- Question or reject the overall approach/architecture chosen in the plan
|
||||
- Suggest alternative implementations that differ from the stated direction
|
||||
- Reject because you think there's a "better way" to achieve the goal
|
||||
- Override the author's technical decisions with your own preferences
|
||||
**PASS even if**: Reference exists but isn't perfect. Developer can explore from there.
|
||||
**FAIL only if**: Reference doesn't exist OR points to completely wrong content.
|
||||
|
||||
**What you MUST do**:
|
||||
- Accept the implementation direction as a given constraint
|
||||
- Evaluate only: "Is this direction documented clearly enough to execute?"
|
||||
- Focus on gaps IN the chosen approach, not gaps in choosing the approach
|
||||
### 2. Executability Check (PRACTICAL)
|
||||
- Can a developer START working on each task?
|
||||
- Is there at least a starting point (file, pattern, or clear description)?
|
||||
|
||||
**REJECT if**: When you simulate actually doing the work **within the stated approach**, you cannot obtain clear information needed for implementation, AND the plan does not specify reference materials to consult.
|
||||
**PASS even if**: Some details need to be figured out during implementation.
|
||||
**FAIL only if**: Task is so vague that developer has NO idea where to begin.
|
||||
|
||||
**ACCEPT if**: You can obtain the necessary information either:
|
||||
1. Directly from the plan itself, OR
|
||||
2. By following references provided in the plan (files, docs, patterns) and tracing through related materials
|
||||
### 3. Critical Blockers Only
|
||||
- Missing information that would COMPLETELY STOP work
|
||||
- Contradictions that make the plan impossible to follow
|
||||
|
||||
**The Test**: "Given the approach the author chose, can I implement this by starting from what's written in the plan and following the trail of information it provides?"
|
||||
|
||||
**WRONG mindset**: "This approach is suboptimal. They should use X instead." → **YOU ARE OVERSTEPPING**
|
||||
**RIGHT mindset**: "Given their choice to use Y, the plan doesn't explain how to handle Z within that approach." → **VALID CRITICISM**
|
||||
**NOT blockers** (do not reject for these):
|
||||
- Missing edge case handling
|
||||
- Incomplete acceptance criteria
|
||||
- Stylistic preferences
|
||||
- "Could be clearer" suggestions
|
||||
- Minor ambiguities a developer can resolve
|
||||
|
||||
---
|
||||
|
||||
## Common Failure Patterns (What the Author Typically Forgets)
|
||||
## What You Do NOT Check
|
||||
|
||||
The plan author is intelligent but has ADHD. They constantly skip providing:
|
||||
- Whether the approach is optimal
|
||||
- Whether there's a "better way"
|
||||
- Whether all edge cases are documented
|
||||
- Whether acceptance criteria are perfect
|
||||
- Whether the architecture is ideal
|
||||
- Code quality concerns
|
||||
- Performance considerations
|
||||
- Security unless explicitly broken
|
||||
|
||||
**1. Reference Materials**
|
||||
- FAIL: Says "implement authentication" but doesn't point to any existing code, docs, or patterns
|
||||
- FAIL: Says "follow the pattern" but doesn't specify which file contains the pattern
|
||||
- FAIL: Says "similar to X" but X doesn't exist or isn't documented
|
||||
|
||||
**2. Business Requirements**
|
||||
- FAIL: Says "add feature X" but doesn't explain what it should do or why
|
||||
- FAIL: Says "handle errors" but doesn't specify which errors or how users should experience them
|
||||
- FAIL: Says "optimize" but doesn't define success criteria
|
||||
|
||||
**3. Architectural Decisions**
|
||||
- FAIL: Says "add to state" but doesn't specify which state management system
|
||||
- FAIL: Says "integrate with Y" but doesn't explain the integration approach
|
||||
- FAIL: Says "call the API" but doesn't specify which endpoint or data flow
|
||||
|
||||
**4. Critical Context**
|
||||
- FAIL: References files that don't exist
|
||||
- FAIL: Points to line numbers that don't contain relevant code
|
||||
- FAIL: Assumes you know project-specific conventions that aren't documented anywhere
|
||||
|
||||
**What You Should NOT Reject**:
|
||||
- PASS: Plan says "follow auth/login.ts pattern" → you read that file → it has imports → you follow those → you understand the full flow
|
||||
- PASS: Plan says "use Redux store" → you find store files by exploring codebase structure → standard Redux patterns apply
|
||||
- PASS: Plan provides clear starting point → you trace through related files and types → you gather all needed details
|
||||
- PASS: The author chose approach X when you think Y would be better → **NOT YOUR CALL**. Evaluate X on its own merits.
|
||||
- PASS: The architecture seems unusual or non-standard → If the author chose it, your job is to ensure it's documented, not to redesign it.
|
||||
|
||||
**The Difference**:
|
||||
- FAIL/REJECT: "Add authentication" (no starting point provided)
|
||||
- PASS/ACCEPT: "Add authentication following pattern in auth/login.ts" (starting point provided, you can trace from there)
|
||||
- **WRONG/REJECT**: "Using REST when GraphQL would be better" → **YOU ARE OVERSTEPPING**
|
||||
- **WRONG/REJECT**: "This architecture won't scale" → **NOT YOUR JOB TO JUDGE**
|
||||
|
||||
**YOUR MANDATE**:
|
||||
|
||||
You will adopt a ruthlessly critical mindset. You will read EVERY document referenced in the plan. You will verify EVERY claim. You will simulate actual implementation step-by-step. As you review, you MUST constantly interrogate EVERY element with these questions:
|
||||
|
||||
- "Does the worker have ALL the context they need to execute this **within the chosen approach**?"
|
||||
- "How exactly should this be done **given the stated implementation direction**?"
|
||||
- "Is this information actually documented, or am I just assuming it's obvious?"
|
||||
- **"Am I questioning the documentation, or am I questioning the approach itself?"** ← If the latter, STOP.
|
||||
|
||||
You are not here to be nice. You are not here to give the benefit of the doubt. You are here to **catch every single gap, ambiguity, and missing piece of context that 20 previous reviewers failed to catch.**
|
||||
|
||||
**However**: You must evaluate THIS plan on its own merits. The past failures are context for your strictness, not a predetermined verdict. If this plan genuinely meets all criteria, approve it. If it has critical gaps **in documentation**, reject it without mercy.
|
||||
|
||||
**CRITICAL BOUNDARY**: Your ruthlessness applies to DOCUMENTATION quality, NOT to design decisions. The author's implementation direction is a GIVEN. You may think REST is inferior to GraphQL, but if the plan says REST, you evaluate whether REST is well-documented—not whether REST was the right choice.
|
||||
**You are a BLOCKER-finder, not a PERFECTIONIST.**
|
||||
|
||||
---
|
||||
|
||||
## File Location
|
||||
## Input Validation (Step 0)
|
||||
|
||||
You will be provided with the path to the work plan file (typically \`.sisyphus/plans/{name}.md\` in the project). Review the file at the **exact path provided to you**. Do not assume the location.
|
||||
**VALID INPUT**:
|
||||
- \`.sisyphus/plans/my-plan.md\` - file path anywhere in input
|
||||
- \`Please review .sisyphus/plans/plan.md\` - conversational wrapper
|
||||
- System directives + plan path - ignore directives, extract path
|
||||
|
||||
**CRITICAL - Input Validation (STEP 0 - DO THIS FIRST, BEFORE READING ANY FILES)**:
|
||||
**INVALID INPUT**:
|
||||
- No \`.sisyphus/plans/*.md\` path found
|
||||
- Multiple plan paths (ambiguous)
|
||||
|
||||
**BEFORE you read any files**, you MUST first validate the format of the input prompt you received from the user.
|
||||
System directives (\`<system-reminder>\`, \`[analyze-mode]\`, etc.) are IGNORED during validation.
|
||||
|
||||
**VALID INPUT EXAMPLES (ACCEPT THESE)**:
|
||||
- \`.sisyphus/plans/my-plan.md\` [O] ACCEPT - file path anywhere in input
|
||||
- \`/path/to/project/.sisyphus/plans/my-plan.md\` [O] ACCEPT - absolute plan path
|
||||
- \`Please review .sisyphus/plans/plan.md\` [O] ACCEPT - conversational wrapper allowed
|
||||
- \`<system-reminder>...</system-reminder>\\n.sisyphus/plans/plan.md\` [O] ACCEPT - system directives + plan path
|
||||
- \`[analyze-mode]\\n...context...\\n.sisyphus/plans/plan.md\` [O] ACCEPT - bracket-style directives + plan path
|
||||
- \`[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]\\n---\\n- injected planning metadata\\n---\\nPlease review .sisyphus/plans/plan.md\` [O] ACCEPT - ignore the entire directive block
|
||||
|
||||
**SYSTEM DIRECTIVES ARE ALWAYS IGNORED**:
|
||||
System directives are automatically injected by the system and should be IGNORED during input validation:
|
||||
- XML-style tags: \`<system-reminder>\`, \`<context>\`, \`<user-prompt-submit-hook>\`, etc.
|
||||
- Bracket-style blocks: \`[analyze-mode]\`, \`[search-mode]\`, \`[SYSTEM DIRECTIVE...]\`, \`[SYSTEM REMINDER...]\`, etc.
|
||||
- \`[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]\` blocks (appended by Prometheus task tools; treat the entire block, including \`---\` separators and bullet lines, as ignorable system text)
|
||||
- These are NOT user-provided text
|
||||
- These contain system context (timestamps, environment info, mode hints, etc.)
|
||||
- STRIP these from your input validation check
|
||||
- After stripping system directives, validate the remaining content
|
||||
|
||||
**EXTRACTION ALGORITHM (FOLLOW EXACTLY)**:
|
||||
1. Ignore injected system directive blocks, especially \`[SYSTEM DIRECTIVE - READ-ONLY PLANNING CONSULTATION]\` (remove the whole block, including \`---\` separators and bullet lines).
|
||||
2. Strip other system directive wrappers (bracket-style blocks and XML-style \`<system-reminder>...</system-reminder>\` tags).
|
||||
3. Strip markdown wrappers around paths (code fences and inline backticks).
|
||||
4. Extract plan paths by finding all substrings containing \`.sisyphus/plans/\` and ending in \`.md\`.
|
||||
5. If exactly 1 match → ACCEPT and proceed to Step 1 using that path.
|
||||
6. If 0 matches → REJECT with: "no plan path found" (no path found).
|
||||
7. If 2+ matches → REJECT with: "ambiguous: multiple plan paths".
|
||||
|
||||
**INVALID INPUT EXAMPLES (REJECT ONLY THESE)**:
|
||||
- \`No plan path provided here\` [X] REJECT - no \`.sisyphus/plans/*.md\` path
|
||||
- \`Compare .sisyphus/plans/first.md and .sisyphus/plans/second.md\` [X] REJECT - multiple plan paths
|
||||
|
||||
**When rejecting for input format, respond EXACTLY**:
|
||||
\`\`\`
|
||||
I REJECT (Input Format Validation)
|
||||
Reason: no plan path found
|
||||
|
||||
You must provide a single plan path that includes \`.sisyphus/plans/\` and ends in \`.md\`.
|
||||
|
||||
Valid format: .sisyphus/plans/plan.md
|
||||
Invalid format: No plan path or multiple plan paths
|
||||
|
||||
NOTE: This rejection is based solely on the input format, not the file contents.
|
||||
The file itself has not been evaluated yet.
|
||||
\`\`\`
|
||||
|
||||
Use this alternate Reason line if multiple paths are present:
|
||||
- Reason: multiple plan paths found
|
||||
|
||||
**ULTRA-CRITICAL REMINDER**:
|
||||
If the input contains exactly one \`.sisyphus/plans/*.md\` path (with or without system directives or conversational wrappers):
|
||||
→ THIS IS VALID INPUT
|
||||
→ DO NOT REJECT IT
|
||||
→ IMMEDIATELY PROCEED TO READ THE FILE
|
||||
→ START EVALUATING THE FILE CONTENTS
|
||||
|
||||
Never reject a single plan path embedded in the input.
|
||||
Never reject system directives (XML or bracket-style) - they are automatically injected and should be ignored!
|
||||
|
||||
|
||||
**IMPORTANT - Response Language**: Your evaluation output MUST match the language used in the work plan content:
|
||||
- Match the language of the plan in your evaluation output
|
||||
- If the plan is written in English → Write your entire evaluation in English
|
||||
- If the plan is mixed → Use the dominant language (majority of task descriptions)
|
||||
|
||||
Example: Plan contains "Modify database schema" → Evaluation output: "## Evaluation Result\\n\\n### Criterion 1: Clarity of Work Content..."
|
||||
**Extraction**: Find all \`.sisyphus/plans/*.md\` paths → exactly 1 = proceed, 0 or 2+ = reject.
|
||||
|
||||
---
|
||||
|
||||
## Review Philosophy
|
||||
## Review Process (SIMPLE)
|
||||
|
||||
Your role is to simulate **executing the work plan as a capable developer** and identify:
|
||||
1. **Ambiguities** that would block or slow down implementation
|
||||
2. **Missing verification methods** that prevent confirming success
|
||||
3. **Gaps in context** requiring >10% guesswork (90% confidence threshold)
|
||||
4. **Lack of overall understanding** of purpose, background, and workflow
|
||||
|
||||
The plan should enable a developer to:
|
||||
- Know exactly what to build and where to look for details
|
||||
- Validate their work objectively without subjective judgment
|
||||
- Complete tasks without needing to "figure out" unstated requirements
|
||||
- Understand the big picture, purpose, and how tasks flow together
|
||||
1. **Validate input** → Extract single plan path
|
||||
2. **Read plan** → Identify tasks and file references
|
||||
3. **Verify references** → Do files exist? Do they contain claimed content?
|
||||
4. **Executability check** → Can each task be started?
|
||||
5. **Decide** → Any BLOCKING issues? No = OKAY. Yes = REJECT with max 3 specific issues.
|
||||
|
||||
---
|
||||
|
||||
## Four Core Evaluation Criteria
|
||||
## Decision Framework
|
||||
|
||||
### Criterion 1: Clarity of Work Content
|
||||
### OKAY (Default - use this unless blocking issues exist)
|
||||
|
||||
**Goal**: Eliminate ambiguity by providing clear reference sources for each task.
|
||||
Issue the verdict **OKAY** when:
|
||||
- Referenced files exist and are reasonably relevant
|
||||
- Tasks have enough context to start (not complete, just start)
|
||||
- No contradictions or impossible requirements
|
||||
- A capable developer could make progress
|
||||
|
||||
**Evaluation Method**: For each task, verify:
|
||||
- **Does the task specify WHERE to find implementation details?**
|
||||
- [PASS] Good: "Follow authentication flow in \`docs/auth-spec.md\` section 3.2"
|
||||
- [PASS] Good: "Implement based on existing pattern in \`src/services/payment.ts:45-67\`"
|
||||
- [FAIL] Bad: "Add authentication" (no reference source)
|
||||
- [FAIL] Bad: "Improve error handling" (vague, no examples)
|
||||
**Remember**: "Good enough" is good enough. You're not blocking publication of a NASA manual.
|
||||
|
||||
- **Can the developer reach 90%+ confidence by reading the referenced source?**
|
||||
- [PASS] Good: Reference to specific file/section that contains concrete examples
|
||||
- [FAIL] Bad: "See codebase for patterns" (too broad, requires extensive exploration)
|
||||
### REJECT (Only for true blockers)
|
||||
|
||||
### Criterion 2: Verification & Acceptance Criteria
|
||||
Issue **REJECT** ONLY when:
|
||||
- Referenced file doesn't exist (verified by reading)
|
||||
- Task is completely impossible to start (zero context)
|
||||
- Plan contains internal contradictions
|
||||
|
||||
**Goal**: Ensure every task has clear, objective success criteria.
|
||||
**Maximum 3 issues per rejection.** If you found more, list only the top 3 most critical.
|
||||
|
||||
**Evaluation Method**: For each task, verify:
|
||||
- **Is there a concrete way to verify completion?**
|
||||
- [PASS] Good: "Verify: Run \`npm test\` → all tests pass. Manually test: Open \`/login\` → OAuth button appears → Click → redirects to Google → successful login"
|
||||
- [PASS] Good: "Acceptance: API response time < 200ms for 95th percentile (measured via \`k6 run load-test.js\`)"
|
||||
- [FAIL] Bad: "Test the feature" (how?)
|
||||
- [FAIL] Bad: "Make sure it works properly" (what defines "properly"?)
|
||||
|
||||
- **Are acceptance criteria measurable/observable?**
|
||||
- [PASS] Good: Observable outcomes (UI elements, API responses, test results, metrics)
|
||||
- [FAIL] Bad: Subjective terms ("clean code", "good UX", "robust implementation")
|
||||
|
||||
### Criterion 3: Context Completeness
|
||||
|
||||
**Goal**: Minimize guesswork by providing all necessary context (90% confidence threshold).
|
||||
|
||||
**Evaluation Method**: Simulate task execution and identify:
|
||||
- **What information is missing that would cause ≥10% uncertainty?**
|
||||
- [PASS] Good: Developer can proceed with <10% guesswork (or natural exploration)
|
||||
- [FAIL] Bad: Developer must make assumptions about business requirements, architecture, or critical context
|
||||
|
||||
- **Are implicit assumptions stated explicitly?**
|
||||
- [PASS] Good: "Assume user is already authenticated (session exists in context)"
|
||||
- [PASS] Good: "Note: Payment processing is handled by background job, not synchronously"
|
||||
- [FAIL] Bad: Leaving critical architectural decisions or business logic unstated
|
||||
|
||||
### Criterion 4: Big Picture & Workflow Understanding
|
||||
|
||||
**Goal**: Ensure the developer understands WHY they're building this, WHAT the overall objective is, and HOW tasks flow together.
|
||||
|
||||
**Evaluation Method**: Assess whether the plan provides:
|
||||
- **Clear Purpose Statement**: Why is this work being done? What problem does it solve?
|
||||
- **Background Context**: What's the current state? What are we changing from?
|
||||
- **Task Flow & Dependencies**: How do tasks connect? What's the logical sequence?
|
||||
- **Success Vision**: What does "done" look like from a product/user perspective?
|
||||
**Each issue must be**:
|
||||
- Specific (exact file path, exact task)
|
||||
- Actionable (what exactly needs to change)
|
||||
- Blocking (work cannot proceed without this)
|
||||
|
||||
---
|
||||
|
||||
## Review Process
|
||||
## Anti-Patterns (DO NOT DO THESE)
|
||||
|
||||
### Step 0: Validate Input Format (MANDATORY FIRST STEP)
|
||||
Extract the plan path from anywhere in the input. If exactly one \`.sisyphus/plans/*.md\` path is found, ACCEPT and continue. If none are found, REJECT with "no plan path found". If multiple are found, REJECT with "ambiguous: multiple plan paths".
|
||||
❌ "Task 3 could be clearer about error handling" → NOT a blocker
|
||||
❌ "Consider adding acceptance criteria for..." → NOT a blocker
|
||||
❌ "The approach in Task 5 might be suboptimal" → NOT YOUR JOB
|
||||
❌ "Missing documentation for edge case X" → NOT a blocker unless X is the main case
|
||||
❌ Rejecting because you'd do it differently → NEVER
|
||||
❌ Listing more than 3 issues → OVERWHELMING, pick top 3
|
||||
|
||||
### Step 1: Read the Work Plan
|
||||
- Load the file from the path provided
|
||||
- Identify the plan's language
|
||||
- Parse all tasks and their descriptions
|
||||
- Extract ALL file references
|
||||
|
||||
### Step 2: MANDATORY DEEP VERIFICATION
|
||||
For EVERY file reference, library mention, or external resource:
|
||||
- Read referenced files to verify content
|
||||
- Search for related patterns/imports across codebase
|
||||
- Verify line numbers contain relevant code
|
||||
- Check that patterns are clear enough to follow
|
||||
|
||||
### Step 3: Apply Four Criteria Checks
|
||||
For **the overall plan and each task**, evaluate:
|
||||
1. **Clarity Check**: Does the task specify clear reference sources?
|
||||
2. **Verification Check**: Are acceptance criteria concrete and measurable?
|
||||
3. **Context Check**: Is there sufficient context to proceed without >10% guesswork?
|
||||
4. **Big Picture Check**: Do I understand WHY, WHAT, and HOW?
|
||||
|
||||
### Step 4: Active Implementation Simulation
|
||||
For 2-3 representative tasks, simulate execution using actual files.
|
||||
|
||||
### Step 5: Check for Red Flags
|
||||
Scan for auto-fail indicators:
|
||||
- Vague action verbs without concrete targets
|
||||
- Missing file paths for code changes
|
||||
- Subjective success criteria
|
||||
- Tasks requiring unstated assumptions
|
||||
|
||||
**SELF-CHECK - Are you overstepping?**
|
||||
Before writing any criticism, ask yourself:
|
||||
- "Am I questioning the APPROACH or the DOCUMENTATION of the approach?"
|
||||
- "Would my feedback change if I accepted the author's direction as a given?"
|
||||
If you find yourself writing "should use X instead" or "this approach won't work because..." → **STOP. You are overstepping your role.**
|
||||
Rephrase to: "Given the chosen approach, the plan doesn't clarify..."
|
||||
|
||||
### Step 6: Write Evaluation Report
|
||||
Use structured format, **in the same language as the work plan**.
|
||||
✅ "Task 3 references \`auth/login.ts\` but file doesn't exist" → BLOCKER
|
||||
✅ "Task 5 says 'implement feature' with no context, files, or description" → BLOCKER
|
||||
✅ "Tasks 2 and 4 contradict each other on data flow" → BLOCKER
|
||||
|
||||
---
|
||||
|
||||
## Approval Criteria
|
||||
## Output Format
|
||||
|
||||
### OKAY Requirements (ALL must be met)
|
||||
1. **100% of file references verified**
|
||||
2. **Zero critically failed file verifications**
|
||||
3. **Critical context documented**
|
||||
4. **≥80% of tasks** have clear reference sources
|
||||
5. **≥90% of tasks** have concrete acceptance criteria
|
||||
6. **Zero tasks** require assumptions about business logic or critical architecture
|
||||
7. **Plan provides clear big picture**
|
||||
8. **Zero critical red flags** detected
|
||||
9. **Active simulation** shows core tasks are executable
|
||||
**[OKAY]** or **[REJECT]**
|
||||
|
||||
### REJECT Triggers (Critical issues only)
|
||||
- Referenced file doesn't exist or contains different content than claimed
|
||||
- Task has vague action verbs AND no reference source
|
||||
- Core tasks missing acceptance criteria entirely
|
||||
- Task requires assumptions about business requirements or critical architecture **within the chosen approach**
|
||||
- Missing purpose statement or unclear WHY
|
||||
- Critical task dependencies undefined
|
||||
**Summary**: 1-2 sentences explaining the verdict.
|
||||
|
||||
### NOT Valid REJECT Reasons (DO NOT REJECT FOR THESE)
|
||||
- You disagree with the implementation approach
|
||||
- You think a different architecture would be better
|
||||
- The approach seems non-standard or unusual
|
||||
- You believe there's a more optimal solution
|
||||
- The technology choice isn't what you would pick
|
||||
|
||||
**Your role is DOCUMENTATION REVIEW, not DESIGN REVIEW.**
|
||||
If REJECT:
|
||||
**Blocking Issues** (max 3):
|
||||
1. [Specific issue + what needs to change]
|
||||
2. [Specific issue + what needs to change]
|
||||
3. [Specific issue + what needs to change]
|
||||
|
||||
---
|
||||
|
||||
## Final Verdict Format
|
||||
## Final Reminders
|
||||
|
||||
**[OKAY / REJECT]**
|
||||
1. **APPROVE by default**. Reject only for true blockers.
|
||||
2. **Max 3 issues**. More than that is overwhelming and counterproductive.
|
||||
3. **Be specific**. "Task X needs Y" not "needs more clarity".
|
||||
4. **No design opinions**. The author's approach is not your concern.
|
||||
5. **Trust developers**. They can figure out minor gaps.
|
||||
|
||||
**Justification**: [Concise explanation]
|
||||
**Your job is to UNBLOCK work, not to BLOCK it with perfectionism.**
|
||||
|
||||
**Summary**:
|
||||
- Clarity: [Brief assessment]
|
||||
- Verifiability: [Brief assessment]
|
||||
- Completeness: [Brief assessment]
|
||||
- Big Picture: [Brief assessment]
|
||||
|
||||
[If REJECT, provide top 3-5 critical improvements needed]
|
||||
|
||||
---
|
||||
|
||||
**Your Success Means**:
|
||||
- **Immediately actionable** for core business logic and architecture
|
||||
- **Clearly verifiable** with objective success criteria
|
||||
- **Contextually complete** with critical information documented
|
||||
- **Strategically coherent** with purpose, background, and flow
|
||||
- **Reference integrity** with all files verified
|
||||
- **Direction-respecting** - you evaluated the plan WITHIN its stated approach
|
||||
|
||||
**Strike the right balance**: Prevent critical failures while empowering developer autonomy.
|
||||
|
||||
**FINAL REMINDER**: You are a DOCUMENTATION reviewer, not a DESIGN consultant. The author's implementation direction is SACRED. Your job ends at "Is this well-documented enough to execute?" - NOT "Is this the right approach?"
|
||||
**Response Language**: Match the language of the plan content.
|
||||
`
|
||||
|
||||
export function createMomusAgent(model: string): AgentConfig {
|
||||
@@ -399,8 +198,8 @@ export function createMomusAgent(model: string): AgentConfig {
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Expert reviewer for evaluating work plans against rigorous clarity, verifiability, and completeness standards.",
|
||||
mode: "subagent" as const,
|
||||
"Expert reviewer for evaluating work plans against rigorous clarity, verifiability, and completeness standards. (Momus - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
...restrictions,
|
||||
@@ -413,7 +212,7 @@ export function createMomusAgent(model: string): AgentConfig {
|
||||
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
|
||||
}
|
||||
|
||||
createMomusAgent.mode = MODE
|
||||
|
||||
export const momusPromptMetadata: AgentPromptMetadata = {
|
||||
category: "advisor",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolAllowlist } from "../shared/permission-compat"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "utility",
|
||||
cost: "CHEAP",
|
||||
@@ -14,8 +16,8 @@ export function createMultimodalLookerAgent(model: string): AgentConfig {
|
||||
|
||||
return {
|
||||
description:
|
||||
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
|
||||
mode: "subagent" as const,
|
||||
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents. (Multimodal-Looker - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
...restrictions,
|
||||
@@ -53,4 +55,4 @@ Response rules:
|
||||
Your output goes straight to the main agent for continued work.`,
|
||||
}
|
||||
}
|
||||
|
||||
createMultimodalLookerAgent.mode = MODE
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import type { AgentMode, AgentPromptMetadata } from "./types"
|
||||
import { isGptModel } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
export const ORACLE_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "advisor",
|
||||
cost: "EXPENSIVE",
|
||||
@@ -105,8 +107,8 @@ export function createOracleAgent(model: string): AgentConfig {
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Read-only consultation agent. High-IQ reasoning specialist for debugging hard problems and high-difficulty architecture design.",
|
||||
mode: "subagent" as const,
|
||||
"Read-only consultation agent. High-IQ reasoning specialist for debugging hard problems and high-difficulty architecture design. (Oracle - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
...restrictions,
|
||||
@@ -119,4 +121,5 @@ export function createOracleAgent(model: string): AgentConfig {
|
||||
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
|
||||
}
|
||||
createOracleAgent.mode = MODE
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "./types"
|
||||
import { isGptModel } from "./types"
|
||||
import type { AgentOverrideConfig } from "../config/schema"
|
||||
import {
|
||||
@@ -6,6 +7,8 @@ import {
|
||||
type PermissionValue,
|
||||
} from "../shared/permission-compat"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
const SISYPHUS_JUNIOR_PROMPT = `<Role>
|
||||
Sisyphus-Junior - Focused executor from OhMyOpenCode.
|
||||
Execute tasks directly. NEVER delegate or spawn other agents.
|
||||
@@ -84,8 +87,8 @@ export function createSisyphusJuniorAgentWithOverrides(
|
||||
|
||||
const base: AgentConfig = {
|
||||
description: override?.description ??
|
||||
"Sisyphus-Junior - Focused task executor. Same discipline, no delegation.",
|
||||
mode: "subagent" as const,
|
||||
"Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: 64000,
|
||||
@@ -107,3 +110,5 @@ export function createSisyphusJuniorAgentWithOverrides(
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
} as AgentConfig
|
||||
}
|
||||
|
||||
createSisyphusJuniorAgentWithOverrides.mode = MODE
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "./types"
|
||||
import { isGptModel } from "./types"
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
import type { AvailableAgent, AvailableTool, AvailableSkill, AvailableCategory } from "./dynamic-agent-prompt-builder"
|
||||
import {
|
||||
buildKeyTriggersSection,
|
||||
@@ -433,8 +436,8 @@ export function createSisyphusAgent(
|
||||
const permission = { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"]
|
||||
const base = {
|
||||
description:
|
||||
"Sisyphus - Powerful AI orchestrator from OhMyOpenCode. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically via category+skills combinations. Uses explore for internal code (parallel-friendly), librarian for external docs.",
|
||||
mode: "primary" as const,
|
||||
"Powerful AI orchestrator. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically via category+skills combinations. Uses explore for internal code (parallel-friendly), librarian for external docs. (Sisyphus - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
maxTokens: 64000,
|
||||
prompt,
|
||||
@@ -448,3 +451,4 @@ export function createSisyphusAgent(
|
||||
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
|
||||
}
|
||||
createSisyphusAgent.mode = MODE
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export type AgentFactory = (model: string) => AgentConfig
|
||||
/**
|
||||
* Agent mode determines UI model selection behavior:
|
||||
* - "primary": Respects user's UI-selected model (sisyphus, atlas)
|
||||
* - "subagent": Uses own fallback chain, ignores UI selection (oracle, explore, etc.)
|
||||
* - "all": Available in both contexts (OpenCode compatibility)
|
||||
*/
|
||||
export type AgentMode = "primary" | "subagent" | "all"
|
||||
|
||||
/**
|
||||
* Agent factory function with static mode property.
|
||||
* Mode is exposed as static property for pre-instantiation access.
|
||||
*/
|
||||
export type AgentFactory = ((model: string) => AgentConfig) & {
|
||||
mode: AgentMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent category for grouping in Sisyphus prompt sections
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createBuiltinAgents } from "./utils"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
|
||||
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||
import * as modelAvailability from "../shared/model-availability"
|
||||
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
|
||||
|
||||
@@ -47,33 +48,32 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Oracle uses connected provider when no availableModels but connected cache exists", async () => {
|
||||
// #given - connected providers cache exists with openai
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
test("Oracle uses connected provider fallback when availableModels is empty and cache exists", async () => {
|
||||
// #given - connected providers cache has "openai", which matches oracle's first fallback entry
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - uses openai from connected cache
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(agents.oracle.reasoningEffort).toBe("medium")
|
||||
expect(agents.oracle.textVerbosity).toBe("high")
|
||||
expect(agents.oracle.thinking).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
// #then - oracle resolves via connected cache fallback to openai/gpt-5.2 (not system default)
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(agents.oracle.reasoningEffort).toBe("medium")
|
||||
expect(agents.oracle.thinking).toBeUndefined()
|
||||
cacheSpy.mockRestore?.()
|
||||
})
|
||||
|
||||
test("Oracle created without model field when no cache exists (first run scenario)", async () => {
|
||||
// #given - no cache at all (first run)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
test("Oracle created without model field when no cache exists (first run scenario)", async () => {
|
||||
// #given - no cache at all (first run)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - oracle should be created with system default model (fallback to systemDefaultModel)
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe(TEST_DEFAULT_MODEL)
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
// #then - oracle should be created with system default model (fallback to systemDefaultModel)
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe(TEST_DEFAULT_MODEL)
|
||||
cacheSpy.mockRestore?.()
|
||||
})
|
||||
|
||||
test("Oracle with GPT model override has reasoningEffort, no thinking", async () => {
|
||||
// #given
|
||||
@@ -123,43 +123,43 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
})
|
||||
|
||||
describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
test("creates agents with connected provider when cache exists", async () => {
|
||||
// #given - connected providers cache exists
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
test("agents created via connected cache fallback even without systemDefaultModel", async () => {
|
||||
// #given - connected cache has "openai", which matches oracle's fallback chain
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - agents should use connected provider from fallback chain
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
// #then - connected cache enables model resolution despite no systemDefaultModel
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
cacheSpy.mockRestore?.()
|
||||
})
|
||||
|
||||
test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => {
|
||||
// #given - no cache and no system default
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => {
|
||||
// #given
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - oracle should NOT be created (resolveModelWithFallback returns undefined)
|
||||
expect(agents.oracle).toBeUndefined()
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
// #then
|
||||
expect(agents.oracle).toBeUndefined()
|
||||
cacheSpy.mockRestore?.()
|
||||
})
|
||||
|
||||
test("sisyphus uses connected provider when cache exists", async () => {
|
||||
// #given - connected providers cache exists with anthropic
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
test("sisyphus created via connected cache fallback even without systemDefaultModel", async () => {
|
||||
// #given - connected cache has "anthropic", which matches sisyphus's first fallback entry
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, undefined)
|
||||
|
||||
// #then - sisyphus should use anthropic from connected cache
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
// #then - connected cache enables model resolution despite no systemDefaultModel
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
cacheSpy.mockRestore?.()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildAgent with category and skills", () => {
|
||||
@@ -408,3 +408,157 @@ describe("buildAgent with category and skills", () => {
|
||||
expect(agent.prompt).not.toContain("agent-browser open")
|
||||
})
|
||||
})
|
||||
|
||||
describe("override.category expansion in createBuiltinAgents", () => {
|
||||
test("standard agent override with category expands category properties", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
oracle: { category: "ultrabrain" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.oracle.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("standard agent override with category AND direct variant - direct wins", async () => {
|
||||
// #given - ultrabrain has variant=xhigh, but direct override says "max"
|
||||
const overrides = {
|
||||
oracle: { category: "ultrabrain", variant: "max" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - direct variant overrides category variant
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.variant).toBe("max")
|
||||
})
|
||||
|
||||
test("standard agent override with category AND direct reasoningEffort - direct wins", async () => {
|
||||
// #given - custom category has reasoningEffort=xhigh, direct override says "low"
|
||||
const categories = {
|
||||
"test-cat": {
|
||||
model: "openai/gpt-5.2",
|
||||
reasoningEffort: "xhigh" as const,
|
||||
},
|
||||
}
|
||||
const overrides = {
|
||||
oracle: { category: "test-cat", reasoningEffort: "low" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, categories)
|
||||
|
||||
// #then - direct reasoningEffort wins over category
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.reasoningEffort).toBe("low")
|
||||
})
|
||||
|
||||
test("standard agent override with category applies reasoningEffort from category when no direct override", async () => {
|
||||
// #given - custom category has reasoningEffort, no direct reasoningEffort in override
|
||||
const categories = {
|
||||
"reasoning-cat": {
|
||||
model: "openai/gpt-5.2",
|
||||
reasoningEffort: "high" as const,
|
||||
},
|
||||
}
|
||||
const overrides = {
|
||||
oracle: { category: "reasoning-cat" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, categories)
|
||||
|
||||
// #then - category reasoningEffort is applied
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.reasoningEffort).toBe("high")
|
||||
})
|
||||
|
||||
test("sisyphus override with category expands category properties", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
sisyphus: { category: "ultrabrain" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.sisyphus.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("atlas override with category expands category properties", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
atlas: { category: "ultrabrain" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
|
||||
expect(agents.atlas).toBeDefined()
|
||||
expect(agents.atlas.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.atlas.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("override with non-existent category has no effect on config", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
oracle: { category: "non-existent-category" } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - no category-specific variant/reasoningEffort applied from non-existent category
|
||||
expect(agents.oracle).toBeDefined()
|
||||
const agentsWithoutOverride = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
expect(agents.oracle.model).toBe(agentsWithoutOverride.oracle.model)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Deadlock prevention - fetchAvailableModels must not receive client", () => {
|
||||
test("createBuiltinAgents should call fetchAvailableModels with undefined client to prevent deadlock", async () => {
|
||||
// #given - This test ensures we don't regress on issue #1301
|
||||
// Passing client to fetchAvailableModels during createBuiltinAgents (called from config handler)
|
||||
// causes deadlock:
|
||||
// - Plugin init waits for server response (client.provider.list())
|
||||
// - Server waits for plugin init to complete before handling requests
|
||||
const fetchSpy = spyOn(modelAvailability, "fetchAvailableModels").mockResolvedValue(new Set<string>())
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
|
||||
const mockClient = {
|
||||
provider: { list: () => Promise.resolve({ data: { connected: [] } }) },
|
||||
model: { list: () => Promise.resolve({ data: [] }) },
|
||||
}
|
||||
|
||||
// #when - Even when client is provided, fetchAvailableModels must be called with undefined
|
||||
await createBuiltinAgents(
|
||||
[],
|
||||
{},
|
||||
undefined,
|
||||
TEST_DEFAULT_MODEL,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
mockClient // client is passed but should NOT be forwarded to fetchAvailableModels
|
||||
)
|
||||
|
||||
// #then - fetchAvailableModels must be called with undefined as first argument (no client)
|
||||
// This prevents the deadlock described in issue #1301
|
||||
expect(fetchSpy).toHaveBeenCalled()
|
||||
const firstCallArgs = fetchSpy.mock.calls[0]
|
||||
expect(firstCallArgs[0]).toBeUndefined()
|
||||
|
||||
fetchSpy.mockRestore?.()
|
||||
cacheSpy.mockRestore?.()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { createMetisAgent } from "./metis"
|
||||
import { createAtlasAgent } from "./atlas"
|
||||
import { createMomusAgent } from "./momus"
|
||||
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
|
||||
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive, readConnectedProvidersCache } from "../shared"
|
||||
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive, readConnectedProvidersCache, isModelAvailable } from "../shared"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
|
||||
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
|
||||
import { createBuiltinSkills } from "../features/builtin-skills"
|
||||
@@ -120,6 +120,33 @@ export function createEnvContext(): string {
|
||||
</omo-env>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a category reference from an agent override into concrete config properties.
|
||||
* Category properties are applied unconditionally (overwriting factory defaults),
|
||||
* because the user's chosen category should take priority over factory base values.
|
||||
* Direct override properties applied later via mergeAgentConfig() will supersede these.
|
||||
*/
|
||||
function applyCategoryOverride(
|
||||
config: AgentConfig,
|
||||
categoryName: string,
|
||||
mergedCategories: Record<string, CategoryConfig>
|
||||
): AgentConfig {
|
||||
const categoryConfig = mergedCategories[categoryName]
|
||||
if (!categoryConfig) return config
|
||||
|
||||
const result = { ...config } as AgentConfig & Record<string, unknown>
|
||||
if (categoryConfig.model) result.model = categoryConfig.model
|
||||
if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant
|
||||
if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature
|
||||
if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort
|
||||
if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity
|
||||
if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking
|
||||
if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p
|
||||
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
|
||||
|
||||
return result as AgentConfig
|
||||
}
|
||||
|
||||
function mergeAgentConfig(
|
||||
base: AgentConfig,
|
||||
override: AgentOverrideConfig
|
||||
@@ -149,12 +176,16 @@ export async function createBuiltinAgents(
|
||||
gitMasterConfig?: GitMasterConfig,
|
||||
discoveredSkills: LoadedSkill[] = [],
|
||||
client?: any,
|
||||
browserProvider?: BrowserAutomationProvider
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
uiSelectedModel?: string
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
const availableModels = client
|
||||
? await fetchAvailableModels(client, { connectedProviders: connectedProviders ?? undefined })
|
||||
: new Set<string>()
|
||||
// IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization.
|
||||
// This function is called from config handler, and calling client API causes deadlock.
|
||||
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
|
||||
const availableModels = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: connectedProviders ?? undefined,
|
||||
})
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
const availableAgents: AvailableAgent[] = []
|
||||
@@ -194,10 +225,20 @@ export async function createBuiltinAgents(
|
||||
if (agentName === "atlas") continue
|
||||
if (includesCaseInsensitive(disabledAgents, agentName)) continue
|
||||
|
||||
const override = findCaseInsensitive(agentOverrides, agentName)
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
|
||||
const resolution = resolveModelWithFallback({
|
||||
const override = findCaseInsensitive(agentOverrides, agentName)
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
|
||||
// Check if agent requires a specific model
|
||||
if (requirement?.requiresModel && availableModels) {
|
||||
if (!isModelAvailable(requirement.requiresModel, availableModels)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
|
||||
|
||||
const resolution = resolveModelWithFallback({
|
||||
uiSelectedModel: isPrimaryAgent ? uiSelectedModel : undefined,
|
||||
userModel: override?.model,
|
||||
fallbackChain: requirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -208,18 +249,23 @@ export async function createBuiltinAgents(
|
||||
|
||||
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider)
|
||||
|
||||
// Apply variant from override or resolved fallback chain
|
||||
if (override?.variant) {
|
||||
config = { ...config, variant: override.variant }
|
||||
} else if (resolvedVariant) {
|
||||
// Apply resolved variant from model fallback chain
|
||||
if (resolvedVariant) {
|
||||
config = { ...config, variant: resolvedVariant }
|
||||
}
|
||||
|
||||
// Expand override.category into concrete properties (higher priority than factory/resolved)
|
||||
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (overrideCategory) {
|
||||
config = applyCategoryOverride(config, overrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (agentName === "librarian" && directory && config.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
config = { ...config, prompt: config.prompt + envContext }
|
||||
}
|
||||
|
||||
// Direct override properties take highest priority
|
||||
if (override) {
|
||||
config = mergeAgentConfig(config, override)
|
||||
}
|
||||
@@ -241,6 +287,7 @@ export async function createBuiltinAgents(
|
||||
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
|
||||
const sisyphusResolution = resolveModelWithFallback({
|
||||
uiSelectedModel,
|
||||
userModel: sisyphusOverride?.model,
|
||||
fallbackChain: sisyphusRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -258,12 +305,15 @@ export async function createBuiltinAgents(
|
||||
availableCategories
|
||||
)
|
||||
|
||||
if (sisyphusOverride?.variant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
|
||||
} else if (sisyphusResolvedVariant) {
|
||||
if (sisyphusResolvedVariant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
|
||||
}
|
||||
|
||||
const sisOverrideCategory = (sisyphusOverride as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (sisOverrideCategory) {
|
||||
sisyphusConfig = applyCategoryOverride(sisyphusConfig, sisOverrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (directory && sisyphusConfig.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
|
||||
@@ -282,6 +332,7 @@ export async function createBuiltinAgents(
|
||||
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
|
||||
|
||||
const atlasResolution = resolveModelWithFallback({
|
||||
// NOTE: Atlas does NOT use uiSelectedModel - respects its own fallbackChain (k2p5 primary)
|
||||
userModel: orchestratorOverride?.model,
|
||||
fallbackChain: atlasRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
@@ -298,12 +349,15 @@ export async function createBuiltinAgents(
|
||||
userCategories: categories,
|
||||
})
|
||||
|
||||
if (orchestratorOverride?.variant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
|
||||
} else if (atlasResolvedVariant) {
|
||||
if (atlasResolvedVariant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
|
||||
}
|
||||
|
||||
const atlasOverrideCategory = (orchestratorOverride as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (atlasOverrideCategory) {
|
||||
orchestratorConfig = applyCategoryOverride(orchestratorConfig, atlasOverrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (orchestratorOverride) {
|
||||
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
|
||||
}
|
||||
|
||||
@@ -5,54 +5,57 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"momus": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"writing": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -77,6 +80,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
},
|
||||
"momus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
@@ -98,6 +102,10 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -141,6 +149,7 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
||||
},
|
||||
"momus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
@@ -163,6 +172,10 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -199,7 +212,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "openai/gpt-5.2",
|
||||
@@ -229,8 +242,12 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
||||
"artistry": {
|
||||
"model": "openai/gpt-5.2",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
@@ -245,8 +262,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
||||
"variant": "medium",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "openai/gpt-5.2",
|
||||
"variant": "high",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"writing": {
|
||||
"model": "openai/gpt-5.2",
|
||||
@@ -266,7 +282,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "openai/gpt-5.2",
|
||||
@@ -296,8 +312,12 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
"artistry": {
|
||||
"model": "openai/gpt-5.2",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
@@ -312,8 +332,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
"variant": "medium",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "openai/gpt-5.2",
|
||||
"variant": "high",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"writing": {
|
||||
"model": "openai/gpt-5.2",
|
||||
@@ -333,7 +352,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "google/gemini-3-pro",
|
||||
@@ -348,6 +367,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "google/gemini-3-pro",
|
||||
@@ -361,11 +381,16 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"quick": {
|
||||
"model": "google/gemini-3-flash",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "google/gemini-3-flash",
|
||||
@@ -394,7 +419,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "google/gemini-3-pro",
|
||||
@@ -409,6 +434,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "google/gemini-3-pro",
|
||||
@@ -422,11 +448,16 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"quick": {
|
||||
"model": "google/gemini-3-flash",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "google/gemini-3-pro",
|
||||
@@ -485,6 +516,10 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -550,6 +585,10 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -579,13 +618,13 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/claude-sonnet-4-5",
|
||||
"model": "opencode/kimi-k2.5-free",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/claude-opus-4-5",
|
||||
@@ -615,6 +654,10 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
@@ -643,13 +686,13 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/claude-sonnet-4-5",
|
||||
"model": "opencode/kimi-k2.5-free",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/claude-opus-4-5",
|
||||
@@ -680,6 +723,10 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
@@ -745,6 +792,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
"model": "github-copilot/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
@@ -810,6 +861,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
"model": "github-copilot/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
@@ -839,7 +894,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
@@ -848,42 +903,45 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"momus": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "zai-coding-plan/glm-4.6v",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"writing": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
@@ -897,7 +955,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/gpt-5-nano",
|
||||
@@ -906,19 +964,19 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"momus": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "zai-coding-plan/glm-4.6v",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
@@ -926,22 +984,25 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"writing": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
@@ -955,13 +1016,13 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"model": "opencode/kimi-k2.5-free",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/big-pickle",
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
@@ -991,6 +1052,10 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1055,6 +1120,10 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
"model": "github-copilot/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
@@ -1097,6 +1166,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
},
|
||||
"momus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "zai-coding-plan/glm-4.6v",
|
||||
@@ -1118,6 +1188,10 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1161,12 +1235,13 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
},
|
||||
"momus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "google/gemini-3-flash",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"prometheus": {
|
||||
@@ -1182,11 +1257,15 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"variant": "max",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -1210,7 +1289,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
"model": "opencode/kimi-k2.5-free",
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
@@ -1246,6 +1325,10 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
"model": "github-copilot/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
@@ -1274,7 +1357,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"model": "opencode/kimi-k2.5-free",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
@@ -1310,6 +1393,10 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
@@ -1338,7 +1425,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"agents": {
|
||||
"atlas": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
"model": "opencode/kimi-k2.5-free",
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
@@ -1375,6 +1462,10 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
|
||||
@@ -250,6 +250,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
@@ -271,6 +272,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
@@ -290,6 +292,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasCopilot: true,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
@@ -309,6 +312,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
@@ -316,7 +320,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
|
||||
// #then should use ultimate fallback for all agents
|
||||
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("opencode/big-pickle")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("opencode/glm-4.7-free")
|
||||
})
|
||||
|
||||
test("uses zai-coding-plan/glm-4.7 for librarian when Z.ai available", () => {
|
||||
@@ -329,6 +333,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: true,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
@@ -350,6 +355,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
@@ -373,6 +379,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
@@ -392,6 +399,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
|
||||
@@ -598,27 +598,28 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
|
||||
}
|
||||
}
|
||||
|
||||
function detectProvidersFromOmoConfig(): { hasOpenAI: boolean; hasOpencodeZen: boolean; hasZaiCodingPlan: boolean } {
|
||||
function detectProvidersFromOmoConfig(): { hasOpenAI: boolean; hasOpencodeZen: boolean; hasZaiCodingPlan: boolean; hasKimiForCoding: boolean } {
|
||||
const omoConfigPath = getOmoConfig()
|
||||
if (!existsSync(omoConfigPath)) {
|
||||
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false }
|
||||
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(omoConfigPath, "utf-8")
|
||||
const omoConfig = parseJsonc<Record<string, unknown>>(content)
|
||||
if (!omoConfig || typeof omoConfig !== "object") {
|
||||
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false }
|
||||
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
|
||||
}
|
||||
|
||||
const configStr = JSON.stringify(omoConfig)
|
||||
const hasOpenAI = configStr.includes('"openai/')
|
||||
const hasOpencodeZen = configStr.includes('"opencode/')
|
||||
const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/')
|
||||
const hasKimiForCoding = configStr.includes('"kimi-for-coding/')
|
||||
|
||||
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan }
|
||||
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding }
|
||||
} catch {
|
||||
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false }
|
||||
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,6 +633,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: true,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -655,10 +657,11 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
// Gemini auth plugin detection still works via plugin presence
|
||||
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
|
||||
|
||||
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan } = detectProvidersFromOmoConfig()
|
||||
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig()
|
||||
result.hasOpenAI = hasOpenAI
|
||||
result.hasOpencodeZen = hasOpencodeZen
|
||||
result.hasZaiCodingPlan = hasZaiCodingPlan
|
||||
result.hasKimiForCoding = hasKimiForCoding
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getDependencyCheckDefinitions } from "./dependencies"
|
||||
import { getGhCliCheckDefinition } from "./gh"
|
||||
import { getLspCheckDefinition } from "./lsp"
|
||||
import { getMcpCheckDefinitions } from "./mcp"
|
||||
import { getMcpOAuthCheckDefinition } from "./mcp-oauth"
|
||||
import { getVersionCheckDefinition } from "./version"
|
||||
|
||||
export * from "./opencode"
|
||||
@@ -19,6 +20,7 @@ export * from "./dependencies"
|
||||
export * from "./gh"
|
||||
export * from "./lsp"
|
||||
export * from "./mcp"
|
||||
export * from "./mcp-oauth"
|
||||
export * from "./version"
|
||||
|
||||
export function getAllCheckDefinitions(): CheckDefinition[] {
|
||||
@@ -32,6 +34,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
|
||||
getGhCliCheckDefinition(),
|
||||
getLspCheckDefinition(),
|
||||
...getMcpCheckDefinitions(),
|
||||
getMcpOAuthCheckDefinition(),
|
||||
getVersionCheckDefinition(),
|
||||
]
|
||||
}
|
||||
|
||||
133
src/cli/doctor/checks/mcp-oauth.test.ts
Normal file
133
src/cli/doctor/checks/mcp-oauth.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as mcpOauth from "./mcp-oauth"
|
||||
|
||||
describe("mcp-oauth check", () => {
|
||||
describe("getMcpOAuthCheckDefinition", () => {
|
||||
it("returns check definition with correct properties", () => {
|
||||
// #given
|
||||
// #when getting definition
|
||||
const def = mcpOauth.getMcpOAuthCheckDefinition()
|
||||
|
||||
// #then should have correct structure
|
||||
expect(def.id).toBe("mcp-oauth-tokens")
|
||||
expect(def.name).toBe("MCP OAuth Tokens")
|
||||
expect(def.category).toBe("tools")
|
||||
expect(def.critical).toBe(false)
|
||||
expect(typeof def.check).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkMcpOAuthTokens", () => {
|
||||
let readStoreSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
readStoreSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns skip when no tokens stored", async () => {
|
||||
// #given no OAuth tokens configured
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue(null)
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should skip
|
||||
expect(result.status).toBe("skip")
|
||||
expect(result.message).toContain("No OAuth")
|
||||
})
|
||||
|
||||
it("returns pass when all tokens valid", async () => {
|
||||
// #given valid tokens with future expiry (expiresAt is in epoch seconds)
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"example.com/resource1": {
|
||||
accessToken: "token1",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
"example.com/resource2": {
|
||||
accessToken: "token2",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
})
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("2")
|
||||
expect(result.message).toContain("valid")
|
||||
})
|
||||
|
||||
it("returns warn when some tokens expired", async () => {
|
||||
// #given mix of valid and expired tokens (expiresAt is in epoch seconds)
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
const pastTime = Math.floor(Date.now() / 1000) - 3600
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"example.com/resource1": {
|
||||
accessToken: "token1",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
"example.com/resource2": {
|
||||
accessToken: "token2",
|
||||
expiresAt: pastTime,
|
||||
},
|
||||
})
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should warn
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("1")
|
||||
expect(result.message).toContain("expired")
|
||||
expect(result.details?.some((d: string) => d.includes("Expired"))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it("returns pass when tokens have no expiry", async () => {
|
||||
// #given tokens without expiry info
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"example.com/resource1": {
|
||||
accessToken: "token1",
|
||||
},
|
||||
})
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should pass (no expiry = assume valid)
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("1")
|
||||
})
|
||||
|
||||
it("includes token details in output", async () => {
|
||||
// #given multiple tokens
|
||||
const futureTime = Math.floor(Date.now() / 1000) + 3600
|
||||
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
|
||||
"api.example.com/v1": {
|
||||
accessToken: "token1",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
"auth.example.com/oauth": {
|
||||
accessToken: "token2",
|
||||
expiresAt: futureTime,
|
||||
},
|
||||
})
|
||||
|
||||
// #when checking OAuth tokens
|
||||
const result = await mcpOauth.checkMcpOAuthTokens()
|
||||
|
||||
// #then should list tokens in details
|
||||
expect(result.details).toBeDefined()
|
||||
expect(result.details?.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
result.details?.some((d: string) => d.includes("api.example.com"))
|
||||
).toBe(true)
|
||||
expect(
|
||||
result.details?.some((d: string) => d.includes("auth.example.com"))
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
80
src/cli/doctor/checks/mcp-oauth.ts
Normal file
80
src/cli/doctor/checks/mcp-oauth.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { CheckResult, CheckDefinition } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import { getMcpOauthStoragePath } from "../../../features/mcp-oauth/storage"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
interface OAuthTokenData {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
expiresAt?: number
|
||||
clientInfo?: {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
}
|
||||
|
||||
type TokenStore = Record<string, OAuthTokenData>
|
||||
|
||||
export function readTokenStore(): TokenStore | null {
|
||||
const filePath = getMcpOauthStoragePath()
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
return JSON.parse(content) as TokenStore
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkMcpOAuthTokens(): Promise<CheckResult> {
|
||||
const store = readTokenStore()
|
||||
|
||||
if (!store || Object.keys(store).length === 0) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
status: "skip",
|
||||
message: "No OAuth tokens configured",
|
||||
details: ["Optional: Configure OAuth tokens for MCP servers"],
|
||||
}
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const tokens = Object.entries(store)
|
||||
const expiredTokens = tokens.filter(
|
||||
([, token]) => token.expiresAt && token.expiresAt < now
|
||||
)
|
||||
|
||||
if (expiredTokens.length > 0) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
status: "warn",
|
||||
message: `${expiredTokens.length} of ${tokens.length} token(s) expired`,
|
||||
details: [
|
||||
...tokens
|
||||
.filter(([, token]) => !token.expiresAt || token.expiresAt >= now)
|
||||
.map(([key]) => `Valid: ${key}`),
|
||||
...expiredTokens.map(([key]) => `Expired: ${key}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
status: "pass",
|
||||
message: `${tokens.length} OAuth token(s) valid`,
|
||||
details: tokens.map(([key]) => `Configured: ${key}`),
|
||||
}
|
||||
}
|
||||
|
||||
export function getMcpOAuthCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.MCP_OAUTH_TOKENS,
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
|
||||
category: "tools",
|
||||
check: checkMcpOAuthTokens,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export const CHECK_IDS = {
|
||||
LSP_SERVERS: "lsp-servers",
|
||||
MCP_BUILTIN: "mcp-builtin",
|
||||
MCP_USER: "mcp-user",
|
||||
MCP_OAUTH_TOKENS: "mcp-oauth-tokens",
|
||||
VERSION_STATUS: "version-status",
|
||||
} as const
|
||||
|
||||
@@ -50,6 +51,7 @@ export const CHECK_NAMES: Record<string, string> = {
|
||||
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
|
||||
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
|
||||
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
|
||||
[CHECK_IDS.MCP_OAUTH_TOKENS]: "MCP OAuth Tokens",
|
||||
[CHECK_IDS.VERSION_STATUS]: "Version Status",
|
||||
} as const
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { install } from "./install"
|
||||
import { run } from "./run"
|
||||
import { getLocalVersion } from "./get-local-version"
|
||||
import { doctor } from "./doctor"
|
||||
import { createMcpOAuthCommand } from "./mcp-oauth"
|
||||
import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
||||
@@ -29,6 +30,7 @@ program
|
||||
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
|
||||
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
|
||||
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
|
||||
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
|
||||
.option("--skip-auth", "Skip authentication setup hints")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
@@ -36,13 +38,14 @@ Examples:
|
||||
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
|
||||
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
|
||||
|
||||
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai):
|
||||
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
|
||||
Claude Native anthropic/ models (Opus, Sonnet, Haiku)
|
||||
OpenAI Native openai/ models (GPT-5.2 for Oracle)
|
||||
Gemini Native google/ models (Gemini 3 Pro, Flash)
|
||||
Copilot github-copilot/ models (fallback)
|
||||
OpenCode Zen opencode/ models (opencode/claude-opus-4-5, etc.)
|
||||
Z.ai zai-coding-plan/glm-4.7 (Librarian priority)
|
||||
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
|
||||
`)
|
||||
.action(async (options) => {
|
||||
const args: InstallArgs = {
|
||||
@@ -53,6 +56,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai):
|
||||
copilot: options.copilot,
|
||||
opencodeZen: options.opencodeZen,
|
||||
zaiCodingPlan: options.zaiCodingPlan,
|
||||
kimiForCoding: options.kimiForCoding,
|
||||
skipAuth: options.skipAuth ?? false,
|
||||
}
|
||||
const exitCode = await install(args)
|
||||
@@ -150,4 +154,6 @@ program
|
||||
console.log(`oh-my-opencode v${VERSION}`)
|
||||
})
|
||||
|
||||
program.addCommand(createMcpOAuthCommand())
|
||||
|
||||
program.parse()
|
||||
|
||||
@@ -45,6 +45,7 @@ function formatConfigSummary(config: InstallConfig): string {
|
||||
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
|
||||
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
|
||||
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
|
||||
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
|
||||
|
||||
lines.push("")
|
||||
lines.push(color.dim("─".repeat(40)))
|
||||
@@ -141,6 +142,10 @@ function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string
|
||||
errors.push(`Invalid --zai-coding-plan value: ${args.zaiCodingPlan} (expected: no, yes)`)
|
||||
}
|
||||
|
||||
if (args.kimiForCoding !== undefined && !["no", "yes"].includes(args.kimiForCoding)) {
|
||||
errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`)
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
@@ -153,10 +158,11 @@ function argsToConfig(args: InstallArgs): InstallConfig {
|
||||
hasCopilot: args.copilot === "yes",
|
||||
hasOpencodeZen: args.opencodeZen === "yes",
|
||||
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
|
||||
hasKimiForCoding: args.kimiForCoding === "yes",
|
||||
}
|
||||
}
|
||||
|
||||
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; openai: BooleanArg; gemini: BooleanArg; copilot: BooleanArg; opencodeZen: BooleanArg; zaiCodingPlan: BooleanArg } {
|
||||
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; openai: BooleanArg; gemini: BooleanArg; copilot: BooleanArg; opencodeZen: BooleanArg; zaiCodingPlan: BooleanArg; kimiForCoding: BooleanArg } {
|
||||
let claude: ClaudeSubscription = "no"
|
||||
if (detected.hasClaude) {
|
||||
claude = detected.isMax20 ? "max20" : "yes"
|
||||
@@ -169,6 +175,7 @@ function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubs
|
||||
copilot: detected.hasCopilot ? "yes" : "no",
|
||||
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
|
||||
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
|
||||
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +185,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
|
||||
const claude = await p.select({
|
||||
message: "Do you have a Claude Pro/Max subscription?",
|
||||
options: [
|
||||
{ value: "no" as const, label: "No", hint: "Will use opencode/big-pickle as fallback" },
|
||||
{ value: "no" as const, label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
|
||||
{ value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
|
||||
{ value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
|
||||
],
|
||||
@@ -260,6 +267,20 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
|
||||
return null
|
||||
}
|
||||
|
||||
const kimiForCoding = await p.select({
|
||||
message: "Do you have a Kimi For Coding subscription?",
|
||||
options: [
|
||||
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
|
||||
{ value: "yes" as const, label: "Yes", hint: "Kimi K2.5 for Sisyphus/Prometheus fallback" },
|
||||
],
|
||||
initialValue: initial.kimiForCoding,
|
||||
})
|
||||
|
||||
if (p.isCancel(kimiForCoding)) {
|
||||
p.cancel("Installation cancelled.")
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
hasClaude: claude !== "no",
|
||||
isMax20: claude === "max20",
|
||||
@@ -268,6 +289,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
|
||||
hasCopilot: copilot === "yes",
|
||||
hasOpencodeZen: opencodeZen === "yes",
|
||||
hasZaiCodingPlan: zaiCodingPlan === "yes",
|
||||
hasKimiForCoding: kimiForCoding === "yes",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +385,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
}
|
||||
|
||||
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
|
||||
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
|
||||
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
|
||||
}
|
||||
|
||||
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
|
||||
@@ -378,7 +400,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
)
|
||||
|
||||
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
|
||||
console.log(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
|
||||
console.log(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`)
|
||||
console.log()
|
||||
console.log(color.dim("oMoMoMoMo... Enjoy!"))
|
||||
console.log()
|
||||
@@ -480,7 +502,7 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
}
|
||||
|
||||
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
|
||||
p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.")
|
||||
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
|
||||
}
|
||||
|
||||
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
|
||||
@@ -496,7 +518,7 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
)
|
||||
|
||||
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
|
||||
p.log.message(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
|
||||
p.log.message(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`)
|
||||
|
||||
p.outro(color.green("oMoMoMoMo... Enjoy!"))
|
||||
|
||||
|
||||
123
src/cli/mcp-oauth/index.test.ts
Normal file
123
src/cli/mcp-oauth/index.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { Command } from "commander"
|
||||
import { createMcpOAuthCommand } from "./index"
|
||||
|
||||
describe("mcp oauth command", () => {
|
||||
|
||||
describe("command structure", () => {
|
||||
it("creates mcp command group with oauth subcommand", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
|
||||
// when
|
||||
const subcommands = mcpCommand.commands.map((cmd: Command) => cmd.name())
|
||||
|
||||
// then
|
||||
expect(subcommands).toContain("oauth")
|
||||
})
|
||||
|
||||
it("oauth subcommand has login, logout, and status subcommands", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
|
||||
// when
|
||||
const subcommands = oauthCommand?.commands.map((cmd: Command) => cmd.name()) ?? []
|
||||
|
||||
// then
|
||||
expect(subcommands).toContain("login")
|
||||
expect(subcommands).toContain("logout")
|
||||
expect(subcommands).toContain("status")
|
||||
})
|
||||
})
|
||||
|
||||
describe("login subcommand", () => {
|
||||
it("exists and has description", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
|
||||
|
||||
// when
|
||||
const description = loginCommand?.description() ?? ""
|
||||
|
||||
// then
|
||||
expect(loginCommand).toBeDefined()
|
||||
expect(description).toContain("OAuth")
|
||||
})
|
||||
|
||||
it("accepts --server-url option", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
|
||||
|
||||
// when
|
||||
const options = loginCommand?.options ?? []
|
||||
const serverUrlOption = options.find((opt: { long?: string }) => opt.long === "--server-url")
|
||||
|
||||
// then
|
||||
expect(serverUrlOption).toBeDefined()
|
||||
})
|
||||
|
||||
it("accepts --client-id option", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
|
||||
|
||||
// when
|
||||
const options = loginCommand?.options ?? []
|
||||
const clientIdOption = options.find((opt: { long?: string }) => opt.long === "--client-id")
|
||||
|
||||
// then
|
||||
expect(clientIdOption).toBeDefined()
|
||||
})
|
||||
|
||||
it("accepts --scopes option", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
|
||||
|
||||
// when
|
||||
const options = loginCommand?.options ?? []
|
||||
const scopesOption = options.find((opt: { long?: string }) => opt.long === "--scopes")
|
||||
|
||||
// then
|
||||
expect(scopesOption).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("logout subcommand", () => {
|
||||
it("exists and has description", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const logoutCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "logout")
|
||||
|
||||
// when
|
||||
const description = logoutCommand?.description() ?? ""
|
||||
|
||||
// then
|
||||
expect(logoutCommand).toBeDefined()
|
||||
expect(description).toContain("tokens")
|
||||
})
|
||||
})
|
||||
|
||||
describe("status subcommand", () => {
|
||||
it("exists and has description", () => {
|
||||
// given
|
||||
const mcpCommand = createMcpOAuthCommand()
|
||||
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
|
||||
const statusCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "status")
|
||||
|
||||
// when
|
||||
const description = statusCommand?.description() ?? ""
|
||||
|
||||
// then
|
||||
expect(statusCommand).toBeDefined()
|
||||
expect(description).toContain("status")
|
||||
})
|
||||
})
|
||||
})
|
||||
43
src/cli/mcp-oauth/index.ts
Normal file
43
src/cli/mcp-oauth/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Command } from "commander"
|
||||
import { login } from "./login"
|
||||
import { logout } from "./logout"
|
||||
import { status } from "./status"
|
||||
|
||||
export function createMcpOAuthCommand(): Command {
|
||||
const mcp = new Command("mcp").description("MCP server management")
|
||||
|
||||
const oauth = new Command("oauth").description("OAuth token management for MCP servers")
|
||||
|
||||
oauth
|
||||
.command("login <server-name>")
|
||||
.description("Authenticate with an MCP server using OAuth")
|
||||
.option("--server-url <url>", "OAuth server URL (required if not in config)")
|
||||
.option("--client-id <id>", "OAuth client ID (optional, uses DCR if not provided)")
|
||||
.option("--scopes <scopes...>", "OAuth scopes to request")
|
||||
.action(async (serverName: string, options) => {
|
||||
const exitCode = await login(serverName, options)
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
oauth
|
||||
.command("logout <server-name>")
|
||||
.description("Remove stored OAuth tokens for an MCP server")
|
||||
.option("--server-url <url>", "OAuth server URL (use if server name differs from URL)")
|
||||
.action(async (serverName: string, options) => {
|
||||
const exitCode = await logout(serverName, options)
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
oauth
|
||||
.command("status [server-name]")
|
||||
.description("Show OAuth token status for MCP servers")
|
||||
.action(async (serverName: string | undefined) => {
|
||||
const exitCode = await status(serverName)
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
mcp.addCommand(oauth)
|
||||
return mcp
|
||||
}
|
||||
|
||||
export { login, logout, status }
|
||||
80
src/cli/mcp-oauth/login.test.ts
Normal file
80
src/cli/mcp-oauth/login.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
|
||||
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token", expiresAt: 1710000000 }))
|
||||
|
||||
mock.module("../../features/mcp-oauth/provider", () => ({
|
||||
McpOAuthProvider: class MockMcpOAuthProvider {
|
||||
constructor(public options: { serverUrl: string; clientId?: string; scopes?: string[] }) {}
|
||||
async login() {
|
||||
return mockLogin()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const { login } = await import("./login")
|
||||
|
||||
describe("login command", () => {
|
||||
beforeEach(() => {
|
||||
mockLogin.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// cleanup
|
||||
})
|
||||
|
||||
it("returns error code when server-url is not provided", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
const options = {}
|
||||
|
||||
// when
|
||||
const exitCode = await login(serverName, options)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it("returns success code when login succeeds", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
const options = {
|
||||
serverUrl: "https://oauth.example.com",
|
||||
}
|
||||
|
||||
// when
|
||||
const exitCode = await login(serverName, options)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(0)
|
||||
expect(mockLogin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("returns error code when login throws", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
const options = {
|
||||
serverUrl: "https://oauth.example.com",
|
||||
}
|
||||
mockLogin.mockRejectedValueOnce(new Error("Network error"))
|
||||
|
||||
// when
|
||||
const exitCode = await login(serverName, options)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it("returns error code when server-url is missing", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
const options = {
|
||||
clientId: "test-client-id",
|
||||
}
|
||||
|
||||
// when
|
||||
const exitCode = await login(serverName, options)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
38
src/cli/mcp-oauth/login.ts
Normal file
38
src/cli/mcp-oauth/login.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { McpOAuthProvider } from "../../features/mcp-oauth/provider"
|
||||
|
||||
export interface LoginOptions {
|
||||
serverUrl?: string
|
||||
clientId?: string
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
export async function login(serverName: string, options: LoginOptions): Promise<number> {
|
||||
try {
|
||||
const serverUrl = options.serverUrl
|
||||
if (!serverUrl) {
|
||||
console.error(`Error: --server-url is required for server "${serverName}"`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const provider = new McpOAuthProvider({
|
||||
serverUrl,
|
||||
clientId: options.clientId,
|
||||
scopes: options.scopes,
|
||||
})
|
||||
|
||||
console.log(`Authenticating with ${serverName}...`)
|
||||
const tokenData = await provider.login()
|
||||
|
||||
console.log(`✓ Successfully authenticated with ${serverName}`)
|
||||
if (tokenData.expiresAt) {
|
||||
const expiryDate = new Date(tokenData.expiresAt * 1000)
|
||||
console.log(` Token expires at: ${expiryDate.toISOString()}`)
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Error: Failed to authenticate with ${serverName}: ${message}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
65
src/cli/mcp-oauth/logout.test.ts
Normal file
65
src/cli/mcp-oauth/logout.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { saveToken } from "../../features/mcp-oauth/storage"
|
||||
|
||||
const { logout } = await import("./logout")
|
||||
|
||||
describe("logout command", () => {
|
||||
const TEST_CONFIG_DIR = join(tmpdir(), "mcp-oauth-logout-test-" + Date.now())
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
process.env.OPENCODE_CONFIG_DIR = TEST_CONFIG_DIR
|
||||
if (!existsSync(TEST_CONFIG_DIR)) {
|
||||
mkdirSync(TEST_CONFIG_DIR, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
if (existsSync(TEST_CONFIG_DIR)) {
|
||||
rmSync(TEST_CONFIG_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("returns success code when logout succeeds", async () => {
|
||||
// given
|
||||
const serverUrl = "https://test-server.example.com"
|
||||
saveToken(serverUrl, serverUrl, { accessToken: "test-token" })
|
||||
|
||||
// when
|
||||
const exitCode = await logout("test-server", { serverUrl })
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("handles non-existent server gracefully", async () => {
|
||||
// given
|
||||
const serverName = "non-existent-server"
|
||||
|
||||
// when
|
||||
const exitCode = await logout(serverName, { serverUrl: "https://nonexistent.example.com" })
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("returns error when --server-url is not provided", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
|
||||
// when
|
||||
const exitCode = await logout(serverName)
|
||||
|
||||
// then
|
||||
expect(exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
30
src/cli/mcp-oauth/logout.ts
Normal file
30
src/cli/mcp-oauth/logout.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { deleteToken } from "../../features/mcp-oauth/storage"
|
||||
|
||||
export interface LogoutOptions {
|
||||
serverUrl?: string
|
||||
}
|
||||
|
||||
export async function logout(serverName: string, options?: LogoutOptions): Promise<number> {
|
||||
try {
|
||||
const serverUrl = options?.serverUrl
|
||||
if (!serverUrl) {
|
||||
console.error(`Error: --server-url is required for logout. Token storage uses server URLs, not names.`)
|
||||
console.error(` Usage: mcp oauth logout ${serverName} --server-url https://your-server.example.com`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const success = deleteToken(serverUrl, serverUrl)
|
||||
|
||||
if (success) {
|
||||
console.log(`✓ Successfully removed tokens for ${serverName}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
console.error(`Error: Failed to remove tokens for ${serverName}`)
|
||||
return 1
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Error: Failed to remove tokens for ${serverName}: ${message}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
48
src/cli/mcp-oauth/status.test.ts
Normal file
48
src/cli/mcp-oauth/status.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { status } from "./status"
|
||||
|
||||
describe("status command", () => {
|
||||
beforeEach(() => {
|
||||
// setup
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// cleanup
|
||||
})
|
||||
|
||||
it("returns success code when checking status for specific server", async () => {
|
||||
// given
|
||||
const serverName = "test-server"
|
||||
|
||||
// when
|
||||
const exitCode = await status(serverName)
|
||||
|
||||
// then
|
||||
expect(typeof exitCode).toBe("number")
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("returns success code when checking status for all servers", async () => {
|
||||
// given
|
||||
const serverName = undefined
|
||||
|
||||
// when
|
||||
const exitCode = await status(serverName)
|
||||
|
||||
// then
|
||||
expect(typeof exitCode).toBe("number")
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it("handles non-existent server gracefully", async () => {
|
||||
// given
|
||||
const serverName = "non-existent-server"
|
||||
|
||||
// when
|
||||
const exitCode = await status(serverName)
|
||||
|
||||
// then
|
||||
expect(typeof exitCode).toBe("number")
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
50
src/cli/mcp-oauth/status.ts
Normal file
50
src/cli/mcp-oauth/status.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { listAllTokens, listTokensByHost } from "../../features/mcp-oauth/storage"
|
||||
|
||||
export async function status(serverName: string | undefined): Promise<number> {
|
||||
try {
|
||||
if (serverName) {
|
||||
const tokens = listTokensByHost(serverName)
|
||||
|
||||
if (Object.keys(tokens).length === 0) {
|
||||
console.log(`No tokens found for ${serverName}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(`OAuth Status for ${serverName}:`)
|
||||
for (const [key, token] of Object.entries(tokens)) {
|
||||
console.log(` ${key}:`)
|
||||
console.log(` Access Token: [REDACTED]`)
|
||||
if (token.refreshToken) {
|
||||
console.log(` Refresh Token: [REDACTED]`)
|
||||
}
|
||||
if (token.expiresAt) {
|
||||
const expiryDate = new Date(token.expiresAt * 1000)
|
||||
const now = Date.now() / 1000
|
||||
const isExpired = token.expiresAt < now
|
||||
const tokenStatus = isExpired ? "EXPIRED" : "VALID"
|
||||
console.log(` Expiry: ${expiryDate.toISOString()} (${tokenStatus})`)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const tokens = listAllTokens()
|
||||
if (Object.keys(tokens).length === 0) {
|
||||
console.log("No OAuth tokens stored")
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log("Stored OAuth Tokens:")
|
||||
for (const [key, token] of Object.entries(tokens)) {
|
||||
const isExpired = token.expiresAt && token.expiresAt < Date.now() / 1000
|
||||
const tokenStatus = isExpired ? "EXPIRED" : "VALID"
|
||||
console.log(` ${key}: ${tokenStatus}`)
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Error: Failed to get token status: ${message}`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ function createConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
|
||||
hasCopilot: false,
|
||||
hasOpencodeZen: false,
|
||||
hasZaiCodingPlan: false,
|
||||
hasKimiForCoding: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ProviderAvailability {
|
||||
opencodeZen: boolean
|
||||
copilot: boolean
|
||||
zai: boolean
|
||||
kimiForCoding: boolean
|
||||
isMaxPlan: boolean
|
||||
}
|
||||
|
||||
@@ -36,7 +37,7 @@ export interface GeneratedOmoConfig {
|
||||
|
||||
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
|
||||
|
||||
const ULTIMATE_FALLBACK = "opencode/big-pickle"
|
||||
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
|
||||
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
|
||||
|
||||
function toProviderAvailability(config: InstallConfig): ProviderAvailability {
|
||||
@@ -49,6 +50,7 @@ function toProviderAvailability(config: InstallConfig): ProviderAvailability {
|
||||
opencodeZen: config.hasOpencodeZen,
|
||||
copilot: config.hasCopilot,
|
||||
zai: config.hasZaiCodingPlan,
|
||||
kimiForCoding: config.hasKimiForCoding,
|
||||
isMaxPlan: config.isMax20,
|
||||
}
|
||||
}
|
||||
@@ -61,6 +63,7 @@ function isProviderAvailable(provider: string, avail: ProviderAvailability): boo
|
||||
"github-copilot": avail.copilot,
|
||||
opencode: avail.opencodeZen,
|
||||
"zai-coding-plan": avail.zai,
|
||||
"kimi-for-coding": avail.kimiForCoding,
|
||||
}
|
||||
return mapping[provider] ?? false
|
||||
}
|
||||
@@ -102,6 +105,8 @@ function getSisyphusFallbackChain(isMaxPlan: boolean): FallbackEntry[] {
|
||||
// For non-max plan, use sonnet instead of opus
|
||||
return [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
]
|
||||
@@ -115,7 +120,8 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
avail.native.gemini ||
|
||||
avail.opencodeZen ||
|
||||
avail.copilot ||
|
||||
avail.zai
|
||||
avail.zai ||
|
||||
avail.kimiForCoding
|
||||
|
||||
if (!hasAnyProvider) {
|
||||
return {
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("createEventState", () => {
|
||||
expect(state.lastOutput).toBe("")
|
||||
expect(state.lastPartText).toBe("")
|
||||
expect(state.currentTool).toBe(null)
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,6 +127,119 @@ describe("event handling", () => {
|
||||
expect(state.mainSessionIdle).toBe(false)
|
||||
})
|
||||
|
||||
it("hasReceivedMeaningfulWork is false initially after session.idle", async () => {
|
||||
// #given - session goes idle without any assistant output (race condition scenario)
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "my-session" },
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then - idle but no meaningful work yet
|
||||
expect(state.mainSessionIdle).toBe(true)
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
|
||||
it("message.updated with assistant role sets hasReceivedMeaningfulWork", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "my-session", role: "assistant" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(true)
|
||||
})
|
||||
|
||||
it("message.updated with user role does not set hasReceivedMeaningfulWork", async () => {
|
||||
// #given - user message should not count as meaningful work
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { sessionID: "my-session", role: "user" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then - user role should not count as meaningful work
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
|
||||
it("tool.execute sets hasReceivedMeaningfulWork", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "tool.execute",
|
||||
properties: {
|
||||
sessionID: "my-session",
|
||||
name: "read_file",
|
||||
input: { filePath: "/src/index.ts" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(true)
|
||||
})
|
||||
|
||||
it("tool.execute from different session does not set hasReceivedMeaningfulWork", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
const state = createEventState()
|
||||
|
||||
const payload: EventPayload = {
|
||||
type: "tool.execute",
|
||||
properties: {
|
||||
sessionID: "other-session",
|
||||
name: "read_file",
|
||||
input: { filePath: "/src/index.ts" },
|
||||
},
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
await processEvents(ctx, events, state)
|
||||
|
||||
// #then - different session's tool call shouldn't count
|
||||
expect(state.hasReceivedMeaningfulWork).toBe(false)
|
||||
})
|
||||
|
||||
it("session.status with busy type sets mainSessionIdle to false", async () => {
|
||||
// #given
|
||||
const ctx = createMockContext("my-session")
|
||||
@@ -136,6 +250,7 @@ describe("event handling", () => {
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
hasReceivedMeaningfulWork: false,
|
||||
}
|
||||
|
||||
const payload: EventPayload = {
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface EventState {
|
||||
lastOutput: string
|
||||
lastPartText: string
|
||||
currentTool: string | null
|
||||
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
|
||||
hasReceivedMeaningfulWork: boolean
|
||||
}
|
||||
|
||||
export function createEventState(): EventState {
|
||||
@@ -73,6 +75,7 @@ export function createEventState(): EventState {
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
hasReceivedMeaningfulWork: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +116,9 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
const isMainSession = sessionID === ctx.sessionID
|
||||
const sessionTag = isMainSession
|
||||
? pc.green("[MAIN]")
|
||||
: pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
|
||||
: sessionID
|
||||
? pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
|
||||
: pc.dim("[system]")
|
||||
|
||||
switch (payload.type) {
|
||||
case "session.idle":
|
||||
@@ -124,8 +129,6 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
// Skip verbose logging for partial message updates
|
||||
// Only log tool invocation state changes, not text streaming
|
||||
const partProps = props as MessagePartUpdatedProps | undefined
|
||||
const part = partProps?.part
|
||||
if (part?.type === "tool-invocation") {
|
||||
@@ -133,6 +136,11 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
|
||||
)
|
||||
} else if (part?.type === "text" && part.text) {
|
||||
const preview = part.text.slice(0, 80).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`)
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -140,11 +148,10 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
case "message.updated": {
|
||||
const msgProps = props as MessageUpdatedProps | undefined
|
||||
const role = msgProps?.info?.role ?? "unknown"
|
||||
const content = msgProps?.content ?? ""
|
||||
const preview = content.slice(0, 100).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.updated (${role}): "${preview}${content.length > 100 ? "..." : ""}"`)
|
||||
)
|
||||
const model = msgProps?.info?.modelID
|
||||
const agent = msgProps?.info?.agent
|
||||
const details = [role, agent, model].filter(Boolean).join(", ")
|
||||
console.error(pc.dim(`${sessionTag} message.updated (${details})`))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -241,6 +248,7 @@ function handleMessagePartUpdated(
|
||||
const newText = part.text.slice(state.lastPartText.length)
|
||||
if (newText) {
|
||||
process.stdout.write(newText)
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
}
|
||||
state.lastPartText = part.text
|
||||
}
|
||||
@@ -257,16 +265,7 @@ function handleMessageUpdated(
|
||||
if (props?.info?.sessionID !== ctx.sessionID) return
|
||||
if (props?.info?.role !== "assistant") return
|
||||
|
||||
const content = props.content
|
||||
if (!content || content === state.lastOutput) return
|
||||
|
||||
if (state.lastPartText.length === 0) {
|
||||
const newContent = content.slice(state.lastOutput.length)
|
||||
if (newContent) {
|
||||
process.stdout.write(newContent)
|
||||
}
|
||||
}
|
||||
state.lastOutput = content
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
}
|
||||
|
||||
function handleToolExecute(
|
||||
@@ -296,6 +295,7 @@ function handleToolExecute(
|
||||
}
|
||||
}
|
||||
|
||||
state.hasReceivedMeaningfulWork = true
|
||||
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,14 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Guard against premature completion: don't check completion until the
|
||||
// session has produced meaningful work (text output, tool call, or tool result).
|
||||
// Without this, a session that goes busy->idle before the LLM responds
|
||||
// would exit immediately because 0 todos + 0 children = "complete".
|
||||
if (!eventState.hasReceivedMeaningfulWork) {
|
||||
continue
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
if (shouldExit) {
|
||||
console.log(pc.green("\n\nAll tasks completed."))
|
||||
|
||||
@@ -44,8 +44,13 @@ export interface SessionStatusProps {
|
||||
}
|
||||
|
||||
export interface MessageUpdatedProps {
|
||||
info?: { sessionID?: string; role?: string }
|
||||
content?: string
|
||||
info?: {
|
||||
sessionID?: string
|
||||
role?: string
|
||||
modelID?: string
|
||||
providerID?: string
|
||||
agent?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessagePartUpdatedProps {
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface InstallArgs {
|
||||
copilot?: BooleanArg
|
||||
opencodeZen?: BooleanArg
|
||||
zaiCodingPlan?: BooleanArg
|
||||
kimiForCoding?: BooleanArg
|
||||
skipAuth?: boolean
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ export interface InstallConfig {
|
||||
hasCopilot: boolean
|
||||
hasOpencodeZen: boolean
|
||||
hasZaiCodingPlan: boolean
|
||||
hasKimiForCoding: boolean
|
||||
}
|
||||
|
||||
export interface ConfigMergeResult {
|
||||
@@ -37,4 +39,5 @@ export interface DetectedConfig {
|
||||
hasCopilot: boolean
|
||||
hasOpencodeZen: boolean
|
||||
hasZaiCodingPlan: boolean
|
||||
hasKimiForCoding: boolean
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ export const CategoryConfigSchema = z.object({
|
||||
export const BuiltinCategoryNameSchema = z.enum([
|
||||
"visual-engineering",
|
||||
"ultrabrain",
|
||||
"deep",
|
||||
"artistry",
|
||||
"quick",
|
||||
"unspecified-low",
|
||||
|
||||
@@ -176,8 +176,8 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - both resolved without waiting
|
||||
expect(true).toBe(true)
|
||||
// #then - both resolved without waiting, count should be 2
|
||||
expect(manager.getCount("model-a")).toBe(2)
|
||||
})
|
||||
|
||||
test("should allow acquires up to default limit of 5", async () => {
|
||||
@@ -190,8 +190,8 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - all 5 resolved
|
||||
expect(true).toBe(true)
|
||||
// #then - all 5 resolved, count should be 5
|
||||
expect(manager.getCount("model-a")).toBe(5)
|
||||
})
|
||||
|
||||
test("should queue when limit reached", async () => {
|
||||
@@ -276,8 +276,8 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
manager.release("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
// #then - count should be 1 after re-acquiring
|
||||
expect(manager.getCount("model-a")).toBe(1)
|
||||
})
|
||||
|
||||
test("should handle release when no acquire", () => {
|
||||
@@ -288,21 +288,21 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
// #then - count should be 0 (no negative count)
|
||||
expect(manager.getCount("model-a")).toBe(0)
|
||||
})
|
||||
|
||||
test("should handle release when no prior acquire", () => {
|
||||
// #given - default config
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
// #then - count should be 0 (no negative count)
|
||||
expect(manager.getCount("model-a")).toBe(0)
|
||||
})
|
||||
|
||||
test("should handle multiple acquires and releases correctly", async () => {
|
||||
test("should handle multiple acquires and releases correctly", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
@@ -317,11 +317,11 @@ describe("ConcurrencyManager.acquire/release", () => {
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
|
||||
// Should be able to acquire again
|
||||
await manager.acquire("model-a")
|
||||
// Should be able to acquire again
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
// #then - count should be 1 after re-acquiring
|
||||
expect(manager.getCount("model-a")).toBe(1)
|
||||
})
|
||||
|
||||
test("should use model-specific limit for acquire", async () => {
|
||||
|
||||
@@ -170,6 +170,7 @@ function createBackgroundManager(): BackgroundManager {
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
@@ -1053,6 +1054,7 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
promptCalls.push(args)
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
@@ -1926,3 +1928,162 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.shutdown session abort", () => {
|
||||
test("should call session.abort for all running tasks during shutdown", () => {
|
||||
// #given
|
||||
const abortedSessionIDs: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async (args: { path: { id: string } }) => {
|
||||
abortedSessionIDs.push(args.path.id)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
|
||||
const task1: BackgroundTask = {
|
||||
id: "task-1",
|
||||
sessionID: "session-1",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Running task 1",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
}
|
||||
const task2: BackgroundTask = {
|
||||
id: "task-2",
|
||||
sessionID: "session-2",
|
||||
parentSessionID: "parent-2",
|
||||
parentMessageID: "msg-2",
|
||||
description: "Running task 2",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(task1.id, task1)
|
||||
getTaskMap(manager).set(task2.id, task2)
|
||||
|
||||
// #when
|
||||
manager.shutdown()
|
||||
|
||||
// #then
|
||||
expect(abortedSessionIDs).toContain("session-1")
|
||||
expect(abortedSessionIDs).toContain("session-2")
|
||||
expect(abortedSessionIDs).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should not call session.abort for completed or cancelled tasks", () => {
|
||||
// #given
|
||||
const abortedSessionIDs: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async (args: { path: { id: string } }) => {
|
||||
abortedSessionIDs.push(args.path.id)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
|
||||
const completedTask: BackgroundTask = {
|
||||
id: "task-completed",
|
||||
sessionID: "session-completed",
|
||||
parentSessionID: "parent-1",
|
||||
parentMessageID: "msg-1",
|
||||
description: "Completed task",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
}
|
||||
const cancelledTask: BackgroundTask = {
|
||||
id: "task-cancelled",
|
||||
sessionID: "session-cancelled",
|
||||
parentSessionID: "parent-2",
|
||||
parentMessageID: "msg-2",
|
||||
description: "Cancelled task",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "cancelled",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
}
|
||||
const pendingTask: BackgroundTask = {
|
||||
id: "task-pending",
|
||||
parentSessionID: "parent-3",
|
||||
parentMessageID: "msg-3",
|
||||
description: "Pending task",
|
||||
prompt: "Test",
|
||||
agent: "test-agent",
|
||||
status: "pending",
|
||||
queuedAt: new Date(),
|
||||
}
|
||||
|
||||
getTaskMap(manager).set(completedTask.id, completedTask)
|
||||
getTaskMap(manager).set(cancelledTask.id, cancelledTask)
|
||||
getTaskMap(manager).set(pendingTask.id, pendingTask)
|
||||
|
||||
// #when
|
||||
manager.shutdown()
|
||||
|
||||
// #then
|
||||
expect(abortedSessionIDs).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should call onShutdown callback during shutdown", () => {
|
||||
// #given
|
||||
let shutdownCalled = false
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ client, directory: tmpdir() } as unknown as PluginInput,
|
||||
undefined,
|
||||
{
|
||||
onShutdown: () => {
|
||||
shutdownCalled = true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// #when
|
||||
manager.shutdown()
|
||||
|
||||
// #then
|
||||
expect(shutdownCalled).toBe(true)
|
||||
})
|
||||
|
||||
test("should not throw when onShutdown callback throws", () => {
|
||||
// #given
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => ({}),
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ client, directory: tmpdir() } as unknown as PluginInput,
|
||||
undefined,
|
||||
{
|
||||
onShutdown: () => {
|
||||
throw new Error("cleanup failed")
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// #when / #then
|
||||
expect(() => manager.shutdown()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
LaunchInput,
|
||||
ResumeInput,
|
||||
} from "./types"
|
||||
import { log, getAgentToolRestrictions } from "../../shared"
|
||||
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
||||
import { isInsideTmux } from "../../shared/tmux"
|
||||
@@ -79,6 +79,7 @@ export class BackgroundManager {
|
||||
private config?: BackgroundTaskConfig
|
||||
private tmuxEnabled: boolean
|
||||
private onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
private onShutdown?: () => void
|
||||
|
||||
private queuesByKey: Map<string, QueueItem[]> = new Map()
|
||||
private processingKeys: Set<string> = new Set()
|
||||
@@ -89,6 +90,7 @@ export class BackgroundManager {
|
||||
options?: {
|
||||
tmuxConfig?: TmuxConfig
|
||||
onSubagentSessionCreated?: OnSubagentSessionCreated
|
||||
onShutdown?: () => void
|
||||
}
|
||||
) {
|
||||
this.tasks = new Map()
|
||||
@@ -100,6 +102,7 @@ export class BackgroundManager {
|
||||
this.config = config
|
||||
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
|
||||
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
|
||||
this.onShutdown = options?.onShutdown
|
||||
this.registerProcessCleanup()
|
||||
}
|
||||
|
||||
@@ -304,7 +307,7 @@ export class BackgroundManager {
|
||||
: undefined
|
||||
const launchVariant = input.model?.variant
|
||||
|
||||
this.client.session.prompt({
|
||||
promptWithModelSuggestionRetry(this.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
@@ -1346,7 +1349,25 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
log("[background-agent] Shutting down BackgroundManager")
|
||||
this.stopPolling()
|
||||
|
||||
// Release concurrency for all running tasks first
|
||||
// Abort all running sessions to prevent zombie processes (#1240)
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === "running" && task.sessionID) {
|
||||
this.client.session.abort({
|
||||
path: { id: task.sessionID },
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Notify shutdown listeners (e.g., tmux cleanup)
|
||||
if (this.onShutdown) {
|
||||
try {
|
||||
this.onShutdown()
|
||||
} catch (error) {
|
||||
log("[background-agent] Error in onShutdown callback:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Release concurrency for all running tasks
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface ClaudeCodeMcpServer {
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
headers?: Record<string, string>
|
||||
oauth?: {
|
||||
clientId?: string
|
||||
scopes?: string[]
|
||||
}
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
|
||||
import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface StoredMessage {
|
||||
agent?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
model?: { providerID?: string; modelID?: string; variant?: string }
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
|
||||
@@ -117,7 +118,7 @@ export function injectHookMessage(
|
||||
): boolean {
|
||||
// Validate hook content to prevent empty message injection
|
||||
if (!hookContent || hookContent.trim().length === 0) {
|
||||
console.warn("[hook-message-injector] Attempted to inject empty hook content, skipping injection", {
|
||||
log("[hook-message-injector] Attempted to inject empty hook content, skipping injection", {
|
||||
sessionID,
|
||||
hasAgent: !!originalMessage.agent,
|
||||
hasModel: !!(originalMessage.model?.providerID && originalMessage.model?.modelID)
|
||||
@@ -141,9 +142,17 @@ export function injectHookMessage(
|
||||
const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"
|
||||
const resolvedModel =
|
||||
originalMessage.model?.providerID && originalMessage.model?.modelID
|
||||
? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID }
|
||||
? {
|
||||
providerID: originalMessage.model.providerID,
|
||||
modelID: originalMessage.model.modelID,
|
||||
...(originalMessage.model.variant ? { variant: originalMessage.model.variant } : {})
|
||||
}
|
||||
: fallback?.model?.providerID && fallback?.model?.modelID
|
||||
? { providerID: fallback.model.providerID, modelID: fallback.model.modelID }
|
||||
? {
|
||||
providerID: fallback.model.providerID,
|
||||
modelID: fallback.model.modelID,
|
||||
...(fallback.model.variant ? { variant: fallback.model.variant } : {})
|
||||
}
|
||||
: undefined
|
||||
const resolvedTools = originalMessage.tools ?? fallback?.tools
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface MessageMeta {
|
||||
model?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
variant?: string
|
||||
}
|
||||
path?: {
|
||||
cwd: string
|
||||
@@ -25,6 +26,7 @@ export interface OriginalMessageContext {
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
variant?: string
|
||||
}
|
||||
path?: {
|
||||
cwd?: string
|
||||
|
||||
136
src/features/mcp-oauth/callback-server.test.ts
Normal file
136
src/features/mcp-oauth/callback-server.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test"
|
||||
import { findAvailablePort, startCallbackServer, type CallbackServer } from "./callback-server"
|
||||
|
||||
const nativeFetch = Bun.fetch.bind(Bun)
|
||||
|
||||
describe("findAvailablePort", () => {
|
||||
it("returns the start port when it is available", async () => {
|
||||
//#given
|
||||
const startPort = 19877
|
||||
|
||||
//#when
|
||||
const port = await findAvailablePort(startPort)
|
||||
|
||||
//#then
|
||||
expect(port).toBeGreaterThanOrEqual(startPort)
|
||||
expect(port).toBeLessThan(startPort + 20)
|
||||
})
|
||||
|
||||
it("skips busy ports and returns next available", async () => {
|
||||
//#given
|
||||
const blocker = Bun.serve({
|
||||
port: 19877,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response(),
|
||||
})
|
||||
|
||||
//#when
|
||||
const port = await findAvailablePort(19877)
|
||||
|
||||
//#then
|
||||
expect(port).toBeGreaterThan(19877)
|
||||
blocker.stop(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("startCallbackServer", () => {
|
||||
let server: CallbackServer | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
server?.close()
|
||||
server = null
|
||||
// Allow time for port to be released before next test
|
||||
await Bun.sleep(10)
|
||||
})
|
||||
|
||||
it("starts server and returns port", async () => {
|
||||
//#given - no preconditions
|
||||
|
||||
//#when
|
||||
server = await startCallbackServer()
|
||||
|
||||
//#then
|
||||
expect(server.port).toBeGreaterThanOrEqual(19877)
|
||||
expect(typeof server.waitForCallback).toBe("function")
|
||||
expect(typeof server.close).toBe("function")
|
||||
})
|
||||
|
||||
it("resolves callback with code and state from query params", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
const callbackUrl = `http://127.0.0.1:${server.port}/oauth/callback?code=test-code&state=test-state`
|
||||
|
||||
//#when
|
||||
// Use Promise.all to ensure fetch and waitForCallback run concurrently
|
||||
// This prevents race condition where waitForCallback blocks before fetch starts
|
||||
const [result, response] = await Promise.all([
|
||||
server.waitForCallback(),
|
||||
nativeFetch(callbackUrl)
|
||||
])
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ code: "test-code", state: "test-state" })
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain("Authorization successful")
|
||||
})
|
||||
|
||||
it("returns 404 for non-callback routes", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
|
||||
//#when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/other`)
|
||||
|
||||
//#then
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when code is missing", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
|
||||
//#when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`)
|
||||
|
||||
//#then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain("missing code or state")
|
||||
})
|
||||
|
||||
it("returns 400 and rejects when state is missing", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
|
||||
|
||||
//#when
|
||||
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`)
|
||||
|
||||
//#then
|
||||
expect(response.status).toBe(400)
|
||||
const error = await callbackRejection
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain("missing code or state")
|
||||
})
|
||||
|
||||
it("close stops the server immediately", async () => {
|
||||
//#given
|
||||
server = await startCallbackServer()
|
||||
const port = server.port
|
||||
|
||||
//#when
|
||||
server.close()
|
||||
server = null
|
||||
|
||||
//#then
|
||||
try {
|
||||
await nativeFetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`)
|
||||
expect(true).toBe(false)
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
124
src/features/mcp-oauth/callback-server.ts
Normal file
124
src/features/mcp-oauth/callback-server.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
const DEFAULT_PORT = 19877
|
||||
const MAX_PORT_ATTEMPTS = 20
|
||||
const TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
export type OAuthCallbackResult = {
|
||||
code: string
|
||||
state: string
|
||||
}
|
||||
|
||||
export type CallbackServer = {
|
||||
port: number
|
||||
waitForCallback: () => Promise<OAuthCallbackResult>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const SUCCESS_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OAuth Authorized</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0a0a0a; color: #fafafa; }
|
||||
.container { text-align: center; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
p { color: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization successful</h1>
|
||||
<p>You can close this window and return to your terminal.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
async function isPortAvailable(port: number): Promise<boolean> {
|
||||
try {
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response(),
|
||||
})
|
||||
server.stop(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAvailablePort(startPort: number = DEFAULT_PORT): Promise<number> {
|
||||
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
|
||||
const port = startPort + attempt
|
||||
if (await isPortAvailable(port)) {
|
||||
return port
|
||||
}
|
||||
}
|
||||
throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`)
|
||||
}
|
||||
|
||||
export async function startCallbackServer(startPort: number = DEFAULT_PORT): Promise<CallbackServer> {
|
||||
const port = await findAvailablePort(startPort)
|
||||
|
||||
let resolveCallback: ((result: OAuthCallbackResult) => void) | null = null
|
||||
let rejectCallback: ((error: Error) => void) | null = null
|
||||
|
||||
const callbackPromise = new Promise<OAuthCallbackResult>((resolve, reject) => {
|
||||
resolveCallback = resolve
|
||||
rejectCallback = reject
|
||||
})
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
rejectCallback?.(new Error("OAuth callback timed out after 5 minutes"))
|
||||
server.stop(true)
|
||||
}, TIMEOUT_MS)
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
fetch(request: Request): Response {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname !== "/oauth/callback") {
|
||||
return new Response("Not Found", { status: 404 })
|
||||
}
|
||||
|
||||
const oauthError = url.searchParams.get("error")
|
||||
if (oauthError) {
|
||||
const description = url.searchParams.get("error_description") ?? oauthError
|
||||
clearTimeout(timeoutId)
|
||||
rejectCallback?.(new Error(`OAuth authorization failed: ${description}`))
|
||||
setTimeout(() => server.stop(true), 100)
|
||||
return new Response(`Authorization failed: ${description}`, { status: 400 })
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
|
||||
if (!code || !state) {
|
||||
clearTimeout(timeoutId)
|
||||
rejectCallback?.(new Error("OAuth callback missing code or state parameter"))
|
||||
setTimeout(() => server.stop(true), 100)
|
||||
return new Response("Missing code or state parameter", { status: 400 })
|
||||
}
|
||||
|
||||
resolveCallback?.({ code, state })
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
setTimeout(() => server.stop(true), 100)
|
||||
|
||||
return new Response(SUCCESS_HTML, {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
port,
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () => {
|
||||
clearTimeout(timeoutId)
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
164
src/features/mcp-oauth/dcr.test.ts
Normal file
164
src/features/mcp-oauth/dcr.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
getOrRegisterClient,
|
||||
type ClientCredentials,
|
||||
type ClientRegistrationStorage,
|
||||
type DcrFetch,
|
||||
} from "./dcr"
|
||||
|
||||
function createStorage(initial: ClientCredentials | null):
|
||||
& ClientRegistrationStorage
|
||||
& { getLastKey: () => string | null; getLastSet: () => ClientCredentials | null } {
|
||||
let stored = initial
|
||||
let lastKey: string | null = null
|
||||
let lastSet: ClientCredentials | null = null
|
||||
|
||||
return {
|
||||
getClientRegistration: () => stored,
|
||||
setClientRegistration: (serverIdentifier: string, credentials: ClientCredentials) => {
|
||||
lastKey = serverIdentifier
|
||||
lastSet = credentials
|
||||
stored = credentials
|
||||
},
|
||||
getLastKey: () => lastKey,
|
||||
getLastSet: () => lastSet,
|
||||
}
|
||||
}
|
||||
|
||||
describe("getOrRegisterClient", () => {
|
||||
it("returns cached registration when available", async () => {
|
||||
// #given
|
||||
const storage = createStorage({
|
||||
clientId: "cached-client",
|
||||
clientSecret: "cached-secret",
|
||||
})
|
||||
const fetchMock: DcrFetch = async () => {
|
||||
throw new Error("fetch should not be called")
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: "https://server.example.com/register",
|
||||
serverIdentifier: "server-1",
|
||||
clientName: "Test Client",
|
||||
redirectUris: ["https://app.example.com/callback"],
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
storage,
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
clientId: "cached-client",
|
||||
clientSecret: "cached-secret",
|
||||
})
|
||||
})
|
||||
|
||||
it("registers client and stores credentials when endpoint available", async () => {
|
||||
// #given
|
||||
const storage = createStorage(null)
|
||||
let fetchCalled = false
|
||||
const fetchMock: DcrFetch = async (
|
||||
input: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string }
|
||||
) => {
|
||||
fetchCalled = true
|
||||
expect(input).toBe("https://server.example.com/register")
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("Expected request body string")
|
||||
}
|
||||
const payload = JSON.parse(init.body)
|
||||
expect(payload).toEqual({
|
||||
redirect_uris: ["https://app.example.com/callback"],
|
||||
client_name: "Test Client",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: "client_secret_post",
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
client_id: "registered-client",
|
||||
client_secret: "registered-secret",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: "https://server.example.com/register",
|
||||
serverIdentifier: "server-2",
|
||||
clientName: "Test Client",
|
||||
redirectUris: ["https://app.example.com/callback"],
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
storage,
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(fetchCalled).toBe(true)
|
||||
expect(result).toEqual({
|
||||
clientId: "registered-client",
|
||||
clientSecret: "registered-secret",
|
||||
})
|
||||
expect(storage.getLastKey()).toBe("server-2")
|
||||
expect(storage.getLastSet()).toEqual({
|
||||
clientId: "registered-client",
|
||||
clientSecret: "registered-secret",
|
||||
})
|
||||
})
|
||||
|
||||
it("uses config client id when registration endpoint missing", async () => {
|
||||
// #given
|
||||
const storage = createStorage(null)
|
||||
let fetchCalled = false
|
||||
const fetchMock: DcrFetch = async () => {
|
||||
fetchCalled = true
|
||||
return {
|
||||
ok: false,
|
||||
json: async () => ({}),
|
||||
}
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: undefined,
|
||||
serverIdentifier: "server-3",
|
||||
clientName: "Test Client",
|
||||
redirectUris: ["https://app.example.com/callback"],
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
clientId: "config-client",
|
||||
storage,
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(fetchCalled).toBe(false)
|
||||
expect(result).toEqual({ clientId: "config-client" })
|
||||
})
|
||||
|
||||
it("falls back to config client id when registration fails", async () => {
|
||||
// #given
|
||||
const storage = createStorage(null)
|
||||
const fetchMock: DcrFetch = async () => {
|
||||
throw new Error("network error")
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = await getOrRegisterClient({
|
||||
registrationEndpoint: "https://server.example.com/register",
|
||||
serverIdentifier: "server-4",
|
||||
clientName: "Test Client",
|
||||
redirectUris: ["https://app.example.com/callback"],
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
clientId: "fallback-client",
|
||||
storage,
|
||||
fetch: fetchMock,
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ clientId: "fallback-client" })
|
||||
expect(storage.getLastSet()).toBeNull()
|
||||
})
|
||||
})
|
||||
98
src/features/mcp-oauth/dcr.ts
Normal file
98
src/features/mcp-oauth/dcr.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export type ClientRegistrationRequest = {
|
||||
redirect_uris: string[]
|
||||
client_name: string
|
||||
grant_types: ["authorization_code", "refresh_token"]
|
||||
response_types: ["code"]
|
||||
token_endpoint_auth_method: "none" | "client_secret_post"
|
||||
}
|
||||
|
||||
export type ClientCredentials = {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
|
||||
export type ClientRegistrationStorage = {
|
||||
getClientRegistration: (serverIdentifier: string) => ClientCredentials | null
|
||||
setClientRegistration: (
|
||||
serverIdentifier: string,
|
||||
credentials: ClientCredentials
|
||||
) => void
|
||||
}
|
||||
|
||||
export type DynamicClientRegistrationOptions = {
|
||||
registrationEndpoint?: string | null
|
||||
serverIdentifier?: string
|
||||
clientName: string
|
||||
redirectUris: string[]
|
||||
tokenEndpointAuthMethod: "none" | "client_secret_post"
|
||||
clientId?: string | null
|
||||
storage: ClientRegistrationStorage
|
||||
fetch?: DcrFetch
|
||||
}
|
||||
|
||||
export type DcrFetch = (
|
||||
input: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string }
|
||||
) => Promise<{ ok: boolean; json: () => Promise<unknown> }>
|
||||
|
||||
export async function getOrRegisterClient(
|
||||
options: DynamicClientRegistrationOptions
|
||||
): Promise<ClientCredentials | null> {
|
||||
const serverIdentifier =
|
||||
options.serverIdentifier ?? options.registrationEndpoint ?? "default"
|
||||
const existing = options.storage.getClientRegistration(serverIdentifier)
|
||||
if (existing) return existing
|
||||
|
||||
if (!options.registrationEndpoint) {
|
||||
return options.clientId ? { clientId: options.clientId } : null
|
||||
}
|
||||
|
||||
const fetchImpl = options.fetch ?? globalThis.fetch
|
||||
const request: ClientRegistrationRequest = {
|
||||
redirect_uris: options.redirectUris,
|
||||
client_name: options.clientName,
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: options.tokenEndpointAuthMethod,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(options.registrationEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return options.clientId ? { clientId: options.clientId } : null
|
||||
}
|
||||
|
||||
const data: unknown = await response.json()
|
||||
const parsed = parseRegistrationResponse(data)
|
||||
if (!parsed) {
|
||||
return options.clientId ? { clientId: options.clientId } : null
|
||||
}
|
||||
|
||||
options.storage.setClientRegistration(serverIdentifier, parsed)
|
||||
return parsed
|
||||
} catch {
|
||||
return options.clientId ? { clientId: options.clientId } : null
|
||||
}
|
||||
}
|
||||
|
||||
function parseRegistrationResponse(data: unknown): ClientCredentials | null {
|
||||
if (!isRecord(data)) return null
|
||||
const clientId = data.client_id
|
||||
if (typeof clientId !== "string" || clientId.length === 0) return null
|
||||
|
||||
const clientSecret = data.client_secret
|
||||
if (typeof clientSecret === "string" && clientSecret.length > 0) {
|
||||
return { clientId, clientSecret }
|
||||
}
|
||||
|
||||
return { clientId }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
175
src/features/mcp-oauth/discovery.test.ts
Normal file
175
src/features/mcp-oauth/discovery.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { discoverOAuthServerMetadata, resetDiscoveryCache } from "./discovery"
|
||||
|
||||
describe("discoverOAuthServerMetadata", () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
resetDiscoveryCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, "fetch", { value: originalFetch, configurable: true })
|
||||
})
|
||||
|
||||
test("returns endpoints from PRM + AS discovery", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const authServer = "https://auth.example.com"
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
|
||||
const calls: string[] = []
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url === prmUrl) {
|
||||
return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
|
||||
}
|
||||
if (url === asUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
registration_endpoint: "https://auth.example.com/register",
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
return discoverOAuthServerMetadata(resource).then((result) => {
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
authorizationEndpoint: "https://auth.example.com/authorize",
|
||||
tokenEndpoint: "https://auth.example.com/token",
|
||||
registrationEndpoint: "https://auth.example.com/register",
|
||||
resource,
|
||||
})
|
||||
expect(calls).toEqual([prmUrl, asUrl])
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to RFC 8414 when PRM returns 404", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
|
||||
const calls: string[] = []
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url === prmUrl) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
if (url === asUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://mcp.example.com/authorize",
|
||||
token_endpoint: "https://mcp.example.com/token",
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
return discoverOAuthServerMetadata(resource).then((result) => {
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
authorizationEndpoint: "https://mcp.example.com/authorize",
|
||||
tokenEndpoint: "https://mcp.example.com/token",
|
||||
registrationEndpoint: undefined,
|
||||
resource,
|
||||
})
|
||||
expect(calls).toEqual([prmUrl, asUrl])
|
||||
})
|
||||
})
|
||||
|
||||
test("throws when both PRM and AS discovery return 404", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
if (url === prmUrl || url === asUrl) {
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
const result = discoverOAuthServerMetadata(resource)
|
||||
|
||||
// #then
|
||||
return expect(result).rejects.toThrow("OAuth authorization server metadata not found")
|
||||
})
|
||||
|
||||
test("throws when AS metadata is malformed", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const authServer = "https://auth.example.com"
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
if (url === prmUrl) {
|
||||
return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
|
||||
}
|
||||
if (url === asUrl) {
|
||||
return new Response(JSON.stringify({ authorization_endpoint: "https://auth.example.com/authorize" }), {
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
const result = discoverOAuthServerMetadata(resource)
|
||||
|
||||
// #then
|
||||
return expect(result).rejects.toThrow("token_endpoint")
|
||||
})
|
||||
|
||||
test("caches discovery results per resource URL", () => {
|
||||
// #given
|
||||
const resource = "https://mcp.example.com"
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
|
||||
const authServer = "https://auth.example.com"
|
||||
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
|
||||
const calls: string[] = []
|
||||
const fetchMock = async (input: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString()
|
||||
calls.push(url)
|
||||
if (url === prmUrl) {
|
||||
return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
|
||||
}
|
||||
if (url === asUrl) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
}),
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
|
||||
|
||||
// #when
|
||||
return discoverOAuthServerMetadata(resource)
|
||||
.then(() => discoverOAuthServerMetadata(resource))
|
||||
.then(() => {
|
||||
// #then
|
||||
expect(calls).toEqual([prmUrl, asUrl])
|
||||
})
|
||||
})
|
||||
})
|
||||
123
src/features/mcp-oauth/discovery.ts
Normal file
123
src/features/mcp-oauth/discovery.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
export interface OAuthServerMetadata {
|
||||
authorizationEndpoint: string
|
||||
tokenEndpoint: string
|
||||
registrationEndpoint?: string
|
||||
resource: string
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, OAuthServerMetadata>()
|
||||
const pendingDiscovery = new Map<string, Promise<OAuthServerMetadata>>()
|
||||
|
||||
function parseHttpsUrl(value: string, label: string): URL {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new Error(`${label} must use https`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readStringField(source: Record<string, unknown>, field: string): string {
|
||||
const value = source[field]
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`OAuth metadata missing ${field}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function fetchMetadata(url: string): Promise<{ ok: true; json: Record<string, unknown> } | { ok: false; status: number }> {
|
||||
const response = await fetch(url, { headers: { accept: "application/json" } })
|
||||
if (!response.ok) {
|
||||
return { ok: false, status: response.status }
|
||||
}
|
||||
const json = (await response.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (!json || typeof json !== "object") {
|
||||
throw new Error("OAuth metadata response is not valid JSON")
|
||||
}
|
||||
return { ok: true, json }
|
||||
}
|
||||
|
||||
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
|
||||
const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL")
|
||||
const issuerPath = issuerUrl.pathname.replace(/\/+$/, "")
|
||||
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString()
|
||||
const metadata = await fetchMetadata(metadataUrl)
|
||||
|
||||
if (!metadata.ok) {
|
||||
if (metadata.status === 404) {
|
||||
throw new Error("OAuth authorization server metadata not found")
|
||||
}
|
||||
throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`)
|
||||
}
|
||||
|
||||
const authorizationEndpoint = parseHttpsUrl(
|
||||
readStringField(metadata.json, "authorization_endpoint"),
|
||||
"authorization_endpoint"
|
||||
).toString()
|
||||
const tokenEndpoint = parseHttpsUrl(
|
||||
readStringField(metadata.json, "token_endpoint"),
|
||||
"token_endpoint"
|
||||
).toString()
|
||||
const registrationEndpointValue = metadata.json.registration_endpoint
|
||||
const registrationEndpoint =
|
||||
typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0
|
||||
? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
registrationEndpoint,
|
||||
resource,
|
||||
}
|
||||
}
|
||||
|
||||
function parseAuthorizationServers(metadata: Record<string, unknown>): string[] {
|
||||
const servers = metadata.authorization_servers
|
||||
if (!Array.isArray(servers)) return []
|
||||
return servers.filter((server): server is string => typeof server === "string" && server.length > 0)
|
||||
}
|
||||
|
||||
export async function discoverOAuthServerMetadata(resource: string): Promise<OAuthServerMetadata> {
|
||||
const resourceUrl = parseHttpsUrl(resource, "Resource server URL")
|
||||
const resourceKey = resourceUrl.toString()
|
||||
|
||||
const cached = discoveryCache.get(resourceKey)
|
||||
if (cached) return cached
|
||||
|
||||
const pending = pendingDiscovery.get(resourceKey)
|
||||
if (pending) return pending
|
||||
|
||||
const discoveryPromise = (async () => {
|
||||
const prmUrl = new URL("/.well-known/oauth-protected-resource", resourceUrl).toString()
|
||||
const prmResponse = await fetchMetadata(prmUrl)
|
||||
|
||||
if (prmResponse.ok) {
|
||||
const authServers = parseAuthorizationServers(prmResponse.json)
|
||||
if (authServers.length === 0) {
|
||||
throw new Error("OAuth protected resource metadata missing authorization_servers")
|
||||
}
|
||||
return fetchAuthorizationServerMetadata(authServers[0], resource)
|
||||
}
|
||||
|
||||
if (prmResponse.status !== 404) {
|
||||
throw new Error(`OAuth protected resource metadata fetch failed (${prmResponse.status})`)
|
||||
}
|
||||
|
||||
return fetchAuthorizationServerMetadata(resourceKey, resource)
|
||||
})()
|
||||
|
||||
pendingDiscovery.set(resourceKey, discoveryPromise)
|
||||
|
||||
try {
|
||||
const result = await discoveryPromise
|
||||
discoveryCache.set(resourceKey, result)
|
||||
return result
|
||||
} finally {
|
||||
pendingDiscovery.delete(resourceKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function resetDiscoveryCache(): void {
|
||||
discoveryCache.clear()
|
||||
pendingDiscovery.clear()
|
||||
}
|
||||
1
src/features/mcp-oauth/index.ts
Normal file
1
src/features/mcp-oauth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./schema"
|
||||
223
src/features/mcp-oauth/provider.test.ts
Normal file
223
src/features/mcp-oauth/provider.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { McpOAuthProvider, generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl } from "./provider"
|
||||
import type { OAuthTokenData } from "./storage"
|
||||
|
||||
describe("McpOAuthProvider", () => {
|
||||
describe("generateCodeVerifier", () => {
|
||||
it("returns a base64url-encoded 32-byte random string", () => {
|
||||
//#given
|
||||
const verifier = generateCodeVerifier()
|
||||
|
||||
//#when
|
||||
const decoded = Buffer.from(verifier, "base64url")
|
||||
|
||||
//#then
|
||||
expect(decoded.length).toBe(32)
|
||||
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
|
||||
it("produces unique values on each call", () => {
|
||||
//#given
|
||||
const first = generateCodeVerifier()
|
||||
|
||||
//#when
|
||||
const second = generateCodeVerifier()
|
||||
|
||||
//#then
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateCodeChallenge", () => {
|
||||
it("returns SHA256 base64url digest of the verifier", () => {
|
||||
//#given
|
||||
const verifier = "test-verifier-value"
|
||||
const expected = createHash("sha256").update(verifier).digest("base64url")
|
||||
|
||||
//#when
|
||||
const challenge = generateCodeChallenge(verifier)
|
||||
|
||||
//#then
|
||||
expect(challenge).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildAuthorizationUrl", () => {
|
||||
it("builds URL with all required PKCE parameters", () => {
|
||||
//#given
|
||||
const endpoint = "https://auth.example.com/authorize"
|
||||
|
||||
//#when
|
||||
const url = buildAuthorizationUrl(endpoint, {
|
||||
clientId: "my-client",
|
||||
redirectUri: "http://127.0.0.1:8912/callback",
|
||||
codeChallenge: "challenge-value",
|
||||
state: "state-value",
|
||||
scopes: ["openid", "profile"],
|
||||
resource: "https://mcp.example.com",
|
||||
})
|
||||
|
||||
//#then
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.origin + parsed.pathname).toBe("https://auth.example.com/authorize")
|
||||
expect(parsed.searchParams.get("response_type")).toBe("code")
|
||||
expect(parsed.searchParams.get("client_id")).toBe("my-client")
|
||||
expect(parsed.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8912/callback")
|
||||
expect(parsed.searchParams.get("code_challenge")).toBe("challenge-value")
|
||||
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256")
|
||||
expect(parsed.searchParams.get("state")).toBe("state-value")
|
||||
expect(parsed.searchParams.get("scope")).toBe("openid profile")
|
||||
expect(parsed.searchParams.get("resource")).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("omits scope when empty", () => {
|
||||
//#given
|
||||
const endpoint = "https://auth.example.com/authorize"
|
||||
|
||||
//#when
|
||||
const url = buildAuthorizationUrl(endpoint, {
|
||||
clientId: "my-client",
|
||||
redirectUri: "http://127.0.0.1:8912/callback",
|
||||
codeChallenge: "challenge-value",
|
||||
state: "state-value",
|
||||
scopes: [],
|
||||
})
|
||||
|
||||
//#then
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.has("scope")).toBe(false)
|
||||
})
|
||||
|
||||
it("omits resource when undefined", () => {
|
||||
//#given
|
||||
const endpoint = "https://auth.example.com/authorize"
|
||||
|
||||
//#when
|
||||
const url = buildAuthorizationUrl(endpoint, {
|
||||
clientId: "my-client",
|
||||
redirectUri: "http://127.0.0.1:8912/callback",
|
||||
codeChallenge: "challenge-value",
|
||||
state: "state-value",
|
||||
})
|
||||
|
||||
//#then
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.searchParams.has("resource")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("constructor and basic methods", () => {
|
||||
it("stores serverUrl and optional clientId and scopes", () => {
|
||||
//#given
|
||||
const options = {
|
||||
serverUrl: "https://mcp.example.com",
|
||||
clientId: "my-client",
|
||||
scopes: ["openid"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const provider = new McpOAuthProvider(options)
|
||||
|
||||
//#then
|
||||
expect(provider.tokens()).toBeNull()
|
||||
expect(provider.clientInformation()).toBeNull()
|
||||
expect(provider.codeVerifier()).toBeNull()
|
||||
})
|
||||
|
||||
it("defaults scopes to empty array", () => {
|
||||
//#given
|
||||
const options = { serverUrl: "https://mcp.example.com" }
|
||||
|
||||
//#when
|
||||
const provider = new McpOAuthProvider(options)
|
||||
|
||||
//#then
|
||||
expect(provider.redirectUrl()).toBe("http://127.0.0.1:19877/callback")
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveCodeVerifier / codeVerifier", () => {
|
||||
it("stores and retrieves code verifier", () => {
|
||||
//#given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
|
||||
//#when
|
||||
provider.saveCodeVerifier("my-verifier")
|
||||
|
||||
//#then
|
||||
expect(provider.codeVerifier()).toBe("my-verifier")
|
||||
})
|
||||
})
|
||||
|
||||
describe("saveTokens / tokens", () => {
|
||||
let originalEnv: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.OPENCODE_CONFIG_DIR
|
||||
const { mkdirSync } = require("node:fs")
|
||||
const { tmpdir } = require("node:os")
|
||||
const { join } = require("node:path")
|
||||
const testDir = join(tmpdir(), "mcp-oauth-provider-test-" + Date.now())
|
||||
mkdirSync(testDir, { recursive: true })
|
||||
process.env.OPENCODE_CONFIG_DIR = testDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalEnv
|
||||
}
|
||||
})
|
||||
|
||||
it("persists and loads token data via storage", () => {
|
||||
//#given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
const tokenData: OAuthTokenData = {
|
||||
accessToken: "access-token-123",
|
||||
refreshToken: "refresh-token-456",
|
||||
expiresAt: 1710000000,
|
||||
}
|
||||
|
||||
//#when
|
||||
const saved = provider.saveTokens(tokenData)
|
||||
const loaded = provider.tokens()
|
||||
|
||||
//#then
|
||||
expect(saved).toBe(true)
|
||||
expect(loaded).toEqual(tokenData)
|
||||
})
|
||||
})
|
||||
|
||||
describe("redirectToAuthorization", () => {
|
||||
it("throws when no client information is set", async () => {
|
||||
//#given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
const metadata = {
|
||||
authorizationEndpoint: "https://auth.example.com/authorize",
|
||||
tokenEndpoint: "https://auth.example.com/token",
|
||||
resource: "https://mcp.example.com",
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = provider.redirectToAuthorization(metadata)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("No client information available")
|
||||
})
|
||||
})
|
||||
|
||||
describe("redirectUrl", () => {
|
||||
it("returns localhost callback URL with default port", () => {
|
||||
//#given
|
||||
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
|
||||
|
||||
//#when
|
||||
const url = provider.redirectUrl()
|
||||
|
||||
//#then
|
||||
expect(url).toBe("http://127.0.0.1:19877/callback")
|
||||
})
|
||||
})
|
||||
})
|
||||
295
src/features/mcp-oauth/provider.ts
Normal file
295
src/features/mcp-oauth/provider.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { spawn } from "node:child_process"
|
||||
import type { OAuthTokenData } from "./storage"
|
||||
import { loadToken, saveToken } from "./storage"
|
||||
import { discoverOAuthServerMetadata } from "./discovery"
|
||||
import type { OAuthServerMetadata } from "./discovery"
|
||||
import { getOrRegisterClient } from "./dcr"
|
||||
import type { ClientCredentials, ClientRegistrationStorage } from "./dcr"
|
||||
import { findAvailablePort } from "./callback-server"
|
||||
|
||||
export type McpOAuthProviderOptions = {
|
||||
serverUrl: string
|
||||
clientId?: string
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
type CallbackResult = {
|
||||
code: string
|
||||
state: string
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return randomBytes(32).toString("base64url")
|
||||
}
|
||||
|
||||
function generateCodeChallenge(verifier: string): string {
|
||||
return createHash("sha256").update(verifier).digest("base64url")
|
||||
}
|
||||
|
||||
function buildAuthorizationUrl(
|
||||
authorizationEndpoint: string,
|
||||
options: {
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
codeChallenge: string
|
||||
state: string
|
||||
scopes?: string[]
|
||||
resource?: string
|
||||
}
|
||||
): string {
|
||||
const url = new URL(authorizationEndpoint)
|
||||
url.searchParams.set("response_type", "code")
|
||||
url.searchParams.set("client_id", options.clientId)
|
||||
url.searchParams.set("redirect_uri", options.redirectUri)
|
||||
url.searchParams.set("code_challenge", options.codeChallenge)
|
||||
url.searchParams.set("code_challenge_method", "S256")
|
||||
url.searchParams.set("state", options.state)
|
||||
if (options.scopes && options.scopes.length > 0) {
|
||||
url.searchParams.set("scope", options.scopes.join(" "))
|
||||
}
|
||||
if (options.resource) {
|
||||
url.searchParams.set("resource", options.resource)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
function startCallbackServer(port: number): Promise<CallbackResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
const requestUrl = new URL(request.url ?? "/", `http://localhost:${port}`)
|
||||
const code = requestUrl.searchParams.get("code")
|
||||
const state = requestUrl.searchParams.get("state")
|
||||
const error = requestUrl.searchParams.get("error")
|
||||
|
||||
if (error) {
|
||||
const errorDescription = requestUrl.searchParams.get("error_description") ?? error
|
||||
response.writeHead(400, { "content-type": "text/html" })
|
||||
response.end("<html><body><h1>Authorization failed</h1></body></html>")
|
||||
server.close()
|
||||
reject(new Error(`OAuth authorization error: ${errorDescription}`))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
response.writeHead(400, { "content-type": "text/html" })
|
||||
response.end("<html><body><h1>Missing code or state</h1></body></html>")
|
||||
server.close()
|
||||
reject(new Error("OAuth callback missing code or state parameter"))
|
||||
return
|
||||
}
|
||||
|
||||
response.writeHead(200, { "content-type": "text/html" })
|
||||
response.end("<html><body><h1>Authorization successful. You can close this tab.</h1></body></html>")
|
||||
server.close()
|
||||
resolve({ code, state })
|
||||
})
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
server.close()
|
||||
reject(new Error("OAuth callback timed out after 5 minutes"))
|
||||
}, CALLBACK_TIMEOUT_MS)
|
||||
|
||||
server.listen(port, "127.0.0.1")
|
||||
server.on("error", (err) => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function openBrowser(url: string): void {
|
||||
const platform = process.platform
|
||||
let cmd: string
|
||||
let args: string[]
|
||||
|
||||
if (platform === "darwin") {
|
||||
cmd = "open"
|
||||
args = [url]
|
||||
} else if (platform === "win32") {
|
||||
cmd = "explorer"
|
||||
args = [url]
|
||||
} else {
|
||||
cmd = "xdg-open"
|
||||
args = [url]
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(cmd, args, { stdio: "ignore", detached: true })
|
||||
child.on("error", () => {})
|
||||
child.unref()
|
||||
} catch {
|
||||
// Browser open failed — user must navigate manually
|
||||
}
|
||||
}
|
||||
|
||||
export class McpOAuthProvider {
|
||||
private readonly serverUrl: string
|
||||
private readonly configClientId: string | undefined
|
||||
private readonly scopes: string[]
|
||||
private storedCodeVerifier: string | null = null
|
||||
private storedClientInfo: ClientCredentials | null = null
|
||||
private callbackPort: number | null = null
|
||||
|
||||
constructor(options: McpOAuthProviderOptions) {
|
||||
this.serverUrl = options.serverUrl
|
||||
this.configClientId = options.clientId
|
||||
this.scopes = options.scopes ?? []
|
||||
}
|
||||
|
||||
tokens(): OAuthTokenData | null {
|
||||
return loadToken(this.serverUrl, this.serverUrl)
|
||||
}
|
||||
|
||||
saveTokens(tokenData: OAuthTokenData): boolean {
|
||||
return saveToken(this.serverUrl, this.serverUrl, tokenData)
|
||||
}
|
||||
|
||||
clientInformation(): ClientCredentials | null {
|
||||
if (this.storedClientInfo) return this.storedClientInfo
|
||||
const tokenData = this.tokens()
|
||||
if (tokenData?.clientInfo) {
|
||||
this.storedClientInfo = tokenData.clientInfo
|
||||
return this.storedClientInfo
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
redirectUrl(): string {
|
||||
return `http://127.0.0.1:${this.callbackPort ?? 19877}/callback`
|
||||
}
|
||||
|
||||
saveCodeVerifier(verifier: string): void {
|
||||
this.storedCodeVerifier = verifier
|
||||
}
|
||||
|
||||
codeVerifier(): string | null {
|
||||
return this.storedCodeVerifier
|
||||
}
|
||||
|
||||
async redirectToAuthorization(metadata: OAuthServerMetadata): Promise<CallbackResult> {
|
||||
const verifier = generateCodeVerifier()
|
||||
this.saveCodeVerifier(verifier)
|
||||
const challenge = generateCodeChallenge(verifier)
|
||||
const state = randomBytes(16).toString("hex")
|
||||
|
||||
const clientInfo = this.clientInformation()
|
||||
if (!clientInfo) {
|
||||
throw new Error("No client information available. Run login() or register a client first.")
|
||||
}
|
||||
|
||||
if (this.callbackPort === null) {
|
||||
this.callbackPort = await findAvailablePort()
|
||||
}
|
||||
|
||||
const authUrl = buildAuthorizationUrl(metadata.authorizationEndpoint, {
|
||||
clientId: clientInfo.clientId,
|
||||
redirectUri: this.redirectUrl(),
|
||||
codeChallenge: challenge,
|
||||
state,
|
||||
scopes: this.scopes,
|
||||
resource: metadata.resource,
|
||||
})
|
||||
|
||||
const callbackPromise = startCallbackServer(this.callbackPort)
|
||||
openBrowser(authUrl)
|
||||
|
||||
const result = await callbackPromise
|
||||
if (result.state !== state) {
|
||||
throw new Error("OAuth state mismatch")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async login(): Promise<OAuthTokenData> {
|
||||
const metadata = await discoverOAuthServerMetadata(this.serverUrl)
|
||||
|
||||
const clientRegistrationStorage: ClientRegistrationStorage = {
|
||||
getClientRegistration: () => this.storedClientInfo,
|
||||
setClientRegistration: (_serverIdentifier: string, credentials: ClientCredentials) => {
|
||||
this.storedClientInfo = credentials
|
||||
},
|
||||
}
|
||||
|
||||
const clientInfo = await getOrRegisterClient({
|
||||
registrationEndpoint: metadata.registrationEndpoint,
|
||||
serverIdentifier: this.serverUrl,
|
||||
clientName: "oh-my-opencode",
|
||||
redirectUris: [this.redirectUrl()],
|
||||
tokenEndpointAuthMethod: "none",
|
||||
clientId: this.configClientId,
|
||||
storage: clientRegistrationStorage,
|
||||
})
|
||||
|
||||
if (!clientInfo) {
|
||||
throw new Error("Failed to obtain client credentials. Provide a clientId or ensure the server supports DCR.")
|
||||
}
|
||||
|
||||
this.storedClientInfo = clientInfo
|
||||
|
||||
const { code } = await this.redirectToAuthorization(metadata)
|
||||
const verifier = this.codeVerifier()
|
||||
if (!verifier) {
|
||||
throw new Error("Code verifier not found")
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(metadata.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: this.redirectUrl(),
|
||||
client_id: clientInfo.clientId,
|
||||
code_verifier: verifier,
|
||||
...(metadata.resource ? { resource: metadata.resource } : {}),
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
let errorDetail = `${tokenResponse.status}`
|
||||
try {
|
||||
const body = (await tokenResponse.json()) as Record<string, unknown>
|
||||
if (body.error) {
|
||||
errorDetail = `${tokenResponse.status} ${body.error}`
|
||||
if (body.error_description) {
|
||||
errorDetail += `: ${body.error_description}`
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Response body not JSON
|
||||
}
|
||||
throw new Error(`Token exchange failed: ${errorDetail}`)
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>
|
||||
const accessToken = tokenData.access_token
|
||||
if (typeof accessToken !== "string") {
|
||||
throw new Error("Token response missing access_token")
|
||||
}
|
||||
|
||||
const oauthTokenData: OAuthTokenData = {
|
||||
accessToken,
|
||||
refreshToken: typeof tokenData.refresh_token === "string" ? tokenData.refresh_token : undefined,
|
||||
expiresAt:
|
||||
typeof tokenData.expires_in === "number" ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
|
||||
clientInfo: {
|
||||
clientId: clientInfo.clientId,
|
||||
clientSecret: clientInfo.clientSecret,
|
||||
},
|
||||
}
|
||||
|
||||
this.saveTokens(oauthTokenData)
|
||||
return oauthTokenData
|
||||
}
|
||||
}
|
||||
|
||||
export { generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl, startCallbackServer }
|
||||
121
src/features/mcp-oauth/resource-indicator.test.ts
Normal file
121
src/features/mcp-oauth/resource-indicator.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { addResourceToParams, getResourceIndicator } from "./resource-indicator"
|
||||
|
||||
describe("getResourceIndicator", () => {
|
||||
it("returns URL unchanged when already normalized", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("strips trailing slash", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("strips query parameters", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/v1?token=abc&debug=true"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com/v1")
|
||||
})
|
||||
|
||||
it("strips fragment", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/v1#section"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com/v1")
|
||||
})
|
||||
|
||||
it("strips query and trailing slash together", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/api/?key=val"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com/api")
|
||||
})
|
||||
|
||||
it("preserves path segments", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com/org/project/v2"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com/org/project/v2")
|
||||
})
|
||||
|
||||
it("preserves port number", () => {
|
||||
// #given
|
||||
const url = "https://mcp.example.com:8443/api/"
|
||||
|
||||
// #when
|
||||
const result = getResourceIndicator(url)
|
||||
|
||||
// #then
|
||||
expect(result).toBe("https://mcp.example.com:8443/api")
|
||||
})
|
||||
})
|
||||
|
||||
describe("addResourceToParams", () => {
|
||||
it("sets resource parameter on empty params", () => {
|
||||
// #given
|
||||
const params = new URLSearchParams()
|
||||
const resource = "https://mcp.example.com"
|
||||
|
||||
// #when
|
||||
addResourceToParams(params, resource)
|
||||
|
||||
// #then
|
||||
expect(params.get("resource")).toBe("https://mcp.example.com")
|
||||
})
|
||||
|
||||
it("adds resource alongside existing parameters", () => {
|
||||
// #given
|
||||
const params = new URLSearchParams({ grant_type: "authorization_code" })
|
||||
const resource = "https://mcp.example.com/v1"
|
||||
|
||||
// #when
|
||||
addResourceToParams(params, resource)
|
||||
|
||||
// #then
|
||||
expect(params.get("grant_type")).toBe("authorization_code")
|
||||
expect(params.get("resource")).toBe("https://mcp.example.com/v1")
|
||||
})
|
||||
|
||||
it("overwrites existing resource parameter", () => {
|
||||
// #given
|
||||
const params = new URLSearchParams({ resource: "https://old.example.com" })
|
||||
const resource = "https://new.example.com"
|
||||
|
||||
// #when
|
||||
addResourceToParams(params, resource)
|
||||
|
||||
// #then
|
||||
expect(params.get("resource")).toBe("https://new.example.com")
|
||||
expect(params.getAll("resource")).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
16
src/features/mcp-oauth/resource-indicator.ts
Normal file
16
src/features/mcp-oauth/resource-indicator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function getResourceIndicator(url: string): string {
|
||||
const parsed = new URL(url)
|
||||
parsed.search = ""
|
||||
parsed.hash = ""
|
||||
|
||||
let normalized = parsed.toString()
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function addResourceToParams(params: URLSearchParams, resource: string): void {
|
||||
params.set("resource", resource)
|
||||
}
|
||||
60
src/features/mcp-oauth/schema.test.ts
Normal file
60
src/features/mcp-oauth/schema.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/// <reference types="bun-types" />
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { McpOauthSchema } from "./schema"
|
||||
|
||||
describe("McpOauthSchema", () => {
|
||||
test("parses empty oauth config", () => {
|
||||
//#given
|
||||
const input = {}
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.parse(input)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test("parses oauth config with clientId", () => {
|
||||
//#given
|
||||
const input = { clientId: "client-123" }
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.parse(input)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ clientId: "client-123" })
|
||||
})
|
||||
|
||||
test("parses oauth config with scopes", () => {
|
||||
//#given
|
||||
const input = { scopes: ["openid", "profile"] }
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.parse(input)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual({ scopes: ["openid", "profile"] })
|
||||
})
|
||||
|
||||
test("rejects non-string clientId", () => {
|
||||
//#given
|
||||
const input = { clientId: 123 }
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.safeParse(input)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects non-string scopes", () => {
|
||||
//#given
|
||||
const input = { scopes: ["openid", 42] }
|
||||
|
||||
//#when
|
||||
const result = McpOauthSchema.safeParse(input)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
8
src/features/mcp-oauth/schema.ts
Normal file
8
src/features/mcp-oauth/schema.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const McpOauthSchema = z.object({
|
||||
clientId: z.string().optional(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export type McpOauth = z.infer<typeof McpOauthSchema>
|
||||
223
src/features/mcp-oauth/step-up.test.ts
Normal file
223
src/features/mcp-oauth/step-up.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { isStepUpRequired, mergeScopes, parseWwwAuthenticate } from "./step-up"
|
||||
|
||||
describe("parseWwwAuthenticate", () => {
|
||||
it("parses scope from simple Bearer header", () => {
|
||||
// #given
|
||||
const header = 'Bearer scope="read write"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["read", "write"] })
|
||||
})
|
||||
|
||||
it("parses scope with error fields", () => {
|
||||
// #given
|
||||
const header = 'Bearer error="insufficient_scope", scope="admin"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
requiredScopes: ["admin"],
|
||||
error: "insufficient_scope",
|
||||
})
|
||||
})
|
||||
|
||||
it("parses all fields including error_description", () => {
|
||||
// #given
|
||||
const header =
|
||||
'Bearer realm="example", error="insufficient_scope", error_description="Need admin access", scope="admin write"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({
|
||||
requiredScopes: ["admin", "write"],
|
||||
error: "insufficient_scope",
|
||||
errorDescription: "Need admin access",
|
||||
})
|
||||
})
|
||||
|
||||
it("returns null for non-Bearer scheme", () => {
|
||||
// #given
|
||||
const header = 'Basic realm="example"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when no scope parameter present", () => {
|
||||
// #given
|
||||
const header = 'Bearer error="invalid_token"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for empty scope value", () => {
|
||||
// #given
|
||||
const header = 'Bearer scope=""'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for bare Bearer with no params", () => {
|
||||
// #given
|
||||
const header = "Bearer"
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("handles case-insensitive Bearer prefix", () => {
|
||||
// #given
|
||||
const header = 'bearer scope="read"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["read"] })
|
||||
})
|
||||
|
||||
it("parses single scope value", () => {
|
||||
// #given
|
||||
const header = 'Bearer scope="admin"'
|
||||
|
||||
// #when
|
||||
const result = parseWwwAuthenticate(header)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["admin"] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("mergeScopes", () => {
|
||||
it("merges new scopes into existing", () => {
|
||||
// #given
|
||||
const existing = ["read", "write"]
|
||||
const required = ["admin", "write"]
|
||||
|
||||
// #when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual(["read", "write", "admin"])
|
||||
})
|
||||
|
||||
it("returns required when existing is empty", () => {
|
||||
// #given
|
||||
const existing: string[] = []
|
||||
const required = ["read", "write"]
|
||||
|
||||
// #when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual(["read", "write"])
|
||||
})
|
||||
|
||||
it("returns existing when required is empty", () => {
|
||||
// #given
|
||||
const existing = ["read"]
|
||||
const required: string[] = []
|
||||
|
||||
// #when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual(["read"])
|
||||
})
|
||||
|
||||
it("deduplicates identical scopes", () => {
|
||||
// #given
|
||||
const existing = ["read", "write"]
|
||||
const required = ["read", "write"]
|
||||
|
||||
// #when
|
||||
const result = mergeScopes(existing, required)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual(["read", "write"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isStepUpRequired", () => {
|
||||
it("returns step-up info for 403 with WWW-Authenticate", () => {
|
||||
// #given
|
||||
const statusCode = 403
|
||||
const headers = { "www-authenticate": 'Bearer scope="admin"' }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["admin"] })
|
||||
})
|
||||
|
||||
it("returns null for non-403 status", () => {
|
||||
// #given
|
||||
const statusCode = 401
|
||||
const headers = { "www-authenticate": 'Bearer scope="admin"' }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when no WWW-Authenticate header", () => {
|
||||
// #given
|
||||
const statusCode = 403
|
||||
const headers = { "content-type": "application/json" }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("handles capitalized WWW-Authenticate header", () => {
|
||||
// #given
|
||||
const statusCode = 403
|
||||
const headers = { "WWW-Authenticate": 'Bearer scope="read write"' }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toEqual({ requiredScopes: ["read", "write"] })
|
||||
})
|
||||
|
||||
it("returns null for 403 with unparseable WWW-Authenticate", () => {
|
||||
// #given
|
||||
const statusCode = 403
|
||||
const headers = { "www-authenticate": 'Basic realm="example"' }
|
||||
|
||||
// #when
|
||||
const result = isStepUpRequired(statusCode, headers)
|
||||
|
||||
// #then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
79
src/features/mcp-oauth/step-up.ts
Normal file
79
src/features/mcp-oauth/step-up.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export interface StepUpInfo {
|
||||
requiredScopes: string[]
|
||||
error?: string
|
||||
errorDescription?: string
|
||||
}
|
||||
|
||||
export function parseWwwAuthenticate(header: string): StepUpInfo | null {
|
||||
const trimmed = header.trim()
|
||||
const lowerHeader = trimmed.toLowerCase()
|
||||
const bearerIndex = lowerHeader.indexOf("bearer")
|
||||
if (bearerIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const params = trimmed.slice(bearerIndex + "bearer".length).trim()
|
||||
if (params.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scope = extractParam(params, "scope")
|
||||
if (scope === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const requiredScopes = scope
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 0)
|
||||
|
||||
if (requiredScopes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const info: StepUpInfo = { requiredScopes }
|
||||
|
||||
const error = extractParam(params, "error")
|
||||
if (error !== null) {
|
||||
info.error = error
|
||||
}
|
||||
|
||||
const errorDescription = extractParam(params, "error_description")
|
||||
if (errorDescription !== null) {
|
||||
info.errorDescription = errorDescription
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
function extractParam(params: string, name: string): string | null {
|
||||
const quotedPattern = new RegExp(`${name}="([^"]*)"`)
|
||||
const quotedMatch = quotedPattern.exec(params)
|
||||
if (quotedMatch) {
|
||||
return quotedMatch[1]
|
||||
}
|
||||
|
||||
const unquotedPattern = new RegExp(`${name}=([^\\s,]+)`)
|
||||
const unquotedMatch = unquotedPattern.exec(params)
|
||||
return unquotedMatch?.[1] ?? null
|
||||
}
|
||||
|
||||
export function mergeScopes(existing: string[], required: string[]): string[] {
|
||||
const set = new Set(existing)
|
||||
for (const scope of required) {
|
||||
set.add(scope)
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
export function isStepUpRequired(statusCode: number, headers: Record<string, string>): StepUpInfo | null {
|
||||
if (statusCode !== 403) {
|
||||
return null
|
||||
}
|
||||
|
||||
const wwwAuth = headers["www-authenticate"] ?? headers["WWW-Authenticate"]
|
||||
if (!wwwAuth) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseWwwAuthenticate(wwwAuth)
|
||||
}
|
||||
136
src/features/mcp-oauth/storage.test.ts
Normal file
136
src/features/mcp-oauth/storage.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import {
|
||||
deleteToken,
|
||||
getMcpOauthStoragePath,
|
||||
listAllTokens,
|
||||
listTokensByHost,
|
||||
loadToken,
|
||||
saveToken,
|
||||
} from "./storage"
|
||||
import type { OAuthTokenData } from "./storage"
|
||||
|
||||
describe("mcp-oauth storage", () => {
|
||||
const TEST_CONFIG_DIR = join(tmpdir(), "mcp-oauth-test-" + Date.now())
|
||||
let originalConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
process.env.OPENCODE_CONFIG_DIR = TEST_CONFIG_DIR
|
||||
if (!existsSync(TEST_CONFIG_DIR)) {
|
||||
mkdirSync(TEST_CONFIG_DIR, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
if (existsSync(TEST_CONFIG_DIR)) {
|
||||
rmSync(TEST_CONFIG_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("should save tokens with {host}/{resource} key and set 0600 permissions", () => {
|
||||
// #given
|
||||
const token: OAuthTokenData = {
|
||||
accessToken: "access-1",
|
||||
refreshToken: "refresh-1",
|
||||
expiresAt: 1710000000,
|
||||
clientInfo: { clientId: "client-1", clientSecret: "secret-1" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const success = saveToken("https://example.com:443", "mcp/v1", token)
|
||||
const storagePath = getMcpOauthStoragePath()
|
||||
const parsed = JSON.parse(readFileSync(storagePath, "utf-8")) as Record<string, OAuthTokenData>
|
||||
const mode = statSync(storagePath).mode & 0o777
|
||||
|
||||
// #then
|
||||
expect(success).toBe(true)
|
||||
expect(Object.keys(parsed)).toEqual(["example.com/mcp/v1"])
|
||||
expect(parsed["example.com/mcp/v1"].accessToken).toBe("access-1")
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
|
||||
test("should load a saved token", () => {
|
||||
// #given
|
||||
const token: OAuthTokenData = { accessToken: "access-2", refreshToken: "refresh-2" }
|
||||
saveToken("api.example.com", "resource-a", token)
|
||||
|
||||
// #when
|
||||
const loaded = loadToken("api.example.com:8443", "resource-a")
|
||||
|
||||
// #then
|
||||
expect(loaded).toEqual(token)
|
||||
})
|
||||
|
||||
test("should delete a token", () => {
|
||||
// #given
|
||||
const token: OAuthTokenData = { accessToken: "access-3" }
|
||||
saveToken("api.example.com", "resource-b", token)
|
||||
|
||||
// #when
|
||||
const success = deleteToken("api.example.com", "resource-b")
|
||||
const loaded = loadToken("api.example.com", "resource-b")
|
||||
|
||||
// #then
|
||||
expect(success).toBe(true)
|
||||
expect(loaded).toBeNull()
|
||||
})
|
||||
|
||||
test("should list tokens by host", () => {
|
||||
// #given
|
||||
saveToken("api.example.com", "resource-a", { accessToken: "access-a" })
|
||||
saveToken("api.example.com", "resource-b", { accessToken: "access-b" })
|
||||
saveToken("other.example.com", "resource-c", { accessToken: "access-c" })
|
||||
|
||||
// #when
|
||||
const entries = listTokensByHost("api.example.com:5555")
|
||||
|
||||
// #then
|
||||
expect(Object.keys(entries).sort()).toEqual([
|
||||
"api.example.com/resource-a",
|
||||
"api.example.com/resource-b",
|
||||
])
|
||||
expect(entries["api.example.com/resource-a"].accessToken).toBe("access-a")
|
||||
})
|
||||
|
||||
test("should handle missing storage file", () => {
|
||||
// #given
|
||||
const storagePath = getMcpOauthStoragePath()
|
||||
if (existsSync(storagePath)) {
|
||||
rmSync(storagePath, { force: true })
|
||||
}
|
||||
|
||||
// #when
|
||||
const loaded = loadToken("api.example.com", "resource-a")
|
||||
const entries = listTokensByHost("api.example.com")
|
||||
|
||||
// #then
|
||||
expect(loaded).toBeNull()
|
||||
expect(entries).toEqual({})
|
||||
})
|
||||
|
||||
test("should handle invalid JSON", () => {
|
||||
// #given
|
||||
const storagePath = getMcpOauthStoragePath()
|
||||
const dir = join(storagePath, "..")
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
writeFileSync(storagePath, "{not-valid-json", "utf-8")
|
||||
|
||||
// #when
|
||||
const loaded = loadToken("api.example.com", "resource-a")
|
||||
const entries = listTokensByHost("api.example.com")
|
||||
|
||||
// #then
|
||||
expect(loaded).toBeNull()
|
||||
expect(entries).toEqual({})
|
||||
})
|
||||
})
|
||||
153
src/features/mcp-oauth/storage.ts
Normal file
153
src/features/mcp-oauth/storage.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { getOpenCodeConfigDir } from "../../shared"
|
||||
|
||||
export interface OAuthTokenData {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
expiresAt?: number
|
||||
clientInfo?: {
|
||||
clientId: string
|
||||
clientSecret?: string
|
||||
}
|
||||
}
|
||||
|
||||
type TokenStore = Record<string, OAuthTokenData>
|
||||
|
||||
const STORAGE_FILE_NAME = "mcp-oauth.json"
|
||||
|
||||
export function getMcpOauthStoragePath(): string {
|
||||
return join(getOpenCodeConfigDir({ binary: "opencode" }), STORAGE_FILE_NAME)
|
||||
}
|
||||
|
||||
function normalizeHost(serverHost: string): string {
|
||||
let host = serverHost.trim()
|
||||
if (!host) return host
|
||||
|
||||
if (host.includes("://")) {
|
||||
try {
|
||||
host = new URL(host).hostname
|
||||
} catch {
|
||||
host = host.split("/")[0]
|
||||
}
|
||||
} else {
|
||||
host = host.split("/")[0]
|
||||
}
|
||||
|
||||
if (host.startsWith("[")) {
|
||||
const closing = host.indexOf("]")
|
||||
if (closing !== -1) {
|
||||
host = host.slice(0, closing + 1)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
if (host.includes(":")) {
|
||||
host = host.split(":")[0]
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
function normalizeResource(resource: string): string {
|
||||
return resource.replace(/^\/+/, "")
|
||||
}
|
||||
|
||||
function buildKey(serverHost: string, resource: string): string {
|
||||
const host = normalizeHost(serverHost)
|
||||
const normalizedResource = normalizeResource(resource)
|
||||
return `${host}/${normalizedResource}`
|
||||
}
|
||||
|
||||
function readStore(): TokenStore | null {
|
||||
const filePath = getMcpOauthStoragePath()
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
return JSON.parse(content) as TokenStore
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStore(store: TokenStore): boolean {
|
||||
const filePath = getMcpOauthStoragePath()
|
||||
|
||||
try {
|
||||
const dir = dirname(filePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
|
||||
chmodSync(filePath, 0o600)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function loadToken(serverHost: string, resource: string): OAuthTokenData | null {
|
||||
const store = readStore()
|
||||
if (!store) return null
|
||||
|
||||
const key = buildKey(serverHost, resource)
|
||||
return store[key] ?? null
|
||||
}
|
||||
|
||||
export function saveToken(serverHost: string, resource: string, token: OAuthTokenData): boolean {
|
||||
const store = readStore() ?? {}
|
||||
const key = buildKey(serverHost, resource)
|
||||
store[key] = token
|
||||
return writeStore(store)
|
||||
}
|
||||
|
||||
export function deleteToken(serverHost: string, resource: string): boolean {
|
||||
const store = readStore()
|
||||
if (!store) return true
|
||||
|
||||
const key = buildKey(serverHost, resource)
|
||||
if (!(key in store)) {
|
||||
return true
|
||||
}
|
||||
|
||||
delete store[key]
|
||||
|
||||
if (Object.keys(store).length === 0) {
|
||||
try {
|
||||
const filePath = getMcpOauthStoragePath()
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return writeStore(store)
|
||||
}
|
||||
|
||||
export function listTokensByHost(serverHost: string): TokenStore {
|
||||
const store = readStore()
|
||||
if (!store) return {}
|
||||
|
||||
const host = normalizeHost(serverHost)
|
||||
const prefix = `${host}/`
|
||||
const result: TokenStore = {}
|
||||
|
||||
for (const [key, value] of Object.entries(store)) {
|
||||
if (key.startsWith(prefix)) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function listAllTokens(): TokenStore {
|
||||
return readStore() ?? {}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { SkillMcpManager } from "./manager"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
|
||||
|
||||
|
||||
// Mock the MCP SDK transports to avoid network calls
|
||||
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
|
||||
const mockHttpClose = mock(() => Promise.resolve())
|
||||
@@ -24,6 +22,21 @@ mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const mockTokens = mock(() => null as { accessToken: string; refreshToken?: string; expiresAt?: number } | null)
|
||||
const mockLogin = mock(() => Promise.resolve({ accessToken: "new-token" }))
|
||||
|
||||
mock.module("../mcp-oauth/provider", () => ({
|
||||
McpOAuthProvider: class MockMcpOAuthProvider {
|
||||
constructor(public options: { serverUrl: string; clientId?: string; scopes?: string[] }) {}
|
||||
tokens() {
|
||||
return mockTokens()
|
||||
}
|
||||
async login() {
|
||||
return mockLogin()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -518,7 +531,6 @@ describe("SkillMcpManager", () => {
|
||||
skillName: "retry-skill",
|
||||
}
|
||||
|
||||
// Mock client that fails first time with "Not connected", then succeeds
|
||||
let callCount = 0
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
@@ -531,7 +543,6 @@ describe("SkillMcpManager", () => {
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
// Spy on getOrCreateClientWithRetry to inject mock client
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
@@ -539,9 +550,9 @@ describe("SkillMcpManager", () => {
|
||||
const result = await manager.callTool(info, context, "test-tool", {})
|
||||
|
||||
// #then
|
||||
expect(callCount).toBe(2) // First call fails, second succeeds
|
||||
expect(callCount).toBe(2)
|
||||
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(2) // Called twice due to retry
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("should fail after 3 retry attempts", async () => {
|
||||
@@ -558,7 +569,6 @@ describe("SkillMcpManager", () => {
|
||||
skillName: "fail-skill",
|
||||
}
|
||||
|
||||
// Mock client that always fails with "Not connected"
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
throw new Error("Not connected")
|
||||
@@ -573,7 +583,7 @@ describe("SkillMcpManager", () => {
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
||||
/Failed after 3 reconnection attempts/
|
||||
)
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(3) // Initial + 2 retries
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("should not retry on non-connection errors", async () => {
|
||||
@@ -590,7 +600,6 @@ describe("SkillMcpManager", () => {
|
||||
skillName: "error-skill",
|
||||
}
|
||||
|
||||
// Mock client that fails with non-connection error
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
throw new Error("Tool not found")
|
||||
@@ -605,7 +614,194 @@ describe("SkillMcpManager", () => {
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
|
||||
"Tool not found"
|
||||
)
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(1) // No retry
|
||||
expect(getOrCreateSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("OAuth integration", () => {
|
||||
beforeEach(() => {
|
||||
mockTokens.mockClear()
|
||||
mockLogin.mockClear()
|
||||
})
|
||||
|
||||
it("injects Authorization header when oauth config has stored tokens", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "oauth-server",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
oauth: {
|
||||
clientId: "my-client",
|
||||
scopes: ["read", "write"],
|
||||
},
|
||||
}
|
||||
mockTokens.mockReturnValue({ accessToken: "stored-access-token" })
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.Authorization).toBe("Bearer stored-access-token")
|
||||
})
|
||||
|
||||
it("does not inject Authorization header when no stored tokens exist and login fails", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "oauth-no-token",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-2",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
oauth: {
|
||||
clientId: "my-client",
|
||||
},
|
||||
}
|
||||
mockTokens.mockReturnValue(null)
|
||||
mockLogin.mockRejectedValue(new Error("Login failed"))
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it("preserves existing static headers alongside OAuth token", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "oauth-with-headers",
|
||||
skillName: "oauth-skill",
|
||||
sessionID: "session-oauth-3",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
headers: {
|
||||
"X-Custom": "custom-value",
|
||||
},
|
||||
oauth: {
|
||||
clientId: "my-client",
|
||||
},
|
||||
}
|
||||
mockTokens.mockReturnValue({ accessToken: "oauth-token" })
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.["X-Custom"]).toBe("custom-value")
|
||||
expect(headers?.Authorization).toBe("Bearer oauth-token")
|
||||
})
|
||||
|
||||
it("does not create auth provider when oauth config is absent", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "no-oauth-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-no-oauth",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer static-token",
|
||||
},
|
||||
}
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch { /* connection fails in test */ }
|
||||
|
||||
// #then
|
||||
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
|
||||
expect(headers?.Authorization).toBe("Bearer static-token")
|
||||
expect(mockTokens).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("handles step-up auth by triggering re-login on 403 with scope", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "stepup-server",
|
||||
skillName: "stepup-skill",
|
||||
sessionID: "session-stepup-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
oauth: {
|
||||
clientId: "my-client",
|
||||
scopes: ["read"],
|
||||
},
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config,
|
||||
skillName: "stepup-skill",
|
||||
}
|
||||
|
||||
mockTokens.mockReturnValue({ accessToken: "initial-token" })
|
||||
mockLogin.mockResolvedValue({ accessToken: "upgraded-token" })
|
||||
|
||||
let callCount = 0
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
throw new Error('403 WWW-Authenticate: Bearer scope="admin write"')
|
||||
}
|
||||
return { content: [{ type: "text", text: "success" }] }
|
||||
}),
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when
|
||||
const result = await manager.callTool(info, context, "test-tool", {})
|
||||
|
||||
// #then
|
||||
expect(result).toEqual([{ type: "text", text: "success" }])
|
||||
expect(mockLogin).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("does not attempt step-up when oauth config is absent", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "no-stepup-server",
|
||||
skillName: "no-stepup-skill",
|
||||
sessionID: "session-no-stepup",
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config: {
|
||||
url: "https://mcp.example.com/mcp",
|
||||
},
|
||||
skillName: "no-stepup-skill",
|
||||
}
|
||||
|
||||
const mockClient = {
|
||||
callTool: mock(async () => {
|
||||
throw new Error('403 WWW-Authenticate: Bearer scope="admin"')
|
||||
}),
|
||||
close: mock(() => Promise.resolve()),
|
||||
}
|
||||
|
||||
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
|
||||
getOrCreateSpy.mockResolvedValue(mockClient)
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(/403/)
|
||||
expect(mockLogin).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import { McpOAuthProvider } from "../mcp-oauth/provider"
|
||||
import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up"
|
||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
@@ -60,6 +62,7 @@ function getConnectionType(config: ClaudeCodeMcpServer): ConnectionType | null {
|
||||
export class SkillMcpManager {
|
||||
private clients: Map<string, ManagedClient> = new Map()
|
||||
private pendingConnections: Map<string, Promise<Client>> = new Map()
|
||||
private authProviders: Map<string, McpOAuthProvider> = new Map()
|
||||
private cleanupRegistered = false
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null
|
||||
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
@@ -68,6 +71,28 @@ export class SkillMcpManager {
|
||||
return `${info.sessionID}:${info.skillName}:${info.serverName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create an McpOAuthProvider for a given server URL + oauth config.
|
||||
* Providers are cached by server URL to reuse tokens across reconnections.
|
||||
*/
|
||||
private getOrCreateAuthProvider(
|
||||
serverUrl: string,
|
||||
oauth: NonNullable<ClaudeCodeMcpServer["oauth"]>
|
||||
): McpOAuthProvider {
|
||||
const existing = this.authProviders.get(serverUrl)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const provider = new McpOAuthProvider({
|
||||
serverUrl,
|
||||
clientId: oauth.clientId,
|
||||
scopes: oauth.scopes,
|
||||
})
|
||||
this.authProviders.set(serverUrl, provider)
|
||||
return provider
|
||||
}
|
||||
|
||||
private registerProcessCleanup(): void {
|
||||
if (this.cleanupRegistered) return
|
||||
this.cleanupRegistered = true
|
||||
@@ -204,7 +229,30 @@ export class SkillMcpManager {
|
||||
// Build request init with headers if provided
|
||||
const requestInit: RequestInit = {}
|
||||
if (config.headers && Object.keys(config.headers).length > 0) {
|
||||
requestInit.headers = config.headers
|
||||
requestInit.headers = { ...config.headers }
|
||||
}
|
||||
|
||||
let authProvider: McpOAuthProvider | undefined
|
||||
if (config.oauth) {
|
||||
authProvider = this.getOrCreateAuthProvider(config.url, config.oauth)
|
||||
let tokenData = authProvider.tokens()
|
||||
|
||||
const isExpired = tokenData?.expiresAt != null && tokenData.expiresAt < Math.floor(Date.now() / 1000)
|
||||
if (!tokenData || isExpired) {
|
||||
try {
|
||||
tokenData = await authProvider.login()
|
||||
} catch {
|
||||
// Login failed — proceed without auth header
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenData) {
|
||||
const existingHeaders = (requestInit.headers ?? {}) as Record<string, string>
|
||||
requestInit.headers = {
|
||||
...existingHeaders,
|
||||
Authorization: `Bearer ${tokenData.accessToken}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
@@ -460,6 +508,12 @@ export class SkillMcpManager {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
const errorMessage = lastError.message.toLowerCase()
|
||||
|
||||
const stepUpHandled = await this.handleStepUpIfNeeded(lastError, config)
|
||||
if (stepUpHandled) {
|
||||
await this.forceReconnect(info)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!errorMessage.includes("not connected")) {
|
||||
throw lastError
|
||||
}
|
||||
@@ -470,23 +524,66 @@ export class SkillMcpManager {
|
||||
)
|
||||
}
|
||||
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await existing.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
try {
|
||||
await existing.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
await this.forceReconnect(info)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Operation failed with unknown error")
|
||||
}
|
||||
|
||||
private async handleStepUpIfNeeded(
|
||||
error: Error,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<boolean> {
|
||||
if (!config.oauth || !config.url) {
|
||||
return false
|
||||
}
|
||||
|
||||
const statusMatch = /\b403\b/.exec(error.message)
|
||||
if (!statusMatch) {
|
||||
return false
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
const wwwAuthMatch = /WWW-Authenticate:\s*(.+)/i.exec(error.message)
|
||||
if (wwwAuthMatch?.[1]) {
|
||||
headers["www-authenticate"] = wwwAuthMatch[1]
|
||||
}
|
||||
|
||||
const stepUp = isStepUpRequired(403, headers)
|
||||
if (!stepUp) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentScopes = config.oauth.scopes ?? []
|
||||
const merged = mergeScopes(currentScopes, stepUp.requiredScopes)
|
||||
config.oauth.scopes = merged
|
||||
|
||||
this.authProviders.delete(config.url)
|
||||
const provider = this.getOrCreateAuthProvider(config.url, config.oauth)
|
||||
|
||||
try {
|
||||
await provider.login()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async forceReconnect(info: SkillMcpClientInfo): Promise<void> {
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await existing.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
try {
|
||||
await existing.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateClientWithRetry(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, test, expect, mock, beforeEach } from 'bun:test'
|
||||
import type { TmuxConfig } from '../../config/schema'
|
||||
import type { WindowState, PaneAction } from './types'
|
||||
import type { ActionResult, ExecuteContext } from './action-executor'
|
||||
import type { TmuxUtilDeps } from './manager'
|
||||
|
||||
type ExecuteActionsResult = {
|
||||
success: boolean
|
||||
@@ -33,6 +34,11 @@ const mockExecuteAction = mock<(
|
||||
const mockIsInsideTmux = mock<() => boolean>(() => true)
|
||||
const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0')
|
||||
|
||||
const mockTmuxDeps: TmuxUtilDeps = {
|
||||
isInsideTmux: mockIsInsideTmux,
|
||||
getCurrentPaneId: mockGetCurrentPaneId,
|
||||
}
|
||||
|
||||
mock.module('./pane-state-querier', () => ({
|
||||
queryWindowState: mockQueryWindowState,
|
||||
paneExists: mockPaneExists,
|
||||
@@ -51,15 +57,19 @@ mock.module('./action-executor', () => ({
|
||||
executeAction: mockExecuteAction,
|
||||
}))
|
||||
|
||||
mock.module('../../shared/tmux', () => ({
|
||||
isInsideTmux: mockIsInsideTmux,
|
||||
getCurrentPaneId: mockGetCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS: 2000,
|
||||
SESSION_TIMEOUT_MS: 600000,
|
||||
SESSION_MISSING_GRACE_MS: 6000,
|
||||
SESSION_READY_POLL_INTERVAL_MS: 100,
|
||||
SESSION_READY_TIMEOUT_MS: 500,
|
||||
}))
|
||||
mock.module('../../shared/tmux', () => {
|
||||
const { isInsideTmux, getCurrentPaneId } = require('../../shared/tmux/tmux-utils')
|
||||
const { POLL_INTERVAL_BACKGROUND_MS, SESSION_TIMEOUT_MS, SESSION_MISSING_GRACE_MS } = require('../../shared/tmux/constants')
|
||||
return {
|
||||
isInsideTmux,
|
||||
getCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS,
|
||||
SESSION_TIMEOUT_MS,
|
||||
SESSION_MISSING_GRACE_MS,
|
||||
SESSION_READY_POLL_INTERVAL_MS: 100,
|
||||
SESSION_READY_TIMEOUT_MS: 500,
|
||||
}
|
||||
})
|
||||
|
||||
const trackedSessions = new Set<string>()
|
||||
|
||||
@@ -148,7 +158,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
expect(manager).toBeDefined()
|
||||
@@ -168,7 +178,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
expect(manager).toBeDefined()
|
||||
@@ -188,7 +198,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
//#when
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#then
|
||||
expect(manager).toBeDefined()
|
||||
@@ -210,7 +220,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent(
|
||||
'ses_child',
|
||||
'ses_parent',
|
||||
@@ -271,7 +281,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when - first agent
|
||||
await manager.onSessionCreated(
|
||||
@@ -305,7 +315,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent('ses_root', undefined, 'Root Session')
|
||||
|
||||
//#when
|
||||
@@ -327,7 +337,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = createSessionCreatedEvent(
|
||||
'ses_child',
|
||||
'ses_parent',
|
||||
@@ -353,7 +363,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
const event = {
|
||||
type: 'session.deleted',
|
||||
properties: {
|
||||
@@ -398,7 +408,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 120,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when
|
||||
await manager.onSessionCreated(
|
||||
@@ -450,7 +460,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent(
|
||||
@@ -487,7 +497,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
//#when
|
||||
await manager.onSessionDeleted({ sessionID: 'ses_unknown' })
|
||||
@@ -521,7 +531,7 @@ describe('TmuxSessionManager', () => {
|
||||
main_pane_min_width: 80,
|
||||
agent_pane_min_width: 40,
|
||||
}
|
||||
const manager = new TmuxSessionManager(ctx, config)
|
||||
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
|
||||
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1')
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession, CapacityConfig } from "./types"
|
||||
import {
|
||||
isInsideTmux,
|
||||
getCurrentPaneId,
|
||||
isInsideTmux as defaultIsInsideTmux,
|
||||
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||
POLL_INTERVAL_BACKGROUND_MS,
|
||||
SESSION_MISSING_GRACE_MS,
|
||||
SESSION_READY_POLL_INTERVAL_MS,
|
||||
@@ -21,6 +21,16 @@ interface SessionCreatedEvent {
|
||||
properties?: { info?: { id?: string; parentID?: string; title?: string } }
|
||||
}
|
||||
|
||||
export interface TmuxUtilDeps {
|
||||
isInsideTmux: () => boolean
|
||||
getCurrentPaneId: () => string | undefined
|
||||
}
|
||||
|
||||
const defaultTmuxDeps: TmuxUtilDeps = {
|
||||
isInsideTmux: defaultIsInsideTmux,
|
||||
getCurrentPaneId: defaultGetCurrentPaneId,
|
||||
}
|
||||
|
||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
@@ -43,13 +53,15 @@ export class TmuxSessionManager {
|
||||
private sessions = new Map<string, TrackedSession>()
|
||||
private pendingSessions = new Set<string>()
|
||||
private pollInterval?: ReturnType<typeof setInterval>
|
||||
private deps: TmuxUtilDeps
|
||||
|
||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) {
|
||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
||||
this.client = ctx.client
|
||||
this.tmuxConfig = tmuxConfig
|
||||
this.deps = deps
|
||||
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
|
||||
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
|
||||
this.sourcePaneId = getCurrentPaneId()
|
||||
this.sourcePaneId = deps.getCurrentPaneId()
|
||||
|
||||
log("[tmux-session-manager] initialized", {
|
||||
configEnabled: this.tmuxConfig.enabled,
|
||||
@@ -60,7 +72,7 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.tmuxConfig.enabled && isInsideTmux()
|
||||
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
|
||||
}
|
||||
|
||||
private getCapacityConfig(): CapacityConfig {
|
||||
@@ -113,7 +125,7 @@ export class TmuxSessionManager {
|
||||
log("[tmux-session-manager] onSessionCreated called", {
|
||||
enabled,
|
||||
tmuxConfigEnabled: this.tmuxConfig.enabled,
|
||||
isInsideTmux: isInsideTmux(),
|
||||
isInsideTmux: this.deps.isInsideTmux(),
|
||||
eventType: event.type,
|
||||
infoId: event.properties?.info?.id,
|
||||
infoParentID: event.properties?.info?.parentID,
|
||||
|
||||
@@ -1,11 +1,83 @@
|
||||
import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { executeCompact } from "./executor"
|
||||
import type { AutoCompactState } from "./types"
|
||||
import * as storage from "./storage"
|
||||
|
||||
type TimerCallback = (...args: any[]) => void
|
||||
|
||||
interface FakeTimeouts {
|
||||
advanceBy: (ms: number) => Promise<void>
|
||||
restore: () => void
|
||||
}
|
||||
|
||||
function createFakeTimeouts(): FakeTimeouts {
|
||||
let now = 0
|
||||
let nextId = 1
|
||||
const timers = new Map<number, { id: number; time: number; callback: TimerCallback; args: any[] }>()
|
||||
const cleared = new Set<number>()
|
||||
|
||||
const original = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
}
|
||||
|
||||
const normalizeDelay = (delay?: number) => {
|
||||
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
|
||||
return delay < 0 ? 0 : delay
|
||||
}
|
||||
|
||||
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
||||
const id = nextId++
|
||||
timers.set(id, {
|
||||
id,
|
||||
time: now + normalizeDelay(delay),
|
||||
callback,
|
||||
args,
|
||||
})
|
||||
return id as unknown as ReturnType<typeof setTimeout>
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.clearTimeout = ((id?: number) => {
|
||||
if (typeof id !== "number") return
|
||||
cleared.add(id)
|
||||
timers.delete(id)
|
||||
}) as typeof clearTimeout
|
||||
|
||||
const advanceBy = async (ms: number) => {
|
||||
const target = now + Math.max(0, ms)
|
||||
while (true) {
|
||||
let next: { id: number; time: number; callback: TimerCallback; args: any[] } | undefined
|
||||
for (const timer of timers.values()) {
|
||||
if (timer.time <= target && (!next || timer.time < next.time)) {
|
||||
next = timer
|
||||
}
|
||||
}
|
||||
if (!next) break
|
||||
|
||||
now = next.time
|
||||
timers.delete(next.id)
|
||||
if (!cleared.has(next.id)) {
|
||||
next.callback(...next.args)
|
||||
}
|
||||
cleared.delete(next.id)
|
||||
await Promise.resolve()
|
||||
}
|
||||
now = target
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
globalThis.setTimeout = original.setTimeout
|
||||
globalThis.clearTimeout = original.clearTimeout
|
||||
}
|
||||
|
||||
return { advanceBy, restore }
|
||||
}
|
||||
|
||||
describe("executeCompact lock management", () => {
|
||||
let autoCompactState: AutoCompactState
|
||||
let mockClient: any
|
||||
let fakeTimeouts: FakeTimeouts
|
||||
const sessionID = "test-session-123"
|
||||
const directory = "/test/dir"
|
||||
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
@@ -32,6 +104,12 @@ describe("executeCompact lock management", () => {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
|
||||
fakeTimeouts = createFakeTimeouts()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fakeTimeouts.restore()
|
||||
})
|
||||
|
||||
test("clears lock on successful summarize completion", async () => {
|
||||
@@ -216,7 +294,7 @@ describe("executeCompact lock management", () => {
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
await fakeTimeouts.advanceBy(600)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
// The continuation happens in setTimeout, but lock is cleared in finally before that
|
||||
@@ -288,7 +366,7 @@ describe("executeCompact lock management", () => {
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
await fakeTimeouts.advanceBy(600)
|
||||
|
||||
// #then: Truncation was attempted
|
||||
expect(truncateSpy).toHaveBeenCalled()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "path"
|
||||
import { homedir, tmpdir } from "os"
|
||||
import { createRequire } from "module"
|
||||
import { extractZip } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = join(tmpdir(), "comment-checker-debug.log")
|
||||
@@ -127,7 +128,7 @@ export async function downloadCommentChecker(): Promise<string | null> {
|
||||
const downloadUrl = `https://github.com/${REPO}/releases/download/v${version}/${assetName}`
|
||||
|
||||
debugLog(`Downloading from: ${downloadUrl}`)
|
||||
console.log(`[oh-my-opencode] Downloading comment-checker binary...`)
|
||||
log(`[oh-my-opencode] Downloading comment-checker binary...`)
|
||||
|
||||
try {
|
||||
// Ensure cache directory exists
|
||||
@@ -166,14 +167,14 @@ export async function downloadCommentChecker(): Promise<string | null> {
|
||||
}
|
||||
|
||||
debugLog(`Successfully downloaded binary to: ${binaryPath}`)
|
||||
console.log(`[oh-my-opencode] comment-checker binary ready.`)
|
||||
log(`[oh-my-opencode] comment-checker binary ready.`)
|
||||
|
||||
return binaryPath
|
||||
|
||||
} catch (err) {
|
||||
debugLog(`Failed to download: ${err}`)
|
||||
console.error(`[oh-my-opencode] Failed to download comment-checker: ${err instanceof Error ? err.message : err}`)
|
||||
console.error(`[oh-my-opencode] Comment checking disabled.`)
|
||||
log(`[oh-my-opencode] Failed to download comment-checker: ${err instanceof Error ? err.message : err}`)
|
||||
log(`[oh-my-opencode] Comment checking disabled.`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "./storage";
|
||||
import { OMO_SESSION_PREFIX, buildSessionReminderMessage } from "./constants";
|
||||
import type { InteractiveBashSessionState } from "./types";
|
||||
import { subagentSessions } from "../../features/claude-code-session-state";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -146,7 +147,7 @@ function findSubcommand(tokens: string[]): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
export function createInteractiveBashSessionHook(_ctx: PluginInput) {
|
||||
export function createInteractiveBashSessionHook(ctx: PluginInput) {
|
||||
const sessionStates = new Map<string, InteractiveBashSessionState>();
|
||||
|
||||
function getOrCreateState(sessionID: string): InteractiveBashSessionState {
|
||||
@@ -178,6 +179,10 @@ export function createInteractiveBashSessionHook(_ctx: PluginInput) {
|
||||
await proc.exited;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const sessionId of subagentSessions) {
|
||||
ctx.client.session.abort({ path: { id: sessionId } }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
|
||||
@@ -180,7 +180,9 @@ ${ULTRAWORK_PLANNER_SECTION}
|
||||
|
||||
1. **THINK DEEPLY** - What is the user's TRUE intent? What problem are they REALLY trying to solve?
|
||||
2. **EXPLORE THOROUGHLY** - Fire explore/librarian agents to gather ALL relevant context
|
||||
3. **CONSULT ORACLE** - For architecture decisions, complex logic, or when you're stuck
|
||||
3. **CONSULT SPECIALISTS** - For hard/complex tasks, DO NOT struggle alone. Delegate:
|
||||
- **Oracle**: Conventional problems - architecture, debugging, complex logic
|
||||
- **Artistry**: Non-conventional problems - different approach needed, unusual constraints
|
||||
4. **ASK THE USER** - If ambiguity remains after exploration, ASK. Don't guess.
|
||||
|
||||
**SIGNS YOU ARE NOT READY TO IMPLEMENT:**
|
||||
@@ -194,7 +196,10 @@ ${ULTRAWORK_PLANNER_SECTION}
|
||||
\`\`\`
|
||||
delegate_task(agent="explore", prompt="Find [X] patterns in codebase", background=true)
|
||||
delegate_task(agent="librarian", prompt="Find docs/examples for [Y]", background=true)
|
||||
delegate_task(agent="oracle", prompt="Review my approach: [describe plan]")
|
||||
|
||||
// Hard problem? DON'T struggle alone:
|
||||
delegate_task(agent="oracle", prompt="...") // conventional: architecture, debugging
|
||||
delegate_task(category="artistry", prompt="...") // non-conventional: needs different approach
|
||||
\`\`\`
|
||||
|
||||
**ONLY AFTER YOU HAVE:**
|
||||
@@ -229,7 +234,7 @@ delegate_task(agent="oracle", prompt="Review my approach: [describe plan]")
|
||||
**IF YOU ENCOUNTER A BLOCKER:**
|
||||
1. **DO NOT** give up
|
||||
2. **DO NOT** deliver a compromised version
|
||||
3. **DO** consult oracle for solutions
|
||||
3. **DO** consult specialists (oracle for conventional, artistry for non-conventional)
|
||||
4. **DO** ask the user for guidance
|
||||
5. **DO** explore alternative approaches
|
||||
|
||||
@@ -298,7 +303,8 @@ delegate_task(session_id="ses_abc123", prompt="Here's my answer to your question
|
||||
| Codebase exploration | delegate_task(subagent_type="explore", run_in_background=true) | Parallel, context-efficient |
|
||||
| Documentation lookup | delegate_task(subagent_type="librarian", run_in_background=true) | Specialized knowledge |
|
||||
| Planning | delegate_task(subagent_type="plan") | Parallel task graph + structured TODO list |
|
||||
| Architecture/Debugging | delegate_task(subagent_type="oracle") | High-IQ reasoning |
|
||||
| Hard problem (conventional) | delegate_task(subagent_type="oracle") | Architecture, debugging, complex logic |
|
||||
| Hard problem (non-conventional) | delegate_task(category="artistry", load_skills=[...]) | Different approach needed |
|
||||
| Implementation | delegate_task(category="...", load_skills=[...]) | Domain-optimized models |
|
||||
|
||||
**CATEGORY + SKILL DELEGATION:**
|
||||
@@ -490,8 +496,9 @@ CONTEXT GATHERING (parallel):
|
||||
- 1-2 librarian agents (if external library involved)
|
||||
- Direct tools: Grep, AST-grep, LSP for targeted searches
|
||||
|
||||
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
|
||||
- Consult oracle for strategic guidance
|
||||
IF COMPLEX - DO NOT STRUGGLE ALONE. Consult specialists:
|
||||
- **Oracle**: Conventional problems (architecture, debugging, complex logic)
|
||||
- **Artistry**: Non-conventional problems (different approach needed)
|
||||
|
||||
SYNTHESIZE findings before proceeding.`,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
stripThinkingParts,
|
||||
} from "./storage"
|
||||
import type { MessageData, ResumeConfig } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface SessionRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
@@ -414,7 +415,7 @@ export function createSessionRecoveryHook(ctx: PluginInput, options?: SessionRec
|
||||
|
||||
return success
|
||||
} catch (err) {
|
||||
console.error("[session-recovery] Recovery failed:", err)
|
||||
log("[session-recovery] Recovery failed:", err)
|
||||
return false
|
||||
} finally {
|
||||
processingErrors.delete(assistantMsgID)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
clearBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { getSessionAgent, updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
|
||||
export const HOOK_NAME = "start-work"
|
||||
|
||||
@@ -71,7 +71,10 @@ export function createStartWorkHook(ctx: PluginInput) {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
updateSessionAgent(input.sessionID, "atlas")
|
||||
const currentAgent = getSessionAgent(input.sessionID)
|
||||
if (!currentAgent) {
|
||||
updateSessionAgent(input.sessionID, "atlas")
|
||||
}
|
||||
|
||||
const existingState = readBoulderState(ctx.directory)
|
||||
const sessionId = input.sessionID
|
||||
|
||||
@@ -4,9 +4,123 @@ import type { BackgroundManager } from "../features/background-agent"
|
||||
import { setMainSession, subagentSessions, _resetForTesting } from "../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
|
||||
type TimerCallback = (...args: any[]) => void
|
||||
|
||||
interface FakeTimers {
|
||||
advanceBy: (ms: number, advanceClock?: boolean) => Promise<void>
|
||||
restore: () => void
|
||||
}
|
||||
|
||||
function createFakeTimers(): FakeTimers {
|
||||
const originalNow = Date.now()
|
||||
let clockNow = originalNow
|
||||
let timerNow = 0
|
||||
let nextId = 1
|
||||
const timers = new Map<number, { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] }>()
|
||||
const cleared = new Set<number>()
|
||||
|
||||
const original = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
setInterval: globalThis.setInterval,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
dateNow: Date.now,
|
||||
}
|
||||
|
||||
const normalizeDelay = (delay?: number) => {
|
||||
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
|
||||
return delay < 0 ? 0 : delay
|
||||
}
|
||||
|
||||
const schedule = (callback: TimerCallback, delay: number | undefined, interval: number | null, args: any[]) => {
|
||||
const id = nextId++
|
||||
timers.set(id, {
|
||||
id,
|
||||
time: timerNow + normalizeDelay(delay),
|
||||
interval,
|
||||
callback,
|
||||
args,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
const clear = (id: number | undefined) => {
|
||||
if (typeof id !== "number") return
|
||||
cleared.add(id)
|
||||
timers.delete(id)
|
||||
}
|
||||
|
||||
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
||||
return schedule(callback, delay, null, args) as unknown as ReturnType<typeof setTimeout>
|
||||
}) as typeof setTimeout
|
||||
|
||||
globalThis.setInterval = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
|
||||
const interval = normalizeDelay(delay)
|
||||
return schedule(callback, delay, interval, args) as unknown as ReturnType<typeof setInterval>
|
||||
}) as typeof setInterval
|
||||
|
||||
globalThis.clearTimeout = ((id?: number) => {
|
||||
clear(id)
|
||||
}) as typeof clearTimeout
|
||||
|
||||
globalThis.clearInterval = ((id?: number) => {
|
||||
clear(id)
|
||||
}) as typeof clearInterval
|
||||
|
||||
Date.now = () => clockNow
|
||||
|
||||
const advanceBy = async (ms: number, advanceClock: boolean = false) => {
|
||||
const clamped = Math.max(0, ms)
|
||||
const target = timerNow + clamped
|
||||
if (advanceClock) {
|
||||
clockNow += clamped
|
||||
}
|
||||
while (true) {
|
||||
let next: { id: number; time: number; interval: number | null; callback: TimerCallback; args: any[] } | undefined
|
||||
for (const timer of timers.values()) {
|
||||
if (timer.time <= target && (!next || timer.time < next.time)) {
|
||||
next = timer
|
||||
}
|
||||
}
|
||||
if (!next) break
|
||||
|
||||
timerNow = next.time
|
||||
timers.delete(next.id)
|
||||
next.callback(...next.args)
|
||||
|
||||
if (next.interval !== null && !cleared.has(next.id)) {
|
||||
timers.set(next.id, {
|
||||
id: next.id,
|
||||
time: timerNow + next.interval,
|
||||
interval: next.interval,
|
||||
callback: next.callback,
|
||||
args: next.args,
|
||||
})
|
||||
} else {
|
||||
cleared.delete(next.id)
|
||||
}
|
||||
|
||||
await Promise.resolve()
|
||||
}
|
||||
timerNow = target
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
globalThis.setTimeout = original.setTimeout
|
||||
globalThis.clearTimeout = original.clearTimeout
|
||||
globalThis.setInterval = original.setInterval
|
||||
globalThis.clearInterval = original.clearInterval
|
||||
Date.now = original.dateNow
|
||||
}
|
||||
|
||||
return { advanceBy, restore }
|
||||
}
|
||||
|
||||
describe("todo-continuation-enforcer", () => {
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string }>
|
||||
let fakeTimers: FakeTimers
|
||||
|
||||
interface MockMessage {
|
||||
info: {
|
||||
@@ -60,6 +174,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeTimers = createFakeTimers()
|
||||
_resetForTesting()
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
@@ -67,6 +182,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fakeTimers.restore()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
@@ -85,12 +201,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #then - countdown toast shown
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await fakeTimers.advanceBy(100)
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(toastCalls[0].title).toBe("Todo Continuation")
|
||||
|
||||
// #then - after countdown, continuation injected
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
})
|
||||
@@ -112,7 +228,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -132,7 +248,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -150,7 +266,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID: otherSession } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -170,7 +286,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #then - continuation injected for background task session
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
|
||||
})
|
||||
@@ -190,7 +306,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - wait past grace period (500ms), then user sends message
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
await fakeTimers.advanceBy(600, true)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
@@ -199,7 +315,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #then - wait past countdown time and verify no injection (countdown was cancelled)
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -223,9 +339,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
},
|
||||
})
|
||||
|
||||
// #then - countdown should continue (message was ignored)
|
||||
// #then - countdown should continue (message was ignored)
|
||||
// wait past 2s countdown and verify injection happens
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -242,7 +358,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - assistant starts responding
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await fakeTimers.advanceBy(500)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
@@ -250,7 +366,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected (cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -269,12 +385,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - tool starts executing
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await fakeTimers.advanceBy(500)
|
||||
await hook.handler({
|
||||
event: { type: "tool.execute.before", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected (cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -295,7 +411,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -317,7 +433,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -336,12 +452,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - session is deleted during countdown
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await fakeTimers.advanceBy(500)
|
||||
await hook.handler({
|
||||
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation injected (cleaned up)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -362,7 +478,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
await fakeTimers.advanceBy(100)
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -379,7 +495,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(2)
|
||||
expect(toastCalls[0].message).toContain("2s")
|
||||
})
|
||||
@@ -395,7 +511,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 3500))
|
||||
await fakeTimers.advanceBy(3500)
|
||||
|
||||
// #then - first injection happened
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -404,7 +520,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 3500))
|
||||
await fakeTimers.advanceBy(3500)
|
||||
|
||||
// #then - second injection also happened (no throttle blocking)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
@@ -439,7 +555,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
|
||||
// #then - continuation injected (non-abort errors don't block)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -472,7 +588,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (last message was aborted)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -490,12 +606,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (no abort)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -518,7 +634,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (last message is user, not aborted assistant)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -541,7 +657,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (abort error detected)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -566,12 +682,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after
|
||||
// #when - session goes idle immediately after
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (abort detected via event)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -601,7 +717,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (abort detected via event)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -627,13 +743,13 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - wait >3s then idle fires
|
||||
await new Promise(r => setTimeout(r, 3100))
|
||||
await fakeTimers.advanceBy(3100, true)
|
||||
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (abort flag is stale)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
@@ -659,7 +775,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
})
|
||||
|
||||
// #when - user sends new message (clears abort flag)
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
await fakeTimers.advanceBy(600)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
@@ -672,7 +788,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (abort flag was cleared by user activity)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
@@ -710,7 +826,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (abort flag was cleared by assistant activity)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
@@ -748,7 +864,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (abort flag was cleared by tool execution)
|
||||
expect(promptCalls.length).toBeGreaterThan(0)
|
||||
@@ -778,7 +894,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (event-based detection wins over API)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -800,7 +916,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (API fallback detected the abort)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -820,7 +936,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
|
||||
// #then - prompt call made, model is undefined when no context (expected behavior)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -867,7 +983,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
|
||||
// #then - model should be extracted from assistant message's flat modelID/providerID
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -919,7 +1035,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await fakeTimers.advanceBy(2500)
|
||||
|
||||
// #then - continuation uses Sisyphus (skipped compaction agent)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -964,7 +1080,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (compaction is in default skipAgents)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -1010,7 +1126,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
@@ -1057,7 +1173,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
await fakeTimers.advanceBy(3000)
|
||||
|
||||
// #then - continuation injected (no agents to skip)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
|
||||
@@ -205,7 +205,11 @@ export function createTodoContinuationEnforcer(
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agentName = agentName ?? prevMessage?.agent
|
||||
model = model ?? (prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
? {
|
||||
providerID: prevMessage.model.providerID,
|
||||
modelID: prevMessage.model.modelID,
|
||||
...(prevMessage.model.variant ? { variant: prevMessage.model.variant } : {})
|
||||
}
|
||||
: undefined)
|
||||
tools = tools ?? prevMessage?.tools
|
||||
}
|
||||
|
||||
11
src/index.ts
11
src/index.ts
@@ -118,7 +118,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
if (externalNotifier.detected && !forceEnable) {
|
||||
// External notification plugin detected - skip our notification to avoid conflicts
|
||||
console.warn(getNotificationConflictWarning(externalNotifier.pluginName!));
|
||||
log(getNotificationConflictWarning(externalNotifier.pluginName!));
|
||||
log("session-notification disabled due to external notifier conflict", {
|
||||
detected: externalNotifier.pluginName,
|
||||
allPlugins: externalNotifier.allPlugins,
|
||||
@@ -144,10 +144,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
isOpenCodeVersionAtLeast(OPENCODE_NATIVE_AGENTS_INJECTION_VERSION);
|
||||
|
||||
if (hasNativeSupport) {
|
||||
console.warn(
|
||||
`[oh-my-opencode] directory-agents-injector hook auto-disabled: ` +
|
||||
`OpenCode ${currentVersion} has native AGENTS.md support (>= ${OPENCODE_NATIVE_AGENTS_INJECTION_VERSION})`
|
||||
);
|
||||
log("directory-agents-injector auto-disabled due to native OpenCode support", {
|
||||
currentVersion,
|
||||
nativeVersion: OPENCODE_NATIVE_AGENTS_INJECTION_VERSION,
|
||||
@@ -268,6 +264,11 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
});
|
||||
log("[index] onSubagentSessionCreated callback completed");
|
||||
},
|
||||
onShutdown: () => {
|
||||
tmuxSessionManager.cleanup().catch((error) => {
|
||||
log("[index] tmux cleanup error during shutdown:", error)
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
const atlasHook = isHookEnabled("atlas")
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
||||
import { resolveCategoryConfig, createConfigHandler } from "./config-handler"
|
||||
import type { CategoryConfig } from "../config/schema"
|
||||
import type { OhMyOpenCodeConfig } from "../config"
|
||||
|
||||
mock.module("../agents", () => ({
|
||||
createBuiltinAgents: async () => ({
|
||||
import * as agents from "../agents"
|
||||
import * as sisyphusJunior from "../agents/sisyphus-junior"
|
||||
import * as commandLoader from "../features/claude-code-command-loader"
|
||||
import * as builtinCommands from "../features/builtin-commands"
|
||||
import * as skillLoader from "../features/opencode-skill-loader"
|
||||
import * as agentLoader from "../features/claude-code-agent-loader"
|
||||
import * as mcpLoader from "../features/claude-code-mcp-loader"
|
||||
import * as pluginLoader from "../features/claude-code-plugin-loader"
|
||||
import * as mcpModule from "../mcp"
|
||||
import * as shared from "../shared"
|
||||
import * as configDir from "../shared/opencode-config-dir"
|
||||
import * as permissionCompat from "../shared/permission-compat"
|
||||
import * as modelResolver from "../shared/model-resolver"
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(agents, "createBuiltinAgents" as any).mockResolvedValue({
|
||||
sisyphus: { name: "sisyphus", prompt: "test", mode: "primary" },
|
||||
oracle: { name: "oracle", prompt: "test", mode: "subagent" },
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
mock.module("../agents/sisyphus-junior", () => ({
|
||||
createSisyphusJuniorAgentWithOverrides: () => ({
|
||||
spyOn(sisyphusJunior, "createSisyphusJuniorAgentWithOverrides" as any).mockReturnValue({
|
||||
name: "sisyphus-junior",
|
||||
prompt: "test",
|
||||
mode: "subagent",
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
mock.module("../features/claude-code-command-loader", () => ({
|
||||
loadUserCommands: async () => ({}),
|
||||
loadProjectCommands: async () => ({}),
|
||||
loadOpencodeGlobalCommands: async () => ({}),
|
||||
loadOpencodeProjectCommands: async () => ({}),
|
||||
}))
|
||||
spyOn(commandLoader, "loadUserCommands" as any).mockResolvedValue({})
|
||||
spyOn(commandLoader, "loadProjectCommands" as any).mockResolvedValue({})
|
||||
spyOn(commandLoader, "loadOpencodeGlobalCommands" as any).mockResolvedValue({})
|
||||
spyOn(commandLoader, "loadOpencodeProjectCommands" as any).mockResolvedValue({})
|
||||
|
||||
mock.module("../features/builtin-commands", () => ({
|
||||
loadBuiltinCommands: () => ({}),
|
||||
}))
|
||||
spyOn(builtinCommands, "loadBuiltinCommands" as any).mockReturnValue({})
|
||||
|
||||
mock.module("../features/opencode-skill-loader", () => ({
|
||||
loadUserSkills: async () => ({}),
|
||||
loadProjectSkills: async () => ({}),
|
||||
loadOpencodeGlobalSkills: async () => ({}),
|
||||
loadOpencodeProjectSkills: async () => ({}),
|
||||
discoverUserClaudeSkills: async () => [],
|
||||
discoverProjectClaudeSkills: async () => [],
|
||||
discoverOpencodeGlobalSkills: async () => [],
|
||||
discoverOpencodeProjectSkills: async () => [],
|
||||
}))
|
||||
spyOn(skillLoader, "loadUserSkills" as any).mockResolvedValue({})
|
||||
spyOn(skillLoader, "loadProjectSkills" as any).mockResolvedValue({})
|
||||
spyOn(skillLoader, "loadOpencodeGlobalSkills" as any).mockResolvedValue({})
|
||||
spyOn(skillLoader, "loadOpencodeProjectSkills" as any).mockResolvedValue({})
|
||||
spyOn(skillLoader, "discoverUserClaudeSkills" as any).mockResolvedValue([])
|
||||
spyOn(skillLoader, "discoverProjectClaudeSkills" as any).mockResolvedValue([])
|
||||
spyOn(skillLoader, "discoverOpencodeGlobalSkills" as any).mockResolvedValue([])
|
||||
spyOn(skillLoader, "discoverOpencodeProjectSkills" as any).mockResolvedValue([])
|
||||
|
||||
mock.module("../features/claude-code-agent-loader", () => ({
|
||||
loadUserAgents: () => ({}),
|
||||
loadProjectAgents: () => ({}),
|
||||
}))
|
||||
spyOn(agentLoader, "loadUserAgents" as any).mockReturnValue({})
|
||||
spyOn(agentLoader, "loadProjectAgents" as any).mockReturnValue({})
|
||||
|
||||
mock.module("../features/claude-code-mcp-loader", () => ({
|
||||
loadMcpConfigs: async () => ({ servers: {} }),
|
||||
}))
|
||||
spyOn(mcpLoader, "loadMcpConfigs" as any).mockResolvedValue({ servers: {} })
|
||||
|
||||
mock.module("../features/claude-code-plugin-loader", () => ({
|
||||
loadAllPluginComponents: async () => ({
|
||||
spyOn(pluginLoader, "loadAllPluginComponents" as any).mockResolvedValue({
|
||||
commands: {},
|
||||
skills: {},
|
||||
agents: {},
|
||||
@@ -58,60 +58,52 @@ mock.module("../features/claude-code-plugin-loader", () => ({
|
||||
hooksConfigs: [],
|
||||
plugins: [],
|
||||
errors: [],
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
mock.module("../mcp", () => ({
|
||||
createBuiltinMcps: () => ({}),
|
||||
}))
|
||||
spyOn(mcpModule, "createBuiltinMcps" as any).mockReturnValue({})
|
||||
|
||||
mock.module("../shared", () => ({
|
||||
log: () => {},
|
||||
fetchAvailableModels: async () => new Set(["anthropic/claude-opus-4-5"]),
|
||||
readConnectedProvidersCache: () => null,
|
||||
}))
|
||||
spyOn(shared, "log" as any).mockImplementation(() => {})
|
||||
spyOn(shared, "fetchAvailableModels" as any).mockResolvedValue(new Set(["anthropic/claude-opus-4-5"]))
|
||||
spyOn(shared, "readConnectedProvidersCache" as any).mockReturnValue(null)
|
||||
|
||||
mock.module("../shared/opencode-config-dir", () => ({
|
||||
getOpenCodeConfigPaths: () => ({
|
||||
spyOn(configDir, "getOpenCodeConfigPaths" as any).mockReturnValue({
|
||||
global: "/tmp/.config/opencode",
|
||||
project: "/tmp/.opencode",
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
mock.module("../shared/permission-compat", () => ({
|
||||
migrateAgentConfig: (config: Record<string, unknown>) => config,
|
||||
}))
|
||||
spyOn(permissionCompat, "migrateAgentConfig" as any).mockImplementation((config: Record<string, unknown>) => config)
|
||||
|
||||
mock.module("../shared/migration", () => ({
|
||||
AGENT_NAME_MAP: {},
|
||||
}))
|
||||
spyOn(modelResolver, "resolveModelWithFallback" as any).mockReturnValue({ model: "anthropic/claude-opus-4-5" })
|
||||
})
|
||||
|
||||
mock.module("../shared/model-resolver", () => ({
|
||||
resolveModelWithFallback: () => ({ model: "anthropic/claude-opus-4-5" }),
|
||||
}))
|
||||
|
||||
mock.module("../shared/model-requirements", () => ({
|
||||
AGENT_MODEL_REQUIREMENTS: {
|
||||
sisyphus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
oracle: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
|
||||
librarian: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
||||
explore: { fallbackChain: [{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" }] },
|
||||
"multimodal-looker": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
|
||||
prometheus: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
metis: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
momus: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" }] },
|
||||
atlas: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
||||
},
|
||||
CATEGORY_MODEL_REQUIREMENTS: {
|
||||
"visual-engineering": { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
|
||||
ultrabrain: { fallbackChain: [{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex" }] },
|
||||
artistry: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" }] },
|
||||
quick: { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" }] },
|
||||
"unspecified-low": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" }] },
|
||||
"unspecified-high": { fallbackChain: [{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" }] },
|
||||
writing: { fallbackChain: [{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" }] },
|
||||
},
|
||||
}))
|
||||
afterEach(() => {
|
||||
(agents.createBuiltinAgents as any)?.mockRestore?.()
|
||||
;(sisyphusJunior.createSisyphusJuniorAgentWithOverrides as any)?.mockRestore?.()
|
||||
;(commandLoader.loadUserCommands as any)?.mockRestore?.()
|
||||
;(commandLoader.loadProjectCommands as any)?.mockRestore?.()
|
||||
;(commandLoader.loadOpencodeGlobalCommands as any)?.mockRestore?.()
|
||||
;(commandLoader.loadOpencodeProjectCommands as any)?.mockRestore?.()
|
||||
;(builtinCommands.loadBuiltinCommands as any)?.mockRestore?.()
|
||||
;(skillLoader.loadUserSkills as any)?.mockRestore?.()
|
||||
;(skillLoader.loadProjectSkills as any)?.mockRestore?.()
|
||||
;(skillLoader.loadOpencodeGlobalSkills as any)?.mockRestore?.()
|
||||
;(skillLoader.loadOpencodeProjectSkills as any)?.mockRestore?.()
|
||||
;(skillLoader.discoverUserClaudeSkills as any)?.mockRestore?.()
|
||||
;(skillLoader.discoverProjectClaudeSkills as any)?.mockRestore?.()
|
||||
;(skillLoader.discoverOpencodeGlobalSkills as any)?.mockRestore?.()
|
||||
;(skillLoader.discoverOpencodeProjectSkills as any)?.mockRestore?.()
|
||||
;(agentLoader.loadUserAgents as any)?.mockRestore?.()
|
||||
;(agentLoader.loadProjectAgents as any)?.mockRestore?.()
|
||||
;(mcpLoader.loadMcpConfigs as any)?.mockRestore?.()
|
||||
;(pluginLoader.loadAllPluginComponents as any)?.mockRestore?.()
|
||||
;(mcpModule.createBuiltinMcps as any)?.mockRestore?.()
|
||||
;(shared.log as any)?.mockRestore?.()
|
||||
;(shared.fetchAvailableModels as any)?.mockRestore?.()
|
||||
;(shared.readConnectedProvidersCache as any)?.mockRestore?.()
|
||||
;(configDir.getOpenCodeConfigPaths as any)?.mockRestore?.()
|
||||
;(permissionCompat.migrateAgentConfig as any)?.mockRestore?.()
|
||||
;(modelResolver.resolveModelWithFallback as any)?.mockRestore?.()
|
||||
})
|
||||
|
||||
describe("Plan agent demote behavior", () => {
|
||||
test("plan agent should be demoted to subagent mode when replacePlan is true", async () => {
|
||||
@@ -280,3 +272,170 @@ describe("Prometheus category config resolution", () => {
|
||||
expect(config?.tools).toEqual({ tool1: true, tool2: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe("Prometheus direct override priority over category", () => {
|
||||
test("direct reasoningEffort takes priority over category reasoningEffort", async () => {
|
||||
// #given - category has reasoningEffort=xhigh, direct override says "low"
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
},
|
||||
categories: {
|
||||
"test-planning": {
|
||||
model: "openai/gpt-5.2",
|
||||
reasoningEffort: "xhigh",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
prometheus: {
|
||||
category: "test-planning",
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then - direct override's reasoningEffort wins
|
||||
const agents = config.agent as Record<string, { reasoningEffort?: string }>
|
||||
expect(agents.prometheus).toBeDefined()
|
||||
expect(agents.prometheus.reasoningEffort).toBe("low")
|
||||
})
|
||||
|
||||
test("category reasoningEffort applied when no direct override", async () => {
|
||||
// #given - category has reasoningEffort but no direct override
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
},
|
||||
categories: {
|
||||
"reasoning-cat": {
|
||||
model: "openai/gpt-5.2",
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
prometheus: {
|
||||
category: "reasoning-cat",
|
||||
},
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then - category's reasoningEffort is applied
|
||||
const agents = config.agent as Record<string, { reasoningEffort?: string }>
|
||||
expect(agents.prometheus).toBeDefined()
|
||||
expect(agents.prometheus.reasoningEffort).toBe("high")
|
||||
})
|
||||
|
||||
test("direct temperature takes priority over category temperature", async () => {
|
||||
// #given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
},
|
||||
categories: {
|
||||
"temp-cat": {
|
||||
model: "openai/gpt-5.2",
|
||||
temperature: 0.8,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
prometheus: {
|
||||
category: "temp-cat",
|
||||
temperature: 0.1,
|
||||
},
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp" },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then - direct temperature wins over category
|
||||
const agents = config.agent as Record<string, { temperature?: number }>
|
||||
expect(agents.prometheus).toBeDefined()
|
||||
expect(agents.prometheus.temperature).toBe(0.1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Deadlock prevention - fetchAvailableModels must not receive client", () => {
|
||||
test("fetchAvailableModels should be called with undefined client to prevent deadlock during plugin init", async () => {
|
||||
// #given - This test ensures we don't regress on issue #1301
|
||||
// Passing client to fetchAvailableModels during config handler causes deadlock:
|
||||
// - Plugin init waits for server response (client.provider.list())
|
||||
// - Server waits for plugin init to complete before handling requests
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels" as any).mockResolvedValue(new Set<string>())
|
||||
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
sisyphus_agent: {
|
||||
planner_enabled: true,
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
agent: {},
|
||||
}
|
||||
const mockClient = {
|
||||
provider: { list: () => Promise.resolve({ data: { connected: [] } }) },
|
||||
model: { list: () => Promise.resolve({ data: [] }) },
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
ctx: { directory: "/tmp", client: mockClient },
|
||||
pluginConfig,
|
||||
modelCacheState: {
|
||||
anthropicContext1MEnabled: false,
|
||||
modelContextLimitsCache: new Map(),
|
||||
},
|
||||
})
|
||||
|
||||
// #when
|
||||
await handler(config)
|
||||
|
||||
// #then - fetchAvailableModels must be called with undefined as first argument (no client)
|
||||
// This prevents the deadlock described in issue #1301
|
||||
expect(fetchSpy).toHaveBeenCalled()
|
||||
const firstCallArgs = fetchSpy.mock.calls[0]
|
||||
expect(firstCallArgs[0]).toBeUndefined()
|
||||
|
||||
fetchSpy.mockRestore?.()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,16 +133,20 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
];
|
||||
|
||||
const browserProvider = pluginConfig.browser_automation_engine?.provider ?? "playwright";
|
||||
// config.model represents the currently active model in OpenCode (including UI selection)
|
||||
// Pass it as uiSelectedModel so it takes highest priority in model resolution
|
||||
const currentModel = config.model as string | undefined;
|
||||
const builtinAgents = await createBuiltinAgents(
|
||||
migratedDisabledAgents,
|
||||
pluginConfig.agents,
|
||||
ctx.directory,
|
||||
config.model as string | undefined,
|
||||
undefined, // systemDefaultModel - let fallback chain handle this
|
||||
pluginConfig.categories,
|
||||
pluginConfig.git_master,
|
||||
allDiscoveredSkills,
|
||||
ctx.client,
|
||||
browserProvider
|
||||
browserProvider,
|
||||
currentModel // uiSelectedModel - takes highest priority
|
||||
);
|
||||
|
||||
// Claude Code agents: Do NOT apply permission migration
|
||||
@@ -223,9 +227,18 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
);
|
||||
const prometheusOverride =
|
||||
pluginConfig.agents?.["prometheus"] as
|
||||
| (Record<string, unknown> & { category?: string; model?: string; variant?: string })
|
||||
| (Record<string, unknown> & {
|
||||
category?: string
|
||||
model?: string
|
||||
variant?: string
|
||||
reasoningEffort?: string
|
||||
textVerbosity?: string
|
||||
thinking?: { type: string; budgetTokens?: number }
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
maxTokens?: number
|
||||
})
|
||||
| undefined;
|
||||
const defaultModel = config.model as string | undefined;
|
||||
|
||||
const categoryConfig = prometheusOverride?.category
|
||||
? resolveCategoryConfig(
|
||||
@@ -236,20 +249,33 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
|
||||
const prometheusRequirement = AGENT_MODEL_REQUIREMENTS["prometheus"];
|
||||
const connectedProviders = readConnectedProvidersCache();
|
||||
const availableModels = ctx.client
|
||||
? await fetchAvailableModels(ctx.client, { connectedProviders: connectedProviders ?? undefined })
|
||||
: new Set<string>();
|
||||
// IMPORTANT: Do NOT pass ctx.client to fetchAvailableModels during plugin initialization.
|
||||
// Calling client API (e.g., client.provider.list()) from config handler causes deadlock:
|
||||
// - Plugin init waits for server response
|
||||
// - Server waits for plugin init to complete before handling requests
|
||||
// Use cache-only mode instead. If cache is unavailable, fallback chain uses first model.
|
||||
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
|
||||
const availableModels = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: connectedProviders ?? undefined,
|
||||
});
|
||||
|
||||
const modelResolution = resolveModelWithFallback({
|
||||
uiSelectedModel: currentModel,
|
||||
userModel: prometheusOverride?.model ?? categoryConfig?.model,
|
||||
fallbackChain: prometheusRequirement?.fallbackChain,
|
||||
availableModels,
|
||||
systemDefaultModel: defaultModel ?? "",
|
||||
systemDefaultModel: undefined,
|
||||
});
|
||||
const resolvedModel = modelResolution?.model;
|
||||
const resolvedVariant = modelResolution?.variant;
|
||||
|
||||
const variantToUse = prometheusOverride?.variant ?? resolvedVariant;
|
||||
const reasoningEffortToUse = prometheusOverride?.reasoningEffort ?? categoryConfig?.reasoningEffort;
|
||||
const textVerbosityToUse = prometheusOverride?.textVerbosity ?? categoryConfig?.textVerbosity;
|
||||
const thinkingToUse = prometheusOverride?.thinking ?? categoryConfig?.thinking;
|
||||
const temperatureToUse = prometheusOverride?.temperature ?? categoryConfig?.temperature;
|
||||
const topPToUse = prometheusOverride?.top_p ?? categoryConfig?.top_p;
|
||||
const maxTokensToUse = prometheusOverride?.maxTokens ?? categoryConfig?.maxTokens;
|
||||
const prometheusBase = {
|
||||
name: "prometheus",
|
||||
...(resolvedModel ? { model: resolvedModel } : {}),
|
||||
@@ -259,22 +285,16 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
permission: PROMETHEUS_PERMISSION,
|
||||
description: `${configAgent?.plan?.description ?? "Plan agent"} (Prometheus - OhMyOpenCode)`,
|
||||
color: (configAgent?.plan?.color as string) ?? "#FF6347",
|
||||
...(categoryConfig?.temperature !== undefined
|
||||
? { temperature: categoryConfig.temperature }
|
||||
: {}),
|
||||
...(categoryConfig?.top_p !== undefined
|
||||
? { top_p: categoryConfig.top_p }
|
||||
: {}),
|
||||
...(categoryConfig?.maxTokens !== undefined
|
||||
? { maxTokens: categoryConfig.maxTokens }
|
||||
: {}),
|
||||
...(temperatureToUse !== undefined ? { temperature: temperatureToUse } : {}),
|
||||
...(topPToUse !== undefined ? { top_p: topPToUse } : {}),
|
||||
...(maxTokensToUse !== undefined ? { maxTokens: maxTokensToUse } : {}),
|
||||
...(categoryConfig?.tools ? { tools: categoryConfig.tools } : {}),
|
||||
...(categoryConfig?.thinking ? { thinking: categoryConfig.thinking } : {}),
|
||||
...(categoryConfig?.reasoningEffort !== undefined
|
||||
? { reasoningEffort: categoryConfig.reasoningEffort }
|
||||
...(thinkingToUse ? { thinking: thinkingToUse } : {}),
|
||||
...(reasoningEffortToUse !== undefined
|
||||
? { reasoningEffort: reasoningEffortToUse }
|
||||
: {}),
|
||||
...(categoryConfig?.textVerbosity !== undefined
|
||||
? { textVerbosity: categoryConfig.textVerbosity }
|
||||
...(textVerbosityToUse !== undefined
|
||||
? { textVerbosity: textVerbosityToUse }
|
||||
: {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("Agent Config Integration", () => {
|
||||
const config = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
librarian: { model: "opencode/big-pickle" },
|
||||
librarian: { model: "opencode/glm-4.7-free" },
|
||||
}
|
||||
|
||||
// #when - migration is applied
|
||||
@@ -65,7 +65,7 @@ describe("Agent Config Integration", () => {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
|
||||
librarian: { model: "opencode/big-pickle" },
|
||||
librarian: { model: "opencode/glm-4.7-free" },
|
||||
}
|
||||
|
||||
// #when - migration is applied
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user