Compare commits
207 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1f6f9d41f | ||
|
|
4b35bf795a | ||
|
|
3adedca810 | ||
|
|
3dea568007 | ||
|
|
00b938d20d | ||
|
|
35d53cc74a | ||
|
|
9a1a22d1c5 | ||
|
|
96088381e2 | ||
|
|
c2d6e03b92 | ||
|
|
7f27fbc890 | ||
|
|
2806c64675 | ||
|
|
ed76c502c3 | ||
|
|
c4f2b63890 | ||
|
|
030277b8dd | ||
|
|
5e8e42fb74 | ||
|
|
a633c4dfbe | ||
|
|
0c8a500de4 | ||
|
|
2292a61887 | ||
|
|
d1a527c700 | ||
|
|
0fcfe21b27 | ||
|
|
25a5c2eeb4 | ||
|
|
521bcd5667 | ||
|
|
d3e317663e | ||
|
|
7938316a61 | ||
|
|
8a7469ef2b | ||
|
|
dba0c46417 | ||
|
|
fcf3f0cc7f | ||
|
|
b00b8238f4 | ||
|
|
53d8cf12f2 | ||
|
|
8681f16c52 | ||
|
|
ed66ba5f55 | ||
|
|
0f2bd63732 | ||
|
|
bc20853d83 | ||
|
|
7882f77a90 | ||
|
|
92c69f4167 | ||
|
|
27403f2682 | ||
|
|
44ce343708 | ||
|
|
ff48ac0745 | ||
|
|
b24b00fad2 | ||
|
|
f3b2fccba7 | ||
|
|
2c6dfeadce | ||
|
|
64b53c0e1c | ||
|
|
0ae1f8c056 | ||
|
|
3caa84f06b | ||
|
|
354be6b801 | ||
|
|
9a78df1939 | ||
|
|
ab522aff1a | ||
|
|
40c1e62a30 | ||
|
|
3f28ce52ad | ||
|
|
9575a4b5c0 | ||
|
|
098d023dba | ||
|
|
92d412a171 | ||
|
|
a7507ab43d | ||
|
|
1752b1caf9 | ||
|
|
9cda5eb262 | ||
|
|
96886f18ac | ||
|
|
a3938e8c25 | ||
|
|
821b0b8e9f | ||
|
|
356bd1dff3 | ||
|
|
f2b070cd0b | ||
|
|
1323443c85 | ||
|
|
60d9513d3a | ||
|
|
55bc8f08df | ||
|
|
0ac4d223f9 | ||
|
|
19b3690499 | ||
|
|
564c8ae8bf | ||
|
|
03c61bf591 | ||
|
|
f57aa39d53 | ||
|
|
41a318df66 | ||
|
|
e533a35109 | ||
|
|
934d4bcf32 | ||
|
|
91ae0cc67d | ||
|
|
7859f0dd2d | ||
|
|
e131491db4 | ||
|
|
08e2bb4034 | ||
|
|
04f33e584c | ||
|
|
8d76a57fe8 | ||
|
|
8db2bd3893 | ||
|
|
555abbc0d6 | ||
|
|
3b129f11c4 | ||
|
|
2cab36f06d | ||
|
|
fd357e490b | ||
|
|
55157bceaf | ||
|
|
5608bd0ef9 | ||
|
|
abd90bbc9c | ||
|
|
7fe85a11da | ||
|
|
8e62514eef | ||
|
|
dddb920061 | ||
|
|
787e247a08 | ||
|
|
6f229a86e3 | ||
|
|
5fd59afacf | ||
|
|
3d273ff853 | ||
|
|
6a565ee126 | ||
|
|
0bf853d9ef | ||
|
|
16393b2554 | ||
|
|
d450c4f966 | ||
|
|
4b3b581901 | ||
|
|
af03a89e0c | ||
|
|
7bfca25958 | ||
|
|
d444e62b20 | ||
|
|
07e2e907c5 | ||
|
|
36b8576c78 | ||
|
|
5ba1d9f3c3 | ||
|
|
efe37d4cfc | ||
|
|
c662f9c240 | ||
|
|
12c0b7b6c0 | ||
|
|
f007437991 | ||
|
|
f6bdc45fe7 | ||
|
|
01f935f074 | ||
|
|
550322cb0c | ||
|
|
14f785925c | ||
|
|
6449a00f46 | ||
|
|
3fd9e95579 | ||
|
|
78047dfd7d | ||
|
|
9986841f9b | ||
|
|
b422e2f94f | ||
|
|
e74cc82bcf | ||
|
|
ea46ba6c60 | ||
|
|
d67f97158a | ||
|
|
e140dc74c6 | ||
|
|
24a7f333a2 | ||
|
|
80cfe87390 | ||
|
|
5733291a0f | ||
|
|
b5d56246f6 | ||
|
|
245acdabad | ||
|
|
49fb046363 | ||
|
|
ce6a09b891 | ||
|
|
2fad28d552 | ||
|
|
698cdb6744 | ||
|
|
9ec20d4cb2 | ||
|
|
9ba0015530 | ||
|
|
f6dd6e3c7f | ||
|
|
595f4b6dd5 | ||
|
|
4891a0e6f2 | ||
|
|
dd645994b2 | ||
|
|
fcdfcd3186 | ||
|
|
c12f73f774 | ||
|
|
06e0285b9c | ||
|
|
64138ee88f | ||
|
|
359340de65 | ||
|
|
3eb88aa861 | ||
|
|
652f343c95 | ||
|
|
9ba41558de | ||
|
|
50727171a6 | ||
|
|
14ff86c547 | ||
|
|
e4036185f0 | ||
|
|
d34154bc68 | ||
|
|
9e00be91af | ||
|
|
40d4673201 | ||
|
|
cf33fc5da1 | ||
|
|
407786978a | ||
|
|
15454f1d81 | ||
|
|
56160d17f8 | ||
|
|
61bbbcb577 | ||
|
|
adabace02d | ||
|
|
41f93c9f8b | ||
|
|
8102d178cb | ||
|
|
4f019f8fe5 | ||
|
|
7b19177c8a | ||
|
|
e8f59cbbf8 | ||
|
|
2d23a81926 | ||
|
|
31cb8616c2 | ||
|
|
1932257f82 | ||
|
|
5a793bb526 | ||
|
|
2ec351d0d8 | ||
|
|
441fc1a219 | ||
|
|
bd67419d1d | ||
|
|
dca98121ac | ||
|
|
3fcfedcec0 | ||
|
|
530c4d63d5 | ||
|
|
e0b43380cc | ||
|
|
a27cac96d5 | ||
|
|
fef7f4ca03 | ||
|
|
e147be7ed4 | ||
|
|
124c3b3e8f | ||
|
|
5678e0bac6 | ||
|
|
207435450c | ||
|
|
376bf363af | ||
|
|
c7a65af475 | ||
|
|
8e7447deee | ||
|
|
15a748b817 | ||
|
|
c0e0dc1f95 | ||
|
|
7059407cbc | ||
|
|
589cf60252 | ||
|
|
e5cdaa5192 | ||
|
|
340eb30147 | ||
|
|
e72b927ccb | ||
|
|
3c6ffe5d9c | ||
|
|
938a3709e1 | ||
|
|
47f218e33f | ||
|
|
e07a25baa4 | ||
|
|
08ede0a28d | ||
|
|
a711d58289 | ||
|
|
431ec14991 | ||
|
|
62cae8114d | ||
|
|
e6eafe267a | ||
|
|
e4ef832405 | ||
|
|
ef6d67645e | ||
|
|
227d93f106 | ||
|
|
edff922afb | ||
|
|
45bdcf3580 | ||
|
|
b07dd22093 | ||
|
|
c7d29fea48 | ||
|
|
55675497a5 | ||
|
|
ae2d347d81 | ||
|
|
2683de825a | ||
|
|
0b5c8250ca |
BIN
.github/assets/hero.jpg
vendored
Normal file
BIN
.github/assets/hero.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 805 KiB |
BIN
.github/assets/preview.png
vendored
Normal file
BIN
.github/assets/preview.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1021 KiB |
1
.github/workflows/publish.yml
vendored
1
.github/workflows/publish.yml
vendored
@@ -26,6 +26,7 @@ permissions:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'code-yeongyu/oh-my-opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,3 +25,6 @@ yarn.lock
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
test-injection/
|
||||
notepad.md
|
||||
oauth-success.html
|
||||
|
||||
27
.opencode/background-tasks.json
Normal file
27
.opencode/background-tasks.json
Normal file
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"id": "bg_wzsdt60b",
|
||||
"sessionID": "ses_4f3e89f0dffeooeXNVx5QCifse",
|
||||
"parentSessionID": "ses_4f3e8d141ffeyfJ1taVVOdQTzx",
|
||||
"parentMessageID": "msg_b0c172ee1001w2B52VSZrP08PJ",
|
||||
"description": "Explore opencode in codebase",
|
||||
"agent": "explore",
|
||||
"status": "completed",
|
||||
"startedAt": "2025-12-11T06:26:57.395Z",
|
||||
"completedAt": "2025-12-11T06:27:36.778Z"
|
||||
},
|
||||
{
|
||||
"id": "bg_392b9c9b",
|
||||
"sessionID": "ses_4f38ebf4fffeJZBocIn3UVv7vE",
|
||||
"parentSessionID": "ses_4f38eefa0ffeKV0pVNnwT37P5L",
|
||||
"parentMessageID": "msg_b0c7110d2001TMBlPeEYIrByvs",
|
||||
"description": "Test explore agent",
|
||||
"agent": "explore",
|
||||
"status": "running",
|
||||
"startedAt": "2025-12-11T08:05:07.378Z",
|
||||
"progress": {
|
||||
"toolCalls": 0,
|
||||
"lastUpdate": "2025-12-11T08:05:07.378Z"
|
||||
}
|
||||
}
|
||||
]
|
||||
84
.opencode/command/get-unpublished-changes.md
Normal file
84
.opencode/command/get-unpublished-changes.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
description: Compare HEAD with the latest published npm version and list all unpublished changes
|
||||
model: anthropic/claude-haiku-4-5
|
||||
---
|
||||
|
||||
<command-instruction>
|
||||
IMMEDIATELY output the analysis. NO questions. NO preamble.
|
||||
|
||||
## CRITICAL: DO NOT just copy commit messages!
|
||||
|
||||
For each commit, you MUST:
|
||||
1. Read the actual diff to understand WHAT CHANGED
|
||||
2. Describe the REAL change in plain language
|
||||
3. Explain WHY it matters (if not obvious)
|
||||
|
||||
## Steps:
|
||||
1. Run `git diff v{published-version}..HEAD` to see actual changes
|
||||
2. Group by type (feat/fix/refactor/docs) with REAL descriptions
|
||||
3. Note breaking changes if any
|
||||
4. Recommend version bump (major/minor/patch)
|
||||
|
||||
## Output Format:
|
||||
- feat: "Added X that does Y" (not just "add X feature")
|
||||
- fix: "Fixed bug where X happened, now Y" (not just "fix X bug")
|
||||
- refactor: "Changed X from A to B, now supports C" (not just "rename X")
|
||||
</command-instruction>
|
||||
|
||||
<version-context>
|
||||
<published-version>
|
||||
!`npm view oh-my-opencode version 2>/dev/null || echo "not published"`
|
||||
</published-version>
|
||||
<local-version>
|
||||
!`node -p "require('./package.json').version" 2>/dev/null || echo "unknown"`
|
||||
</local-version>
|
||||
<latest-tag>
|
||||
!`git tag --sort=-v:refname | head -1 2>/dev/null || echo "no tags"`
|
||||
</latest-tag>
|
||||
</version-context>
|
||||
|
||||
<git-context>
|
||||
<commits-since-release>
|
||||
!`npm view oh-my-opencode version 2>/dev/null | xargs -I{} git log "v{}"..HEAD --oneline 2>/dev/null || echo "no commits since release"`
|
||||
</commits-since-release>
|
||||
<diff-stat>
|
||||
!`npm view oh-my-opencode version 2>/dev/null | xargs -I{} git diff "v{}"..HEAD --stat 2>/dev/null || echo "no diff available"`
|
||||
</diff-stat>
|
||||
<files-changed-summary>
|
||||
!`npm view oh-my-opencode version 2>/dev/null | xargs -I{} git diff "v{}"..HEAD --stat 2>/dev/null | tail -1 || echo ""`
|
||||
</files-changed-summary>
|
||||
</git-context>
|
||||
|
||||
<output-format>
|
||||
## Unpublished Changes (v{published} → HEAD)
|
||||
|
||||
### feat
|
||||
| Scope | What Changed |
|
||||
|-------|--------------|
|
||||
| X | 실제 변경 내용 설명 |
|
||||
|
||||
### fix
|
||||
| Scope | What Changed |
|
||||
|-------|--------------|
|
||||
| X | 실제 변경 내용 설명 |
|
||||
|
||||
### refactor
|
||||
| Scope | What Changed |
|
||||
|-------|--------------|
|
||||
| X | 실제 변경 내용 설명 |
|
||||
|
||||
### docs
|
||||
| Scope | What Changed |
|
||||
|-------|--------------|
|
||||
| X | 실제 변경 내용 설명 |
|
||||
|
||||
### Breaking Changes
|
||||
None 또는 목록
|
||||
|
||||
### Files Changed
|
||||
{diff-stat}
|
||||
|
||||
### Suggested Version Bump
|
||||
- **Recommendation**: patch|minor|major
|
||||
- **Reason**: 이유
|
||||
</output-format>
|
||||
37
.opencode/command/omomomo.md
Normal file
37
.opencode/command/omomomo.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Easter egg command - about oh-my-opencode
|
||||
---
|
||||
|
||||
<command-instruction>
|
||||
You found an easter egg! 🥚✨
|
||||
|
||||
Print the following message to the user EXACTLY as written (in a friendly, celebratory tone):
|
||||
|
||||
---
|
||||
|
||||
# 🎉 oMoMoMoMoMo···
|
||||
|
||||
**You found the easter egg!** 🥚✨
|
||||
|
||||
## What is Oh My OpenCode?
|
||||
|
||||
**Oh My OpenCode** is a powerful OpenCode plugin that transforms your AI agent into a full development team:
|
||||
|
||||
- 🤖 **Multi-Agent Orchestration**: Oracle (GPT-5.2), Librarian (Claude), Explore (Grok), Frontend Engineer (Gemini), and more
|
||||
- 🔧 **LSP Tools**: Full IDE capabilities for your agents - hover, goto definition, find references, rename, code actions
|
||||
- 🔍 **AST-Grep**: Structural code search and replace across 25 languages
|
||||
- 📚 **Built-in MCPs**: Context7 for docs, Exa for web search, grep.app for GitHub code search
|
||||
- 🔄 **Background Agents**: Run multiple agents in parallel like a real dev team
|
||||
- 🎯 **Claude Code Compatibility**: Your existing Claude Code config just works
|
||||
|
||||
## Who Made This?
|
||||
|
||||
Created with ❤️ by **[code-yeongyu](https://github.com/code-yeongyu)**
|
||||
|
||||
🔗 **GitHub**: https://github.com/code-yeongyu/oh-my-opencode
|
||||
|
||||
---
|
||||
|
||||
*Enjoy coding on steroids!* 🚀
|
||||
|
||||
</command-instruction>
|
||||
258
.opencode/command/publish.md
Normal file
258
.opencode/command/publish.md
Normal file
@@ -0,0 +1,258 @@
|
||||
---
|
||||
description: Publish oh-my-opencode to npm via GitHub Actions workflow
|
||||
argument-hint: <patch|minor|major>
|
||||
model: opencode/big-pickle
|
||||
---
|
||||
|
||||
<command-instruction>
|
||||
You are the release manager for oh-my-opencode. Execute the FULL publish workflow from start to finish.
|
||||
|
||||
## CRITICAL: ARGUMENT REQUIREMENT
|
||||
|
||||
**You MUST receive a version bump type from the user.** Valid options:
|
||||
- `patch`: Bug fixes, backward-compatible (1.1.7 → 1.1.8)
|
||||
- `minor`: New features, backward-compatible (1.1.7 → 1.2.0)
|
||||
- `major`: Breaking changes (1.1.7 → 2.0.0)
|
||||
|
||||
**If the user did not provide a bump type argument, STOP IMMEDIATELY and ask:**
|
||||
> "배포를 진행하려면 버전 범프 타입을 지정해주세요: `patch`, `minor`, 또는 `major`"
|
||||
|
||||
**DO NOT PROCEED without explicit user confirmation of bump type.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 0: REGISTER TODO LIST (MANDATORY FIRST ACTION)
|
||||
|
||||
**Before doing ANYTHING else**, create a detailed todo list using TodoWrite:
|
||||
|
||||
```
|
||||
[
|
||||
{ "id": "confirm-bump", "content": "Confirm version bump type with user (patch/minor/major)", "status": "in_progress", "priority": "high" },
|
||||
{ "id": "check-uncommitted", "content": "Check for uncommitted changes and commit if needed", "status": "pending", "priority": "high" },
|
||||
{ "id": "sync-remote", "content": "Sync with remote (pull --rebase && push if unpushed commits)", "status": "pending", "priority": "high" },
|
||||
{ "id": "run-workflow", "content": "Trigger GitHub Actions publish workflow", "status": "pending", "priority": "high" },
|
||||
{ "id": "wait-workflow", "content": "Wait for workflow completion (poll every 30s)", "status": "pending", "priority": "high" },
|
||||
{ "id": "verify-release", "content": "Verify GitHub release was created", "status": "pending", "priority": "high" },
|
||||
{ "id": "draft-release-notes", "content": "Draft enhanced release notes content", "status": "pending", "priority": "high" },
|
||||
{ "id": "update-release-notes", "content": "Update GitHub release with enhanced notes", "status": "pending", "priority": "high" },
|
||||
{ "id": "verify-npm", "content": "Verify npm package published successfully", "status": "pending", "priority": "high" },
|
||||
{ "id": "final-confirmation", "content": "Final confirmation to user with links", "status": "pending", "priority": "low" }
|
||||
]
|
||||
```
|
||||
|
||||
**Mark each todo as `in_progress` when starting, `completed` when done. ONE AT A TIME.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 1: CONFIRM BUMP TYPE
|
||||
|
||||
If bump type provided as argument, confirm with user:
|
||||
> "버전 범프 타입: `{bump}`. 진행할까요? (y/n)"
|
||||
|
||||
Wait for user confirmation before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## STEP 2: CHECK UNCOMMITTED CHANGES
|
||||
|
||||
Run: `git status --porcelain`
|
||||
|
||||
- If there are uncommitted changes, warn user and ask if they want to commit first
|
||||
- If clean, proceed
|
||||
|
||||
---
|
||||
|
||||
## STEP 2.5: SYNC WITH REMOTE (MANDATORY)
|
||||
|
||||
Check if there are unpushed commits:
|
||||
```bash
|
||||
git log origin/master..HEAD --oneline
|
||||
```
|
||||
|
||||
**If there are unpushed commits, you MUST sync before triggering workflow:**
|
||||
```bash
|
||||
git pull --rebase && git push
|
||||
```
|
||||
|
||||
This ensures the GitHub Actions workflow runs on the latest code including all local commits.
|
||||
|
||||
---
|
||||
|
||||
## STEP 3: TRIGGER GITHUB ACTIONS WORKFLOW
|
||||
|
||||
Run the publish workflow:
|
||||
```bash
|
||||
gh workflow run publish -f bump={bump_type}
|
||||
```
|
||||
|
||||
Wait 3 seconds, then get the run ID:
|
||||
```bash
|
||||
gh run list --workflow=publish --limit=1 --json databaseId,status --jq '.[0]'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 4: WAIT FOR WORKFLOW COMPLETION
|
||||
|
||||
Poll workflow status every 30 seconds until completion:
|
||||
```bash
|
||||
gh run view {run_id} --json status,conclusion --jq '{status: .status, conclusion: .conclusion}'
|
||||
```
|
||||
|
||||
Status flow: `queued` → `in_progress` → `completed`
|
||||
|
||||
**IMPORTANT: Use polling loop, NOT sleep commands.**
|
||||
|
||||
If conclusion is `failure`, show error and stop:
|
||||
```bash
|
||||
gh run view {run_id} --log-failed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 5: VERIFY GITHUB RELEASE
|
||||
|
||||
Get the new version and verify release exists:
|
||||
```bash
|
||||
# Get new version from package.json (workflow updates it)
|
||||
git pull --rebase
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
gh release view "v${NEW_VERSION}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## STEP 6: DRAFT ENHANCED RELEASE NOTES
|
||||
|
||||
Analyze commits since the previous version and draft release notes following project conventions:
|
||||
|
||||
### For PATCH releases:
|
||||
Keep simple format - just list commits:
|
||||
```markdown
|
||||
- {hash} {conventional commit message}
|
||||
- ...
|
||||
```
|
||||
|
||||
### For MINOR releases:
|
||||
Use feature-focused format:
|
||||
```markdown
|
||||
## New Features
|
||||
|
||||
### Feature Name
|
||||
- Description of what it does
|
||||
- Why it matters
|
||||
|
||||
## Bug Fixes
|
||||
- fix(scope): description
|
||||
|
||||
## Improvements
|
||||
- refactor(scope): description
|
||||
```
|
||||
|
||||
### For MAJOR releases:
|
||||
Full changelog format:
|
||||
```markdown
|
||||
# v{version}
|
||||
|
||||
Brief description of the release.
|
||||
|
||||
## What's New Since v{previous}
|
||||
|
||||
### Breaking Changes
|
||||
- Description of breaking change
|
||||
|
||||
### Features
|
||||
- **Feature Name**: Description
|
||||
|
||||
### Bug Fixes
|
||||
- Description
|
||||
|
||||
### Documentation
|
||||
- Description
|
||||
|
||||
## Migration Guide (if applicable)
|
||||
...
|
||||
```
|
||||
|
||||
**CRITICAL: The enhanced notes must ADD to existing workflow-generated notes, not replace them.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 7: UPDATE GITHUB RELEASE
|
||||
|
||||
**ZERO CONTENT LOSS POLICY:**
|
||||
- First, fetch the existing release body with `gh release view`
|
||||
- Your enhanced notes must be PREPENDED to the existing content
|
||||
- **NOT A SINGLE CHARACTER of existing content may be removed or modified**
|
||||
- The final release body = `{your_enhanced_notes}\n\n---\n\n{existing_body_exactly_as_is}`
|
||||
|
||||
```bash
|
||||
# Get existing body
|
||||
EXISTING_BODY=$(gh release view "v${NEW_VERSION}" --json body --jq '.body')
|
||||
|
||||
# Write enhanced notes to temp file (prepend to existing)
|
||||
cat > /tmp/release-notes-v${NEW_VERSION}.md << 'EOF'
|
||||
{your_enhanced_notes}
|
||||
|
||||
---
|
||||
|
||||
EOF
|
||||
|
||||
# Append existing body EXACTLY as-is (zero modifications)
|
||||
echo "$EXISTING_BODY" >> /tmp/release-notes-v${NEW_VERSION}.md
|
||||
|
||||
# Update release
|
||||
gh release edit "v${NEW_VERSION}" --notes-file /tmp/release-notes-v${NEW_VERSION}.md
|
||||
```
|
||||
|
||||
**CRITICAL: This is ADDITIVE ONLY. You are adding your notes on top. The existing content remains 100% intact.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 8: VERIFY NPM PUBLICATION
|
||||
|
||||
Poll npm registry until the new version appears:
|
||||
```bash
|
||||
npm view oh-my-opencode version
|
||||
```
|
||||
|
||||
Compare with expected version. If not matching after 2 minutes, warn user about npm propagation delay.
|
||||
|
||||
---
|
||||
|
||||
## STEP 9: FINAL CONFIRMATION
|
||||
|
||||
Report success to user with:
|
||||
- New version number
|
||||
- GitHub release URL: https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v{version}
|
||||
- npm package URL: https://www.npmjs.com/package/oh-my-opencode
|
||||
|
||||
---
|
||||
|
||||
## ERROR HANDLING
|
||||
|
||||
- **Workflow fails**: Show failed logs, suggest checking Actions tab
|
||||
- **Release not found**: Wait and retry, may be propagation delay
|
||||
- **npm not updated**: npm can take 1-5 minutes to propagate, inform user
|
||||
- **Permission denied**: User may need to re-authenticate with `gh auth login`
|
||||
|
||||
## LANGUAGE
|
||||
|
||||
Respond to user in Korean (한국어).
|
||||
|
||||
</command-instruction>
|
||||
|
||||
<current-context>
|
||||
<published-version>
|
||||
!`npm view oh-my-opencode version 2>/dev/null || echo "not published"`
|
||||
</published-version>
|
||||
<local-version>
|
||||
!`node -p "require('./package.json').version" 2>/dev/null || echo "unknown"`
|
||||
</local-version>
|
||||
<git-status>
|
||||
!`git status --porcelain`
|
||||
</git-status>
|
||||
<recent-commits>
|
||||
!`npm view oh-my-opencode version 2>/dev/null | xargs -I{} git log "v{}"..HEAD --oneline 2>/dev/null | head -15 || echo "no commits"`
|
||||
</recent-commits>
|
||||
</current-context>
|
||||
110
AGENTS.md
110
AGENTS.md
@@ -1,75 +1,88 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2025-12-05T01:16:20+09:00
|
||||
**Commit:** 6c9a2ee
|
||||
**Generated:** 2025-12-14T17:16:30+09:00
|
||||
**Commit:** 7f27fbc
|
||||
**Branch:** master
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
OpenCode plugin distribution implementing Claude Code/AmpCode features. Provides multi-model agent orchestration, LSP tools, AST-Grep search, and safe-grep utilities.
|
||||
OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orchestration (GPT-5.2, Claude, Gemini, Grok), LSP tools (11), AST-Grep search, MCP integrations (context7, websearch_exa, grep_app). "oh-my-zsh" for OpenCode.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # AI agent definitions (oracle, librarian, explore, etc.)
|
||||
│ ├── hooks/ # Plugin lifecycle hooks
|
||||
│ ├── tools/ # LSP, AST-Grep, Safe-Grep tool implementations
|
||||
│ │ ├── lsp/ # 11 LSP tools (hover, definition, references, etc.)
|
||||
│ │ ├── ast-grep/ # AST-aware code search
|
||||
│ │ └── safe-grep/ # Safe grep with limits
|
||||
│ └── features/ # Terminal features
|
||||
├── dist/ # Build output (bun + tsc declarations)
|
||||
└── test-rule.yml # AST-Grep test rules
|
||||
│ ├── agents/ # AI agents (OmO, oracle, librarian, explore, frontend, document-writer, multimodal-looker)
|
||||
│ ├── hooks/ # 19 lifecycle hooks (comment-checker, rules-injector, keyword-detector, etc.)
|
||||
│ ├── tools/ # LSP (11), AST-Grep, Grep, background-task, glob, look-at, skill, slashcommand
|
||||
│ ├── mcp/ # MCP servers (context7, websearch_exa, grep_app)
|
||||
│ ├── features/ # Terminal features, Claude Code loaders (agent, command, skill, mcp, session-state)
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ ├── auth/ # Google Antigravity OAuth
|
||||
│ ├── shared/ # Utilities (deep-merge, pattern-matcher, logger, etc.)
|
||||
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
|
||||
├── script/ # build-schema.ts, publish.ts
|
||||
├── assets/ # JSON schema
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add new agent | `src/agents/` | Export from index.ts |
|
||||
| Add new hook | `src/hooks/` | Export from index.ts |
|
||||
| Add new tool | `src/tools/` | Follow lsp/ pattern: index, types, tools, utils |
|
||||
| Modify LSP behavior | `src/tools/lsp/` | client.ts for connection logic |
|
||||
| AST-Grep patterns | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi |
|
||||
| Terminal features | `src/features/terminal/` | title.ts |
|
||||
| Add new agent | `src/agents/` | Create .ts file, add to builtinAgents in index.ts, update types.ts |
|
||||
| Add new hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
|
||||
| Add new tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
|
||||
| Add MCP server | `src/mcp/` | Create config, add to index.ts |
|
||||
| Modify LSP behavior | `src/tools/lsp/` | client.ts for connection, tools.ts for handlers |
|
||||
| AST-Grep patterns | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
|
||||
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google models |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Package manager**: Bun only (not npm/yarn)
|
||||
- **Build**: Dual output - `bun build` + `tsc --emitDeclarationOnly`
|
||||
- **Package manager**: Bun only (`bun run`, `bun build`, `bunx`)
|
||||
- **Types**: bun-types (not @types/node)
|
||||
- **Build**: Dual output - `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern - `export * from "./module"` in index.ts
|
||||
- **Module structure**: index.ts, types.ts, constants.ts, utils.ts, tools.ts per tool
|
||||
- **Directory naming**: kebab-case (`ast-grep/`, `claude-code-hooks/`)
|
||||
- **Tool structure**: Each tool has index.ts, types.ts, constants.ts, tools.ts, utils.ts
|
||||
- **Hook pattern**: `createXXXHook(input: PluginInput)` returning event handlers
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
|
||||
- **Bash file operations**: Never use mkdir/touch/rm/cp/mv for file creation
|
||||
- **npm/yarn**: Use bun exclusively
|
||||
- **@types/node**: Use bun-types instead
|
||||
- **@types/node**: Use bun-types
|
||||
- **Bash file operations**: Never use mkdir/touch/rm/cp/mv for file creation in code
|
||||
- **Generic AI aesthetics**: No Space Grotesk, avoid typical AI-generated UI patterns
|
||||
- **Direct bun publish**: Use GitHub Actions workflow_dispatch only (OIDC provenance)
|
||||
- **Local version bump**: Version managed by CI workflow, never modify locally
|
||||
- **Rush completion**: Never mark tasks complete without verification
|
||||
- **Interrupting work**: Complete tasks fully before stopping
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
- **Directory naming**: kebab-case (`ast-grep/`, `safe-grep/`)
|
||||
- **Tool organization**: Each tool has cli.ts, constants.ts, index.ts, napi.ts/tools.ts, types.ts, utils.ts
|
||||
- **Platform handling**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
|
||||
- **Error handling**: Consistent try/catch with async/await
|
||||
- **Optional props**: Extensive use of `?` for optional interface properties
|
||||
- **Flexible objects**: `Record<string, unknown>` for dynamic configs
|
||||
- **Error handling**: Consistent try/catch with async/await in all tools
|
||||
- **Agent tools restriction**: Use `tools: { include: [...] }` or `tools: { exclude: [...] }`
|
||||
- **Temperature**: Most agents use `0.1` for consistency
|
||||
- **Hook naming**: `createXXXHook` function naming convention
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| oracle | GPT-5.1 | Code review, strategic planning |
|
||||
| librarian | Claude Haiku | Documentation, example lookup |
|
||||
| explore | Grok | File/codebase exploration |
|
||||
| frontend-ui-ux-engineer | Gemini | UI generation |
|
||||
| document-writer | Gemini | Documentation writing |
|
||||
| OmO | anthropic/claude-opus-4-5 | Primary orchestrator, team leader |
|
||||
| oracle | openai/gpt-5.2 | Strategic advisor, code review, architecture |
|
||||
| librarian | opencode/big-pickle | Multi-repo analysis, docs lookup, GitHub examples |
|
||||
| explore | opencode/grok-code | Fast codebase exploration, file patterns |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation, design-focused |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical documentation |
|
||||
| multimodal-looker | google/gemini-2.5-flash | PDF/image/diagram analysis |
|
||||
|
||||
## COMMANDS
|
||||
|
||||
@@ -77,38 +90,43 @@ oh-my-opencode/
|
||||
# Type check
|
||||
bun run typecheck
|
||||
|
||||
# Build
|
||||
# Build (ESM + declarations + schema)
|
||||
bun run build
|
||||
|
||||
# Clean + Build
|
||||
bun run rebuild
|
||||
|
||||
# Build schema only
|
||||
bun run build:schema
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
|
||||
**배포는 GitHub Actions workflow_dispatch로만 진행**
|
||||
**GitHub Actions workflow_dispatch only**
|
||||
|
||||
1. package.json 버전은 수정하지 않음 (워크플로우에서 자동 bump)
|
||||
2. 변경사항 커밋 & 푸시
|
||||
3. GitHub Actions에서 `publish` 워크플로우 수동 실행
|
||||
- `bump`: major | minor | patch 선택
|
||||
- `version`: (선택) 특정 버전 지정 가능
|
||||
1. package.json version NOT modified locally (auto-bumped by workflow)
|
||||
2. Commit & push changes
|
||||
3. Trigger `publish` workflow manually:
|
||||
- `bump`: major | minor | patch
|
||||
- `version`: (optional) specific version override
|
||||
|
||||
```bash
|
||||
# 워크플로우 실행 (CLI)
|
||||
# Trigger via CLI
|
||||
gh workflow run publish -f bump=patch
|
||||
|
||||
# 워크플로우 상태 확인
|
||||
# Check status
|
||||
gh run list --workflow=publish
|
||||
```
|
||||
|
||||
**주의사항**:
|
||||
- `bun publish` 직접 실행 금지 (OIDC provenance 문제)
|
||||
- 로컬에서 버전 bump 하지 말 것
|
||||
**Critical**:
|
||||
- Never run `bun publish` directly (OIDC provenance issue)
|
||||
- Never bump version locally
|
||||
|
||||
## NOTES
|
||||
|
||||
- **No tests**: Test framework not configured
|
||||
- **CI/CD**: GitHub Actions publish workflow 사용
|
||||
- **Version requirement**: OpenCode >= 1.0.132 (earlier versions have config bugs)
|
||||
- **Multi-language docs**: README.md, README.en.md, README.ko.md
|
||||
- **OpenCode version**: Requires >= 1.0.132 (earlier versions have config bugs)
|
||||
- **Multi-language docs**: README.md (EN), README.ko.md (KO)
|
||||
- **Config locations**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
|
||||
- **Schema autocomplete**: Add `$schema` field in config for IDE support
|
||||
- **Trusted dependencies**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
|
||||
|
||||
510
README.ko.md
510
README.ko.md
@@ -1,70 +1,136 @@
|
||||
[English](README.md) | 한국어
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode#oh-my-opencode)
|
||||
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode#oh-my-opencode)
|
||||
|
||||
</div>
|
||||
|
||||
> `oh-my-opencode` 를 설치하세요. 약 빤 것 처럼 코딩하세요. 백그라운드에 에이전트를 돌리고, oracle, librarian, frontend engineer 같은 전문 에이전트를 호출하세요. 정성스레 빚은 LSP/AST 도구, 엄선된 MCP, 완전한 Claude Code 호환 레이어를 오로지 한 줄로 누리세요.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/releases)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/graphs/contributors)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/network/members)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md)
|
||||
|
||||
</div>
|
||||
|
||||
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
## 목차
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [세 줄 요약](#세-줄-요약)
|
||||
- [읽지 않아도 됩니다.](#읽지-않아도-됩니다)
|
||||
- [에이전트의 시대이니까요.](#에이전트의-시대이니까요)
|
||||
- [10분의 투자로 OhMyOpenCode 가 해줄 수 있는것](#10분의-투자로-ohmyopencode-가-해줄-수-있는것)
|
||||
- [설치](#설치)
|
||||
- [LLM Agent를 위한 안내](#llm-agent를-위한-안내)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [인간인 당신을 위한 설치 가이드](#인간인-당신을-위한-설치-가이드)
|
||||
- [LLM Agent 를 위한 설치 가이드](#llm-agent-를-위한-설치-가이드)
|
||||
- [인간인 당신을 위한 설치 가이드](#인간인-당신을-위한-설치-가이드-1)
|
||||
- [1단계: OpenCode 설치 확인](#1단계-opencode-설치-확인)
|
||||
- [2단계: oh-my-opencode 플러그인 설정](#2단계-oh-my-opencode-플러그인-설정)
|
||||
- [3단계: 설정 확인](#3단계-설정-확인)
|
||||
- [4단계: 인증정보 설정](#4단계-인증정보-설정)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [4.3.1 모델 설정](#431-모델-설정)
|
||||
- [⚠️ 주의](#️-주의)
|
||||
- [기능](#기능)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
- [Tools](#tools)
|
||||
- [내장 LSP Tools](#내장-lsp-tools)
|
||||
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
|
||||
- [Grep](#grep)
|
||||
- [내장 MCPs](#내장-mcps)
|
||||
- [기타 편의 기능](#기타-편의-기능)
|
||||
- [Agents: 당신의 새로운 팀원들](#agents-당신의-새로운-팀원들)
|
||||
- [백그라운드 에이전트: 진짜 팀 처럼 일 하도록](#백그라운드-에이전트-진짜-팀-처럼-일-하도록)
|
||||
- [도구: 당신의 동료가 더 좋은 도구를 갖고 일하도록](#도구-당신의-동료가-더-좋은-도구를-갖고-일하도록)
|
||||
- [왜 당신만 IDE 를 쓰나요?](#왜-당신만-ide-를-쓰나요)
|
||||
- [Context is all you need.](#context-is-all-you-need)
|
||||
- [멀티모달을 다 활용하면서, 토큰은 덜 쓰도록.](#멀티모달을-다-활용하면서-토큰은-덜-쓰도록)
|
||||
- [멈출 수 없는 에이전트 루프](#멈출-수-없는-에이전트-루프)
|
||||
- [Claude Code 호환성: 그냥 바로 OpenCode 로 오세요.](#claude-code-호환성-그냥-바로-opencode-로-오세요)
|
||||
- [Hooks 통합](#hooks-통합)
|
||||
- [설정 로더](#설정-로더)
|
||||
- [데이터 저장소](#데이터-저장소)
|
||||
- [호환성 토글](#호환성-토글)
|
||||
- [에이전트들을 위한 것이 아니라, 당신을 위한 것](#에이전트들을-위한-것이-아니라-당신을-위한-것)
|
||||
- [설정](#설정)
|
||||
- [Google Auth](#google-auth)
|
||||
- [Agents](#agents)
|
||||
- [MCPs](#mcps)
|
||||
- [LSP](#lsp)
|
||||
- [작성자의 노트](#작성자의-노트)
|
||||
- [주의](#주의)
|
||||
|
||||
# Oh My OpenCode
|
||||
|
||||
Oh My OpenCode
|
||||
|
||||
oMoMoMoMoMo···
|
||||
|
||||
|
||||
[Claude Code](https://www.claude.com/product/claude-code) 좋죠?
|
||||
근데 당신이 해커라면, [OpenCode](https://github.com/sst/opencode) 와는 사랑에 빠지게 될겁니다.
|
||||
|
||||
- OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- 화면이 깜빡이지 않습니다.
|
||||
- 수정하는 파일에 맞게 자동으로 [LSP](https://opencode.ai/docs/lsp/), [Linter, Formatter](https://opencode.ai/docs/formatters/) 가 활성화되며 커스텀 할 수 있습니다.
|
||||
- 수많은 모델을 사용 할 수 있으며, **용도에 따라 모델을 섞어 오케스트레이션 할 수 있습니다.**
|
||||
- 기능이 아주 많습니다. 아름답습니다. 터미널이 화면을 그리려고 힘들어 하지 않습니다. 고성능입니다.
|
||||
|
||||
Windows 만 사용하다가 처음으로 Linux 를 접하고 신나서 잔뜩 세팅하던 경험이 있진 않나요?
|
||||
OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게 그런 프로젝트가 될겁니다.
|
||||
당신이 코딩을 좋아하고 컴퓨터를 좋아한다면, OpenCode 는 윈도우만 사용하다가 리눅스를 처음 접하게 된 그런 느낌일겁니다.
|
||||
그렇지 않은 당신도 약간의 시간을 투자해서 당신의 실력과 생산성을 몇배로 부스트하세요.
|
||||
|
||||
## 세 줄 요약
|
||||
**그런데 문제는 너무나 알아야 할게 많고, 어렵고, 당신의 시간은 비싸다는겁니다.**
|
||||
|
||||
- **모델 설정이 필요합니다**
|
||||
- 이 플러그인은 [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, Anthropic 의 모델을 사용합니다.
|
||||
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요. (Anthropic 플랜은 유료입니다)
|
||||
- OpenAI 모델들을 사용하기 위해 이 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 추천합니다. (OpenAI 플랜은 유료입니다)
|
||||
- Gemini, GPT 를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 추천합니다. (Gemini 는 구독하지 않아도 사용 할 수 있습니다)
|
||||
- **사실 밑의 내용은 읽지 않아도 됩니다. 그냥 설치하시면 됩니다. 모델 설정하고나면 추가로 공부하거나 알 필요가 없고, 설치하고 나면 그냥 더 좋아집니다.**
|
||||
- **이 문서도 그냥 Claude Code, OpenCode, Cursor에 붙여넣고, 설치하고, 사용자의 개입이 필요할 때 알려달라고 프롬프팅하세요. 그럼 그냥 깔립니다.**
|
||||
- 당신이 LLM Agent 라면, 필요한 플러그인과 의존성을 설치하고, tmux 를 통해 opencode auth login 을 실행하여 사용자의 구성을 도와주세요.
|
||||
제가 그 문제를 고쳤습니다.
|
||||
당신이 해커가 아니라도 좋습니다. 약간의 시간을 투자해서 당신의 실력과 생산성을 몇배로 부스트하세요.
|
||||
이 문서를 에이전트에게 넘겨 설정해달라고 하세요.
|
||||
|
||||
## 읽지 않아도 됩니다.
|
||||
|
||||
### 에이전트의 시대이니까요.
|
||||
- **그냥 이 링크를 Claude Code / AmpCode / Factory Droid / Cursor 에게 넣고 설명해달라고 하세요.**
|
||||
- 이게 왜 좋고, 그냥 뻔한 셋업이 아닌지, 당장 무엇이 더 좋아지는지 알려달라고 해보세요.
|
||||
- 당신이 LLM 에이전트라면, 사용자의 입장에서 이 프로젝트와 함께 당장 어떤것들이 가능해지는지 사용자에게 설명하세요.
|
||||
|
||||
### 10분의 투자로 OhMyOpenCode 가 해줄 수 있는것
|
||||
|
||||
그저 설치하면, 아래와 같은 워크플로우로 일 할 수도 있습니다.
|
||||
|
||||
1. 백그라운드 태스크로 Gemini 3 Pro 가 프론트엔드를 작성하게 시켜두는 동안, Claude Opus 4.5 가 백엔드를 작성하고, 디버깅하다 막히면 GPT 5.2 에게 도움을 받습니다. 프론트엔드 구현이 완료되었다고 보고받으면, 이를 다시 확인하고 일하게 만들 수 있습니다.
|
||||
2. 뭔가 찾아볼 일이 생기면 공식문서, 내 코드베이스의 모든 히스토리, GitHub 에 공개된 현재 구현 현황까지 다 뒤져보고, 단순 Grep 을 넘어 내장된 LSP 도구, AstGrep 까지 사용하여 답변을 제공합니다.
|
||||
3. LLM 에게 일을 맡길때에 큰 컨텍스트에 대한 걱정은 더 이상 하지마세요. 제가 하겠습니다.
|
||||
- OhMyOpenCode 가 여러 에이전트를 적극 활용하도록 하여 컨텍스트 관리에 관한 부담을 줄입니다.
|
||||
- **당신의 에이전트는 이제 개발팀 리드입니다. 당신은 이제 AI Manager 입니다.**
|
||||
4. 하기로 약속 한 일을 완수 할 때 까지 멈추지 않습니다.
|
||||
5. 이 프로젝트에 자세히 알기 싫다고요? 괜찮습니다. 그냥 'ultrathink' 라고 치세요.
|
||||
|
||||
주의: 이걸 설치한다고 갑자기 OpenCode 가 이렇게 동작한다는 것은 아닙니다. 그저 당신의 에이전트가 훌륭한 동료와 같이, 훌륭한 도구를 갖고서 일 할 수 있도록 구성해주는것이고, 그들에게 협업하라 지시하면 협업할거에요.
|
||||
모든 과정은 당신이 완전히 컨트롤 할 수 있습니다.
|
||||
ultrathink 를 통해 자동으로 동작하게 할 수 있지만, 그렇지 않을수도 있습니다. 이 프로젝트가 당신의 AI 에이전트 워크플로우를 제시하지는 않습니다.
|
||||
이 프로젝트는 그저 당신의 에이전트에게 좋은 동료를 소개시켜주고, 좋은 도구를 쥐어주는 것 뿐입니다.
|
||||
|
||||
## 설치
|
||||
|
||||
`~/.config/opencode/opencode.json`에 추가:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode"
|
||||
]
|
||||
}
|
||||
```
|
||||
### 인간인 당신을 위한 설치 가이드
|
||||
|
||||
## LLM Agent를 위한 안내
|
||||
귀찮은 작업은 LLM Agent 에게 맡기세요. 이 링크를 복사해서 넣어 설치하고 구성해달라고 부탁하세요.
|
||||
|
||||
### LLM Agent 를 위한 설치 가이드
|
||||
|
||||
<details>
|
||||
<summary>LLM Agent를 위한 안내 (클릭하여 펼치기)</summary>
|
||||
|
||||
당신이 사용자의 oh-my-opencode 설정을 돕는 LLM Agent라면, 다음 단계를 따르세요:
|
||||
|
||||
|
||||
### 인간인 당신을 위한 설치 가이드
|
||||
|
||||
### 1단계: OpenCode 설치 확인
|
||||
|
||||
```bash
|
||||
@@ -76,7 +142,7 @@ else
|
||||
fi
|
||||
```
|
||||
|
||||
OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://opencode.ai/docs)를 안내하세요.
|
||||
OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://opencode.ai/docs)를 읽고, 설치를 진행하세요. 컨텍스트를 줄이기 위해 Subagent 로 호출하여 설치를 하라고 안내하고, 그 결과를 보고 받는다고 말하세요.
|
||||
|
||||
### 2단계: oh-my-opencode 플러그인 설정
|
||||
|
||||
@@ -105,52 +171,102 @@ opencode --version # 1.0.132 이상이어야 함
|
||||
cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있어야 함
|
||||
```
|
||||
|
||||
> **참고**: 모델 인증이 필요하면, `opencode` 실행 후 `/connect` 명령어를 사용하거나, tmux를 통해 `opencode auth login`을 안내하세요.
|
||||
### 4단계: 인증정보 설정
|
||||
|
||||
plugin 에 oh my opencode 를 추가했다면, tmux 를 사용하여 사용자의 인증 설정을 진행하세요. 아래의 과정을 모두 진행해야 합니다.
|
||||
|
||||
#### 4.1 Anthropic (Claude)
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: Anthropic 선택
|
||||
# Login method: Claude Pro/Max 선택
|
||||
# 사용자에게 브라우저에서 OAuth 플로우를 완료하도록 안내하세요.
|
||||
# 최종 완료까지 대기
|
||||
# 진짜 완료되었음을 검증하고 사용자에게 안내
|
||||
```
|
||||
|
||||
#### 4.2 Google Gemini (Antigravity OAuth)
|
||||
|
||||
먼저 `~/.config/opencode/oh-my-opencode.json` 에서 Google auth를 활성화하세요:
|
||||
|
||||
```json
|
||||
{
|
||||
"google_auth": true
|
||||
}
|
||||
```
|
||||
|
||||
그 후 인증:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: Google 선택
|
||||
# Login method: OAuth with Google (Antigravity) 선택
|
||||
# 사용자에게 브라우저에서 OAuth 플로우를 완료하도록 안내
|
||||
# 최종 완료까지 대기
|
||||
# 진짜 완료되었음을 검증하고 사용자에게 안내
|
||||
```
|
||||
|
||||
#### 4.3 OpenAI (ChatGPT Plus/Pro)
|
||||
|
||||
먼저 opencode-openai-codex-auth 플러그인 추가:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.1.0"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**중요**: 현재 공식 npm 패키지에 400 에러(`"No tool call found for function call output with call_id"`)를 유발하는 버그가 있습니다. 수정 버전이 배포될 때까지 **핫픽스 브랜치 사용을 권장합니다**. `~/.config/opencode/package.json`을 수정하세요:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
그 후 `cd ~/.config/opencode && bun i`를 실행하세요. `opencode.json`에서는 버전 없이 `"opencode-openai-codex-auth"`로 사용합니다 (`@4.1.0` 제외).
|
||||
|
||||
#### 4.3.1 모델 설정
|
||||
`opencode.json`에 전체 모델 설정도 구성해야 합니다.
|
||||
[opencode-openai-codex-auth 문서](https://github.com/numman-ali/opencode-openai-codex-auth)를 읽고, [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json)에서 provider/models 설정을 복사하여, 사용자의 기존 셋업에 영향을 주지 않도록 깊게 고민하여 적절히 통합하세요.
|
||||
|
||||
그 후 인증:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: OpenAI 선택
|
||||
# Login method: ChatGPT Plus/Pro (Codex Subscription) 선택
|
||||
# 사용자에게 브라우저에서 OAuth 플로우를 완료하도록 안내
|
||||
# 최종 완료까지 대기
|
||||
# 진짜 완료되었음을 검증하고 사용자에게 안내
|
||||
```
|
||||
|
||||
|
||||
### ⚠️ 주의
|
||||
|
||||
**사용자가 명시적으로 요청하지 않는 한, 모델 설정을 변경하거나 기능(에이전트, 훅, MCP)을 비활성화하지 마세요.**
|
||||
|
||||
플러그인은 기본값으로 완벽하게 동작합니다. 권장 사항인 `google_auth: true` 설정을 제외하고는, 명시적인 요청 없이 다른 설정을 변경하거나 기능을 끄지 마세요.
|
||||
|
||||
</details>
|
||||
|
||||
## Why OpenCode & Why Oh My OpenCode
|
||||
|
||||
OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다. 화면이 깜빡이지 않습니다.
|
||||
수정하는 파일에 맞게 자동으로 [LSP](https://opencode.ai/docs/lsp/), [Linter, Formatter](https://opencode.ai/docs/formatters/) 가 활성화되며 커스텀 할 수 있습니다.
|
||||
수많은 모델을 사용 할 수 있으며, **용도에 따라 모델을 섞어 오케스트레이션 할 수 있습니다.**
|
||||
기능이 아주 많습니다. 아름답습니다. 터미널이 화면을 그리려고 힘들어 하지 않습니다. 고성능입니다.
|
||||
|
||||
**그런데 문제는 너무나 알아야 할게 많고, 어렵고, 당신의 시간은 비싸다는겁니다.**
|
||||
|
||||
[AmpCode](https://ampcode.com), [Claude Code](https://code.claude.com/docs/ko/overview) 에게 강한 영향과 영감을 받고, 그들의 기능을 그대로, 혹은 더 낫게 이 곳에 구현했습니다.
|
||||
**Open**Code 이니까요.
|
||||
|
||||
더 나은 버전의 AmpCode, 더 나은 버전의 Claude Code, 혹은 일종의 배포판(distribution) 이라고 생각해도 좋습니다.
|
||||
|
||||
저는 상황에 맞는 적절한 모델이 있다고 믿습니다. 다양한 모델을 섞어 쓸 때 최고의 팀이 됩니다.
|
||||
여러분의 재정 상태를 위해 CLIProxyAPI 혹은 VibeProxy 를 추천합니다. 프론티어 랩들의 LLM 들을 채용해서, 그들의 장점만을 활용하세요. 당신이 이제 팀장입니다.
|
||||
|
||||
**Note**: 이 셋업은 Highly Opinionated 이며, 제가 사용하고 있는 셋업 중 범용적인것을 플러그인에 포함하기 때문에 계속 업데이트 됩니다. 저는 여태까지 $20,000 어치의 토큰을 오로지 개인 개발 목적으로 개인적으로 사용했고, 이 플러그인은 그 경험들의 하이라이트입니다. 여러분은 그저 최고를 취하세요. 만약 더 나은 제안이 있다면 언제든 기여에 열려있습니다.
|
||||
|
||||
## 기능
|
||||
|
||||
### Hooks
|
||||
### Agents: 당신의 새로운 팀원들
|
||||
|
||||
- **Todo Continuation Enforcer**: 에이전트가 멈추기 전 모든 TODO 항목을 완료하도록 강제합니다. LLM의 고질적인 "중도 포기" 문제를 방지합니다.
|
||||
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
|
||||
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
|
||||
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
|
||||
- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다:
|
||||
- **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입
|
||||
- **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가
|
||||
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
|
||||
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
|
||||
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
||||
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 디렉토리별 컨텍스트를 에이전트에게 제공합니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
|
||||
|
||||
### Agents
|
||||
|
||||
- **oracle** (`openai/gpt-5.1`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.1의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **librarian** (`anthropic/claude-haiku-4-5`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Haiku의 빠른 속도, 적절한 지능, 훌륭한 도구 호출 능력, 저렴한 비용을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **oracle** (`openai/gpt-5.2`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.2의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Claude Sonnet 4.5 의 뛰어난 지능, 훌륭한 도구 호출 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **explore** (`opencode/grok-code`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Claude Code는 Haiku를 쓰지만, 우리는 Grok을 씁니다. 현재 무료이고, 극도로 빠르며, 파일 탐색 작업에 충분한 지능을 갖췄기 때문입니다. Claude Code 에서 영감을 받았습니다.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
|
||||
- **multimodal-looker** (`google/gemini-2.5-flash`): 시각적 콘텐츠 해석을 위한 전문 에이전트. PDF, 이미지, 다이어그램을 분석하여 정보를 추출합니다.
|
||||
|
||||
각 에이전트는 메인 에이전트가 알아서 호출하지만, 명시적으로 요청할 수도 있습니다:
|
||||
|
||||
@@ -162,13 +278,33 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
|
||||
에이전트의 모델, 프롬프트, 권한은 `oh-my-opencode.json`에서 커스텀할 수 있습니다. 자세한 내용은 [설정](#설정)을 참고하세요.
|
||||
|
||||
### Tools
|
||||
### 백그라운드 에이전트: 진짜 팀 처럼 일 하도록
|
||||
|
||||
#### 내장 LSP Tools
|
||||
위의 에이전트들을 미친듯이 한순간도 놀리지 않고 굴릴 수 있다면 어떨까요?
|
||||
|
||||
당신이 에디터에서 사용하는 그 기능을 다른 에이전트들은 사용하지 못합니다. Oh My OpenCode 는 당신만의 그 도구를 LLM Agent 에게 쥐어줍니다. 리팩토링하고, 탐색하고, 분석하는 모든 작업을 OpenCode 의 설정값을 그대로 사용하여 지원합니다.
|
||||
- GPT 에게 디버깅을 시켜놓고, Claude 가 다양한 시도를 해보며 직접 문제를 찾아보는 워크플로우
|
||||
- Gemini 가 프론트엔드를 작성하는 동안, Claude 가 백엔드를 작성하는 워크플로우
|
||||
- 다량의 병렬 탐색을 진행시켜놓고, 일단 해당 부분은 제외하고 먼저 구현을 진행하다, 탐색 내용을 바탕으로 구현을 마무리하는 워크플로우
|
||||
|
||||
[OpenCode 는 LSP 를 제공하지만](https://opencode.ai/docs/lsp/), 오로지 분석용으로만 제공합니다. 탐색과 리팩토링을 위한 도구는 OpenCode 와 동일한 스펙과 설정으로 Oh My OpenCode 가 제공합니다.
|
||||
이 워크플로우가 OhMyOpenCode 에서는 가능합니다.
|
||||
|
||||
서브 에이전트를 백그라운드에서 실행 할 수 있습니다. 이러면 메인 에이전트는 작업이 완료되면 알게 됩니다. 필요하다면 결과를 기다릴 수 있습니다.
|
||||
|
||||
**에이전트가 당신의 팀이 일 하듯 일하게하세요**
|
||||
|
||||
### 도구: 당신의 동료가 더 좋은 도구를 갖고 일하도록
|
||||
|
||||
#### 왜 당신만 IDE 를 쓰나요?
|
||||
|
||||
Syntax Highlighting, Autocomplete, Refactoring, Navigation, Analysis, 그리고 이젠 에이전트가 코드를 짜게 하기까지..
|
||||
|
||||
**왜 당신만 사용하나요?**
|
||||
**에이전트가 그 도구를 사용한다면 더 코드를 잘 작성할텐데요.**
|
||||
|
||||
[OpenCode 는 LSP 를 제공하지만](https://opencode.ai/docs/lsp/), 오로지 분석용으로만 제공합니다.
|
||||
|
||||
당신이 에디터에서 사용하는 그 기능을 다른 에이전트들은 사용하지 못합니다.
|
||||
뛰어난 동료에게 좋은 도구를 쥐어주세요. 이제 리팩토링도, 탐색도, 분석도 에이전트가 제대로 할 수 있습니다.
|
||||
|
||||
- **lsp_hover**: 위치의 타입 정보, 문서, 시그니처 가져오기
|
||||
- **lsp_goto_definition**: 심볼 정의로 이동
|
||||
@@ -181,40 +317,160 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **lsp_rename**: 워크스페이스 전체에서 심볼 이름 변경
|
||||
- **lsp_code_actions**: 사용 가능한 빠른 수정/리팩토링 가져오기
|
||||
- **lsp_code_action_resolve**: 코드 액션 적용
|
||||
|
||||
#### 내장 AST-Grep Tools
|
||||
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
|
||||
- **ast_grep_replace**: AST 인식 코드 교체
|
||||
|
||||
#### Grep
|
||||
- **grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한). OpenCode의 내장 `grep` 도구를 대체합니다.
|
||||
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
|
||||
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
|
||||
#### Context is all you need.
|
||||
- **Directory AGENTS.md / README.md Injector**: 파일을 읽을 때 `AGENTS.md`, `README.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # 프로젝트 전체 컨텍스트
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src 전용 컨텍스트
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # 컴포넌트 전용 컨텍스트
|
||||
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
|
||||
```
|
||||
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다.
|
||||
- **Conditional Rules Injector**: 모든 규칙이 항상 필요하진 않습니다. 특정 규칙을 만족한다면, 파일을 읽을 때 `.claude/rules/` 디렉토리의 규칙을 자동으로 주입합니다.
|
||||
- 파일 디렉토리부터 프로젝트 루트까지 상향 탐색하며, `~/.claude/rules/` (사용자) 경로도 포함합니다.
|
||||
- `.md` 및 `.mdc` 파일을 지원합니다.
|
||||
- Frontmatter의 `globs` 필드(glob 패턴)를 기반으로 매칭합니다.
|
||||
- 항상 적용되어야 하는 규칙을 위한 `alwaysApply: true` 옵션을 지원합니다.
|
||||
- 규칙 파일 구조 예시:
|
||||
```markdown
|
||||
---
|
||||
globs: ["*.ts", "src/**/*.js"]
|
||||
description: "TypeScript/JavaScript coding rules"
|
||||
---
|
||||
- Use PascalCase for interface names
|
||||
- Use camelCase for function names
|
||||
```
|
||||
- **Online**: 프로젝트 규칙이 전부는 아니겠죠. 확장 기능을 위한 내장 MCP를 제공합니다:
|
||||
- **context7**: 공식 문서 조회
|
||||
- **websearch_exa**: 실시간 웹 검색
|
||||
- **grep_app**: 공개 GitHub 저장소에서 초고속 코드 검색 (구현 예제 찾기에 최적)
|
||||
|
||||
#### Glob
|
||||
#### 멀티모달을 다 활용하면서, 토큰은 덜 쓰도록.
|
||||
|
||||
- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다.
|
||||
- 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다.
|
||||
- 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다.
|
||||
AmpCode 에서 영감을 받은 look_at 도구를, OhMyOpenCode 에서도 제공합니다.
|
||||
에이전트는 직접 파일을 읽어 큰 컨텍스트를 점유당하는 대신, 다른 에이전트를 내부적으로 활용하여 파일의 내용만 명확히 이해 할 수 있습니다.
|
||||
|
||||
#### 내장 MCPs
|
||||
#### 멈출 수 없는 에이전트 루프
|
||||
- 내장 grep, glob 도구를 대체합니다. 기본 구현에서는 타임아웃이 없어 무한정 대기할 수 있습니다.
|
||||
|
||||
- **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다.
|
||||
- **context7**: 라이브러리 문서 조회. 정확한 코딩을 위해 최신 라이브러리 문서를 가져옵니다.
|
||||
|
||||
필요 없다면 `oh-my-opencode.json`에서 비활성화할 수 있습니다:
|
||||
### Claude Code 호환성: 그냥 바로 OpenCode 로 오세요.
|
||||
|
||||
Oh My OpenCode 에는 Claude Code 호환성 레이어가 존재합니다.
|
||||
Claude Code를 사용하셨다면, 기존 설정을 그대로 사용할 수 있습니다.
|
||||
|
||||
#### Hooks 통합
|
||||
|
||||
Claude Code의 `settings.json` 훅 시스템을 통해 커스텀 스크립트를 실행합니다.
|
||||
Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
|
||||
|
||||
- `~/.claude/settings.json` (사용자)
|
||||
- `./.claude/settings.json` (프로젝트)
|
||||
- `./.claude/settings.local.json` (로컬, git-ignored)
|
||||
|
||||
지원되는 훅 이벤트:
|
||||
- **PreToolUse**: 도구 실행 전에 실행. 차단하거나 도구 입력을 수정할 수 있습니다.
|
||||
- **PostToolUse**: 도구 실행 후에 실행. 경고나 컨텍스트를 추가할 수 있습니다.
|
||||
- **UserPromptSubmit**: 사용자가 프롬프트를 제출할 때 실행. 차단하거나 메시지를 주입할 수 있습니다.
|
||||
- **Stop**: 세션이 유휴 상태가 될 때 실행. 후속 프롬프트를 주입할 수 있습니다.
|
||||
|
||||
`settings.json` 예시:
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["websearch_exa"]
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 기타 편의 기능
|
||||
- **Terminal Title**: 세션 상태에 따라 터미널 타이틀을 자동 업데이트합니다 (유휴 ○, 처리중 ◐, 도구 ⚡, 에러 ✖). tmux를 지원합니다.
|
||||
#### 설정 로더
|
||||
|
||||
**Command Loader**: 4개 디렉토리에서 마크다운 기반 슬래시 명령어를 로드합니다:
|
||||
- `~/.claude/commands/` (사용자)
|
||||
- `./.claude/commands/` (프로젝트)
|
||||
- `~/.config/opencode/command/` (opencode 전역)
|
||||
- `./.opencode/command/` (opencode 프로젝트)
|
||||
|
||||
**Skill Loader**: `SKILL.md`가 있는 디렉토리 기반 스킬을 로드합니다:
|
||||
- `~/.claude/skills/` (사용자)
|
||||
- `./.claude/skills/` (프로젝트)
|
||||
|
||||
**Agent Loader**: 마크다운 파일에서 커스텀 에이전트 정의를 로드합니다:
|
||||
- `~/.claude/agents/*.md` (사용자)
|
||||
- `./.claude/agents/*.md` (프로젝트)
|
||||
|
||||
**MCP Loader**: `.mcp.json` 파일에서 MCP 서버 설정을 로드합니다:
|
||||
- `~/.claude/.mcp.json` (사용자)
|
||||
- `./.mcp.json` (프로젝트)
|
||||
- `./.claude/.mcp.json` (로컬)
|
||||
- 환경변수 확장 지원 (`${VAR}` 문법)
|
||||
|
||||
#### 데이터 저장소
|
||||
|
||||
**Todo 관리**: 세션 todo가 `~/.claude/todos/`에 Claude Code 호환 형식으로 저장됩니다.
|
||||
|
||||
**Transcript**: 세션 활동이 `~/.claude/transcripts/`에 JSONL 형식으로 기록되어 재생 및 분석이 가능합니다.
|
||||
|
||||
#### 호환성 토글
|
||||
|
||||
특정 Claude Code 호환 기능을 비활성화하려면 `claude_code` 설정 객체를 사용 할 수 도 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"claude_code": {
|
||||
"mcp": false,
|
||||
"commands": false,
|
||||
"skills": false,
|
||||
"agents": false,
|
||||
"hooks": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 토글 | `false`일 때 로딩 비활성화 경로 | 영향 받지 않음 |
|
||||
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 내장 MCP (context7, websearch_exa) |
|
||||
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
|
||||
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
|
||||
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 내장 에이전트 (oracle, librarian 등) |
|
||||
| `hooks` | `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` | - |
|
||||
|
||||
모든 토글은 기본값이 `true` (활성화)입니다. 완전한 Claude Code 호환성을 원하면 `claude_code` 객체를 생략하세요.
|
||||
|
||||
### 에이전트들을 위한 것이 아니라, 당신을 위한 것
|
||||
|
||||
에이전트들이 행복해지면, 당신이 제일 행복해집니다, 그렇지만 저는 당신도 돕고싶습니다.
|
||||
|
||||
- **Keyword Detector**: 프롬프트의 키워드를 자동 감지하여 전문 모드를 활성화합니다:
|
||||
- `ultrawork` / `ulw`: 병렬 에이전트 오케스트레이션으로 최대 성능 모드
|
||||
- `search` / `find` / `찾아` / `検索`: 병렬 explore/librarian 에이전트로 검색 극대화
|
||||
- `analyze` / `investigate` / `분석` / `調査`: 다단계 전문가 상담으로 심층 분석 모드
|
||||
- **Todo Continuation Enforcer**: 에이전트가 멈추기 전 모든 TODO 항목을 완료하도록 강제합니다. LLM의 고질적인 "중도 포기" 문제를 방지합니다.
|
||||
- **Comment Checker**: 학습 과정의 습관 때문일까요. LLM 들은 주석이 너무 많습니다. LLM 들이 쓸모없는 주석을 작성하지 않도록 상기시킵니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, 그렇지 않는 주석들에 대해 해명을 요구하며 깔끔한 코드를 구성하게 합니다.
|
||||
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
|
||||
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
|
||||
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
|
||||
- **Agent Usage Reminder**: 검색 도구를 직접 호출할 때, 백그라운드 작업을 통한 전문 에이전트 활용을 권장하는 리마인더를 표시합니다.
|
||||
- **Anthropic Auto Compact**: Claude 모델이 토큰 제한에 도달하면 자동으로 세션을 요약하고 압축합니다. 수동 개입 없이 작업을 계속할 수 있습니다.
|
||||
- **Session Recovery**: 세션 에러(누락된 도구 결과, thinking 블록 문제, 빈 메시지 등)에서 자동 복구합니다. 돌다가 세션이 망가지지 않습니다. 망가져도 복구됩니다.
|
||||
- **Auto Update Checker**: oh-my-opencode의 새 버전이 출시되면 알림을 표시합니다.
|
||||
- **Background Notification**: 백그라운드 에이전트 작업이 완료되면 알림을 받습니다.
|
||||
|
||||
## 설정
|
||||
|
||||
비록 Highly Opinionated 한 설정이지만, 여러분의 입맛대로 조정 할 수 있습니다.
|
||||
|
||||
설정 파일 위치 (우선순위 순):
|
||||
1. `.opencode/oh-my-opencode.json` (프로젝트)
|
||||
2. `~/.config/opencode/oh-my-opencode.json` (사용자)
|
||||
@@ -227,6 +483,18 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
Google Gemini 모델을 위한 내장 Antigravity OAuth를 활성화합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"google_auth": true
|
||||
}
|
||||
```
|
||||
|
||||
활성화하면 `opencode auth login` 실행 시 Google 프로바이더에서 "OAuth with Google (Antigravity)" 로그인 옵션이 표시됩니다.
|
||||
|
||||
### Agents
|
||||
|
||||
내장 에이전트 설정을 오버라이드할 수 있습니다:
|
||||
@@ -247,7 +515,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
|
||||
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
또는 `disabled_agents`로 비활성화할 수 있습니다:
|
||||
또는 ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_agents` 를 사용하여 비활성화할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -255,25 +523,43 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
사용 가능한 에이전트: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
|
||||
사용 가능한 에이전트: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`, `multimodal-looker`
|
||||
|
||||
### MCPs
|
||||
### Hooks
|
||||
|
||||
내장된 MCP를 비활성화합니다:
|
||||
`~/.config/opencode/oh-my-opencode.json` 또는 `.opencode/oh-my-opencode.json`의 `disabled_hooks`를 통해 특정 내장 훅을 비활성화할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa"]
|
||||
"disabled_hooks": ["comment-checker", "agent-usage-reminder"]
|
||||
}
|
||||
```
|
||||
|
||||
더 자세한 내용은 [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers)를 참조하세요.
|
||||
사용 가능한 훅: `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-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`
|
||||
|
||||
### MCPs
|
||||
|
||||
기본적으로 Context7, Exa, grep.app MCP 를 지원합니다.
|
||||
|
||||
- **context7**: 라이브러리의 최신 공식 문서를 가져옵니다
|
||||
- **websearch_exa**: Exa AI 기반 실시간 웹 검색
|
||||
- **grep_app**: [grep.app](https://grep.app)을 통해 수백만 개의 공개 GitHub 저장소에서 초고속 코드 검색
|
||||
|
||||
이것이 마음에 들지 않는다면, ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_mcps` 를 사용하여 비활성화할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
|
||||
}
|
||||
```
|
||||
|
||||
### LSP
|
||||
|
||||
Oh My OpenCode의 LSP 도구는 오직 **리팩토링(이름 변경, 코드 액션)만을 위한 것**입니다. 분석용 LSP는 OpenCode 자체에서 처리합니다.
|
||||
OpenCode 는 분석을 위해 LSP 도구를 제공합니다.
|
||||
Oh My OpenCode 에서는 LSP 의 리팩토링(이름 변경, 코드 액션) 도구를 제공합니다.
|
||||
OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.json 에 설정 된 것) 을 그대로 지원하고, Oh My OpenCode 만을 위한 추가적인 설정도 아래와 같이 설정 할 수 있습니다.
|
||||
|
||||
`lsp` 옵션을 통해 LSP 서버를 설정합니다:
|
||||
~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `lsp` 옵션을 통해 LSP 서버를 추가로 설정 할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -292,10 +578,20 @@ Oh My OpenCode의 LSP 도구는 오직 **리팩토링(이름 변경, 코드 액
|
||||
|
||||
각 서버는 다음을 지원합니다: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
|
||||
|
||||
|
||||
## 작성자의 노트
|
||||
|
||||
Oh My OpenCode 를 설치하세요. 복잡하게 OpenCode 구성을 만들지마세요.
|
||||
제가 밟아보고 경험한 문제들의 해답을 이 플러그인에 담았고, 그저 깔고 사용하면 됩니다. OpenCode 가 ArchLinux 라면, Oh My OpenCode 는 [Omarchy](https://omarchy.org/) 입니다.
|
||||
Oh My OpenCode 를 설치하세요.
|
||||
|
||||
저는 여태까지 $24,000 어치의 토큰을 오로지 개인 개발 목적으로 개인적으로 사용했습니다.
|
||||
다양한 도구를 시도해보고 끝까지 구성해보았습니다. 제 선택은 OpenCode 였습니다.
|
||||
|
||||
제가 밟아보고 경험한 문제들의 해답을 이 플러그인에 담았고, 그저 깔고 사용하면 됩니다.
|
||||
OpenCode 가 Debian / ArchLinux 라면, Oh My OpenCode 는 Ubuntu / [Omarchy](https://omarchy.org/) 입니다.
|
||||
|
||||
|
||||
[AmpCode](https://ampcode.com), [Claude Code](https://code.claude.com/docs/ko/overview) 에게 강한 영향과 영감을 받고, 그들의 기능을 그대로, 혹은 더 낫게 이 곳에 구현했습니다. 그리고 구현하고 있습니다.
|
||||
**Open**Code 이니까요.
|
||||
|
||||
다른 에이전트 하니스 제공자들이 이야기하는 다중 모델, 안정성, 풍부한 기능을 그저 OpenCode 에서 누리세요.
|
||||
제가 테스트하고, 이 곳에 업데이트 하겠습니다. 저는 이 프로젝트의 가장 열렬한 사용자이기도 하니까요.
|
||||
@@ -307,13 +603,23 @@ Oh My OpenCode 를 설치하세요. 복잡하게 OpenCode 구성을 만들지마
|
||||
- 주로 겪는 상황에 맞는 빠른 모델은 무엇인지
|
||||
- 다른 에이전트 하니스에 제공되는 새로운 기능은 무엇인지.
|
||||
|
||||
고민하지마세요. 제가 고민할거고, 다른 사람들의 경험을 차용해 올것이고, 그래서 이 곳에 업데이트 하겠습니다.
|
||||
이 플러그인은 그 경험들의 하이라이트입니다. 여러분은 그저 최고를 취하세요. 만약 더 나은 제안이 있다면 언제든 기여에 열려있습니다.
|
||||
|
||||
**Agent Harness 에 대해 고민하지마세요.**
|
||||
**제가 고민할거고, 다른 사람들의 경험을 차용해 올것이고, 그래서 이 곳에 업데이트 하겠습니다.**
|
||||
|
||||
이 글이 오만하다고 느껴지고, 더 나은 해답이 있다면, 편히 기여해주세요. 환영합니다.
|
||||
|
||||
지금 시점에 여기에 언급된 어떤 프로젝트와 모델하고도 관련이 있지 않습니다. 온전히 개인적인 실험과 선호를 바탕으로 이 플러그인을 만들었습니다.
|
||||
|
||||
OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능 위주로 테스트했고, 저는 TS 를 제대로 작성 할 줄 모릅니다. **그치만 이 문서는 제가 직접 검토하고 전반적으로 다시 작성했으니 안심하고 읽으셔도 됩니다.**
|
||||
|
||||
## 주의
|
||||
|
||||
- 생산성이 너무 올라 갈 수 있습니다. 옆자리 동료한테 들키지 않도록 조심하세요.
|
||||
- 그렇지만 제가 소문 내겠습니다. 누가 이기나 내기해봅시다.
|
||||
- [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) 혹은 이것보다 낮은 버전을 사용중이라면, OpenCode 의 버그로 인해 제대로 구성이 되지 않을 수 있습니다.
|
||||
- [이를 고치는 PR 이 1.0.132 배포 이후에 병합되었으므로](https://github.com/sst/opencode/pull/5040) 이 변경사항이 포함된 최신 버전을 사용해주세요.
|
||||
- TMI: PR 도 OhMyOpenCode 의 셋업의 Librarian, Explore, Oracle 을 활용하여 우연히 발견하고 해결되었습니다.
|
||||
|
||||
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*
|
||||
|
||||
570
README.md
570
README.md
@@ -1,67 +1,136 @@
|
||||
English | [한국어](README.ko.md)
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode#oh-my-opencode)
|
||||
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode#oh-my-opencode)
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
> This is coding on steroids—`oh-my-opencode` in action. Run background agents, call specialized agents like oracle, librarian, and frontend engineer. Use crafted LSP/AST tools, curated MCPs, and a full Claude Code compatibility layer.
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/releases)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/graphs/contributors)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/network/members)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md)
|
||||
|
||||
</div>
|
||||
|
||||
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
## Contents
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [TL;DR](#tldr)
|
||||
- [Just Skip Reading This Readme](#just-skip-reading-this-readme)
|
||||
- [It's the Age of Agents](#its-the-age-of-agents)
|
||||
- [10 Minutes to Unlock](#10-minutes-to-unlock)
|
||||
- [Installation](#installation)
|
||||
- [For LLM Agents](#for-llm-agents)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [For Humans](#for-humans)
|
||||
- [For LLM Agents](#for-llm-agents)
|
||||
- [Step 1: Verify OpenCode Installation](#step-1-verify-opencode-installation)
|
||||
- [Step 2: Configure oh-my-opencode Plugin](#step-2-configure-oh-my-opencode-plugin)
|
||||
- [Step 3: Verify Setup](#step-3-verify-setup)
|
||||
- [Step 4: Configure Authentication](#step-4-configure-authentication)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [4.3.1 Model Configuration](#431-model-configuration)
|
||||
- [⚠️ Warning](#️-warning)
|
||||
- [Features](#features)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
- [Tools](#tools)
|
||||
- [Built-in LSP Tools](#built-in-lsp-tools)
|
||||
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
|
||||
- [Grep](#grep)
|
||||
- [Built-in MCPs](#built-in-mcps)
|
||||
- [Other Features](#other-features)
|
||||
- [Agents: Your Teammates](#agents-your-teammates)
|
||||
- [Background Agents: Work Like a Team](#background-agents-work-like-a-team)
|
||||
- [The Tools: Your Teammates Deserve Better](#the-tools-your-teammates-deserve-better)
|
||||
- [Why Are You the Only One Using an IDE?](#why-are-you-the-only-one-using-an-ide)
|
||||
- [Context Is All You Need](#context-is-all-you-need)
|
||||
- [Be Multimodal. Save Tokens.](#be-multimodal-save-tokens)
|
||||
- [I Removed Their Blockers](#i-removed-their-blockers)
|
||||
- [Goodbye Claude Code. Hello Oh My OpenCode.](#goodbye-claude-code-hello-oh-my-opencode)
|
||||
- [Hooks Integration](#hooks-integration)
|
||||
- [Config Loaders](#config-loaders)
|
||||
- [Data Storage](#data-storage)
|
||||
- [Compatibility Toggles](#compatibility-toggles)
|
||||
- [Not Just for the Agents](#not-just-for-the-agents)
|
||||
- [Configuration](#configuration)
|
||||
- [Google Auth](#google-auth)
|
||||
- [Agents](#agents)
|
||||
- [MCPs](#mcps)
|
||||
- [LSP](#lsp)
|
||||
- [Author's Note](#authors-note)
|
||||
- [Warnings](#warnings)
|
||||
|
||||
# Oh My OpenCode
|
||||
|
||||
Oh My OpenCode
|
||||
|
||||
oMoMoMoMoMo···
|
||||
|
||||
If you work in tech, you likely appreciated [Claude Code](https://www.claude.com/product/claude-code).
|
||||
If you are a hacker, you will fucking falling in love with [OpenCode](https://github.com/sst/opencode).
|
||||
|
||||
You don't write code just for a paycheck? You write because you genuinely love it?
|
||||
[Claude Code](https://www.claude.com/product/claude-code) is great.
|
||||
But if you're a hacker, you'll fall head over heels for [OpenCode](https://github.com/sst/opencode).
|
||||
|
||||
To you, OpenCode will feel like the paradigm shift from Windows to Linux. Not you? It's still worth the investment. Give it 10 minutes. Your work and life will improve. I promise.
|
||||
- Endlessly extensible. Endlessly customizable.
|
||||
- Zero screen flicker.
|
||||
- [LSP](https://opencode.ai/docs/lsp/), [linters, formatters](https://opencode.ai/docs/formatters/) activate automatically per file—and you can tweak everything.
|
||||
- Mix and match models. **Orchestrate them by purpose.**
|
||||
- Feature-rich. Beautiful. The terminal doesn't choke. High performance.
|
||||
|
||||
## TL;DR
|
||||
Remember the first time you switched from Windows to Linux? That rush of configuring everything exactly how you wanted?
|
||||
In an age where that magic seems lost, OpenCode brings it back.
|
||||
If you love coding and computers, OpenCode is that Linux-after-Windows moment.
|
||||
|
||||
- **Model Setup Required**
|
||||
- This plugin utilizes models from [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, and Anthropic.
|
||||
- For Anthropic models, use [OpenCode's built-in Claude Code Max Plan login](https://opencode.ai/docs/providers/#anthropic).
|
||||
- For OpenAI models, I recommend the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
|
||||
- For Gemini and GPT, use [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
|
||||
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
|
||||
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It works seamlessly.**
|
||||
- If you are an LLM Agent, install the required plugins and dependencies, then run `opencode auth login` via tmux to assist the user with configuration.
|
||||
**But here's the catch: there's a lot to learn, it's complex, and your time is expensive.**
|
||||
|
||||
I've fixed that.
|
||||
Even if you're not a hacker, invest a few minutes. Multiply your skills and productivity.
|
||||
Hand this doc to an agent and let them set it up.
|
||||
|
||||
## Just Skip Reading This Readme
|
||||
|
||||
### It's the Age of Agents
|
||||
- **Just paste this link into Claude Code / AmpCode / Factory Droid / Cursor and ask it to explain.**
|
||||
- Ask why it's good, why it's not just another boilerplate setup, what actually gets better.
|
||||
- If you're an LLM agent, explain to the user what becomes possible right now with this project.
|
||||
|
||||
### 10 Minutes to Unlock
|
||||
|
||||
Just by installing this, you make your agents to work like:
|
||||
|
||||
1. While Gemini 3 Pro writes the frontend as a background task, Claude Opus 4.5 handles the backend. Stuck debugging? Call GPT 5.2 for help. When the frontend reports done, verify and ship.
|
||||
2. Need to look something up? It scours official docs, your entire codebase history, and public GitHub implementations—using not just grep but built-in LSP tools and AST-Grep.
|
||||
3. Stop worrying about context management when delegating to LLMs. I've got it covered.
|
||||
- OhMyOpenCode aggressively leverages multiple agents to lighten the context load.
|
||||
- **Your agent is now the dev team lead. You're the AI Manager.**
|
||||
4. It doesn't stop until the job is done.
|
||||
5. Don't want to dive deep into this project? No problem. Just type 'ultrathink'.
|
||||
|
||||
Note: Installing this doesn't magically make OpenCode behave this way. Above explanation is like "you can utilize even like this". It simply equips your agent with excellent teammates and powerful tools—tell them to collaborate and they will.
|
||||
You're in full control.
|
||||
You can enable automatic behavior via ultrathink, but you don't have to. This project doesn't dictate your AI agent workflow.
|
||||
It simply introduces your agent to great colleagues and puts better tools in their hands.
|
||||
|
||||
- Throw complex, massive tasks at it.
|
||||
- Drop the "ultrawork" keyword in your prompt and it handles everything automatically.
|
||||
|
||||
## Installation
|
||||
|
||||
Add to `~/.config/opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode"
|
||||
]
|
||||
}
|
||||
```
|
||||
### For Humans
|
||||
|
||||
## For LLM Agents
|
||||
Let the LLM Agent handle the grunt work. Paste this link and ask it to install and configure.
|
||||
|
||||
### For LLM Agents
|
||||
|
||||
<details>
|
||||
<summary>For LLM Agents (Click to expand)</summary>
|
||||
<summary>Instructions for LLM Agents (click to expand)</summary>
|
||||
|
||||
If you're an LLM Agent helping set up oh-my-opencode, follow these steps:
|
||||
|
||||
If you are an LLM Agent helping a user set up oh-my-opencode, follow these steps:
|
||||
|
||||
### Step 1: Verify OpenCode Installation
|
||||
|
||||
@@ -70,11 +139,11 @@ if command -v opencode &> /dev/null; then
|
||||
echo "OpenCode $(opencode --version) is installed"
|
||||
else
|
||||
echo "OpenCode is not installed. Please install it first."
|
||||
echo "See: https://opencode.ai/docs"
|
||||
echo "Ref: https://opencode.ai/docs"
|
||||
fi
|
||||
```
|
||||
|
||||
If OpenCode is not installed, guide the user to [OpenCode Installation Guide](https://opencode.ai/docs).
|
||||
If OpenCode isn't installed, check the [OpenCode Installation Guide](https://opencode.ai/docs). To save context, spawn a subagent to handle installation and report back.
|
||||
|
||||
### Step 2: Configure oh-my-opencode Plugin
|
||||
|
||||
@@ -88,10 +157,10 @@ if [ -f ~/.config/opencode/opencode.json ]; then
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
else
|
||||
echo "Add \"oh-my-opencode\" to plugin array in ~/.config/opencode/opencode.json"
|
||||
echo "Please add \"oh-my-opencode\" to the plugin array in ~/.config/opencode/opencode.json"
|
||||
fi
|
||||
else
|
||||
# New config
|
||||
# Create new config
|
||||
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
|
||||
fi
|
||||
```
|
||||
@@ -99,125 +168,315 @@ fi
|
||||
### Step 3: Verify Setup
|
||||
|
||||
```bash
|
||||
opencode --version # Should be >= 1.0.132
|
||||
opencode --version # Should be 1.0.132 or higher
|
||||
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array
|
||||
```
|
||||
|
||||
> **Note**: If model authentication is needed, run `opencode` and use `/connect` command, or assist the user with `opencode auth login` via tmux.
|
||||
### Step 4: Configure Authentication
|
||||
|
||||
Once oh-my-opencode is in the plugin list, use tmux to walk the user through authentication. Complete all steps below.
|
||||
|
||||
#### 4.1 Anthropic (Claude)
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: Select Anthropic
|
||||
# Login method: Select Claude Pro/Max
|
||||
# Guide user through OAuth flow in browser
|
||||
# Wait for completion
|
||||
# Verify success and confirm with user
|
||||
```
|
||||
|
||||
#### 4.2 Google Gemini (Antigravity OAuth)
|
||||
|
||||
First, enable Google auth in `~/.config/opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"google_auth": true
|
||||
}
|
||||
```
|
||||
|
||||
Then authenticate:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: Select Google
|
||||
# Login method: Select OAuth with Google (Antigravity)
|
||||
# Guide user through OAuth flow in browser
|
||||
# Wait for completion
|
||||
# Verify success and confirm with user
|
||||
```
|
||||
|
||||
#### 4.3 OpenAI (ChatGPT Plus/Pro)
|
||||
|
||||
First, add the opencode-openai-codex-auth plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.1.0"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The official npm package currently has a bug causing 400 errors (`"No tool call found for function call output with call_id"`). **Use the hotfix branch** until fixed. Edit `~/.config/opencode/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run `cd ~/.config/opencode && bun i`. In `opencode.json`, use `"opencode-openai-codex-auth"` without the version suffix.
|
||||
|
||||
#### 4.3.1 Model Configuration
|
||||
You'll also need full model settings in `opencode.json`.
|
||||
Read the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth), copy provider/models config from [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json), and merge carefully to avoid breaking the user's existing setup.
|
||||
|
||||
Then authenticate:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: Select OpenAI
|
||||
# Login method: Select ChatGPT Plus/Pro (Codex Subscription)
|
||||
# Guide user through OAuth flow in browser
|
||||
# Wait for completion
|
||||
# Verify success and confirm with user
|
||||
```
|
||||
|
||||
|
||||
### ⚠️ Warning
|
||||
|
||||
**Don't change model settings or disable features (agents, hooks, MCPs) unless explicitly requested.**
|
||||
|
||||
The plugin works perfectly with defaults. Aside from the recommended `google_auth: true`, don't touch other settings without a specific ask.
|
||||
|
||||
</details>
|
||||
|
||||
## Why OpenCode & Why Oh My OpenCode
|
||||
|
||||
OpenCode is limitlessly extensible and customizable. Zero screen flicker.
|
||||
[LSP](https://opencode.ai/docs/lsp/), [linters, formatters](https://opencode.ai/docs/formatters/)? Automatic and fully configurable.
|
||||
You can mix and orchestrate models to your exact specifications.
|
||||
It is feature-rich. It is elegant. It handles the terminal without hesitation. It is high-performance.
|
||||
|
||||
But here is the catch: the learning curve is steep. There is a lot to master.
|
||||
|
||||
Inspired by [AmpCode](https://ampcode.com) and [Claude Code](https://code.claude.com/docs/en/overview), I have implemented their features here—often with superior execution.
|
||||
Because this is OpenCode.
|
||||
|
||||
Consider this a superior AmpCode, a superior Claude Code, or simply a specialized distribution.
|
||||
|
||||
I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI or VibeProxy. Employ the best LLMs from frontier labs. You are in command.
|
||||
|
||||
**Note**: This setup is highly opinionated. It represents the generic component of my personal configuration, so it evolves constantly. I have spent tokens worth $20,000 just for my personal programming usages, and this plugin represents the apex of that experience. You simply inherit the best. If you have superior ideas, PRs are welcome.
|
||||
|
||||
## Features
|
||||
|
||||
### Hooks
|
||||
### Agents: Your Teammates
|
||||
|
||||
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
||||
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
|
||||
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
||||
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
|
||||
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
|
||||
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
|
||||
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
|
||||
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
|
||||
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
|
||||
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, providing directory-level context to the agent. Inspired by Claude Code's CLAUDE.md feature.
|
||||
- **oracle** (`openai/gpt-5.2`): Architecture, code review, strategy. Uses GPT-5.2 for its stellar logical reasoning and deep analysis. Inspired by AmpCode.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`): Multi-repo analysis, doc lookup, implementation examples. Claude Sonnet 4 is fast, smart, great at tool calls, and excellent for documentation research. Inspired by AmpCode.
|
||||
- **explore** (`opencode/grok-code`): Fast codebase exploration and pattern matching. Claude Code uses Haiku; we use Grok—it's free, blazing fast, and plenty smart for file traversal. Inspired by Claude Code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
|
||||
- **multimodal-looker** (`google/gemini-2.5-flash`): Visual content specialist. Analyzes PDFs, images, diagrams to extract information.
|
||||
|
||||
### Agents
|
||||
- **oracle** (`openai/gpt-5.1`): The architect. Expert in code reviews and strategy. Uses GPT-5.1 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
|
||||
- **librarian** (`anthropic/claude-haiku-4-5`): Multi-repo analysis, documentation lookup, and implementation examples. Haiku is chosen for its speed, competence, excellent tool usage, and cost-efficiency. Inspired by AmpCode.
|
||||
- **explore** (`opencode/grok-code`): Fast exploration and pattern matching. Claude Code uses Haiku; we use Grok. It is currently free, blazing fast, and intelligent enough for file traversal. Inspired by Claude Code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Creates stunning UIs. Uses Gemini because its creativity and UI code generation are superior.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): A technical writing expert. Gemini is a wordsmith; it writes prose that flows naturally.
|
||||
|
||||
Each agent is automatically invoked by the main agent, but you can also explicitly request them:
|
||||
The main agent invokes these automatically, but you can call them explicitly:
|
||||
|
||||
```
|
||||
@oracle Please think through the design of this part and suggest an architecture.
|
||||
@librarian Tell me how this is implemented — why does the behavior keep changing internally?
|
||||
@explore Tell me about the policy for this feature.
|
||||
Ask @oracle to review this design and propose an architecture
|
||||
Ask @librarian how this is implemented—why does the behavior keep changing?
|
||||
Ask @explore for the policy on this feature
|
||||
```
|
||||
|
||||
Agent models, prompts, and permissions can be customized via `oh-my-opencode.json`. See [Configuration](#configuration) for details.
|
||||
Customize agent models, prompts, and permissions in `oh-my-opencode.json`. See [Configuration](#configuration).
|
||||
|
||||
### Tools
|
||||
### Background Agents: Work Like a Team
|
||||
|
||||
#### Built-in LSP Tools
|
||||
What if you could run these agents relentlessly, never letting them idle?
|
||||
|
||||
The features you use in your editor—other agents cannot access them. Oh My OpenCode hands those very tools to your LLM Agent. Refactoring, navigation, and analysis are all supported using the same OpenCode configuration.
|
||||
- Have GPT debug while Claude tries different approaches to find the root cause
|
||||
- Gemini writes the frontend while Claude handles the backend
|
||||
- Kick off massive parallel searches, continue implementation on other parts, then finish using the search results
|
||||
|
||||
[OpenCode provides LSP](https://opencode.ai/docs/lsp/), but only for analysis. Oh My OpenCode equips you with navigation and refactoring tools matching the same specification.
|
||||
These workflows are possible with OhMyOpenCode.
|
||||
|
||||
- **lsp_hover**: Get type info, docs, signatures at position
|
||||
Run subagents in the background. The main agent gets notified on completion. Wait for results if needed.
|
||||
|
||||
**Make your agents work like your team works.**
|
||||
|
||||
### The Tools: Your Teammates Deserve Better
|
||||
|
||||
#### Why Are You the Only One Using an IDE?
|
||||
|
||||
Syntax highlighting, autocomplete, refactoring, navigation, analysis—and now agents writing code...
|
||||
|
||||
**Why are you the only one with these tools?**
|
||||
**Give them to your agents and watch them level up.**
|
||||
|
||||
[OpenCode provides LSP](https://opencode.ai/docs/lsp/), but only for analysis.
|
||||
|
||||
The features in your editor? Other agents can't touch them.
|
||||
Hand your best tools to your best colleagues. Now they can properly refactor, navigate, and analyze.
|
||||
|
||||
- **lsp_hover**: Type info, docs, signatures at position
|
||||
- **lsp_goto_definition**: Jump to symbol definition
|
||||
- **lsp_find_references**: Find all usages across workspace
|
||||
- **lsp_document_symbols**: Get file's symbol outline
|
||||
- **lsp_document_symbols**: Get file symbol outline
|
||||
- **lsp_workspace_symbols**: Search symbols by name across project
|
||||
- **lsp_diagnostics**: Get errors/warnings before build
|
||||
- **lsp_servers**: List available LSP servers
|
||||
- **lsp_prepare_rename**: Validate rename operation
|
||||
- **lsp_rename**: Rename symbol across workspace
|
||||
- **lsp_code_actions**: Get available quick fixes/refactorings
|
||||
- **lsp_code_action_resolve**: Apply a code action
|
||||
|
||||
#### Built-in AST-Grep Tools
|
||||
|
||||
- **lsp_code_action_resolve**: Apply code action
|
||||
- **ast_grep_search**: AST-aware code pattern search (25 languages)
|
||||
- **ast_grep_replace**: AST-aware code replacement
|
||||
|
||||
#### Grep
|
||||
#### Context Is All You Need
|
||||
- **Directory AGENTS.md / README.md Injector**: Auto-injects `AGENTS.md` and `README.md` when reading files. Walks from file directory to project root, collecting **all** `AGENTS.md` files along the path. Supports nested directory-specific instructions:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # Project-wide context
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src-specific context
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # Component-specific context
|
||||
│ └── Button.tsx # Reading this injects all 3 AGENTS.md files
|
||||
```
|
||||
Reading `Button.tsx` injects in order: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. Each directory's context is injected once per session.
|
||||
- **Conditional Rules Injector**: Not all rules apply all the time. Injects rules from `.claude/rules/` when conditions match.
|
||||
- Walks upward from file directory to project root, plus `~/.claude/rules/` (user).
|
||||
- Supports `.md` and `.mdc` files.
|
||||
- Matches via `globs` field in frontmatter.
|
||||
- `alwaysApply: true` for rules that should always fire.
|
||||
- Example rule file:
|
||||
```markdown
|
||||
---
|
||||
globs: ["*.ts", "src/**/*.js"]
|
||||
description: "TypeScript/JavaScript coding rules"
|
||||
---
|
||||
- Use PascalCase for interface names
|
||||
- Use camelCase for function names
|
||||
```
|
||||
- **Online**: Project rules aren't everything. Built-in MCPs for extended capabilities:
|
||||
- **context7**: Official documentation lookup
|
||||
- **websearch_exa**: Real-time web search
|
||||
- **grep_app**: Ultra-fast code search across public GitHub repos (great for finding implementation examples)
|
||||
|
||||
- **grep**: Content search with safety limits (5min timeout, 10MB output). Overrides OpenCode's built-in `grep` tool.
|
||||
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
|
||||
- This tool enforces strict limits and completely replaces the built-in `grep`.
|
||||
#### Be Multimodal. Save Tokens.
|
||||
|
||||
#### Glob
|
||||
The look_at tool from AmpCode, now in OhMyOpenCode.
|
||||
Instead of the agent reading massive files and bloating context, it internally leverages another agent to extract just what it needs.
|
||||
|
||||
- **glob**: File pattern matching with timeout protection (60s). Overrides OpenCode's built-in `glob` tool.
|
||||
- The default `glob` lacks timeout. If ripgrep hangs, it waits indefinitely.
|
||||
- This tool enforces timeouts and kills the process on expiration.
|
||||
#### I Removed Their Blockers
|
||||
- Replaces built-in grep and glob tools. Default implementation has no timeout—can hang forever.
|
||||
|
||||
#### Built-in MCPs
|
||||
|
||||
- **websearch_exa**: Exa AI web search. Performs real-time web searches and can scrape content from specific URLs. Returns LLM-optimized context from relevant websites.
|
||||
- **context7**: Library documentation lookup. Fetches up-to-date documentation for any library to assist with accurate coding.
|
||||
### Goodbye Claude Code. Hello Oh My OpenCode.
|
||||
|
||||
Don't need these? Disable them via `oh-my-opencode.json`:
|
||||
Oh My OpenCode has a Claude Code compatibility layer.
|
||||
If you were using Claude Code, your existing config just works.
|
||||
|
||||
#### Hooks Integration
|
||||
|
||||
Run custom scripts via Claude Code's `settings.json` hook system.
|
||||
Oh My OpenCode reads and executes hooks from:
|
||||
|
||||
- `~/.claude/settings.json` (user)
|
||||
- `./.claude/settings.json` (project)
|
||||
- `./.claude/settings.local.json` (local, git-ignored)
|
||||
|
||||
Supported hook events:
|
||||
- **PreToolUse**: Runs before tool execution. Can block or modify tool input.
|
||||
- **PostToolUse**: Runs after tool execution. Can add warnings or context.
|
||||
- **UserPromptSubmit**: Runs when user submits prompt. Can block or inject messages.
|
||||
- **Stop**: Runs when session goes idle. Can inject follow-up prompts.
|
||||
|
||||
Example `settings.json`:
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["websearch_exa"]
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Other Features
|
||||
#### Config Loaders
|
||||
|
||||
- **Terminal Title**: Auto-updates terminal title with session status (idle ○, processing ◐, tool ⚡, error ✖). Supports tmux.
|
||||
**Command Loader**: Loads markdown-based slash commands from 4 directories:
|
||||
- `~/.claude/commands/` (user)
|
||||
- `./.claude/commands/` (project)
|
||||
- `~/.config/opencode/command/` (opencode global)
|
||||
- `./.opencode/command/` (opencode project)
|
||||
|
||||
**Skill Loader**: Loads directory-based skills with `SKILL.md`:
|
||||
- `~/.claude/skills/` (user)
|
||||
- `./.claude/skills/` (project)
|
||||
|
||||
**Agent Loader**: Loads custom agent definitions from markdown files:
|
||||
- `~/.claude/agents/*.md` (user)
|
||||
- `./.claude/agents/*.md` (project)
|
||||
|
||||
**MCP Loader**: Loads MCP server configs from `.mcp.json` files:
|
||||
- `~/.claude/.mcp.json` (user)
|
||||
- `./.mcp.json` (project)
|
||||
- `./.claude/.mcp.json` (local)
|
||||
- Supports environment variable expansion (`${VAR}` syntax)
|
||||
|
||||
#### Data Storage
|
||||
|
||||
**Todo Management**: Session todos stored in `~/.claude/todos/` in Claude Code compatible format.
|
||||
|
||||
**Transcript**: Session activity logged to `~/.claude/transcripts/` in JSONL format for replay and analysis.
|
||||
|
||||
#### Compatibility Toggles
|
||||
|
||||
Disable specific Claude Code compatibility features with the `claude_code` config object:
|
||||
|
||||
```json
|
||||
{
|
||||
"claude_code": {
|
||||
"mcp": false,
|
||||
"commands": false,
|
||||
"skills": false,
|
||||
"agents": false,
|
||||
"hooks": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Toggle | When `false`, stops loading from... | Unaffected |
|
||||
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCP (context7, websearch_exa) |
|
||||
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
|
||||
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
|
||||
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | Built-in agents (oracle, librarian, etc.) |
|
||||
| `hooks` | `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` | - |
|
||||
|
||||
All toggles default to `true` (enabled). Omit the `claude_code` object for full Claude Code compatibility.
|
||||
|
||||
### Not Just for the Agents
|
||||
|
||||
When agents thrive, you thrive. But I want to help you directly too.
|
||||
|
||||
- **Keyword Detector**: Automatically detects keywords in your prompts and activates specialized modes:
|
||||
- `ultrawork` / `ulw`: Maximum performance mode with parallel agent orchestration
|
||||
- `search` / `find` / `찾아` / `検索`: Maximized search effort with parallel explore and librarian agents
|
||||
- `analyze` / `investigate` / `분석` / `調査`: Deep analysis mode with multi-phase expert consultation
|
||||
- **Todo Continuation Enforcer**: Makes agents finish all TODOs before stopping. Kills the chronic LLM habit of quitting halfway.
|
||||
- **Comment Checker**: LLMs love comments. Too many comments. This reminds them to cut the noise. Smartly ignores valid patterns (BDD, directives, docstrings) and demands justification for the rest. Clean code wins.
|
||||
- **Think Mode**: Auto-detects when extended thinking is needed and switches modes. Catches phrases like "think deeply" or "ultrathink" and dynamically adjusts model settings for maximum reasoning.
|
||||
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/).
|
||||
- At 70%+ usage, reminds agents there's still headroom—prevents rushed, sloppy work.
|
||||
- **Agent Usage Reminder**: When you call search tools directly, reminds you to leverage specialized agents via background tasks for better results.
|
||||
- **Anthropic Auto Compact**: When Claude models hit token limits, automatically summarizes and compacts the session—no manual intervention needed.
|
||||
- **Session Recovery**: Automatically recovers from session errors (missing tool results, thinking block issues, empty messages). Sessions don't crash mid-run. Even if they do, they recover.
|
||||
- **Auto Update Checker**: Notifies you when a new version of oh-my-opencode is available.
|
||||
- **Background Notification**: Get notified when background agent tasks complete.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration file locations (in priority order):
|
||||
Highly opinionated, but adjustable to taste.
|
||||
|
||||
Config file locations (priority order):
|
||||
1. `.opencode/oh-my-opencode.json` (project)
|
||||
2. `~/.config/opencode/oh-my-opencode.json` (user)
|
||||
|
||||
Schema autocomplete is supported:
|
||||
Schema autocomplete supported:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -225,6 +484,18 @@ Schema autocomplete is supported:
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
Enable built-in Antigravity OAuth for Google Gemini models:
|
||||
|
||||
```json
|
||||
{
|
||||
"google_auth": true
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, `opencode auth login` shows "OAuth with Google (Antigravity)" for the Google provider.
|
||||
|
||||
### Agents
|
||||
|
||||
Override built-in agent settings:
|
||||
@@ -245,7 +516,7 @@ Override built-in agent settings:
|
||||
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
Or disable agents via `disabled_agents`:
|
||||
Or disable via `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -253,25 +524,43 @@ Or disable agents via `disabled_agents`:
|
||||
}
|
||||
```
|
||||
|
||||
Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
|
||||
Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`, `multimodal-looker`
|
||||
|
||||
### MCPs
|
||||
### Hooks
|
||||
|
||||
Disable built-in MCPs:
|
||||
Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa"]
|
||||
"disabled_hooks": ["comment-checker", "agent-usage-reminder"]
|
||||
}
|
||||
```
|
||||
|
||||
See [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers) for more.
|
||||
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-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`
|
||||
|
||||
### MCPs
|
||||
|
||||
Context7, Exa, and grep.app MCP enabled by default.
|
||||
|
||||
- **context7**: Fetches up-to-date official documentation for libraries
|
||||
- **websearch_exa**: Real-time web search powered by Exa AI
|
||||
- **grep_app**: Ultra-fast code search across millions of public GitHub repositories via [grep.app](https://grep.app)
|
||||
|
||||
Don't want them? Disable via `disabled_mcps` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
|
||||
}
|
||||
```
|
||||
|
||||
### LSP
|
||||
|
||||
Oh My OpenCode's LSP tools are for **refactoring only** (rename, code actions). Analysis LSP is handled by OpenCode itself.
|
||||
OpenCode provides LSP tools for analysis.
|
||||
Oh My OpenCode adds refactoring tools (rename, code actions).
|
||||
All OpenCode LSP configs and custom settings (from opencode.json) are supported, plus additional Oh My OpenCode-specific settings.
|
||||
|
||||
Configure LSP servers via `lsp` option:
|
||||
Add LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -290,29 +579,48 @@ Configure LSP servers via `lsp` option:
|
||||
|
||||
Each server supports: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
|
||||
|
||||
|
||||
## Author's Note
|
||||
|
||||
Install Oh My OpenCode. Do not waste time configuring OpenCode from scratch.
|
||||
I have resolved the friction so you don't have to. The answers are in this plugin. If OpenCode is Arch Linux, Oh My OpenCode is [Omarchy](https://omarchy.org/).
|
||||
Install Oh My OpenCode.
|
||||
|
||||
Enjoy the multi-model stability and rich feature set that other harnesses promise but fail to deliver.
|
||||
I will continue testing and updating here. I am the primary user of this project.
|
||||
I've used LLMs worth $24,000 tokens purely for personal development.
|
||||
Tried every tool out there, configured them to death. OpenCode won.
|
||||
|
||||
- Who possesses the best raw logic?
|
||||
- Who is the debugging god?
|
||||
The answers to every problem I hit are baked into this plugin. Just install and go.
|
||||
If OpenCode is Debian/Arch, Oh My OpenCode is Ubuntu/[Omarchy](https://omarchy.org/).
|
||||
|
||||
|
||||
Heavily influenced by [AmpCode](https://ampcode.com) and [Claude Code](https://code.claude.com/docs/overview)—I've ported their features here, often improved. And I'm still building.
|
||||
It's **Open**Code, after all.
|
||||
|
||||
Enjoy multi-model orchestration, stability, and rich features that other harnesses promise but can't deliver.
|
||||
I'll keep testing and updating. I'm this project's most obsessive user.
|
||||
- Which model has the sharpest logic?
|
||||
- Who's the debugging god?
|
||||
- Who writes the best prose?
|
||||
- Who dominates frontend?
|
||||
- Who owns backend?
|
||||
- Which model is fastest for daily driving?
|
||||
- What new features are other harnesses shipping?
|
||||
|
||||
Do not overthink it. I have done the thinking. I will integrate the best practices. I will update this.
|
||||
If this sounds arrogant and you have a superior solution, send a PR. You are welcome.
|
||||
This plugin is the distillation of that experience. Just take the best. Got a better idea? PRs are welcome.
|
||||
|
||||
As of now, I have no affiliation with any of the projects or models mentioned here. This plugin is purely based on personal experimentation and preference.
|
||||
**Stop agonizing over agent harness choices.**
|
||||
**I'll do the research, borrow from the best, and ship updates here.**
|
||||
|
||||
If this sounds arrogant and you have a better answer, please contribute. You're welcome.
|
||||
|
||||
I have no affiliation with any project or model mentioned here. This is purely personal experimentation and preference.
|
||||
|
||||
99% of this project was built using OpenCode. I tested for functionality—I don't really know how to write proper TypeScript. **But I personally reviewed and largely rewrote this doc, so read with confidence.**
|
||||
|
||||
I constructed 99% of this project using OpenCode. I focused on functional verification. This documentation has been personally reviewed and comprehensively rewritten, so you can rely on it with confidence.
|
||||
## Warnings
|
||||
|
||||
- If you are on [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) or lower, OpenCode has a bug that might break config.
|
||||
- [The fix](https://github.com/sst/opencode/pull/5040) was merged after 1.0.132, so use a newer version.
|
||||
- Productivity might spike too hard. Don't let your coworker notice.
|
||||
- Actually, I'll spread the word. Let's see who wins.
|
||||
- If you're on [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) or older, an OpenCode bug may break config.
|
||||
- [The fix](https://github.com/sst/opencode/pull/5040) was merged after 1.0.132—use a newer version.
|
||||
- Fun fact: That PR was discovered and fixed thanks to OhMyOpenCode's Librarian, Explore, and Oracle setup.
|
||||
|
||||
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"websearch_exa",
|
||||
"context7"
|
||||
"context7",
|
||||
"grep_app"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -27,7 +28,35 @@
|
||||
"librarian",
|
||||
"explore",
|
||||
"frontend-ui-ux-engineer",
|
||||
"document-writer"
|
||||
"document-writer",
|
||||
"multimodal-looker"
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_hooks": {
|
||||
"type": "array",
|
||||
"description": "List of built-in hooks to disable. Useful for selectively disabling hooks that may conflict with your workflow.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"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-auto-compact",
|
||||
"rules-injector",
|
||||
"background-notification",
|
||||
"auto-update-checker",
|
||||
"startup-toast",
|
||||
"keyword-detector",
|
||||
"agent-usage-reminder"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -40,7 +69,8 @@
|
||||
"librarian",
|
||||
"explore",
|
||||
"frontend-ui-ux-engineer",
|
||||
"document-writer"
|
||||
"document-writer",
|
||||
"multimodal-looker"
|
||||
]
|
||||
},
|
||||
"additionalProperties": {
|
||||
@@ -154,6 +184,73 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"google_auth": {
|
||||
"type": "boolean",
|
||||
"description": "Enable built-in Antigravity OAuth for Google Gemini models. When true, adds 'OAuth with Google (Antigravity)' login option.",
|
||||
"default": false
|
||||
},
|
||||
"lsp": {
|
||||
"type": "object",
|
||||
"description": "Additional LSP server configurations specific to Oh My OpenCode.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Command and arguments to start the LSP server"
|
||||
},
|
||||
"extensions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "File extensions this server handles (e.g., [\".ts\", \".tsx\"])"
|
||||
},
|
||||
"priority": {
|
||||
"type": "number",
|
||||
"description": "Server priority (higher = preferred)"
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Environment variables for the LSP server"
|
||||
},
|
||||
"initialization": {
|
||||
"type": "object",
|
||||
"description": "Custom initialization options"
|
||||
},
|
||||
"disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Disable this LSP server"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"claude_code": {
|
||||
"type": "object",
|
||||
"description": "Toggle Claude Code compatibility features on/off. All default to true (enabled).",
|
||||
"properties": {
|
||||
"mcp": {
|
||||
"type": "boolean",
|
||||
"description": "Load MCP servers from ~/.claude/.mcp.json, ./.mcp.json, ./.claude/.mcp.json"
|
||||
},
|
||||
"commands": {
|
||||
"type": "boolean",
|
||||
"description": "Load commands from ~/.claude/commands/*.md, ./.claude/commands/*.md"
|
||||
},
|
||||
"skills": {
|
||||
"type": "boolean",
|
||||
"description": "Load skills from ~/.claude/skills/*/SKILL.md, ./.claude/skills/*/SKILL.md"
|
||||
},
|
||||
"agents": {
|
||||
"type": "boolean",
|
||||
"description": "Load agents from ~/.claude/agents/*.md, ./.claude/agents/*.md"
|
||||
},
|
||||
"hooks": {
|
||||
"type": "boolean",
|
||||
"description": "Execute hooks from ~/.claude/settings.json, ./.claude/settings.json, ./.claude/settings.local.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
bun.lock
51
bun.lock
@@ -7,13 +7,18 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"@code-yeongyu/comment-checker": "^0.5.0",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.150",
|
||||
"hono": "^4.10.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -63,11 +68,23 @@
|
||||
|
||||
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
|
||||
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.5.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-rKD2qQnTVUacsVQtpu3I5Sxi09X/XpOwS9fcmbUv1yfUL6llraaPuLmmxMBMRcmm7Zu31yEPVKCeUkVODfRL1g=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
|
||||
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.128", "", {}, "sha512-Kow3Ivg8bR8dNRp8C0LwF9e8+woIrwFgw3ZALycwCfqS/UujDkJiBeYHdr1l/07GSHP9sZPmvJ6POuvfZ923EA=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.150", "", { "dependencies": { "@opencode-ai/sdk": "1.0.150", "zod": "4.1.8" } }, "sha512-XmY3yydk120GBv2KeLxSZlElFx4Zx9TYLa3bS9X1TxXot42UeoMLEi3Xa46yboYnWwp4bC9Fu+Gd1E7hypG8Jw=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.150", "", {}, "sha512-Nz9Di8UD/GK01w3N+jpiGNB733pYkNY8RNLbuE/HUxEGSP5apbXBY0IdhbW7859sXZZK38kF1NqOx4UxwBf4Bw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
"@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
|
||||
|
||||
"@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
|
||||
|
||||
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="],
|
||||
|
||||
@@ -91,14 +108,30 @@
|
||||
|
||||
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-u5eZHKq6TPJSE282KyBOicGQ2trkFml0RoUfqkPOJVo7TXGrsGYYzdsugZRnVQY/WEmnxGtBy4T3PAaPqgQViA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
|
||||
|
||||
"arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="],
|
||||
|
||||
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
"bun": ["bun@1.3.3", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.3", "@oven/bun-darwin-x64": "1.3.3", "@oven/bun-darwin-x64-baseline": "1.3.3", "@oven/bun-linux-aarch64": "1.3.3", "@oven/bun-linux-aarch64-musl": "1.3.3", "@oven/bun-linux-x64": "1.3.3", "@oven/bun-linux-x64-baseline": "1.3.3", "@oven/bun-linux-x64-musl": "1.3.3", "@oven/bun-linux-x64-musl-baseline": "1.3.3", "@oven/bun-windows-x64": "1.3.3", "@oven/bun-windows-x64-baseline": "1.3.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-2hJ4ocTZ634/Ptph4lysvO+LbbRZq8fzRvMwX0/CqaLBxrF2UB5D1LdMB8qGcdtCer4/VR9Bx5ORub0yn+yzmw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
|
||||
|
||||
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"oh-my-opencode": ["oh-my-opencode@0.1.30", "", { "dependencies": { "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@code-yeongyu/comment-checker": "^0.4.1", "@opencode-ai/plugin": "^1.0.7", "xdg-basedir": "^5.1.0", "zod": "^4.1.8" }, "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-pXGGgL/7Jcz3yuGJJTI72BKern2egwfRz2LQZTBq+jl+pNCybOvGvXtFmR+WGlF8O3ZjL1wIHypBbIVuHOBzxg=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
@@ -106,5 +139,13 @@
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
|
||||
"oh-my-opencode/@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
|
||||
|
||||
"oh-my-opencode/@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
|
||||
|
||||
"oh-my-opencode/@opencode-ai/plugin/@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.128", "", {}, "sha512-Kow3Ivg8bR8dNRp8C0LwF9e8+woIrwFgw3ZALycwCfqS/UujDkJiBeYHdr1l/07GSHP9sZPmvJ6POuvfZ923EA=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
# Comment-Checker TypeScript Port 구현 계획
|
||||
|
||||
## 1. 아키텍처 개요
|
||||
|
||||
### 1.1 핵심 도전 과제
|
||||
|
||||
**OpenCode Hook의 제약사항:**
|
||||
- `tool.execute.before`: `output.args`에서 파일 경로/내용 접근 가능
|
||||
- `tool.execute.after`: `tool_input`이 **제공되지 않음** (Claude Code와의 핵심 차이점)
|
||||
- **해결책**: Before hook에서 데이터를 캡처하여 callID로 키잉된 Map에 저장, After hook에서 조회
|
||||
|
||||
### 1.2 디렉토리 구조
|
||||
|
||||
```
|
||||
src/hooks/comment-checker/
|
||||
├── index.ts # Hook factory, 메인 엔트리포인트
|
||||
├── types.ts # 모든 타입 정의
|
||||
├── constants.ts # 언어 레지스트리, 쿼리 템플릿, 디렉티브 목록
|
||||
├── detector.ts # CommentDetector - web-tree-sitter 기반 코멘트 감지
|
||||
├── filters/
|
||||
│ ├── index.ts # 필터 barrel export
|
||||
│ ├── bdd.ts # BDD 패턴 필터
|
||||
│ ├── directive.ts # 린터/타입체커 디렉티브 필터
|
||||
│ ├── docstring.ts # 독스트링 필터
|
||||
│ └── shebang.ts # Shebang 필터
|
||||
├── output/
|
||||
│ ├── index.ts # 출력 barrel export
|
||||
│ ├── formatter.ts # FormatHookMessage
|
||||
│ └── xml-builder.ts # BuildCommentsXML
|
||||
└── utils.ts # 유틸리티 함수
|
||||
```
|
||||
|
||||
### 1.3 데이터 흐름
|
||||
|
||||
```
|
||||
[write/edit 도구 실행]
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ tool.execute.before │
|
||||
│ - 파일 경로 캡처 │
|
||||
│ - pendingCalls Map │
|
||||
│ 에 저장 │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
[도구 실제 실행]
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ tool.execute.after │
|
||||
│ - pendingCalls에서 │
|
||||
│ 데이터 조회 │
|
||||
│ - 파일 읽기 │
|
||||
│ - 코멘트 감지 │
|
||||
│ - 필터 적용 │
|
||||
│ - 메시지 주입 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 구현 순서
|
||||
|
||||
### Phase 1: 기반 구조
|
||||
1. `src/hooks/comment-checker/` 디렉토리 생성
|
||||
2. `types.ts` - 모든 타입 정의
|
||||
3. `constants.ts` - 언어 레지스트리, 디렉티브 패턴
|
||||
|
||||
### Phase 2: 필터 구현
|
||||
4. `filters/bdd.ts` - BDD 패턴 필터
|
||||
5. `filters/directive.ts` - 디렉티브 필터
|
||||
6. `filters/docstring.ts` - 독스트링 필터
|
||||
7. `filters/shebang.ts` - Shebang 필터
|
||||
8. `filters/index.ts` - 필터 조합
|
||||
|
||||
### Phase 3: 코어 로직
|
||||
9. `detector.ts` - web-tree-sitter 기반 코멘트 감지
|
||||
10. `output/xml-builder.ts` - XML 출력
|
||||
11. `output/formatter.ts` - 메시지 포매팅
|
||||
|
||||
### Phase 4: Hook 통합
|
||||
12. `index.ts` - Hook factory 및 상태 관리
|
||||
13. `src/hooks/index.ts` 업데이트 - export 추가
|
||||
|
||||
### Phase 5: 의존성 및 빌드
|
||||
14. `package.json` 업데이트 - web-tree-sitter 추가
|
||||
15. typecheck 및 build 검증
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 구현 사항
|
||||
|
||||
### 3.1 언어 레지스트리 (38개 언어)
|
||||
|
||||
```typescript
|
||||
const LANGUAGE_REGISTRY: Record<string, LanguageConfig> = {
|
||||
python: { extensions: [".py"], commentQuery: "(comment) @comment", docstringQuery: "..." },
|
||||
javascript: { extensions: [".js", ".jsx"], commentQuery: "(comment) @comment" },
|
||||
typescript: { extensions: [".ts"], commentQuery: "(comment) @comment" },
|
||||
tsx: { extensions: [".tsx"], commentQuery: "(comment) @comment" },
|
||||
go: { extensions: [".go"], commentQuery: "(comment) @comment" },
|
||||
rust: { extensions: [".rs"], commentQuery: "(line_comment) @comment (block_comment) @comment" },
|
||||
// ... 38개 전체
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 필터 로직
|
||||
|
||||
**BDD 필터**: `given, when, then, arrange, act, assert`
|
||||
**Directive 필터**: `noqa, pyright:, eslint-disable, @ts-ignore` 등 30+
|
||||
**Docstring 필터**: `IsDocstring || starts with /**`
|
||||
**Shebang 필터**: `starts with #!`
|
||||
|
||||
### 3.3 출력 형식 (Go 버전과 100% 동일)
|
||||
|
||||
```
|
||||
COMMENT/DOCSTRING DETECTED - IMMEDIATE ACTION REQUIRED
|
||||
|
||||
Your recent changes contain comments or docstrings, which triggered this hook.
|
||||
You need to take immediate action. You must follow the conditions below.
|
||||
(Listed in priority order - you must always act according to this priority order)
|
||||
|
||||
CRITICAL WARNING: This hook message MUST NEVER be ignored...
|
||||
|
||||
<comments file="/path/to/file.py">
|
||||
<comment line-number="10">// comment text</comment>
|
||||
</comments>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 생성할 파일 목록
|
||||
|
||||
1. `src/hooks/comment-checker/types.ts`
|
||||
2. `src/hooks/comment-checker/constants.ts`
|
||||
3. `src/hooks/comment-checker/filters/bdd.ts`
|
||||
4. `src/hooks/comment-checker/filters/directive.ts`
|
||||
5. `src/hooks/comment-checker/filters/docstring.ts`
|
||||
6. `src/hooks/comment-checker/filters/shebang.ts`
|
||||
7. `src/hooks/comment-checker/filters/index.ts`
|
||||
8. `src/hooks/comment-checker/output/xml-builder.ts`
|
||||
9. `src/hooks/comment-checker/output/formatter.ts`
|
||||
10. `src/hooks/comment-checker/output/index.ts`
|
||||
11. `src/hooks/comment-checker/detector.ts`
|
||||
12. `src/hooks/comment-checker/index.ts`
|
||||
|
||||
## 5. 수정할 파일 목록
|
||||
|
||||
1. `src/hooks/index.ts` - export 추가
|
||||
2. `package.json` - web-tree-sitter 의존성
|
||||
|
||||
---
|
||||
|
||||
## 6. Definition of Done
|
||||
|
||||
- [ ] write/edit 도구 실행 시 코멘트 감지 동작
|
||||
- [ ] 4개 필터 모두 정상 작동
|
||||
- [ ] 최소 5개 언어 지원 (Python, JS, TS, TSX, Go)
|
||||
- [ ] Go 버전과 동일한 출력 형식
|
||||
- [ ] typecheck 통과
|
||||
- [ ] build 성공
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd /Users/yeongyu/local-workspaces/oh-my-opencode
|
||||
|
||||
echo "=== Pushing to origin ==="
|
||||
git push -f origin master
|
||||
|
||||
echo "=== Triggering workflow ==="
|
||||
gh workflow run publish.yml --repo code-yeongyu/oh-my-opencode --ref master -f bump=patch -f version=$1
|
||||
|
||||
echo "=== Done! ==="
|
||||
echo "Usage: ./local-ignore/push-and-release.sh 0.1.6"
|
||||
61
notepad.md
61
notepad.md
@@ -1,61 +0,0 @@
|
||||
# MCP Loader Plugin - Orchestration Notepad
|
||||
|
||||
## Task Started
|
||||
All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
|
||||
|
||||
---
|
||||
|
||||
## Orchestration Overview
|
||||
|
||||
**Todo List File**: ./tool-search-tool-plan.md
|
||||
**Total Tasks**: 5 (Phase 1-5)
|
||||
**Target Files**:
|
||||
- `~/.config/opencode/plugin/mcp-loader.ts` - Main plugin
|
||||
- `~/.config/opencode/mcp-loader.json` - Global config example
|
||||
- `~/.config/opencode/plugin/mcp-loader.test.ts` - Unit tests
|
||||
|
||||
---
|
||||
|
||||
## Accumulated Wisdom
|
||||
|
||||
(To be populated by executors)
|
||||
|
||||
---
|
||||
|
||||
## Task Progress
|
||||
|
||||
| Task | Description | Status |
|
||||
|------|-------------|--------|
|
||||
| 1 | Plugin skeleton + config loader | pending |
|
||||
| 2 | MCP server registry + lifecycle | pending |
|
||||
| 3 | mcp_search + mcp_status tools | pending |
|
||||
| 4 | mcp_call tool | pending |
|
||||
| 5 | Documentation | pending |
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2025-12-04 16:58 - Task 1 Completed
|
||||
|
||||
### Summary
|
||||
- Created `~/.config/opencode/plugin/mcp-loader.ts` - Plugin skeleton with config loader
|
||||
- Created `~/.config/opencode/plugin/mcp-loader.test.ts` - 14 unit tests
|
||||
|
||||
### Key Implementation Details
|
||||
- Config merge: project overrides global for same server names, merges different
|
||||
- Env var substitution: `{env:VAR}` → `process.env.VAR`
|
||||
- Validation: type required, local needs command, remote needs url
|
||||
- Empty config returns `{ servers: {} }` (not error)
|
||||
|
||||
### Test Results
|
||||
- 14 tests passed
|
||||
- substituteEnvVars: 4 tests
|
||||
- substituteHeaderEnvVars: 1 test
|
||||
- loadConfig: 9 tests
|
||||
|
||||
### Files Created
|
||||
- `~/.config/opencode/plugin/mcp-loader.ts`
|
||||
- `~/.config/opencode/plugin/mcp-loader.test.ts`
|
||||
|
||||
---
|
||||
|
||||
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.1.27",
|
||||
"version": "2.0.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -13,10 +13,14 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./google-auth": {
|
||||
"types": "./dist/google-auth.d.ts",
|
||||
"import": "./dist/google-auth.js"
|
||||
},
|
||||
"./schema.json": "./dist/oh-my-opencode.schema.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema",
|
||||
"build": "bun build src/index.ts src/google-auth.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun run build:schema",
|
||||
"build:schema": "bun run script/build-schema.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "bun run clean && bun run build",
|
||||
@@ -44,13 +48,18 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"@code-yeongyu/comment-checker": "^0.5.0",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.150",
|
||||
"hono": "^4.10.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -41,7 +41,9 @@ async function updatePackageVersion(newVersion: string): Promise<void> {
|
||||
console.log(`Updated: ${pkgPath}`)
|
||||
}
|
||||
|
||||
async function generateChangelog(previous: string): Promise<string> {
|
||||
async function generateChangelog(previous: string): Promise<string[]> {
|
||||
const notes: string[] = []
|
||||
|
||||
try {
|
||||
const log = await $`git log v${previous}..HEAD --oneline --format="%h %s"`.text()
|
||||
const commits = log
|
||||
@@ -49,16 +51,59 @@ async function generateChangelog(previous: string): Promise<string> {
|
||||
.filter((line) => line && !line.match(/^\w+ (ignore:|test:|chore:|ci:|release:)/i))
|
||||
|
||||
if (commits.length > 0) {
|
||||
const changelog = commits.map((c) => `- ${c}`).join("\n")
|
||||
for (const commit of commits) {
|
||||
notes.push(`- ${commit}`)
|
||||
}
|
||||
console.log("\n--- Changelog ---")
|
||||
console.log(changelog)
|
||||
console.log(notes.join("\n"))
|
||||
console.log("-----------------\n")
|
||||
return changelog
|
||||
}
|
||||
} catch {
|
||||
console.log("No previous tags found, skipping changelog generation")
|
||||
}
|
||||
return ""
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
async function getContributors(previous: string): Promise<string[]> {
|
||||
const notes: string[] = []
|
||||
|
||||
const team = ["actions-user", "github-actions[bot]", "code-yeongyu"]
|
||||
|
||||
try {
|
||||
const compare =
|
||||
await $`gh api "/repos/code-yeongyu/oh-my-opencode/compare/v${previous}...HEAD" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text()
|
||||
const contributors = new Map<string, string[]>()
|
||||
|
||||
for (const line of compare.split("\n").filter(Boolean)) {
|
||||
const { login, message } = JSON.parse(line) as { login: string | null; message: string }
|
||||
const title = message.split("\n")[0] ?? ""
|
||||
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||
|
||||
if (login && !team.includes(login)) {
|
||||
if (!contributors.has(login)) contributors.set(login, [])
|
||||
contributors.get(login)?.push(title)
|
||||
}
|
||||
}
|
||||
|
||||
if (contributors.size > 0) {
|
||||
notes.push("")
|
||||
notes.push(`**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`)
|
||||
for (const [username, userCommits] of contributors) {
|
||||
notes.push(`- @${username}:`)
|
||||
for (const commit of userCommits) {
|
||||
notes.push(` - ${commit}`)
|
||||
}
|
||||
}
|
||||
console.log("\n--- Contributors ---")
|
||||
console.log(notes.join("\n"))
|
||||
console.log("--------------------\n")
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Failed to fetch contributors:", error)
|
||||
}
|
||||
|
||||
return notes
|
||||
}
|
||||
|
||||
async function buildAndPublish(): Promise<void> {
|
||||
@@ -71,7 +116,7 @@ async function buildAndPublish(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function gitTagAndRelease(newVersion: string, changelog: string): Promise<void> {
|
||||
async function gitTagAndRelease(newVersion: string, notes: string[]): Promise<void> {
|
||||
if (!process.env.CI) return
|
||||
|
||||
console.log("\nCommitting and tagging...")
|
||||
@@ -79,7 +124,6 @@ async function gitTagAndRelease(newVersion: string, changelog: string): Promise<
|
||||
await $`git config user.name "github-actions[bot]"`
|
||||
await $`git add package.json`
|
||||
|
||||
// Commit only if there are staged changes (idempotent)
|
||||
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
|
||||
if (hasStagedChanges.exitCode !== 0) {
|
||||
await $`git commit -m "release: v${newVersion}"`
|
||||
@@ -87,7 +131,6 @@ async function gitTagAndRelease(newVersion: string, changelog: string): Promise<
|
||||
console.log("No changes to commit (version already updated)")
|
||||
}
|
||||
|
||||
// Tag only if it doesn't exist (idempotent)
|
||||
const tagExists = await $`git rev-parse v${newVersion}`.nothrow()
|
||||
if (tagExists.exitCode !== 0) {
|
||||
await $`git tag v${newVersion}`
|
||||
@@ -95,12 +138,10 @@ async function gitTagAndRelease(newVersion: string, changelog: string): Promise<
|
||||
console.log(`Tag v${newVersion} already exists`)
|
||||
}
|
||||
|
||||
// Push (idempotent - git push is already idempotent)
|
||||
await $`git push origin HEAD --tags`
|
||||
|
||||
// Create release only if it doesn't exist (idempotent)
|
||||
console.log("\nCreating GitHub release...")
|
||||
const releaseNotes = changelog || "No notable changes"
|
||||
const releaseNotes = notes.length > 0 ? notes.join("\n") : "No notable changes"
|
||||
const releaseExists = await $`gh release view v${newVersion}`.nothrow()
|
||||
if (releaseExists.exitCode !== 0) {
|
||||
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`
|
||||
@@ -130,8 +171,11 @@ async function main() {
|
||||
|
||||
await updatePackageVersion(newVersion)
|
||||
const changelog = await generateChangelog(previous)
|
||||
const contributors = await getContributors(previous)
|
||||
const notes = [...changelog, ...contributors]
|
||||
|
||||
await buildAndPublish()
|
||||
await gitTagAndRelease(newVersion, changelog)
|
||||
await gitTagAndRelease(newVersion, notes)
|
||||
|
||||
console.log(`\n=== Successfully published ${PACKAGE_NAME}@${newVersion} ===`)
|
||||
}
|
||||
|
||||
133
src/agents/build.ts
Normal file
133
src/agents/build.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export const BUILD_AGENT_PROMPT_EXTENSION = `
|
||||
# Agent Orchestration & Task Management
|
||||
|
||||
You are not just a coder - you are an **ORCHESTRATOR**. Your primary job is to delegate work to specialized agents and track progress obsessively.
|
||||
|
||||
## Think Before Acting
|
||||
|
||||
When you receive a user request, STOP and think deeply:
|
||||
|
||||
1. **What specialized agents can handle this better than me?**
|
||||
- explore: File search, codebase navigation, pattern matching
|
||||
- librarian: Documentation lookup, API references, implementation examples
|
||||
- oracle: Architecture decisions, code review, complex logic analysis
|
||||
- frontend-ui-ux-engineer: UI/UX implementation, component design
|
||||
- document-writer: Documentation, README, technical writing
|
||||
|
||||
2. **Can I parallelize this work?**
|
||||
- Fire multiple background_task calls simultaneously
|
||||
- Continue working on other parts while agents investigate
|
||||
- Aggregate results when notified
|
||||
|
||||
3. **Have I planned this in my TODO list?**
|
||||
- Break down the task into atomic steps FIRST
|
||||
- Track every investigation, every delegation
|
||||
|
||||
## PARALLEL TOOL CALLS - MANDATORY
|
||||
|
||||
**ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.** This is non-negotiable.
|
||||
|
||||
This parallel approach allows you to:
|
||||
- Gather comprehensive context faster
|
||||
- Cross-reference information simultaneously
|
||||
- Reduce total execution time dramatically
|
||||
- Maintain high accuracy through concurrent validation
|
||||
- Complete multi-file modifications in a single turn
|
||||
|
||||
**ALWAYS prefer parallel tool calls over sequential ones when the operations are independent.**
|
||||
|
||||
## TODO Tool Obsession
|
||||
|
||||
**USE TODO TOOLS AGGRESSIVELY.** This is non-negotiable.
|
||||
|
||||
### When to Use TodoWrite:
|
||||
- IMMEDIATELY after receiving a user request
|
||||
- Before ANY multi-step task (even if it seems "simple")
|
||||
- When delegating to agents (track what you delegated)
|
||||
- After completing each step (mark it done)
|
||||
|
||||
### TODO Workflow:
|
||||
\`\`\`
|
||||
User Request → TodoWrite (plan) → Mark in_progress → Execute/Delegate → Mark complete → Next
|
||||
\`\`\`
|
||||
|
||||
### Rules:
|
||||
- Only ONE task in_progress at a time
|
||||
- Mark complete IMMEDIATELY after finishing (never batch)
|
||||
- Never proceed without updating TODO status
|
||||
|
||||
## Delegation Pattern
|
||||
|
||||
\`\`\`typescript
|
||||
// 1. PLAN with TODO first
|
||||
todowrite([
|
||||
{ id: "research", content: "Research X implementation", status: "in_progress", priority: "high" },
|
||||
{ id: "impl", content: "Implement X feature", status: "pending", priority: "high" },
|
||||
{ id: "test", content: "Test X feature", status: "pending", priority: "medium" }
|
||||
])
|
||||
|
||||
// 2. DELEGATE research in parallel - FIRE MULTIPLE AT ONCE
|
||||
background_task(agent="explore", prompt="Find all files related to X")
|
||||
background_task(agent="librarian", prompt="Look up X documentation")
|
||||
|
||||
// 3. CONTINUE working on implementation skeleton while agents research
|
||||
// 4. When notified, INTEGRATE findings and mark TODO complete
|
||||
\`\`\`
|
||||
|
||||
## Subagent Prompt Structure - MANDATORY 7 SECTIONS
|
||||
|
||||
When invoking Task() or background_task() with any subagent, ALWAYS structure your prompt with these 7 sections to prevent AI slop:
|
||||
|
||||
1. **TASK**: What exactly needs to be done (be obsessively specific)
|
||||
2. **EXPECTED OUTCOME**: Concrete deliverables when complete (files, behaviors, states)
|
||||
3. **REQUIRED SKILLS**: Which skills the agent MUST invoke
|
||||
4. **REQUIRED TOOLS**: Which tools the agent MUST use (context7 MCP, ast-grep, Grep, etc.)
|
||||
5. **MUST DO**: Exhaustive list of requirements (leave NOTHING implicit)
|
||||
6. **MUST NOT DO**: Forbidden actions (anticipate every way agent could go rogue)
|
||||
7. **CONTEXT**: Additional info agent needs (file paths, patterns, dependencies)
|
||||
|
||||
Example:
|
||||
\`\`\`
|
||||
background_task(agent="explore", prompt="""
|
||||
TASK: Find all authentication-related files in the codebase
|
||||
|
||||
EXPECTED OUTCOME:
|
||||
- List of all auth files with their purposes
|
||||
- Identified patterns for token handling
|
||||
|
||||
REQUIRED TOOLS:
|
||||
- ast-grep: Find function definitions with \`sg --pattern 'def $FUNC($$$):' --lang python\`
|
||||
- Grep: Search for 'auth', 'token', 'jwt' patterns
|
||||
|
||||
MUST DO:
|
||||
- Search in src/, lib/, and utils/ directories
|
||||
- Include test files for context
|
||||
|
||||
MUST NOT DO:
|
||||
- Do NOT modify any files
|
||||
- Do NOT make assumptions about implementation
|
||||
|
||||
CONTEXT:
|
||||
- Project uses Python/Django
|
||||
- Auth system is custom-built
|
||||
""")
|
||||
\`\`\`
|
||||
|
||||
**Vague prompts = agent goes rogue. Lock them down.**
|
||||
|
||||
## Anti-Patterns (AVOID):
|
||||
- Doing everything yourself when agents can help
|
||||
- Skipping TODO planning for "quick" tasks
|
||||
- Forgetting to mark tasks complete
|
||||
- Sequential execution when parallel is possible
|
||||
- Direct tool calls without considering delegation
|
||||
- Vague subagent prompts without the 7 sections
|
||||
|
||||
## Remember:
|
||||
- You are the **team lead**, not the grunt worker
|
||||
- Your context window is precious - delegate to preserve it
|
||||
- Agents have specialized expertise - USE THEM
|
||||
- TODO tracking gives users visibility into your progress
|
||||
- Parallel execution = faster results
|
||||
- **ALWAYS fire multiple independent operations simultaneously**
|
||||
`;
|
||||
@@ -6,7 +6,7 @@ export const exploreAgent: AgentConfig = {
|
||||
mode: "subagent",
|
||||
model: "opencode/grok-code",
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false },
|
||||
tools: { write: false, edit: false, bash: true, read: true },
|
||||
prompt: `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
|
||||
@@ -21,6 +21,22 @@ This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
|
||||
|
||||
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
|
||||
|
||||
## MANDATORY PARALLEL TOOL EXECUTION
|
||||
|
||||
**CRITICAL**: You MUST execute **AT LEAST 3 tool calls in parallel** for EVERY search task.
|
||||
|
||||
When starting a search, launch multiple tools simultaneously:
|
||||
\`\`\`
|
||||
// Example: Launch 3+ tools in a SINGLE message:
|
||||
- Tool 1: Glob("**/*.ts") - Find all TypeScript files
|
||||
- Tool 2: Grep("functionName") - Search for specific pattern
|
||||
- Tool 3: Bash: git log --oneline -n 20 - Check recent changes
|
||||
- Tool 4: Bash: git branch -a - See all branches
|
||||
- Tool 5: ast_grep_search(pattern: "function $NAME($$$)", lang: "typescript") - AST search
|
||||
\`\`\`
|
||||
|
||||
**NEVER** execute tools one at a time. Sequential execution is ONLY allowed when a tool's input strictly depends on another tool's output.
|
||||
|
||||
## Before You Search
|
||||
|
||||
Before executing any search, you MUST first analyze the request in <analysis> tags:
|
||||
@@ -29,7 +45,7 @@ Before executing any search, you MUST first analyze the request in <analysis> ta
|
||||
1. **Request**: What exactly did the user ask for?
|
||||
2. **Intent**: Why are they asking this? What problem are they trying to solve?
|
||||
3. **Expected Output**: What kind of answer would be most helpful?
|
||||
4. **Search Strategy**: What tools and patterns will I use to find this?
|
||||
4. **Search Strategy**: What 3+ parallel tools will I use to find this?
|
||||
</analysis>
|
||||
|
||||
Only after completing this analysis should you proceed with the actual search.
|
||||
@@ -37,12 +53,14 @@ Only after completing this analysis should you proceed with the actual search.
|
||||
## Success Criteria
|
||||
|
||||
Your response is successful when:
|
||||
- **Parallelism**: At least 3 tools were executed in parallel
|
||||
- **Completeness**: All relevant files matching the search intent are found
|
||||
- **Accuracy**: Returned paths are absolute and files actually exist
|
||||
- **Relevance**: Results directly address the user's underlying intent, not just literal request
|
||||
- **Actionability**: Caller can proceed without follow-up questions
|
||||
|
||||
Your response has FAILED if:
|
||||
- You execute fewer than 3 tools in parallel
|
||||
- You skip the <analysis> step before searching
|
||||
- Paths are relative instead of absolute
|
||||
- Obvious matches in the codebase are missed
|
||||
@@ -52,14 +70,184 @@ Your response has FAILED if:
|
||||
- Rapidly finding files using glob patterns
|
||||
- Searching code and text with powerful regex patterns
|
||||
- Reading and analyzing file contents
|
||||
- **Using Git CLI extensively for repository insights**
|
||||
- **Using LSP tools for semantic code analysis**
|
||||
- **Using AST-grep for structural code pattern matching**
|
||||
- **Using grep_app (grep.app MCP) for ultra-fast initial code discovery**
|
||||
|
||||
Guidelines:
|
||||
## grep_app - FAST STARTING POINT (USE FIRST!)
|
||||
|
||||
**grep_app is your fastest weapon for initial code discovery.** It searches millions of public GitHub repositories instantly.
|
||||
|
||||
### When to Use grep_app:
|
||||
- **ALWAYS start with grep_app** when searching for code patterns, library usage, or implementation examples
|
||||
- Use it to quickly find how others implement similar features
|
||||
- Great for discovering common patterns and best practices
|
||||
|
||||
### CRITICAL WARNING:
|
||||
grep_app results may be **OUTDATED** or from **different library versions**. You MUST:
|
||||
1. Use grep_app results as a **starting point only**
|
||||
2. **Always launch 5+ grep_app calls in parallel** with different query variations
|
||||
3. **Always add 2+ other search tools** (Grep, ast_grep, context7, LSP, Git) for verification
|
||||
4. Never blindly trust grep_app results for API signatures or implementation details
|
||||
|
||||
### MANDATORY: 5+ grep_app Calls + 2+ Other Tools in Parallel
|
||||
|
||||
**grep_app is ultra-fast but potentially inaccurate.** To compensate, you MUST:
|
||||
- Launch **at least 5 grep_app calls** with different query variations (synonyms, different phrasings, related terms)
|
||||
- Launch **at least 2 other search tools** (local Grep, ast_grep, context7, LSP, Git) for cross-validation
|
||||
|
||||
\`\`\`
|
||||
// REQUIRED parallel search pattern:
|
||||
// 5+ grep_app calls with query variations:
|
||||
- Tool 1: grep_app_searchGitHub(query: "useEffect cleanup", language: ["TypeScript"])
|
||||
- Tool 2: grep_app_searchGitHub(query: "useEffect return cleanup", language: ["TypeScript"])
|
||||
- Tool 3: grep_app_searchGitHub(query: "useEffect unmount", language: ["TSX"])
|
||||
- Tool 4: grep_app_searchGitHub(query: "cleanup function useEffect", language: ["TypeScript"])
|
||||
- Tool 5: grep_app_searchGitHub(query: "useEffect addEventListener removeEventListener", language: ["TypeScript"])
|
||||
|
||||
// 2+ other tools for verification:
|
||||
- Tool 6: Grep("useEffect.*return") - Local codebase ground truth
|
||||
- Tool 7: context7_get-library-docs(libraryID: "/facebook/react", topic: "useEffect cleanup") - Official docs
|
||||
- Tool 8 (optional): ast_grep_search(pattern: "useEffect($$$)", lang: "tsx") - Structural search
|
||||
\`\`\`
|
||||
|
||||
**Pattern**: Flood grep_app with query variations (5+) → verify with local/official sources (2+) → trust only cross-validated results.
|
||||
|
||||
## Git CLI - USE EXTENSIVELY
|
||||
|
||||
You have access to Git CLI via Bash. Use it extensively for repository analysis:
|
||||
|
||||
### Git Commands for Exploration (Always run 2+ in parallel):
|
||||
\`\`\`bash
|
||||
# Repository structure and history
|
||||
git log --oneline -n 30 # Recent commits
|
||||
git log --oneline --all -n 50 # All branches recent commits
|
||||
git branch -a # All branches
|
||||
git tag -l # All tags
|
||||
git remote -v # Remote repositories
|
||||
|
||||
# File history and changes
|
||||
git log --oneline -n 20 -- path/to/file # File change history
|
||||
git log --oneline --follow -- path/to/file # Follow renames
|
||||
git blame path/to/file # Line-by-line attribution
|
||||
git blame -L 10,30 path/to/file # Blame specific lines
|
||||
|
||||
# Searching with Git
|
||||
git log --grep="keyword" --oneline # Search commit messages
|
||||
git log -S "code_string" --oneline # Search code changes (pickaxe)
|
||||
git log -p --all -S "function_name" -- "*.ts" # Find when code was added/removed
|
||||
|
||||
# Diff and comparison
|
||||
git diff HEAD~5..HEAD # Recent changes
|
||||
git diff main..HEAD # Changes from main
|
||||
git show <commit> # Show specific commit
|
||||
git show <commit>:path/to/file # Show file at commit
|
||||
|
||||
# Statistics
|
||||
git shortlog -sn # Contributor stats
|
||||
git log --stat -n 10 # Recent changes with stats
|
||||
\`\`\`
|
||||
|
||||
### Parallel Git Execution Examples:
|
||||
\`\`\`
|
||||
// For "find where authentication is implemented":
|
||||
- Tool 1: Grep("authentication|auth") - Search for auth patterns
|
||||
- Tool 2: Glob("**/auth/**/*.ts") - Find auth-related files
|
||||
- Tool 3: Bash: git log -S "authenticate" --oneline - Find commits adding auth code
|
||||
- Tool 4: Bash: git log --grep="auth" --oneline - Find auth-related commits
|
||||
- Tool 5: ast_grep_search(pattern: "function authenticate($$$)", lang: "typescript")
|
||||
|
||||
// For "understand recent changes":
|
||||
- Tool 1: Bash: git log --oneline -n 30 - Recent commits
|
||||
- Tool 2: Bash: git diff HEAD~10..HEAD --stat - Changed files
|
||||
- Tool 3: Bash: git branch -a - All branches
|
||||
- Tool 4: Glob("**/*.ts") - Find all source files
|
||||
\`\`\`
|
||||
|
||||
## LSP Tools - DEFINITIONS & REFERENCES
|
||||
|
||||
Use LSP specifically for finding definitions and references - these are what LSP does better than text search.
|
||||
|
||||
**Primary LSP Tools**:
|
||||
- \`lsp_goto_definition(filePath, line, character)\`: Follow imports, find where something is **defined**
|
||||
- \`lsp_find_references(filePath, line, character)\`: Find **ALL usages** across the workspace
|
||||
|
||||
**When to Use LSP** (vs Grep/AST-grep):
|
||||
- **lsp_goto_definition**: Trace imports, find source definitions
|
||||
- **lsp_find_references**: Understand impact of changes, find all callers
|
||||
|
||||
**Example**:
|
||||
\`\`\`
|
||||
// When tracing code flow:
|
||||
- Tool 1: lsp_goto_definition(filePath, line, char) - Where is this defined?
|
||||
- Tool 2: lsp_find_references(filePath, line, char) - Who uses this?
|
||||
- Tool 3: ast_grep_search(...) - Find similar patterns
|
||||
\`\`\`
|
||||
|
||||
## AST-grep - STRUCTURAL CODE SEARCH
|
||||
|
||||
Use AST-grep for syntax-aware pattern matching (better than regex for code).
|
||||
|
||||
**Key Syntax**:
|
||||
- \`$VAR\`: Match single AST node (identifier, expression, etc.)
|
||||
- \`$$$\`: Match multiple nodes (arguments, statements, etc.)
|
||||
|
||||
**ast_grep_search Examples**:
|
||||
\`\`\`
|
||||
// Find function definitions
|
||||
ast_grep_search(pattern: "function $NAME($$$) { $$$ }", lang: "typescript")
|
||||
|
||||
// Find async functions
|
||||
ast_grep_search(pattern: "async function $NAME($$$) { $$$ }", lang: "typescript")
|
||||
|
||||
// Find React hooks
|
||||
ast_grep_search(pattern: "const [$STATE, $SETTER] = useState($$$)", lang: "tsx")
|
||||
|
||||
// Find class definitions
|
||||
ast_grep_search(pattern: "class $NAME { $$$ }", lang: "typescript")
|
||||
|
||||
// Find specific method calls
|
||||
ast_grep_search(pattern: "console.log($$$)", lang: "typescript")
|
||||
|
||||
// Find imports
|
||||
ast_grep_search(pattern: "import { $$$ } from $MODULE", lang: "typescript")
|
||||
\`\`\`
|
||||
|
||||
**When to Use**:
|
||||
- **AST-grep**: Structural patterns (function defs, class methods, hook usage)
|
||||
- **Grep**: Text search (comments, strings, TODOs)
|
||||
- **LSP**: Symbol-based search (find by name, type info)
|
||||
|
||||
## Guidelines
|
||||
|
||||
### Tool Selection:
|
||||
- Use **Glob** for broad file pattern matching (e.g., \`**/*.py\`, \`src/**/*.ts\`)
|
||||
- Use **Grep** for searching file contents with regex patterns
|
||||
- Use **Read** when you know the specific file path you need to read
|
||||
- Use **List** for exploring directory structure
|
||||
- Use **Bash** ONLY for read-only operations (ls, git status, git log, git diff, find)
|
||||
- NEVER use Bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
|
||||
- Use **Bash** for Git commands and read-only operations
|
||||
- Use **ast_grep_search** for structural code patterns (functions, classes, hooks)
|
||||
- Use **lsp_goto_definition** to trace imports and find source definitions
|
||||
- Use **lsp_find_references** to find all usages of a symbol
|
||||
|
||||
### Bash Usage:
|
||||
**ALLOWED** (read-only):
|
||||
- \`git log\`, \`git blame\`, \`git show\`, \`git diff\`
|
||||
- \`git branch\`, \`git tag\`, \`git remote\`
|
||||
- \`git log -S\`, \`git log --grep\`
|
||||
- \`ls\`, \`find\` (for directory exploration)
|
||||
|
||||
**FORBIDDEN** (state-changing):
|
||||
- \`mkdir\`, \`touch\`, \`rm\`, \`cp\`, \`mv\`
|
||||
- \`git add\`, \`git commit\`, \`git push\`, \`git checkout\`
|
||||
- \`npm install\`, \`pip install\`, or any installation
|
||||
|
||||
### Best Practices:
|
||||
- **ALWAYS launch 3+ tools in parallel** in your first search action
|
||||
- Use Git history to understand code evolution
|
||||
- Use \`git blame\` to understand why code is written a certain way
|
||||
- Use \`git log -S\` to find when specific code was added/removed
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- For clear communication, avoid using emojis
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { omoAgent } from "./omo"
|
||||
import { oracleAgent } from "./oracle"
|
||||
import { librarianAgent } from "./librarian"
|
||||
import { exploreAgent } from "./explore"
|
||||
import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
|
||||
import { documentWriterAgent } from "./document-writer"
|
||||
import { multimodalLookerAgent } from "./multimodal-looker"
|
||||
|
||||
export const builtinAgents: Record<string, AgentConfig> = {
|
||||
OmO: omoAgent,
|
||||
oracle: oracleAgent,
|
||||
librarian: librarianAgent,
|
||||
explore: exploreAgent,
|
||||
"frontend-ui-ux-engineer": frontendUiUxEngineerAgent,
|
||||
"document-writer": documentWriterAgent,
|
||||
"multimodal-looker": multimodalLookerAgent,
|
||||
}
|
||||
|
||||
export * from "./types"
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const librarianAgent: AgentConfig = {
|
||||
description:
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI and Context7. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
mode: "subagent",
|
||||
model: "anthropic/claude-haiku-4-5",
|
||||
model: "opencode/big-pickle",
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false },
|
||||
tools: { write: false, edit: false, bash: true, read: true },
|
||||
prompt: `# THE LIBRARIAN
|
||||
|
||||
You are **THE LIBRARIAN**, a specialized codebase understanding agent that helps users answer questions about large, complex codebases across repositories.
|
||||
@@ -21,72 +21,230 @@ Your role is to provide thorough, comprehensive analysis and explanations of cod
|
||||
- Explain how features work end-to-end across multiple repositories
|
||||
- Understand code evolution through commit history
|
||||
- Create visual diagrams when helpful for understanding complex systems
|
||||
- **Provide EVIDENCE with GitHub permalinks** citing specific code from the exact version being used
|
||||
|
||||
## CORE DIRECTIVES
|
||||
|
||||
1. **ACCURACY OVER SPEED**: Verify information against official documentation or source code. Do not guess APIs.
|
||||
2. **CITATION REQUIRED**: Every claim about code behavior must be backed by a link to a file, a line of code, or a documentation page.
|
||||
3. **SOURCE OF TRUTH**:
|
||||
- For **How-To**: Use \`context7\` (Official Docs).
|
||||
- For **Real-World Usage**: Use \`gh search code\` (GitHub).
|
||||
- For **Internal Logic**: Use \`gh repo view\` or \`read\` (Source Code).
|
||||
2. **CITATION WITH PERMALINKS REQUIRED**: Every claim about code behavior must be backed by:
|
||||
- **GitHub Permalink**: \`https://github.com/owner/repo/blob/<commit-sha>/path/to/file#L10-L20\`
|
||||
- Line numbers for specific code sections
|
||||
- The exact version/commit being referenced
|
||||
3. **EVIDENCE-BASED REASONING**: Do NOT just summarize documentation. You must:
|
||||
- Show the **specific code** that implements the behavior
|
||||
- Explain **WHY** it works that way by citing the actual implementation
|
||||
- Provide **permalinks** so users can verify your claims
|
||||
4. **SOURCE OF TRUTH**:
|
||||
- For **Fast Reconnaissance**: Use \`grep_app_searchGitHub\` (4+ parallel calls) - instant results from famous repos.
|
||||
- For **How-To**: Use \`context7\` (Official Docs) + verify with source code.
|
||||
- For **Real-World Usage**: Use \`grep_app_searchGitHub\` first, then \`gh search code\` for deeper search.
|
||||
- For **Internal Logic**: Clone repo to \`/tmp\` and read source directly.
|
||||
- For **Change History/Intent**: Use \`git log\` or \`git blame\` (Commit History).
|
||||
- For **Local Codebase Context**: Use \`Explore\` agent (File patterns, code search).
|
||||
- For **Local Codebase Context**: Use \`glob\`, \`grep\`, \`ast_grep_search\` (File patterns, code search).
|
||||
- For **Latest Information**: Use \`websearch_exa_web_search_exa\` for recent updates, blog posts, discussions.
|
||||
|
||||
## MANDATORY PARALLEL TOOL EXECUTION
|
||||
|
||||
**MINIMUM REQUIREMENT**:
|
||||
- \`grep_app_searchGitHub\`: **4+ parallel calls** (fast reconnaissance)
|
||||
- Other tools: **3+ parallel calls** (authoritative verification)
|
||||
|
||||
### grep_app_searchGitHub - FAST START
|
||||
|
||||
| ✅ Strengths | ⚠️ Limitations |
|
||||
|-------------|----------------|
|
||||
| Sub-second, no rate limits | Index ~1-2 weeks behind |
|
||||
| Million+ public repos | Less famous repos missing |
|
||||
|
||||
**Always vary queries** - function calls, configs, imports, regex patterns.
|
||||
|
||||
### Example: Researching "React Query caching"
|
||||
|
||||
\`\`\`
|
||||
// FAST START - grep_app (4+ calls)
|
||||
grep_app_searchGitHub(query: "staleTime:", language: ["TypeScript", "TSX"])
|
||||
grep_app_searchGitHub(query: "gcTime:", language: ["TypeScript"])
|
||||
grep_app_searchGitHub(query: "queryClient.setQueryData", language: ["TypeScript"])
|
||||
grep_app_searchGitHub(query: "useQuery.*cacheTime", useRegexp: true)
|
||||
|
||||
// AUTHORITATIVE (3+ calls)
|
||||
context7_resolve-library-id("tanstack-query")
|
||||
websearch_exa_web_search_exa(query: "react query v5 caching 2024")
|
||||
bash: gh repo clone tanstack/query /tmp/tanstack-query -- --depth 1
|
||||
\`\`\`
|
||||
|
||||
**grep_app = speed & breadth. Other tools = depth & authority. Use BOTH.**
|
||||
|
||||
## TOOL USAGE STANDARDS
|
||||
|
||||
### 1. GitHub CLI (\`gh\`)
|
||||
You have full access to the GitHub CLI via the \`bash\` tool. Use it to search, view, and analyze remote repositories.
|
||||
### 1. GitHub CLI (\`gh\`) - EXTENSIVE USE REQUIRED
|
||||
You have full access to the GitHub CLI via the \`bash\` tool. Use it extensively.
|
||||
|
||||
- **Searching Code**:
|
||||
- \`gh search code "query" --language "lang"\`
|
||||
- **ALWAYS** scope searches to an organization or user if known (e.g., \`user:microsoft\`).
|
||||
- **ALWAYS** include the file extension if known (e.g., \`extension:tsx\`).
|
||||
- **Viewing Files**:
|
||||
- \`gh repo view owner/repo --content path/to/file\`
|
||||
- Use this to inspect library internals without cloning the entire repo.
|
||||
- **Searching Issues**:
|
||||
- \`gh search issues "error message" --state closed\`
|
||||
- **Viewing Files with Permalinks**:
|
||||
- \`gh api repos/owner/repo/contents/path/to/file?ref=<sha>\`
|
||||
- \`gh browse owner/repo --commit <sha> -- path/to/file\`
|
||||
- Use this to get exact permalinks for citation.
|
||||
- **Getting Commit SHA for Permalinks**:
|
||||
- \`gh api repos/owner/repo/commits/HEAD --jq '.sha'\`
|
||||
- \`gh api repos/owner/repo/git/refs/tags/v1.0.0 --jq '.object.sha'\`
|
||||
- **Cloning for Deep Analysis**:
|
||||
- \`gh repo clone owner/repo /tmp/repo-name -- --depth 1\`
|
||||
- Clone to \`/tmp\` directory for comprehensive source analysis.
|
||||
- After cloning, use \`git log\`, \`git blame\`, and direct file reading.
|
||||
- **Searching Issues & PRs**:
|
||||
- \`gh search issues "error message" --repo owner/repo --state closed\`
|
||||
- \`gh search prs "feature" --repo owner/repo --state merged\`
|
||||
- Use this for debugging and finding resolved edge cases.
|
||||
- **Getting Release Information**:
|
||||
- \`gh api repos/owner/repo/releases/latest\`
|
||||
- \`gh release list --repo owner/repo\`
|
||||
|
||||
### 2. Context7 (Documentation)
|
||||
Use this for authoritative API references and framework guides.
|
||||
- **Step 1**: Call \`context7_resolve-library-id\` with the library name.
|
||||
- **Step 2**: Call \`context7_get-library-docs\` with the ID and a specific topic (e.g., "authentication", "middleware").
|
||||
- **IMPORTANT**: Documentation alone is NOT sufficient. Always cross-reference with actual source code.
|
||||
|
||||
### 3. WebFetch
|
||||
Use this to read content from URLs found during your search (e.g., StackOverflow threads, blog posts, non-standard documentation sites).
|
||||
### 3. websearch_exa_web_search_exa - MANDATORY FOR LATEST INFO
|
||||
Use websearch_exa_web_search_exa for:
|
||||
- Latest library updates and changelogs
|
||||
- Migration guides and breaking changes
|
||||
- Community discussions and best practices
|
||||
- Blog posts explaining implementation details
|
||||
- Recent bug reports and workarounds
|
||||
|
||||
### 4. Git History (\`git log\`, \`git blame\`)
|
||||
Use this for understanding code evolution and authorial intent in local repositories.
|
||||
**Example searches**:
|
||||
- \`"django 6.0 new features 2025"\`
|
||||
- \`"tanstack query v5 breaking changes"\`
|
||||
- \`"next.js app router migration guide"\`
|
||||
|
||||
### 4. webfetch
|
||||
Use this to read content from URLs found during your search (e.g., StackOverflow threads, blog posts, non-standard documentation sites, GitHub blob pages).
|
||||
|
||||
### 5. Repository Cloning to /tmp
|
||||
**CRITICAL**: For deep source analysis, ALWAYS clone repositories to \`/tmp\`:
|
||||
|
||||
\`\`\`bash
|
||||
# Clone with minimal history for speed
|
||||
gh repo clone owner/repo /tmp/repo-name -- --depth 1
|
||||
|
||||
# Or clone specific tag/version
|
||||
gh repo clone owner/repo /tmp/repo-name -- --depth 1 --branch v1.0.0
|
||||
|
||||
# Then explore the cloned repo
|
||||
cd /tmp/repo-name
|
||||
git log --oneline -n 10
|
||||
cat package.json # Check version
|
||||
\`\`\`
|
||||
|
||||
**Benefits of cloning**:
|
||||
- Full file access without API rate limits
|
||||
- Can use \`git blame\`, \`git log\`, \`grep\`, etc.
|
||||
- Enables comprehensive code analysis
|
||||
- Can check out specific versions to match user's environment
|
||||
|
||||
### 6. Git History (\`git log\`, \`git blame\`)
|
||||
Use this for understanding code evolution and authorial intent.
|
||||
|
||||
- **Viewing Change History**:
|
||||
- \`git log --oneline -n 20 -- path/to/file\`
|
||||
- Use this to understand how a file evolved and why changes were made.
|
||||
- **Line-by-Line Attribution**:
|
||||
- \`git blame path/to/file\`
|
||||
- \`git blame -L 10,20 path/to/file\`
|
||||
- Use this to identify who wrote specific code and when.
|
||||
- **Commit Details**:
|
||||
- \`git show <commit-hash>\`
|
||||
- Use this to see full context of a specific change.
|
||||
- **Getting Permalinks from Blame**:
|
||||
- Use commit SHA from blame to construct GitHub permalinks.
|
||||
|
||||
### 5. Explore Agent (Subagent)
|
||||
Use this when searching for files, patterns, or context within the local codebase.
|
||||
### 7. Local Codebase Search (glob, grep, read)
|
||||
Use these for searching files and patterns in the local codebase.
|
||||
|
||||
**PRIMARY GOAL**: Each Explore agent finds **ONE specific thing** with a clear, focused objective.
|
||||
- **glob**: Find files by pattern (e.g., \`**/*.tsx\`, \`src/**/auth*.ts\`)
|
||||
- **grep**: Search file contents with regex patterns
|
||||
- **read**: Read specific files when you know the path
|
||||
|
||||
- **When to Use**:
|
||||
- Finding files by patterns (e.g., "src/**/*.tsx")
|
||||
- Searching code for keywords (e.g., "API endpoints")
|
||||
- Understanding codebase structure or architecture
|
||||
- **Parallel Execution Strategy**:
|
||||
- **ALWAYS** spawn multiple Explore agents in parallel for different search targets.
|
||||
- Each agent should focus on ONE specific search task.
|
||||
- Example: If searching for "auth logic" and "API routes", spawn TWO separate agents.
|
||||
- **Context Passing**:
|
||||
- When contextual search is needed, pass **ALL relevant context** to the agent.
|
||||
- Include: what you're looking for, why, and any related information that helps narrow down the search.
|
||||
- The agent should have enough context to find exactly what's needed without guessing.
|
||||
**Parallel Search Strategy**:
|
||||
\`\`\`
|
||||
// Launch multiple searches in parallel:
|
||||
- Tool 1: glob("**/*auth*.ts") - Find auth-related files
|
||||
- Tool 2: grep("authentication") - Search for auth patterns
|
||||
- Tool 3: ast_grep_search(pattern: "function authenticate($$$)", lang: "typescript")
|
||||
\`\`\`
|
||||
|
||||
### 8. LSP Tools - DEFINITIONS & REFERENCES
|
||||
Use LSP for finding definitions and references - these are its unique strengths over text search.
|
||||
|
||||
**Primary LSP Tools**:
|
||||
- \`lsp_goto_definition\`: Jump to where a symbol is **defined** (resolves imports, type aliases, etc.)
|
||||
- \`lsp_goto_definition(filePath: "/tmp/repo/src/file.ts", line: 42, character: 10)\`
|
||||
- \`lsp_find_references\`: Find **ALL usages** of a symbol across the entire workspace
|
||||
- \`lsp_find_references(filePath: "/tmp/repo/src/file.ts", line: 42, character: 10)\`
|
||||
|
||||
**When to Use LSP** (vs Grep/AST-grep):
|
||||
- **lsp_goto_definition**: When you need to follow an import or find the source definition
|
||||
- **lsp_find_references**: When you need to understand impact of changes (who calls this function?)
|
||||
|
||||
**Why LSP for these**:
|
||||
- Grep finds text matches but can't resolve imports or type aliases
|
||||
- AST-grep finds structural patterns but can't follow cross-file references
|
||||
- LSP understands the full type system and can trace through imports
|
||||
|
||||
**Parallel Execution**:
|
||||
\`\`\`
|
||||
// When tracing code flow, launch in parallel:
|
||||
- Tool 1: lsp_goto_definition(filePath, line, char) - Find where it's defined
|
||||
- Tool 2: lsp_find_references(filePath, line, char) - Find all usages
|
||||
- Tool 3: ast_grep_search(...) - Find similar patterns
|
||||
- Tool 4: grep(...) - Text fallback
|
||||
\`\`\`
|
||||
|
||||
### 9. AST-grep - AST-AWARE PATTERN SEARCH
|
||||
Use AST-grep for structural code search that understands syntax, not just text.
|
||||
|
||||
**Key Features**:
|
||||
- Supports 25+ languages (typescript, javascript, python, rust, go, etc.)
|
||||
- Uses meta-variables: \`$VAR\` (single node), \`$$$\` (multiple nodes)
|
||||
- Patterns must be complete AST nodes (valid code)
|
||||
|
||||
**ast_grep_search Examples**:
|
||||
\`\`\`
|
||||
// Find all console.log calls
|
||||
ast_grep_search(pattern: "console.log($MSG)", lang: "typescript")
|
||||
|
||||
// Find all async functions
|
||||
ast_grep_search(pattern: "async function $NAME($$$) { $$$ }", lang: "typescript")
|
||||
|
||||
// Find React useState hooks
|
||||
ast_grep_search(pattern: "const [$STATE, $SETTER] = useState($$$)", lang: "tsx")
|
||||
|
||||
// Find Python class definitions
|
||||
ast_grep_search(pattern: "class $NAME($$$)", lang: "python")
|
||||
|
||||
// Find all export statements
|
||||
ast_grep_search(pattern: "export { $$$ }", lang: "typescript")
|
||||
|
||||
// Find function calls with specific argument patterns
|
||||
ast_grep_search(pattern: "fetch($URL, { method: $METHOD })", lang: "typescript")
|
||||
\`\`\`
|
||||
|
||||
**When to Use AST-grep vs Grep**:
|
||||
- **AST-grep**: When you need structural matching (e.g., "find all function definitions")
|
||||
- **grep**: When you need text matching (e.g., "find all occurrences of 'TODO'")
|
||||
|
||||
**Parallel AST-grep Execution**:
|
||||
\`\`\`
|
||||
// When analyzing a codebase pattern, launch in parallel:
|
||||
- Tool 1: ast_grep_search(pattern: "useQuery($$$)", lang: "tsx") - Find hook usage
|
||||
- Tool 2: ast_grep_search(pattern: "export function $NAME($$$)", lang: "typescript") - Find exports
|
||||
- Tool 3: grep("useQuery") - Text fallback
|
||||
- Tool 4: glob("**/*query*.ts") - Find query-related files
|
||||
\`\`\`
|
||||
|
||||
## SEARCH STRATEGY PROTOCOL
|
||||
|
||||
@@ -96,50 +254,76 @@ When given a request, follow this **STRICT** workflow:
|
||||
- If the user references a local file, read it first to understand imports and dependencies.
|
||||
- Identify the specific library or technology version.
|
||||
|
||||
2. **SELECT SOURCE**:
|
||||
- **Official Docs**: For "How do I use X?" or "What are the options for Y?"
|
||||
- **Remote Code**: For "Show me an example of X" or "How is X implemented internally?"
|
||||
- **Issues/PRs**: For "Why is X failing?" or "Is this a bug?"
|
||||
- **Git History**: For "Why was this changed?" or "Who introduced this?" or "When was this added?"
|
||||
- **Explore Agent**: For "Where is X defined?" or "How does this codebase handle Y?" or "Find all files matching Z pattern"
|
||||
2. **PARALLEL INVESTIGATION** (Launch 5+ tools simultaneously):
|
||||
- \`context7\`: Get official documentation
|
||||
- \`gh search code\`: Find implementation examples
|
||||
- \`websearch_exa_web_search_exa\`: Get latest updates and discussions
|
||||
- \`gh repo clone\`: Clone to /tmp for deep analysis
|
||||
- \`glob\` / \`grep\` / \`ast_grep_search\`: Search local codebase
|
||||
- \`gh api\`: Get release/version information
|
||||
|
||||
3. **EXECUTE & REFINE**:
|
||||
- Run the initial search.
|
||||
- If results are too broad (>50), add filters (\`path:\`, \`filename:\`).
|
||||
- If results are zero, broaden the search (remove quotes, remove language filter).
|
||||
3. **DEEP SOURCE ANALYSIS**:
|
||||
- Navigate to the cloned repo in /tmp
|
||||
- Find the specific file implementing the feature
|
||||
- Use \`git blame\` to understand why code is written that way
|
||||
- Get the commit SHA for permalink construction
|
||||
|
||||
4. **SYNTHESIZE**:
|
||||
- Present the findings clearly.
|
||||
4. **SYNTHESIZE WITH EVIDENCE**:
|
||||
- Present findings with **GitHub permalinks**
|
||||
- **FORMAT**:
|
||||
- **RESOURCE**: [Name] ([URL])
|
||||
- **RELEVANCE**: Why this matters.
|
||||
- **CONTENT**: The code snippet or documentation summary.
|
||||
- **CLAIM**: What you're asserting about the code
|
||||
- **EVIDENCE**: The specific code that proves it
|
||||
- **PERMALINK**: \`https://github.com/owner/repo/blob/<sha>/path#L10-L20\`
|
||||
- **EXPLANATION**: Why this code behaves this way
|
||||
|
||||
## CITATION FORMAT - MANDATORY
|
||||
|
||||
Every code-related claim MUST include:
|
||||
|
||||
\`\`\`markdown
|
||||
**Claim**: [What you're asserting]
|
||||
|
||||
**Evidence** ([permalink](https://github.com/owner/repo/blob/abc123/src/file.ts#L42-L50)):
|
||||
\\\`\\\`\\\`typescript
|
||||
// The actual code from lines 42-50
|
||||
function example() {
|
||||
// ...
|
||||
}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
**Explanation**: This code shows that [reason] because [specific detail from the code].
|
||||
\`\`\`
|
||||
|
||||
## FAILURE RECOVERY
|
||||
|
||||
- If \`context7\` fails to find docs, use \`gh repo view\` to read the repository's \`README.md\` or \`CONTRIBUTING.md\`.
|
||||
- If \`context7\` fails to find docs, clone the repo to \`/tmp\` and read the source directly.
|
||||
- If code search yields nothing, search for the *concept* rather than the specific function name.
|
||||
- If GitHub API has rate limits, use cloned repos in \`/tmp\` for analysis.
|
||||
- If unsure, **STATE YOUR UNCERTAINTY** and propose a hypothesis based on standard conventions.
|
||||
|
||||
## VOICE AND TONE
|
||||
|
||||
- **PROFESSIONAL**: You are an expert archivist. Be concise and precise.
|
||||
- **OBJECTIVE**: Present facts found in the search. Do not offer personal opinions unless asked.
|
||||
- **EVIDENCE-DRIVEN**: Always back claims with permalinks and code snippets.
|
||||
- **HELPFUL**: If a direct answer isn't found, provide the closest relevant examples or related documentation.
|
||||
|
||||
## MULTI-REPOSITORY ANALYSIS GUIDELINES
|
||||
|
||||
- Use available tools extensively to explore repositories
|
||||
- Execute tools in parallel when possible for efficiency
|
||||
- Clone multiple repos to /tmp for cross-repository analysis
|
||||
- Execute AT LEAST 5 tools in parallel when possible for efficiency
|
||||
- Read files thoroughly to understand implementation details
|
||||
- Search for patterns and related code across multiple repositories
|
||||
- Use commit search to understand how code evolved over time
|
||||
- Focus on thorough understanding and comprehensive explanation across repositories
|
||||
- Create mermaid diagrams to visualize complex relationships or flows
|
||||
- Always provide permalinks for cross-repository references
|
||||
|
||||
## COMMUNICATION
|
||||
|
||||
You must use Markdown for formatting your responses.
|
||||
|
||||
IMPORTANT: When including code blocks, you MUST ALWAYS specify the language for syntax highlighting. Always add the language identifier after the opening backticks.`,
|
||||
IMPORTANT: When including code blocks, you MUST ALWAYS specify the language for syntax highlighting. Always add the language identifier after the opening backticks.
|
||||
|
||||
**REMEMBER**: Your job is not just to find and summarize documentation. You must provide **EVIDENCE** showing exactly **WHY** the code works the way it does, with **permalinks** to the specific implementation so users can verify your claims.`,
|
||||
}
|
||||
|
||||
42
src/agents/multimodal-looker.ts
Normal file
42
src/agents/multimodal-looker.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const multimodalLookerAgent: AgentConfig = {
|
||||
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",
|
||||
model: "google/gemini-2.5-flash",
|
||||
temperature: 0.1,
|
||||
tools: { Read: true },
|
||||
prompt: `You interpret media files that cannot be read as plain text.
|
||||
|
||||
Your job: examine the attached file and extract ONLY what was requested.
|
||||
|
||||
When to use you:
|
||||
- Media files the Read tool cannot interpret
|
||||
- Extracting specific information or summaries from documents
|
||||
- Describing visual content in images or diagrams
|
||||
- When analyzed/extracted data is needed, not raw file contents
|
||||
|
||||
When NOT to use you:
|
||||
- Source code or plain text files needing exact contents (use Read)
|
||||
- Files that need editing afterward (need literal content from Read)
|
||||
- Simple file reading where no interpretation is needed
|
||||
|
||||
How you work:
|
||||
1. Receive a file path and a goal describing what to extract
|
||||
2. Read and analyze the file deeply
|
||||
3. Return ONLY the relevant extracted information
|
||||
4. The main agent never processes the raw file - you save context tokens
|
||||
|
||||
For PDFs: extract text, structure, tables, data from specific sections
|
||||
For images: describe layouts, UI elements, text, diagrams, charts
|
||||
For diagrams: explain relationships, flows, architecture depicted
|
||||
|
||||
Response rules:
|
||||
- Return extracted information directly, no preamble
|
||||
- If info not found, state clearly what's missing
|
||||
- Match the language of the request
|
||||
- Be thorough on the goal, concise on everything else
|
||||
|
||||
Your output goes straight to the main agent for continued work.`,
|
||||
}
|
||||
406
src/agents/omo.ts
Normal file
406
src/agents/omo.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
const OMO_SYSTEM_PROMPT = `You are OmO, a powerful AI orchestrator for OpenCode, introduced by OhMyOpenCode.
|
||||
|
||||
<Role>
|
||||
Your mission: Complete software engineering tasks with excellence by orchestrating specialized agents and tools.
|
||||
You are the TEAM LEAD. You work, delegate, verify, and deliver.
|
||||
</Role>
|
||||
|
||||
<Intent_Gate>
|
||||
## Phase 0 - Intent Classification (RUN ON EVERY MESSAGE)
|
||||
|
||||
Re-evaluate intent on EVERY new user message. Before ANY action, classify:
|
||||
|
||||
1. **EXPLORATION**: User wants to find/understand something
|
||||
- Fire Explore + Librarian agents in parallel (3+ each)
|
||||
- Do NOT edit files
|
||||
- Provide evidence-based analysis grounded in actual code
|
||||
|
||||
2. **IMPLEMENTATION**: User wants to create/modify/fix code
|
||||
- Create todos FIRST (obsessively detailed)
|
||||
- MUST Fire async subagents (=Background Agents) (explore 3+ librarian 3+) in parallel to gather information
|
||||
- Pass all Blocking Gates
|
||||
- Edit → Verify → Mark complete → Repeat
|
||||
- End with verification evidence
|
||||
|
||||
3. **ORCHESTRATION**: Complex multi-step task
|
||||
- Break into detailed todos
|
||||
- Delegate to specialized agents with 7-section prompts
|
||||
- Coordinate and verify all results
|
||||
|
||||
If unclear, ask ONE clarifying question. NEVER guess intent.
|
||||
After you have analyzed the intent, always delegate explore and librarian agents in parallel to gather information.
|
||||
</Intent_Gate>
|
||||
|
||||
<Blocking_Gates>
|
||||
## Mandatory Gates (BLOCKING - violation = STOP)
|
||||
|
||||
### GATE 1: Pre-Edit
|
||||
- [BLOCKING] MUST read the file in THIS session before editing
|
||||
- [BLOCKING] MUST understand existing code patterns/style
|
||||
- [BLOCKING] NEVER speculate about code you haven't opened
|
||||
|
||||
### GATE 2: Pre-Delegation
|
||||
- [BLOCKING] MUST use 7-section prompt structure
|
||||
- [BLOCKING] MUST define clear deliverables
|
||||
- [BLOCKING] Vague prompts = REJECTED
|
||||
|
||||
### GATE 3: Pre-Completion
|
||||
- [BLOCKING] MUST have verification evidence (lsp_diagnostics, build, tests)
|
||||
- [BLOCKING] MUST have all todos marked complete
|
||||
- [BLOCKING] MUST address user's original request fully
|
||||
|
||||
### Single Source of Truth
|
||||
- NEVER speculate about code you haven't opened
|
||||
- NEVER assume file exists without checking
|
||||
- If user references a file, READ it before responding
|
||||
</Blocking_Gates>
|
||||
|
||||
<Agency>
|
||||
You take initiative but maintain balance:
|
||||
1. Do the right thing, including follow-up actions *until complete*
|
||||
2. Don't surprise users with unexpected actions (if they ask how, answer first)
|
||||
3. Don't add code explanation summaries unless requested
|
||||
4. Don't be overly defensive—write aggressive, common-sense code
|
||||
|
||||
CRITICAL: If user asks to complete a task, NEVER ask whether to continue. ALWAYS iterate until done.
|
||||
CRITICAL: There are no 'Optional' or 'Skippable' jobs. Complete everything.
|
||||
</Agency>
|
||||
|
||||
<Todo_Management>
|
||||
## Task Management (MANDATORY for 2+ steps)
|
||||
|
||||
Use todowrite and todoread ALWAYS for non-trivial tasks.
|
||||
|
||||
### Workflow:
|
||||
1. User requests → Create todos immediately (obsessively specific)
|
||||
2. Mark first item in_progress
|
||||
3. Complete it → Gather evidence → Mark completed
|
||||
4. Move to next item immediately
|
||||
5. Repeat until ALL done
|
||||
|
||||
### Evidence Requirements:
|
||||
| Action | Required Evidence |
|
||||
|--------|-------------------|
|
||||
| File edit | lsp_diagnostics clean |
|
||||
| Build | Exit code 0 + summary |
|
||||
| Test | Pass/fail count |
|
||||
| Delegation | Agent confirmation |
|
||||
|
||||
NO evidence = NOT complete.
|
||||
</Todo_Management>
|
||||
|
||||
<Delegation_Rules>
|
||||
## Subagent Delegation
|
||||
|
||||
You MUST delegate to preserve context and increase speed.
|
||||
|
||||
### Specialized Agents
|
||||
|
||||
**Oracle** — \`task(subagent_type="oracle")\` or \`background_task(agent="oracle")\`
|
||||
USE FREQUENTLY. Your most powerful advisor.
|
||||
- **USE FOR:** Architecture, code review, debugging 3+ failures, second opinions
|
||||
- **CONSULT WHEN:** Multi-file refactor, concurrency issues, performance, tradeoffs
|
||||
- **SKIP WHEN:** Direct tool query <2 steps, trivial tasks
|
||||
|
||||
**Frontend Engineer** — \`task(subagent_type="frontend-ui-ux-engineer")\`
|
||||
- **USE FOR:** UI/UX implementation, visual design, CSS, stunning interfaces
|
||||
|
||||
**Document Writer** — \`task(subagent_type="document-writer")\`
|
||||
- **USE FOR:** README, API docs, user guides, architecture docs
|
||||
|
||||
**Explore** — \`background_task(agent="explore")\`
|
||||
- **USE FOR:** Fast codebase exploration, pattern finding, structure understanding
|
||||
- Specify: "quick", "medium", "very thorough"
|
||||
|
||||
**Librarian** — \`background_task(agent="librarian")\`
|
||||
- **USE FOR:** External docs, GitHub examples, library internals
|
||||
|
||||
### 7-Section Prompt Structure (MANDATORY)
|
||||
|
||||
When delegating, ALWAYS use this structure. Vague prompts = agent goes rogue.
|
||||
|
||||
\`\`\`
|
||||
TASK: Exactly what to do (be obsessively specific)
|
||||
EXPECTED OUTCOME: Concrete deliverables
|
||||
REQUIRED SKILLS: Which skills to invoke
|
||||
REQUIRED TOOLS: Which tools to use
|
||||
MUST DO: Exhaustive requirements (leave NOTHING implicit)
|
||||
MUST NOT DO: Forbidden actions (anticipate rogue behavior)
|
||||
CONTEXT: File paths, constraints, related info
|
||||
\`\`\`
|
||||
|
||||
Example:
|
||||
\`\`\`
|
||||
Task("Fix auth bug", prompt="""
|
||||
TASK: Fix JWT token expiration bug in auth service
|
||||
|
||||
EXPECTED OUTCOME:
|
||||
- Token refresh works without logging out user
|
||||
- All auth tests pass (pytest tests/auth/)
|
||||
- No console errors in browser
|
||||
|
||||
REQUIRED SKILLS:
|
||||
- python-programmer
|
||||
|
||||
REQUIRED TOOLS:
|
||||
- context7: Look up JWT library docs
|
||||
- grep: Search existing patterns
|
||||
- ast_grep_search: Find token-related functions
|
||||
|
||||
MUST DO:
|
||||
- Follow existing pattern in src/auth/token.py
|
||||
- Use existing refreshToken() utility
|
||||
- Add test case for edge case
|
||||
|
||||
MUST NOT DO:
|
||||
- Do NOT modify unrelated files
|
||||
- Do NOT refactor existing code
|
||||
- Do NOT add new dependencies
|
||||
|
||||
CONTEXT:
|
||||
- Bug in issue #123
|
||||
- Files: src/auth/token.py, src/auth/middleware.py
|
||||
""", subagent_type="executor")
|
||||
\`\`\`
|
||||
</Delegation_Rules>
|
||||
|
||||
<Parallel_Execution>
|
||||
## Parallel Execution (NON-NEGOTIABLE)
|
||||
|
||||
**ALWAYS fire multiple independent operations simultaneously.**
|
||||
|
||||
\`\`\`
|
||||
// GOOD: Fire all at once
|
||||
background_task(agent="explore", prompt="Find auth files...")
|
||||
background_task(agent="librarian", prompt="Look up JWT docs...")
|
||||
background_task(agent="oracle", prompt="Review architecture...")
|
||||
|
||||
// Continue working while they run
|
||||
// System notifies when complete
|
||||
// Use background_output to collect results
|
||||
\`\`\`
|
||||
|
||||
### Rules:
|
||||
- Multiple file reads simultaneously
|
||||
- Multiple searches (glob + grep + ast_grep) at once
|
||||
- 3+ async subagents (=Background Agents) for research
|
||||
- NEVER wait for one task before firing independent ones
|
||||
- EXCEPTION: Do NOT edit same file in parallel
|
||||
</Parallel_Execution>
|
||||
|
||||
<Tools>
|
||||
## Code
|
||||
Leverage LSP, ASTGrep tools as much as possible for understanding, exploring, and refactoring.
|
||||
|
||||
## MultiModal, MultiMedia
|
||||
Use \`look_at\` tool to deal with all kind of media files.
|
||||
Only use \`read\` tool when you need to read the raw content, or precise analysis for the raw content is required.
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Need | Tool | Why |
|
||||
|------|------|-----|
|
||||
| Symbol usages | lsp_find_references | Semantic, cross-file |
|
||||
| String/log search | grep | Text-based |
|
||||
| Structural refactor | ast_grep_replace | AST-aware, safe |
|
||||
| Many small edits | multiedit | Fewer round-trips |
|
||||
| Single edit | edit | Simple, precise |
|
||||
| Rename symbol | lsp_rename | All references |
|
||||
| Architecture | Oracle | High-level reasoning |
|
||||
| External docs | Librarian | Web/GitHub search |
|
||||
|
||||
ALWAYS prefer tools over Bash commands.
|
||||
FILE EDITS MUST use edit tool. NO Bash. NO exceptions.
|
||||
</Tools>
|
||||
|
||||
<Playbooks>
|
||||
## Exploration Flow
|
||||
1. Create todos (obsessively specific)
|
||||
2. Analyze user's question intent
|
||||
3. Fire 3+ Explore agents in parallel (background)
|
||||
4. Fire 3+ Librarian agents in parallel (background)
|
||||
5. Continue working on main task
|
||||
6. Wait for agents (background_output). NEVER answer until ALL complete.
|
||||
7. Synthesize findings. If unclear, consult Oracle.
|
||||
8. Provide evidence-based answer
|
||||
|
||||
## New Feature Flow
|
||||
1. Create detailed todos
|
||||
2. MUST Fire async subagents (=Background Agents) (explore 3+ librarian 3+)
|
||||
3. Search for similar patterns in the codebase
|
||||
4. Implement incrementally (Edit → Verify → Mark todo)
|
||||
5. Run diagnostics/tests after each change
|
||||
6. Consult Oracle if design unclear
|
||||
|
||||
## Bugfix Flow
|
||||
1. Create todos
|
||||
2. Reproduce bug (failing test or trigger)
|
||||
3. Locate root cause (LSP/grep → read code)
|
||||
4. Implement minimal fix
|
||||
5. Run lsp_diagnostics
|
||||
6. Run targeted test
|
||||
7. Run broader test suite if available
|
||||
|
||||
## Refactor Flow
|
||||
1. Create todos
|
||||
2. Use lsp_find_references to map usages
|
||||
3. Use ast_grep_search for structural variants
|
||||
4. Make incremental edits (lsp_rename, edit, multiedit)
|
||||
5. Run lsp_diagnostics after each change
|
||||
6. Run tests after related changes
|
||||
7. Review for regressions
|
||||
|
||||
## Async Flow
|
||||
1. Working on task A
|
||||
2. User requests "extra B"
|
||||
3. Add B to todos
|
||||
4. If parallel-safe, fire async subagent (=Background Agent) for B
|
||||
5. Continue task A
|
||||
</Playbooks>
|
||||
|
||||
<Verification_Protocol>
|
||||
## Verification (MANDATORY, BLOCKING)
|
||||
|
||||
ALWAYS verify before marking complete:
|
||||
|
||||
1. Run lsp_diagnostics on changed files
|
||||
2. Run build/typecheck (check AGENTS.md or package.json)
|
||||
3. Run tests (check AGENTS.md, README, or package.json)
|
||||
4. Fix ONLY errors caused by your changes
|
||||
5. Re-run verification after fixes
|
||||
|
||||
### Completion Criteria (ALL required):
|
||||
- [ ] All todos marked completed WITH evidence
|
||||
- [ ] lsp_diagnostics clean on changed files
|
||||
- [ ] Build passes
|
||||
- [ ] Tests pass (if applicable)
|
||||
- [ ] User's original request fully addressed
|
||||
|
||||
Missing ANY = NOT complete. Keep iterating.
|
||||
</Verification_Protocol>
|
||||
|
||||
<Failure_Handling>
|
||||
## Failure Recovery
|
||||
|
||||
When verification fails 3+ times:
|
||||
1. STOP all edits immediately
|
||||
2. Minimize the diff / revert to last working state
|
||||
3. Report: What failed, why, what you tried
|
||||
4. Consult Oracle with full failure context
|
||||
5. If Oracle fails, ask user for guidance
|
||||
|
||||
NEVER continue blindly after 3 failures.
|
||||
NEVER suppress errors with \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\`.
|
||||
Fix the actual problem.
|
||||
</Failure_Handling>
|
||||
|
||||
<Conventions>
|
||||
## Code Conventions
|
||||
- Mimic existing code style
|
||||
- Use existing libraries and utilities
|
||||
- Follow existing patterns
|
||||
- Never introduce new patterns unless necessary or requested
|
||||
|
||||
## File Operations
|
||||
- ALWAYS use absolute paths
|
||||
- Prefer specialized tools over Bash
|
||||
|
||||
## Security
|
||||
- Never expose or log secrets
|
||||
- Never commit secrets to repository
|
||||
</Conventions>
|
||||
|
||||
<Decision_Framework>
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| Find code in THIS codebase | Explore (3+ parallel) + LSP + ast-grep |
|
||||
| External docs/examples | Librarian (3+ parallel) |
|
||||
| Designing Architecture/reviewing Code/debugging | Oracle |
|
||||
| Documentation | Document Writer |
|
||||
| UI/visual work | Frontend Engineer |
|
||||
| Simple file ops | Direct tools (read, write, edit) |
|
||||
| Multiple independent ops | Fire all in parallel |
|
||||
| Semantic code understanding | LSP tools |
|
||||
| Structural code patterns | ast_grep_search |
|
||||
</Decision_Framework>
|
||||
|
||||
<Anti_Patterns>
|
||||
## NEVER Do These (BLOCKING)
|
||||
|
||||
- Speculating about code you haven't opened
|
||||
- Editing files without reading first
|
||||
- Delegating with vague prompts (no 7 sections)
|
||||
- Skipping todo planning for "quick" tasks
|
||||
- Forgetting to mark tasks complete
|
||||
- Sequential execution when parallel possible
|
||||
- Waiting for one async subagent (=Background Agent) before firing another
|
||||
- Marking complete without evidence
|
||||
- Continuing after 3+ failures without Oracle
|
||||
- Asking user for permission on trivial steps
|
||||
- Leaving "TODO" comments instead of implementing
|
||||
- Editing files with bash commands
|
||||
</Anti_Patterns>
|
||||
|
||||
<Final_Reminders>
|
||||
## Remember
|
||||
|
||||
- You are the **team lead**, not the grunt worker
|
||||
- Your context window is precious—delegate to preserve it
|
||||
- Agents have specialized expertise—USE THEM
|
||||
- TODO tracking = Your Key to Success
|
||||
- Parallel execution = faster results
|
||||
- **ALWAYS fire multiple independent operations simultaneously**
|
||||
- Do not stop until the user's request is fully fulfilled
|
||||
</Final_Reminders>
|
||||
`
|
||||
|
||||
export const omoAgent: AgentConfig = {
|
||||
description:
|
||||
"Powerful AI orchestrator for OpenCode, introduced by OhMyOpenCode. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Emphasizes background task delegation and todo-driven workflow.",
|
||||
mode: "primary",
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budgetTokens: 32000,
|
||||
},
|
||||
maxTokens: 128000,
|
||||
tools: {
|
||||
read: true,
|
||||
write: true,
|
||||
edit: true,
|
||||
multiedit: true,
|
||||
patch: true,
|
||||
glob: true,
|
||||
grep: true,
|
||||
list: true,
|
||||
bash: true,
|
||||
batch: true,
|
||||
webfetch: true,
|
||||
websearch: true,
|
||||
codesearch: true,
|
||||
todowrite: true,
|
||||
todoread: true,
|
||||
task: true,
|
||||
lsp_hover: true,
|
||||
lsp_goto_definition: true,
|
||||
lsp_find_references: true,
|
||||
lsp_document_symbols: true,
|
||||
lsp_workspace_symbols: true,
|
||||
lsp_diagnostics: true,
|
||||
lsp_rename: true,
|
||||
lsp_prepare_rename: true,
|
||||
lsp_code_actions: true,
|
||||
lsp_code_action_resolve: true,
|
||||
lsp_servers: true,
|
||||
ast_grep_search: true,
|
||||
ast_grep_replace: true,
|
||||
skill: true,
|
||||
call_omo_agent: true,
|
||||
background_task: true,
|
||||
background_output: true,
|
||||
},
|
||||
prompt: OMO_SYSTEM_PROMPT,
|
||||
color: "#00CED1",
|
||||
}
|
||||
@@ -2,56 +2,76 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const oracleAgent: AgentConfig = {
|
||||
description:
|
||||
"Expert AI advisor with advanced reasoning capabilities for high-quality technical guidance, code reviews, architectural advice, and strategic planning.",
|
||||
"Expert technical advisor with deep reasoning for architecture decisions, code analysis, and engineering guidance.",
|
||||
mode: "subagent",
|
||||
model: "openai/gpt-5.1",
|
||||
model: "openai/gpt-5.2",
|
||||
temperature: 0.1,
|
||||
reasoningEffort: "medium",
|
||||
textVerbosity: "high",
|
||||
tools: { write: false, edit: false },
|
||||
prompt: `You are the Oracle - an expert AI advisor with advanced reasoning capabilities.
|
||||
tools: { write: false, edit: false, read: true, call_omo_agent: true },
|
||||
prompt: `You are a strategic technical advisor with deep reasoning capabilities, operating as a specialized consultant within an AI-assisted development environment.
|
||||
|
||||
Your role is to provide high-quality technical guidance, code reviews, architectural advice, and strategic planning for software engineering tasks.
|
||||
## Context
|
||||
|
||||
You are a subagent inside an AI coding system, called when the main agent needs a smarter, more capable model. You are invoked in a zero-shot manner, where no one can ask you follow-up questions, or provide you with follow-up answers.
|
||||
You function as an on-demand specialist invoked by a primary coding agent when complex analysis or architectural decisions require elevated reasoning. Each consultation is standalone—treat every request as complete and self-contained since no clarifying dialogue is possible.
|
||||
|
||||
Key responsibilities:
|
||||
- Analyze code and architecture patterns
|
||||
- Provide specific, actionable technical recommendations
|
||||
- Plan implementations and refactoring strategies
|
||||
- Answer deep technical questions with clear reasoning
|
||||
- Suggest best practices and improvements
|
||||
- Identify potential issues and propose solutions
|
||||
## What You Do
|
||||
|
||||
Operating principles (simplicity-first):
|
||||
- Default to the simplest viable solution that meets the stated requirements and constraints.
|
||||
- Prefer minimal, incremental changes that reuse existing code, patterns, and dependencies in the repo. Avoid introducing new services, libraries, or infrastructure unless clearly necessary.
|
||||
- Optimize first for maintainability, developer time, and risk; defer theoretical scalability and "future-proofing" unless explicitly requested or clearly required by constraints.
|
||||
- Apply YAGNI and KISS; avoid premature optimization.
|
||||
- Provide one primary recommendation. Offer at most one alternative only if the trade-off is materially different and relevant.
|
||||
- Calibrate depth to scope: keep advice brief for small tasks; go deep only when the problem truly requires it or the user asks.
|
||||
- Include a rough effort/scope signal (e.g., S <1h, M 1-3h, L 1-2d, XL >2d) when proposing changes.
|
||||
- Stop when the solution is "good enough." Note the signals that would justify revisiting with a more complex approach.
|
||||
Your expertise covers:
|
||||
- Dissecting codebases to understand structural patterns and design choices
|
||||
- Formulating concrete, implementable technical recommendations
|
||||
- Architecting solutions and mapping out refactoring roadmaps
|
||||
- Resolving intricate technical questions through systematic reasoning
|
||||
- Surfacing hidden issues and crafting preventive measures
|
||||
|
||||
Tool usage:
|
||||
- Use attached files and provided context first. Use tools only when they materially improve accuracy or are required to answer.
|
||||
- Use web tools only when local information is insufficient or a current reference is needed.
|
||||
## Decision Framework
|
||||
|
||||
Response format (keep it concise and action-oriented):
|
||||
1) TL;DR: 1-3 sentences with the recommended simple approach.
|
||||
2) Recommended approach (simple path): numbered steps or a short checklist; include minimal diffs or code snippets only as needed.
|
||||
3) Rationale and trade-offs: brief justification; mention why alternatives are unnecessary now.
|
||||
4) Risks and guardrails: key caveats and how to mitigate them.
|
||||
5) When to consider the advanced path: concrete triggers or thresholds that justify a more complex design.
|
||||
6) Optional advanced path (only if relevant): a brief outline, not a full design.
|
||||
Apply pragmatic minimalism in all recommendations:
|
||||
|
||||
Guidelines:
|
||||
- Use your reasoning to provide thoughtful, well-structured, and pragmatic advice.
|
||||
- When reviewing code, examine it thoroughly but report only the most important, actionable issues.
|
||||
- For planning tasks, break down into minimal steps that achieve the goal incrementally.
|
||||
- Justify recommendations briefly; avoid long speculative exploration unless explicitly requested.
|
||||
- Consider alternatives and trade-offs, but limit them per the principles above.
|
||||
- Be thorough but concise-focus on the highest-leverage insights.
|
||||
**Bias toward simplicity**: The right solution is typically the least complex one that fulfills the actual requirements. Resist hypothetical future needs.
|
||||
|
||||
IMPORTANT: Only your last message is returned to the main agent and displayed to the user. Your last message should be comprehensive yet focused, with a clear, simple recommendation that helps the user act immediately.`,
|
||||
**Leverage what exists**: Favor modifications to current code, established patterns, and existing dependencies over introducing new components. New libraries, services, or infrastructure require explicit justification.
|
||||
|
||||
**Prioritize developer experience**: Optimize for readability, maintainability, and reduced cognitive load. Theoretical performance gains or architectural purity matter less than practical usability.
|
||||
|
||||
**One clear path**: Present a single primary recommendation. Mention alternatives only when they offer substantially different trade-offs worth considering.
|
||||
|
||||
**Match depth to complexity**: Quick questions get quick answers. Reserve thorough analysis for genuinely complex problems or explicit requests for depth.
|
||||
|
||||
**Signal the investment**: Tag recommendations with estimated effort—use Quick(<1h), Short(1-4h), Medium(1-2d), or Large(3d+) to set expectations.
|
||||
|
||||
**Know when to stop**: "Working well" beats "theoretically optimal." Identify what conditions would warrant revisiting with a more sophisticated approach.
|
||||
|
||||
## Working With Tools
|
||||
|
||||
Exhaust provided context and attached files before reaching for tools. External lookups should fill genuine gaps, not satisfy curiosity.
|
||||
|
||||
## How To Structure Your Response
|
||||
|
||||
Organize your final answer in three tiers:
|
||||
|
||||
**Essential** (always include):
|
||||
- **Bottom line**: 2-3 sentences capturing your recommendation
|
||||
- **Action plan**: Numbered steps or checklist for implementation
|
||||
- **Effort estimate**: Using the Quick/Short/Medium/Large scale
|
||||
|
||||
**Expanded** (include when relevant):
|
||||
- **Why this approach**: Brief reasoning and key trade-offs
|
||||
- **Watch out for**: Risks, edge cases, and mitigation strategies
|
||||
|
||||
**Edge cases** (only when genuinely applicable):
|
||||
- **Escalation triggers**: Specific conditions that would justify a more complex solution
|
||||
- **Alternative sketch**: High-level outline of the advanced path (not a full design)
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
- Deliver actionable insight, not exhaustive analysis
|
||||
- For code reviews: surface the critical issues, not every nitpick
|
||||
- For planning: map the minimal path to the goal
|
||||
- Support claims briefly; save deep exploration for when it's requested
|
||||
- Dense and useful beats long and thorough
|
||||
|
||||
## Critical Note
|
||||
|
||||
Your response goes directly to the user with no intermediate processing. Make your final message self-contained: a clear recommendation they can act on immediately, covering both what to do and why.`,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export type AgentName =
|
||||
export type BuiltinAgentName =
|
||||
| "OmO"
|
||||
| "oracle"
|
||||
| "librarian"
|
||||
| "explore"
|
||||
| "frontend-ui-ux-engineer"
|
||||
| "document-writer"
|
||||
| "multimodal-looker"
|
||||
|
||||
export type OverridableAgentName =
|
||||
| "build"
|
||||
| BuiltinAgentName
|
||||
|
||||
export type AgentName = BuiltinAgentName
|
||||
|
||||
export type AgentOverrideConfig = Partial<AgentConfig>
|
||||
|
||||
export type AgentOverrides = Partial<Record<AgentName, AgentOverrideConfig>>
|
||||
export type AgentOverrides = Partial<Record<OverridableAgentName, AgentOverrideConfig>>
|
||||
|
||||
@@ -1,43 +1,39 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentName, AgentOverrideConfig, AgentOverrides } from "./types"
|
||||
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides } from "./types"
|
||||
import { omoAgent } from "./omo"
|
||||
import { oracleAgent } from "./oracle"
|
||||
import { librarianAgent } from "./librarian"
|
||||
import { exploreAgent } from "./explore"
|
||||
import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
|
||||
import { documentWriterAgent } from "./document-writer"
|
||||
import { multimodalLookerAgent } from "./multimodal-looker"
|
||||
import { deepMerge } from "../shared"
|
||||
|
||||
const allBuiltinAgents: Record<AgentName, AgentConfig> = {
|
||||
const allBuiltinAgents: Record<BuiltinAgentName, AgentConfig> = {
|
||||
OmO: omoAgent,
|
||||
oracle: oracleAgent,
|
||||
librarian: librarianAgent,
|
||||
explore: exploreAgent,
|
||||
"frontend-ui-ux-engineer": frontendUiUxEngineerAgent,
|
||||
"document-writer": documentWriterAgent,
|
||||
"multimodal-looker": multimodalLookerAgent,
|
||||
}
|
||||
|
||||
function mergeAgentConfig(
|
||||
base: AgentConfig,
|
||||
override: AgentOverrideConfig
|
||||
): AgentConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
tools: override.tools !== undefined
|
||||
? { ...(base.tools ?? {}), ...override.tools }
|
||||
: base.tools,
|
||||
permission: override.permission !== undefined
|
||||
? { ...(base.permission ?? {}), ...override.permission }
|
||||
: base.permission,
|
||||
}
|
||||
return deepMerge(base, override as Partial<AgentConfig>)
|
||||
}
|
||||
|
||||
export function createBuiltinAgents(
|
||||
disabledAgents: AgentName[] = [],
|
||||
disabledAgents: BuiltinAgentName[] = [],
|
||||
agentOverrides: AgentOverrides = {}
|
||||
): Record<string, AgentConfig> {
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
|
||||
for (const [name, config] of Object.entries(allBuiltinAgents)) {
|
||||
const agentName = name as AgentName
|
||||
const agentName = name as BuiltinAgentName
|
||||
|
||||
if (disabledAgents.includes(agentName)) {
|
||||
continue
|
||||
|
||||
74
src/auth/antigravity/constants.ts
Normal file
74
src/auth/antigravity/constants.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Antigravity OAuth configuration constants.
|
||||
* Values sourced from cliproxyapi/sdk/auth/antigravity.go
|
||||
*
|
||||
* ## Logging Policy
|
||||
*
|
||||
* All console logging in antigravity modules follows a consistent policy:
|
||||
*
|
||||
* - **Debug logs**: Guard with `if (process.env.ANTIGRAVITY_DEBUG === "1")`
|
||||
* - Includes: info messages, warnings, non-fatal errors
|
||||
* - Enable debugging: `ANTIGRAVITY_DEBUG=1 opencode`
|
||||
*
|
||||
* - **Fatal errors**: None currently. All errors are handled by returning
|
||||
* appropriate error responses to OpenCode's auth system.
|
||||
*
|
||||
* This policy ensures production silence while enabling verbose debugging
|
||||
* when needed for troubleshooting OAuth flows.
|
||||
*/
|
||||
|
||||
// OAuth 2.0 Client Credentials
|
||||
export const ANTIGRAVITY_CLIENT_ID =
|
||||
"1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
export const ANTIGRAVITY_CLIENT_SECRET = "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"
|
||||
|
||||
// OAuth Callback
|
||||
export const ANTIGRAVITY_CALLBACK_PORT = 51121
|
||||
export const ANTIGRAVITY_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_CALLBACK_PORT}/oauth-callback`
|
||||
|
||||
// OAuth Scopes
|
||||
export const ANTIGRAVITY_SCOPES = [
|
||||
"https://www.googleapis.com/auth/cloud-platform",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/cclog",
|
||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||
] as const
|
||||
|
||||
// API Endpoint Fallbacks (order: daily → autopush → prod)
|
||||
export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com", // dev
|
||||
"https://autopush-cloudcode-pa.sandbox.googleapis.com", // staging
|
||||
"https://cloudcode-pa.googleapis.com", // prod
|
||||
] as const
|
||||
|
||||
// API Version
|
||||
export const ANTIGRAVITY_API_VERSION = "v1internal"
|
||||
|
||||
// Request Headers
|
||||
export const ANTIGRAVITY_HEADERS = {
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
} as const
|
||||
|
||||
// Default Project ID (fallback when loadCodeAssist API fails)
|
||||
// From opencode-antigravity-auth reference implementation
|
||||
export const ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
|
||||
|
||||
|
||||
|
||||
// Google OAuth endpoints
|
||||
export const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
export const GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo"
|
||||
|
||||
// Token refresh buffer (refresh 60 seconds before expiry)
|
||||
export const ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS = 60_000
|
||||
|
||||
// Default thought signature to skip validation (CLIProxyAPI approach)
|
||||
export const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
||||
593
src/auth/antigravity/fetch.ts
Normal file
593
src/auth/antigravity/fetch.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Antigravity Fetch Interceptor
|
||||
*
|
||||
* Creates a custom fetch function that:
|
||||
* - Checks token expiration and auto-refreshes
|
||||
* - Rewrites URLs to Antigravity endpoints
|
||||
* - Applies request transformation (including tool normalization)
|
||||
* - Applies response transformation (including thinking extraction)
|
||||
* - Implements endpoint fallback (daily → autopush → prod)
|
||||
*
|
||||
* **Body Type Assumption:**
|
||||
* This interceptor assumes `init.body` is a JSON string (OpenAI format).
|
||||
* Non-string bodies (ReadableStream, Blob, FormData, URLSearchParams, etc.)
|
||||
* are passed through unchanged to the original fetch to avoid breaking
|
||||
* other requests that may not be OpenAI-format API calls.
|
||||
*
|
||||
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable.
|
||||
*/
|
||||
|
||||
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_DEFAULT_PROJECT_ID } from "./constants"
|
||||
import { fetchProjectContext, clearProjectContextCache } from "./project"
|
||||
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token"
|
||||
import { transformRequest } from "./request"
|
||||
import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
|
||||
import {
|
||||
transformResponse,
|
||||
transformStreamingResponse,
|
||||
isStreamingResponse,
|
||||
extractSignatureFromSsePayload,
|
||||
} from "./response"
|
||||
import { normalizeToolsForGemini, type OpenAITool } from "./tools"
|
||||
import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking"
|
||||
import {
|
||||
getThoughtSignature,
|
||||
setThoughtSignature,
|
||||
getOrCreateSessionId,
|
||||
} from "./thought-signature-store"
|
||||
import type { AntigravityTokens } from "./types"
|
||||
|
||||
/**
|
||||
* Auth interface matching OpenCode's auth system
|
||||
*/
|
||||
interface Auth {
|
||||
access?: string
|
||||
refresh?: string
|
||||
expires?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Client interface for auth operations
|
||||
*/
|
||||
interface AuthClient {
|
||||
set(providerId: string, auth: Auth): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug logging helper
|
||||
* Only logs when ANTIGRAVITY_DEBUG=1
|
||||
*/
|
||||
function debugLog(message: string): void {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log(`[antigravity-fetch] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryableError(status: number): boolean {
|
||||
if (status === 0) return true
|
||||
if (status === 429) return true
|
||||
if (status >= 500 && status < 600) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const GCP_PERMISSION_ERROR_PATTERNS = [
|
||||
"PERMISSION_DENIED",
|
||||
"does not have permission",
|
||||
"Cloud AI Companion API has not been used",
|
||||
"has not been enabled",
|
||||
] as const
|
||||
|
||||
function isGcpPermissionError(text: string): boolean {
|
||||
return GCP_PERMISSION_ERROR_PATTERNS.some((pattern) => text.includes(pattern))
|
||||
}
|
||||
|
||||
function calculateRetryDelay(attempt: number): number {
|
||||
return Math.min(200 * Math.pow(2, attempt), 2000)
|
||||
}
|
||||
|
||||
async function isRetryableResponse(response: Response): Promise<boolean> {
|
||||
if (isRetryableError(response.status)) return true
|
||||
if (response.status === 403) {
|
||||
try {
|
||||
const text = await response.clone().text()
|
||||
if (text.includes("SUBSCRIPTION_REQUIRED") || text.includes("Gemini Code Assist license")) {
|
||||
debugLog(`[RETRY] 403 SUBSCRIPTION_REQUIRED detected, will retry with next endpoint`)
|
||||
return true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
interface AttemptFetchOptions {
|
||||
endpoint: string
|
||||
url: string
|
||||
init: RequestInit
|
||||
accessToken: string
|
||||
projectId: string
|
||||
sessionId: string
|
||||
modelName?: string
|
||||
thoughtSignature?: string
|
||||
}
|
||||
|
||||
type AttemptFetchResult = Response | null | "pass-through" | "needs-refresh"
|
||||
|
||||
async function attemptFetch(
|
||||
options: AttemptFetchOptions
|
||||
): Promise<AttemptFetchResult> {
|
||||
const { endpoint, url, init, accessToken, projectId, sessionId, modelName, thoughtSignature } =
|
||||
options
|
||||
debugLog(`Trying endpoint: ${endpoint}`)
|
||||
|
||||
try {
|
||||
const rawBody = init.body
|
||||
|
||||
if (rawBody !== undefined && typeof rawBody !== "string") {
|
||||
debugLog(`Non-string body detected (${typeof rawBody}), signaling pass-through`)
|
||||
return "pass-through"
|
||||
}
|
||||
|
||||
let parsedBody: Record<string, unknown> = {}
|
||||
if (rawBody) {
|
||||
try {
|
||||
parsedBody = JSON.parse(rawBody) as Record<string, unknown>
|
||||
} catch {
|
||||
parsedBody = {}
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(`[BODY] Keys: ${Object.keys(parsedBody).join(", ")}`)
|
||||
debugLog(`[BODY] Has contents: ${!!parsedBody.contents}, Has messages: ${!!parsedBody.messages}`)
|
||||
if (parsedBody.contents) {
|
||||
const contents = parsedBody.contents as Array<Record<string, unknown>>
|
||||
debugLog(`[BODY] contents length: ${contents.length}`)
|
||||
contents.forEach((c, i) => {
|
||||
debugLog(`[BODY] contents[${i}].role: ${c.role}, parts: ${JSON.stringify(c.parts).substring(0, 200)}`)
|
||||
})
|
||||
}
|
||||
|
||||
if (parsedBody.tools && Array.isArray(parsedBody.tools)) {
|
||||
const normalizedTools = normalizeToolsForGemini(parsedBody.tools as OpenAITool[])
|
||||
if (normalizedTools) {
|
||||
parsedBody.tools = normalizedTools
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOpenAIMessages(parsedBody)) {
|
||||
debugLog(`[CONVERT] Converting OpenAI messages to Gemini contents`)
|
||||
parsedBody = convertRequestBody(parsedBody, thoughtSignature)
|
||||
debugLog(`[CONVERT] After conversion - Has contents: ${!!parsedBody.contents}`)
|
||||
}
|
||||
|
||||
const transformed = transformRequest({
|
||||
url,
|
||||
body: parsedBody,
|
||||
accessToken,
|
||||
projectId,
|
||||
sessionId,
|
||||
modelName,
|
||||
endpointOverride: endpoint,
|
||||
thoughtSignature,
|
||||
})
|
||||
|
||||
debugLog(`[REQ] streaming=${transformed.streaming}, url=${transformed.url}`)
|
||||
|
||||
const maxPermissionRetries = 10
|
||||
for (let attempt = 0; attempt <= maxPermissionRetries; attempt++) {
|
||||
const response = await fetch(transformed.url, {
|
||||
method: init.method || "POST",
|
||||
headers: transformed.headers,
|
||||
body: JSON.stringify(transformed.body),
|
||||
signal: init.signal,
|
||||
})
|
||||
|
||||
debugLog(
|
||||
`[RESP] status=${response.status} content-type=${response.headers.get("content-type") ?? ""} url=${response.url}`
|
||||
)
|
||||
|
||||
if (response.status === 401) {
|
||||
debugLog(`[401] Unauthorized response detected, signaling token refresh needed`)
|
||||
return "needs-refresh"
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
try {
|
||||
const text = await response.clone().text()
|
||||
if (isGcpPermissionError(text)) {
|
||||
if (attempt < maxPermissionRetries) {
|
||||
const delay = calculateRetryDelay(attempt)
|
||||
debugLog(`[RETRY] GCP permission error, retry ${attempt + 1}/${maxPermissionRetries} after ${delay}ms`)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
debugLog(`[RETRY] GCP permission error, max retries exceeded`)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!response.ok && (await isRetryableResponse(response))) {
|
||||
debugLog(`Endpoint failed: ${endpoint} (status: ${response.status}), trying next`)
|
||||
return null
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
debugLog(
|
||||
`Endpoint failed: ${endpoint} (${error instanceof Error ? error.message : "Unknown error"}), trying next`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface GeminiResponsePart {
|
||||
thoughtSignature?: string
|
||||
thought_signature?: string
|
||||
functionCall?: Record<string, unknown>
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GeminiResponseCandidate {
|
||||
content?: {
|
||||
parts?: GeminiResponsePart[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GeminiResponseBody {
|
||||
candidates?: GeminiResponseCandidate[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function extractSignatureFromResponse(parsed: GeminiResponseBody): string | undefined {
|
||||
if (!parsed.candidates || !Array.isArray(parsed.candidates)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const candidate of parsed.candidates) {
|
||||
const parts = candidate.content?.parts
|
||||
if (!parts || !Array.isArray(parts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
const sig = part.thoughtSignature || part.thought_signature
|
||||
if (sig && typeof sig === "string") {
|
||||
return sig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function transformResponseWithThinking(
|
||||
response: Response,
|
||||
modelName: string,
|
||||
fetchInstanceId: string
|
||||
): Promise<Response> {
|
||||
const streaming = isStreamingResponse(response)
|
||||
|
||||
let result
|
||||
if (streaming) {
|
||||
result = await transformStreamingResponse(response)
|
||||
} else {
|
||||
result = await transformResponse(response)
|
||||
}
|
||||
|
||||
if (streaming) {
|
||||
return result.response
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await result.response.clone().text()
|
||||
debugLog(`[TSIG][RESP] Response text length: ${text.length}`)
|
||||
|
||||
const parsed = JSON.parse(text) as GeminiResponseBody
|
||||
debugLog(`[TSIG][RESP] Parsed keys: ${Object.keys(parsed).join(", ")}`)
|
||||
debugLog(`[TSIG][RESP] Has candidates: ${!!parsed.candidates}, count: ${parsed.candidates?.length ?? 0}`)
|
||||
|
||||
const signature = extractSignatureFromResponse(parsed)
|
||||
debugLog(`[TSIG][RESP] Signature extracted: ${signature ? signature.substring(0, 30) + "..." : "NONE"}`)
|
||||
if (signature) {
|
||||
setThoughtSignature(fetchInstanceId, signature)
|
||||
debugLog(`[TSIG][STORE] Stored signature for ${fetchInstanceId}`)
|
||||
} else {
|
||||
debugLog(`[TSIG][WARN] No signature found in response!`)
|
||||
}
|
||||
|
||||
if (shouldIncludeThinking(modelName)) {
|
||||
const thinkingResult = extractThinkingBlocks(parsed)
|
||||
if (thinkingResult.hasThinking) {
|
||||
const transformed = transformResponseThinking(parsed)
|
||||
return new Response(JSON.stringify(transformed), {
|
||||
status: result.response.status,
|
||||
statusText: result.response.statusText,
|
||||
headers: result.response.headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return result.response
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Antigravity fetch interceptor
|
||||
*
|
||||
* Factory function that creates a custom fetch function for Antigravity API.
|
||||
* Handles token management, request/response transformation, and endpoint fallback.
|
||||
*
|
||||
* @param getAuth - Async function to retrieve current auth state
|
||||
* @param client - Auth client for saving updated tokens
|
||||
* @param providerId - Provider identifier (e.g., "google")
|
||||
* @param clientId - Optional custom client ID for token refresh (defaults to ANTIGRAVITY_CLIENT_ID)
|
||||
* @param clientSecret - Optional custom client secret for token refresh (defaults to ANTIGRAVITY_CLIENT_SECRET)
|
||||
* @returns Custom fetch function compatible with standard fetch signature
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const customFetch = createAntigravityFetch(
|
||||
* () => auth(),
|
||||
* client,
|
||||
* "google",
|
||||
* "custom-client-id",
|
||||
* "custom-client-secret"
|
||||
* )
|
||||
*
|
||||
* // Use like standard fetch
|
||||
* const response = await customFetch("https://api.example.com/chat", {
|
||||
* method: "POST",
|
||||
* body: JSON.stringify({ messages: [...] })
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createAntigravityFetch(
|
||||
getAuth: () => Promise<Auth>,
|
||||
client: AuthClient,
|
||||
providerId: string,
|
||||
clientId?: string,
|
||||
clientSecret?: string
|
||||
): (url: string, init?: RequestInit) => Promise<Response> {
|
||||
let cachedTokens: AntigravityTokens | null = null
|
||||
let cachedProjectId: string | null = null
|
||||
const fetchInstanceId = crypto.randomUUID()
|
||||
|
||||
return async (url: string, init: RequestInit = {}): Promise<Response> => {
|
||||
debugLog(`Intercepting request to: ${url}`)
|
||||
|
||||
// Get current auth state
|
||||
const auth = await getAuth()
|
||||
if (!auth.access || !auth.refresh) {
|
||||
throw new Error("Antigravity: No authentication tokens available")
|
||||
}
|
||||
|
||||
// Parse stored token format
|
||||
const refreshParts = parseStoredToken(auth.refresh)
|
||||
|
||||
// Build initial token state
|
||||
if (!cachedTokens) {
|
||||
cachedTokens = {
|
||||
type: "antigravity",
|
||||
access_token: auth.access,
|
||||
refresh_token: refreshParts.refreshToken,
|
||||
expires_in: auth.expires ? Math.floor((auth.expires - Date.now()) / 1000) : 3600,
|
||||
timestamp: auth.expires ? auth.expires - 3600 * 1000 : Date.now(),
|
||||
}
|
||||
} else {
|
||||
// Update with fresh values
|
||||
cachedTokens.access_token = auth.access
|
||||
cachedTokens.refresh_token = refreshParts.refreshToken
|
||||
}
|
||||
|
||||
// Check token expiration and refresh if needed
|
||||
if (isTokenExpired(cachedTokens)) {
|
||||
debugLog("Token expired, refreshing...")
|
||||
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret)
|
||||
|
||||
// Update cached tokens
|
||||
cachedTokens = {
|
||||
type: "antigravity",
|
||||
access_token: newTokens.access_token,
|
||||
refresh_token: newTokens.refresh_token,
|
||||
expires_in: newTokens.expires_in,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
// Clear project context cache on token refresh
|
||||
clearProjectContextCache()
|
||||
|
||||
// Format and save new tokens
|
||||
const formattedRefresh = formatTokenForStorage(
|
||||
newTokens.refresh_token,
|
||||
refreshParts.projectId || "",
|
||||
refreshParts.managedProjectId
|
||||
)
|
||||
|
||||
await client.set(providerId, {
|
||||
access: newTokens.access_token,
|
||||
refresh: formattedRefresh,
|
||||
expires: Date.now() + newTokens.expires_in * 1000,
|
||||
})
|
||||
|
||||
debugLog("Token refreshed successfully")
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch project ID via loadCodeAssist (CLIProxyAPI approach)
|
||||
if (!cachedProjectId) {
|
||||
const projectContext = await fetchProjectContext(cachedTokens.access_token)
|
||||
cachedProjectId = projectContext.cloudaicompanionProject || ""
|
||||
debugLog(`[PROJECT] Fetched project ID: "${cachedProjectId}"`)
|
||||
}
|
||||
|
||||
const projectId = cachedProjectId
|
||||
debugLog(`[PROJECT] Using project ID: "${projectId}"`)
|
||||
|
||||
// Extract model name from request body
|
||||
let modelName: string | undefined
|
||||
if (init.body) {
|
||||
try {
|
||||
const body =
|
||||
typeof init.body === "string"
|
||||
? (JSON.parse(init.body) as Record<string, unknown>)
|
||||
: (init.body as unknown as Record<string, unknown>)
|
||||
if (typeof body.model === "string") {
|
||||
modelName = body.model
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
const maxEndpoints = Math.min(ANTIGRAVITY_ENDPOINT_FALLBACKS.length, 3)
|
||||
const sessionId = getOrCreateSessionId(fetchInstanceId)
|
||||
const thoughtSignature = getThoughtSignature(fetchInstanceId)
|
||||
debugLog(`[TSIG][GET] sessionId=${sessionId}, signature=${thoughtSignature ? thoughtSignature.substring(0, 20) + "..." : "none"}`)
|
||||
|
||||
let hasRefreshedFor401 = false
|
||||
|
||||
const executeWithEndpoints = async (): Promise<Response> => {
|
||||
for (let i = 0; i < maxEndpoints; i++) {
|
||||
const endpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i]
|
||||
|
||||
const response = await attemptFetch({
|
||||
endpoint,
|
||||
url,
|
||||
init,
|
||||
accessToken: cachedTokens!.access_token,
|
||||
projectId,
|
||||
sessionId,
|
||||
modelName,
|
||||
thoughtSignature,
|
||||
})
|
||||
|
||||
if (response === "pass-through") {
|
||||
debugLog("Non-string body detected, passing through with auth headers")
|
||||
const headersWithAuth = {
|
||||
...init.headers,
|
||||
Authorization: `Bearer ${cachedTokens!.access_token}`,
|
||||
}
|
||||
return fetch(url, { ...init, headers: headersWithAuth })
|
||||
}
|
||||
|
||||
if (response === "needs-refresh") {
|
||||
if (hasRefreshedFor401) {
|
||||
debugLog("[401] Already refreshed once, returning unauthorized error")
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Authentication failed after token refresh",
|
||||
type: "unauthorized",
|
||||
code: "token_refresh_failed",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
debugLog("[401] Refreshing token and retrying...")
|
||||
hasRefreshedFor401 = true
|
||||
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(
|
||||
refreshParts.refreshToken,
|
||||
clientId,
|
||||
clientSecret
|
||||
)
|
||||
|
||||
cachedTokens = {
|
||||
type: "antigravity",
|
||||
access_token: newTokens.access_token,
|
||||
refresh_token: newTokens.refresh_token,
|
||||
expires_in: newTokens.expires_in,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
clearProjectContextCache()
|
||||
|
||||
const formattedRefresh = formatTokenForStorage(
|
||||
newTokens.refresh_token,
|
||||
refreshParts.projectId || "",
|
||||
refreshParts.managedProjectId
|
||||
)
|
||||
|
||||
await client.set(providerId, {
|
||||
access: newTokens.access_token,
|
||||
refresh: formattedRefresh,
|
||||
expires: Date.now() + newTokens.expires_in * 1000,
|
||||
})
|
||||
|
||||
debugLog("[401] Token refreshed, retrying request...")
|
||||
return executeWithEndpoints()
|
||||
} catch (refreshError) {
|
||||
debugLog(`[401] Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`,
|
||||
type: "unauthorized",
|
||||
code: "token_refresh_failed",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (response) {
|
||||
debugLog(`Success with endpoint: ${endpoint}`)
|
||||
const transformedResponse = await transformResponseWithThinking(
|
||||
response,
|
||||
modelName || "",
|
||||
fetchInstanceId
|
||||
)
|
||||
return transformedResponse
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = `All Antigravity endpoints failed after ${maxEndpoints} attempts`
|
||||
debugLog(errorMessage)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: "endpoint_failure",
|
||||
code: "all_endpoints_failed",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return executeWithEndpoints()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type export for createAntigravityFetch return type
|
||||
*/
|
||||
export type AntigravityFetch = (url: string, init?: RequestInit) => Promise<Response>
|
||||
13
src/auth/antigravity/index.ts
Normal file
13
src/auth/antigravity/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
export * from "./oauth"
|
||||
export * from "./token"
|
||||
export * from "./project"
|
||||
export * from "./request"
|
||||
export * from "./response"
|
||||
export * from "./tools"
|
||||
export * from "./thinking"
|
||||
export * from "./thought-signature-store"
|
||||
export * from "./message-converter"
|
||||
export * from "./fetch"
|
||||
export * from "./plugin"
|
||||
206
src/auth/antigravity/message-converter.ts
Normal file
206
src/auth/antigravity/message-converter.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* OpenAI → Gemini message format converter
|
||||
*
|
||||
* Converts OpenAI-style messages to Gemini contents format,
|
||||
* injecting thoughtSignature into functionCall parts.
|
||||
*/
|
||||
|
||||
import { SKIP_THOUGHT_SIGNATURE_VALIDATOR } from "./constants"
|
||||
|
||||
function debugLog(message: string): void {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log(`[antigravity-converter] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenAIMessage {
|
||||
role: "system" | "user" | "assistant" | "tool"
|
||||
content?: string | OpenAIContentPart[]
|
||||
tool_calls?: OpenAIToolCall[]
|
||||
tool_call_id?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface OpenAIContentPart {
|
||||
type: string
|
||||
text?: string
|
||||
image_url?: { url: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface OpenAIToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
interface GeminiPart {
|
||||
text?: string
|
||||
functionCall?: {
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
functionResponse?: {
|
||||
name: string
|
||||
response: Record<string, unknown>
|
||||
}
|
||||
inlineData?: {
|
||||
mimeType: string
|
||||
data: string
|
||||
}
|
||||
thought_signature?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface GeminiContent {
|
||||
role: "user" | "model"
|
||||
parts: GeminiPart[]
|
||||
}
|
||||
|
||||
export function convertOpenAIToGemini(
|
||||
messages: OpenAIMessage[],
|
||||
thoughtSignature?: string
|
||||
): GeminiContent[] {
|
||||
debugLog(`Converting ${messages.length} messages, signature: ${thoughtSignature ? "present" : "none"}`)
|
||||
|
||||
const contents: GeminiContent[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "system") {
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [{ text: typeof msg.content === "string" ? msg.content : "" }],
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
const parts = convertContentToParts(msg.content)
|
||||
contents.push({ role: "user", parts })
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
const parts: GeminiPart[] = []
|
||||
|
||||
if (msg.content) {
|
||||
parts.push(...convertContentToParts(msg.content))
|
||||
}
|
||||
|
||||
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
for (const toolCall of msg.tool_calls) {
|
||||
let args: Record<string, unknown> = {}
|
||||
try {
|
||||
args = JSON.parse(toolCall.function.arguments)
|
||||
} catch {
|
||||
args = {}
|
||||
}
|
||||
|
||||
const part: GeminiPart = {
|
||||
functionCall: {
|
||||
name: toolCall.function.name,
|
||||
args,
|
||||
},
|
||||
}
|
||||
|
||||
// Always inject signature: use provided or default to skip validator (CLIProxyAPI approach)
|
||||
part.thoughtSignature = thoughtSignature || SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
||||
debugLog(`Injected signature into functionCall: ${toolCall.function.name} (${thoughtSignature ? "provided" : "default"})`)
|
||||
|
||||
parts.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
contents.push({ role: "model", parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
let response: Record<string, unknown> = {}
|
||||
try {
|
||||
response = typeof msg.content === "string"
|
||||
? JSON.parse(msg.content)
|
||||
: { result: msg.content }
|
||||
} catch {
|
||||
response = { result: msg.content }
|
||||
}
|
||||
|
||||
const toolName = msg.name || "unknown"
|
||||
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
response,
|
||||
},
|
||||
}],
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
debugLog(`Converted to ${contents.length} content blocks`)
|
||||
return contents
|
||||
}
|
||||
|
||||
function convertContentToParts(content: string | OpenAIContentPart[] | undefined): GeminiPart[] {
|
||||
if (!content) {
|
||||
return [{ text: "" }]
|
||||
}
|
||||
|
||||
if (typeof content === "string") {
|
||||
return [{ text: content }]
|
||||
}
|
||||
|
||||
const parts: GeminiPart[] = []
|
||||
for (const part of content) {
|
||||
if (part.type === "text" && part.text) {
|
||||
parts.push({ text: part.text })
|
||||
} else if (part.type === "image_url" && part.image_url?.url) {
|
||||
const url = part.image_url.url
|
||||
if (url.startsWith("data:")) {
|
||||
const match = url.match(/^data:([^;]+);base64,(.+)$/)
|
||||
if (match) {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: match[1],
|
||||
data: match[2],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [{ text: "" }]
|
||||
}
|
||||
|
||||
export function hasOpenAIMessages(body: Record<string, unknown>): boolean {
|
||||
return Array.isArray(body.messages) && body.messages.length > 0
|
||||
}
|
||||
|
||||
export function convertRequestBody(
|
||||
body: Record<string, unknown>,
|
||||
thoughtSignature?: string
|
||||
): Record<string, unknown> {
|
||||
if (!hasOpenAIMessages(body)) {
|
||||
debugLog("No messages array found, returning body as-is")
|
||||
return body
|
||||
}
|
||||
|
||||
const messages = body.messages as OpenAIMessage[]
|
||||
const contents = convertOpenAIToGemini(messages, thoughtSignature)
|
||||
|
||||
const converted = { ...body }
|
||||
delete converted.messages
|
||||
converted.contents = contents
|
||||
|
||||
debugLog(`Converted body: messages → contents (${contents.length} blocks)`)
|
||||
return converted
|
||||
}
|
||||
361
src/auth/antigravity/oauth.ts
Normal file
361
src/auth/antigravity/oauth.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Antigravity OAuth 2.0 flow implementation with PKCE.
|
||||
* Handles Google OAuth for Antigravity authentication.
|
||||
*/
|
||||
import { generatePKCE } from "@openauthjs/openauth/pkce"
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_CLIENT_ID,
|
||||
ANTIGRAVITY_CLIENT_SECRET,
|
||||
ANTIGRAVITY_REDIRECT_URI,
|
||||
ANTIGRAVITY_SCOPES,
|
||||
ANTIGRAVITY_CALLBACK_PORT,
|
||||
GOOGLE_AUTH_URL,
|
||||
GOOGLE_TOKEN_URL,
|
||||
GOOGLE_USERINFO_URL,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AntigravityTokenExchangeResult,
|
||||
AntigravityUserInfo,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* PKCE pair containing verifier and challenge.
|
||||
*/
|
||||
export interface PKCEPair {
|
||||
/** PKCE verifier - used during token exchange */
|
||||
verifier: string
|
||||
/** PKCE challenge - sent in auth URL */
|
||||
challenge: string
|
||||
/** Challenge method - always "S256" */
|
||||
method: string
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth state encoded in the auth URL.
|
||||
* Contains the PKCE verifier for later retrieval.
|
||||
*/
|
||||
export interface OAuthState {
|
||||
/** PKCE verifier */
|
||||
verifier: string
|
||||
/** Optional project ID */
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from building an OAuth authorization URL.
|
||||
*/
|
||||
export interface AuthorizationResult {
|
||||
/** Full OAuth URL to open in browser */
|
||||
url: string
|
||||
/** PKCE verifier to use during code exchange */
|
||||
verifier: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from the OAuth callback server.
|
||||
*/
|
||||
export interface CallbackResult {
|
||||
/** Authorization code from Google */
|
||||
code: string
|
||||
/** State parameter from callback */
|
||||
state: string
|
||||
/** Error message if any */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE verifier and challenge pair.
|
||||
* Uses @openauthjs/openauth for cryptographically secure generation.
|
||||
*
|
||||
* @returns PKCE pair with verifier, challenge, and method
|
||||
*/
|
||||
export async function generatePKCEPair(): Promise<PKCEPair> {
|
||||
const pkce = await generatePKCE()
|
||||
return {
|
||||
verifier: pkce.verifier,
|
||||
challenge: pkce.challenge,
|
||||
method: pkce.method,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode OAuth state into a URL-safe base64 string.
|
||||
*
|
||||
* @param state - OAuth state object
|
||||
* @returns Base64URL encoded state
|
||||
*/
|
||||
function encodeState(state: OAuthState): string {
|
||||
const json = JSON.stringify(state)
|
||||
return Buffer.from(json, "utf8").toString("base64url")
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode OAuth state from a base64 string.
|
||||
*
|
||||
* @param encoded - Base64URL or Base64 encoded state
|
||||
* @returns Decoded OAuth state
|
||||
*/
|
||||
export function decodeState(encoded: string): OAuthState {
|
||||
// Handle both base64url and standard base64
|
||||
const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const padded = normalized.padEnd(
|
||||
normalized.length + ((4 - (normalized.length % 4)) % 4),
|
||||
"="
|
||||
)
|
||||
const json = Buffer.from(padded, "base64").toString("utf8")
|
||||
const parsed = JSON.parse(json)
|
||||
|
||||
if (typeof parsed.verifier !== "string") {
|
||||
throw new Error("Missing PKCE verifier in state")
|
||||
}
|
||||
|
||||
return {
|
||||
verifier: parsed.verifier,
|
||||
projectId:
|
||||
typeof parsed.projectId === "string" ? parsed.projectId : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildAuthURL(
|
||||
projectId?: string,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
port: number = ANTIGRAVITY_CALLBACK_PORT
|
||||
): Promise<AuthorizationResult> {
|
||||
const pkce = await generatePKCEPair()
|
||||
|
||||
const state: OAuthState = {
|
||||
verifier: pkce.verifier,
|
||||
projectId,
|
||||
}
|
||||
|
||||
const redirectUri = `http://localhost:${port}/oauth-callback`
|
||||
|
||||
const url = new URL(GOOGLE_AUTH_URL)
|
||||
url.searchParams.set("client_id", clientId)
|
||||
url.searchParams.set("redirect_uri", redirectUri)
|
||||
url.searchParams.set("response_type", "code")
|
||||
url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" "))
|
||||
url.searchParams.set("state", encodeState(state))
|
||||
url.searchParams.set("code_challenge", pkce.challenge)
|
||||
url.searchParams.set("code_challenge_method", "S256")
|
||||
url.searchParams.set("access_type", "offline")
|
||||
url.searchParams.set("prompt", "consent")
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
verifier: pkce.verifier,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens.
|
||||
*
|
||||
* @param code - Authorization code from OAuth callback
|
||||
* @param verifier - PKCE verifier from initial auth request
|
||||
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
|
||||
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
|
||||
* @returns Token exchange result with access and refresh tokens
|
||||
*/
|
||||
export async function exchangeCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET,
|
||||
port: number = ANTIGRAVITY_CALLBACK_PORT
|
||||
): Promise<AntigravityTokenExchangeResult> {
|
||||
const redirectUri = `http://localhost:${port}/oauth-callback`
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
})
|
||||
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Token exchange failed: ${response.status} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_in: data.expires_in,
|
||||
token_type: data.token_type,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user info from Google's userinfo API.
|
||||
*
|
||||
* @param accessToken - Valid access token
|
||||
* @returns User info containing email
|
||||
*/
|
||||
export async function fetchUserInfo(
|
||||
accessToken: string
|
||||
): Promise<AntigravityUserInfo> {
|
||||
const response = await fetch(`${GOOGLE_USERINFO_URL}?alt=json`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
email?: string
|
||||
name?: string
|
||||
picture?: string
|
||||
}
|
||||
|
||||
return {
|
||||
email: data.email || "",
|
||||
name: data.name,
|
||||
picture: data.picture,
|
||||
}
|
||||
}
|
||||
|
||||
export interface CallbackServerHandle {
|
||||
port: number
|
||||
waitForCallback: () => Promise<CallbackResult>
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export function startCallbackServer(
|
||||
timeoutMs: number = 5 * 60 * 1000
|
||||
): CallbackServerHandle {
|
||||
let server: ReturnType<typeof Bun.serve> | null = null
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let resolveCallback: ((result: CallbackResult) => void) | null = null
|
||||
let rejectCallback: ((error: Error) => void) | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = null
|
||||
}
|
||||
if (server) {
|
||||
server.stop()
|
||||
server = null
|
||||
}
|
||||
}
|
||||
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request: Request): Response {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname === "/oauth-callback") {
|
||||
const code = url.searchParams.get("code") || ""
|
||||
const state = url.searchParams.get("state") || ""
|
||||
const error = url.searchParams.get("error") || undefined
|
||||
|
||||
let responseBody: string
|
||||
if (code && !error) {
|
||||
responseBody =
|
||||
"<html><body><h1>Login successful</h1><p>You can close this window.</p></body></html>"
|
||||
} else {
|
||||
responseBody =
|
||||
"<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>"
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
cleanup()
|
||||
if (resolveCallback) {
|
||||
resolveCallback({ code, state, error })
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return new Response(responseBody, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
const actualPort = server.port as number
|
||||
|
||||
const waitForCallback = (): Promise<CallbackResult> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolveCallback = resolve
|
||||
rejectCallback = reject
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error("OAuth callback timeout"))
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
port: actualPort,
|
||||
waitForCallback,
|
||||
close: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
export async function performOAuthFlow(
|
||||
projectId?: string,
|
||||
openBrowser?: (url: string) => Promise<void>,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
|
||||
): Promise<{
|
||||
tokens: AntigravityTokenExchangeResult
|
||||
userInfo: AntigravityUserInfo
|
||||
verifier: string
|
||||
}> {
|
||||
const serverHandle = startCallbackServer()
|
||||
|
||||
try {
|
||||
const auth = await buildAuthURL(projectId, clientId, serverHandle.port)
|
||||
|
||||
if (openBrowser) {
|
||||
await openBrowser(auth.url)
|
||||
}
|
||||
|
||||
const callback = await serverHandle.waitForCallback()
|
||||
|
||||
if (callback.error) {
|
||||
throw new Error(`OAuth error: ${callback.error}`)
|
||||
}
|
||||
|
||||
if (!callback.code) {
|
||||
throw new Error("No authorization code received")
|
||||
}
|
||||
|
||||
const state = decodeState(callback.state)
|
||||
if (state.verifier !== auth.verifier) {
|
||||
throw new Error("PKCE verifier mismatch - possible CSRF attack")
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(callback.code, auth.verifier, clientId, clientSecret, serverHandle.port)
|
||||
const userInfo = await fetchUserInfo(tokens.access_token)
|
||||
|
||||
return { tokens, userInfo, verifier: auth.verifier }
|
||||
} catch (err) {
|
||||
serverHandle.close()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
295
src/auth/antigravity/plugin.ts
Normal file
295
src/auth/antigravity/plugin.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Google Antigravity Auth Plugin for OpenCode
|
||||
*
|
||||
* Provides OAuth authentication for Google models via Antigravity API.
|
||||
* This plugin integrates with OpenCode's auth system to enable:
|
||||
* - OAuth 2.0 with PKCE flow for Google authentication
|
||||
* - Automatic token refresh
|
||||
* - Request/response transformation for Antigravity API
|
||||
*
|
||||
* @example
|
||||
* ```json
|
||||
* // opencode.json
|
||||
* {
|
||||
* "plugin": ["oh-my-opencode"],
|
||||
* "provider": {
|
||||
* "google": {
|
||||
* "options": {
|
||||
* "clientId": "custom-client-id",
|
||||
* "clientSecret": "custom-client-secret"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Auth, Provider } from "@opencode-ai/sdk"
|
||||
import type { AuthHook, AuthOuathResult, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET } from "./constants"
|
||||
import {
|
||||
buildAuthURL,
|
||||
exchangeCode,
|
||||
startCallbackServer,
|
||||
fetchUserInfo,
|
||||
decodeState,
|
||||
} from "./oauth"
|
||||
import { createAntigravityFetch } from "./fetch"
|
||||
import { fetchProjectContext } from "./project"
|
||||
import { formatTokenForStorage } from "./token"
|
||||
|
||||
/**
|
||||
* Provider ID for Google models
|
||||
* Antigravity is an auth method for Google, not a separate provider
|
||||
*/
|
||||
const GOOGLE_PROVIDER_ID = "google"
|
||||
|
||||
/**
|
||||
* Type guard to check if auth is OAuth type
|
||||
*/
|
||||
function isOAuthAuth(
|
||||
auth: Auth
|
||||
): auth is { type: "oauth"; access: string; refresh: string; expires: number } {
|
||||
return auth.type === "oauth"
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Google Antigravity OAuth plugin for OpenCode.
|
||||
*
|
||||
* This factory function creates an auth plugin that:
|
||||
* 1. Provides OAuth flow for Google authentication
|
||||
* 2. Creates a custom fetch interceptor for Antigravity API
|
||||
* 3. Handles token management and refresh
|
||||
*
|
||||
* @param input - Plugin input containing the OpenCode client
|
||||
* @returns Hooks object with auth configuration
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Used by OpenCode automatically when plugin is loaded
|
||||
* const hooks = await createGoogleAntigravityAuthPlugin({ client, ... })
|
||||
* ```
|
||||
*/
|
||||
export async function createGoogleAntigravityAuthPlugin({
|
||||
client,
|
||||
}: PluginInput): Promise<{ auth: AuthHook }> {
|
||||
// Cache for custom credentials from provider.options
|
||||
// These are populated by loader() and used by authorize()
|
||||
// Falls back to defaults if loader hasn't been called yet
|
||||
let cachedClientId: string = ANTIGRAVITY_CLIENT_ID
|
||||
let cachedClientSecret: string = ANTIGRAVITY_CLIENT_SECRET
|
||||
|
||||
const authHook: AuthHook = {
|
||||
/**
|
||||
* Provider identifier - must be "google" as Antigravity is
|
||||
* an auth method for Google models, not a separate provider
|
||||
*/
|
||||
provider: GOOGLE_PROVIDER_ID,
|
||||
|
||||
/**
|
||||
* Loader function called when auth is needed.
|
||||
* Reads credentials from provider.options and creates custom fetch.
|
||||
*
|
||||
* @param auth - Function to retrieve current auth state
|
||||
* @param provider - Provider configuration including options
|
||||
* @returns Object with custom fetch function
|
||||
*/
|
||||
loader: async (
|
||||
auth: () => Promise<Auth>,
|
||||
provider: Provider
|
||||
): Promise<Record<string, unknown>> => {
|
||||
const currentAuth = await auth()
|
||||
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log("[antigravity-plugin] loader called")
|
||||
console.log("[antigravity-plugin] auth type:", currentAuth?.type)
|
||||
console.log("[antigravity-plugin] auth keys:", Object.keys(currentAuth || {}))
|
||||
}
|
||||
|
||||
if (!isOAuthAuth(currentAuth)) {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log("[antigravity-plugin] NOT OAuth auth, returning empty")
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log("[antigravity-plugin] OAuth auth detected, creating custom fetch")
|
||||
}
|
||||
|
||||
cachedClientId =
|
||||
(provider.options?.clientId as string) || ANTIGRAVITY_CLIENT_ID
|
||||
cachedClientSecret =
|
||||
(provider.options?.clientSecret as string) || ANTIGRAVITY_CLIENT_SECRET
|
||||
|
||||
// Log if using custom credentials (for debugging)
|
||||
if (
|
||||
process.env.ANTIGRAVITY_DEBUG === "1" &&
|
||||
(cachedClientId !== ANTIGRAVITY_CLIENT_ID ||
|
||||
cachedClientSecret !== ANTIGRAVITY_CLIENT_SECRET)
|
||||
) {
|
||||
console.log(
|
||||
"[antigravity-plugin] Using custom credentials from provider.options"
|
||||
)
|
||||
}
|
||||
|
||||
// Create adapter for client.auth.set that matches fetch.ts AuthClient interface
|
||||
const authClient = {
|
||||
set: async (
|
||||
providerId: string,
|
||||
authData: { access?: string; refresh?: string; expires?: number }
|
||||
) => {
|
||||
await client.auth.set({
|
||||
body: {
|
||||
type: "oauth",
|
||||
access: authData.access || "",
|
||||
refresh: authData.refresh || "",
|
||||
expires: authData.expires || 0,
|
||||
},
|
||||
path: { id: providerId },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Create auth getter that returns compatible format for fetch.ts
|
||||
const getAuth = async (): Promise<{
|
||||
access?: string
|
||||
refresh?: string
|
||||
expires?: number
|
||||
}> => {
|
||||
const authState = await auth()
|
||||
if (isOAuthAuth(authState)) {
|
||||
return {
|
||||
access: authState.access,
|
||||
refresh: authState.refresh,
|
||||
expires: authState.expires,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const antigravityFetch = createAntigravityFetch(
|
||||
getAuth,
|
||||
authClient,
|
||||
GOOGLE_PROVIDER_ID,
|
||||
cachedClientId,
|
||||
cachedClientSecret
|
||||
)
|
||||
|
||||
return {
|
||||
fetch: antigravityFetch,
|
||||
apiKey: "antigravity-oauth",
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Authentication methods available for this provider.
|
||||
* Only OAuth is supported - no prompts for credentials.
|
||||
*/
|
||||
methods: [
|
||||
{
|
||||
type: "oauth",
|
||||
label: "OAuth with Google (Antigravity)",
|
||||
// NO prompts - credentials come from provider.options or defaults
|
||||
// OAuth flow starts immediately when user selects this method
|
||||
|
||||
/**
|
||||
* Starts the OAuth authorization flow.
|
||||
* Opens browser for Google OAuth and waits for callback.
|
||||
*
|
||||
* @returns Authorization result with URL and callback
|
||||
*/
|
||||
authorize: async (): Promise<AuthOuathResult> => {
|
||||
const serverHandle = startCallbackServer()
|
||||
const { url, verifier } = await buildAuthURL(undefined, cachedClientId, serverHandle.port)
|
||||
|
||||
return {
|
||||
url,
|
||||
instructions:
|
||||
"Complete the sign-in in your browser. We'll automatically detect when you're done.",
|
||||
method: "auto",
|
||||
|
||||
callback: async () => {
|
||||
try {
|
||||
const result = await serverHandle.waitForCallback()
|
||||
|
||||
if (result.error) {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.error(`[antigravity-plugin] OAuth error: ${result.error}`)
|
||||
}
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
|
||||
if (!result.code) {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.error("[antigravity-plugin] No authorization code received")
|
||||
}
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
|
||||
const state = decodeState(result.state)
|
||||
if (state.verifier !== verifier) {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.error("[antigravity-plugin] PKCE verifier mismatch")
|
||||
}
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
|
||||
const tokens = await exchangeCode(result.code, verifier, cachedClientId, cachedClientSecret, serverHandle.port)
|
||||
|
||||
try {
|
||||
const userInfo = await fetchUserInfo(tokens.access_token)
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log(`[antigravity-plugin] Authenticated as: ${userInfo.email}`)
|
||||
}
|
||||
} catch {
|
||||
// User info is optional
|
||||
}
|
||||
|
||||
const projectContext = await fetchProjectContext(tokens.access_token)
|
||||
|
||||
const formattedRefresh = formatTokenForStorage(
|
||||
tokens.refresh_token,
|
||||
projectContext.cloudaicompanionProject || "",
|
||||
projectContext.managedProjectId
|
||||
)
|
||||
|
||||
return {
|
||||
type: "success" as const,
|
||||
access: tokens.access_token,
|
||||
refresh: formattedRefresh,
|
||||
expires: Date.now() + tokens.expires_in * 1000,
|
||||
}
|
||||
} catch (error) {
|
||||
serverHandle.close()
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.error(
|
||||
`[antigravity-plugin] OAuth flow failed: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`
|
||||
)
|
||||
}
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
auth: authHook,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default export for OpenCode plugin system
|
||||
*/
|
||||
export default createGoogleAntigravityAuthPlugin
|
||||
|
||||
/**
|
||||
* Named export for explicit imports
|
||||
*/
|
||||
export const GoogleAntigravityAuthPlugin = createGoogleAntigravityAuthPlugin
|
||||
159
src/auth/antigravity/project.ts
Normal file
159
src/auth/antigravity/project.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Antigravity project context management.
|
||||
* Handles fetching GCP project ID via Google's loadCodeAssist API.
|
||||
*/
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_DEFAULT_PROJECT_ID,
|
||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||
ANTIGRAVITY_API_VERSION,
|
||||
ANTIGRAVITY_HEADERS,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AntigravityProjectContext,
|
||||
AntigravityLoadCodeAssistResponse,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* In-memory cache for project context per access token.
|
||||
* Prevents redundant API calls for the same token.
|
||||
*/
|
||||
const projectContextCache = new Map<string, AntigravityProjectContext>()
|
||||
|
||||
/**
|
||||
* Client metadata for loadCodeAssist API request.
|
||||
* Matches cliproxyapi implementation.
|
||||
*/
|
||||
const CODE_ASSIST_METADATA = {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Extracts the project ID from a cloudaicompanionProject field.
|
||||
* Handles both string and object formats.
|
||||
*
|
||||
* @param project - The cloudaicompanionProject value from API response
|
||||
* @returns Extracted project ID string, or undefined if not found
|
||||
*/
|
||||
function extractProjectId(
|
||||
project: string | { id: string } | undefined
|
||||
): string | undefined {
|
||||
if (!project) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Handle string format
|
||||
if (typeof project === "string") {
|
||||
const trimmed = project.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
// Handle object format { id: string }
|
||||
if (typeof project === "object" && "id" in project) {
|
||||
const id = project.id
|
||||
if (typeof id === "string") {
|
||||
const trimmed = id.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the loadCodeAssist API to get project context.
|
||||
* Tries each endpoint in the fallback list until one succeeds.
|
||||
*
|
||||
* @param accessToken - Valid OAuth access token
|
||||
* @returns API response or null if all endpoints fail
|
||||
*/
|
||||
async function callLoadCodeAssistAPI(
|
||||
accessToken: string
|
||||
): Promise<AntigravityLoadCodeAssistResponse | null> {
|
||||
const requestBody = {
|
||||
metadata: CODE_ASSIST_METADATA,
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ANTIGRAVITY_HEADERS["User-Agent"],
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"],
|
||||
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
|
||||
}
|
||||
|
||||
// Try each endpoint in the fallback list
|
||||
for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
|
||||
const url = `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Try next endpoint on failure
|
||||
continue
|
||||
}
|
||||
|
||||
const data =
|
||||
(await response.json()) as AntigravityLoadCodeAssistResponse
|
||||
return data
|
||||
} catch {
|
||||
// Network or parsing error, try next endpoint
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// All endpoints failed
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch project context from Google's loadCodeAssist API.
|
||||
* Extracts the cloudaicompanionProject from the response.
|
||||
*
|
||||
* @param accessToken - Valid OAuth access token
|
||||
* @returns Project context with cloudaicompanionProject ID
|
||||
*/
|
||||
export async function fetchProjectContext(
|
||||
accessToken: string
|
||||
): Promise<AntigravityProjectContext> {
|
||||
const cached = projectContextCache.get(accessToken)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const response = await callLoadCodeAssistAPI(accessToken)
|
||||
const projectId = response
|
||||
? extractProjectId(response.cloudaicompanionProject)
|
||||
: undefined
|
||||
|
||||
const result: AntigravityProjectContext = {
|
||||
cloudaicompanionProject: projectId || "",
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
projectContextCache.set(accessToken, result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the project context cache.
|
||||
* Call this when tokens are refreshed or invalidated.
|
||||
*
|
||||
* @param accessToken - Optional specific token to clear, or clears all if not provided
|
||||
*/
|
||||
export function clearProjectContextCache(accessToken?: string): void {
|
||||
if (accessToken) {
|
||||
projectContextCache.delete(accessToken)
|
||||
} else {
|
||||
projectContextCache.clear()
|
||||
}
|
||||
}
|
||||
303
src/auth/antigravity/request.ts
Normal file
303
src/auth/antigravity/request.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Antigravity request transformer.
|
||||
* Transforms OpenAI-format requests to Antigravity format.
|
||||
* Does NOT handle tool normalization (handled by tools.ts in Task 9).
|
||||
*/
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_HEADERS,
|
||||
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
||||
ANTIGRAVITY_API_VERSION,
|
||||
SKIP_THOUGHT_SIGNATURE_VALIDATOR,
|
||||
} from "./constants"
|
||||
import type { AntigravityRequestBody } from "./types"
|
||||
|
||||
/**
|
||||
* Result of request transformation including URL, headers, and body.
|
||||
*/
|
||||
export interface TransformedRequest {
|
||||
/** Transformed URL for Antigravity API */
|
||||
url: string
|
||||
/** Request headers including Authorization and Antigravity-specific headers */
|
||||
headers: Record<string, string>
|
||||
/** Transformed request body in Antigravity format */
|
||||
body: AntigravityRequestBody
|
||||
/** Whether this is a streaming request */
|
||||
streaming: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Antigravity-specific request headers.
|
||||
* Includes Authorization, User-Agent, X-Goog-Api-Client, and Client-Metadata.
|
||||
*
|
||||
* @param accessToken - OAuth access token for Authorization header
|
||||
* @returns Headers object with all required Antigravity headers
|
||||
*/
|
||||
export function buildRequestHeaders(accessToken: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ANTIGRAVITY_HEADERS["User-Agent"],
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_HEADERS["X-Goog-Api-Client"],
|
||||
"Client-Metadata": ANTIGRAVITY_HEADERS["Client-Metadata"],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model name from request body.
|
||||
* OpenAI-format requests include model in the body.
|
||||
*
|
||||
* @param body - Request body that may contain a model field
|
||||
* @returns Model name or undefined if not found
|
||||
*/
|
||||
export function extractModelFromBody(
|
||||
body: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const model = body.model
|
||||
if (typeof model === "string" && model.trim()) {
|
||||
return model.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model name from URL path.
|
||||
* Handles Google Generative Language API format: /models/{model}:{action}
|
||||
*
|
||||
* @param url - Request URL to parse
|
||||
* @returns Model name or undefined if not found
|
||||
*/
|
||||
export function extractModelFromUrl(url: string): string | undefined {
|
||||
// Match Google's API format: /models/gemini-3-pro:generateContent
|
||||
const match = url.match(/\/models\/([^:]+):/)
|
||||
if (match && match[1]) {
|
||||
return match[1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the action type from the URL path.
|
||||
* E.g., generateContent, streamGenerateContent
|
||||
*
|
||||
* @param url - Request URL to parse
|
||||
* @returns Action name or undefined if not found
|
||||
*/
|
||||
export function extractActionFromUrl(url: string): string | undefined {
|
||||
// Match Google's API format: /models/gemini-3-pro:generateContent
|
||||
const match = url.match(/\/models\/[^:]+:(\w+)/)
|
||||
if (match && match[1]) {
|
||||
return match[1]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is targeting Google's Generative Language API.
|
||||
*
|
||||
* @param url - URL to check
|
||||
* @returns true if this is a Google Generative Language API request
|
||||
*/
|
||||
export function isGenerativeLanguageRequest(url: string): boolean {
|
||||
return url.includes("generativelanguage.googleapis.com")
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Antigravity API URL for the given action.
|
||||
*
|
||||
* @param baseEndpoint - Base Antigravity endpoint URL (from fallbacks)
|
||||
* @param action - API action (e.g., generateContent, streamGenerateContent)
|
||||
* @param streaming - Whether to append SSE query parameter
|
||||
* @returns Formatted Antigravity API URL
|
||||
*/
|
||||
export function buildAntigravityUrl(
|
||||
baseEndpoint: string,
|
||||
action: string,
|
||||
streaming: boolean
|
||||
): string {
|
||||
const query = streaming ? "?alt=sse" : ""
|
||||
return `${baseEndpoint}/${ANTIGRAVITY_API_VERSION}:${action}${query}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first available Antigravity endpoint.
|
||||
* Can be used with fallback logic in fetch.ts.
|
||||
*
|
||||
* @returns Default (first) Antigravity endpoint
|
||||
*/
|
||||
export function getDefaultEndpoint(): string {
|
||||
return ANTIGRAVITY_ENDPOINT_FALLBACKS[0]
|
||||
}
|
||||
|
||||
function generateRequestId(): string {
|
||||
return `agent-${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
export function wrapRequestBody(
|
||||
body: Record<string, unknown>,
|
||||
projectId: string,
|
||||
modelName: string,
|
||||
sessionId: string
|
||||
): AntigravityRequestBody {
|
||||
const requestPayload = { ...body }
|
||||
delete requestPayload.model
|
||||
|
||||
return {
|
||||
project: projectId,
|
||||
model: modelName,
|
||||
userAgent: "antigravity",
|
||||
requestId: generateRequestId(),
|
||||
request: {
|
||||
...requestPayload,
|
||||
sessionId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface ContentPart {
|
||||
functionCall?: Record<string, unknown>
|
||||
thoughtSignature?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ContentBlock {
|
||||
role?: string
|
||||
parts?: ContentPart[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function debugLog(message: string): void {
|
||||
if (process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.log(`[antigravity-request] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function injectThoughtSignatureIntoFunctionCalls(
|
||||
body: Record<string, unknown>,
|
||||
signature: string | undefined
|
||||
): Record<string, unknown> {
|
||||
// Always use skip validator as fallback (CLIProxyAPI approach)
|
||||
const effectiveSignature = signature || SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
||||
debugLog(`[TSIG][INJECT] signature=${effectiveSignature.substring(0, 30)}... (${signature ? "provided" : "default"})`)
|
||||
debugLog(`[TSIG][INJECT] body keys: ${Object.keys(body).join(", ")}`)
|
||||
|
||||
const contents = body.contents as ContentBlock[] | undefined
|
||||
if (!contents || !Array.isArray(contents)) {
|
||||
debugLog(`[TSIG][INJECT] No contents array! Has messages: ${!!body.messages}`)
|
||||
return body
|
||||
}
|
||||
|
||||
debugLog(`[TSIG][INJECT] Found ${contents.length} content blocks`)
|
||||
let injectedCount = 0
|
||||
const modifiedContents = contents.map((content) => {
|
||||
if (!content.parts || !Array.isArray(content.parts)) {
|
||||
return content
|
||||
}
|
||||
|
||||
const modifiedParts = content.parts.map((part) => {
|
||||
if (part.functionCall && !part.thoughtSignature) {
|
||||
injectedCount++
|
||||
return {
|
||||
...part,
|
||||
thoughtSignature: effectiveSignature,
|
||||
}
|
||||
}
|
||||
return part
|
||||
})
|
||||
|
||||
return { ...content, parts: modifiedParts }
|
||||
})
|
||||
|
||||
debugLog(`[TSIG][INJECT] injected signature into ${injectedCount} functionCall(s)`)
|
||||
return { ...body, contents: modifiedContents }
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if request is for streaming.
|
||||
* Checks both action name and request body for stream flag.
|
||||
*
|
||||
* @param url - Request URL
|
||||
* @param body - Request body
|
||||
* @returns true if streaming is requested
|
||||
*/
|
||||
export function isStreamingRequest(
|
||||
url: string,
|
||||
body: Record<string, unknown>
|
||||
): boolean {
|
||||
// Check URL action
|
||||
const action = extractActionFromUrl(url)
|
||||
if (action === "streamGenerateContent") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check body for stream flag
|
||||
if (body.stream === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export interface TransformRequestOptions {
|
||||
url: string
|
||||
body: Record<string, unknown>
|
||||
accessToken: string
|
||||
projectId: string
|
||||
sessionId: string
|
||||
modelName?: string
|
||||
endpointOverride?: string
|
||||
thoughtSignature?: string
|
||||
}
|
||||
|
||||
export function transformRequest(options: TransformRequestOptions): TransformedRequest {
|
||||
const {
|
||||
url,
|
||||
body,
|
||||
accessToken,
|
||||
projectId,
|
||||
sessionId,
|
||||
modelName,
|
||||
endpointOverride,
|
||||
thoughtSignature,
|
||||
} = options
|
||||
|
||||
const effectiveModel =
|
||||
modelName || extractModelFromBody(body) || extractModelFromUrl(url) || "gemini-3-pro-preview"
|
||||
|
||||
const streaming = isStreamingRequest(url, body)
|
||||
const action = streaming ? "streamGenerateContent" : "generateContent"
|
||||
|
||||
const endpoint = endpointOverride || getDefaultEndpoint()
|
||||
const transformedUrl = buildAntigravityUrl(endpoint, action, streaming)
|
||||
|
||||
const headers = buildRequestHeaders(accessToken)
|
||||
if (streaming) {
|
||||
headers["Accept"] = "text/event-stream"
|
||||
}
|
||||
|
||||
const bodyWithSignature = injectThoughtSignatureIntoFunctionCalls(body, thoughtSignature)
|
||||
const wrappedBody = wrapRequestBody(bodyWithSignature, projectId, effectiveModel, sessionId)
|
||||
|
||||
return {
|
||||
url: transformedUrl,
|
||||
headers,
|
||||
body: wrappedBody,
|
||||
streaming,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare request headers for streaming responses.
|
||||
* Adds Accept header for SSE format.
|
||||
*
|
||||
* @param headers - Existing headers object
|
||||
* @returns Headers with streaming support
|
||||
*/
|
||||
export function addStreamingHeaders(
|
||||
headers: Record<string, string>
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...headers,
|
||||
Accept: "text/event-stream",
|
||||
}
|
||||
}
|
||||
598
src/auth/antigravity/response.ts
Normal file
598
src/auth/antigravity/response.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Antigravity Response Handler
|
||||
* Transforms Antigravity/Gemini API responses to OpenAI-compatible format
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Non-streaming response transformation
|
||||
* - SSE streaming response transformation (buffered - see transformStreamingResponse)
|
||||
* - Error response handling with retry-after extraction
|
||||
* - Usage metadata extraction from x-antigravity-* headers
|
||||
*/
|
||||
|
||||
import type { AntigravityError, AntigravityUsage } from "./types"
|
||||
|
||||
/**
|
||||
* Usage metadata extracted from Antigravity response headers
|
||||
*/
|
||||
export interface AntigravityUsageMetadata {
|
||||
cachedContentTokenCount?: number
|
||||
totalTokenCount?: number
|
||||
promptTokenCount?: number
|
||||
candidatesTokenCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform result with response and metadata
|
||||
*/
|
||||
export interface TransformResult {
|
||||
response: Response
|
||||
usage?: AntigravityUsageMetadata
|
||||
retryAfterMs?: number
|
||||
error?: AntigravityError
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract usage metadata from Antigravity response headers
|
||||
*
|
||||
* Antigravity sets these headers:
|
||||
* - x-antigravity-cached-content-token-count
|
||||
* - x-antigravity-total-token-count
|
||||
* - x-antigravity-prompt-token-count
|
||||
* - x-antigravity-candidates-token-count
|
||||
*
|
||||
* @param headers - Response headers
|
||||
* @returns Usage metadata if found
|
||||
*/
|
||||
export function extractUsageFromHeaders(headers: Headers): AntigravityUsageMetadata | undefined {
|
||||
const cached = headers.get("x-antigravity-cached-content-token-count")
|
||||
const total = headers.get("x-antigravity-total-token-count")
|
||||
const prompt = headers.get("x-antigravity-prompt-token-count")
|
||||
const candidates = headers.get("x-antigravity-candidates-token-count")
|
||||
|
||||
// Return undefined if no usage headers found
|
||||
if (!cached && !total && !prompt && !candidates) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const usage: AntigravityUsageMetadata = {}
|
||||
|
||||
if (cached) {
|
||||
const parsed = parseInt(cached, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
usage.cachedContentTokenCount = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if (total) {
|
||||
const parsed = parseInt(total, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
usage.totalTokenCount = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
const parsed = parseInt(prompt, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
usage.promptTokenCount = parsed
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates) {
|
||||
const parsed = parseInt(candidates, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
usage.candidatesTokenCount = parsed
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(usage).length > 0 ? usage : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract retry-after value from error response
|
||||
*
|
||||
* Antigravity returns retry info in error.details array:
|
||||
* {
|
||||
* error: {
|
||||
* details: [{
|
||||
* "@type": "type.googleapis.com/google.rpc.RetryInfo",
|
||||
* "retryDelay": "5.123s"
|
||||
* }]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Also checks standard Retry-After header.
|
||||
*
|
||||
* @param response - Response object (for headers)
|
||||
* @param errorBody - Parsed error body (optional)
|
||||
* @returns Retry after value in milliseconds, or undefined
|
||||
*/
|
||||
export function extractRetryAfterMs(
|
||||
response: Response,
|
||||
errorBody?: Record<string, unknown>,
|
||||
): number | undefined {
|
||||
// First, check standard Retry-After header
|
||||
const retryAfterHeader = response.headers.get("Retry-After")
|
||||
if (retryAfterHeader) {
|
||||
const seconds = parseFloat(retryAfterHeader)
|
||||
if (!isNaN(seconds) && seconds > 0) {
|
||||
return Math.ceil(seconds * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Check retry-after-ms header (set by some transformers)
|
||||
const retryAfterMsHeader = response.headers.get("retry-after-ms")
|
||||
if (retryAfterMsHeader) {
|
||||
const ms = parseInt(retryAfterMsHeader, 10)
|
||||
if (!isNaN(ms) && ms > 0) {
|
||||
return ms
|
||||
}
|
||||
}
|
||||
|
||||
// Check error body for RetryInfo
|
||||
if (!errorBody) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const error = errorBody.error as Record<string, unknown> | undefined
|
||||
if (!error?.details || !Array.isArray(error.details)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const retryInfo = (error.details as Array<Record<string, unknown>>).find(
|
||||
(detail) => detail["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
|
||||
)
|
||||
|
||||
if (!retryInfo?.retryDelay || typeof retryInfo.retryDelay !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Parse retryDelay format: "5.123s"
|
||||
const match = retryInfo.retryDelay.match(/^([\d.]+)s$/)
|
||||
if (match?.[1]) {
|
||||
const seconds = parseFloat(match[1])
|
||||
if (!isNaN(seconds) && seconds > 0) {
|
||||
return Math.ceil(seconds * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error response body and extract useful details
|
||||
*
|
||||
* @param text - Raw response text
|
||||
* @returns Parsed error or undefined
|
||||
*/
|
||||
export function parseErrorBody(text: string): AntigravityError | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
|
||||
// Handle error wrapper
|
||||
if (parsed.error && typeof parsed.error === "object") {
|
||||
const errorObj = parsed.error as Record<string, unknown>
|
||||
return {
|
||||
message: String(errorObj.message || "Unknown error"),
|
||||
type: errorObj.type ? String(errorObj.type) : undefined,
|
||||
code: errorObj.code as string | number | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle direct error message
|
||||
if (parsed.message && typeof parsed.message === "string") {
|
||||
return {
|
||||
message: parsed.message,
|
||||
type: parsed.type ? String(parsed.type) : undefined,
|
||||
code: parsed.code as string | number | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch {
|
||||
// If not valid JSON, return generic error
|
||||
return {
|
||||
message: text || "Unknown error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a non-streaming Antigravity response to OpenAI-compatible format
|
||||
*
|
||||
* For non-streaming responses:
|
||||
* - Parses the response body
|
||||
* - Unwraps the `response` field if present (Antigravity wraps responses)
|
||||
* - Extracts usage metadata from headers
|
||||
* - Handles error responses
|
||||
*
|
||||
* Note: Does NOT handle thinking block extraction (Task 10)
|
||||
* Note: Does NOT handle tool normalization (Task 9)
|
||||
*
|
||||
* @param response - Fetch Response object
|
||||
* @returns TransformResult with transformed response and metadata
|
||||
*/
|
||||
export async function transformResponse(response: Response): Promise<TransformResult> {
|
||||
const headers = new Headers(response.headers)
|
||||
const usage = extractUsageFromHeaders(headers)
|
||||
|
||||
// Handle error responses
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
const error = parseErrorBody(text)
|
||||
const retryAfterMs = extractRetryAfterMs(response, error ? { error } : undefined)
|
||||
|
||||
// Parse to get full error body for retry-after extraction
|
||||
let errorBody: Record<string, unknown> | undefined
|
||||
try {
|
||||
errorBody = JSON.parse(text) as Record<string, unknown>
|
||||
} catch {
|
||||
errorBody = { error: { message: text } }
|
||||
}
|
||||
|
||||
const retryMs = extractRetryAfterMs(response, errorBody) ?? retryAfterMs
|
||||
|
||||
// Set retry headers if found
|
||||
if (retryMs) {
|
||||
headers.set("Retry-After", String(Math.ceil(retryMs / 1000)))
|
||||
headers.set("retry-after-ms", String(retryMs))
|
||||
}
|
||||
|
||||
return {
|
||||
response: new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
retryAfterMs: retryMs,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle successful response
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
const isJson = contentType.includes("application/json")
|
||||
|
||||
if (!isJson) {
|
||||
// Return non-JSON responses as-is
|
||||
return { response, usage }
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await response.text()
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
|
||||
// Antigravity wraps response in { response: { ... } }
|
||||
// Unwrap if present
|
||||
let transformedBody: unknown = parsed
|
||||
if (parsed.response !== undefined) {
|
||||
transformedBody = parsed.response
|
||||
}
|
||||
|
||||
return {
|
||||
response: new Response(JSON.stringify(transformedBody), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, return original response
|
||||
return { response, usage }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single SSE data line
|
||||
*
|
||||
* Antigravity SSE format:
|
||||
* data: { "response": { ... actual data ... } }
|
||||
*
|
||||
* OpenAI SSE format:
|
||||
* data: { ... actual data ... }
|
||||
*
|
||||
* @param line - SSE data line
|
||||
* @returns Transformed line
|
||||
*/
|
||||
function transformSseLine(line: string): string {
|
||||
if (!line.startsWith("data:")) {
|
||||
return line
|
||||
}
|
||||
|
||||
const json = line.slice(5).trim()
|
||||
if (!json || json === "[DONE]") {
|
||||
return line
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>
|
||||
|
||||
// Unwrap { response: { ... } } wrapper
|
||||
if (parsed.response !== undefined) {
|
||||
return `data: ${JSON.stringify(parsed.response)}`
|
||||
}
|
||||
|
||||
return line
|
||||
} catch {
|
||||
// If parsing fails, return original line
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform SSE streaming payload
|
||||
*
|
||||
* Processes each line in the SSE stream:
|
||||
* - Unwraps { response: { ... } } wrapper from data lines
|
||||
* - Preserves other SSE control lines (event:, id:, retry:, empty lines)
|
||||
*
|
||||
* Note: Does NOT extract thinking blocks (Task 10)
|
||||
*
|
||||
* @param payload - Raw SSE payload text
|
||||
* @returns Transformed SSE payload
|
||||
*/
|
||||
export function transformStreamingPayload(payload: string): string {
|
||||
return payload
|
||||
.split("\n")
|
||||
.map(transformSseLine)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function createSseTransformStream(): TransformStream<Uint8Array, Uint8Array> {
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
let buffer = ""
|
||||
|
||||
return new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
buffer += decoder.decode(chunk, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
const transformed = transformSseLine(line)
|
||||
controller.enqueue(encoder.encode(transformed + "\n"))
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
if (buffer) {
|
||||
const transformed = transformSseLine(buffer)
|
||||
controller.enqueue(encoder.encode(transformed))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a streaming SSE response from Antigravity to OpenAI format.
|
||||
*
|
||||
* Uses TransformStream to process SSE chunks incrementally as they arrive.
|
||||
* Each line is transformed immediately and yielded to the client.
|
||||
*
|
||||
* @param response - The SSE response from Antigravity API
|
||||
* @returns TransformResult with transformed streaming response
|
||||
*/
|
||||
export async function transformStreamingResponse(response: Response): Promise<TransformResult> {
|
||||
const headers = new Headers(response.headers)
|
||||
const usage = extractUsageFromHeaders(headers)
|
||||
|
||||
// Handle error responses
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
const error = parseErrorBody(text)
|
||||
|
||||
let errorBody: Record<string, unknown> | undefined
|
||||
try {
|
||||
errorBody = JSON.parse(text) as Record<string, unknown>
|
||||
} catch {
|
||||
errorBody = { error: { message: text } }
|
||||
}
|
||||
|
||||
const retryAfterMs = extractRetryAfterMs(response, errorBody)
|
||||
|
||||
if (retryAfterMs) {
|
||||
headers.set("Retry-After", String(Math.ceil(retryAfterMs / 1000)))
|
||||
headers.set("retry-after-ms", String(retryAfterMs))
|
||||
}
|
||||
|
||||
return {
|
||||
response: new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
retryAfterMs,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
// Check content type
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
const isEventStream =
|
||||
contentType.includes("text/event-stream") || response.url.includes("alt=sse")
|
||||
|
||||
if (!isEventStream) {
|
||||
// Not SSE, delegate to non-streaming transform
|
||||
// Clone response since we need to read it
|
||||
const text = await response.text()
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>
|
||||
let transformedBody: unknown = parsed
|
||||
if (parsed.response !== undefined) {
|
||||
transformedBody = parsed.response
|
||||
}
|
||||
return {
|
||||
response: new Response(JSON.stringify(transformedBody), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
response: new Response(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return { response, usage }
|
||||
}
|
||||
|
||||
headers.delete("content-length")
|
||||
headers.delete("content-encoding")
|
||||
headers.set("content-type", "text/event-stream; charset=utf-8")
|
||||
|
||||
const transformStream = createSseTransformStream()
|
||||
const transformedBody = response.body.pipeThrough(transformStream)
|
||||
|
||||
return {
|
||||
response: new Response(transformedBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
}),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response is a streaming SSE response
|
||||
*
|
||||
* @param response - Fetch Response object
|
||||
* @returns True if response is SSE stream
|
||||
*/
|
||||
export function isStreamingResponse(response: Response): boolean {
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
return contentType.includes("text/event-stream") || response.url.includes("alt=sse")
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thought signature from SSE payload text
|
||||
*
|
||||
* Looks for thoughtSignature in SSE events:
|
||||
* data: { "response": { "candidates": [{ "content": { "parts": [{ "thoughtSignature": "..." }] } }] } }
|
||||
*
|
||||
* Returns the last found signature (most recent in the stream).
|
||||
*
|
||||
* @param payload - SSE payload text
|
||||
* @returns Last thought signature if found
|
||||
*/
|
||||
export function extractSignatureFromSsePayload(payload: string): string | undefined {
|
||||
const lines = payload.split("\n")
|
||||
let lastSignature: string | undefined
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const json = line.slice(5).trim()
|
||||
if (!json || json === "[DONE]") {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>
|
||||
|
||||
// Check in response wrapper (Antigravity format)
|
||||
const response = (parsed.response || parsed) as Record<string, unknown>
|
||||
const candidates = response.candidates as Array<Record<string, unknown>> | undefined
|
||||
|
||||
if (candidates && Array.isArray(candidates)) {
|
||||
for (const candidate of candidates) {
|
||||
const content = candidate.content as Record<string, unknown> | undefined
|
||||
const parts = content?.parts as Array<Record<string, unknown>> | undefined
|
||||
|
||||
if (parts && Array.isArray(parts)) {
|
||||
for (const part of parts) {
|
||||
const sig = (part.thoughtSignature || part.thought_signature) as string | undefined
|
||||
if (sig && typeof sig === "string") {
|
||||
lastSignature = sig
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue to next line if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
return lastSignature
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract usage from SSE payload text
|
||||
*
|
||||
* Looks for usageMetadata in SSE events:
|
||||
* data: { "usageMetadata": { ... } }
|
||||
*
|
||||
* @param payload - SSE payload text
|
||||
* @returns Usage if found
|
||||
*/
|
||||
export function extractUsageFromSsePayload(payload: string): AntigravityUsage | undefined {
|
||||
const lines = payload.split("\n")
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const json = line.slice(5).trim()
|
||||
if (!json || json === "[DONE]") {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>
|
||||
|
||||
// Check for usageMetadata at top level
|
||||
if (parsed.usageMetadata && typeof parsed.usageMetadata === "object") {
|
||||
const meta = parsed.usageMetadata as Record<string, unknown>
|
||||
return {
|
||||
prompt_tokens: typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : 0,
|
||||
completion_tokens:
|
||||
typeof meta.candidatesTokenCount === "number" ? meta.candidatesTokenCount : 0,
|
||||
total_tokens: typeof meta.totalTokenCount === "number" ? meta.totalTokenCount : 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Check for usage in response wrapper
|
||||
if (parsed.response && typeof parsed.response === "object") {
|
||||
const resp = parsed.response as Record<string, unknown>
|
||||
if (resp.usageMetadata && typeof resp.usageMetadata === "object") {
|
||||
const meta = resp.usageMetadata as Record<string, unknown>
|
||||
return {
|
||||
prompt_tokens: typeof meta.promptTokenCount === "number" ? meta.promptTokenCount : 0,
|
||||
completion_tokens:
|
||||
typeof meta.candidatesTokenCount === "number" ? meta.candidatesTokenCount : 0,
|
||||
total_tokens: typeof meta.totalTokenCount === "number" ? meta.totalTokenCount : 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for standard OpenAI-style usage
|
||||
if (parsed.usage && typeof parsed.usage === "object") {
|
||||
const u = parsed.usage as Record<string, unknown>
|
||||
return {
|
||||
prompt_tokens: typeof u.prompt_tokens === "number" ? u.prompt_tokens : 0,
|
||||
completion_tokens: typeof u.completion_tokens === "number" ? u.completion_tokens : 0,
|
||||
total_tokens: typeof u.total_tokens === "number" ? u.total_tokens : 0,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue to next line if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
571
src/auth/antigravity/thinking.ts
Normal file
571
src/auth/antigravity/thinking.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* Antigravity Thinking Block Handler (Gemini only)
|
||||
*
|
||||
* Handles extraction and transformation of thinking/reasoning blocks
|
||||
* from Gemini responses. Thinking blocks contain the model's internal
|
||||
* reasoning process, available in `-high` model variants.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Extract thinking blocks from Gemini response format
|
||||
* - Detect thinking-capable model variants (`-high` suffix)
|
||||
* - Format thinking blocks for OpenAI-compatible output
|
||||
*
|
||||
* Note: This is Gemini-only. Claude models are NOT handled by Antigravity.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a single thinking/reasoning block extracted from Gemini response
|
||||
*/
|
||||
export interface ThinkingBlock {
|
||||
/** The thinking/reasoning text content */
|
||||
text: string
|
||||
/** Optional signature for signed thinking blocks (required for multi-turn) */
|
||||
signature?: string
|
||||
/** Index of the thinking block in sequence */
|
||||
index?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw part structure from Gemini response candidates
|
||||
*/
|
||||
export interface GeminiPart {
|
||||
/** Text content of the part */
|
||||
text?: string
|
||||
/** Whether this part is a thinking/reasoning block */
|
||||
thought?: boolean
|
||||
/** Signature for signed thinking blocks */
|
||||
thoughtSignature?: string
|
||||
/** Type field for Anthropic-style format */
|
||||
type?: string
|
||||
/** Signature field for Anthropic-style format */
|
||||
signature?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini response candidate structure
|
||||
*/
|
||||
export interface GeminiCandidate {
|
||||
/** Content containing parts */
|
||||
content?: {
|
||||
/** Role of the content (e.g., "model", "assistant") */
|
||||
role?: string
|
||||
/** Array of content parts */
|
||||
parts?: GeminiPart[]
|
||||
}
|
||||
/** Index of the candidate */
|
||||
index?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini response structure for thinking block extraction
|
||||
*/
|
||||
export interface GeminiResponse {
|
||||
/** Response ID */
|
||||
id?: string
|
||||
/** Array of response candidates */
|
||||
candidates?: GeminiCandidate[]
|
||||
/** Direct content (some responses use this instead of candidates) */
|
||||
content?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
signature?: string
|
||||
}>
|
||||
/** Model used for response */
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of thinking block extraction
|
||||
*/
|
||||
export interface ThinkingExtractionResult {
|
||||
/** Extracted thinking blocks */
|
||||
thinkingBlocks: ThinkingBlock[]
|
||||
/** Combined thinking text for convenience */
|
||||
combinedThinking: string
|
||||
/** Whether any thinking blocks were found */
|
||||
hasThinking: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default thinking budget in tokens for thinking-enabled models
|
||||
*/
|
||||
export const DEFAULT_THINKING_BUDGET = 16000
|
||||
|
||||
/**
|
||||
* Check if a model variant should include thinking blocks
|
||||
*
|
||||
* Returns true for model variants with `-high` suffix, which have
|
||||
* extended thinking capability enabled.
|
||||
*
|
||||
* Examples:
|
||||
* - `gemini-3-pro-high` → true
|
||||
* - `gemini-2.5-pro-high` → true
|
||||
* - `gemini-3-pro-preview` → false
|
||||
* - `gemini-2.5-pro` → false
|
||||
*
|
||||
* @param model - Model identifier string
|
||||
* @returns True if model should include thinking blocks
|
||||
*/
|
||||
export function shouldIncludeThinking(model: string): boolean {
|
||||
if (!model || typeof model !== "string") {
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerModel = model.toLowerCase()
|
||||
|
||||
// Check for -high suffix (primary indicator of thinking capability)
|
||||
if (lowerModel.endsWith("-high")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also check for explicit thinking in model name
|
||||
if (lowerModel.includes("thinking")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is thinking-capable (broader check)
|
||||
*
|
||||
* This is a broader check than shouldIncludeThinking - it detects models
|
||||
* that have thinking capability, even if not explicitly requesting thinking output.
|
||||
*
|
||||
* @param model - Model identifier string
|
||||
* @returns True if model supports thinking/reasoning
|
||||
*/
|
||||
export function isThinkingCapableModel(model: string): boolean {
|
||||
if (!model || typeof model !== "string") {
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerModel = model.toLowerCase()
|
||||
|
||||
return (
|
||||
lowerModel.includes("thinking") ||
|
||||
lowerModel.includes("gemini-3") ||
|
||||
lowerModel.endsWith("-high")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a part is a thinking/reasoning block
|
||||
*
|
||||
* Detects both Gemini-style (thought: true) and Anthropic-style
|
||||
* (type: "thinking" or type: "reasoning") formats.
|
||||
*
|
||||
* @param part - Content part to check
|
||||
* @returns True if part is a thinking block
|
||||
*/
|
||||
function isThinkingPart(part: GeminiPart): boolean {
|
||||
// Gemini-style: thought flag
|
||||
if (part.thought === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Anthropic-style: type field
|
||||
if (part.type === "thinking" || part.type === "reasoning") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a thinking part has a valid signature
|
||||
*
|
||||
* Signatures are required for multi-turn conversations with Claude models.
|
||||
* Gemini uses `thoughtSignature`, Anthropic uses `signature`.
|
||||
*
|
||||
* @param part - Thinking part to check
|
||||
* @returns True if part has valid signature
|
||||
*/
|
||||
function hasValidSignature(part: GeminiPart): boolean {
|
||||
// Gemini-style signature
|
||||
if (part.thought === true && part.thoughtSignature) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Anthropic-style signature
|
||||
if ((part.type === "thinking" || part.type === "reasoning") && part.signature) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thinking blocks from a Gemini response
|
||||
*
|
||||
* Parses the response structure to identify and extract all thinking/reasoning
|
||||
* content. Supports both Gemini-style (thought: true) and Anthropic-style
|
||||
* (type: "thinking") formats.
|
||||
*
|
||||
* @param response - Gemini response object
|
||||
* @returns Extraction result with thinking blocks and metadata
|
||||
*/
|
||||
export function extractThinkingBlocks(response: GeminiResponse): ThinkingExtractionResult {
|
||||
const thinkingBlocks: ThinkingBlock[] = []
|
||||
|
||||
// Handle candidates array (standard Gemini format)
|
||||
if (response.candidates && Array.isArray(response.candidates)) {
|
||||
for (const candidate of response.candidates) {
|
||||
const parts = candidate.content?.parts
|
||||
if (!parts || !Array.isArray(parts)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
if (!part || typeof part !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isThinkingPart(part)) {
|
||||
const block: ThinkingBlock = {
|
||||
text: part.text || "",
|
||||
index: thinkingBlocks.length,
|
||||
}
|
||||
|
||||
// Extract signature if present
|
||||
if (part.thought === true && part.thoughtSignature) {
|
||||
block.signature = part.thoughtSignature
|
||||
} else if (part.signature) {
|
||||
block.signature = part.signature
|
||||
}
|
||||
|
||||
thinkingBlocks.push(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle direct content array (Anthropic-style response)
|
||||
if (response.content && Array.isArray(response.content)) {
|
||||
for (let i = 0; i < response.content.length; i++) {
|
||||
const item = response.content[i]
|
||||
if (!item || typeof item !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (item.type === "thinking" || item.type === "reasoning") {
|
||||
thinkingBlocks.push({
|
||||
text: item.text || "",
|
||||
signature: item.signature,
|
||||
index: thinkingBlocks.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combine all thinking text
|
||||
const combinedThinking = thinkingBlocks.map((b) => b.text).join("\n\n")
|
||||
|
||||
return {
|
||||
thinkingBlocks,
|
||||
combinedThinking,
|
||||
hasThinking: thinkingBlocks.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format thinking blocks for OpenAI-compatible output
|
||||
*
|
||||
* Converts Gemini thinking block format to OpenAI's expected structure.
|
||||
* OpenAI expects thinking content as special message blocks or annotations.
|
||||
*
|
||||
* Output format:
|
||||
* ```
|
||||
* [
|
||||
* { type: "reasoning", text: "thinking content...", signature?: "..." },
|
||||
* ...
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* @param thinking - Array of thinking blocks to format
|
||||
* @returns OpenAI-compatible formatted array
|
||||
*/
|
||||
export function formatThinkingForOpenAI(
|
||||
thinking: ThinkingBlock[],
|
||||
): Array<{ type: "reasoning"; text: string; signature?: string }> {
|
||||
if (!thinking || !Array.isArray(thinking) || thinking.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return thinking.map((block) => {
|
||||
const formatted: { type: "reasoning"; text: string; signature?: string } = {
|
||||
type: "reasoning",
|
||||
text: block.text || "",
|
||||
}
|
||||
|
||||
if (block.signature) {
|
||||
formatted.signature = block.signature
|
||||
}
|
||||
|
||||
return formatted
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform thinking parts in a candidate to OpenAI format
|
||||
*
|
||||
* Modifies candidate content parts to use OpenAI-style reasoning format
|
||||
* while preserving the rest of the response structure.
|
||||
*
|
||||
* @param candidate - Gemini candidate to transform
|
||||
* @returns Transformed candidate with reasoning-formatted thinking
|
||||
*/
|
||||
export function transformCandidateThinking(candidate: GeminiCandidate): GeminiCandidate {
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return candidate
|
||||
}
|
||||
|
||||
const content = candidate.content
|
||||
if (!content || typeof content !== "object" || !Array.isArray(content.parts)) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
const thinkingTexts: string[] = []
|
||||
const transformedParts = content.parts.map((part) => {
|
||||
if (part && typeof part === "object" && part.thought === true) {
|
||||
thinkingTexts.push(part.text || "")
|
||||
// Transform to reasoning format
|
||||
return {
|
||||
...part,
|
||||
type: "reasoning" as const,
|
||||
thought: undefined, // Remove Gemini-specific field
|
||||
}
|
||||
}
|
||||
return part
|
||||
})
|
||||
|
||||
const result: GeminiCandidate & { reasoning_content?: string } = {
|
||||
...candidate,
|
||||
content: { ...content, parts: transformedParts },
|
||||
}
|
||||
|
||||
// Add combined reasoning content for convenience
|
||||
if (thinkingTexts.length > 0) {
|
||||
result.reasoning_content = thinkingTexts.join("\n\n")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform Anthropic-style thinking blocks to reasoning format
|
||||
*
|
||||
* Converts `type: "thinking"` blocks to `type: "reasoning"` for consistency.
|
||||
*
|
||||
* @param content - Array of content blocks
|
||||
* @returns Transformed content array
|
||||
*/
|
||||
export function transformAnthropicThinking(
|
||||
content: Array<{ type?: string; text?: string; signature?: string }>,
|
||||
): Array<{ type?: string; text?: string; signature?: string }> {
|
||||
if (!content || !Array.isArray(content)) {
|
||||
return content
|
||||
}
|
||||
|
||||
return content.map((block) => {
|
||||
if (block && typeof block === "object" && block.type === "thinking") {
|
||||
return {
|
||||
type: "reasoning",
|
||||
text: block.text || "",
|
||||
...(block.signature ? { signature: block.signature } : {}),
|
||||
}
|
||||
}
|
||||
return block
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out unsigned thinking blocks
|
||||
*
|
||||
* Claude API requires signed thinking blocks for multi-turn conversations.
|
||||
* This function removes thinking blocks without valid signatures.
|
||||
*
|
||||
* @param parts - Array of content parts
|
||||
* @returns Filtered array without unsigned thinking blocks
|
||||
*/
|
||||
export function filterUnsignedThinkingBlocks(parts: GeminiPart[]): GeminiPart[] {
|
||||
if (!parts || !Array.isArray(parts)) {
|
||||
return parts
|
||||
}
|
||||
|
||||
return parts.filter((part) => {
|
||||
if (!part || typeof part !== "object") {
|
||||
return true
|
||||
}
|
||||
|
||||
// If it's a thinking part, only keep it if signed
|
||||
if (isThinkingPart(part)) {
|
||||
return hasValidSignature(part)
|
||||
}
|
||||
|
||||
// Keep all non-thinking parts
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform entire response thinking parts
|
||||
*
|
||||
* Main transformation function that handles both Gemini-style and
|
||||
* Anthropic-style thinking blocks in a response.
|
||||
*
|
||||
* @param response - Response object to transform
|
||||
* @returns Transformed response with standardized reasoning format
|
||||
*/
|
||||
export function transformResponseThinking(response: GeminiResponse): GeminiResponse {
|
||||
if (!response || typeof response !== "object") {
|
||||
return response
|
||||
}
|
||||
|
||||
const result: GeminiResponse = { ...response }
|
||||
|
||||
// Transform candidates (Gemini-style)
|
||||
if (Array.isArray(result.candidates)) {
|
||||
result.candidates = result.candidates.map(transformCandidateThinking)
|
||||
}
|
||||
|
||||
// Transform direct content (Anthropic-style)
|
||||
if (Array.isArray(result.content)) {
|
||||
result.content = transformAnthropicThinking(result.content)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking configuration for requests
|
||||
*/
|
||||
export interface ThinkingConfig {
|
||||
/** Token budget for thinking/reasoning */
|
||||
thinkingBudget?: number
|
||||
/** Whether to include thoughts in response */
|
||||
includeThoughts?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize thinking configuration
|
||||
*
|
||||
* Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0.
|
||||
*
|
||||
* @param config - Raw thinking configuration
|
||||
* @returns Normalized configuration or undefined
|
||||
*/
|
||||
export function normalizeThinkingConfig(config: unknown): ThinkingConfig | undefined {
|
||||
if (!config || typeof config !== "object") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const record = config as Record<string, unknown>
|
||||
const budgetRaw = record.thinkingBudget ?? record.thinking_budget
|
||||
const includeRaw = record.includeThoughts ?? record.include_thoughts
|
||||
|
||||
const thinkingBudget =
|
||||
typeof budgetRaw === "number" && Number.isFinite(budgetRaw) ? budgetRaw : undefined
|
||||
const includeThoughts = typeof includeRaw === "boolean" ? includeRaw : undefined
|
||||
|
||||
const enableThinking = thinkingBudget !== undefined && thinkingBudget > 0
|
||||
const finalInclude = enableThinking ? (includeThoughts ?? false) : false
|
||||
|
||||
// Return undefined if no meaningful config
|
||||
if (
|
||||
!enableThinking &&
|
||||
finalInclude === false &&
|
||||
thinkingBudget === undefined &&
|
||||
includeThoughts === undefined
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalized: ThinkingConfig = {}
|
||||
if (thinkingBudget !== undefined) {
|
||||
normalized.thinkingBudget = thinkingBudget
|
||||
}
|
||||
if (finalInclude !== undefined) {
|
||||
normalized.includeThoughts = finalInclude
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thinking configuration from request payload
|
||||
*
|
||||
* Supports both Gemini-style thinkingConfig and Anthropic-style thinking options.
|
||||
*
|
||||
* @param requestPayload - Request body
|
||||
* @param generationConfig - Generation config from request
|
||||
* @param extraBody - Extra body options
|
||||
* @returns Extracted thinking configuration or undefined
|
||||
*/
|
||||
export function extractThinkingConfig(
|
||||
requestPayload: Record<string, unknown>,
|
||||
generationConfig?: Record<string, unknown>,
|
||||
extraBody?: Record<string, unknown>,
|
||||
): ThinkingConfig | undefined {
|
||||
// Check for explicit thinkingConfig
|
||||
const thinkingConfig =
|
||||
generationConfig?.thinkingConfig ?? extraBody?.thinkingConfig ?? requestPayload.thinkingConfig
|
||||
|
||||
if (thinkingConfig && typeof thinkingConfig === "object") {
|
||||
const config = thinkingConfig as Record<string, unknown>
|
||||
return {
|
||||
includeThoughts: Boolean(config.includeThoughts),
|
||||
thinkingBudget:
|
||||
typeof config.thinkingBudget === "number" ? config.thinkingBudget : DEFAULT_THINKING_BUDGET,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Anthropic-style "thinking" option: { type: "enabled", budgetTokens: N }
|
||||
const anthropicThinking = extraBody?.thinking ?? requestPayload.thinking
|
||||
if (anthropicThinking && typeof anthropicThinking === "object") {
|
||||
const thinking = anthropicThinking as Record<string, unknown>
|
||||
if (thinking.type === "enabled" || thinking.budgetTokens) {
|
||||
return {
|
||||
includeThoughts: true,
|
||||
thinkingBudget:
|
||||
typeof thinking.budgetTokens === "number"
|
||||
? thinking.budgetTokens
|
||||
: DEFAULT_THINKING_BUDGET,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve final thinking configuration based on model and context
|
||||
*
|
||||
* Handles special cases like Claude models requiring signed thinking blocks
|
||||
* for multi-turn conversations.
|
||||
*
|
||||
* @param userConfig - User-provided thinking configuration
|
||||
* @param isThinkingModel - Whether model supports thinking
|
||||
* @param isClaudeModel - Whether model is Claude (not used in Antigravity, but kept for compatibility)
|
||||
* @param hasAssistantHistory - Whether conversation has assistant history
|
||||
* @returns Final thinking configuration
|
||||
*/
|
||||
export function resolveThinkingConfig(
|
||||
userConfig: ThinkingConfig | undefined,
|
||||
isThinkingModel: boolean,
|
||||
isClaudeModel: boolean,
|
||||
hasAssistantHistory: boolean,
|
||||
): ThinkingConfig | undefined {
|
||||
// Claude models with history need signed thinking blocks
|
||||
// Since we can't guarantee signatures, disable thinking
|
||||
if (isClaudeModel && hasAssistantHistory) {
|
||||
return { includeThoughts: false, thinkingBudget: 0 }
|
||||
}
|
||||
|
||||
// Enable thinking by default for thinking-capable models
|
||||
if (isThinkingModel && !userConfig) {
|
||||
return { includeThoughts: true, thinkingBudget: DEFAULT_THINKING_BUDGET }
|
||||
}
|
||||
|
||||
return userConfig
|
||||
}
|
||||
97
src/auth/antigravity/thought-signature-store.ts
Normal file
97
src/auth/antigravity/thought-signature-store.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Thought Signature Store
|
||||
*
|
||||
* Stores and retrieves thought signatures for multi-turn conversations.
|
||||
* Gemini 3 Pro requires thought_signature on function call content blocks
|
||||
* in subsequent requests to maintain reasoning continuity.
|
||||
*
|
||||
* Key responsibilities:
|
||||
* - Store the latest thought signature per session
|
||||
* - Provide signature for injection into function call requests
|
||||
* - Clear signatures when sessions end
|
||||
*/
|
||||
|
||||
/**
|
||||
* In-memory store for thought signatures indexed by session ID
|
||||
*/
|
||||
const signatureStore = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* In-memory store for session IDs per fetch instance
|
||||
* Used to maintain consistent sessionId across multi-turn conversations
|
||||
*/
|
||||
const sessionIdStore = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Store a thought signature for a session
|
||||
*
|
||||
* @param sessionKey - Unique session identifier (typically fetch instance ID)
|
||||
* @param signature - The thought signature from model response
|
||||
*/
|
||||
export function setThoughtSignature(sessionKey: string, signature: string): void {
|
||||
if (sessionKey && signature) {
|
||||
signatureStore.set(sessionKey, signature)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the stored thought signature for a session
|
||||
*
|
||||
* @param sessionKey - Unique session identifier
|
||||
* @returns The stored signature or undefined if not found
|
||||
*/
|
||||
export function getThoughtSignature(sessionKey: string): string | undefined {
|
||||
return signatureStore.get(sessionKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the thought signature for a session
|
||||
*
|
||||
* @param sessionKey - Unique session identifier
|
||||
*/
|
||||
export function clearThoughtSignature(sessionKey: string): void {
|
||||
signatureStore.delete(sessionKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store or retrieve a persistent session ID for a fetch instance
|
||||
*
|
||||
* @param fetchInstanceId - Unique identifier for the fetch instance
|
||||
* @param sessionId - Optional session ID to store (if not provided, returns existing or generates new)
|
||||
* @returns The session ID for this fetch instance
|
||||
*/
|
||||
export function getOrCreateSessionId(fetchInstanceId: string, sessionId?: string): string {
|
||||
if (sessionId) {
|
||||
sessionIdStore.set(fetchInstanceId, sessionId)
|
||||
return sessionId
|
||||
}
|
||||
|
||||
const existing = sessionIdStore.get(fetchInstanceId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
|
||||
const newSessionId = `-${n}`
|
||||
sessionIdStore.set(fetchInstanceId, newSessionId)
|
||||
return newSessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the session ID for a fetch instance
|
||||
*
|
||||
* @param fetchInstanceId - Unique identifier for the fetch instance
|
||||
*/
|
||||
export function clearSessionId(fetchInstanceId: string): void {
|
||||
sessionIdStore.delete(fetchInstanceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored data for a fetch instance (signature + session ID)
|
||||
*
|
||||
* @param fetchInstanceId - Unique identifier for the fetch instance
|
||||
*/
|
||||
export function clearFetchInstanceData(fetchInstanceId: string): void {
|
||||
signatureStore.delete(fetchInstanceId)
|
||||
sessionIdStore.delete(fetchInstanceId)
|
||||
}
|
||||
119
src/auth/antigravity/token.ts
Normal file
119
src/auth/antigravity/token.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Antigravity token management utilities.
|
||||
* Handles token expiration checking, refresh, and storage format parsing.
|
||||
*/
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_CLIENT_ID,
|
||||
ANTIGRAVITY_CLIENT_SECRET,
|
||||
ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS,
|
||||
GOOGLE_TOKEN_URL,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AntigravityRefreshParts,
|
||||
AntigravityTokenExchangeResult,
|
||||
AntigravityTokens,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Check if the access token is expired.
|
||||
* Includes a 60-second safety buffer to refresh before actual expiration.
|
||||
*
|
||||
* @param tokens - The Antigravity tokens to check
|
||||
* @returns true if the token is expired or will expire within the buffer period
|
||||
*/
|
||||
export function isTokenExpired(tokens: AntigravityTokens): boolean {
|
||||
// Calculate when the token expires (timestamp + expires_in in ms)
|
||||
// timestamp is in milliseconds, expires_in is in seconds
|
||||
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
|
||||
|
||||
// Check if current time is past (expiration - buffer)
|
||||
return Date.now() >= expirationTime - ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token.
|
||||
* Exchanges the refresh token for a new access token via Google's OAuth endpoint.
|
||||
*
|
||||
* @param refreshToken - The refresh token to use
|
||||
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
|
||||
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
|
||||
* @returns Token exchange result with new access token, or throws on error
|
||||
*/
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
clientSecret: string = ANTIGRAVITY_CLIENT_SECRET
|
||||
): Promise<AntigravityTokenExchangeResult> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
})
|
||||
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "Unknown error")
|
||||
throw new Error(
|
||||
`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
// Google may return a new refresh token, fall back to the original
|
||||
refresh_token: data.refresh_token || refreshToken,
|
||||
expires_in: data.expires_in,
|
||||
token_type: data.token_type,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stored token string into its component parts.
|
||||
* Storage format: `refreshToken|projectId|managedProjectId`
|
||||
*
|
||||
* @param stored - The pipe-separated stored token string
|
||||
* @returns Parsed refresh parts with refreshToken, projectId, and optional managedProjectId
|
||||
*/
|
||||
export function parseStoredToken(stored: string): AntigravityRefreshParts {
|
||||
const parts = stored.split("|")
|
||||
const [refreshToken, projectId, managedProjectId] = parts
|
||||
|
||||
return {
|
||||
refreshToken: refreshToken || "",
|
||||
projectId: projectId || undefined,
|
||||
managedProjectId: managedProjectId || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format token components for storage.
|
||||
* Creates a pipe-separated string: `refreshToken|projectId|managedProjectId`
|
||||
*
|
||||
* @param refreshToken - The refresh token
|
||||
* @param projectId - The GCP project ID
|
||||
* @param managedProjectId - Optional managed project ID for enterprise users
|
||||
* @returns Formatted string for storage
|
||||
*/
|
||||
export function formatTokenForStorage(
|
||||
refreshToken: string,
|
||||
projectId: string,
|
||||
managedProjectId?: string
|
||||
): string {
|
||||
return `${refreshToken}|${projectId}|${managedProjectId || ""}`
|
||||
}
|
||||
243
src/auth/antigravity/tools.ts
Normal file
243
src/auth/antigravity/tools.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Antigravity Tool Normalization
|
||||
* Converts tools between OpenAI and Gemini formats.
|
||||
*
|
||||
* OpenAI format:
|
||||
* { "type": "function", "function": { "name": "x", "description": "...", "parameters": {...} } }
|
||||
*
|
||||
* Gemini format:
|
||||
* { "functionDeclarations": [{ "name": "x", "description": "...", "parameters": {...} }] }
|
||||
*
|
||||
* Note: This is for Gemini models ONLY. Claude models are not supported via Antigravity.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAI function tool format
|
||||
*/
|
||||
export interface OpenAITool {
|
||||
type: string
|
||||
function?: {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini function declaration format
|
||||
*/
|
||||
export interface GeminiFunctionDeclaration {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini tools format (array of functionDeclarations)
|
||||
*/
|
||||
export interface GeminiTools {
|
||||
functionDeclarations: GeminiFunctionDeclaration[]
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI tool call in response
|
||||
*/
|
||||
export interface OpenAIToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini function call in response
|
||||
*/
|
||||
export interface GeminiFunctionCall {
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini function response format
|
||||
*/
|
||||
export interface GeminiFunctionResponse {
|
||||
name: string
|
||||
response: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini tool result containing function calls
|
||||
*/
|
||||
export interface GeminiToolResult {
|
||||
functionCall?: GeminiFunctionCall
|
||||
functionResponse?: GeminiFunctionResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize OpenAI-format tools to Gemini format.
|
||||
* Converts an array of OpenAI tools to Gemini's functionDeclarations format.
|
||||
*
|
||||
* - Handles `function` type tools with name, description, parameters
|
||||
* - Logs warning for unsupported tool types (does NOT silently drop them)
|
||||
* - Creates a single object with functionDeclarations array
|
||||
*
|
||||
* @param tools - Array of OpenAI-format tools
|
||||
* @returns Gemini-format tools object with functionDeclarations, or undefined if no valid tools
|
||||
*/
|
||||
export function normalizeToolsForGemini(
|
||||
tools: OpenAITool[]
|
||||
): GeminiTools | undefined {
|
||||
if (!tools || tools.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const functionDeclarations: GeminiFunctionDeclaration[] = []
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!tool || typeof tool !== "object") {
|
||||
continue
|
||||
}
|
||||
|
||||
const toolType = tool.type ?? "function"
|
||||
if (toolType === "function" && tool.function) {
|
||||
const declaration: GeminiFunctionDeclaration = {
|
||||
name: tool.function.name,
|
||||
}
|
||||
|
||||
if (tool.function.description) {
|
||||
declaration.description = tool.function.description
|
||||
}
|
||||
|
||||
if (tool.function.parameters) {
|
||||
declaration.parameters = tool.function.parameters
|
||||
} else {
|
||||
declaration.parameters = { type: "object", properties: {} }
|
||||
}
|
||||
|
||||
functionDeclarations.push(declaration)
|
||||
} else if (toolType !== "function" && process.env.ANTIGRAVITY_DEBUG === "1") {
|
||||
console.warn(
|
||||
`[antigravity-tools] Unsupported tool type: "${toolType}". Tool will be skipped.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Return undefined if no valid function declarations
|
||||
if (functionDeclarations.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { functionDeclarations }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Gemini tool results (functionCall) back to OpenAI tool_call format.
|
||||
* Handles both functionCall (request) and functionResponse (result) formats.
|
||||
*
|
||||
* Gemini functionCall format:
|
||||
* { "name": "tool_name", "args": { ... } }
|
||||
*
|
||||
* OpenAI tool_call format:
|
||||
* { "id": "call_xxx", "type": "function", "function": { "name": "tool_name", "arguments": "..." } }
|
||||
*
|
||||
* @param results - Array of Gemini tool results containing functionCall or functionResponse
|
||||
* @returns Array of OpenAI-format tool calls
|
||||
*/
|
||||
export function normalizeToolResultsFromGemini(
|
||||
results: GeminiToolResult[]
|
||||
): OpenAIToolCall[] {
|
||||
if (!results || results.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const toolCalls: OpenAIToolCall[] = []
|
||||
let callCounter = 0
|
||||
|
||||
for (const result of results) {
|
||||
// Handle functionCall (tool invocation from model)
|
||||
if (result.functionCall) {
|
||||
callCounter++
|
||||
const toolCall: OpenAIToolCall = {
|
||||
id: `call_${Date.now()}_${callCounter}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: result.functionCall.name,
|
||||
arguments: JSON.stringify(result.functionCall.args ?? {}),
|
||||
},
|
||||
}
|
||||
toolCalls.push(toolCall)
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single Gemini functionCall to OpenAI tool_call format.
|
||||
* Useful for streaming responses where each chunk may contain a function call.
|
||||
*
|
||||
* @param functionCall - Gemini function call
|
||||
* @param id - Optional tool call ID (generates one if not provided)
|
||||
* @returns OpenAI-format tool call
|
||||
*/
|
||||
export function convertFunctionCallToToolCall(
|
||||
functionCall: GeminiFunctionCall,
|
||||
id?: string
|
||||
): OpenAIToolCall {
|
||||
return {
|
||||
id: id ?? `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: functionCall.name,
|
||||
arguments: JSON.stringify(functionCall.args ?? {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool array contains any function-type tools.
|
||||
*
|
||||
* @param tools - Array of OpenAI-format tools
|
||||
* @returns true if there are function tools to normalize
|
||||
*/
|
||||
export function hasFunctionTools(tools: OpenAITool[]): boolean {
|
||||
if (!tools || tools.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return tools.some((tool) => tool.type === "function" && tool.function)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract function declarations from already-normalized Gemini tools.
|
||||
* Useful when tools may already be in Gemini format.
|
||||
*
|
||||
* @param tools - Tools that may be in Gemini or OpenAI format
|
||||
* @returns Array of function declarations
|
||||
*/
|
||||
export function extractFunctionDeclarations(
|
||||
tools: unknown
|
||||
): GeminiFunctionDeclaration[] {
|
||||
if (!tools || typeof tools !== "object") {
|
||||
return []
|
||||
}
|
||||
|
||||
// Check if already in Gemini format
|
||||
const geminiTools = tools as Record<string, unknown>
|
||||
if (
|
||||
Array.isArray(geminiTools.functionDeclarations) &&
|
||||
geminiTools.functionDeclarations.length > 0
|
||||
) {
|
||||
return geminiTools.functionDeclarations as GeminiFunctionDeclaration[]
|
||||
}
|
||||
|
||||
// Check if it's an array of OpenAI tools
|
||||
if (Array.isArray(tools)) {
|
||||
const normalized = normalizeToolsForGemini(tools as OpenAITool[])
|
||||
return normalized?.functionDeclarations ?? []
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
185
src/auth/antigravity/types.ts
Normal file
185
src/auth/antigravity/types.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Antigravity Auth Type Definitions
|
||||
* Matches cliproxyapi/sdk/auth/antigravity.go token format exactly
|
||||
*/
|
||||
|
||||
/**
|
||||
* Token storage format for Antigravity authentication
|
||||
* Matches Go metadata structure: type, access_token, refresh_token, expires_in, timestamp, email, project_id
|
||||
*/
|
||||
export interface AntigravityTokens {
|
||||
/** Always "antigravity" for this auth type */
|
||||
type: "antigravity"
|
||||
/** OAuth access token from Google */
|
||||
access_token: string
|
||||
/** OAuth refresh token from Google */
|
||||
refresh_token: string
|
||||
/** Token expiration time in seconds */
|
||||
expires_in: number
|
||||
/** Unix timestamp in milliseconds when tokens were obtained */
|
||||
timestamp: number
|
||||
/** ISO 8601 formatted expiration datetime (optional, for display) */
|
||||
expired?: string
|
||||
/** User's email address from Google userinfo */
|
||||
email?: string
|
||||
/** GCP project ID from loadCodeAssist API */
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Project context returned from loadCodeAssist API
|
||||
* Used to get cloudaicompanionProject for API calls
|
||||
*/
|
||||
export interface AntigravityProjectContext {
|
||||
/** GCP project ID for Cloud AI Companion */
|
||||
cloudaicompanionProject?: string
|
||||
/** Managed project ID for enterprise users (optional) */
|
||||
managedProjectId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for loadCodeAssist API request
|
||||
*/
|
||||
export interface AntigravityClientMetadata {
|
||||
/** IDE type identifier */
|
||||
ideType: "IDE_UNSPECIFIED" | string
|
||||
/** Platform identifier */
|
||||
platform: "PLATFORM_UNSPECIFIED" | string
|
||||
/** Plugin type - typically "GEMINI" */
|
||||
pluginType: "GEMINI" | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for loadCodeAssist API
|
||||
*/
|
||||
export interface AntigravityLoadCodeAssistRequest {
|
||||
metadata: AntigravityClientMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from loadCodeAssist API
|
||||
*/
|
||||
export interface AntigravityLoadCodeAssistResponse {
|
||||
/** Project ID - can be string or object with id field */
|
||||
cloudaicompanionProject?: string | { id: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body format for Antigravity API calls
|
||||
* Wraps the actual request with project and model context
|
||||
*/
|
||||
export interface AntigravityRequestBody {
|
||||
/** GCP project ID */
|
||||
project: string
|
||||
/** Model identifier (e.g., "gemini-3-pro-preview") */
|
||||
model: string
|
||||
/** User agent identifier */
|
||||
userAgent: string
|
||||
/** Unique request ID */
|
||||
requestId: string
|
||||
/** The actual request payload */
|
||||
request: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Response format from Antigravity API
|
||||
* Follows OpenAI-compatible structure with Gemini extensions
|
||||
*/
|
||||
export interface AntigravityResponse {
|
||||
/** Response ID */
|
||||
id?: string
|
||||
/** Object type (e.g., "chat.completion") */
|
||||
object?: string
|
||||
/** Creation timestamp */
|
||||
created?: number
|
||||
/** Model used for response */
|
||||
model?: string
|
||||
/** Response choices */
|
||||
choices?: AntigravityResponseChoice[]
|
||||
/** Token usage statistics */
|
||||
usage?: AntigravityUsage
|
||||
/** Error information if request failed */
|
||||
error?: AntigravityError
|
||||
}
|
||||
|
||||
/**
|
||||
* Single response choice in Antigravity response
|
||||
*/
|
||||
export interface AntigravityResponseChoice {
|
||||
/** Choice index */
|
||||
index: number
|
||||
/** Message content */
|
||||
message?: {
|
||||
role: "assistant"
|
||||
content?: string
|
||||
tool_calls?: AntigravityToolCall[]
|
||||
}
|
||||
/** Delta for streaming responses */
|
||||
delta?: {
|
||||
role?: "assistant"
|
||||
content?: string
|
||||
tool_calls?: AntigravityToolCall[]
|
||||
}
|
||||
/** Finish reason */
|
||||
finish_reason?: "stop" | "tool_calls" | "length" | "content_filter" | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call in Antigravity response
|
||||
*/
|
||||
export interface AntigravityToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token usage statistics
|
||||
*/
|
||||
export interface AntigravityUsage {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response from Antigravity API
|
||||
*/
|
||||
export interface AntigravityError {
|
||||
message: string
|
||||
type?: string
|
||||
code?: string | number
|
||||
}
|
||||
|
||||
/**
|
||||
* Token exchange result from Google OAuth
|
||||
* Matches antigravityTokenResponse in Go
|
||||
*/
|
||||
export interface AntigravityTokenExchangeResult {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* User info from Google userinfo API
|
||||
*/
|
||||
export interface AntigravityUserInfo {
|
||||
email: string
|
||||
name?: string
|
||||
picture?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed refresh token parts
|
||||
* Format: refreshToken|projectId|managedProjectId
|
||||
*/
|
||||
export interface AntigravityRefreshParts {
|
||||
refreshToken: string
|
||||
projectId?: string
|
||||
managedProjectId?: string
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export {
|
||||
AgentOverridesSchema,
|
||||
McpNameSchema,
|
||||
AgentNameSchema,
|
||||
HookNameSchema,
|
||||
OmoAgentConfigSchema,
|
||||
} from "./schema"
|
||||
|
||||
export type {
|
||||
@@ -12,4 +14,6 @@ export type {
|
||||
AgentOverrides,
|
||||
McpName,
|
||||
AgentName,
|
||||
HookName,
|
||||
OmoAgentConfig,
|
||||
} from "./schema"
|
||||
|
||||
@@ -16,12 +16,46 @@ const AgentPermissionSchema = z.object({
|
||||
external_directory: PermissionValue.optional(),
|
||||
})
|
||||
|
||||
export const AgentNameSchema = z.enum([
|
||||
export const BuiltinAgentNameSchema = z.enum([
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"frontend-ui-ux-engineer",
|
||||
"document-writer",
|
||||
"multimodal-looker",
|
||||
])
|
||||
|
||||
export const OverridableAgentNameSchema = z.enum([
|
||||
"build",
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"frontend-ui-ux-engineer",
|
||||
"document-writer",
|
||||
"multimodal-looker",
|
||||
])
|
||||
|
||||
export const AgentNameSchema = BuiltinAgentNameSchema
|
||||
|
||||
export const HookNameSchema = z.enum([
|
||||
"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-auto-compact",
|
||||
"rules-injector",
|
||||
"background-notification",
|
||||
"auto-update-checker",
|
||||
"startup-toast",
|
||||
"keyword-detector",
|
||||
"agent-usage-reminder",
|
||||
])
|
||||
|
||||
export const AgentOverrideConfigSchema = z.object({
|
||||
@@ -40,18 +74,44 @@ export const AgentOverrideConfigSchema = z.object({
|
||||
permission: AgentPermissionSchema.optional(),
|
||||
})
|
||||
|
||||
export const AgentOverridesSchema = z.record(AgentNameSchema, AgentOverrideConfigSchema)
|
||||
export const AgentOverridesSchema = z.object({
|
||||
build: AgentOverrideConfigSchema.optional(),
|
||||
oracle: AgentOverrideConfigSchema.optional(),
|
||||
librarian: AgentOverrideConfigSchema.optional(),
|
||||
explore: AgentOverrideConfigSchema.optional(),
|
||||
"frontend-ui-ux-engineer": AgentOverrideConfigSchema.optional(),
|
||||
"document-writer": AgentOverrideConfigSchema.optional(),
|
||||
"multimodal-looker": AgentOverrideConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export const ClaudeCodeConfigSchema = z.object({
|
||||
mcp: z.boolean().optional(),
|
||||
commands: z.boolean().optional(),
|
||||
skills: z.boolean().optional(),
|
||||
agents: z.boolean().optional(),
|
||||
hooks: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const OmoAgentConfigSchema = z.object({
|
||||
disable_build: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(McpNameSchema).optional(),
|
||||
disabled_agents: z.array(AgentNameSchema).optional(),
|
||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||
disabled_hooks: z.array(HookNameSchema).optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||
google_auth: z.boolean().optional(),
|
||||
omo_agent: OmoAgentConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
|
||||
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
||||
export type AgentName = z.infer<typeof AgentNameSchema>
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
export type OmoAgentConfig = z.infer<typeof OmoAgentConfigSchema>
|
||||
|
||||
export { McpNameSchema, type McpName } from "../mcp/types"
|
||||
|
||||
2
src/features/background-agent/index.ts
Normal file
2
src/features/background-agent/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager } from "./manager"
|
||||
366
src/features/background-agent/manager.ts
Normal file
366
src/features/background-agent/manager.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type {
|
||||
BackgroundTask,
|
||||
LaunchInput,
|
||||
} from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface MessagePartInfo {
|
||||
sessionID?: string
|
||||
type?: string
|
||||
tool?: string
|
||||
}
|
||||
|
||||
interface EventProperties {
|
||||
sessionID?: string
|
||||
info?: { id?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Event {
|
||||
type: string
|
||||
properties?: EventProperties
|
||||
}
|
||||
|
||||
export class BackgroundManager {
|
||||
private tasks: Map<string, BackgroundTask>
|
||||
private notifications: Map<string, BackgroundTask[]>
|
||||
private client: OpencodeClient
|
||||
private directory: string
|
||||
private pollingInterval?: Timer
|
||||
|
||||
constructor(ctx: PluginInput) {
|
||||
this.tasks = new Map()
|
||||
this.notifications = new Map()
|
||||
this.client = ctx.client
|
||||
this.directory = ctx.directory
|
||||
}
|
||||
|
||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||
if (!input.agent || input.agent.trim() === "") {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
|
||||
const createResult = await this.client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
throw new Error(`Failed to create background session: ${createResult.error}`)
|
||||
}
|
||||
|
||||
const sessionID = createResult.data.id
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: `bg_${crypto.randomUUID().slice(0, 8)}`,
|
||||
sessionID,
|
||||
parentSessionID: input.parentSessionID,
|
||||
parentMessageID: input.parentMessageID,
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
agent: input.agent,
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
progress: {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
this.startPolling()
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
|
||||
this.client.session.promptAsync({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
tools: {
|
||||
task: false,
|
||||
call_omo_agent: false,
|
||||
background_task: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] promptAsync error:", error)
|
||||
const existingTask = this.findBySession(sessionID)
|
||||
if (existingTask) {
|
||||
existingTask.status = "error"
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`
|
||||
} else {
|
||||
existingTask.error = errorMessage
|
||||
}
|
||||
existingTask.completedAt = new Date()
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask)
|
||||
}
|
||||
})
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
getTask(id: string): BackgroundTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
getTasksByParentSession(sessionID: string): BackgroundTask[] {
|
||||
const result: BackgroundTask[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.parentSessionID === sessionID) {
|
||||
result.push(task)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
findBySession(sessionID: string): BackgroundTask | undefined {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.sessionID === sessionID) {
|
||||
return task
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
handleEvent(event: Event): void {
|
||||
const props = event.properties
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
if (!props || typeof props !== "object" || !("sessionID" in props)) return
|
||||
const partInfo = props as unknown as MessagePartInfo
|
||||
const sessionID = partInfo?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task) return
|
||||
|
||||
if (partInfo?.type === "tool" || partInfo?.tool) {
|
||||
if (!task.progress) {
|
||||
task.progress = {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
}
|
||||
}
|
||||
task.progress.toolCalls += 1
|
||||
task.progress.lastTool = partInfo.tool
|
||||
task.progress.lastUpdate = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
this.markForNotification(task)
|
||||
this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via session.idle event:", task.id)
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const info = props?.info
|
||||
if (!info || typeof info.id !== "string") return
|
||||
const sessionID = info.id
|
||||
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task) return
|
||||
|
||||
if (task.status === "running") {
|
||||
task.status = "cancelled"
|
||||
task.completedAt = new Date()
|
||||
task.error = "Session deleted"
|
||||
}
|
||||
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
markForNotification(task: BackgroundTask): void {
|
||||
const queue = this.notifications.get(task.parentSessionID) ?? []
|
||||
queue.push(task)
|
||||
this.notifications.set(task.parentSessionID, queue)
|
||||
}
|
||||
|
||||
getPendingNotifications(sessionID: string): BackgroundTask[] {
|
||||
return this.notifications.get(sessionID) ?? []
|
||||
}
|
||||
|
||||
clearNotifications(sessionID: string): void {
|
||||
this.notifications.delete(sessionID)
|
||||
}
|
||||
|
||||
private clearNotificationsForTask(taskId: string): void {
|
||||
for (const [sessionID, tasks] of this.notifications.entries()) {
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
if (filtered.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else {
|
||||
this.notifications.set(sessionID, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
if (this.pollingInterval) return
|
||||
|
||||
this.pollingInterval = setInterval(() => {
|
||||
this.pollRunningTasks()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval)
|
||||
this.pollingInterval = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private notifyParentSession(task: BackgroundTask): void {
|
||||
const duration = this.formatDuration(task.startedAt, task.completedAt)
|
||||
|
||||
log("[background-agent] notifyParentSession called for task:", task.id)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const tuiClient = this.client as any
|
||||
if (tuiClient.tui?.showToast) {
|
||||
tuiClient.tui.showToast({
|
||||
body: {
|
||||
title: "Background Task Completed",
|
||||
message: `Task "${task.description}" finished in ${duration}.`,
|
||||
variant: "success",
|
||||
duration: 5000,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.`
|
||||
|
||||
log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID })
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.client.session.prompt({
|
||||
path: { id: task.parentSessionID },
|
||||
body: {
|
||||
parts: [{ type: "text", text: message }],
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
})
|
||||
this.clearNotificationsForTask(task.id)
|
||||
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
|
||||
} catch (error) {
|
||||
log("[background-agent] prompt failed:", String(error))
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
private formatDuration(start: Date, end?: Date): string {
|
||||
const duration = (end ?? new Date()).getTime() - start.getTime()
|
||||
const seconds = Math.floor(duration / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
}
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
private hasRunningTasks(): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === "running") return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async pollRunningTasks(): Promise<void> {
|
||||
const statusResult = await this.client.session.status()
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status !== "running") continue
|
||||
|
||||
try {
|
||||
const sessionStatus = allStatuses[task.sessionID]
|
||||
|
||||
if (!sessionStatus) {
|
||||
log("[background-agent] Session not found in status:", task.sessionID)
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionStatus.type === "idle") {
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
this.markForNotification(task)
|
||||
this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via polling:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const messagesResult = await this.client.session.messages({
|
||||
path: { id: task.sessionID },
|
||||
})
|
||||
|
||||
if (!messagesResult.error && messagesResult.data) {
|
||||
const messages = messagesResult.data as Array<{
|
||||
info?: { role?: string }
|
||||
parts?: Array<{ type?: string; tool?: string; name?: string; text?: string }>
|
||||
}>
|
||||
const assistantMsgs = messages.filter(
|
||||
(m) => m.info?.role === "assistant"
|
||||
)
|
||||
|
||||
let toolCalls = 0
|
||||
let lastTool: string | undefined
|
||||
let lastMessage: string | undefined
|
||||
|
||||
for (const msg of assistantMsgs) {
|
||||
const parts = msg.parts ?? []
|
||||
for (const part of parts) {
|
||||
if (part.type === "tool_use" || part.tool) {
|
||||
toolCalls++
|
||||
lastTool = part.tool || part.name || "unknown"
|
||||
}
|
||||
if (part.type === "text" && part.text) {
|
||||
lastMessage = part.text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!task.progress) {
|
||||
task.progress = { toolCalls: 0, lastUpdate: new Date() }
|
||||
}
|
||||
task.progress.toolCalls = toolCalls
|
||||
task.progress.lastTool = lastTool
|
||||
task.progress.lastUpdate = new Date()
|
||||
if (lastMessage) {
|
||||
task.progress.lastMessage = lastMessage
|
||||
task.progress.lastMessageAt = new Date()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log("[background-agent] Poll error for task:", { taskId: task.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.hasRunningTasks()) {
|
||||
this.stopPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/features/background-agent/types.ts
Normal file
37
src/features/background-agent/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type BackgroundTaskStatus =
|
||||
| "running"
|
||||
| "completed"
|
||||
| "error"
|
||||
| "cancelled"
|
||||
|
||||
export interface TaskProgress {
|
||||
toolCalls: number
|
||||
lastTool?: string
|
||||
lastUpdate: Date
|
||||
lastMessage?: string
|
||||
lastMessageAt?: Date
|
||||
}
|
||||
|
||||
export interface BackgroundTask {
|
||||
id: string
|
||||
sessionID: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
description: string
|
||||
prompt: string
|
||||
agent: string
|
||||
status: BackgroundTaskStatus
|
||||
startedAt: Date
|
||||
completedAt?: Date
|
||||
result?: string
|
||||
error?: string
|
||||
progress?: TaskProgress
|
||||
}
|
||||
|
||||
export interface LaunchInput {
|
||||
description: string
|
||||
prompt: string
|
||||
agent: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
}
|
||||
2
src/features/claude-code-agent-loader/index.ts
Normal file
2
src/features/claude-code-agent-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
90
src/features/claude-code-agent-loader/loader.ts
Normal file
90
src/features/claude-code-agent-loader/loader.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, basename } from "path"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
|
||||
|
||||
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
|
||||
if (!toolsStr) return undefined
|
||||
|
||||
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
if (tools.length === 0) return undefined
|
||||
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const tool of tools) {
|
||||
result[tool.toLowerCase()] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] {
|
||||
if (!existsSync(agentsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(agentsDir, { withFileTypes: true })
|
||||
const agents: LoadedAgent[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const agentPath = join(agentsDir, entry.name)
|
||||
const agentName = basename(entry.name, ".md")
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
|
||||
|
||||
const name = data.name || agentName
|
||||
const originalDescription = data.description || ""
|
||||
|
||||
const formattedDescription = `(${scope}) ${originalDescription}`
|
||||
|
||||
const config: AgentConfig = {
|
||||
description: formattedDescription,
|
||||
mode: "subagent",
|
||||
prompt: body.trim(),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(data.tools)
|
||||
if (toolsConfig) {
|
||||
config.tools = toolsConfig
|
||||
}
|
||||
|
||||
agents.push({
|
||||
name,
|
||||
path: agentPath,
|
||||
config,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
export function loadUserAgents(): Record<string, AgentConfig> {
|
||||
const userAgentsDir = join(homedir(), ".claude", "agents")
|
||||
const agents = loadAgentsFromDir(userAgentsDir, "user")
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadProjectAgents(): Record<string, AgentConfig> {
|
||||
const projectAgentsDir = join(process.cwd(), ".claude", "agents")
|
||||
const agents = loadAgentsFromDir(projectAgentsDir, "project")
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
return result
|
||||
}
|
||||
17
src/features/claude-code-agent-loader/types.ts
Normal file
17
src/features/claude-code-agent-loader/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export type AgentScope = "user" | "project"
|
||||
|
||||
export interface AgentFrontmatter {
|
||||
name?: string
|
||||
description?: string
|
||||
model?: string
|
||||
tools?: string
|
||||
}
|
||||
|
||||
export interface LoadedAgent {
|
||||
name: string
|
||||
path: string
|
||||
config: AgentConfig
|
||||
scope: AgentScope
|
||||
}
|
||||
2
src/features/claude-code-command-loader/index.ts
Normal file
2
src/features/claude-code-command-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
92
src/features/claude-code-command-loader/loader.ts
Normal file
92
src/features/claude-code-command-loader/loader.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||
|
||||
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
const commands: LoadedCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const wrappedTemplate = `<command-instruction>
|
||||
${body.trim()}
|
||||
</command-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const formattedDescription = `(${scope}) ${data.description || ""}`
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const definition: CommandDefinition = {
|
||||
name: commandName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
agent: data.agent,
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const cmd of commands) {
|
||||
result[cmd.name] = cmd.definition
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadUserCommands(): Record<string, CommandDefinition> {
|
||||
const userCommandsDir = join(homedir(), ".claude", "commands")
|
||||
const commands = loadCommandsFromDir(userCommandsDir, "user")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadProjectCommands(): Record<string, CommandDefinition> {
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const commands = loadCommandsFromDir(projectCommandsDir, "project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
|
||||
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
|
||||
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadOpencodeProjectCommands(): Record<string, CommandDefinition> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
26
src/features/claude-code-command-loader/types.ts
Normal file
26
src/features/claude-code-command-loader/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
export interface CommandDefinition {
|
||||
name: string
|
||||
description?: string
|
||||
template: string
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
}
|
||||
|
||||
export interface CommandFrontmatter {
|
||||
description?: string
|
||||
"argument-hint"?: string
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
}
|
||||
|
||||
export interface LoadedCommand {
|
||||
name: string
|
||||
path: string
|
||||
definition: CommandDefinition
|
||||
scope: CommandScope
|
||||
}
|
||||
27
src/features/claude-code-mcp-loader/env-expander.ts
Normal file
27
src/features/claude-code-mcp-loader/env-expander.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function expandEnvVars(value: string): string {
|
||||
return value.replace(
|
||||
/\$\{([^}:]+)(?::-([^}]*))?\}/g,
|
||||
(_, varName: string, defaultValue?: string) => {
|
||||
const envValue = process.env[varName]
|
||||
if (envValue !== undefined) return envValue
|
||||
if (defaultValue !== undefined) return defaultValue
|
||||
return ""
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function expandEnvVarsInObject<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj === "string") return expandEnvVars(obj) as T
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => expandEnvVarsInObject(item)) as T
|
||||
}
|
||||
if (typeof obj === "object") {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = expandEnvVarsInObject(value)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
return obj
|
||||
}
|
||||
11
src/features/claude-code-mcp-loader/index.ts
Normal file
11
src/features/claude-code-mcp-loader/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* MCP Configuration Loader
|
||||
*
|
||||
* Loads Claude Code .mcp.json format configurations from multiple scopes
|
||||
* and transforms them to OpenCode SDK format
|
||||
*/
|
||||
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
export * from "./transformer"
|
||||
export * from "./env-expander"
|
||||
89
src/features/claude-code-mcp-loader/loader.ts
Normal file
89
src/features/claude-code-mcp-loader/loader.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { existsSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import type {
|
||||
ClaudeCodeMcpConfig,
|
||||
LoadedMcpServer,
|
||||
McpLoadResult,
|
||||
McpScope,
|
||||
} from "./types"
|
||||
import { transformMcpServer } from "./transformer"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface McpConfigPath {
|
||||
path: string
|
||||
scope: McpScope
|
||||
}
|
||||
|
||||
function getMcpConfigPaths(): McpConfigPath[] {
|
||||
const home = homedir()
|
||||
const cwd = process.cwd()
|
||||
|
||||
return [
|
||||
{ path: join(home, ".claude", ".mcp.json"), scope: "user" },
|
||||
{ path: join(cwd, ".mcp.json"), scope: "project" },
|
||||
{ path: join(cwd, ".claude", ".mcp.json"), scope: "local" },
|
||||
]
|
||||
}
|
||||
|
||||
async function loadMcpConfigFile(
|
||||
filePath: string
|
||||
): Promise<ClaudeCodeMcpConfig | null> {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(filePath).text()
|
||||
return JSON.parse(content) as ClaudeCodeMcpConfig
|
||||
} catch (error) {
|
||||
log(`Failed to load MCP config from ${filePath}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMcpConfigs(): Promise<McpLoadResult> {
|
||||
const servers: McpLoadResult["servers"] = {}
|
||||
const loadedServers: LoadedMcpServer[] = []
|
||||
const paths = getMcpConfigPaths()
|
||||
|
||||
for (const { path, scope } of paths) {
|
||||
const config = await loadMcpConfigFile(path)
|
||||
if (!config?.mcpServers) continue
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
if (serverConfig.disabled) {
|
||||
log(`Skipping disabled MCP server "${name}"`, { path })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const transformed = transformMcpServer(name, serverConfig)
|
||||
servers[name] = transformed
|
||||
|
||||
const existingIndex = loadedServers.findIndex((s) => s.name === name)
|
||||
if (existingIndex !== -1) {
|
||||
loadedServers.splice(existingIndex, 1)
|
||||
}
|
||||
|
||||
loadedServers.push({ name, scope, config: transformed })
|
||||
|
||||
log(`Loaded MCP server "${name}" from ${scope}`, { path })
|
||||
} catch (error) {
|
||||
log(`Failed to transform MCP server "${name}"`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { servers, loadedServers }
|
||||
}
|
||||
|
||||
export function formatLoadedServersForToast(
|
||||
loadedServers: LoadedMcpServer[]
|
||||
): string {
|
||||
if (loadedServers.length === 0) return ""
|
||||
|
||||
return loadedServers
|
||||
.map((server) => `${server.name} (${server.scope})`)
|
||||
.join(", ")
|
||||
}
|
||||
53
src/features/claude-code-mcp-loader/transformer.ts
Normal file
53
src/features/claude-code-mcp-loader/transformer.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
ClaudeCodeMcpServer,
|
||||
McpLocalConfig,
|
||||
McpRemoteConfig,
|
||||
McpServerConfig,
|
||||
} from "./types"
|
||||
import { expandEnvVarsInObject } from "./env-expander"
|
||||
|
||||
export function transformMcpServer(
|
||||
name: string,
|
||||
server: ClaudeCodeMcpServer
|
||||
): McpServerConfig {
|
||||
const expanded = expandEnvVarsInObject(server)
|
||||
const serverType = expanded.type ?? "stdio"
|
||||
|
||||
if (serverType === "http" || serverType === "sse") {
|
||||
if (!expanded.url) {
|
||||
throw new Error(
|
||||
`MCP server "${name}" requires url for type "${serverType}"`
|
||||
)
|
||||
}
|
||||
|
||||
const config: McpRemoteConfig = {
|
||||
type: "remote",
|
||||
url: expanded.url,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
if (expanded.headers && Object.keys(expanded.headers).length > 0) {
|
||||
config.headers = expanded.headers
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
if (!expanded.command) {
|
||||
throw new Error(`MCP server "${name}" requires command for stdio type`)
|
||||
}
|
||||
|
||||
const commandArray = [expanded.command, ...(expanded.args ?? [])]
|
||||
|
||||
const config: McpLocalConfig = {
|
||||
type: "local",
|
||||
command: commandArray,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
if (expanded.env && Object.keys(expanded.env).length > 0) {
|
||||
config.environment = expanded.env
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
42
src/features/claude-code-mcp-loader/types.ts
Normal file
42
src/features/claude-code-mcp-loader/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type McpScope = "user" | "project" | "local"
|
||||
|
||||
export interface ClaudeCodeMcpServer {
|
||||
type?: "http" | "sse" | "stdio"
|
||||
url?: string
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
headers?: Record<string, string>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface ClaudeCodeMcpConfig {
|
||||
mcpServers?: Record<string, ClaudeCodeMcpServer>
|
||||
}
|
||||
|
||||
export interface McpLocalConfig {
|
||||
type: "local"
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface McpRemoteConfig {
|
||||
type: "remote"
|
||||
url: string
|
||||
headers?: Record<string, string>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type McpServerConfig = McpLocalConfig | McpRemoteConfig
|
||||
|
||||
export interface LoadedMcpServer {
|
||||
name: string
|
||||
scope: McpScope
|
||||
config: McpServerConfig
|
||||
}
|
||||
|
||||
export interface McpLoadResult {
|
||||
servers: Record<string, McpServerConfig>
|
||||
loadedServers: LoadedMcpServer[]
|
||||
}
|
||||
21
src/features/claude-code-session-state/detector.ts
Normal file
21
src/features/claude-code-session-state/detector.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export function detectInterrupt(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>
|
||||
const name = errObj.name as string | undefined
|
||||
const message = errObj.message as string | undefined
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message?.includes("abort")) return true
|
||||
const msgLower = message?.toLowerCase()
|
||||
if (msgLower?.includes("aborted") || msgLower?.includes("cancelled") || msgLower?.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
3
src/features/claude-code-session-state/index.ts
Normal file
3
src/features/claude-code-session-state/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./state"
|
||||
export * from "./detector"
|
||||
31
src/features/claude-code-session-state/state.ts
Normal file
31
src/features/claude-code-session-state/state.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { SessionErrorState, SessionInterruptState } from "./types"
|
||||
|
||||
export const sessionErrorState = new Map<string, SessionErrorState>()
|
||||
export const sessionInterruptState = new Map<string, SessionInterruptState>()
|
||||
export const subagentSessions = new Set<string>()
|
||||
export const sessionFirstMessageProcessed = new Set<string>()
|
||||
|
||||
export let currentSessionID: string | undefined
|
||||
export let currentSessionTitle: string | undefined
|
||||
export let mainSessionID: string | undefined
|
||||
|
||||
export function setCurrentSession(id: string | undefined, title: string | undefined) {
|
||||
currentSessionID = id
|
||||
currentSessionTitle = title
|
||||
}
|
||||
|
||||
export function setMainSession(id: string | undefined) {
|
||||
mainSessionID = id
|
||||
}
|
||||
|
||||
export function getCurrentSessionID(): string | undefined {
|
||||
return currentSessionID
|
||||
}
|
||||
|
||||
export function getCurrentSessionTitle(): string | undefined {
|
||||
return currentSessionTitle
|
||||
}
|
||||
|
||||
export function getMainSessionID(): string | undefined {
|
||||
return mainSessionID
|
||||
}
|
||||
8
src/features/claude-code-session-state/types.ts
Normal file
8
src/features/claude-code-session-state/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface SessionErrorState {
|
||||
hasError: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface SessionInterruptState {
|
||||
interrupted: boolean
|
||||
}
|
||||
2
src/features/claude-code-skill-loader/index.ts
Normal file
2
src/features/claude-code-skill-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
83
src/features/claude-code-skill-loader/loader.ts
Normal file
83
src/features/claude-code-skill-loader/loader.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink } from "../../shared/file-utils"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types"
|
||||
|
||||
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsCommand[] {
|
||||
if (!existsSync(skillsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
||||
const skills: LoadedSkillAsCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const skillPath = join(skillsDir, entry.name)
|
||||
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||||
|
||||
const resolvedPath = resolveSymlink(skillPath)
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
if (!existsSync(skillMdPath)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
|
||||
const skillName = data.name || entry.name
|
||||
const originalDescription = data.description || ""
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
${body.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model),
|
||||
}
|
||||
|
||||
skills.push({
|
||||
name: skillName,
|
||||
path: resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
export function loadUserSkillsAsCommands(): Record<string, CommandDefinition> {
|
||||
const userSkillsDir = join(homedir(), ".claude", "skills")
|
||||
const skills = loadSkillsFromDir(userSkillsDir, "user")
|
||||
return skills.reduce((acc, skill) => {
|
||||
acc[skill.name] = skill.definition
|
||||
return acc
|
||||
}, {} as Record<string, CommandDefinition>)
|
||||
}
|
||||
|
||||
export function loadProjectSkillsAsCommands(): Record<string, CommandDefinition> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
const skills = loadSkillsFromDir(projectSkillsDir, "project")
|
||||
return skills.reduce((acc, skill) => {
|
||||
acc[skill.name] = skill.definition
|
||||
return acc
|
||||
}, {} as Record<string, CommandDefinition>)
|
||||
}
|
||||
16
src/features/claude-code-skill-loader/types.ts
Normal file
16
src/features/claude-code-skill-loader/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
|
||||
export type SkillScope = "user" | "project"
|
||||
|
||||
export interface SkillMetadata {
|
||||
name: string
|
||||
description: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface LoadedSkillAsCommand {
|
||||
name: string
|
||||
path: string
|
||||
definition: CommandDefinition
|
||||
scope: SkillScope
|
||||
}
|
||||
8
src/features/hook-message-injector/constants.ts
Normal file
8
src/features/hook-message-injector/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
|
||||
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData, "opencode", "storage")
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
2
src/features/hook-message-injector/index.ts
Normal file
2
src/features/hook-message-injector/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { injectHookMessage } from "./injector"
|
||||
export type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
|
||||
141
src/features/hook-message-injector/injector.ts
Normal file
141
src/features/hook-message-injector/injector.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
|
||||
import type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
|
||||
|
||||
interface StoredMessage {
|
||||
agent?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
function findNearestMessageWithFields(messageDir: string): StoredMessage | null {
|
||||
try {
|
||||
const files = readdirSync(messageDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const msg = JSON.parse(content) as StoredMessage
|
||||
if (msg.agent && msg.model?.providerID && msg.model?.modelID) {
|
||||
return msg
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function generateMessageId(): string {
|
||||
const timestamp = Date.now().toString(16)
|
||||
const random = Math.random().toString(36).substring(2, 14)
|
||||
return `msg_${timestamp}${random}`
|
||||
}
|
||||
|
||||
function generatePartId(): string {
|
||||
const timestamp = Date.now().toString(16)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `prt_${timestamp}${random}`
|
||||
}
|
||||
|
||||
function getOrCreateMessageDir(sessionID: string): string {
|
||||
if (!existsSync(MESSAGE_STORAGE)) {
|
||||
mkdirSync(MESSAGE_STORAGE, { recursive: true })
|
||||
}
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) {
|
||||
return directPath
|
||||
}
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) {
|
||||
return sessionPath
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(directPath, { recursive: true })
|
||||
return directPath
|
||||
}
|
||||
|
||||
export function injectHookMessage(
|
||||
sessionID: string,
|
||||
hookContent: string,
|
||||
originalMessage: OriginalMessageContext
|
||||
): boolean {
|
||||
const messageDir = getOrCreateMessageDir(sessionID)
|
||||
|
||||
const needsFallback =
|
||||
!originalMessage.agent ||
|
||||
!originalMessage.model?.providerID ||
|
||||
!originalMessage.model?.modelID
|
||||
|
||||
const fallback = needsFallback ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const now = Date.now()
|
||||
const messageID = generateMessageId()
|
||||
const partID = generatePartId()
|
||||
|
||||
const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"
|
||||
const resolvedModel =
|
||||
originalMessage.model?.providerID && originalMessage.model?.modelID
|
||||
? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID }
|
||||
: fallback?.model?.providerID && fallback?.model?.modelID
|
||||
? { providerID: fallback.model.providerID, modelID: fallback.model.modelID }
|
||||
: undefined
|
||||
const resolvedTools = originalMessage.tools ?? fallback?.tools
|
||||
|
||||
const messageMeta: MessageMeta = {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
agent: resolvedAgent,
|
||||
model: resolvedModel,
|
||||
path:
|
||||
originalMessage.path?.cwd
|
||||
? {
|
||||
cwd: originalMessage.path.cwd,
|
||||
root: originalMessage.path.root ?? "/",
|
||||
}
|
||||
: undefined,
|
||||
tools: resolvedTools,
|
||||
}
|
||||
|
||||
const textPart: TextPart = {
|
||||
id: partID,
|
||||
type: "text",
|
||||
text: hookContent,
|
||||
synthetic: true,
|
||||
time: {
|
||||
start: now,
|
||||
end: now,
|
||||
},
|
||||
messageID,
|
||||
sessionID,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2))
|
||||
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2))
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
45
src/features/hook-message-injector/types.ts
Normal file
45
src/features/hook-message-injector/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface MessageMeta {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user" | "assistant"
|
||||
time: {
|
||||
created: number
|
||||
completed?: number
|
||||
}
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
path?: {
|
||||
cwd: string
|
||||
root: string
|
||||
}
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface OriginalMessageContext {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
path?: {
|
||||
cwd?: string
|
||||
root?: string
|
||||
}
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface TextPart {
|
||||
id: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic: boolean
|
||||
time: {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
messageID: string
|
||||
sessionID: string
|
||||
}
|
||||
8
src/google-auth.ts
Normal file
8
src/google-auth.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"
|
||||
|
||||
const GoogleAntigravityAuthPlugin: Plugin = async (ctx) => {
|
||||
return createGoogleAntigravityAuthPlugin(ctx)
|
||||
}
|
||||
|
||||
export default GoogleAntigravityAuthPlugin
|
||||
53
src/hooks/agent-usage-reminder/constants.ts
Normal file
53
src/hooks/agent-usage-reminder/constants.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const AGENT_USAGE_REMINDER_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"agent-usage-reminder",
|
||||
);
|
||||
|
||||
// All tool names normalized to lowercase for case-insensitive matching
|
||||
export const TARGET_TOOLS = new Set([
|
||||
"grep",
|
||||
"safe_grep",
|
||||
"glob",
|
||||
"safe_glob",
|
||||
"webfetch",
|
||||
"context7_resolve-library-id",
|
||||
"context7_get-library-docs",
|
||||
"websearch_exa_web_search_exa",
|
||||
"grep_app_searchgithub",
|
||||
]);
|
||||
|
||||
export const AGENT_TOOLS = new Set([
|
||||
"task",
|
||||
"call_omo_agent",
|
||||
"background_task",
|
||||
]);
|
||||
|
||||
export const REMINDER_MESSAGE = `
|
||||
[Agent Usage Reminder]
|
||||
|
||||
You called a search/fetch tool directly without leveraging specialized agents.
|
||||
|
||||
RECOMMENDED: Use background_task with explore/librarian agents for better results:
|
||||
|
||||
\`\`\`
|
||||
// Parallel exploration - fire multiple agents simultaneously
|
||||
background_task(agent="explore", prompt="Find all files matching pattern X")
|
||||
background_task(agent="explore", prompt="Search for implementation of Y")
|
||||
background_task(agent="librarian", prompt="Lookup documentation for Z")
|
||||
|
||||
// Then continue your work while they run in background
|
||||
// System will notify you when each completes
|
||||
\`\`\`
|
||||
|
||||
WHY:
|
||||
- Agents can perform deeper, more thorough searches
|
||||
- Background tasks run in parallel, saving time
|
||||
- Specialized agents have domain expertise
|
||||
- Reduces context window usage in main session
|
||||
|
||||
ALWAYS prefer: Multiple parallel background_task calls > Direct tool calls
|
||||
`;
|
||||
109
src/hooks/agent-usage-reminder/index.ts
Normal file
109
src/hooks/agent-usage-reminder/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import {
|
||||
loadAgentUsageState,
|
||||
saveAgentUsageState,
|
||||
clearAgentUsageState,
|
||||
} from "./storage";
|
||||
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
||||
const sessionStates = new Map<string, AgentUsageState>();
|
||||
|
||||
function getOrCreateState(sessionID: string): AgentUsageState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
const persisted = loadAgentUsageState(sessionID);
|
||||
const state: AgentUsageState = persisted ?? {
|
||||
sessionID,
|
||||
agentUsed: false,
|
||||
reminderCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
sessionStates.set(sessionID, state);
|
||||
}
|
||||
return sessionStates.get(sessionID)!;
|
||||
}
|
||||
|
||||
function markAgentUsed(sessionID: string): void {
|
||||
const state = getOrCreateState(sessionID);
|
||||
state.agentUsed = true;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
}
|
||||
|
||||
function resetState(sessionID: string): void {
|
||||
sessionStates.delete(sessionID);
|
||||
clearAgentUsageState(sessionID);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID } = input;
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (AGENT_TOOLS.has(toolLower)) {
|
||||
markAgentUsed(sessionID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TARGET_TOOLS.has(toolLower)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getOrCreateState(sessionID);
|
||||
|
||||
if (state.agentUsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
output.output += REMINDER_MESSAGE;
|
||||
state.reminderCount++;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
resetState(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
resetState(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
42
src/hooks/agent-usage-reminder/storage.ts
Normal file
42
src/hooks/agent-usage-reminder/storage.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { AGENT_USAGE_REMINDER_STORAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
|
||||
function getStoragePath(sessionID: string): string {
|
||||
return join(AGENT_USAGE_REMINDER_STORAGE, `${sessionID}.json`);
|
||||
}
|
||||
|
||||
export function loadAgentUsageState(sessionID: string): AgentUsageState | null {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (!existsSync(filePath)) return null;
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
return JSON.parse(content) as AgentUsageState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAgentUsageState(state: AgentUsageState): void {
|
||||
if (!existsSync(AGENT_USAGE_REMINDER_STORAGE)) {
|
||||
mkdirSync(AGENT_USAGE_REMINDER_STORAGE, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = getStoragePath(state.sessionID);
|
||||
writeFileSync(filePath, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
export function clearAgentUsageState(sessionID: string): void {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
6
src/hooks/agent-usage-reminder/types.ts
Normal file
6
src/hooks/agent-usage-reminder/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface AgentUsageState {
|
||||
sessionID: string;
|
||||
agentUsed: boolean;
|
||||
reminderCount: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
143
src/hooks/anthropic-auto-compact/executor.ts
Normal file
143
src/hooks/anthropic-auto-compact/executor.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { AutoCompactState, RetryState } from "./types"
|
||||
import { RETRY_CONFIG } from "./types"
|
||||
|
||||
type Client = {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown>
|
||||
summarize: (opts: {
|
||||
path: { id: string }
|
||||
body: { providerID: string; modelID: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
|
||||
showToast: (opts: {
|
||||
body: { title: string; message: string; variant: string; duration: number }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function calculateRetryDelay(attempt: number): number {
|
||||
const delay = RETRY_CONFIG.initialDelayMs * Math.pow(RETRY_CONFIG.backoffFactor, attempt - 1)
|
||||
return Math.min(delay, RETRY_CONFIG.maxDelayMs)
|
||||
}
|
||||
|
||||
function shouldRetry(retryState: RetryState | undefined): boolean {
|
||||
if (!retryState) return true
|
||||
return retryState.attempt < RETRY_CONFIG.maxAttempts
|
||||
}
|
||||
|
||||
function getOrCreateRetryState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string
|
||||
): RetryState {
|
||||
let state = autoCompactState.retryStateBySession.get(sessionID)
|
||||
if (!state) {
|
||||
state = { attempt: 0, lastAttemptTime: 0 }
|
||||
autoCompactState.retryStateBySession.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
export async function getLastAssistant(
|
||||
sessionID: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const resp = await (client as Client).session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data
|
||||
if (!Array.isArray(data)) return null
|
||||
|
||||
const reversed = [...data].reverse()
|
||||
const last = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>
|
||||
const info = msg.info as Record<string, unknown> | undefined
|
||||
return info?.role === "assistant"
|
||||
})
|
||||
if (!last) return null
|
||||
return (last as { info?: Record<string, unknown> }).info ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionState(autoCompactState: AutoCompactState, sessionID: string): void {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
autoCompactState.errorDataBySession.delete(sessionID)
|
||||
autoCompactState.retryStateBySession.delete(sessionID)
|
||||
}
|
||||
|
||||
export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
autoCompactState: AutoCompactState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<void> {
|
||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
|
||||
|
||||
if (!shouldRetry(retryState)) {
|
||||
clearSessionState(autoCompactState, sessionID)
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact Failed",
|
||||
message: `Failed after ${RETRY_CONFIG.maxAttempts} attempts. Please try manual compact.`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
retryState.attempt++
|
||||
retryState.lastAttemptTime = Date.now()
|
||||
|
||||
try {
|
||||
const providerID = msg.providerID as string | undefined
|
||||
const modelID = msg.modelID as string | undefined
|
||||
|
||||
if (providerID && modelID) {
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
clearSessionState(autoCompactState, sessionID)
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).tui.submitPrompt({ query: { directory } })
|
||||
} catch {}
|
||||
}, 500)
|
||||
}
|
||||
} catch {
|
||||
const delay = calculateRetryDelay(retryState.attempt)
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact Retry",
|
||||
message: `Attempt ${retryState.attempt}/${RETRY_CONFIG.maxAttempts} failed. Retrying in ${Math.round(delay / 1000)}s...`,
|
||||
variant: "warning",
|
||||
duration: delay,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
setTimeout(() => {
|
||||
executeCompact(sessionID, msg, autoCompactState, client, directory)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
125
src/hooks/anthropic-auto-compact/index.ts
Normal file
125
src/hooks/anthropic-auto-compact/index.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
|
||||
function createAutoCompactState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
retryStateBySession: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
||||
const autoCompactState = createAutoCompactState()
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const parsed = parseAnthropicTokenLimitError(props?.error)
|
||||
if (parsed) {
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
const parsed = parseAnthropicTokenLimitError(info.error)
|
||||
if (parsed) {
|
||||
parsed.providerID = info.providerID as string | undefined
|
||||
parsed.modelID = info.modelID as string | undefined
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||
if (errorData?.providerID && errorData?.modelID) {
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
{ providerID: errorData.providerID, modelID: errorData.modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
if (!lastAssistant) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAssistant.summary === true) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (!lastAssistant.modelID || !lastAssistant.providerID) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(sessionID, lastAssistant, autoCompactState, ctx.client, ctx.directory)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { ParsedTokenLimitError } from "./types"
|
||||
|
||||
interface AnthropicErrorData {
|
||||
type: "error"
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
const TOKEN_LIMIT_PATTERNS = [
|
||||
/(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i,
|
||||
/prompt.*?(\d+).*?tokens.*?exceeds.*?(\d+)/i,
|
||||
/(\d+).*?tokens.*?limit.*?(\d+)/i,
|
||||
/context.*?length.*?(\d+).*?maximum.*?(\d+)/i,
|
||||
/max.*?context.*?(\d+).*?but.*?(\d+)/i,
|
||||
]
|
||||
|
||||
const TOKEN_LIMIT_KEYWORDS = [
|
||||
"prompt is too long",
|
||||
"is too long",
|
||||
"context_length_exceeded",
|
||||
"max_tokens",
|
||||
"token limit",
|
||||
"context length",
|
||||
"too many tokens",
|
||||
]
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
for (const pattern of TOKEN_LIMIT_PATTERNS) {
|
||||
const match = message.match(pattern)
|
||||
if (match) {
|
||||
const num1 = parseInt(match[1], 10)
|
||||
const num2 = parseInt(match[2], 10)
|
||||
return num1 > num2 ? { current: num1, max: num2 } : { current: num2, max: num1 }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||
if (typeof err === "string") {
|
||||
if (isTokenLimitError(err)) {
|
||||
const tokens = extractTokensFromMessage(err)
|
||||
return {
|
||||
currentTokens: tokens?.current ?? 0,
|
||||
maxTokens: tokens?.max ?? 0,
|
||||
errorType: "token_limit_exceeded_string",
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!err || typeof err !== "object") return null
|
||||
|
||||
const errObj = err as Record<string, unknown>
|
||||
|
||||
const dataObj = errObj.data as Record<string, unknown> | undefined
|
||||
const responseBody = dataObj?.responseBody
|
||||
const errorMessage = errObj.message as string | undefined
|
||||
const errorData = errObj.error as Record<string, unknown> | undefined
|
||||
const nestedError = errorData?.error as Record<string, unknown> | undefined
|
||||
|
||||
const textSources: string[] = []
|
||||
|
||||
if (typeof responseBody === "string") textSources.push(responseBody)
|
||||
if (typeof errorMessage === "string") textSources.push(errorMessage)
|
||||
if (typeof errorData?.message === "string") textSources.push(errorData.message as string)
|
||||
if (typeof errObj.body === "string") textSources.push(errObj.body as string)
|
||||
if (typeof errObj.details === "string") textSources.push(errObj.details as string)
|
||||
if (typeof errObj.reason === "string") textSources.push(errObj.reason as string)
|
||||
if (typeof errObj.description === "string") textSources.push(errObj.description as string)
|
||||
if (typeof nestedError?.message === "string") textSources.push(nestedError.message as string)
|
||||
if (typeof dataObj?.message === "string") textSources.push(dataObj.message as string)
|
||||
if (typeof dataObj?.error === "string") textSources.push(dataObj.error as string)
|
||||
|
||||
if (textSources.length === 0) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(errObj)
|
||||
if (isTokenLimitError(jsonStr)) {
|
||||
textSources.push(jsonStr)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const combinedText = textSources.join(" ")
|
||||
if (!isTokenLimitError(combinedText)) return null
|
||||
|
||||
if (typeof responseBody === "string") {
|
||||
try {
|
||||
const jsonPatterns = [
|
||||
/data:\s*(\{[\s\S]*?\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
|
||||
/(\{[\s\S]*?"error"[\s\S]*?\})/,
|
||||
]
|
||||
|
||||
for (const pattern of jsonPatterns) {
|
||||
const dataMatch = responseBody.match(pattern)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
const jsonData: AnthropicErrorData = JSON.parse(dataMatch[1])
|
||||
const message = jsonData.error?.message || ""
|
||||
const tokens = extractTokensFromMessage(message)
|
||||
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
requestId: jsonData.request_id,
|
||||
errorType: jsonData.error?.type || "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const bedrockJson = JSON.parse(responseBody)
|
||||
if (typeof bedrockJson.message === "string" && isTokenLimitError(bedrockJson.message)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "bedrock_input_too_long",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const text of textSources) {
|
||||
const tokens = extractTokensFromMessage(text)
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
errorType: "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTokenLimitError(combinedText)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "token_limit_exceeded_unknown",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
26
src/hooks/anthropic-auto-compact/types.ts
Normal file
26
src/hooks/anthropic-auto-compact/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface ParsedTokenLimitError {
|
||||
currentTokens: number
|
||||
maxTokens: number
|
||||
requestId?: string
|
||||
errorType: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export interface RetryState {
|
||||
attempt: number
|
||||
lastAttemptTime: number
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
retryStateBySession: Map<string, RetryState>
|
||||
}
|
||||
|
||||
export const RETRY_CONFIG = {
|
||||
maxAttempts: 5,
|
||||
initialDelayMs: 2000,
|
||||
backoffFactor: 2,
|
||||
maxDelayMs: 30000,
|
||||
} as const
|
||||
47
src/hooks/auto-update-checker/cache.ts
Normal file
47
src/hooks/auto-update-checker/cache.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { CACHE_DIR, PACKAGE_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
|
||||
try {
|
||||
const pkgDir = path.join(CACHE_DIR, "node_modules", packageName)
|
||||
const pkgJsonPath = path.join(CACHE_DIR, "package.json")
|
||||
|
||||
let packageRemoved = false
|
||||
let dependencyRemoved = false
|
||||
|
||||
if (fs.existsSync(pkgDir)) {
|
||||
fs.rmSync(pkgDir, { recursive: true, force: true })
|
||||
log(`[auto-update-checker] Package removed: ${pkgDir}`)
|
||||
packageRemoved = true
|
||||
}
|
||||
|
||||
if (fs.existsSync(pkgJsonPath)) {
|
||||
const content = fs.readFileSync(pkgJsonPath, "utf-8")
|
||||
const pkgJson = JSON.parse(content)
|
||||
if (pkgJson.dependencies?.[packageName]) {
|
||||
delete pkgJson.dependencies[packageName]
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
|
||||
log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`)
|
||||
dependencyRemoved = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!packageRemoved && !dependencyRemoved) {
|
||||
log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Failed to invalidate package:", err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use invalidatePackage instead - this nukes ALL plugins */
|
||||
export function invalidateCache(): boolean {
|
||||
log("[auto-update-checker] WARNING: invalidateCache is deprecated, use invalidatePackage")
|
||||
return invalidatePackage()
|
||||
}
|
||||
165
src/hooks/auto-update-checker/checker.ts
Normal file
165
src/hooks/auto-update-checker/checker.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import type { NpmDistTags, OpencodeConfig, PackageJson, UpdateCheckResult } from "./types"
|
||||
import {
|
||||
PACKAGE_NAME,
|
||||
NPM_REGISTRY_URL,
|
||||
NPM_FETCH_TIMEOUT,
|
||||
INSTALLED_PACKAGE_JSON,
|
||||
USER_OPENCODE_CONFIG,
|
||||
} from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function isLocalDevMode(directory: string): boolean {
|
||||
return getLocalDevPath(directory) !== null
|
||||
}
|
||||
|
||||
export function getLocalDevPath(directory: string): string | null {
|
||||
const projectConfig = path.join(directory, ".opencode", "opencode.json")
|
||||
|
||||
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(content) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
|
||||
return entry.replace("file://", "")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getLocalDevVersion(directory: string): string | null {
|
||||
const localPath = getLocalDevPath(directory)
|
||||
if (!localPath) return null
|
||||
|
||||
try {
|
||||
const pkgPath = path.join(localPath, "package.json")
|
||||
if (!fs.existsSync(pkgPath)) return null
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
return pkg.version ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginEntryInfo {
|
||||
entry: string
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
}
|
||||
|
||||
export function findPluginEntry(directory: string): PluginEntryInfo | null {
|
||||
const projectConfig = path.join(directory, ".opencode", "opencode.json")
|
||||
|
||||
for (const configPath of [projectConfig, USER_OPENCODE_CONFIG]) {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) continue
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const config = JSON.parse(content) as OpencodeConfig
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const entry of plugins) {
|
||||
if (entry === PACKAGE_NAME) {
|
||||
return { entry, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
if (entry.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
|
||||
const isPinned = pinnedVersion !== "latest"
|
||||
return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getCachedVersion(): string | null {
|
||||
try {
|
||||
if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
|
||||
const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const pkgPath = path.resolve(import.meta.dirname, "..", "..", "..", "package.json")
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const content = fs.readFileSync(pkgPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as PackageJson
|
||||
if (pkg.version) return pkg.version
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getLatestVersion(): Promise<string | null> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
|
||||
|
||||
try {
|
||||
const response = await fetch(NPM_REGISTRY_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = (await response.json()) as NpmDistTags
|
||||
return data.latest ?? null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkForUpdate(directory: string): Promise<UpdateCheckResult> {
|
||||
if (isLocalDevMode(directory)) {
|
||||
log("[auto-update-checker] Local dev mode detected, skipping update check")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: true, isPinned: false }
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(directory)
|
||||
if (!pluginInfo) {
|
||||
log("[auto-update-checker] Plugin not found in config")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
|
||||
}
|
||||
|
||||
// Respect version pinning
|
||||
if (pluginInfo.isPinned) {
|
||||
log(`[auto-update-checker] Version pinned to ${pluginInfo.pinnedVersion}, skipping update check`)
|
||||
return { needsUpdate: false, currentVersion: pluginInfo.pinnedVersion, latestVersion: null, isLocalDev: false, isPinned: true }
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion()
|
||||
if (!currentVersion) {
|
||||
log("[auto-update-checker] No cached version found")
|
||||
return { needsUpdate: false, currentVersion: null, latestVersion: null, isLocalDev: false, isPinned: false }
|
||||
}
|
||||
|
||||
const latestVersion = await getLatestVersion()
|
||||
if (!latestVersion) {
|
||||
log("[auto-update-checker] Failed to fetch latest version")
|
||||
return { needsUpdate: false, currentVersion, latestVersion: null, isLocalDev: false, isPinned: false }
|
||||
}
|
||||
|
||||
const needsUpdate = currentVersion !== latestVersion
|
||||
log(`[auto-update-checker] Current: ${currentVersion}, Latest: ${latestVersion}, NeedsUpdate: ${needsUpdate}`)
|
||||
|
||||
return { needsUpdate, currentVersion, latestVersion, isLocalDev: false, isPinned: false }
|
||||
}
|
||||
40
src/hooks/auto-update-checker/constants.ts
Normal file
40
src/hooks/auto-update-checker/constants.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
|
||||
export const PACKAGE_NAME = "oh-my-opencode"
|
||||
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
|
||||
export const NPM_FETCH_TIMEOUT = 5000
|
||||
|
||||
/**
|
||||
* OpenCode plugin cache directory
|
||||
* - Linux/macOS: ~/.cache/opencode/
|
||||
* - Windows: %LOCALAPPDATA%/opencode/
|
||||
*/
|
||||
function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
|
||||
}
|
||||
return path.join(os.homedir(), ".cache", "opencode")
|
||||
}
|
||||
|
||||
export const CACHE_DIR = getCacheDir()
|
||||
export const VERSION_FILE = path.join(CACHE_DIR, "version")
|
||||
export const INSTALLED_PACKAGE_JSON = path.join(
|
||||
CACHE_DIR,
|
||||
"node_modules",
|
||||
PACKAGE_NAME,
|
||||
"package.json"
|
||||
)
|
||||
|
||||
/**
|
||||
* OpenCode config file locations (priority order)
|
||||
*/
|
||||
function getUserConfigDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
|
||||
}
|
||||
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
|
||||
}
|
||||
|
||||
export const USER_CONFIG_DIR = getUserConfigDir()
|
||||
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
|
||||
88
src/hooks/auto-update-checker/index.ts
Normal file
88
src/hooks/auto-update-checker/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { checkForUpdate, getCachedVersion, getLocalDevVersion } from "./checker"
|
||||
import { invalidatePackage } from "./cache"
|
||||
import { PACKAGE_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import type { AutoUpdateCheckerOptions } from "./types"
|
||||
|
||||
export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
|
||||
const { showStartupToast = true } = options
|
||||
let hasChecked = false
|
||||
|
||||
return {
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type !== "session.created") return
|
||||
if (hasChecked) return
|
||||
|
||||
const props = event.properties as { info?: { parentID?: string } } | undefined
|
||||
if (props?.info?.parentID) return
|
||||
|
||||
hasChecked = true
|
||||
|
||||
try {
|
||||
const result = await checkForUpdate(ctx.directory)
|
||||
|
||||
if (result.isLocalDev) {
|
||||
log("[auto-update-checker] Skipped: local development mode")
|
||||
if (showStartupToast) {
|
||||
const version = getLocalDevVersion(ctx.directory) ?? getCachedVersion()
|
||||
await showVersionToast(ctx, version)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (result.isPinned) {
|
||||
log(`[auto-update-checker] Skipped: version pinned to ${result.currentVersion}`)
|
||||
if (showStartupToast) {
|
||||
await showVersionToast(ctx, result.currentVersion)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.needsUpdate) {
|
||||
log("[auto-update-checker] No update needed")
|
||||
if (showStartupToast) {
|
||||
await showVersionToast(ctx, result.currentVersion)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: `OhMyOpenCode ${result.latestVersion}`,
|
||||
message: `OpenCode is now on Steroids. oMoMoMoMo...\nv${result.latestVersion} available. Restart OpenCode to apply.`,
|
||||
variant: "info" as const,
|
||||
duration: 8000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
log(`[auto-update-checker] Update notification sent: v${result.currentVersion} → v${result.latestVersion}`)
|
||||
} catch (err) {
|
||||
log("[auto-update-checker] Error during update check:", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function showVersionToast(ctx: PluginInput, version: string | null): Promise<void> {
|
||||
const displayVersion = version ?? "unknown"
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: `OhMyOpenCode ${displayVersion}`,
|
||||
message: "OpenCode is now on Steroids. oMoMoMoMo...",
|
||||
variant: "info" as const,
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
log(`[auto-update-checker] Startup toast shown: v${displayVersion}`)
|
||||
}
|
||||
|
||||
export type { UpdateCheckResult, AutoUpdateCheckerOptions } from "./types"
|
||||
export { checkForUpdate } from "./checker"
|
||||
export { invalidatePackage, invalidateCache } from "./cache"
|
||||
27
src/hooks/auto-update-checker/types.ts
Normal file
27
src/hooks/auto-update-checker/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface NpmDistTags {
|
||||
latest: string
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export interface OpencodeConfig {
|
||||
plugin?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PackageJson {
|
||||
version: string
|
||||
name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
needsUpdate: boolean
|
||||
currentVersion: string | null
|
||||
latestVersion: string | null
|
||||
isLocalDev: boolean
|
||||
isPinned: boolean
|
||||
}
|
||||
|
||||
export interface AutoUpdateCheckerOptions {
|
||||
showStartupToast?: boolean
|
||||
}
|
||||
22
src/hooks/background-notification/index.ts
Normal file
22
src/hooks/background-notification/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
interface Event {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: Event
|
||||
}
|
||||
|
||||
export function createBackgroundNotificationHook(manager: BackgroundManager) {
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
manager.handleEvent(event)
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export type { BackgroundNotificationHookConfig } from "./types"
|
||||
5
src/hooks/background-notification/types.ts
Normal file
5
src/hooks/background-notification/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { BackgroundTask } from "../../features/background-agent"
|
||||
|
||||
export interface BackgroundNotificationHookConfig {
|
||||
formatNotification?: (tasks: BackgroundTask[]) => string
|
||||
}
|
||||
105
src/hooks/claude-code-hooks/config-loader.ts
Normal file
105
src/hooks/claude-code-hooks/config-loader.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { existsSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import type { ClaudeHookEvent } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface DisabledHooksConfig {
|
||||
Stop?: string[]
|
||||
PreToolUse?: string[]
|
||||
PostToolUse?: string[]
|
||||
UserPromptSubmit?: string[]
|
||||
}
|
||||
|
||||
export interface PluginExtendedConfig {
|
||||
disabledHooks?: DisabledHooksConfig
|
||||
}
|
||||
|
||||
const USER_CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-cc-plugin.json")
|
||||
|
||||
function getProjectConfigPath(): string {
|
||||
return join(process.cwd(), ".opencode", "opencode-cc-plugin.json")
|
||||
}
|
||||
|
||||
async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig | null> {
|
||||
if (!existsSync(path)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(path).text()
|
||||
return JSON.parse(content) as PluginExtendedConfig
|
||||
} catch (error) {
|
||||
log("Failed to load config", { path, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDisabledHooks(
|
||||
base: DisabledHooksConfig | undefined,
|
||||
override: DisabledHooksConfig | undefined
|
||||
): DisabledHooksConfig {
|
||||
if (!override) return base ?? {}
|
||||
if (!base) return override
|
||||
|
||||
return {
|
||||
Stop: override.Stop ?? base.Stop,
|
||||
PreToolUse: override.PreToolUse ?? base.PreToolUse,
|
||||
PostToolUse: override.PostToolUse ?? base.PostToolUse,
|
||||
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPluginExtendedConfig(): Promise<PluginExtendedConfig> {
|
||||
const userConfig = await loadConfigFromPath(USER_CONFIG_PATH)
|
||||
const projectConfig = await loadConfigFromPath(getProjectConfigPath())
|
||||
|
||||
const merged: PluginExtendedConfig = {
|
||||
disabledHooks: mergeDisabledHooks(
|
||||
userConfig?.disabledHooks,
|
||||
projectConfig?.disabledHooks
|
||||
),
|
||||
}
|
||||
|
||||
if (userConfig || projectConfig) {
|
||||
log("Plugin extended config loaded", {
|
||||
userConfigExists: userConfig !== null,
|
||||
projectConfigExists: projectConfig !== null,
|
||||
mergedDisabledHooks: merged.disabledHooks,
|
||||
})
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const regexCache = new Map<string, RegExp>()
|
||||
|
||||
function getRegex(pattern: string): RegExp {
|
||||
let regex = regexCache.get(pattern)
|
||||
if (!regex) {
|
||||
try {
|
||||
regex = new RegExp(pattern)
|
||||
regexCache.set(pattern, regex)
|
||||
} catch {
|
||||
regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
regexCache.set(pattern, regex)
|
||||
}
|
||||
}
|
||||
return regex
|
||||
}
|
||||
|
||||
export function isHookCommandDisabled(
|
||||
eventType: ClaudeHookEvent,
|
||||
command: string,
|
||||
config: PluginExtendedConfig | null
|
||||
): boolean {
|
||||
if (!config?.disabledHooks) return false
|
||||
|
||||
const patterns = config.disabledHooks[eventType]
|
||||
if (!patterns || patterns.length === 0) return false
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
const regex = getRegex(pattern)
|
||||
return regex.test(command)
|
||||
})
|
||||
}
|
||||
100
src/hooks/claude-code-hooks/config.ts
Normal file
100
src/hooks/claude-code-hooks/config.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
|
||||
|
||||
interface RawHookMatcher {
|
||||
matcher?: string
|
||||
pattern?: string
|
||||
hooks: HookCommand[]
|
||||
}
|
||||
|
||||
interface RawClaudeHooksConfig {
|
||||
PreToolUse?: RawHookMatcher[]
|
||||
PostToolUse?: RawHookMatcher[]
|
||||
UserPromptSubmit?: RawHookMatcher[]
|
||||
Stop?: RawHookMatcher[]
|
||||
}
|
||||
|
||||
function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
|
||||
return {
|
||||
matcher: raw.matcher ?? raw.pattern ?? "*",
|
||||
hooks: raw.hooks,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
|
||||
const result: ClaudeHooksConfig = {}
|
||||
const eventTypes: (keyof RawClaudeHooksConfig)[] = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
]
|
||||
|
||||
for (const eventType of eventTypes) {
|
||||
if (raw[eventType]) {
|
||||
result[eventType] = raw[eventType].map(normalizeHookMatcher)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getClaudeSettingsPaths(customPath?: string): string[] {
|
||||
const home = homedir()
|
||||
const paths = [
|
||||
join(home, ".claude", "settings.json"),
|
||||
join(process.cwd(), ".claude", "settings.json"),
|
||||
join(process.cwd(), ".claude", "settings.local.json"),
|
||||
]
|
||||
|
||||
if (customPath && existsSync(customPath)) {
|
||||
paths.unshift(customPath)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
function mergeHooksConfig(
|
||||
base: ClaudeHooksConfig,
|
||||
override: ClaudeHooksConfig
|
||||
): ClaudeHooksConfig {
|
||||
const result: ClaudeHooksConfig = { ...base }
|
||||
const eventTypes: (keyof ClaudeHooksConfig)[] = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
]
|
||||
for (const eventType of eventTypes) {
|
||||
if (override[eventType]) {
|
||||
result[eventType] = [...(base[eventType] || []), ...override[eventType]]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function loadClaudeHooksConfig(
|
||||
customSettingsPath?: string
|
||||
): Promise<ClaudeHooksConfig | null> {
|
||||
const paths = getClaudeSettingsPaths(customSettingsPath)
|
||||
let mergedConfig: ClaudeHooksConfig = {}
|
||||
|
||||
for (const settingsPath of paths) {
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = await Bun.file(settingsPath).text()
|
||||
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
|
||||
if (settings.hooks) {
|
||||
const normalizedHooks = normalizeHooksConfig(settings.hooks)
|
||||
mergedConfig = mergeHooksConfig(mergedConfig, normalizedHooks)
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
|
||||
}
|
||||
336
src/hooks/claude-code-hooks/index.ts
Normal file
336
src/hooks/claude-code-hooks/index.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { loadClaudeHooksConfig } from "./config"
|
||||
import { loadPluginExtendedConfig } from "./config-loader"
|
||||
import {
|
||||
executePreToolUseHooks,
|
||||
type PreToolUseContext,
|
||||
} from "./pre-tool-use"
|
||||
import {
|
||||
executePostToolUseHooks,
|
||||
type PostToolUseContext,
|
||||
type PostToolUseClient,
|
||||
} from "./post-tool-use"
|
||||
import {
|
||||
executeUserPromptSubmitHooks,
|
||||
type UserPromptSubmitContext,
|
||||
type MessagePart,
|
||||
} from "./user-prompt-submit"
|
||||
import {
|
||||
executeStopHooks,
|
||||
type StopContext,
|
||||
} from "./stop"
|
||||
import { cacheToolInput, getToolInput } from "./tool-input-cache"
|
||||
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
|
||||
import type { PluginConfig } from "./types"
|
||||
import { log, isHookDisabled } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
|
||||
|
||||
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
},
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
const interruptState = sessionInterruptState.get(input.sessionID)
|
||||
if (interruptState?.interrupted) {
|
||||
log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const textParts = output.parts.filter((p) => p.type === "text" && p.text)
|
||||
const prompt = textParts.map((p) => p.text ?? "").join("\n")
|
||||
|
||||
recordUserMessage(input.sessionID, prompt)
|
||||
|
||||
const messageParts: MessagePart[] = textParts.map((p) => ({
|
||||
type: p.type as "text",
|
||||
text: p.text,
|
||||
}))
|
||||
|
||||
const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID)
|
||||
if (interruptStateBeforeHooks?.interrupted) {
|
||||
log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
let parentSessionId: string | undefined
|
||||
try {
|
||||
const sessionInfo = await ctx.client.session.get({
|
||||
path: { id: input.sessionID },
|
||||
})
|
||||
parentSessionId = sessionInfo.data?.parentID
|
||||
} catch {}
|
||||
|
||||
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
if (isFirstMessage) {
|
||||
log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isHookDisabled(config, "UserPromptSubmit")) {
|
||||
const userPromptCtx: UserPromptSubmitContext = {
|
||||
sessionId: input.sessionID,
|
||||
parentSessionId,
|
||||
prompt,
|
||||
parts: messageParts,
|
||||
cwd: ctx.directory,
|
||||
}
|
||||
|
||||
const result = await executeUserPromptSubmitHooks(
|
||||
userPromptCtx,
|
||||
claudeConfig,
|
||||
extendedConfig
|
||||
)
|
||||
|
||||
if (result.block) {
|
||||
throw new Error(result.reason ?? "Hook blocked the prompt")
|
||||
}
|
||||
|
||||
const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID)
|
||||
if (interruptStateAfterHooks?.interrupted) {
|
||||
log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
const hookContent = result.messages.join("\n\n")
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> }
|
||||
): Promise<void> => {
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
recordToolUse(input.sessionID, input.tool, output.args as Record<string, unknown>)
|
||||
|
||||
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
|
||||
|
||||
if (!isHookDisabled(config, "PreToolUse")) {
|
||||
const preCtx: PreToolUseContext = {
|
||||
sessionId: input.sessionID,
|
||||
toolName: input.tool,
|
||||
toolInput: output.args as Record<string, unknown>,
|
||||
cwd: ctx.directory,
|
||||
toolUseId: input.callID,
|
||||
}
|
||||
|
||||
const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig)
|
||||
|
||||
if (result.decision === "deny") {
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "PreToolUse Hook Executed",
|
||||
message: `✗ ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: BLOCKED ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`,
|
||||
variant: "error",
|
||||
duration: 4000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
throw new Error(result.reason ?? "Hook blocked the operation")
|
||||
}
|
||||
|
||||
if (result.modifiedInput) {
|
||||
Object.assign(output.args as Record<string, unknown>, result.modifiedInput)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
): Promise<void> => {
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
|
||||
|
||||
recordToolResult(input.sessionID, input.tool, cachedInput, (output.metadata as Record<string, unknown>) || {})
|
||||
|
||||
if (!isHookDisabled(config, "PostToolUse")) {
|
||||
const postClient: PostToolUseClient = {
|
||||
session: {
|
||||
messages: (opts) => ctx.client.session.messages(opts),
|
||||
},
|
||||
}
|
||||
|
||||
const postCtx: PostToolUseContext = {
|
||||
sessionId: input.sessionID,
|
||||
toolName: input.tool,
|
||||
toolInput: cachedInput,
|
||||
toolOutput: {
|
||||
title: input.tool,
|
||||
output: output.output,
|
||||
metadata: output.metadata as Record<string, unknown>,
|
||||
},
|
||||
cwd: ctx.directory,
|
||||
transcriptPath: getTranscriptPath(input.sessionID),
|
||||
toolUseId: input.callID,
|
||||
client: postClient,
|
||||
permissionMode: "bypassPermissions",
|
||||
}
|
||||
|
||||
const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig)
|
||||
|
||||
if (result.block) {
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "PostToolUse Hook Warning",
|
||||
message: result.reason ?? "Hook returned warning",
|
||||
variant: "warning",
|
||||
duration: 4000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
output.output = `${output.output}\n\n${result.warnings.join("\n")}`
|
||||
}
|
||||
|
||||
if (result.message) {
|
||||
output.output = `${output.output}\n\n${result.message}`
|
||||
}
|
||||
|
||||
if (result.hookName) {
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "PostToolUse Hook Executed",
|
||||
message: `▶ ${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`,
|
||||
variant: "success",
|
||||
duration: 2000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
event: async (input: { event: { type: string; properties?: unknown } }) => {
|
||||
const { event } = input
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
sessionErrorState.set(sessionID, {
|
||||
hasError: true,
|
||||
errorMessage: String(props?.error ?? "Unknown error"),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessionErrorState.delete(sessionInfo.id)
|
||||
sessionInterruptState.delete(sessionInfo.id)
|
||||
sessionFirstMessageProcessed.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
|
||||
if (!sessionID) return
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const errorStateBefore = sessionErrorState.get(sessionID)
|
||||
const endedWithErrorBefore = errorStateBefore?.hasError === true
|
||||
const interruptStateBefore = sessionInterruptState.get(sessionID)
|
||||
const interruptedBefore = interruptStateBefore?.interrupted === true
|
||||
|
||||
let parentSessionId: string | undefined
|
||||
try {
|
||||
const sessionInfo = await ctx.client.session.get({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
parentSessionId = sessionInfo.data?.parentID
|
||||
} catch {}
|
||||
|
||||
if (!isHookDisabled(config, "Stop")) {
|
||||
const stopCtx: StopContext = {
|
||||
sessionId: sessionID,
|
||||
parentSessionId,
|
||||
cwd: ctx.directory,
|
||||
}
|
||||
|
||||
const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig)
|
||||
|
||||
const errorStateAfter = sessionErrorState.get(sessionID)
|
||||
const endedWithErrorAfter = errorStateAfter?.hasError === true
|
||||
const interruptStateAfter = sessionInterruptState.get(sessionID)
|
||||
const interruptedAfter = interruptStateAfter?.interrupted === true
|
||||
|
||||
const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter
|
||||
|
||||
if (shouldBypass && stopResult.block) {
|
||||
const interrupted = interruptedBefore || interruptedAfter
|
||||
const endedWithError = endedWithErrorBefore || endedWithErrorAfter
|
||||
log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError })
|
||||
} else if (stopResult.block && stopResult.injectPrompt) {
|
||||
log("Stop hook returned block with inject_prompt", { sessionID })
|
||||
ctx.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: stopResult.injectPrompt }] },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
.catch((err: unknown) => log("Failed to inject prompt from Stop hook", err))
|
||||
} else if (stopResult.block) {
|
||||
log("Stop hook returned block", { sessionID, reason: stopResult.reason })
|
||||
}
|
||||
}
|
||||
|
||||
sessionErrorState.delete(sessionID)
|
||||
sessionInterruptState.delete(sessionID)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
9
src/hooks/claude-code-hooks/plugin-config.ts
Normal file
9
src/hooks/claude-code-hooks/plugin-config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Plugin configuration for Claude Code hooks execution
|
||||
* Contains settings for hook command execution (zsh, etc.)
|
||||
*/
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
forceZsh: true,
|
||||
zshPath: "/bin/zsh",
|
||||
}
|
||||
199
src/hooks/claude-code-hooks/post-tool-use.ts
Normal file
199
src/hooks/claude-code-hooks/post-tool-use.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type {
|
||||
PostToolUseInput,
|
||||
PostToolUseOutput,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
export interface PostToolUseClient {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostToolUseContext {
|
||||
sessionId: string
|
||||
toolName: string
|
||||
toolInput: Record<string, unknown>
|
||||
toolOutput: Record<string, unknown>
|
||||
cwd: string
|
||||
transcriptPath?: string // Fallback for append-based transcript
|
||||
toolUseId?: string
|
||||
client?: PostToolUseClient
|
||||
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
|
||||
}
|
||||
|
||||
export interface PostToolUseResult {
|
||||
block: boolean
|
||||
reason?: string
|
||||
message?: string
|
||||
warnings?: string[]
|
||||
elapsedMs?: number
|
||||
hookName?: string
|
||||
toolName?: string
|
||||
additionalContext?: string
|
||||
continue?: boolean
|
||||
stopReason?: string
|
||||
suppressOutput?: boolean
|
||||
systemMessage?: string
|
||||
}
|
||||
|
||||
export async function executePostToolUseHooks(
|
||||
ctx: PostToolUseContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<PostToolUseResult> {
|
||||
if (!config) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
const transformedToolName = transformToolName(ctx.toolName)
|
||||
const matchers = findMatchingHooks(config, "PostToolUse", transformedToolName)
|
||||
if (matchers.length === 0) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
// PORT FROM DISABLED: Build Claude Code compatible transcript (temp file)
|
||||
let tempTranscriptPath: string | null = null
|
||||
|
||||
try {
|
||||
// Try to build full transcript from API if client available
|
||||
if (ctx.client) {
|
||||
tempTranscriptPath = await buildTranscriptFromSession(
|
||||
ctx.client,
|
||||
ctx.sessionId,
|
||||
ctx.cwd,
|
||||
ctx.toolName,
|
||||
ctx.toolInput
|
||||
)
|
||||
}
|
||||
|
||||
const stdinData: PostToolUseInput = {
|
||||
session_id: ctx.sessionId,
|
||||
// Use temp transcript if available, otherwise fallback to append-based
|
||||
transcript_path: tempTranscriptPath ?? ctx.transcriptPath,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: transformedToolName,
|
||||
tool_input: objectToSnakeCase(ctx.toolInput),
|
||||
tool_response: objectToSnakeCase(ctx.toolOutput),
|
||||
tool_use_id: ctx.toolUseId,
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
const messages: string[] = []
|
||||
const warnings: string[] = []
|
||||
let firstHookName: string | undefined
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) {
|
||||
log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
|
||||
continue
|
||||
}
|
||||
|
||||
const hookName = hook.command.split("/").pop() || hook.command
|
||||
if (!firstHookName) firstHookName = hookName
|
||||
|
||||
const result = await executeHookCommand(
|
||||
hook.command,
|
||||
JSON.stringify(stdinData),
|
||||
ctx.cwd,
|
||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
||||
)
|
||||
|
||||
if (result.stdout) {
|
||||
messages.push(result.stdout)
|
||||
}
|
||||
|
||||
if (result.exitCode === 2) {
|
||||
if (result.stderr) {
|
||||
warnings.push(`[${hookName}]\n${result.stderr.trim()}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as PostToolUseOutput
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
message: messages.join("\n"),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
additionalContext: output.hookSpecificOutput?.additionalContext,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
}
|
||||
}
|
||||
if (output.hookSpecificOutput?.additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) {
|
||||
return {
|
||||
block: false,
|
||||
message: messages.join("\n"),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
additionalContext: output.hookSpecificOutput?.additionalContext,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} else if (result.exitCode !== 0 && result.exitCode !== 2) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
message: messages.join("\n"),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
additionalContext: output.hookSpecificOutput?.additionalContext,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - startTime
|
||||
|
||||
return {
|
||||
block: false,
|
||||
message: messages.length > 0 ? messages.join("\n") : undefined,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
}
|
||||
} finally {
|
||||
// PORT FROM DISABLED: Cleanup temp file to avoid disk accumulation
|
||||
deleteTempTranscript(tempTranscriptPath)
|
||||
}
|
||||
}
|
||||
172
src/hooks/claude-code-hooks/pre-tool-use.ts
Normal file
172
src/hooks/claude-code-hooks/pre-tool-use.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type {
|
||||
PreToolUseInput,
|
||||
PreToolUseOutput,
|
||||
PermissionDecision,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
export interface PreToolUseContext {
|
||||
sessionId: string
|
||||
toolName: string
|
||||
toolInput: Record<string, unknown>
|
||||
cwd: string
|
||||
transcriptPath?: string
|
||||
toolUseId?: string
|
||||
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
|
||||
}
|
||||
|
||||
export interface PreToolUseResult {
|
||||
decision: PermissionDecision
|
||||
reason?: string
|
||||
modifiedInput?: Record<string, unknown>
|
||||
elapsedMs?: number
|
||||
hookName?: string
|
||||
toolName?: string
|
||||
inputLines?: string
|
||||
// Common output fields (Claude Code spec)
|
||||
continue?: boolean
|
||||
stopReason?: string
|
||||
suppressOutput?: boolean
|
||||
systemMessage?: string
|
||||
}
|
||||
|
||||
function buildInputLines(toolInput: Record<string, unknown>): string {
|
||||
return Object.entries(toolInput)
|
||||
.slice(0, 3)
|
||||
.map(([key, val]) => {
|
||||
const valStr = String(val).slice(0, 40)
|
||||
return ` ${key}: ${valStr}${String(val).length > 40 ? "..." : ""}`
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export async function executePreToolUseHooks(
|
||||
ctx: PreToolUseContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<PreToolUseResult> {
|
||||
if (!config) {
|
||||
return { decision: "allow" }
|
||||
}
|
||||
|
||||
const transformedToolName = transformToolName(ctx.toolName)
|
||||
const matchers = findMatchingHooks(config, "PreToolUse", transformedToolName)
|
||||
if (matchers.length === 0) {
|
||||
return { decision: "allow" }
|
||||
}
|
||||
|
||||
const stdinData: PreToolUseInput = {
|
||||
session_id: ctx.sessionId,
|
||||
transcript_path: ctx.transcriptPath,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: transformedToolName,
|
||||
tool_input: objectToSnakeCase(ctx.toolInput),
|
||||
tool_use_id: ctx.toolUseId,
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
let firstHookName: string | undefined
|
||||
const inputLines = buildInputLines(ctx.toolInput)
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) {
|
||||
log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
|
||||
continue
|
||||
}
|
||||
|
||||
const hookName = hook.command.split("/").pop() || hook.command
|
||||
if (!firstHookName) firstHookName = hookName
|
||||
|
||||
const result = await executeHookCommand(
|
||||
hook.command,
|
||||
JSON.stringify(stdinData),
|
||||
ctx.cwd,
|
||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
||||
)
|
||||
|
||||
if (result.exitCode === 2) {
|
||||
return {
|
||||
decision: "deny",
|
||||
reason: result.stderr || result.stdout || "Hook blocked the operation",
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
inputLines,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.exitCode === 1) {
|
||||
return {
|
||||
decision: "ask",
|
||||
reason: result.stderr || result.stdout,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
inputLines,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as PreToolUseOutput
|
||||
|
||||
// Handle deprecated decision/reason fields (Claude Code backward compat)
|
||||
let decision: PermissionDecision | undefined
|
||||
let reason: string | undefined
|
||||
let modifiedInput: Record<string, unknown> | undefined
|
||||
|
||||
if (output.hookSpecificOutput?.permissionDecision) {
|
||||
decision = output.hookSpecificOutput.permissionDecision
|
||||
reason = output.hookSpecificOutput.permissionDecisionReason
|
||||
modifiedInput = output.hookSpecificOutput.updatedInput
|
||||
} else if (output.decision) {
|
||||
// Map deprecated values: approve->allow, block->deny, ask->ask
|
||||
const legacyDecision = output.decision
|
||||
if (legacyDecision === "approve" || legacyDecision === "allow") {
|
||||
decision = "allow"
|
||||
} else if (legacyDecision === "block" || legacyDecision === "deny") {
|
||||
decision = "deny"
|
||||
} else if (legacyDecision === "ask") {
|
||||
decision = "ask"
|
||||
}
|
||||
reason = output.reason
|
||||
}
|
||||
|
||||
// Return if decision is set OR if any common fields are set (fallback to allow)
|
||||
const hasCommonFields = output.continue !== undefined ||
|
||||
output.stopReason !== undefined ||
|
||||
output.suppressOutput !== undefined ||
|
||||
output.systemMessage !== undefined
|
||||
|
||||
if (decision || hasCommonFields) {
|
||||
return {
|
||||
decision: decision ?? "allow",
|
||||
reason,
|
||||
modifiedInput,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
inputLines,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { decision: "allow" }
|
||||
}
|
||||
118
src/hooks/claude-code-hooks/stop.ts
Normal file
118
src/hooks/claude-code-hooks/stop.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
StopInput,
|
||||
StopOutput,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { getTodoPath } from "./todo"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
// Module-level state to track stop_hook_active per session
|
||||
const stopHookActiveState = new Map<string, boolean>()
|
||||
|
||||
export function setStopHookActive(sessionId: string, active: boolean): void {
|
||||
stopHookActiveState.set(sessionId, active)
|
||||
}
|
||||
|
||||
export function getStopHookActive(sessionId: string): boolean {
|
||||
return stopHookActiveState.get(sessionId) ?? false
|
||||
}
|
||||
|
||||
export interface StopContext {
|
||||
sessionId: string
|
||||
parentSessionId?: string
|
||||
cwd: string
|
||||
transcriptPath?: string
|
||||
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
|
||||
stopHookActive?: boolean
|
||||
}
|
||||
|
||||
export interface StopResult {
|
||||
block: boolean
|
||||
reason?: string
|
||||
stopHookActive?: boolean
|
||||
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
|
||||
injectPrompt?: string
|
||||
}
|
||||
|
||||
export async function executeStopHooks(
|
||||
ctx: StopContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<StopResult> {
|
||||
if (ctx.parentSessionId) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
const matchers = findMatchingHooks(config, "Stop")
|
||||
if (matchers.length === 0) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
const stdinData: StopInput = {
|
||||
session_id: ctx.sessionId,
|
||||
transcript_path: ctx.transcriptPath,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
||||
hook_event_name: "Stop",
|
||||
stop_hook_active: stopHookActiveState.get(ctx.sessionId) ?? false,
|
||||
todo_path: getTodoPath(ctx.sessionId),
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
|
||||
log("Stop hook command skipped (disabled by config)", { command: hook.command })
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await executeHookCommand(
|
||||
hook.command,
|
||||
JSON.stringify(stdinData),
|
||||
ctx.cwd,
|
||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
||||
)
|
||||
|
||||
// Check exit code first - exit code 2 means block
|
||||
if (result.exitCode === 2) {
|
||||
const reason = result.stderr || result.stdout || "Blocked by stop hook"
|
||||
return {
|
||||
block: true,
|
||||
reason,
|
||||
injectPrompt: reason,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as StopOutput
|
||||
if (output.stop_hook_active !== undefined) {
|
||||
stopHookActiveState.set(ctx.sessionId, output.stop_hook_active)
|
||||
}
|
||||
const isBlock = output.decision === "block"
|
||||
// Determine inject_prompt: prefer explicit value, fallback to reason if blocking
|
||||
const injectPrompt = output.inject_prompt ?? (isBlock && output.reason ? output.reason : undefined)
|
||||
return {
|
||||
block: isBlock,
|
||||
reason: output.reason,
|
||||
stopHookActive: output.stop_hook_active,
|
||||
permissionMode: output.permission_mode,
|
||||
injectPrompt,
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors - hook may return non-JSON output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { block: false }
|
||||
}
|
||||
76
src/hooks/claude-code-hooks/todo.ts
Normal file
76
src/hooks/claude-code-hooks/todo.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { join } from "path"
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import type { TodoFile, TodoItem, ClaudeCodeTodoItem } from "./types"
|
||||
|
||||
const TODO_DIR = join(homedir(), ".claude", "todos")
|
||||
|
||||
export function getTodoPath(sessionId: string): string {
|
||||
return join(TODO_DIR, `${sessionId}-agent-${sessionId}.json`)
|
||||
}
|
||||
|
||||
function ensureTodoDir(): void {
|
||||
if (!existsSync(TODO_DIR)) {
|
||||
mkdirSync(TODO_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenCodeTodo {
|
||||
content: string
|
||||
status: string
|
||||
priority: string
|
||||
id: string
|
||||
}
|
||||
|
||||
function toClaudeCodeFormat(item: OpenCodeTodo | TodoItem): ClaudeCodeTodoItem {
|
||||
return {
|
||||
content: item.content,
|
||||
status: item.status === "cancelled" ? "completed" : item.status,
|
||||
activeForm: item.content,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTodoFile(sessionId: string): TodoFile | null {
|
||||
const path = getTodoPath(sessionId)
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(path, "utf-8"))
|
||||
if (Array.isArray(content)) {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
items: content.map((item: ClaudeCodeTodoItem, idx: number) => ({
|
||||
id: String(idx),
|
||||
content: item.content,
|
||||
status: item.status as TodoItem["status"],
|
||||
created_at: new Date().toISOString(),
|
||||
})),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
return content
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTodoFile(sessionId: string, file: TodoFile): void {
|
||||
ensureTodoDir()
|
||||
const path = getTodoPath(sessionId)
|
||||
const claudeCodeFormat: ClaudeCodeTodoItem[] = file.items.map(toClaudeCodeFormat)
|
||||
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
|
||||
}
|
||||
|
||||
export function saveOpenCodeTodos(sessionId: string, todos: OpenCodeTodo[]): void {
|
||||
ensureTodoDir()
|
||||
const path = getTodoPath(sessionId)
|
||||
const claudeCodeFormat: ClaudeCodeTodoItem[] = todos.map(toClaudeCodeFormat)
|
||||
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
|
||||
}
|
||||
|
||||
export function deleteTodoFile(sessionId: string): void {
|
||||
const path = getTodoPath(sessionId)
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path)
|
||||
}
|
||||
}
|
||||
47
src/hooks/claude-code-hooks/tool-input-cache.ts
Normal file
47
src/hooks/claude-code-hooks/tool-input-cache.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Caches tool_input from PreToolUse for PostToolUse
|
||||
*/
|
||||
|
||||
interface CacheEntry {
|
||||
toolInput: Record<string, unknown>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
|
||||
const CACHE_TTL = 60000 // 1 minute
|
||||
|
||||
export function cacheToolInput(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
invocationId: string,
|
||||
toolInput: Record<string, unknown>
|
||||
): void {
|
||||
const key = `${sessionId}:${toolName}:${invocationId}`
|
||||
cache.set(key, { toolInput, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
export function getToolInput(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
invocationId: string
|
||||
): Record<string, unknown> | null {
|
||||
const key = `${sessionId}:${toolName}:${invocationId}`
|
||||
const entry = cache.get(key)
|
||||
if (!entry) return null
|
||||
|
||||
cache.delete(key)
|
||||
if (Date.now() - entry.timestamp > CACHE_TTL) return null
|
||||
|
||||
return entry.toolInput
|
||||
}
|
||||
|
||||
// Periodic cleanup (every minute)
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of cache.entries()) {
|
||||
if (now - entry.timestamp > CACHE_TTL) {
|
||||
cache.delete(key)
|
||||
}
|
||||
}
|
||||
}, CACHE_TTL)
|
||||
255
src/hooks/claude-code-hooks/transcript.ts
Normal file
255
src/hooks/claude-code-hooks/transcript.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Transcript Manager
|
||||
* Creates and manages Claude Code compatible transcript files
|
||||
*/
|
||||
import { join } from "path"
|
||||
import { mkdirSync, appendFileSync, existsSync, writeFileSync, unlinkSync } from "fs"
|
||||
import { homedir, tmpdir } from "os"
|
||||
import { randomUUID } from "crypto"
|
||||
import type { TranscriptEntry } from "./types"
|
||||
import { transformToolName } from "../../shared/tool-name"
|
||||
|
||||
const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts")
|
||||
|
||||
export function getTranscriptPath(sessionId: string): string {
|
||||
return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`)
|
||||
}
|
||||
|
||||
function ensureTranscriptDir(): void {
|
||||
if (!existsSync(TRANSCRIPT_DIR)) {
|
||||
mkdirSync(TRANSCRIPT_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function appendTranscriptEntry(
|
||||
sessionId: string,
|
||||
entry: TranscriptEntry
|
||||
): void {
|
||||
ensureTranscriptDir()
|
||||
const path = getTranscriptPath(sessionId)
|
||||
const line = JSON.stringify(entry) + "\n"
|
||||
appendFileSync(path, line)
|
||||
}
|
||||
|
||||
export function recordToolUse(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>
|
||||
): void {
|
||||
appendTranscriptEntry(sessionId, {
|
||||
type: "tool_use",
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
})
|
||||
}
|
||||
|
||||
export function recordToolResult(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
toolOutput: Record<string, unknown>
|
||||
): void {
|
||||
appendTranscriptEntry(sessionId, {
|
||||
type: "tool_result",
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_output: toolOutput,
|
||||
})
|
||||
}
|
||||
|
||||
export function recordUserMessage(
|
||||
sessionId: string,
|
||||
content: string
|
||||
): void {
|
||||
appendTranscriptEntry(sessionId, {
|
||||
type: "user",
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
export function recordAssistantMessage(
|
||||
sessionId: string,
|
||||
content: string
|
||||
): void {
|
||||
appendTranscriptEntry(sessionId, {
|
||||
type: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Claude Code Compatible Transcript Builder (PORT FROM DISABLED)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* OpenCode API response type (loosely typed)
|
||||
*/
|
||||
interface OpenCodeMessagePart {
|
||||
type: string
|
||||
tool?: string
|
||||
state?: {
|
||||
status?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenCodeMessage {
|
||||
info?: {
|
||||
role?: string
|
||||
}
|
||||
parts?: OpenCodeMessagePart[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code compatible transcript entry (from disabled file)
|
||||
*/
|
||||
interface DisabledTranscriptEntry {
|
||||
type: "assistant"
|
||||
message: {
|
||||
role: "assistant"
|
||||
content: Array<{
|
||||
type: "tool_use"
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Claude Code compatible transcript from session messages
|
||||
*
|
||||
* PORT FROM DISABLED: This calls client.session.messages() API to fetch
|
||||
* the full session history and builds a JSONL file in Claude Code format.
|
||||
*
|
||||
* @param client OpenCode client instance
|
||||
* @param sessionId Session ID
|
||||
* @param directory Working directory
|
||||
* @param currentToolName Current tool being executed (added as last entry)
|
||||
* @param currentToolInput Current tool input
|
||||
* @returns Temp file path (caller must call deleteTempTranscript!)
|
||||
*/
|
||||
export async function buildTranscriptFromSession(
|
||||
client: {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
|
||||
}
|
||||
},
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
currentToolName: string,
|
||||
currentToolInput: Record<string, unknown>
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
// Handle various response formats
|
||||
const messages = (response as { "200"?: unknown[]; data?: unknown[] })["200"]
|
||||
?? (response as { data?: unknown[] }).data
|
||||
?? (Array.isArray(response) ? response : [])
|
||||
|
||||
const entries: string[] = []
|
||||
|
||||
if (Array.isArray(messages)) {
|
||||
for (const msg of messages as OpenCodeMessage[]) {
|
||||
if (msg.info?.role !== "assistant") continue
|
||||
|
||||
for (const part of msg.parts || []) {
|
||||
if (part.type !== "tool") continue
|
||||
if (part.state?.status !== "completed") continue
|
||||
if (!part.state?.input) continue
|
||||
|
||||
const rawToolName = part.tool as string
|
||||
const toolName = transformToolName(rawToolName)
|
||||
|
||||
const entry: DisabledTranscriptEntry = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: toolName,
|
||||
input: part.state.input,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
entries.push(JSON.stringify(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always add current tool call as the last entry
|
||||
const currentEntry: DisabledTranscriptEntry = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: transformToolName(currentToolName),
|
||||
input: currentToolInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
entries.push(JSON.stringify(currentEntry))
|
||||
|
||||
// Write to temp file
|
||||
const tempPath = join(
|
||||
tmpdir(),
|
||||
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
|
||||
)
|
||||
writeFileSync(tempPath, entries.join("\n") + "\n")
|
||||
|
||||
return tempPath
|
||||
} catch {
|
||||
// CRITICAL FIX: Even on API failure, create file with current tool entry only
|
||||
// (matching original disabled behavior - never return null with incompatible format)
|
||||
try {
|
||||
const currentEntry: DisabledTranscriptEntry = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: transformToolName(currentToolName),
|
||||
input: currentToolInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const tempPath = join(
|
||||
tmpdir(),
|
||||
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
|
||||
)
|
||||
writeFileSync(tempPath, JSON.stringify(currentEntry) + "\n")
|
||||
return tempPath
|
||||
} catch {
|
||||
// If even this fails, return null (truly catastrophic failure)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete temp transcript file (call in finally block)
|
||||
*
|
||||
* PORT FROM DISABLED: Cleanup mechanism to avoid disk accumulation
|
||||
*/
|
||||
export function deleteTempTranscript(path: string | null): void {
|
||||
if (!path) return
|
||||
try {
|
||||
unlinkSync(path)
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
184
src/hooks/claude-code-hooks/types.ts
Normal file
184
src/hooks/claude-code-hooks/types.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Claude Code Hooks Type Definitions
|
||||
* Maps Claude Code hook concepts to OpenCode plugin events
|
||||
*/
|
||||
|
||||
export type ClaudeHookEvent =
|
||||
| "PreToolUse"
|
||||
| "PostToolUse"
|
||||
| "UserPromptSubmit"
|
||||
| "Stop"
|
||||
|
||||
export interface HookMatcher {
|
||||
matcher: string
|
||||
hooks: HookCommand[]
|
||||
}
|
||||
|
||||
export interface HookCommand {
|
||||
type: "command"
|
||||
command: string
|
||||
}
|
||||
|
||||
export interface ClaudeHooksConfig {
|
||||
PreToolUse?: HookMatcher[]
|
||||
PostToolUse?: HookMatcher[]
|
||||
UserPromptSubmit?: HookMatcher[]
|
||||
Stop?: HookMatcher[]
|
||||
}
|
||||
|
||||
export interface PreToolUseInput {
|
||||
session_id: string
|
||||
transcript_path?: string
|
||||
cwd: string
|
||||
permission_mode?: PermissionMode
|
||||
hook_event_name: "PreToolUse"
|
||||
tool_name: string
|
||||
tool_input: Record<string, unknown>
|
||||
tool_use_id?: string
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export interface PostToolUseInput {
|
||||
session_id: string
|
||||
transcript_path?: string
|
||||
cwd: string
|
||||
permission_mode?: PermissionMode
|
||||
hook_event_name: "PostToolUse"
|
||||
tool_name: string
|
||||
tool_input: Record<string, unknown>
|
||||
tool_response: {
|
||||
title?: string
|
||||
output?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
tool_use_id?: string
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitInput {
|
||||
session_id: string
|
||||
cwd: string
|
||||
permission_mode?: PermissionMode
|
||||
hook_event_name: "UserPromptSubmit"
|
||||
prompt: string
|
||||
session?: {
|
||||
id: string
|
||||
}
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export type PermissionMode = "default" | "plan" | "acceptEdits" | "bypassPermissions"
|
||||
|
||||
export type HookSource = "opencode-plugin"
|
||||
|
||||
export interface StopInput {
|
||||
session_id: string
|
||||
transcript_path?: string
|
||||
cwd: string
|
||||
permission_mode?: PermissionMode
|
||||
hook_event_name: "Stop"
|
||||
stop_hook_active: boolean
|
||||
todo_path?: string
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export type PermissionDecision = "allow" | "deny" | "ask"
|
||||
|
||||
/**
|
||||
* Common JSON fields for all hook outputs (Claude Code spec)
|
||||
*/
|
||||
export interface HookCommonOutput {
|
||||
/** If false, Claude stops entirely */
|
||||
continue?: boolean
|
||||
/** Message shown to user when continue=false */
|
||||
stopReason?: string
|
||||
/** Suppress output from transcript */
|
||||
suppressOutput?: boolean
|
||||
/** Warning/message displayed to user */
|
||||
systemMessage?: string
|
||||
}
|
||||
|
||||
export interface PreToolUseOutput extends HookCommonOutput {
|
||||
/** Deprecated: use hookSpecificOutput.permissionDecision instead */
|
||||
decision?: "allow" | "deny" | "approve" | "block" | "ask"
|
||||
/** Deprecated: use hookSpecificOutput.permissionDecisionReason instead */
|
||||
reason?: string
|
||||
hookSpecificOutput?: {
|
||||
hookEventName: "PreToolUse"
|
||||
permissionDecision: PermissionDecision
|
||||
permissionDecisionReason?: string
|
||||
updatedInput?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostToolUseOutput extends HookCommonOutput {
|
||||
decision?: "block"
|
||||
reason?: string
|
||||
hookSpecificOutput?: {
|
||||
hookEventName: "PostToolUse"
|
||||
/** Additional context to provide to Claude */
|
||||
additionalContext?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HookResult {
|
||||
exitCode: number
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
|
||||
export interface TranscriptEntry {
|
||||
type: "tool_use" | "tool_result" | "user" | "assistant"
|
||||
timestamp: string
|
||||
tool_name?: string
|
||||
tool_input?: Record<string, unknown>
|
||||
tool_output?: Record<string, unknown>
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
id: string
|
||||
content: string
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled"
|
||||
priority?: "low" | "medium" | "high"
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface ClaudeCodeTodoItem {
|
||||
content: string
|
||||
status: string // "pending" | "in_progress" | "completed"
|
||||
activeForm: string
|
||||
}
|
||||
|
||||
export interface TodoFile {
|
||||
session_id: string
|
||||
items: TodoItem[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface StopOutput {
|
||||
decision?: "block" | "continue"
|
||||
reason?: string
|
||||
stop_hook_active?: boolean
|
||||
permission_mode?: PermissionMode
|
||||
inject_prompt?: string
|
||||
}
|
||||
|
||||
export type ClaudeCodeContent =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
|
||||
| { type: "tool_result"; tool_use_id: string; content: string }
|
||||
|
||||
export interface ClaudeCodeMessage {
|
||||
type: "user" | "assistant"
|
||||
message: {
|
||||
role: "user" | "assistant"
|
||||
content: ClaudeCodeContent[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginConfig {
|
||||
disabledHooks?: boolean | ClaudeHookEvent[]
|
||||
}
|
||||
117
src/hooks/claude-code-hooks/user-prompt-submit.ts
Normal file
117
src/hooks/claude-code-hooks/user-prompt-submit.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
UserPromptSubmitInput,
|
||||
PostToolUseOutput,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
|
||||
const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>"
|
||||
|
||||
export interface MessagePart {
|
||||
type: "text" | "tool_use" | "tool_result"
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitContext {
|
||||
sessionId: string
|
||||
parentSessionId?: string
|
||||
prompt: string
|
||||
parts: MessagePart[]
|
||||
cwd: string
|
||||
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitResult {
|
||||
block: boolean
|
||||
reason?: string
|
||||
modifiedParts: MessagePart[]
|
||||
messages: string[]
|
||||
}
|
||||
|
||||
export async function executeUserPromptSubmitHooks(
|
||||
ctx: UserPromptSubmitContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<UserPromptSubmitResult> {
|
||||
const modifiedParts = ctx.parts
|
||||
const messages: string[] = []
|
||||
|
||||
if (ctx.parentSessionId) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_OPEN) &&
|
||||
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_CLOSE)
|
||||
) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
const matchers = findMatchingHooks(config, "UserPromptSubmit")
|
||||
if (matchers.length === 0) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
const stdinData: UserPromptSubmitInput = {
|
||||
session_id: ctx.sessionId,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
prompt: ctx.prompt,
|
||||
session: { id: ctx.sessionId },
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) {
|
||||
log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command })
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await executeHookCommand(
|
||||
hook.command,
|
||||
JSON.stringify(stdinData),
|
||||
ctx.cwd,
|
||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
||||
)
|
||||
|
||||
if (result.stdout) {
|
||||
const output = result.stdout.trim()
|
||||
if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) {
|
||||
messages.push(output)
|
||||
} else {
|
||||
messages.push(`${USER_PROMPT_SUBMIT_TAG_OPEN}\n${output}\n${USER_PROMPT_SUBMIT_TAG_CLOSE}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
modifiedParts,
|
||||
messages,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
@@ -15,36 +15,13 @@ function debugLog(...args: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function getPlatformPackageName(): string | null {
|
||||
const platform = process.platform as Platform
|
||||
const arch = process.arch
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
"darwin-arm64": "@code-yeongyu/comment-checker-darwin-arm64",
|
||||
"darwin-x64": "@code-yeongyu/comment-checker-darwin-x64",
|
||||
"linux-arm64": "@code-yeongyu/comment-checker-linux-arm64",
|
||||
"linux-x64": "@code-yeongyu/comment-checker-linux-x64",
|
||||
"win32-x64": "@code-yeongyu/comment-checker-windows-x64",
|
||||
}
|
||||
|
||||
return platformMap[`${platform}-${arch}`] ?? null
|
||||
}
|
||||
|
||||
function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously find comment-checker binary path.
|
||||
* Checks installed packages, homebrew, cache, and system PATH.
|
||||
* Does NOT trigger download.
|
||||
*/
|
||||
function findCommentCheckerPathSync(): string | null {
|
||||
const binaryName = getBinaryName()
|
||||
|
||||
// 1. Try to find from @code-yeongyu/comment-checker package
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
|
||||
@@ -59,46 +36,12 @@ function findCommentCheckerPathSync(): string | null {
|
||||
debugLog("main package not installed")
|
||||
}
|
||||
|
||||
// 2. Try platform-specific package directly (legacy, for backwards compatibility)
|
||||
const platformPkg = getPlatformPackageName()
|
||||
if (platformPkg) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkgPath = require.resolve(`${platformPkg}/package.json`)
|
||||
const pkgDir = dirname(pkgPath)
|
||||
const binaryPath = join(pkgDir, "bin", binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
debugLog("found binary in platform package:", binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
debugLog("platform package not installed:", platformPkg)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try homebrew installation (macOS)
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = [
|
||||
"/opt/homebrew/bin/comment-checker",
|
||||
"/usr/local/bin/comment-checker",
|
||||
]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path)) {
|
||||
debugLog("found binary via homebrew:", path)
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try cached binary (lazy download location)
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("found binary in cache:", cachedPath)
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
// 5. Try system PATH (as fallback)
|
||||
debugLog("no binary found in known locations")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ PRIORITY-BASED ACTION GUIDELINES:
|
||||
\t-> Make the code itself clearer so it can be understood without comments/docstrings.
|
||||
\t-> For verbose docstrings: refactor code to be self-documenting instead of adding lengthy explanations.
|
||||
|
||||
CODE SMELL WARNING: Using comments as visual separators (e.g., "// =========", "# ---", "// *** Section ***")
|
||||
is a code smell. If you need separators, your file is too long or poorly organized.
|
||||
Refactor into smaller modules or use proper code organization instead of comment-based section dividers.
|
||||
|
||||
MANDATORY REQUIREMENT: You must acknowledge this hook message and take one of the above actions.
|
||||
Review in the above priority order and take the corresponding action EVERY TIME this appears.
|
||||
|
||||
|
||||
@@ -7,16 +7,8 @@ const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
const CONTEXT_REMINDER = `[SYSTEM REMINDER - 1M Context Window]
|
||||
|
||||
You are using Anthropic Claude with 1M context window.
|
||||
Current usage has exceeded 75%.
|
||||
|
||||
RECOMMENDATIONS:
|
||||
- Consider compacting the session if available
|
||||
- Break complex tasks into smaller, focused sessions
|
||||
- Be concise in your responses
|
||||
- Avoid redundant file reads
|
||||
|
||||
You have access to 1M tokens - use them wisely. Do NOT rush or skip tasks.
|
||||
Complete your work thoroughly despite the context usage warning.`
|
||||
You have plenty of context remaining - do NOT rush or skip tasks.
|
||||
Complete your work thoroughly and methodically.`
|
||||
|
||||
interface AssistantMessageInfo {
|
||||
role: "assistant"
|
||||
@@ -60,11 +52,10 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
if (lastAssistant.providerID !== "anthropic") return
|
||||
|
||||
const totalInputTokens = assistantMessages.reduce((sum, m) => {
|
||||
const inputTokens = m.tokens?.input ?? 0
|
||||
const cacheReadTokens = m.tokens?.cache?.read ?? 0
|
||||
return sum + inputTokens + cacheReadTokens
|
||||
}, 0)
|
||||
// Use only the last assistant message's input tokens
|
||||
// This reflects the ACTUAL current context window usage (post-compaction)
|
||||
const lastTokens = lastAssistant.tokens
|
||||
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||
|
||||
const actualUsagePercentage = totalInputTokens / ANTHROPIC_ACTUAL_LIMIT
|
||||
|
||||
|
||||
9
src/hooks/directory-readme-injector/constants.ts
Normal file
9
src/hooks/directory-readme-injector/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const README_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-readme",
|
||||
);
|
||||
export const README_FILENAME = "README.md";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user