Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
765507648c | ||
|
|
c10bc5fcdf | ||
|
|
c0b28b0715 | ||
|
|
dd60002a0d | ||
|
|
25d2946b76 | ||
|
|
122e918503 | ||
|
|
aeff184e0c | ||
|
|
b995ea8595 | ||
|
|
6e5edafeee | ||
|
|
bfb5d43bc2 | ||
|
|
385e8a97b0 | ||
|
|
7daabf9617 | ||
|
|
5fbcb88a3f | ||
|
|
daa5f6ee5b | ||
|
|
4d66ea9730 | ||
|
|
4d4273603a | ||
|
|
7b7c14301e | ||
|
|
e3be656f86 | ||
|
|
c11cb2e3f1 | ||
|
|
195e8dcb17 | ||
|
|
284e7f5bc3 | ||
|
|
465c9e511f | ||
|
|
18d134fa57 | ||
|
|
092718f82d | ||
|
|
19f504fcfa | ||
|
|
49f3be5a1f | ||
|
|
6d6102f1ff | ||
|
|
1d7e534b92 | ||
|
|
17b7dd396e | ||
|
|
889d80d0ca | ||
|
|
87e229fb62 | ||
|
|
78514ec6d4 | ||
|
|
1c12925c9e | ||
|
|
262f0c3f1f | ||
|
|
aace1982ec | ||
|
|
8d8ea4079d | ||
|
|
c5f51030f0 | ||
|
|
b2c2c6eab7 | ||
|
|
c4c0d82f97 | ||
|
|
3e180cd9f1 | ||
|
|
776d857fd2 | ||
|
|
90d43dc292 | ||
|
|
6bc9a31ee4 | ||
|
|
5c8cfbfad8 | ||
|
|
1d2dc69ae5 | ||
|
|
0cee39dafb | ||
|
|
dd12928390 | ||
|
|
2246d1c5ef | ||
|
|
1fc7fe7122 | ||
|
|
3ba7e6d46b | ||
|
|
dec4994fd6 | ||
|
|
c5205e7e2f | ||
|
|
8e2fda870a | ||
|
|
cad6425a4a | ||
|
|
15de6f637e | ||
|
|
e05d9dfc35 | ||
|
|
77bdefbf9d | ||
|
|
6db44cdbf4 | ||
|
|
7c24f657e7 | ||
|
|
1b427570c8 | ||
|
|
109fb50028 | ||
|
|
e1a9e7e76a | ||
|
|
6160730f24 | ||
|
|
f9234a6a5e | ||
|
|
27b5c1fda3 | ||
|
|
9bc2360d31 | ||
|
|
ad2bd673c4 | ||
|
|
57ef5df932 | ||
|
|
101299ebec | ||
|
|
0b4821cfdf | ||
|
|
9bfe7d8a1d | ||
|
|
d9cfc1ec97 | ||
|
|
accedb59b7 | ||
|
|
1bff5f7966 | ||
|
|
dacecfd3b2 | ||
|
|
0399c1f4ed | ||
|
|
ebdce7972e | ||
|
|
3de2a9f113 | ||
|
|
8897697887 | ||
|
|
06b77643ba | ||
|
|
3b17ee9bd0 | ||
|
|
0734167516 | ||
|
|
419416deb8 | ||
|
|
695f9e03fc | ||
|
|
c804da43cf |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -120,15 +120,19 @@ jobs:
|
||||
echo "Updating existing draft release..."
|
||||
gh release edit next \
|
||||
--title "Upcoming Changes 🍿" \
|
||||
--notes "${{ steps.notes.outputs.notes }}" \
|
||||
--draft
|
||||
--notes-file - \
|
||||
--draft <<'EOF'
|
||||
${{ steps.notes.outputs.notes }}
|
||||
EOF
|
||||
else
|
||||
echo "Creating new draft release..."
|
||||
gh release create next \
|
||||
--title "Upcoming Changes 🍿" \
|
||||
--notes "${{ steps.notes.outputs.notes }}" \
|
||||
--notes-file - \
|
||||
--draft \
|
||||
--target ${{ github.sha }}
|
||||
--target ${{ github.sha }} <<'EOF'
|
||||
${{ steps.notes.outputs.notes }}
|
||||
EOF
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
118
.github/workflows/sisyphus-agent.yml
vendored
118
.github/workflows/sisyphus-agent.yml
vendored
@@ -6,12 +6,10 @@ on:
|
||||
prompt:
|
||||
description: "Custom prompt"
|
||||
required: false
|
||||
# Only issue_comment works for fork PRs (secrets available)
|
||||
# pull_request_review/pull_request_review_comment do NOT get secrets for fork PRs
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
agent:
|
||||
@@ -19,9 +17,9 @@ jobs:
|
||||
# @sisyphus-dev-ai mention only (maintainers, exclude self)
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(contains(github.event.comment.body || github.event.review.body, '@sisyphus-dev-ai') &&
|
||||
(github.event.comment.user.login || github.event.review.user.login) != 'sisyphus-dev-ai' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association))
|
||||
(contains(github.event.comment.body, '@sisyphus-dev-ai') &&
|
||||
github.event.comment.user.login != 'sisyphus-dev-ai' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
|
||||
|
||||
# Minimal default GITHUB_TOKEN permissions
|
||||
permissions:
|
||||
@@ -88,14 +86,19 @@ jobs:
|
||||
|
||||
# Install OpenCode (skip if cached)
|
||||
if ! command -v opencode &>/dev/null; then
|
||||
for i in 1 2 3; do
|
||||
echo "Attempt $i: Installing OpenCode..."
|
||||
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
|
||||
if file /tmp/opencode-install.sh | grep -q "shell script\|text"; then
|
||||
bash /tmp/opencode-install.sh && break
|
||||
echo "Installing OpenCode..."
|
||||
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
|
||||
|
||||
# Try default installer first, fallback to pinned version if it fails
|
||||
if file /tmp/opencode-install.sh | grep -q "shell script\|text"; then
|
||||
if ! bash /tmp/opencode-install.sh 2>&1; then
|
||||
echo "Default installer failed, trying with pinned version..."
|
||||
bash /tmp/opencode-install.sh --version 1.0.204
|
||||
fi
|
||||
echo "Download corrupted, retrying in 5s..."
|
||||
done
|
||||
else
|
||||
echo "Download corrupted, trying direct install with pinned version..."
|
||||
bash <(curl -fsSL https://opencode.ai/install) --version 1.0.204
|
||||
fi
|
||||
fi
|
||||
opencode --version
|
||||
|
||||
@@ -186,6 +189,25 @@ jobs:
|
||||
)"
|
||||
```
|
||||
|
||||
### GitHub Markdown Rules (MUST FOLLOW)
|
||||
|
||||
**Code blocks MUST have EXACTLY 3 backticks and language identifier:**
|
||||
- CORRECT: ` ```bash ` ... ` ``` `
|
||||
- WRONG: ` ``` ` (no language), ` ```` ` (4 backticks), ` `` ` (2 backticks)
|
||||
|
||||
**Every opening ` ``` ` MUST have a closing ` ``` ` on its own line:**
|
||||
```
|
||||
```bash
|
||||
code here
|
||||
```
|
||||
```
|
||||
|
||||
**NO trailing backticks or spaces after closing ` ``` `**
|
||||
|
||||
**For inline code, use SINGLE backticks:** `code` not ```code```
|
||||
|
||||
**Lists inside code blocks break rendering - avoid them or use plain text**
|
||||
|
||||
### Rules
|
||||
- EVERY response = GitHub comment (use heredoc for proper escaping)
|
||||
- Code changes = PR (never push main/master)
|
||||
@@ -210,39 +232,30 @@ jobs:
|
||||
id: context
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
COMMENT_ID_VAL: ${{ github.event.comment.id }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
EVENT="${{ github.event_name }}"
|
||||
|
||||
if [[ "$EVENT" == "issue_comment" ]]; then
|
||||
ISSUE_NUM="${{ github.event.issue.number }}"
|
||||
COMMENT="${{ github.event.comment.body }}"
|
||||
AUTHOR="${{ github.event.comment.user.login }}"
|
||||
COMMENT_ID="${{ github.event.comment.id }}"
|
||||
if [[ "$EVENT_NAME" == "issue_comment" ]]; then
|
||||
ISSUE_NUM="$ISSUE_NUMBER"
|
||||
AUTHOR="$COMMENT_AUTHOR"
|
||||
COMMENT_ID="$COMMENT_ID_VAL"
|
||||
|
||||
# Check if PR or Issue
|
||||
if gh api "repos/${{ github.repository }}/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
|
||||
if gh api "repos/$REPO/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
|
||||
echo "type=pr" >> $GITHUB_OUTPUT
|
||||
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=issue" >> $GITHUB_OUTPUT
|
||||
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
elif [[ "$EVENT" == "pull_request_review_comment" ]]; then
|
||||
echo "type=pr" >> $GITHUB_OUTPUT
|
||||
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
COMMENT="${{ github.event.comment.body }}"
|
||||
AUTHOR="${{ github.event.comment.user.login }}"
|
||||
COMMENT_ID="${{ github.event.comment.id }}"
|
||||
elif [[ "$EVENT" == "pull_request_review" ]]; then
|
||||
echo "type=pr" >> $GITHUB_OUTPUT
|
||||
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
|
||||
COMMENT="${{ github.event.review.body }}"
|
||||
AUTHOR="${{ github.event.review.user.login }}"
|
||||
COMMENT_ID=""
|
||||
fi
|
||||
|
||||
echo "comment<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$COMMENT" >> $GITHUB_OUTPUT
|
||||
echo "$COMMENT_BODY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
echo "author=$AUTHOR" >> $GITHUB_OUTPUT
|
||||
echo "comment_id=$COMMENT_ID" >> $GITHUB_OUTPUT
|
||||
@@ -280,29 +293,44 @@ jobs:
|
||||
- name: Run oh-my-opencode
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_PAT }}
|
||||
USER_COMMENT: ${{ steps.context.outputs.comment }}
|
||||
COMMENT_AUTHOR: ${{ steps.context.outputs.author }}
|
||||
CONTEXT_TYPE: ${{ steps.context.outputs.type }}
|
||||
CONTEXT_NUMBER: ${{ steps.context.outputs.number }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
export PATH="$HOME/.opencode/bin:$PATH"
|
||||
|
||||
PROMPT="
|
||||
Your username is @sisyphus-dev-ai, mentioned by @${{ steps.context.outputs.author }} in ${{ github.repository }}.
|
||||
PROMPT=$(cat <<'PROMPT_EOF'
|
||||
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
|
||||
|
||||
## Context
|
||||
- Type: ${{ steps.context.outputs.type }}
|
||||
- Number: #${{ steps.context.outputs.number }}
|
||||
- Repository: ${{ github.repository }}
|
||||
- Default Branch: ${{ github.event.repository.default_branch }}
|
||||
- Type: TYPE_PLACEHOLDER
|
||||
- Number: #NUMBER_PLACEHOLDER
|
||||
- Repository: REPO_PLACEHOLDER
|
||||
- Default Branch: BRANCH_PLACEHOLDER
|
||||
|
||||
## User's Request
|
||||
${{ steps.context.outputs.comment }}
|
||||
COMMENT_PLACEHOLDER
|
||||
|
||||
---
|
||||
|
||||
First, acknowledge with \`gh issue comment ${{ steps.context.outputs.number }} --body \"👋 Hey @${{ steps.context.outputs.author }}! I'm on it...\"\`
|
||||
First, acknowledge with `gh issue comment NUMBER_PLACEHOLDER --body "👋 Hey @AUTHOR_PLACEHOLDER! I'm on it..."`
|
||||
|
||||
Then write everything using the todo tools.
|
||||
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to \`${{ github.event.repository.default_branch }}\` branch."
|
||||
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
|
||||
PROMPT_EOF
|
||||
)
|
||||
|
||||
bun run dist/cli/index.js run "$PROMPT"
|
||||
PROMPT="${PROMPT//AUTHOR_PLACEHOLDER/$COMMENT_AUTHOR}"
|
||||
PROMPT="${PROMPT//REPO_PLACEHOLDER/$REPO_NAME}"
|
||||
PROMPT="${PROMPT//TYPE_PLACEHOLDER/$CONTEXT_TYPE}"
|
||||
PROMPT="${PROMPT//NUMBER_PLACEHOLDER/$CONTEXT_NUMBER}"
|
||||
PROMPT="${PROMPT//BRANCH_PLACEHOLDER/$DEFAULT_BRANCH}"
|
||||
PROMPT="${PROMPT//COMMENT_PLACEHOLDER/$USER_COMMENT}"
|
||||
|
||||
stdbuf -oL -eL bun run dist/cli/index.js run "$PROMPT"
|
||||
|
||||
# Push changes (as sisyphus-dev-ai)
|
||||
- name: Push changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2025-12-24T17:07:00+09:00
|
||||
**Commit:** 0172241
|
||||
**Generated:** 2025-12-28T19:26:00+09:00
|
||||
**Commit:** 122e918
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
131
README.ja.md
131
README.ja.md
@@ -56,6 +56,8 @@
|
||||
|
||||
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
## 目次
|
||||
@@ -388,14 +390,47 @@ gh repo star code-yeongyu/oh-my-opencode
|
||||
</details>
|
||||
|
||||
|
||||
## アンインストール
|
||||
|
||||
oh-my-opencode を削除するには:
|
||||
|
||||
1. **OpenCode 設定からプラグインを削除**
|
||||
|
||||
`~/.config/opencode/opencode.json` (または `opencode.jsonc`) を編集し、`plugin` 配列から `"oh-my-opencode"` を削除します:
|
||||
|
||||
```bash
|
||||
# jq を使用する例
|
||||
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
```
|
||||
|
||||
2. **設定ファイルの削除 (オプション)**
|
||||
|
||||
```bash
|
||||
# ユーザー設定を削除
|
||||
rm -f ~/.config/opencode/oh-my-opencode.json
|
||||
|
||||
# プロジェクト設定を削除 (存在する場合)
|
||||
rm -f .opencode/oh-my-opencode.json
|
||||
```
|
||||
|
||||
3. **削除の確認**
|
||||
|
||||
```bash
|
||||
opencode --version
|
||||
# プラグインがロードされなくなっているはずです
|
||||
```
|
||||
|
||||
|
||||
## 機能
|
||||
|
||||
### Agents: あなたの新しいチームメイト
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`): **デフォルトエージェントです。** OpenCode のための強力な AI オーケストレーターです。専門のサブエージェントを活用して、複雑なタスクを計画、委任、実行します。バックグラウンドタスクへの委任と Todo ベースのワークフローを重視します。最大の推論能力を発揮するため、Claude Opus 4.5 と拡張思考 (32k token budget) を使用します。
|
||||
- **oracle** (`openai/gpt-5.2`): アーキテクチャ、コードレビュー、戦略立案のための専門アドバイザー。GPT-5.2 の卓越した論理的推論と深い分析能力を活用します。AmpCode からインスピレーションを得ました。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
|
||||
- **explore** (`opencode/grok-code`): 高速なコードベース探索、ファイルパターンマッチング。Claude Code は Haiku を使用しますが、私たちは Grok を使います。現在無料であり、極めて高速で、ファイル探索タスクには十分な知能を備えているからです。Claude Code からインスピレーションを得ました。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` または `google/gemini-3-flash`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、それ以外は Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
|
||||
- **explore** (`opencode/grok-code`、`google/gemini-3-flash`、または `anthropic/claude-haiku-4-5`): 高速なコードベース探索、ファイルパターンマッチング。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、Claude max20 が利用可能な場合は 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-3-flash`): 視覚コンテンツ解釈のための専門エージェント。PDF、画像、図表を分析して情報を抽出します。
|
||||
@@ -455,6 +490,19 @@ Ask @explore for the policy on this feature
|
||||
- **ast_grep_search**: AST 認識コードパターン検索 (25言語対応)
|
||||
- **ast_grep_replace**: AST 認識コード置換
|
||||
|
||||
#### セッション管理
|
||||
|
||||
OpenCode セッション履歴をナビゲートおよび検索するためのツール:
|
||||
|
||||
- **session_list**: 日付およびリミットでフィルタリングしながらすべての OpenCode セッションを一覧表示
|
||||
- **session_read**: 特定のセッションからメッセージと履歴を読み取る
|
||||
- **session_search**: セッションメッセージ全体を全文検索
|
||||
- **session_info**: セッションに関するメタデータと統計情報を取得
|
||||
|
||||
これらのツールにより、エージェントは以前の会話を参照し、セッション間の継続性を維持できます。
|
||||
|
||||
- **call_omo_agent**: 専門的な explore/librarian エージェントを起動。非同期実行のための `run_in_background` パラメータをサポート。
|
||||
|
||||
#### Context Is All You Need
|
||||
- **Directory AGENTS.md / README.md Injector**: ファイルを読み込む際、`AGENTS.md` と `README.md` の内容を自動的に注入します。ファイルディレクトリからプロジェクトルートまで遡り、パス上の **すべて** の `AGENTS.md` ファイルを収集します。ネストされたディレクトリごとの指示をサポートします:
|
||||
```
|
||||
@@ -599,8 +647,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
- **Agent Usage Reminder**: 検索ツールを直接呼び出す際、バックグラウンドタスクを通じた専門エージェントの活用を推奨するリマインダーを表示します。
|
||||
- **Anthropic Auto Compact**: Claude モデルがトークン制限に達すると、自動的にセッションを要約・圧縮します。手動での介入は不要です。
|
||||
- **Session Recovery**: セッションエラー(ツールの結果欠落、thinking ブロックの問題、空のメッセージなど)から自動復旧します。セッションが途中でクラッシュすることはありません。もしクラッシュしても復旧します。
|
||||
- **Auto Update Checker**: oh-my-opencode の新バージョンがリリースされると通知します。
|
||||
- **Startup Toast**: OhMyOpenCode ロード時にウェルカムメッセージを表示します。セッションを正しく始めるための、ささやかな "oMoMoMo" です。
|
||||
- **Auto Update Checker**: oh-my-opencode の新バージョンを自動でチェックし、設定を自動更新できます。現在のバージョンと Sisyphus ステータスを表示する起動トースト通知を表示します(Sisyphus 有効時は「Sisyphus on steroids is steering OpenCode」、無効時は「OpenCode is now on Steroids. oMoMoMoMo...」)。全機能を無効化するには `disabled_hooks` に `"auto-update-checker"` を、トースト通知のみ無効化するには `"startup-toast"` を追加してください。[設定 > フック](#フック) 参照。
|
||||
- **Background Notification**: バックグラウンドエージェントのタスクが完了すると通知を受け取ります。
|
||||
- **Session Notification**: エージェントがアイドル状態になると OS 通知を送ります。macOS、Linux、Windows で動作します—エージェントが入力を待っている時を見逃しません。
|
||||
- **Empty Task Response Detector**: Task ツールが空の応答を返すと検知します。既に空の応答が返ってきているのに、いつまでも待ち続ける状況を防ぎます。
|
||||
@@ -618,7 +665,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
|
||||
| プラットフォーム | ユーザー設定パス |
|
||||
|------------------|------------------|
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (優先) または `%APPDATA%\opencode\oh-my-opencode.json` (フォールバック) |
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (推奨) または `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
|
||||
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
|
||||
|
||||
スキーマ自動補完がサポートされています:
|
||||
@@ -629,6 +676,36 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
### JSONC のサポート
|
||||
|
||||
`oh-my-opencode` 設定ファイルは JSONC (コメント付き JSON) をサポートしています:
|
||||
- 行コメント: `// コメント`
|
||||
- ブロックコメント: `/* コメント */`
|
||||
- 末尾のカンマ: `{ "key": "value", }`
|
||||
|
||||
`oh-my-opencode.jsonc` と `oh-my-opencode.json` の両方が存在する場合、`.jsonc` が優先されます。
|
||||
|
||||
**コメント付きの例:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
|
||||
// Antigravity OAuth 経由で Google Gemini を有効にする
|
||||
"google_auth": false,
|
||||
|
||||
/* エージェントのオーバーライド - 特定のタスクに合わせてモデルをカスタマイズ */
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"model": "openai/gpt-5.2" // 戦略的な推論のための GPT
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/grok-code" // 探索のための高速かつ無料のモデル
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
**推奨**: 外部の [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) プラグインを使用してください。マルチアカウントロードバランシング、より多くのモデル(Antigravity 経由の Claude を含む)、活発なメンテナンスを提供します。[インストール > Google Gemini](#42-google-gemini-antigravity-oauth) を参照。
|
||||
@@ -717,8 +794,8 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
有効時(デフォルト)、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
|
||||
|
||||
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OhMyOpenCode 強化版のビルドエージェント(デフォルトで無効)
|
||||
- **Planner-Sisyphus**: OhMyOpenCode 強化版のプランエージェント(デフォルトで有効)
|
||||
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェント(SDK 制限により名前変更、デフォルトで無効)
|
||||
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェント(SDK 制限により名前変更、デフォルトで有効)
|
||||
|
||||
**設定オプション:**
|
||||
|
||||
@@ -726,26 +803,24 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"disabled": false,
|
||||
"builder_enabled": false,
|
||||
"default_builder_enabled": false,
|
||||
"planner_enabled": true,
|
||||
"replace_build": true,
|
||||
"replace_plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**例:Builder-Sisyphus を有効化し、デフォルトのビルドモードも維持する:**
|
||||
**例:Builder-Sisyphus を有効化:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"builder_enabled": true,
|
||||
"replace_build": false
|
||||
"default_builder_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
これにより、Builder-Sisyphus とデフォルトのビルドエージェントの両方を同時に利用できます。
|
||||
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
|
||||
|
||||
**例:すべての Sisyphus オーケストレーションを無効化:**
|
||||
|
||||
@@ -776,13 +851,12 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
|
||||
| `builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェント(OhMyOpenCode 強化版ビルドモード)を有効化します。デフォルトの OpenCode ビルド体験を維持するため、デフォルトでは無効です。 |
|
||||
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェント(OhMyOpenCode 強化版プランモード)を有効化します。デフォルトで有効です。 |
|
||||
| `replace_build` | `true` | `true` の場合、デフォルトのビルドエージェントをサブエージェントモードに降格させます。`false` に設定すると、Builder-Sisyphus とデフォルトのビルドの両方を利用できます。 |
|
||||
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化します(OpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
|
||||
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -794,7 +868,9 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
利用可能なフック:`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`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
|
||||
利用可能なフック:`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`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
|
||||
|
||||
**`auto-update-checker`と`startup-toast`について**: `startup-toast` フックは `auto-update-checker` のサブ機能です。アップデートチェックは有効なまま起動トースト通知のみを無効化するには、`disabled_hooks` に `"startup-toast"` を追加してください。すべてのアップデートチェック機能(トーストを含む)を無効化するには、`"auto-update-checker"` を追加してください。
|
||||
|
||||
### MCPs
|
||||
|
||||
@@ -846,7 +922,8 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
|
||||
"experimental": {
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false
|
||||
"truncate_all_tool_outputs": false,
|
||||
"dcp_on_compaction_failure": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -856,6 +933,7 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
| `truncate_all_tool_outputs` | `true` | プロンプトが長くなりすぎるのを防ぐため、コンテキストウィンドウの使用状況に基づいてすべてのツール出力を動的に切り詰めます。完全なツール出力が必要な場合は`false`に設定して無効化します。 |
|
||||
| `dcp_for_compaction` | `false` | 有効にすると、トークン制限エラー発生時にDCP(Dynamic Context Pruning)が最初に実行され、その後コンパクションが実行されます。DCPが不要なコンテキストを整理した後、すぐにコンパクションが進行します。トークン制限に達した際によりスマートな回復が必要な場合は有効にしてください。 |
|
||||
|
||||
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。
|
||||
|
||||
@@ -903,11 +981,16 @@ OpenCode が Debian / ArchLinux だとしたら、Oh My OpenCode は Ubuntu / [O
|
||||
- [修正 PR](https://github.com/sst/opencode/pull/5040) は 1.0.132 以降にマージされたため、新しいバージョンを使用してください。
|
||||
- 余談:この PR も、OhMyOpenCode の Librarian、Explore、Oracle セットアップを活用して偶然発見され、修正されました。
|
||||
|
||||
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*
|
||||
|
||||
## こちらの企業の専門家にご愛用いただいています
|
||||
|
||||
- [Indent](https://indentcorp.com)
|
||||
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
|
||||
- [Google](https://google.com)
|
||||
- [Microsoft](https://microsoft.com)
|
||||
|
||||
## スポンサー
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- 最初のスポンサー
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
|
||||
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*
|
||||
|
||||
134
README.ko.md
134
README.ko.md
@@ -53,6 +53,8 @@
|
||||
|
||||
> "Oh My Opencode는 독보적입니다, 경쟁자가 없습니다" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "시지푸스 이름 자체가 이쁘잖아요?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
@@ -385,14 +387,47 @@ gh repo star code-yeongyu/oh-my-opencode
|
||||
</details>
|
||||
|
||||
|
||||
## 언인스톨
|
||||
|
||||
oh-my-opencode를 제거하려면:
|
||||
|
||||
1. **OpenCode 설정에서 플러그인 제거**
|
||||
|
||||
`~/.config/opencode/opencode.json` (또는 `opencode.jsonc`)를 편집하여 `plugin` 배열에서 `"oh-my-opencode"`를 제거합니다:
|
||||
|
||||
```bash
|
||||
# jq 사용 예시
|
||||
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
```
|
||||
|
||||
2. **설정 파일 삭제 (선택 사항)**
|
||||
|
||||
```bash
|
||||
# 사용자 설정 삭제
|
||||
rm -f ~/.config/opencode/oh-my-opencode.json
|
||||
|
||||
# 프로젝트 설정 삭제 (존재하는 경우)
|
||||
rm -f .opencode/oh-my-opencode.json
|
||||
```
|
||||
|
||||
3. **제거 확인**
|
||||
|
||||
```bash
|
||||
opencode --version
|
||||
# 플러그인이 더 이상 로드되지 않아야 합니다
|
||||
```
|
||||
|
||||
|
||||
## 기능
|
||||
|
||||
### Agents: 당신의 새로운 팀원들
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`): **기본 에이전트입니다.** OpenCode를 위한 강력한 AI 오케스트레이터입니다. 전문 서브에이전트를 활용하여 복잡한 작업을 계획, 위임, 실행합니다. 백그라운드 태스크 위임과 todo 기반 워크플로우를 강조합니다. 최대 추론 능력을 위해 Claude Opus 4.5와 확장된 사고(32k 버짓)를 사용합니다.
|
||||
- **oracle** (`openai/gpt-5.2`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.2의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Claude Sonnet 4.5를 사용하여 깊은 코드베이스 이해와 GitHub 조사, 근거 기반의 답변을 제공합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **explore** (`opencode/grok-code`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Claude Code는 Haiku를 쓰지만, 우리는 Grok을 씁니다. 현재 무료이고, 극도로 빠르며, 파일 탐색 작업에 충분한 지능을 갖췄기 때문입니다. Claude Code 에서 영감을 받았습니다.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` 또는 `google/gemini-3-flash`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Antigravity 인증이 설정된 경우 Gemini 3 Flash를 사용하고, 그렇지 않으면 Claude Sonnet 4.5를 사용하여 깊은 코드베이스 이해와 GitHub 조사, 근거 기반의 답변을 제공합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, 또는 `anthropic/claude-haiku-4-5`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Antigravity 인증이 설정된 경우 Gemini 3 Flash를 사용하고, Claude max20이 있으면 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-3-flash`): 시각적 콘텐츠 해석을 위한 전문 에이전트. PDF, 이미지, 다이어그램을 분석하여 정보를 추출합니다.
|
||||
@@ -448,6 +483,18 @@ Syntax Highlighting, Autocomplete, Refactoring, Navigation, Analysis, 그리고
|
||||
- **lsp_code_action_resolve**: 코드 액션 적용
|
||||
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
|
||||
- **ast_grep_replace**: AST 인식 코드 교체
|
||||
- **call_omo_agent**: 전문 explore/librarian 에이전트를 생성합니다. 비동기 실행을 위한 `run_in_background` 파라미터를 지원합니다.
|
||||
|
||||
#### 세션 관리 (Session Management)
|
||||
|
||||
OpenCode 세션 히스토리를 탐색하고 검색하기 위한 도구들입니다:
|
||||
|
||||
- **session_list**: 날짜 및 개수 제한 필터링을 포함한 모든 OpenCode 세션 목록 조회
|
||||
- **session_read**: 특정 세션의 메시지 및 히스토리 읽기
|
||||
- **session_search**: 세션 메시지 전체 텍스트 검색
|
||||
- **session_info**: 세션에 대한 메타데이터 및 통계 정보 조회
|
||||
|
||||
이 도구들을 통해 에이전트는 이전 대화를 참조하고 세션 간의 연속성을 유지할 수 있습니다.
|
||||
|
||||
#### Context is all you need.
|
||||
- **Directory AGENTS.md / README.md Injector**: 파일을 읽을 때 `AGENTS.md`, `README.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
|
||||
@@ -593,14 +640,17 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
|
||||
- **Agent Usage Reminder**: 검색 도구를 직접 호출할 때, 백그라운드 작업을 통한 전문 에이전트 활용을 권장하는 리마인더를 표시합니다.
|
||||
- **Anthropic Auto Compact**: Claude 모델이 토큰 제한에 도달하면 자동으로 세션을 요약하고 압축합니다. 수동 개입 없이 작업을 계속할 수 있습니다.
|
||||
- **Session Recovery**: 세션 에러(누락된 도구 결과, thinking 블록 문제, 빈 메시지 등)에서 자동 복구합니다. 돌다가 세션이 망가지지 않습니다. 망가져도 복구됩니다.
|
||||
- **Auto Update Checker**: oh-my-opencode의 새 버전이 출시되면 알림을 표시합니다.
|
||||
- **Startup Toast**: OhMyOpenCode 로드 시 환영 메시지를 표시합니다. 세션을 제대로 시작하기 위한 작은 "oMoMoMo".
|
||||
- **Auto Update Checker**: oh-my-opencode의 새 버전을 자동으로 확인하고 설정을 자동 업데이트할 수 있습니다. 현재 버전과 Sisyphus 상태를 표시하는 시작 토스트 알림을 표시합니다 (Sisyphus 활성화 시 "Sisyphus on steroids is steering OpenCode", 비활성화 시 "OpenCode is now on Steroids. oMoMoMoMo..."). 모든 기능을 비활성화하려면 `disabled_hooks`에 `"auto-update-checker"`를, 토스트 알림만 비활성화하려면 `"startup-toast"`를 추가하세요. [설정 > 훅](#훅) 참조.
|
||||
- **Background Notification**: 백그라운드 에이전트 작업이 완료되면 알림을 받습니다.
|
||||
- **Session Notification**: 에이전트가 대기 상태가 되면 OS 알림을 보냅니다. macOS, Linux, Windows에서 작동—에이전트가 입력을 기다릴 때 놓치지 마세요.
|
||||
- **Empty Task Response Detector**: Task 도구가 빈 응답을 반환하면 감지합니다. 이미 빈 응답이 왔는데 무한정 기다리는 상황을 방지합니다.
|
||||
- **Empty Message Sanitizer**: 빈 채팅 메시지로 인한 API 오류를 방지합니다. 전송 전 메시지 내용을 자동으로 정리합니다.
|
||||
- **Grep Output Truncator**: grep은 산더미 같은 텍스트를 반환할 수 있습니다. 남은 컨텍스트 윈도우에 따라 동적으로 출력을 축소합니다—50% 여유 공간 유지, 최대 50k 토큰.
|
||||
- **Tool Output Truncator**: 같은 아이디어, 더 넓은 범위. Grep, Glob, LSP 도구, AST-grep의 출력을 축소합니다. 한 번의 장황한 검색이 전체 컨텍스트를 잡아먹는 것을 방지합니다.
|
||||
- **선제적 압축 (Preemptive Compaction)**: 세션 토큰 한계에 도달하기 전에 선제적으로 세션을 압축합니다. 문제가 발생하기 전에 미리 실행됩니다.
|
||||
- **압축 컨텍스트 주입기 (Compaction Context Injector)**: 세션 압축 중에 중요한 컨텍스트(AGENTS.md, 현재 디렉토리 정보 등)를 유지하여 중요한 상태를 잃지 않도록 합니다.
|
||||
- **사고 블록 검증기 (Thinking Block Validator)**: 사고(thinking) 블록의 형식이 올바른지 검증하여 잘못된 형식으로 인한 API 오류를 방지합니다.
|
||||
- **Claude Code 훅 (Claude Code Hooks)**: Claude Code의 settings.json에 설정된 훅을 실행합니다. PreToolUse/PostToolUse/UserPromptSubmit/Stop 이벤트를 지원하는 호환성 레이어입니다.
|
||||
|
||||
## 설정
|
||||
|
||||
@@ -612,7 +662,7 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
|
||||
|
||||
| 플랫폼 | 사용자 설정 경로 |
|
||||
|--------|------------------|
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (우선) 또는 `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (권장) 또는 `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
|
||||
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
|
||||
|
||||
Schema 자동 완성이 지원됩니다:
|
||||
@@ -623,6 +673,36 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
### JSONC 지원
|
||||
|
||||
`oh-my-opencode` 설정 파일은 JSONC(주석이 포함된 JSON)를 지원합니다:
|
||||
- 한 줄 주석: `// 주석`
|
||||
- 블록 주석: `/* 주석 */`
|
||||
- 후행 콤마(Trailing commas): `{ "key": "value", }`
|
||||
|
||||
`oh-my-opencode.jsonc`와 `oh-my-opencode.json` 파일이 모두 존재할 경우, `.jsonc` 파일이 우선순위를 갖습니다.
|
||||
|
||||
**주석이 포함된 예시:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
|
||||
// Antigravity OAuth를 통해 Google Gemini 활성화
|
||||
"google_auth": false,
|
||||
|
||||
/* 에이전트 오버라이드 - 특정 작업에 대한 모델 커스터마이징 */
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"model": "openai/gpt-5.2" // 전략적 추론을 위한 GPT
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/grok-code" // 탐색을 위한 빠르고 무료인 모델
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
**권장**: 외부 [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) 플러그인을 사용하세요. 멀티 계정 로드밸런싱, 더 많은 모델(Antigravity를 통한 Claude 포함), 활발한 유지보수를 제공합니다. [설치 > Google Gemini](#42-google-gemini-antigravity-oauth) 참조.
|
||||
@@ -711,8 +791,8 @@ Schema 자동 완성이 지원됩니다:
|
||||
활성화 시 (기본값), Sisyphus는 옵션으로 선택 가능한 특화 에이전트들과 함께 강력한 오케스트레이터를 제공합니다:
|
||||
|
||||
- **Sisyphus**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OhMyOpenCode 강화 버전 빌드 에이전트 (기본적으로 비활성화)
|
||||
- **Planner-Sisyphus**: OhMyOpenCode 강화 버전 플랜 에이전트 (기본적으로 활성화)
|
||||
- **Builder-Sisyphus**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
|
||||
- **Planner-Sisyphus**: OpenCode 기본 플랜 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 활성화)
|
||||
|
||||
**설정 옵션:**
|
||||
|
||||
@@ -720,26 +800,24 @@ Schema 자동 완성이 지원됩니다:
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"disabled": false,
|
||||
"builder_enabled": false,
|
||||
"default_builder_enabled": false,
|
||||
"planner_enabled": true,
|
||||
"replace_build": true,
|
||||
"replace_plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**예시: Builder-Sisyphus를 활성화하면서 기본 빌드 모드도 유지하기:**
|
||||
**예시: Builder-Sisyphus 활성화하기:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"builder_enabled": true,
|
||||
"replace_build": false
|
||||
"default_builder_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
이렇게 하면 Builder-Sisyphus와 기본 빌드 에이전트를 동시에 사용할 수 있습니다.
|
||||
이렇게 하면 Sisyphus와 함께 Builder-Sisyphus 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
|
||||
|
||||
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
|
||||
|
||||
@@ -770,13 +848,12 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| ------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
|
||||
| `builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트 (OhMyOpenCode 강화 빌드 모드)를 활성화합니다. 기본 OpenCode 빌드 경험을 보존하기 위해 기본적으로 비활성화되어 있습니다. |
|
||||
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트 (OhMyOpenCode 강화 플랜 모드)를 활성화합니다. 기본적으로 활성화되어 있습니다. |
|
||||
| `replace_build` | `true` | `true`면 기본 빌드 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Builder-Sisyphus와 기본 빌드를 모두 사용할 수 있습니다. |
|
||||
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
|
||||
| `default_builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
|
||||
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트를 활성화합니다 (OpenCode plan과 동일, SDK 제한으로 이름만 변경). 기본적으로 활성화되어 있습니다. |
|
||||
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -788,7 +865,9 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
사용 가능한 훅: `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`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
|
||||
사용 가능한 훅: `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`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
|
||||
|
||||
**`auto-update-checker`와 `startup-toast`에 대한 참고사항**: `startup-toast` 훅은 `auto-update-checker`의 하위 기능입니다. 업데이트 확인은 유지하면서 시작 토스트 알림만 비활성화하려면 `disabled_hooks`에 `"startup-toast"`를 추가하세요. 모든 업데이트 확인 기능(토스트 포함)을 비활성화하려면 `"auto-update-checker"`를 추가하세요.
|
||||
|
||||
### MCPs
|
||||
|
||||
@@ -840,7 +919,8 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
|
||||
"experimental": {
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false
|
||||
"truncate_all_tool_outputs": false,
|
||||
"dcp_on_compaction_failure": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -850,6 +930,7 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
|
||||
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
|
||||
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
|
||||
| `truncate_all_tool_outputs` | `true` | 프롬프트가 너무 길어지는 것을 방지하기 위해 컨텍스트 윈도우 사용량에 따라 모든 도구 출력을 동적으로 잘라냅니다. 전체 도구 출력이 필요한 경우 `false`로 설정하여 비활성화하세요. |
|
||||
| `dcp_for_compaction` | `false` | 활성화하면, 토큰 제한 에러 발생 시 DCP(Dynamic Context Pruning)가 가장 먼저 실행되고, 그 다음 compaction이 실행됩니다. DCP가 불필요한 컨텍스트를 정리한 후 바로 compaction이 진행됩니다. 토큰 제한에 도달했을 때 더 스마트한 복구를 원하면 활성화하세요. |
|
||||
|
||||
**경고**: 이 기능들은 실험적이며 예상치 못한 동작을 유발할 수 있습니다. 의미를 이해한 경우에만 활성화하세요.
|
||||
|
||||
@@ -897,11 +978,16 @@ OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능
|
||||
- [이를 고치는 PR 이 1.0.132 배포 이후에 병합되었으므로](https://github.com/sst/opencode/pull/5040) 이 변경사항이 포함된 최신 버전을 사용해주세요.
|
||||
- TMI: PR 도 OhMyOpenCode 의 셋업의 Librarian, Explore, Oracle 을 활용하여 우연히 발견하고 해결되었습니다.
|
||||
|
||||
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*
|
||||
|
||||
## 다음 기업의 능력있는 개인들이 사용하고 있습니다
|
||||
|
||||
- [Indent](https://indentcorp.com)
|
||||
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
|
||||
- [Google](https://google.com)
|
||||
- [Microsoft](https://microsoft.com)
|
||||
|
||||
## 스폰서
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- 첫 번째 스폰서
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
|
||||
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*
|
||||
|
||||
99
README.md
99
README.md
@@ -61,6 +61,8 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
> "Oh My Opencode is king of the hill and has no contenders" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Isn't the name Sisyphus itself beautiful?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
@@ -463,8 +465,8 @@ To remove oh-my-opencode:
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`): **The default agent.** A powerful AI orchestrator for OpenCode. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Emphasizes background task delegation and todo-driven workflow. Uses Claude Opus 4.5 with extended thinking (32k budget) for maximum reasoning capability.
|
||||
- **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. Uses Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. 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.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` or `google/gemini-3-flash`): Multi-repo analysis, doc lookup, implementation examples. Uses Gemini 3 Flash when Antigravity auth is configured, otherwise Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
|
||||
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, or `anthropic/claude-haiku-4-5`): Fast codebase exploration and pattern matching. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-high`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
|
||||
- **document-writer** (`google/gemini-3-flash`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
|
||||
- **multimodal-looker** (`google/gemini-3-flash`): Visual content specialist. Analyzes PDFs, images, diagrams to extract information.
|
||||
@@ -520,6 +522,18 @@ Hand your best tools to your best colleagues. Now they can properly refactor, na
|
||||
- **lsp_code_action_resolve**: Apply code action
|
||||
- **ast_grep_search**: AST-aware code pattern search (25 languages)
|
||||
- **ast_grep_replace**: AST-aware code replacement
|
||||
- **call_omo_agent**: Spawn specialized explore/librarian agents. Supports `run_in_background` parameter for async execution.
|
||||
|
||||
#### Session Management
|
||||
|
||||
Tools to navigate and search your OpenCode session history:
|
||||
|
||||
- **session_list**: List all OpenCode sessions with filtering by date and limit
|
||||
- **session_read**: Read messages and history from a specific session
|
||||
- **session_search**: Full-text search across session messages
|
||||
- **session_info**: Get metadata and statistics about a session
|
||||
|
||||
These tools enable agents to reference previous conversations and maintain continuity across sessions.
|
||||
|
||||
#### 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:
|
||||
@@ -665,14 +679,17 @@ When agents thrive, you thrive. But I want to help you directly too.
|
||||
- **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.
|
||||
- **Startup Toast**: Shows a welcome message when OhMyOpenCode loads. A little "oMoMoMo" to start your session right.
|
||||
- **Auto Update Checker**: Automatically checks for new versions of oh-my-opencode and can auto-update your configuration. Shows startup toast notifications displaying current version and Sisyphus status ("Sisyphus on steroids is steering OpenCode" when enabled, or "OpenCode is now on Steroids. oMoMoMoMo..." otherwise). Disable all features with `"auto-update-checker"` in `disabled_hooks`, or disable just toast notifications with `"startup-toast"` in `disabled_hooks`. See [Configuration > Hooks](#hooks).
|
||||
- **Background Notification**: Get notified when background agent tasks complete.
|
||||
- **Session Notification**: Sends OS notifications when agents go idle. Works on macOS, Linux, and Windows—never miss when your agent needs input.
|
||||
- **Empty Task Response Detector**: Catches when Task tool returns nothing. Warns you about potential agent failures so you don't wait forever for a response that already came back empty.
|
||||
- **Empty Message Sanitizer**: Prevents API errors from empty chat messages by automatically sanitizing message content before sending.
|
||||
- **Grep Output Truncator**: Grep can return mountains of text. This dynamically truncates output based on your remaining context window—keeps 50% headroom, caps at 50k tokens.
|
||||
- **Tool Output Truncator**: Same idea, broader scope. Truncates output from Grep, Glob, LSP tools, and AST-grep. Prevents one verbose search from eating your entire context.
|
||||
- **Preemptive Compaction**: Compacts session proactively before hitting hard token limits. Runs before you get into trouble.
|
||||
- **Compaction Context Injector**: Preserves critical context (AGENTS.md, current directory info) during session compaction so you don't lose important state.
|
||||
- **Thinking Block Validator**: Validates thinking blocks to ensure proper formatting and prevent API errors from malformed thinking content.
|
||||
- **Claude Code Hooks**: Executes hooks from Claude Code's settings.json - this is the compatibility layer that runs PreToolUse/PostToolUse/UserPromptSubmit/Stop hooks.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -695,6 +712,36 @@ Schema autocomplete supported:
|
||||
}
|
||||
```
|
||||
|
||||
### JSONC Support
|
||||
|
||||
The `oh-my-opencode` configuration file supports JSONC (JSON with Comments):
|
||||
- Line comments: `// comment`
|
||||
- Block comments: `/* comment */`
|
||||
- Trailing commas: `{ "key": "value", }`
|
||||
|
||||
When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc` takes priority.
|
||||
|
||||
**Example with comments:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
|
||||
// Enable Google Gemini via Antigravity OAuth
|
||||
"google_auth": false,
|
||||
|
||||
/* Agent overrides - customize models for specific tasks */
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"model": "openai/gpt-5.2" // GPT for strategic reasoning
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/grok-code" // Free & fast for exploration
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
**Recommended**: Use the external [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin. It provides multi-account load balancing, more models (including Claude via Antigravity), and active maintenance. See [Installation > Google Gemini](#google-gemini-antigravity-oauth).
|
||||
@@ -783,8 +830,8 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
|
||||
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
|
||||
|
||||
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: Optional build agent with OhMyOpenCode enhancements (disabled by default)
|
||||
- **Planner-Sisyphus**: Plan agent with OhMyOpenCode enhancements (enabled by default)
|
||||
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
|
||||
- **Planner-Sisyphus**: OpenCode's default plan agent, renamed due to SDK limitations (enabled by default)
|
||||
|
||||
**Configuration Options:**
|
||||
|
||||
@@ -792,26 +839,24 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"disabled": false,
|
||||
"builder_enabled": false,
|
||||
"default_builder_enabled": false,
|
||||
"planner_enabled": true,
|
||||
"replace_build": true,
|
||||
"replace_plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Enable Builder-Sisyphus and keep default build mode:**
|
||||
**Example: Enable Builder-Sisyphus:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"builder_enabled": true,
|
||||
"replace_build": false
|
||||
"default_builder_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to have both Builder-Sisyphus AND the default build agent available simultaneously.
|
||||
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
|
||||
|
||||
**Example: Disable all Sisyphus orchestration:**
|
||||
|
||||
@@ -842,13 +887,12 @@ You can also customize Sisyphus agents like other agents:
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
|
||||
| `builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (OhMyOpenCode enhanced build mode). Disabled by default to preserve default OpenCode build experience. |
|
||||
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (OhMyOpenCode enhanced plan mode). Enabled by default. |
|
||||
| `replace_build` | `true` | When `true`, demotes default build agent to subagent mode. Set to `false` to keep both Builder-Sisyphus and default build available. |
|
||||
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
|
||||
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (same as OpenCode plan, renamed due to SDK limitations). Enabled by default. |
|
||||
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -860,7 +904,9 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
|
||||
}
|
||||
```
|
||||
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
|
||||
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`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
|
||||
|
||||
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
|
||||
|
||||
### MCPs
|
||||
|
||||
@@ -912,7 +958,8 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
"experimental": {
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false
|
||||
"truncate_all_tool_outputs": false,
|
||||
"dcp_on_compaction_failure": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -922,6 +969,7 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
|
||||
| `truncate_all_tool_outputs` | `true` | Dynamically truncates ALL tool outputs based on context window usage to prevent prompts from becoming too long. Disable by setting to `false` if you need full tool outputs. |
|
||||
| `dcp_for_compaction` | `false` | When enabled, Dynamic Context Pruning (DCP) runs FIRST when token limit errors occur, before attempting compaction. DCP prunes redundant context, then compaction runs immediately. Enable this for smarter recovery when hitting token limits. |
|
||||
|
||||
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.
|
||||
|
||||
@@ -969,11 +1017,16 @@ I have no affiliation with any project or model mentioned here. This is purely p
|
||||
- [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.*
|
||||
|
||||
## Loved by professionals at
|
||||
|
||||
- [Indent](https://indentcorp.com)
|
||||
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
|
||||
- [Google](https://google.com)
|
||||
- [Microsoft](https://microsoft.com)
|
||||
|
||||
## Sponsors
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- The first sponsor
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
|
||||
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*
|
||||
|
||||
135
README.zh-cn.md
135
README.zh-cn.md
@@ -58,6 +58,8 @@
|
||||
|
||||
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
@@ -396,14 +398,47 @@ gh repo star code-yeongyu/oh-my-opencode
|
||||
</details>
|
||||
|
||||
|
||||
## 卸载
|
||||
|
||||
要移除 oh-my-opencode:
|
||||
|
||||
1. **从 OpenCode 配置中移除插件**
|
||||
|
||||
编辑 `~/.config/opencode/opencode.json` (或 `opencode.jsonc`),从 `plugin` 数组中移除 `"oh-my-opencode"`:
|
||||
|
||||
```bash
|
||||
# 使用 jq 的示例
|
||||
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
```
|
||||
|
||||
2. **删除配置文件 (可选)**
|
||||
|
||||
```bash
|
||||
# 删除用户配置
|
||||
rm -f ~/.config/opencode/oh-my-opencode.json
|
||||
|
||||
# 删除项目配置 (如果存在)
|
||||
rm -f .opencode/oh-my-opencode.json
|
||||
```
|
||||
|
||||
3. **确认移除**
|
||||
|
||||
```bash
|
||||
opencode --version
|
||||
# 插件不应再被加载
|
||||
```
|
||||
|
||||
|
||||
## 功能
|
||||
|
||||
### Agents:你的神队友
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`):**默认 Agent。** OpenCode 专属的强力 AI 编排器。指挥专业子 Agent 搞定复杂任务。主打后台任务委派和 Todo 驱动。用 Claude Opus 4.5 加上扩展思考(32k token 预算),智商拉满。
|
||||
- **oracle** (`openai/gpt-5.2`):架构师、代码审查员、战略家。GPT-5.2 的逻辑推理和深度分析能力不是盖的。致敬 AmpCode。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`):多仓库分析、查文档、找示例。Claude Sonnet 4.5 深入理解代码库,GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
|
||||
- **explore** (`opencode/grok-code`):极速代码库扫描、模式匹配。Claude Code 用 Haiku,我们用 Grok——免费、飞快、扫文件够用了。致敬 Claude Code。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` 或 `google/gemini-3-flash`):多仓库分析、查文档、找示例。配置 Antigravity 认证时使用 Gemini 3 Flash,否则使用 Claude Sonnet 4.5 深入理解代码库,GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
|
||||
- **explore** (`opencode/grok-code`、`google/gemini-3-flash` 或 `anthropic/claude-haiku-4-5`):极速代码库扫描、模式匹配。配置 Antigravity 认证时使用 Gemini 3 Flash,Claude max20 可用时使用 Haiku,否则用 Grok。致敬 Claude Code。
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`):设计师出身的程序员。UI 做得那是真漂亮。Gemini 写这种创意美观的代码是一绝。
|
||||
- **document-writer** (`google/gemini-3-pro-preview`):技术写作专家。Gemini 文笔好,写出来的东西读着顺畅。
|
||||
- **multimodal-looker** (`google/gemini-3-flash`):视觉内容专家。PDF、图片、图表,看一眼就知道里头有啥。
|
||||
@@ -459,6 +494,18 @@ OhMyOpenCode 让这些成为可能。
|
||||
- **lsp_code_action_resolve**:应用代码操作
|
||||
- **ast_grep_search**:AST 感知代码搜索(支持 25 种语言)
|
||||
- **ast_grep_replace**:AST 感知代码替换
|
||||
- **call_omo_agent**: 产生专门的 explore/librarian Agent。支持用于异步执行的 `run_in_background` 参数。
|
||||
|
||||
#### 会话管理 (Session Management)
|
||||
|
||||
用于导航和搜索 OpenCode 会话历史的工具:
|
||||
|
||||
- **session_list**: 列出所有 OpenCode 会话,支持按日期和数量限制进行过滤
|
||||
- **session_read**: 读取特定会话的消息和历史记录
|
||||
- **session_search**: 在会话消息中进行全文搜索
|
||||
- **session_info**: 获取有关会话的元数据和统计信息
|
||||
|
||||
这些工具使 Agent 能够引用之前的对话并保持跨会话的连续性。
|
||||
|
||||
#### 上下文就是一切 (Context is all you need)
|
||||
- **Directory AGENTS.md / README.md 注入器**:读文件时自动把 `AGENTS.md` 和 `README.md` 塞进去。从当前目录一路往上找,路径上**所有** `AGENTS.md` 全都带上。支持嵌套指令:
|
||||
@@ -604,8 +651,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
- **Agent 使用提醒**:你自己搜东西的时候,弹窗提醒你"这种事让后台专业 Agent 干更好"。
|
||||
- **Anthropic 自动压缩**:Claude Token 爆了?自动总结压缩会话——不用你操心。
|
||||
- **会话恢复**:工具没结果?Thinking 卡住?消息是空的?自动恢复。会话崩不了,崩了也能救回来。
|
||||
- **自动更新检查**:oh-my-opencode 更新了会告诉你。
|
||||
- **启动提示**:加载时来句"oMoMoMo",开启元气满满的一次会话。
|
||||
- **自动更新检查**:自动检查 oh-my-opencode 新版本并可自动更新配置。显示启动提示通知,展示当前版本和 Sisyphus 状态(Sisyphus 启用时显示「Sisyphus on steroids is steering OpenCode」,禁用时显示「OpenCode is now on Steroids. oMoMoMoMo...」)。要禁用全部功能,在 `disabled_hooks` 中添加 `"auto-update-checker"`;只禁用提示通知,添加 `"startup-toast"`。详见 [配置 > Hooks](#hooks)。
|
||||
- **后台通知**:后台 Agent 活儿干完了告诉你。
|
||||
- **会话通知**:Agent 没事干了发系统通知。macOS、Linux、Windows 通吃——别让 Agent 等你。
|
||||
- **空 Task 响应检测**:Task 工具回了个寂寞?立马报警,别傻傻等一个永远不会来的响应。
|
||||
@@ -619,7 +665,12 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
|
||||
配置文件(优先级从高到低):
|
||||
1. `.opencode/oh-my-opencode.json`(项目级)
|
||||
2. `~/.config/opencode/oh-my-opencode.json`(用户级)
|
||||
2. 用户配置(按平台):
|
||||
|
||||
| 平台 | 用户配置路径 |
|
||||
|----------|------------------|
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (首选) 或 `%APPDATA%\opencode\oh-my-opencode.json` (备选) |
|
||||
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
|
||||
|
||||
支持 Schema 自动补全:
|
||||
|
||||
@@ -629,6 +680,36 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
### JSONC 支持
|
||||
|
||||
`oh-my-opencode` 配置文件支持 JSONC(带注释的 JSON):
|
||||
- 行注释:`// 注释`
|
||||
- 块注释:`/* 注释 */`
|
||||
- 尾随逗号:`{ "key": "value", }`
|
||||
|
||||
当 `oh-my-opencode.jsonc` 和 `oh-my-opencode.json` 文件同时存在时,`.jsonc` 优先。
|
||||
|
||||
**带注释的示例:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
|
||||
// 通过 Antigravity OAuth 启用 Google Gemini
|
||||
"google_auth": false,
|
||||
|
||||
/* Agent 覆盖 - 为特定任务自定义模型 */
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"model": "openai/gpt-5.2" // 用于战略推理的 GPT
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/grok-code" // 快速且免费的搜索模型
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
**强推**:用外部 [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) 插件。多账号负载均衡、更多模型(包括 Antigravity 版 Claude)、有人维护。看 [安装 > Google Gemini](#42-google-gemini-antigravity-oauth)。
|
||||
@@ -717,8 +798,8 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent:
|
||||
|
||||
- **Sisyphus**:主编排 Agent(Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**:OhMyOpenCode 增强版构建 Agent(默认禁用)
|
||||
- **Planner-Sisyphus**:OhMyOpenCode 增强版计划 Agent(默认启用)
|
||||
- **Builder-Sisyphus**:OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用)
|
||||
- **Planner-Sisyphus**:OpenCode 默认计划 Agent(因 SDK 限制仅改名,默认启用)
|
||||
|
||||
**配置选项:**
|
||||
|
||||
@@ -726,26 +807,24 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"disabled": false,
|
||||
"builder_enabled": false,
|
||||
"default_builder_enabled": false,
|
||||
"planner_enabled": true,
|
||||
"replace_build": true,
|
||||
"replace_plan": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**示例:启用 Builder-Sisyphus,同时保留默认构建模式:**
|
||||
**示例:启用 Builder-Sisyphus:**
|
||||
|
||||
```json
|
||||
{
|
||||
"sisyphus_agent": {
|
||||
"builder_enabled": true,
|
||||
"replace_build": false
|
||||
"default_builder_enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样你就能同时使用 Builder-Sisyphus 和默认构建 Agent。
|
||||
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
|
||||
|
||||
**示例:禁用所有 Sisyphus 编排:**
|
||||
|
||||
@@ -776,13 +855,12 @@ Sisyphus Agent 也能自定义:
|
||||
}
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| ------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
|
||||
| `builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent(OhMyOpenCode 增强构建模式)。为了保留默认 OpenCode 构建体验,默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent(OhMyOpenCode 增强计划模式)。默认启用。 |
|
||||
| `replace_build` | `true` | 设为 `true` 就把默认构建 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Builder-Sisyphus 和默认构建。 |
|
||||
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent(与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
|
||||
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -794,7 +872,9 @@ Sisyphus Agent 也能自定义:
|
||||
}
|
||||
```
|
||||
|
||||
可关的 hook:`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`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`
|
||||
可关的 hook:`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`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`preemptive-compaction`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`
|
||||
|
||||
**关于 `auto-update-checker` 和 `startup-toast`**: `startup-toast` hook 是 `auto-update-checker` 的子功能。若想保持更新检查但只禁用启动提示通知,在 `disabled_hooks` 中添加 `"startup-toast"`。若要禁用所有更新检查功能(包括提示),添加 `"auto-update-checker"`。
|
||||
|
||||
### MCPs
|
||||
|
||||
@@ -846,7 +926,8 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
"experimental": {
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false
|
||||
"truncate_all_tool_outputs": false,
|
||||
"dcp_on_compaction_failure": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -856,6 +937,7 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
|
||||
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
|
||||
| `truncate_all_tool_outputs` | `true` | 为防止提示过长,根据上下文窗口使用情况动态截断所有工具输出。如需完整工具输出,设置为 `false` 禁用此功能。 |
|
||||
| `dcp_for_compaction` | `false` | 启用后,当发生 token 限制错误时,DCP(动态上下文剪枝)首先运行,然后立即执行压缩。DCP 清理不必要的上下文后,压缩立即进行。当达到 token 限制时需要更智能的恢复请启用此选项。 |
|
||||
|
||||
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。
|
||||
|
||||
@@ -902,11 +984,16 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
- [修复 PR](https://github.com/sst/opencode/pull/5040) 在 1.0.132 之后才合进去——请用新版本。
|
||||
- 花絮:这 bug 也是靠 OhMyOpenCode 的 Librarian、Explore、Oracle 配合发现并修好的。
|
||||
|
||||
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*
|
||||
|
||||
## 以下企业的专业人士都在用
|
||||
|
||||
- [Indent](https://indentcorp.com)
|
||||
- Making Spray - influencer marketing solution, vovushop - crossborder commerce platform, vreview - ai commerce review marketing solution
|
||||
- [Google](https://google.com)
|
||||
- [Microsoft](https://microsoft.com)
|
||||
|
||||
## 赞助者
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- 第一位赞助者
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
|
||||
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*
|
||||
|
||||
@@ -59,7 +59,17 @@
|
||||
"agent-usage-reminder",
|
||||
"non-interactive-env",
|
||||
"interactive-bash-session",
|
||||
"empty-message-sanitizer"
|
||||
"empty-message-sanitizer",
|
||||
"thinking-block-validator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_commands": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"init-deep"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -408,7 +418,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
@@ -1339,6 +1349,18 @@
|
||||
},
|
||||
"hooks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"plugins": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"plugins_override": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1351,20 +1373,25 @@
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"builder_enabled": {
|
||||
"default_builder_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"planner_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"replace_build": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"replace_plan": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"comment_checker": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"custom_prompt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1385,6 +1412,100 @@
|
||||
"truncate_all_tool_outputs": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"dynamic_context_pruning": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"notification": {
|
||||
"default": "detailed",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"off",
|
||||
"minimal",
|
||||
"detailed"
|
||||
]
|
||||
},
|
||||
"turn_protection": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"turns": {
|
||||
"default": 3,
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"maximum": 10
|
||||
}
|
||||
}
|
||||
},
|
||||
"protected_tools": {
|
||||
"default": [
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
"session_read",
|
||||
"session_write",
|
||||
"session_search"
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"strategies": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deduplication": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"supersede_writes": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"aggressive": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"purge_errors": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"turns": {
|
||||
"default": 5,
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"maximum": 20
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dcp_for_compaction": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
7
bun.lock
7
bun.lock
@@ -8,12 +8,13 @@
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
@@ -72,7 +73,7 @@
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
|
||||
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-VtDPrhbUJcb5BIS18VMcY/N/xSLbMr6dpU9MO1NYQyEDhI4pSIx07K4gOlCutG/nHVCjO+HEarn8rttODP+5UA=="],
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -110,6 +111,8 @@
|
||||
|
||||
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.5.3",
|
||||
"version": "2.7.1",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -53,12 +53,13 @@
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
|
||||
@@ -15,6 +15,62 @@
|
||||
"created_at": "2025-12-25T06:19:27Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 217
|
||||
},
|
||||
{
|
||||
"name": "mylukin",
|
||||
"id": 1021019,
|
||||
"comment_id": 3691531529,
|
||||
"created_at": "2025-12-25T15:15:29Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 240
|
||||
},
|
||||
{
|
||||
"name": "codewithkenzo",
|
||||
"id": 115878491,
|
||||
"comment_id": 3691825625,
|
||||
"created_at": "2025-12-25T23:47:52Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 253
|
||||
},
|
||||
{
|
||||
"name": "stevenvo",
|
||||
"id": 875426,
|
||||
"comment_id": 3692141372,
|
||||
"created_at": "2025-12-26T05:16:12Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 248
|
||||
},
|
||||
{
|
||||
"name": "harshav167",
|
||||
"id": 80092815,
|
||||
"comment_id": 3693666997,
|
||||
"created_at": "2025-12-27T04:40:35Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 268
|
||||
},
|
||||
{
|
||||
"name": "adam2am",
|
||||
"id": 128839448,
|
||||
"comment_id": 3694022446,
|
||||
"created_at": "2025-12-27T14:49:05Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 281
|
||||
},
|
||||
{
|
||||
"name": "devxoul",
|
||||
"id": 931655,
|
||||
"comment_id": 3694098760,
|
||||
"created_at": "2025-12-27T17:05:50Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 288
|
||||
},
|
||||
{
|
||||
"name": "SyedTahirHussan",
|
||||
"id": 9879266,
|
||||
"comment_id": 3694598917,
|
||||
"created_at": "2025-12-28T09:24:03Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 306
|
||||
}
|
||||
]
|
||||
}
|
||||
89
src/agents/AGENTS.md
Normal file
89
src/agents/AGENTS.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# AGENTS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
AI agent definitions for multi-model orchestration. 7 specialized agents: Sisyphus (orchestrator), oracle (strategy), librarian (research), explore (grep), frontend-ui-ux-engineer, document-writer, multimodal-looker.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
agents/
|
||||
├── sisyphus.ts # Primary orchestrator (Claude Opus 4.5)
|
||||
├── oracle.ts # Strategic advisor (GPT-5.2)
|
||||
├── librarian.ts # Multi-repo research (Claude Sonnet 4.5)
|
||||
├── explore.ts # Fast codebase grep (Grok Code)
|
||||
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro)
|
||||
├── document-writer.ts # Technical docs (Gemini 3 Flash)
|
||||
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
|
||||
├── build-prompt.ts # Shared build agent prompt
|
||||
├── plan-prompt.ts # Shared plan agent prompt
|
||||
├── types.ts # AgentModelConfig interface
|
||||
├── utils.ts # createBuiltinAgents(), getAgentName()
|
||||
└── index.ts # builtinAgents export
|
||||
```
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Default Model | Fallback | Purpose |
|
||||
|-------|---------------|----------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | - | Primary orchestrator with extended thinking |
|
||||
| oracle | openai/gpt-5.2 | - | Architecture, debugging, code review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, OSS research, GitHub examples |
|
||||
| explore | opencode/grok-code | google/gemini-3-flash, anthropic/claude-haiku-4-5 | Fast contextual grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | UI/UX code generation |
|
||||
| document-writer | google/gemini-3-pro-preview | - | Technical writing |
|
||||
| multimodal-looker | google/gemini-3-flash | - | PDF/image analysis |
|
||||
|
||||
## HOW TO ADD AN AGENT
|
||||
|
||||
1. Create `src/agents/my-agent.ts`:
|
||||
```typescript
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const myAgent: AgentConfig = {
|
||||
model: "provider/model-name",
|
||||
temperature: 0.1,
|
||||
system: "Agent system prompt...",
|
||||
tools: { include: ["tool1", "tool2"] }, // or exclude: [...]
|
||||
}
|
||||
```
|
||||
2. Add to `builtinAgents` in `src/agents/index.ts`
|
||||
3. Update `types.ts` if adding new config options
|
||||
|
||||
## AGENT CONFIG OPTIONS
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| model | string | Model identifier (provider/model-name) |
|
||||
| temperature | number | 0.0-1.0, most use 0.1 for consistency |
|
||||
| system | string | System prompt (can be multiline template literal) |
|
||||
| tools | object | `{ include: [...] }` or `{ exclude: [...] }` |
|
||||
| top_p | number | Optional nucleus sampling |
|
||||
| maxTokens | number | Optional max output tokens |
|
||||
|
||||
## MODEL FALLBACK LOGIC
|
||||
|
||||
`createBuiltinAgents()` in utils.ts handles model fallback:
|
||||
|
||||
1. Check user config override (`agents.{name}.model`)
|
||||
2. Check installer settings (claude max20, gemini antigravity)
|
||||
3. Use default model
|
||||
|
||||
**Fallback order for explore**:
|
||||
- If gemini antigravity enabled → `google/gemini-3-flash`
|
||||
- If claude max20 enabled → `anthropic/claude-haiku-4-5`
|
||||
- Default → `opencode/grok-code` (free)
|
||||
|
||||
## ANTI-PATTERNS (AGENTS)
|
||||
|
||||
- **High temperature**: Don't use >0.3 for code-related agents
|
||||
- **Broad tool access**: Prefer explicit `include` over unrestricted access
|
||||
- **Monolithic prompts**: Keep prompts focused; delegate to specialized agents
|
||||
- **Missing fallbacks**: Consider free/cheap fallbacks for rate-limited models
|
||||
|
||||
## SHARED PROMPTS
|
||||
|
||||
- **build-prompt.ts**: Base prompt for build agents (OpenCode default + Sisyphus variants)
|
||||
- **plan-prompt.ts**: Base prompt for plan agents (Planner-Sisyphus)
|
||||
|
||||
Used by `src/index.ts` when creating Builder-Sisyphus and Planner-Sisyphus variants.
|
||||
@@ -17,16 +17,15 @@
|
||||
* 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 { ANTIGRAVITY_ENDPOINT_FALLBACKS } from "./constants"
|
||||
import { fetchProjectContext, clearProjectContextCache, invalidateProjectContextByRefreshToken } from "./project"
|
||||
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage, AntigravityTokenRefreshError } 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"
|
||||
@@ -391,7 +390,6 @@ export function createAntigravityFetch(
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret)
|
||||
|
||||
// Update cached tokens
|
||||
cachedTokens = {
|
||||
type: "antigravity",
|
||||
access_token: newTokens.access_token,
|
||||
@@ -400,10 +398,8 @@ export function createAntigravityFetch(
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
// Clear project context cache on token refresh
|
||||
clearProjectContextCache()
|
||||
|
||||
// Format and save new tokens
|
||||
const formattedRefresh = formatTokenForStorage(
|
||||
newTokens.refresh_token,
|
||||
refreshParts.projectId || "",
|
||||
@@ -418,6 +414,16 @@ export function createAntigravityFetch(
|
||||
|
||||
debugLog("Token refreshed successfully")
|
||||
} catch (error) {
|
||||
if (error instanceof AntigravityTokenRefreshError) {
|
||||
if (error.isInvalidGrant) {
|
||||
debugLog(`[REFRESH] Token revoked (invalid_grant), clearing caches`)
|
||||
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
|
||||
clearProjectContextCache()
|
||||
}
|
||||
throw new Error(
|
||||
`Antigravity: Token refresh failed: ${error.description || error.message}${error.code ? ` (${error.code})` : ""}`
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
)
|
||||
@@ -535,11 +541,33 @@ export function createAntigravityFetch(
|
||||
debugLog("[401] Token refreshed, retrying request...")
|
||||
return executeWithEndpoints()
|
||||
} catch (refreshError) {
|
||||
if (refreshError instanceof AntigravityTokenRefreshError) {
|
||||
if (refreshError.isInvalidGrant) {
|
||||
debugLog(`[401] Token revoked (invalid_grant), clearing caches`)
|
||||
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
|
||||
clearProjectContextCache()
|
||||
}
|
||||
debugLog(`[401] Token refresh failed: ${refreshError.description || refreshError.message}`)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: refreshError.description || refreshError.message,
|
||||
type: refreshError.isInvalidGrant ? "token_revoked" : "unauthorized",
|
||||
code: refreshError.code || "token_refresh_failed",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
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"}`,
|
||||
message: refreshError instanceof Error ? refreshError.message : "Unknown error",
|
||||
type: "unauthorized",
|
||||
code: "token_refresh_failed",
|
||||
},
|
||||
|
||||
@@ -267,3 +267,8 @@ export function clearProjectContextCache(accessToken?: string): void {
|
||||
projectContextCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateProjectContextByRefreshToken(_refreshToken: string): void {
|
||||
projectContextCache.clear()
|
||||
debugLog(`[invalidateProjectContextByRefreshToken] Cleared all project context cache due to refresh token invalidation`)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Antigravity token management utilities.
|
||||
* Handles token expiration checking, refresh, and storage format parsing.
|
||||
*/
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_CLIENT_ID,
|
||||
ANTIGRAVITY_CLIENT_SECRET,
|
||||
@@ -13,33 +8,86 @@ import type {
|
||||
AntigravityRefreshParts,
|
||||
AntigravityTokenExchangeResult,
|
||||
AntigravityTokens,
|
||||
OAuthErrorPayload,
|
||||
ParsedOAuthError,
|
||||
} 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
|
||||
export class AntigravityTokenRefreshError extends Error {
|
||||
code?: string
|
||||
description?: string
|
||||
status: number
|
||||
statusText: string
|
||||
responseBody?: string
|
||||
|
||||
// Check if current time is past (expiration - buffer)
|
||||
constructor(options: {
|
||||
message: string
|
||||
code?: string
|
||||
description?: string
|
||||
status: number
|
||||
statusText: string
|
||||
responseBody?: string
|
||||
}) {
|
||||
super(options.message)
|
||||
this.name = "AntigravityTokenRefreshError"
|
||||
this.code = options.code
|
||||
this.description = options.description
|
||||
this.status = options.status
|
||||
this.statusText = options.statusText
|
||||
this.responseBody = options.responseBody
|
||||
}
|
||||
|
||||
get isInvalidGrant(): boolean {
|
||||
return this.code === "invalid_grant"
|
||||
}
|
||||
|
||||
get isNetworkError(): boolean {
|
||||
return this.status === 0
|
||||
}
|
||||
}
|
||||
|
||||
function parseOAuthErrorPayload(text: string | undefined): ParsedOAuthError {
|
||||
if (!text) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as OAuthErrorPayload
|
||||
let code: string | undefined
|
||||
|
||||
if (typeof payload.error === "string") {
|
||||
code = payload.error
|
||||
} else if (payload.error && typeof payload.error === "object") {
|
||||
code = payload.error.status ?? payload.error.code
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
description: payload.error_description,
|
||||
}
|
||||
} catch {
|
||||
return { description: text }
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenExpired(tokens: AntigravityTokens): boolean {
|
||||
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
|
||||
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
|
||||
*/
|
||||
const MAX_REFRESH_RETRIES = 3
|
||||
const INITIAL_RETRY_DELAY_MS = 1000
|
||||
|
||||
function calculateRetryDelay(attempt: number): number {
|
||||
return Math.min(INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt), 10000)
|
||||
}
|
||||
|
||||
function isRetryableError(status: number): boolean {
|
||||
if (status === 0) return true
|
||||
if (status === 429) return true
|
||||
if (status >= 500 && status < 600) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
@@ -52,35 +100,81 @@ export async function refreshAccessToken(
|
||||
client_secret: clientSecret,
|
||||
})
|
||||
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
let lastError: AntigravityTokenRefreshError | undefined
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
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 || refreshToken,
|
||||
expires_in: data.expires_in,
|
||||
token_type: data.token_type,
|
||||
}
|
||||
}
|
||||
|
||||
const responseBody = await response.text().catch(() => undefined)
|
||||
const parsed = parseOAuthErrorPayload(responseBody)
|
||||
|
||||
lastError = new AntigravityTokenRefreshError({
|
||||
message: parsed.description || `Token refresh failed: ${response.status} ${response.statusText}`,
|
||||
code: parsed.code,
|
||||
description: parsed.description,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
responseBody,
|
||||
})
|
||||
|
||||
if (parsed.code === "invalid_grant") {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (!isRetryableError(response.status)) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (attempt < MAX_REFRESH_RETRIES) {
|
||||
const delay = calculateRetryDelay(attempt)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AntigravityTokenRefreshError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
lastError = new AntigravityTokenRefreshError({
|
||||
message: error instanceof Error ? error.message : "Network error during token refresh",
|
||||
status: 0,
|
||||
statusText: "Network Error",
|
||||
})
|
||||
|
||||
if (attempt < MAX_REFRESH_RETRIES) {
|
||||
const delay = calculateRetryDelay(attempt)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new AntigravityTokenRefreshError({
|
||||
message: "Token refresh failed after all retries",
|
||||
status: 0,
|
||||
statusText: "Max Retries Exceeded",
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -194,3 +194,20 @@ export interface AntigravityRefreshParts {
|
||||
projectId?: string
|
||||
managedProjectId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth error payload from Google
|
||||
* Google returns errors in multiple formats, this handles all of them
|
||||
*/
|
||||
export interface OAuthErrorPayload {
|
||||
error?: string | { status?: string; code?: string; message?: string }
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed OAuth error with normalized fields
|
||||
*/
|
||||
export interface ParsedOAuthError {
|
||||
code?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { parseJsonc } from "../shared"
|
||||
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
@@ -39,80 +40,10 @@ export function detectConfigFormat(): { format: ConfigFormat; path: string } {
|
||||
return { format: "none", path: OPENCODE_JSON }
|
||||
}
|
||||
|
||||
function stripJsoncComments(content: string): string {
|
||||
let result = ""
|
||||
let i = 0
|
||||
let inString = false
|
||||
let escape = false
|
||||
|
||||
while (i < content.length) {
|
||||
const char = content[i]
|
||||
|
||||
if (escape) {
|
||||
result += char
|
||||
escape = false
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "\\") {
|
||||
result += char
|
||||
escape = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' && !inString) {
|
||||
inString = true
|
||||
result += char
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' && inString) {
|
||||
inString = false
|
||||
result += char
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
result += char
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Outside string - check for comments
|
||||
if (char === "/" && content[i + 1] === "/") {
|
||||
// Line comment - skip to end of line
|
||||
while (i < content.length && content[i] !== "\n") {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "/" && content[i + 1] === "*") {
|
||||
// Block comment - skip to */
|
||||
i += 2
|
||||
while (i < content.length - 1 && !(content[i] === "*" && content[i + 1] === "/")) {
|
||||
i++
|
||||
}
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
|
||||
result += char
|
||||
i++
|
||||
}
|
||||
|
||||
return result.replace(/,(\s*[}\]])/g, "$1")
|
||||
}
|
||||
|
||||
function parseConfig(path: string, isJsonc: boolean): OpenCodeConfig | null {
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8")
|
||||
const cleaned = isJsonc ? stripJsoncComments(content) : content
|
||||
return JSON.parse(cleaned) as OpenCodeConfig
|
||||
return parseJsonc<OpenCodeConfig>(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -215,9 +146,16 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
|
||||
|
||||
if (!installConfig.hasClaude) {
|
||||
agents["Sisyphus"] = { model: "opencode/big-pickle" }
|
||||
}
|
||||
|
||||
if (installConfig.hasGemini) {
|
||||
agents["librarian"] = { model: "google/gemini-3-flash" }
|
||||
agents["explore"] = { model: "google/gemini-3-flash" }
|
||||
} else if (installConfig.hasClaude && installConfig.isMax20) {
|
||||
agents["explore"] = { model: "anthropic/claude-haiku-4-5" }
|
||||
} else {
|
||||
agents["librarian"] = { model: "opencode/big-pickle" }
|
||||
} else if (!installConfig.isMax20) {
|
||||
agents["librarian"] = { model: "opencode/big-pickle" }
|
||||
agents["explore"] = { model: "opencode/big-pickle" }
|
||||
}
|
||||
|
||||
if (!installConfig.hasChatGPT) {
|
||||
@@ -252,8 +190,7 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
||||
|
||||
if (existsSync(OMO_CONFIG)) {
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
const cleaned = stripJsoncComments(content)
|
||||
const existing = JSON.parse(cleaned) as Record<string, unknown>
|
||||
const existing = parseJsonc<Record<string, unknown>>(content)
|
||||
delete existing.agents
|
||||
const merged = deepMerge(existing, newConfig)
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
|
||||
@@ -484,7 +421,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
|
||||
try {
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
const omoConfig = JSON.parse(stripJsoncComments(content)) as OmoConfigData
|
||||
const omoConfig = parseJsonc<OmoConfigData>(content)
|
||||
|
||||
const agents = omoConfig.agents ?? {}
|
||||
|
||||
|
||||
66
src/cli/get-local-version/formatter.ts
Normal file
66
src/cli/get-local-version/formatter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import color from "picocolors"
|
||||
import type { VersionInfo } from "./types"
|
||||
|
||||
const SYMBOLS = {
|
||||
check: color.green("✓"),
|
||||
cross: color.red("✗"),
|
||||
arrow: color.cyan("→"),
|
||||
info: color.blue("ℹ"),
|
||||
warn: color.yellow("⚠"),
|
||||
pin: color.magenta("📌"),
|
||||
dev: color.cyan("🔧"),
|
||||
}
|
||||
|
||||
export function formatVersionOutput(info: VersionInfo): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push("")
|
||||
lines.push(color.bold(color.white("oh-my-opencode Version Information")))
|
||||
lines.push(color.dim("─".repeat(50)))
|
||||
lines.push("")
|
||||
|
||||
if (info.currentVersion) {
|
||||
lines.push(` Current Version: ${color.cyan(info.currentVersion)}`)
|
||||
} else {
|
||||
lines.push(` Current Version: ${color.dim("unknown")}`)
|
||||
}
|
||||
|
||||
if (!info.isLocalDev && info.latestVersion) {
|
||||
lines.push(` Latest Version: ${color.cyan(info.latestVersion)}`)
|
||||
}
|
||||
|
||||
lines.push("")
|
||||
|
||||
switch (info.status) {
|
||||
case "up-to-date":
|
||||
lines.push(` ${SYMBOLS.check} ${color.green("You're up to date!")}`)
|
||||
break
|
||||
case "outdated":
|
||||
lines.push(` ${SYMBOLS.warn} ${color.yellow("Update available")}`)
|
||||
lines.push(` ${color.dim("Run:")} ${color.cyan("cd ~/.config/opencode && bun update oh-my-opencode")}`)
|
||||
break
|
||||
case "local-dev":
|
||||
lines.push(` ${SYMBOLS.dev} ${color.cyan("Running in local development mode")}`)
|
||||
lines.push(` ${color.dim("Using file:// protocol from config")}`)
|
||||
break
|
||||
case "pinned":
|
||||
lines.push(` ${SYMBOLS.pin} ${color.magenta(`Version pinned to ${info.pinnedVersion}`)}`)
|
||||
lines.push(` ${color.dim("Update check skipped for pinned versions")}`)
|
||||
break
|
||||
case "error":
|
||||
lines.push(` ${SYMBOLS.cross} ${color.red("Unable to check for updates")}`)
|
||||
lines.push(` ${color.dim("Network error or npm registry unavailable")}`)
|
||||
break
|
||||
case "unknown":
|
||||
lines.push(` ${SYMBOLS.info} ${color.yellow("Version information unavailable")}`)
|
||||
break
|
||||
}
|
||||
|
||||
lines.push("")
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatJsonOutput(info: VersionInfo): string {
|
||||
return JSON.stringify(info, null, 2)
|
||||
}
|
||||
104
src/cli/get-local-version/index.ts
Normal file
104
src/cli/get-local-version/index.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { getCachedVersion, getLatestVersion, isLocalDevMode, findPluginEntry } from "../../hooks/auto-update-checker/checker"
|
||||
import type { GetLocalVersionOptions, VersionInfo } from "./types"
|
||||
import { formatVersionOutput, formatJsonOutput } from "./formatter"
|
||||
|
||||
export async function getLocalVersion(options: GetLocalVersionOptions = {}): Promise<number> {
|
||||
const directory = options.directory ?? process.cwd()
|
||||
|
||||
try {
|
||||
if (isLocalDevMode(directory)) {
|
||||
const currentVersion = getCachedVersion()
|
||||
const info: VersionInfo = {
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: true,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "local-dev",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(directory)
|
||||
if (pluginInfo?.isPinned) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion: pluginInfo.pinnedVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: true,
|
||||
pinnedVersion: pluginInfo.pinnedVersion,
|
||||
status: "pinned",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion()
|
||||
if (!currentVersion) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "unknown",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 1
|
||||
}
|
||||
|
||||
const latestVersion = await getLatestVersion()
|
||||
|
||||
if (!latestVersion) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "error",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
}
|
||||
|
||||
const isUpToDate = currentVersion === latestVersion
|
||||
const info: VersionInfo = {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpToDate,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: isUpToDate ? "up-to-date" : "outdated",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
|
||||
} catch (error) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "error",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
export * from "./types"
|
||||
14
src/cli/get-local-version/types.ts
Normal file
14
src/cli/get-local-version/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface VersionInfo {
|
||||
currentVersion: string | null
|
||||
latestVersion: string | null
|
||||
isUpToDate: boolean
|
||||
isLocalDev: boolean
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
status: "up-to-date" | "outdated" | "local-dev" | "pinned" | "error" | "unknown"
|
||||
}
|
||||
|
||||
export interface GetLocalVersionOptions {
|
||||
directory?: string
|
||||
json?: boolean
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
import { Command } from "commander"
|
||||
import { install } from "./install"
|
||||
import { run } from "./run"
|
||||
import { getLocalVersion } from "./get-local-version"
|
||||
import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
||||
|
||||
const packageJson = await import("../../package.json")
|
||||
const VERSION = packageJson.version
|
||||
@@ -73,6 +75,32 @@ Unlike 'opencode run', this command waits until:
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("get-local-version")
|
||||
.description("Show current installed version and check for updates")
|
||||
.option("-d, --directory <path>", "Working directory to check config from")
|
||||
.option("--json", "Output in JSON format for scripting")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ bunx oh-my-opencode get-local-version
|
||||
$ bunx oh-my-opencode get-local-version --json
|
||||
$ bunx oh-my-opencode get-local-version --directory /path/to/project
|
||||
|
||||
This command shows:
|
||||
- Current installed version
|
||||
- Latest available version on npm
|
||||
- Whether you're up to date
|
||||
- Special modes (local dev, pinned version)
|
||||
`)
|
||||
.action(async (options) => {
|
||||
const versionOptions: GetLocalVersionOptions = {
|
||||
directory: options.directory,
|
||||
json: options.json ?? false,
|
||||
}
|
||||
const exitCode = await getLocalVersion(versionOptions)
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Show version information")
|
||||
|
||||
@@ -12,8 +12,8 @@ export async function checkCompletionConditions(ctx: RunContext): Promise<boolea
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// API errors are transient - silently continue polling
|
||||
} catch (err) {
|
||||
console.error(pc.red(`[completion] API error: ${err}`))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,13 +16,15 @@ async function* toAsyncIterable<T>(items: T[]): AsyncIterable<T> {
|
||||
}
|
||||
|
||||
describe("createEventState", () => {
|
||||
it("creates initial state with mainSessionIdle false and empty lastOutput", () => {
|
||||
it("creates initial state with correct defaults", () => {
|
||||
// #given / #when
|
||||
const state = createEventState()
|
||||
|
||||
// #then
|
||||
expect(state.mainSessionIdle).toBe(false)
|
||||
expect(state.lastOutput).toBe("")
|
||||
expect(state.lastPartText).toBe("")
|
||||
expect(state.currentTool).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,7 +39,7 @@ describe("event handling", () => {
|
||||
properties: { sessionID: "my-session" },
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([{ payload }])
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
@@ -57,7 +59,7 @@ describe("event handling", () => {
|
||||
properties: { sessionID: "other-session" },
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([{ payload }])
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
@@ -72,7 +74,11 @@ describe("event handling", () => {
|
||||
const ctx = createMockContext("my-session")
|
||||
const state: EventState = {
|
||||
mainSessionIdle: true,
|
||||
mainSessionError: false,
|
||||
lastError: null,
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
}
|
||||
|
||||
const payload: EventPayload = {
|
||||
@@ -80,7 +86,7 @@ describe("event handling", () => {
|
||||
properties: { sessionID: "my-session", status: { type: "busy" } },
|
||||
}
|
||||
|
||||
const events = toAsyncIterable([{ payload }])
|
||||
const events = toAsyncIterable([payload])
|
||||
const { processEvents } = await import("./events")
|
||||
|
||||
// #when
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import pc from "picocolors"
|
||||
import type {
|
||||
RunContext,
|
||||
EventPayload,
|
||||
SessionIdleProps,
|
||||
SessionStatusProps,
|
||||
SessionErrorProps,
|
||||
MessageUpdatedProps,
|
||||
MessagePartUpdatedProps,
|
||||
ToolExecuteProps,
|
||||
ToolResultProps,
|
||||
} from "./types"
|
||||
|
||||
export interface EventState {
|
||||
mainSessionIdle: boolean
|
||||
mainSessionError: boolean
|
||||
lastError: string | null
|
||||
lastOutput: string
|
||||
lastPartText: string
|
||||
currentTool: string | null
|
||||
}
|
||||
|
||||
export function createEventState(): EventState {
|
||||
return {
|
||||
mainSessionIdle: false,
|
||||
mainSessionError: false,
|
||||
lastError: null,
|
||||
lastOutput: "",
|
||||
lastPartText: "",
|
||||
currentTool: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +40,97 @@ export async function processEvents(
|
||||
if (ctx.abortController.signal.aborted) break
|
||||
|
||||
try {
|
||||
const payload = (event as { payload?: EventPayload }).payload
|
||||
if (!payload) continue
|
||||
const payload = event as EventPayload
|
||||
if (!payload?.type) {
|
||||
console.error(pc.dim(`[event] no type: ${JSON.stringify(event)}`))
|
||||
continue
|
||||
}
|
||||
|
||||
logEventVerbose(ctx, payload)
|
||||
|
||||
handleSessionError(ctx, payload, state)
|
||||
handleSessionIdle(ctx, payload, state)
|
||||
handleSessionStatus(ctx, payload, state)
|
||||
handleMessagePartUpdated(ctx, payload, state)
|
||||
handleMessageUpdated(ctx, payload, state)
|
||||
} catch {}
|
||||
handleToolExecute(ctx, payload, state)
|
||||
handleToolResult(ctx, payload, state)
|
||||
} catch (err) {
|
||||
console.error(pc.red(`[event error] ${err}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
const props = payload.properties as Record<string, unknown> | undefined
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID ?? info?.sessionID
|
||||
const isMainSession = sessionID === ctx.sessionID
|
||||
const sessionTag = isMainSession
|
||||
? pc.green("[MAIN]")
|
||||
: pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
|
||||
|
||||
switch (payload.type) {
|
||||
case "session.idle":
|
||||
case "session.status": {
|
||||
const status = (props?.status as { type?: string })?.type ?? "idle"
|
||||
console.error(pc.dim(`${sessionTag} ${payload.type}: ${status}`))
|
||||
break
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
const partProps = props as MessagePartUpdatedProps | undefined
|
||||
const role = partProps?.info?.role ?? "unknown"
|
||||
const part = partProps?.part
|
||||
if (part?.type === "text" && part.text) {
|
||||
const preview = part.text.slice(0, 100).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (${role}): "${preview}${part.text.length > 100 ? "..." : ""}"`)
|
||||
)
|
||||
} else if (part?.type === "tool-invocation") {
|
||||
const toolPart = part as { toolName?: string; state?: string }
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "message.updated": {
|
||||
const msgProps = props as MessageUpdatedProps | undefined
|
||||
const role = msgProps?.info?.role ?? "unknown"
|
||||
const content = msgProps?.content ?? ""
|
||||
const preview = content.slice(0, 100).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.updated (${role}): "${preview}${content.length > 100 ? "..." : ""}"`)
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "tool.execute": {
|
||||
const toolProps = props as ToolExecuteProps | undefined
|
||||
const toolName = toolProps?.name ?? "unknown"
|
||||
const input = toolProps?.input ?? {}
|
||||
const inputStr = JSON.stringify(input).slice(0, 150)
|
||||
console.error(
|
||||
pc.cyan(`${sessionTag} ⚡ TOOL.EXECUTE: ${pc.bold(toolName)}`)
|
||||
)
|
||||
console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`))
|
||||
break
|
||||
}
|
||||
|
||||
case "tool.result": {
|
||||
const resultProps = props as ToolResultProps | undefined
|
||||
const output = resultProps?.output ?? ""
|
||||
const preview = output.slice(0, 200).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.green(`${sessionTag} ✓ TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(pc.dim(`${sessionTag} ${payload.type}`))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +160,46 @@ function handleSessionStatus(
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionError(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "session.error") return
|
||||
|
||||
const props = payload.properties as SessionErrorProps | undefined
|
||||
if (props?.sessionID === ctx.sessionID) {
|
||||
state.mainSessionError = true
|
||||
state.lastError = props?.error
|
||||
? String(props.error instanceof Error ? props.error.message : props.error)
|
||||
: "Unknown error"
|
||||
console.error(pc.red(`\n[session.error] ${state.lastError}`))
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessagePartUpdated(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "message.part.updated") return
|
||||
|
||||
const props = payload.properties as MessagePartUpdatedProps | undefined
|
||||
if (props?.info?.sessionID !== ctx.sessionID) return
|
||||
if (props?.info?.role !== "assistant") return
|
||||
|
||||
const part = props.part
|
||||
if (!part) return
|
||||
|
||||
if (part.type === "text" && part.text) {
|
||||
const newText = part.text.slice(state.lastPartText.length)
|
||||
if (newText) {
|
||||
process.stdout.write(newText)
|
||||
}
|
||||
state.lastPartText = part.text
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessageUpdated(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
@@ -77,9 +214,66 @@ function handleMessageUpdated(
|
||||
const content = props.content
|
||||
if (!content || content === state.lastOutput) return
|
||||
|
||||
const newContent = content.slice(state.lastOutput.length)
|
||||
if (newContent) {
|
||||
process.stdout.write(newContent)
|
||||
if (state.lastPartText.length === 0) {
|
||||
const newContent = content.slice(state.lastOutput.length)
|
||||
if (newContent) {
|
||||
process.stdout.write(newContent)
|
||||
}
|
||||
}
|
||||
state.lastOutput = content
|
||||
}
|
||||
|
||||
function handleToolExecute(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "tool.execute") return
|
||||
|
||||
const props = payload.properties as ToolExecuteProps | undefined
|
||||
if (props?.sessionID !== ctx.sessionID) return
|
||||
|
||||
const toolName = props?.name || "unknown"
|
||||
state.currentTool = toolName
|
||||
|
||||
let inputPreview = ""
|
||||
if (props?.input) {
|
||||
const input = props.input
|
||||
if (input.command) {
|
||||
inputPreview = ` ${pc.dim(String(input.command).slice(0, 60))}`
|
||||
} else if (input.pattern) {
|
||||
inputPreview = ` ${pc.dim(String(input.pattern).slice(0, 40))}`
|
||||
} else if (input.filePath) {
|
||||
inputPreview = ` ${pc.dim(String(input.filePath))}`
|
||||
} else if (input.query) {
|
||||
inputPreview = ` ${pc.dim(String(input.query).slice(0, 40))}`
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${pc.cyan("⚡")} ${pc.bold(toolName)}${inputPreview}\n`)
|
||||
}
|
||||
|
||||
function handleToolResult(
|
||||
ctx: RunContext,
|
||||
payload: EventPayload,
|
||||
state: EventState
|
||||
): void {
|
||||
if (payload.type !== "tool.result") return
|
||||
|
||||
const props = payload.properties as ToolResultProps | undefined
|
||||
if (props?.sessionID !== ctx.sessionID) return
|
||||
|
||||
const output = props?.output || ""
|
||||
const maxLen = 200
|
||||
const preview = output.length > maxLen
|
||||
? output.slice(0, maxLen) + "..."
|
||||
: output
|
||||
|
||||
if (preview.trim()) {
|
||||
const lines = preview.split("\n").slice(0, 3)
|
||||
process.stdout.write(pc.dim(` └─ ${lines.join("\n ")}\n`))
|
||||
}
|
||||
|
||||
state.currentTool = null
|
||||
state.lastPartText = ""
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { checkCompletionConditions } from "./completion"
|
||||
import { createEventState, processEvents } from "./events"
|
||||
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000
|
||||
const DEFAULT_TIMEOUT_MS = 0
|
||||
|
||||
export async function run(options: RunOptions): Promise<number> {
|
||||
const {
|
||||
@@ -18,10 +18,15 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
console.log(pc.cyan("Starting opencode server..."))
|
||||
|
||||
const abortController = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.log(pc.yellow("\nTimeout reached. Aborting..."))
|
||||
abortController.abort()
|
||||
}, timeout)
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// timeout=0 means no timeout (run until completion)
|
||||
if (timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
console.log(pc.yellow("\nTimeout reached. Aborting..."))
|
||||
abortController.abort()
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
try {
|
||||
const { client, server } = await createOpencode({
|
||||
@@ -29,7 +34,7 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId)
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
server.close()
|
||||
}
|
||||
|
||||
@@ -82,13 +87,19 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if session errored - exit with failure if so
|
||||
if (eventState.mainSessionError) {
|
||||
console.error(pc.red(`\n\nSession ended with error: ${eventState.lastError}`))
|
||||
console.error(pc.yellow("Check if todos were completed before the error."))
|
||||
cleanup()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
if (shouldExit) {
|
||||
console.log(pc.green("\n\nAll tasks completed."))
|
||||
abortController.abort()
|
||||
await eventProcessor.catch(() => {})
|
||||
cleanup()
|
||||
return 0
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +111,7 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
throw err
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId)
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return 130
|
||||
}
|
||||
|
||||
@@ -47,3 +47,30 @@ export interface MessageUpdatedProps {
|
||||
info?: { sessionID?: string; role?: string }
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface MessagePartUpdatedProps {
|
||||
info?: { sessionID?: string; role?: string }
|
||||
part?: {
|
||||
type?: string
|
||||
text?: string
|
||||
name?: string
|
||||
input?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolExecuteProps {
|
||||
sessionID?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ToolResultProps {
|
||||
sessionID?: string
|
||||
name?: string
|
||||
output?: string
|
||||
}
|
||||
|
||||
export interface SessionErrorProps {
|
||||
sessionID?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export {
|
||||
McpNameSchema,
|
||||
AgentNameSchema,
|
||||
HookNameSchema,
|
||||
BuiltinCommandNameSchema,
|
||||
SisyphusAgentConfigSchema,
|
||||
ExperimentalConfigSchema,
|
||||
} from "./schema"
|
||||
@@ -16,6 +17,8 @@ export type {
|
||||
McpName,
|
||||
AgentName,
|
||||
HookName,
|
||||
BuiltinCommandName,
|
||||
SisyphusAgentConfig,
|
||||
ExperimentalConfig,
|
||||
DynamicContextPruningConfig,
|
||||
} from "./schema"
|
||||
|
||||
@@ -30,7 +30,7 @@ export const OverridableAgentNameSchema = z.enum([
|
||||
"build",
|
||||
"plan",
|
||||
"Sisyphus",
|
||||
"Builder-Sisyphus",
|
||||
"OpenCode-Builder",
|
||||
"Planner-Sisyphus",
|
||||
"oracle",
|
||||
"librarian",
|
||||
@@ -64,6 +64,11 @@ export const HookNameSchema = z.enum([
|
||||
"non-interactive-env",
|
||||
"interactive-bash-session",
|
||||
"empty-message-sanitizer",
|
||||
"thinking-block-validator",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
"init-deep",
|
||||
])
|
||||
|
||||
export const AgentOverrideConfigSchema = z.object({
|
||||
@@ -87,7 +92,7 @@ export const AgentOverridesSchema = z.object({
|
||||
build: AgentOverrideConfigSchema.optional(),
|
||||
plan: AgentOverrideConfigSchema.optional(),
|
||||
Sisyphus: AgentOverrideConfigSchema.optional(),
|
||||
"Builder-Sisyphus": AgentOverrideConfigSchema.optional(),
|
||||
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
|
||||
"Planner-Sisyphus": AgentOverrideConfigSchema.optional(),
|
||||
oracle: AgentOverrideConfigSchema.optional(),
|
||||
librarian: AgentOverrideConfigSchema.optional(),
|
||||
@@ -103,16 +108,58 @@ export const ClaudeCodeConfigSchema = z.object({
|
||||
skills: z.boolean().optional(),
|
||||
agents: z.boolean().optional(),
|
||||
hooks: z.boolean().optional(),
|
||||
plugins: z.boolean().optional(),
|
||||
plugins_override: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
|
||||
export const SisyphusAgentConfigSchema = z.object({
|
||||
disabled: z.boolean().optional(),
|
||||
builder_enabled: z.boolean().optional(),
|
||||
default_builder_enabled: z.boolean().optional(),
|
||||
planner_enabled: z.boolean().optional(),
|
||||
replace_build: z.boolean().optional(),
|
||||
replace_plan: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const CommentCheckerConfigSchema = z.object({
|
||||
/** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */
|
||||
custom_prompt: z.string().optional(),
|
||||
})
|
||||
|
||||
export const DynamicContextPruningConfigSchema = z.object({
|
||||
/** Enable dynamic context pruning (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Notification level: off, minimal, or detailed (default: detailed) */
|
||||
notification: z.enum(["off", "minimal", "detailed"]).default("detailed"),
|
||||
/** Turn protection - prevent pruning recent tool outputs */
|
||||
turn_protection: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
turns: z.number().min(1).max(10).default(3),
|
||||
}).optional(),
|
||||
/** Tools that should never be pruned */
|
||||
protected_tools: z.array(z.string()).default([
|
||||
"task", "todowrite", "todoread",
|
||||
"lsp_rename", "lsp_code_action_resolve",
|
||||
"session_read", "session_write", "session_search",
|
||||
]),
|
||||
/** Pruning strategies configuration */
|
||||
strategies: z.object({
|
||||
/** Remove duplicate tool calls (same tool + same args) */
|
||||
deduplication: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
}).optional(),
|
||||
/** Prune write inputs when file subsequently read */
|
||||
supersede_writes: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
/** Aggressive mode: prune any write if ANY subsequent read */
|
||||
aggressive: z.boolean().default(false),
|
||||
}).optional(),
|
||||
/** Prune errored tool inputs after N turns */
|
||||
purge_errors: z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
turns: z.number().min(1).max(20).default(5),
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export const ExperimentalConfigSchema = z.object({
|
||||
aggressive_truncation: z.boolean().optional(),
|
||||
auto_resume: z.boolean().optional(),
|
||||
@@ -122,6 +169,10 @@ export const ExperimentalConfigSchema = z.object({
|
||||
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
|
||||
/** Truncate all tool outputs, not just whitelisted tools (default: true) */
|
||||
truncate_all_tool_outputs: z.boolean().default(true),
|
||||
/** Dynamic context pruning configuration */
|
||||
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
|
||||
/** Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded (default: false) */
|
||||
dcp_for_compaction: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
@@ -129,10 +180,12 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
disabled_mcps: z.array(McpNameSchema).optional(),
|
||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||
disabled_hooks: z.array(HookNameSchema).optional(),
|
||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||
google_auth: z.boolean().optional(),
|
||||
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
|
||||
comment_checker: CommentCheckerConfigSchema.optional(),
|
||||
experimental: ExperimentalConfigSchema.optional(),
|
||||
auto_update: z.boolean().optional(),
|
||||
})
|
||||
@@ -142,7 +195,10 @@ 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 BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
|
||||
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
|
||||
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>
|
||||
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||
export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningConfigSchema>
|
||||
|
||||
export { McpNameSchema, type McpName } from "../mcp/types"
|
||||
|
||||
@@ -12,10 +12,12 @@ features/
|
||||
│ ├── manager.ts # Task lifecycle, notifications
|
||||
│ ├── manager.test.ts
|
||||
│ └── types.ts
|
||||
├── builtin-commands/ # Built-in slash command definitions
|
||||
├── claude-code-agent-loader/ # Load agents from ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # Load commands from ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # Load MCPs from .mcp.json
|
||||
│ └── env-expander.ts # ${VAR} expansion
|
||||
├── claude-code-plugin-loader/ # Load external plugins from installed_plugins.json
|
||||
├── claude-code-session-state/ # Session state persistence
|
||||
├── claude-code-skill-loader/ # Load skills from ~/.claude/skills/*/SKILL.md
|
||||
└── hook-message-injector/ # Inject messages into conversation
|
||||
|
||||
@@ -57,7 +57,7 @@ export class BackgroundManager {
|
||||
private notifications: Map<string, BackgroundTask[]>
|
||||
private client: OpencodeClient
|
||||
private directory: string
|
||||
private pollingInterval?: Timer
|
||||
private pollingInterval?: ReturnType<typeof setInterval>
|
||||
|
||||
constructor(ctx: PluginInput) {
|
||||
this.tasks = new Map()
|
||||
@@ -99,6 +99,7 @@ export class BackgroundManager {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
parentModel: input.parentModel,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
@@ -286,6 +287,7 @@ export class BackgroundManager {
|
||||
this.pollingInterval = setInterval(() => {
|
||||
this.pollRunningTasks()
|
||||
}, 2000)
|
||||
this.pollingInterval.unref()
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
@@ -295,6 +297,12 @@ export class BackgroundManager {
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.stopPolling()
|
||||
this.tasks.clear()
|
||||
this.notifications.clear()
|
||||
}
|
||||
|
||||
private notifyParentSession(task: BackgroundTask): void {
|
||||
const duration = this.formatDuration(task.startedAt, task.completedAt)
|
||||
|
||||
@@ -317,23 +325,33 @@ export class BackgroundManager {
|
||||
|
||||
log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID })
|
||||
|
||||
const taskId = task.id
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const modelContext = task.parentModel ?? prevMessage?.model
|
||||
const modelField = modelContext?.providerID && modelContext?.modelID
|
||||
? { providerID: modelContext.providerID, modelID: modelContext.modelID }
|
||||
: undefined
|
||||
|
||||
await this.client.session.prompt({
|
||||
path: { id: task.parentSessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
model: modelField,
|
||||
parts: [{ type: "text", text: message }],
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
})
|
||||
this.clearNotificationsForTask(task.id)
|
||||
this.clearNotificationsForTask(taskId)
|
||||
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
|
||||
} catch (error) {
|
||||
log("[background-agent] prompt failed:", String(error))
|
||||
} finally {
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface BackgroundTask {
|
||||
result?: string
|
||||
error?: string
|
||||
progress?: TaskProgress
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
}
|
||||
|
||||
export interface LaunchInput {
|
||||
@@ -34,4 +35,5 @@ export interface LaunchInput {
|
||||
agent: string
|
||||
parentSessionID: string
|
||||
parentMessageID: string
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
}
|
||||
|
||||
35
src/features/builtin-commands/commands.ts
Normal file
35
src/features/builtin-commands/commands.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader"
|
||||
import type { BuiltinCommandName, BuiltinCommands } from "./types"
|
||||
import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
|
||||
|
||||
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
|
||||
"init-deep": {
|
||||
description: "(builtin) Initialize hierarchical AGENTS.md knowledge base",
|
||||
template: `<command-instruction>
|
||||
${INIT_DEEP_TEMPLATE}
|
||||
</command-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`,
|
||||
argumentHint: "[--create-new] [--max-depth=N]",
|
||||
},
|
||||
}
|
||||
|
||||
export function loadBuiltinCommands(
|
||||
disabledCommands?: BuiltinCommandName[]
|
||||
): BuiltinCommands {
|
||||
const disabled = new Set(disabledCommands ?? [])
|
||||
const commands: BuiltinCommands = {}
|
||||
|
||||
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
|
||||
if (!disabled.has(name as BuiltinCommandName)) {
|
||||
commands[name] = {
|
||||
name,
|
||||
...definition,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
2
src/features/builtin-commands/index.ts
Normal file
2
src/features/builtin-commands/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./commands"
|
||||
394
src/features/builtin-commands/templates/init-deep.ts
Normal file
394
src/features/builtin-commands/templates/init-deep.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
export const INIT_DEEP_TEMPLATE = `# Initialize Deep Knowledge Base
|
||||
|
||||
Generate comprehensive AGENTS.md files across project hierarchy. Combines root-level project knowledge (gen-knowledge) with complexity-based subdirectory documentation (gen-knowledge-deep).
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`
|
||||
/init-deep # Analyze and generate hierarchical AGENTS.md
|
||||
/init-deep --create-new # Force create from scratch (ignore existing)
|
||||
/init-deep --max-depth=2 # Limit to N directory levels (default: 3)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Telegraphic Style**: Sacrifice grammar for concision ("Project uses React" → "React 18")
|
||||
- **Predict-then-Compare**: Predict standard → find actual → document ONLY deviations
|
||||
- **Hierarchy Aware**: Parent covers general, children cover specific
|
||||
- **No Redundancy**: Child AGENTS.md NEVER repeats parent content
|
||||
- **LSP-First**: Use LSP tools for accurate code intelligence when available (semantic > text search)
|
||||
|
||||
---
|
||||
|
||||
## Process
|
||||
|
||||
<critical>
|
||||
**MANDATORY: TodoWrite for ALL phases. Mark in_progress → completed in real-time.**
|
||||
</critical>
|
||||
|
||||
### Phase 0: Initialize
|
||||
|
||||
\`\`\`
|
||||
TodoWrite([
|
||||
{ id: "p1-analysis", content: "Parallel project structure & complexity analysis", status: "pending", priority: "high" },
|
||||
{ id: "p2-scoring", content: "Score directories, determine AGENTS.md locations", status: "pending", priority: "high" },
|
||||
{ id: "p3-root", content: "Generate root AGENTS.md with Predict-then-Compare", status: "pending", priority: "high" },
|
||||
{ id: "p4-subdirs", content: "Generate subdirectory AGENTS.md files in parallel", status: "pending", priority: "high" },
|
||||
{ id: "p5-review", content: "Review, deduplicate, validate all files", status: "pending", priority: "medium" }
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Parallel Project Analysis
|
||||
|
||||
**Mark "p1-analysis" as in_progress.**
|
||||
|
||||
Launch **ALL tasks simultaneously**:
|
||||
|
||||
<parallel-tasks>
|
||||
|
||||
### Structural Analysis (bash - run in parallel)
|
||||
\`\`\`bash
|
||||
# Task A: Directory depth analysis
|
||||
find . -type d -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' -not -path '*/dist/*' -not -path '*/build/*' | awk -F/ '{print NF-1}' | sort -n | uniq -c
|
||||
|
||||
# Task B: File count per directory
|
||||
find . -type f -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -30
|
||||
|
||||
# Task C: Code concentration
|
||||
find . -type f \\( -name "*.py" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.go" -o -name "*.rs" -o -name "*.java" \\) -not -path '*/node_modules/*' -not -path '*/venv/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20
|
||||
|
||||
# Task D: Existing knowledge files
|
||||
find . -type f \\( -name "AGENTS.md" -o -name "CLAUDE.md" \\) -not -path '*/node_modules/*' 2>/dev/null
|
||||
\`\`\`
|
||||
|
||||
### Context Gathering (Explore agents - background_task in parallel)
|
||||
|
||||
\`\`\`
|
||||
background_task(agent="explore", prompt="Project structure: PREDICT standard {lang} patterns → FIND package.json/pyproject.toml/go.mod → REPORT deviations only")
|
||||
|
||||
background_task(agent="explore", prompt="Entry points: PREDICT typical (main.py, index.ts) → FIND actual → REPORT non-standard organization")
|
||||
|
||||
background_task(agent="explore", prompt="Conventions: FIND .cursor/rules, .cursorrules, eslintrc, pyproject.toml → REPORT project-specific rules DIFFERENT from defaults")
|
||||
|
||||
background_task(agent="explore", prompt="Anti-patterns: FIND comments with 'DO NOT', 'NEVER', 'ALWAYS', 'LEGACY', 'DEPRECATED' → REPORT forbidden patterns")
|
||||
|
||||
background_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makefile, justfile → REPORT non-standard build/deploy patterns")
|
||||
|
||||
background_task(agent="explore", prompt="Test patterns: FIND pytest.ini, jest.config, test structure → REPORT unique testing conventions")
|
||||
\`\`\`
|
||||
|
||||
### Code Intelligence Analysis (LSP tools - run in parallel)
|
||||
|
||||
LSP provides semantic understanding beyond text search. Use for accurate code mapping.
|
||||
|
||||
\`\`\`
|
||||
# Step 1: Check LSP availability
|
||||
lsp_servers() # Verify language server is available
|
||||
|
||||
# Step 2: Analyze entry point files (run in parallel)
|
||||
# Find entry points first, then analyze each with lsp_document_symbols
|
||||
lsp_document_symbols(filePath="src/index.ts") # Main entry
|
||||
lsp_document_symbols(filePath="src/main.py") # Python entry
|
||||
lsp_document_symbols(filePath="cmd/main.go") # Go entry
|
||||
|
||||
# Step 3: Discover key symbols across workspace (run in parallel)
|
||||
lsp_workspace_symbols(filePath=".", query="class") # All classes
|
||||
lsp_workspace_symbols(filePath=".", query="interface") # All interfaces
|
||||
lsp_workspace_symbols(filePath=".", query="function") # Top-level functions
|
||||
lsp_workspace_symbols(filePath=".", query="type") # Type definitions
|
||||
|
||||
# Step 4: Analyze symbol centrality (for top 5-10 key symbols)
|
||||
# High reference count = central/important concept
|
||||
lsp_find_references(filePath="src/index.ts", line=X, character=Y) # Main export
|
||||
\`\`\`
|
||||
|
||||
#### LSP Analysis Output Format
|
||||
|
||||
\`\`\`
|
||||
CODE_INTELLIGENCE = {
|
||||
entry_points: [
|
||||
{ file: "src/index.ts", exports: ["Plugin", "createHook"], symbol_count: 12 }
|
||||
],
|
||||
key_symbols: [
|
||||
{ name: "Plugin", type: "class", file: "src/index.ts", refs: 45, role: "Central orchestrator" },
|
||||
{ name: "createHook", type: "function", file: "src/utils.ts", refs: 23, role: "Hook factory" }
|
||||
],
|
||||
module_boundaries: [
|
||||
{ dir: "src/hooks", exports: 21, imports_from: ["shared/"] },
|
||||
{ dir: "src/tools", exports: 15, imports_from: ["shared/", "hooks/"] }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
<critical>
|
||||
**LSP Fallback**: If LSP unavailable (no server installed), skip this section and rely on explore agents + AST-grep patterns.
|
||||
</critical>
|
||||
|
||||
</parallel-tasks>
|
||||
|
||||
**Collect all results. Mark "p1-analysis" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Complexity Scoring & Location Decision
|
||||
|
||||
**Mark "p2-scoring" as in_progress.**
|
||||
|
||||
### Scoring Matrix
|
||||
|
||||
| Factor | Weight | Threshold | Source |
|
||||
|--------|--------|-----------|--------|
|
||||
| File count | 3x | >20 files = high | bash |
|
||||
| Subdirectory count | 2x | >5 subdirs = high | bash |
|
||||
| Code file ratio | 2x | >70% code = high | bash |
|
||||
| Unique patterns | 1x | Has own config | explore |
|
||||
| Module boundary | 2x | Has __init__.py/index.ts | bash |
|
||||
| **Symbol density** | 2x | >30 symbols = high | LSP |
|
||||
| **Export count** | 2x | >10 exports = high | LSP |
|
||||
| **Reference centrality** | 3x | Symbols with >20 refs | LSP |
|
||||
|
||||
<lsp-scoring>
|
||||
**LSP-Enhanced Scoring** (if available):
|
||||
|
||||
\`\`\`
|
||||
For each directory in candidates:
|
||||
symbols = lsp_document_symbols(dir/index.ts or dir/__init__.py)
|
||||
|
||||
symbol_score = len(symbols) > 30 ? 6 : len(symbols) > 15 ? 3 : 0
|
||||
export_score = count(exported symbols) > 10 ? 4 : 0
|
||||
|
||||
# Check if this module is central (many things depend on it)
|
||||
for each exported symbol:
|
||||
refs = lsp_find_references(symbol)
|
||||
if refs > 20: centrality_score += 3
|
||||
|
||||
total_score += symbol_score + export_score + centrality_score
|
||||
\`\`\`
|
||||
</lsp-scoring>
|
||||
|
||||
### Decision Rules
|
||||
|
||||
| Score | Action |
|
||||
|-------|--------|
|
||||
| **Root (.)** | ALWAYS create AGENTS.md |
|
||||
| **High (>15)** | Create dedicated AGENTS.md |
|
||||
| **Medium (8-15)** | Create if distinct domain |
|
||||
| **Low (<8)** | Skip, parent sufficient |
|
||||
|
||||
### Output Format
|
||||
|
||||
\`\`\`
|
||||
AGENTS_LOCATIONS = [
|
||||
{ path: ".", type: "root" },
|
||||
{ path: "src/api", score: 18, reason: "high complexity, 45 files" },
|
||||
{ path: "src/hooks", score: 12, reason: "distinct domain, unique patterns" },
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
**Mark "p2-scoring" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Generate Root AGENTS.md
|
||||
|
||||
**Mark "p3-root" as in_progress.**
|
||||
|
||||
Root AGENTS.md gets **full treatment** with Predict-then-Compare synthesis.
|
||||
|
||||
### Required Sections
|
||||
|
||||
\`\`\`markdown
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** {TIMESTAMP}
|
||||
**Commit:** {SHORT_SHA}
|
||||
**Branch:** {BRANCH}
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
{1-2 sentences: what project does, core tech stack}
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
\\\`\\\`\\\`
|
||||
{project-root}/
|
||||
├── {dir}/ # {non-obvious purpose only}
|
||||
└── {entry} # entry point
|
||||
\\\`\\\`\\\`
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add feature X | \\\`src/x/\\\` | {pattern hint} |
|
||||
|
||||
## CODE MAP
|
||||
|
||||
{Generated from LSP analysis - shows key symbols and their relationships}
|
||||
|
||||
| Symbol | Type | Location | Refs | Role |
|
||||
|--------|------|----------|------|------|
|
||||
| {MainClass} | Class | \\\`src/index.ts\\\` | {N} | {Central orchestrator} |
|
||||
| {createX} | Function | \\\`src/utils.ts\\\` | {N} | {Factory pattern} |
|
||||
| {Config} | Interface | \\\`src/types.ts\\\` | {N} | {Configuration contract} |
|
||||
|
||||
### Module Dependencies
|
||||
|
||||
\\\`\\\`\\\`
|
||||
{entry} ──imports──> {core/}
|
||||
│ │
|
||||
└──imports──> {utils/} <──imports── {features/}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
<code-map-note>
|
||||
**Skip CODE MAP if**: LSP unavailable OR project too small (<10 files) OR no clear module boundaries.
|
||||
</code-map-note>
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
{ONLY deviations from standard - skip generic advice}
|
||||
|
||||
- **{rule}**: {specific detail}
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
|
||||
{Things explicitly forbidden HERE}
|
||||
|
||||
- **{pattern}**: {why} → {alternative}
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
{Project-specific coding styles}
|
||||
|
||||
- **{style}**: {how different}
|
||||
|
||||
## COMMANDS
|
||||
|
||||
\\\`\\\`\\\`bash
|
||||
{dev-command}
|
||||
{test-command}
|
||||
{build-command}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
## NOTES
|
||||
|
||||
{Gotchas, non-obvious info}
|
||||
\`\`\`
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] Size: 50-150 lines
|
||||
- [ ] No generic advice ("write clean code")
|
||||
- [ ] No obvious info ("tests/ has tests")
|
||||
- [ ] Every item is project-specific
|
||||
|
||||
**Mark "p3-root" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Generate Subdirectory AGENTS.md
|
||||
|
||||
**Mark "p4-subdirs" as in_progress.**
|
||||
|
||||
For each location in AGENTS_LOCATIONS (except root), launch **parallel document-writer agents**:
|
||||
|
||||
\`\`\`typescript
|
||||
for (const loc of AGENTS_LOCATIONS.filter(l => l.path !== ".")) {
|
||||
background_task({
|
||||
agent: "document-writer",
|
||||
prompt: \\\`
|
||||
Generate AGENTS.md for: \${loc.path}
|
||||
|
||||
CONTEXT:
|
||||
- Complexity reason: \${loc.reason}
|
||||
- Parent AGENTS.md: ./AGENTS.md (already covers project overview)
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Focus ONLY on this directory's specific context
|
||||
2. NEVER repeat parent AGENTS.md content
|
||||
3. Shorter is better - 30-80 lines max
|
||||
4. Telegraphic style - sacrifice grammar
|
||||
|
||||
REQUIRED SECTIONS:
|
||||
- OVERVIEW (1 line: what this directory does)
|
||||
- STRUCTURE (only if >5 subdirs)
|
||||
- WHERE TO LOOK (directory-specific tasks)
|
||||
- CONVENTIONS (only if DIFFERENT from root)
|
||||
- ANTI-PATTERNS (directory-specific only)
|
||||
|
||||
OUTPUT: Write to \${loc.path}/AGENTS.md
|
||||
\\\`
|
||||
})
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Wait for all agents. Mark "p4-subdirs" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Review & Deduplicate
|
||||
|
||||
**Mark "p5-review" as in_progress.**
|
||||
|
||||
### Validation Checklist
|
||||
|
||||
For EACH generated AGENTS.md:
|
||||
|
||||
| Check | Action if Fail |
|
||||
|-------|----------------|
|
||||
| Contains generic advice | REMOVE the line |
|
||||
| Repeats parent content | REMOVE the line |
|
||||
| Missing required section | ADD it |
|
||||
| Over 150 lines (root) / 80 lines (subdir) | TRIM |
|
||||
| Verbose explanations | REWRITE telegraphic |
|
||||
|
||||
### Cross-Reference Validation
|
||||
|
||||
\`\`\`
|
||||
For each child AGENTS.md:
|
||||
For each line in child:
|
||||
If similar line exists in parent:
|
||||
REMOVE from child (parent already covers)
|
||||
\`\`\`
|
||||
|
||||
**Mark "p5-review" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Final Report
|
||||
|
||||
\`\`\`
|
||||
=== init-deep Complete ===
|
||||
|
||||
Files Generated:
|
||||
✓ ./AGENTS.md (root, {N} lines)
|
||||
✓ ./src/hooks/AGENTS.md ({N} lines)
|
||||
✓ ./src/tools/AGENTS.md ({N} lines)
|
||||
|
||||
Directories Analyzed: {N}
|
||||
AGENTS.md Created: {N}
|
||||
Total Lines: {N}
|
||||
|
||||
Hierarchy:
|
||||
./AGENTS.md
|
||||
├── src/hooks/AGENTS.md
|
||||
└── src/tools/AGENTS.md
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns for THIS Command
|
||||
|
||||
- **Over-documenting**: Not every directory needs AGENTS.md
|
||||
- **Redundancy**: Child must NOT repeat parent
|
||||
- **Generic content**: Remove anything that applies to ALL projects
|
||||
- **Sequential execution**: MUST use parallel agents
|
||||
- **Deep nesting**: Rarely need AGENTS.md at depth 4+
|
||||
- **Verbose style**: "This directory contains..." → just list it
|
||||
- **Ignoring LSP**: If LSP available, USE IT - semantic analysis > text grep
|
||||
- **LSP without fallback**: Always have explore agent backup if LSP unavailable
|
||||
- **Over-referencing**: Don't trace refs for EVERY symbol - focus on exports only`
|
||||
9
src/features/builtin-commands/types.ts
Normal file
9
src/features/builtin-commands/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader"
|
||||
|
||||
export type BuiltinCommandName = "init-deep"
|
||||
|
||||
export interface BuiltinCommandConfig {
|
||||
disabled_commands?: BuiltinCommandName[]
|
||||
}
|
||||
|
||||
export type BuiltinCommands = Record<string, CommandDefinition>
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { getClaudeConfigDir } from "../../shared"
|
||||
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
|
||||
|
||||
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
|
||||
@@ -68,7 +68,7 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
|
||||
}
|
||||
|
||||
export function loadUserAgents(): Record<string, AgentConfig> {
|
||||
const userAgentsDir = join(homedir(), ".claude", "agents")
|
||||
const userAgentsDir = join(getClaudeConfigDir(), "agents")
|
||||
const agents = loadAgentsFromDir(userAgentsDir, "user")
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { getClaudeConfigDir } from "../../shared"
|
||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||
|
||||
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
|
||||
@@ -68,7 +68,7 @@ function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefi
|
||||
}
|
||||
|
||||
export function loadUserCommands(): Record<string, CommandDefinition> {
|
||||
const userCommandsDir = join(homedir(), ".claude", "commands")
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const commands = loadCommandsFromDir(userCommandsDir, "user")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
@@ -80,6 +80,7 @@ export function loadProjectCommands(): Record<string, CommandDefinition> {
|
||||
}
|
||||
|
||||
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
|
||||
const { homedir } = require("os")
|
||||
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
|
||||
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type {
|
||||
ClaudeCodeMcpConfig,
|
||||
LoadedMcpServer,
|
||||
@@ -16,11 +16,11 @@ interface McpConfigPath {
|
||||
}
|
||||
|
||||
function getMcpConfigPaths(): McpConfigPath[] {
|
||||
const home = homedir()
|
||||
const claudeConfigDir = getClaudeConfigDir()
|
||||
const cwd = process.cwd()
|
||||
|
||||
return [
|
||||
{ path: join(home, ".claude", ".mcp.json"), scope: "user" },
|
||||
{ path: join(claudeConfigDir, ".mcp.json"), scope: "user" },
|
||||
{ path: join(cwd, ".mcp.json"), scope: "project" },
|
||||
{ path: join(cwd, ".claude", ".mcp.json"), scope: "local" },
|
||||
]
|
||||
|
||||
3
src/features/claude-code-plugin-loader/index.ts
Normal file
3
src/features/claude-code-plugin-loader/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
export type { PluginLoaderOptions, ClaudeSettings } from "./types"
|
||||
480
src/features/claude-code-plugin-loader/loader.ts
Normal file
480
src/features/claude-code-plugin-loader/loader.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
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 { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { isMarkdownFile, resolveSymlink } from "../../shared/file-utils"
|
||||
import { log } from "../../shared/logger"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import { transformMcpServer } from "../claude-code-mcp-loader/transformer"
|
||||
import type { CommandDefinition, CommandFrontmatter } from "../claude-code-command-loader/types"
|
||||
import type { SkillMetadata } from "../claude-code-skill-loader/types"
|
||||
import type { AgentFrontmatter } from "../claude-code-agent-loader/types"
|
||||
import type { ClaudeCodeMcpConfig, McpServerConfig } from "../claude-code-mcp-loader/types"
|
||||
import type {
|
||||
InstalledPluginsDatabase,
|
||||
PluginInstallation,
|
||||
PluginManifest,
|
||||
LoadedPlugin,
|
||||
PluginLoadResult,
|
||||
PluginLoadError,
|
||||
PluginScope,
|
||||
HooksConfig,
|
||||
ClaudeSettings,
|
||||
PluginLoaderOptions,
|
||||
} from "./types"
|
||||
|
||||
const CLAUDE_PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"
|
||||
|
||||
function getPluginsBaseDir(): string {
|
||||
// Allow override for testing
|
||||
if (process.env.CLAUDE_PLUGINS_HOME) {
|
||||
return process.env.CLAUDE_PLUGINS_HOME
|
||||
}
|
||||
return join(homedir(), ".claude", "plugins")
|
||||
}
|
||||
|
||||
function getInstalledPluginsPath(): string {
|
||||
return join(getPluginsBaseDir(), "installed_plugins.json")
|
||||
}
|
||||
|
||||
function resolvePluginPath(path: string, pluginRoot: string): string {
|
||||
return path.replace(CLAUDE_PLUGIN_ROOT_VAR, pluginRoot)
|
||||
}
|
||||
|
||||
function resolvePluginPaths<T>(obj: T, pluginRoot: string): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj === "string") {
|
||||
return resolvePluginPath(obj, pluginRoot) as T
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => resolvePluginPaths(item, pluginRoot)) as T
|
||||
}
|
||||
if (typeof obj === "object") {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = resolvePluginPaths(value, pluginRoot)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
function loadInstalledPlugins(): InstalledPluginsDatabase | null {
|
||||
const dbPath = getInstalledPluginsPath()
|
||||
if (!existsSync(dbPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(dbPath, "utf-8")
|
||||
return JSON.parse(content) as InstalledPluginsDatabase
|
||||
} catch (error) {
|
||||
log("Failed to load installed plugins database", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getClaudeSettingsPath(): string {
|
||||
if (process.env.CLAUDE_SETTINGS_PATH) {
|
||||
return process.env.CLAUDE_SETTINGS_PATH
|
||||
}
|
||||
return join(homedir(), ".claude", "settings.json")
|
||||
}
|
||||
|
||||
function loadClaudeSettings(): ClaudeSettings | null {
|
||||
const settingsPath = getClaudeSettingsPath()
|
||||
if (!existsSync(settingsPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(settingsPath, "utf-8")
|
||||
return JSON.parse(content) as ClaudeSettings
|
||||
} catch (error) {
|
||||
log("Failed to load Claude settings", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function loadPluginManifest(installPath: string): PluginManifest | null {
|
||||
const manifestPath = join(installPath, ".claude-plugin", "plugin.json")
|
||||
if (!existsSync(manifestPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(manifestPath, "utf-8")
|
||||
return JSON.parse(content) as PluginManifest
|
||||
} catch (error) {
|
||||
log(`Failed to load plugin manifest from ${manifestPath}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function derivePluginNameFromKey(pluginKey: string): string {
|
||||
const atIndex = pluginKey.indexOf("@")
|
||||
if (atIndex > 0) {
|
||||
return pluginKey.substring(0, atIndex)
|
||||
}
|
||||
return pluginKey
|
||||
}
|
||||
|
||||
function isPluginEnabled(
|
||||
pluginKey: string,
|
||||
settingsEnabledPlugins: Record<string, boolean> | undefined,
|
||||
overrideEnabledPlugins: Record<string, boolean> | undefined
|
||||
): boolean {
|
||||
if (overrideEnabledPlugins && pluginKey in overrideEnabledPlugins) {
|
||||
return overrideEnabledPlugins[pluginKey]
|
||||
}
|
||||
if (settingsEnabledPlugins && pluginKey in settingsEnabledPlugins) {
|
||||
return settingsEnabledPlugins[pluginKey]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function extractPluginEntries(
|
||||
db: InstalledPluginsDatabase
|
||||
): Array<[string, PluginInstallation | undefined]> {
|
||||
if (db.version === 1) {
|
||||
return Object.entries(db.plugins).map(([key, installation]) => [key, installation])
|
||||
}
|
||||
return Object.entries(db.plugins).map(([key, installations]) => [key, installations[0]])
|
||||
}
|
||||
|
||||
export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginLoadResult {
|
||||
const db = loadInstalledPlugins()
|
||||
const settings = loadClaudeSettings()
|
||||
const plugins: LoadedPlugin[] = []
|
||||
const errors: PluginLoadError[] = []
|
||||
|
||||
if (!db || !db.plugins) {
|
||||
return { plugins, errors }
|
||||
}
|
||||
|
||||
const settingsEnabledPlugins = settings?.enabledPlugins
|
||||
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
||||
|
||||
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
||||
if (!installation) continue
|
||||
|
||||
if (!isPluginEnabled(pluginKey, settingsEnabledPlugins, overrideEnabledPlugins)) {
|
||||
log(`Plugin disabled: ${pluginKey}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const { installPath, scope, version } = installation
|
||||
|
||||
if (!existsSync(installPath)) {
|
||||
errors.push({
|
||||
pluginKey,
|
||||
installPath,
|
||||
error: "Plugin installation path does not exist",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const manifest = loadPluginManifest(installPath)
|
||||
const pluginName = manifest?.name || derivePluginNameFromKey(pluginKey)
|
||||
|
||||
const loadedPlugin: LoadedPlugin = {
|
||||
name: pluginName,
|
||||
version: version || manifest?.version || "unknown",
|
||||
scope: scope as PluginScope,
|
||||
installPath,
|
||||
pluginKey,
|
||||
manifest: manifest ?? undefined,
|
||||
}
|
||||
|
||||
if (existsSync(join(installPath, "commands"))) {
|
||||
loadedPlugin.commandsDir = join(installPath, "commands")
|
||||
}
|
||||
if (existsSync(join(installPath, "agents"))) {
|
||||
loadedPlugin.agentsDir = join(installPath, "agents")
|
||||
}
|
||||
if (existsSync(join(installPath, "skills"))) {
|
||||
loadedPlugin.skillsDir = join(installPath, "skills")
|
||||
}
|
||||
|
||||
const hooksPath = join(installPath, "hooks", "hooks.json")
|
||||
if (existsSync(hooksPath)) {
|
||||
loadedPlugin.hooksPath = hooksPath
|
||||
}
|
||||
|
||||
const mcpPath = join(installPath, ".mcp.json")
|
||||
if (existsSync(mcpPath)) {
|
||||
loadedPlugin.mcpPath = mcpPath
|
||||
}
|
||||
|
||||
plugins.push(loadedPlugin)
|
||||
log(`Discovered plugin: ${pluginName}@${version} (${scope})`, { installPath, hasManifest: !!manifest })
|
||||
}
|
||||
|
||||
return { plugins, errors }
|
||||
}
|
||||
|
||||
export function loadPluginCommands(
|
||||
plugins: LoadedPlugin[]
|
||||
): Record<string, CommandDefinition> {
|
||||
const commands: Record<string, CommandDefinition> = {}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.commandsDir || !existsSync(plugin.commandsDir)) continue
|
||||
|
||||
const entries = readdirSync(plugin.commandsDir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(plugin.commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
const namespacedName = `${plugin.name}:${commandName}`
|
||||
|
||||
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 = `(plugin: ${plugin.name}) ${data.description || ""}`
|
||||
|
||||
commands[namespacedName] = {
|
||||
name: namespacedName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
agent: data.agent,
|
||||
model: sanitizeModelField(data.model, "claude-code"),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
log(`Loaded plugin command: ${namespacedName}`, { path: commandPath })
|
||||
} catch (error) {
|
||||
log(`Failed to load plugin command: ${commandPath}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
export function loadPluginSkillsAsCommands(
|
||||
plugins: LoadedPlugin[]
|
||||
): Record<string, CommandDefinition> {
|
||||
const skills: Record<string, CommandDefinition> = {}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.skillsDir || !existsSync(plugin.skillsDir)) continue
|
||||
|
||||
const entries = readdirSync(plugin.skillsDir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const skillPath = join(plugin.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 namespacedName = `${plugin.name}:${skillName}`
|
||||
const originalDescription = data.description || ""
|
||||
const formattedDescription = `(plugin: ${plugin.name} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
${body.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
skills[namespacedName] = {
|
||||
name: namespacedName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model),
|
||||
}
|
||||
|
||||
log(`Loaded plugin skill: ${namespacedName}`, { path: resolvedPath })
|
||||
} catch (error) {
|
||||
log(`Failed to load plugin skill: ${skillPath}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function loadPluginAgents(
|
||||
plugins: LoadedPlugin[]
|
||||
): Record<string, AgentConfig> {
|
||||
const agents: Record<string, AgentConfig> = {}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue
|
||||
|
||||
const entries = readdirSync(plugin.agentsDir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const agentPath = join(plugin.agentsDir, entry.name)
|
||||
const agentName = basename(entry.name, ".md")
|
||||
const namespacedName = `${plugin.name}:${agentName}`
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
|
||||
|
||||
const name = data.name || agentName
|
||||
const originalDescription = data.description || ""
|
||||
const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}`
|
||||
|
||||
const config: AgentConfig = {
|
||||
description: formattedDescription,
|
||||
mode: "subagent",
|
||||
prompt: body.trim(),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(data.tools)
|
||||
if (toolsConfig) {
|
||||
config.tools = toolsConfig
|
||||
}
|
||||
|
||||
agents[namespacedName] = config
|
||||
log(`Loaded plugin agent: ${namespacedName}`, { path: agentPath })
|
||||
} catch (error) {
|
||||
log(`Failed to load plugin agent: ${agentPath}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
export async function loadPluginMcpServers(
|
||||
plugins: LoadedPlugin[]
|
||||
): Promise<Record<string, McpServerConfig>> {
|
||||
const servers: Record<string, McpServerConfig> = {}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.mcpPath || !existsSync(plugin.mcpPath)) continue
|
||||
|
||||
try {
|
||||
const content = await Bun.file(plugin.mcpPath).text()
|
||||
let config = JSON.parse(content) as ClaudeCodeMcpConfig
|
||||
|
||||
config = resolvePluginPaths(config, plugin.installPath)
|
||||
config = expandEnvVarsInObject(config)
|
||||
|
||||
if (!config.mcpServers) continue
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
if (serverConfig.disabled) {
|
||||
log(`Skipping disabled MCP server "${name}" from plugin ${plugin.name}`)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const transformed = transformMcpServer(name, serverConfig)
|
||||
const namespacedName = `${plugin.name}:${name}`
|
||||
servers[namespacedName] = transformed
|
||||
log(`Loaded plugin MCP server: ${namespacedName}`, { path: plugin.mcpPath })
|
||||
} catch (error) {
|
||||
log(`Failed to transform plugin MCP server "${name}"`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Failed to load plugin MCP config: ${plugin.mcpPath}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
export function loadPluginHooksConfigs(
|
||||
plugins: LoadedPlugin[]
|
||||
): HooksConfig[] {
|
||||
const configs: HooksConfig[] = []
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.hooksPath || !existsSync(plugin.hooksPath)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(plugin.hooksPath, "utf-8")
|
||||
let config = JSON.parse(content) as HooksConfig
|
||||
|
||||
config = resolvePluginPaths(config, plugin.installPath)
|
||||
|
||||
configs.push(config)
|
||||
log(`Loaded plugin hooks config from ${plugin.name}`, { path: plugin.hooksPath })
|
||||
} catch (error) {
|
||||
log(`Failed to load plugin hooks config: ${plugin.hooksPath}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return configs
|
||||
}
|
||||
|
||||
export interface PluginComponentsResult {
|
||||
commands: Record<string, CommandDefinition>
|
||||
skills: Record<string, CommandDefinition>
|
||||
agents: Record<string, AgentConfig>
|
||||
mcpServers: Record<string, McpServerConfig>
|
||||
hooksConfigs: HooksConfig[]
|
||||
plugins: LoadedPlugin[]
|
||||
errors: PluginLoadError[]
|
||||
}
|
||||
|
||||
export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise<PluginComponentsResult> {
|
||||
const { plugins, errors } = discoverInstalledPlugins(options)
|
||||
|
||||
const commands = loadPluginCommands(plugins)
|
||||
const skills = loadPluginSkillsAsCommands(plugins)
|
||||
const agents = loadPluginAgents(plugins)
|
||||
const mcpServers = await loadPluginMcpServers(plugins)
|
||||
const hooksConfigs = loadPluginHooksConfigs(plugins)
|
||||
|
||||
log(`Loaded ${plugins.length} plugins with ${Object.keys(commands).length} commands, ${Object.keys(skills).length} skills, ${Object.keys(agents).length} agents, ${Object.keys(mcpServers).length} MCP servers`)
|
||||
|
||||
return {
|
||||
commands,
|
||||
skills,
|
||||
agents,
|
||||
mcpServers,
|
||||
hooksConfigs,
|
||||
plugins,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
210
src/features/claude-code-plugin-loader/types.ts
Normal file
210
src/features/claude-code-plugin-loader/types.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Claude Code Plugin Types
|
||||
*
|
||||
* Type definitions for Claude Code plugin system compatibility.
|
||||
* Based on https://code.claude.com/docs/en/plugins-reference
|
||||
*/
|
||||
|
||||
export type PluginScope = "user" | "project" | "local" | "managed"
|
||||
|
||||
/**
|
||||
* Plugin installation entry in installed_plugins.json
|
||||
*/
|
||||
export interface PluginInstallation {
|
||||
scope: PluginScope
|
||||
installPath: string
|
||||
version: string
|
||||
installedAt: string
|
||||
lastUpdated: string
|
||||
gitCommitSha?: string
|
||||
isLocal?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed plugins database v1 (legacy)
|
||||
* plugins stored as direct objects
|
||||
*/
|
||||
export interface InstalledPluginsDatabaseV1 {
|
||||
version: 1
|
||||
plugins: Record<string, PluginInstallation>
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed plugins database v2 (current)
|
||||
* plugins stored as arrays
|
||||
*/
|
||||
export interface InstalledPluginsDatabaseV2 {
|
||||
version: 2
|
||||
plugins: Record<string, PluginInstallation[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed plugins database structure
|
||||
* Located at ~/.claude/plugins/installed_plugins.json
|
||||
*/
|
||||
export type InstalledPluginsDatabase = InstalledPluginsDatabaseV1 | InstalledPluginsDatabaseV2
|
||||
|
||||
/**
|
||||
* Plugin author information
|
||||
*/
|
||||
export interface PluginAuthor {
|
||||
name?: string
|
||||
email?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin manifest (plugin.json)
|
||||
* Located at <plugin_root>/.claude-plugin/plugin.json
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
name: string
|
||||
version?: string
|
||||
description?: string
|
||||
author?: PluginAuthor
|
||||
homepage?: string
|
||||
repository?: string
|
||||
license?: string
|
||||
keywords?: string[]
|
||||
|
||||
// Component paths (can be string or array)
|
||||
commands?: string | string[]
|
||||
agents?: string | string[]
|
||||
skills?: string | string[]
|
||||
hooks?: string | HooksConfig
|
||||
mcpServers?: string | McpServersConfig
|
||||
lspServers?: string | LspServersConfig
|
||||
outputStyles?: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hooks configuration
|
||||
*/
|
||||
export interface HookEntry {
|
||||
type: "command" | "prompt" | "agent"
|
||||
command?: string
|
||||
prompt?: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export interface HookMatcher {
|
||||
matcher?: string
|
||||
hooks: HookEntry[]
|
||||
}
|
||||
|
||||
export interface HooksConfig {
|
||||
hooks?: {
|
||||
PreToolUse?: HookMatcher[]
|
||||
PostToolUse?: HookMatcher[]
|
||||
PostToolUseFailure?: HookMatcher[]
|
||||
PermissionRequest?: HookMatcher[]
|
||||
UserPromptSubmit?: HookMatcher[]
|
||||
Notification?: HookMatcher[]
|
||||
Stop?: HookMatcher[]
|
||||
SubagentStart?: HookMatcher[]
|
||||
SubagentStop?: HookMatcher[]
|
||||
SessionStart?: HookMatcher[]
|
||||
SessionEnd?: HookMatcher[]
|
||||
PreCompact?: HookMatcher[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP servers configuration in plugin
|
||||
*/
|
||||
export interface PluginMcpServer {
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
cwd?: string
|
||||
url?: string
|
||||
type?: "stdio" | "http" | "sse"
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface McpServersConfig {
|
||||
mcpServers?: Record<string, PluginMcpServer>
|
||||
}
|
||||
|
||||
/**
|
||||
* LSP server configuration
|
||||
*/
|
||||
export interface LspServerConfig {
|
||||
command: string
|
||||
args?: string[]
|
||||
extensionToLanguage: Record<string, string>
|
||||
transport?: "stdio" | "socket"
|
||||
env?: Record<string, string>
|
||||
initializationOptions?: Record<string, unknown>
|
||||
settings?: Record<string, unknown>
|
||||
workspaceFolder?: string
|
||||
startupTimeout?: number
|
||||
shutdownTimeout?: number
|
||||
restartOnCrash?: boolean
|
||||
maxRestarts?: number
|
||||
loggingConfig?: {
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export interface LspServersConfig {
|
||||
[language: string]: LspServerConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded plugin with all resolved components
|
||||
*/
|
||||
export interface LoadedPlugin {
|
||||
name: string
|
||||
version: string
|
||||
scope: PluginScope
|
||||
installPath: string
|
||||
manifest?: PluginManifest
|
||||
pluginKey: string
|
||||
|
||||
// Resolved paths for components
|
||||
commandsDir?: string
|
||||
agentsDir?: string
|
||||
skillsDir?: string
|
||||
hooksPath?: string
|
||||
mcpPath?: string
|
||||
lspPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin load result with all components
|
||||
*/
|
||||
export interface PluginLoadResult {
|
||||
plugins: LoadedPlugin[]
|
||||
errors: PluginLoadError[]
|
||||
}
|
||||
|
||||
export interface PluginLoadError {
|
||||
pluginKey: string
|
||||
installPath: string
|
||||
error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude settings from ~/.claude/settings.json
|
||||
*/
|
||||
export interface ClaudeSettings {
|
||||
enabledPlugins?: Record<string, boolean>
|
||||
// Other settings we don't use
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin loader options
|
||||
*/
|
||||
export interface PluginLoaderOptions {
|
||||
/**
|
||||
* Override enabled plugins from oh-my-opencode config.
|
||||
* Key format: "pluginName@marketplace" (e.g., "shell-scripting@claude-code-workflows")
|
||||
* Value: true = enabled, false = disabled
|
||||
*
|
||||
* This takes precedence over ~/.claude/settings.json enabledPlugins
|
||||
*/
|
||||
enabledPluginsOverride?: Record<string, boolean>
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { getClaudeConfigDir } from "../../shared"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types"
|
||||
|
||||
@@ -68,7 +68,7 @@ $ARGUMENTS
|
||||
}
|
||||
|
||||
export function loadUserSkillsAsCommands(): Record<string, CommandDefinition> {
|
||||
const userSkillsDir = join(homedir(), ".claude", "skills")
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
const skills = loadSkillsFromDir(userSkillsDir, "user")
|
||||
return skills.reduce((acc, skill) => {
|
||||
acc[skill.name] = skill.definition
|
||||
|
||||
@@ -27,6 +27,7 @@ hooks/
|
||||
├── rules-injector/ # Conditional rules from .claude/rules/
|
||||
├── session-recovery/ # Recover from session errors
|
||||
├── think-mode/ # Auto-detect thinking triggers
|
||||
├── thinking-block-validator/ # Validate thinking blocks in messages
|
||||
├── context-window-monitor.ts # Monitor context usage (standalone)
|
||||
├── empty-task-response-detector.ts
|
||||
├── session-notification.ts # OS notify on idle (standalone)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const AGENT_USAGE_REMINDER_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"agent-usage-reminder",
|
||||
|
||||
261
src/hooks/anthropic-auto-compact/executor.test.ts
Normal file
261
src/hooks/anthropic-auto-compact/executor.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { executeCompact } from "./executor"
|
||||
import type { AutoCompactState } from "./types"
|
||||
|
||||
describe("executeCompact lock management", () => {
|
||||
let autoCompactState: AutoCompactState
|
||||
let mockClient: any
|
||||
const sessionID = "test-session-123"
|
||||
const directory = "/test/dir"
|
||||
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
|
||||
beforeEach(() => {
|
||||
// #given: Fresh state for each test
|
||||
autoCompactState = {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map(),
|
||||
retryStateBySession: new Map(),
|
||||
fallbackStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
dcpStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
compactionInProgress: new Set<string>(),
|
||||
}
|
||||
|
||||
mockClient = {
|
||||
session: {
|
||||
messages: mock(() => Promise.resolve({ data: [] })),
|
||||
summarize: mock(() => Promise.resolve()),
|
||||
revert: mock(() => Promise.resolve()),
|
||||
prompt_async: mock(() => Promise.resolve()),
|
||||
},
|
||||
tui: {
|
||||
showToast: mock(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
test("clears lock on successful summarize completion", async () => {
|
||||
// #given: Valid session with providerID/modelID
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction successfully
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when summarize throws exception", async () => {
|
||||
// #given: Summarize will fail
|
||||
mockClient.session.summarize = mock(() =>
|
||||
Promise.reject(new Error("Network timeout")),
|
||||
)
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should still be cleared despite exception
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when revert throws exception", async () => {
|
||||
// #given: Force revert path by exhausting retry attempts and making revert fail
|
||||
mockClient.session.revert = mock(() =>
|
||||
Promise.reject(new Error("Revert failed")),
|
||||
)
|
||||
mockClient.session.messages = mock(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{ info: { id: "msg1", role: "user" } },
|
||||
{ info: { id: "msg2", role: "assistant" } },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// Exhaust retry attempts
|
||||
autoCompactState.retryStateBySession.set(sessionID, {
|
||||
attempt: 5,
|
||||
lastAttemptTime: Date.now(),
|
||||
})
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock cleared even though revert failed
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("shows toast when lock already held", async () => {
|
||||
// #given: Lock already held
|
||||
autoCompactState.compactionInProgress.add(sessionID)
|
||||
|
||||
// #when: Try to execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Toast should be shown with warning message
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
title: "Compact In Progress",
|
||||
message: expect.stringContaining("Recovery already running"),
|
||||
variant: "warning",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// #then: compactionInProgress should still have the lock
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("clears lock when fixEmptyMessages path executes", async () => {
|
||||
// #given: Empty content error scenario
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "non-empty content required",
|
||||
messageIndex: 0,
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction (fixEmptyMessages will be called)
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when truncation is sufficient", async () => {
|
||||
// #given: Aggressive truncation scenario with sufficient truncation
|
||||
// This test verifies the early return path in aggressive truncation
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 250000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
const experimental = {
|
||||
truncate_all_tool_outputs: false,
|
||||
aggressive_truncation: true,
|
||||
}
|
||||
|
||||
// #when: Execute compaction with experimental flag
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
mockClient,
|
||||
directory,
|
||||
experimental,
|
||||
)
|
||||
|
||||
// #then: Lock should be cleared even on early return
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("prevents concurrent compaction attempts", async () => {
|
||||
// #given: Lock already held (simpler test)
|
||||
autoCompactState.compactionInProgress.add(sessionID)
|
||||
|
||||
// #when: Try to execute compaction while lock is held
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Toast should be shown
|
||||
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||
const blockedToast = toastCalls.find(
|
||||
(call: any) => call[0]?.body?.title === "Compact In Progress",
|
||||
)
|
||||
expect(blockedToast).toBeDefined()
|
||||
|
||||
// #then: Lock should still be held (not cleared by blocked attempt)
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
test("clears lock after max recovery attempts exhausted", async () => {
|
||||
// #given: All retry/revert attempts exhausted
|
||||
mockClient.session.messages = mock(() => Promise.resolve({ data: [] }))
|
||||
|
||||
// Max out all attempts
|
||||
autoCompactState.retryStateBySession.set(sessionID, {
|
||||
attempt: 5,
|
||||
lastAttemptTime: Date.now(),
|
||||
})
|
||||
autoCompactState.fallbackStateBySession.set(sessionID, {
|
||||
revertAttempt: 5,
|
||||
})
|
||||
autoCompactState.truncateStateBySession.set(sessionID, {
|
||||
truncateAttempt: 5,
|
||||
})
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Should show failure toast
|
||||
const toastCalls = (mockClient.tui.showToast as any).mock.calls
|
||||
const failureToast = toastCalls.find(
|
||||
(call: any) => call[0]?.body?.title === "Auto Compact Failed",
|
||||
)
|
||||
expect(failureToast).toBeDefined()
|
||||
|
||||
// #then: Lock should still be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when client.tui.showToast throws", async () => {
|
||||
// #given: Toast will fail (this should never happen but testing robustness)
|
||||
mockClient.tui.showToast = mock(() =>
|
||||
Promise.reject(new Error("Toast failed")),
|
||||
)
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Lock should be cleared even if toast fails
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("clears lock when prompt_async in continuation throws", async () => {
|
||||
// #given: prompt_async will fail during continuation
|
||||
mockClient.session.prompt_async = mock(() =>
|
||||
Promise.reject(new Error("Prompt failed")),
|
||||
)
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 100000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
|
||||
// #then: Lock should be cleared
|
||||
// The continuation happens in setTimeout, but lock is cleared in finally before that
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ function createAutoCompactState(): AutoCompactState {
|
||||
retryStateBySession: new Map(),
|
||||
fallbackStateBySession: new Map(),
|
||||
truncateStateBySession: new Map(),
|
||||
dcpStateBySession: new Map(),
|
||||
emptyContentAttemptBySession: new Map(),
|
||||
compactionInProgress: new Set<string>(),
|
||||
}
|
||||
@@ -36,6 +37,7 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: Anthr
|
||||
autoCompactState.retryStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.fallbackStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.dcpStateBySession.delete(sessionInfo.id)
|
||||
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
|
||||
autoCompactState.compactionInProgress.delete(sessionInfo.id)
|
||||
}
|
||||
@@ -148,6 +150,6 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: Anthr
|
||||
}
|
||||
}
|
||||
|
||||
export type { AutoCompactState, FallbackState, ParsedTokenLimitError, TruncateState } from "./types"
|
||||
export type { AutoCompactState, DcpState, FallbackState, ParsedTokenLimitError, TruncateState } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
|
||||
@@ -26,6 +26,7 @@ const TOKEN_LIMIT_KEYWORDS = [
|
||||
"context length",
|
||||
"too many tokens",
|
||||
"non-empty content",
|
||||
"invalid_request_error",
|
||||
]
|
||||
|
||||
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
|
||||
@@ -114,9 +115,10 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
|
||||
if (typeof responseBody === "string") {
|
||||
try {
|
||||
const jsonPatterns = [
|
||||
/data:\s*(\{[\s\S]*?\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
|
||||
/(\{[\s\S]*?"error"[\s\S]*?\})/,
|
||||
// Greedy match to last } for nested JSON
|
||||
/data:\s*(\{[\s\S]*\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*\})/,
|
||||
/(\{[\s\S]*"error"[\s\S]*\})/,
|
||||
]
|
||||
|
||||
for (const pattern of jsonPatterns) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { createToolSignature } from "./pruning-deduplication"
|
||||
|
||||
describe("createToolSignature", () => {
|
||||
test("creates consistent signature for same input", () => {
|
||||
const input1 = { filePath: "/foo/bar.ts", content: "hello" }
|
||||
const input2 = { content: "hello", filePath: "/foo/bar.ts" }
|
||||
|
||||
const sig1 = createToolSignature("read", input1)
|
||||
const sig2 = createToolSignature("read", input2)
|
||||
|
||||
expect(sig1).toBe(sig2)
|
||||
})
|
||||
|
||||
test("creates different signature for different input", () => {
|
||||
const input1 = { filePath: "/foo/bar.ts" }
|
||||
const input2 = { filePath: "/foo/baz.ts" }
|
||||
|
||||
const sig1 = createToolSignature("read", input1)
|
||||
const sig2 = createToolSignature("read", input2)
|
||||
|
||||
expect(sig1).not.toBe(sig2)
|
||||
})
|
||||
|
||||
test("includes tool name in signature", () => {
|
||||
const input = { filePath: "/foo/bar.ts" }
|
||||
|
||||
const sig1 = createToolSignature("read", input)
|
||||
const sig2 = createToolSignature("write", input)
|
||||
|
||||
expect(sig1).not.toBe(sig2)
|
||||
})
|
||||
})
|
||||
184
src/hooks/anthropic-auto-compact/pruning-deduplication.ts
Normal file
184
src/hooks/anthropic-auto-compact/pruning-deduplication.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState, ToolCallSignature } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface DeduplicationConfig {
|
||||
enabled: boolean
|
||||
protectedTools?: string[]
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
parts?: ToolPart[]
|
||||
}
|
||||
|
||||
export function createToolSignature(toolName: string, input: unknown): string {
|
||||
const sortedInput = sortObject(input)
|
||||
return `${toolName}::${JSON.stringify(sortedInput)}`
|
||||
}
|
||||
|
||||
function sortObject(obj: unknown): unknown {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj !== "object") return obj
|
||||
if (Array.isArray(obj)) return obj.map(sortObject)
|
||||
|
||||
const sorted: Record<string, unknown> = {}
|
||||
const keys = Object.keys(obj as Record<string, unknown>).sort()
|
||||
for (const key of keys) {
|
||||
sorted[key] = sortObject((obj as Record<string, unknown>)[key])
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readMessages(sessionID: string): MessagePart[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
|
||||
const messages: MessagePart[] = []
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (data.parts) {
|
||||
messages.push(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export function executeDeduplication(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: DeduplicationConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
const signatures = new Map<string, ToolCallSignature[]>()
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
currentTurn++
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||
|
||||
if (protectedTools.has(part.tool)) continue
|
||||
|
||||
if (config.protectedTools?.includes(part.tool)) continue
|
||||
|
||||
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
const signature = createToolSignature(part.tool, part.state?.input)
|
||||
|
||||
if (!signatures.has(signature)) {
|
||||
signatures.set(signature, [])
|
||||
}
|
||||
|
||||
signatures.get(signature)!.push({
|
||||
toolName: part.tool,
|
||||
signature,
|
||||
callID: part.callID,
|
||||
turn: currentTurn,
|
||||
})
|
||||
|
||||
if (!state.toolSignatures.has(signature)) {
|
||||
state.toolSignatures.set(signature, [])
|
||||
}
|
||||
state.toolSignatures.get(signature)!.push({
|
||||
toolName: part.tool,
|
||||
signature,
|
||||
callID: part.callID,
|
||||
turn: currentTurn,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let prunedCount = 0
|
||||
let tokensSaved = 0
|
||||
|
||||
for (const [signature, calls] of signatures) {
|
||||
if (calls.length > 1) {
|
||||
const toPrune = calls.slice(0, -1)
|
||||
|
||||
for (const call of toPrune) {
|
||||
state.toolIdsToPrune.add(call.callID)
|
||||
prunedCount++
|
||||
|
||||
const output = findToolOutput(messages, call.callID)
|
||||
if (output) {
|
||||
tokensSaved += estimateTokens(output)
|
||||
}
|
||||
|
||||
log("[pruning-deduplication] pruned duplicate", {
|
||||
tool: call.toolName,
|
||||
callID: call.callID,
|
||||
turn: call.turn,
|
||||
signature: signature.substring(0, 100),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[pruning-deduplication] complete", {
|
||||
prunedCount,
|
||||
tokensSaved,
|
||||
uniqueSignatures: signatures.size,
|
||||
})
|
||||
|
||||
return prunedCount
|
||||
}
|
||||
|
||||
function findToolOutput(messages: MessagePart[], callID: string): string | null {
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.callID === callID && part.state?.output) {
|
||||
return part.state.output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
126
src/hooks/anthropic-auto-compact/pruning-executor.ts
Normal file
126
src/hooks/anthropic-auto-compact/pruning-executor.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { DynamicContextPruningConfig } from "../../config"
|
||||
import type { PruningState, PruningResult } from "./pruning-types"
|
||||
import { executeDeduplication } from "./pruning-deduplication"
|
||||
import { executeSupersedeWrites } from "./pruning-supersede"
|
||||
import { executePurgeErrors } from "./pruning-purge-errors"
|
||||
import { applyPruning } from "./pruning-storage"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const DEFAULT_PROTECTED_TOOLS = new Set([
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
"session_read",
|
||||
"session_write",
|
||||
"session_search",
|
||||
])
|
||||
|
||||
function createPruningState(): PruningState {
|
||||
return {
|
||||
toolIdsToPrune: new Set<string>(),
|
||||
currentTurn: 0,
|
||||
fileOperations: new Map(),
|
||||
toolSignatures: new Map(),
|
||||
erroredTools: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDynamicContextPruning(
|
||||
sessionID: string,
|
||||
config: DynamicContextPruningConfig,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any
|
||||
): Promise<PruningResult> {
|
||||
const state = createPruningState()
|
||||
|
||||
const protectedTools = new Set([
|
||||
...DEFAULT_PROTECTED_TOOLS,
|
||||
...(config.protected_tools || []),
|
||||
])
|
||||
|
||||
log("[pruning-executor] starting DCP", {
|
||||
sessionID,
|
||||
notification: config.notification,
|
||||
turnProtection: config.turn_protection,
|
||||
})
|
||||
|
||||
let dedupCount = 0
|
||||
let supersedeCount = 0
|
||||
let purgeCount = 0
|
||||
|
||||
if (config.strategies?.deduplication?.enabled !== false) {
|
||||
dedupCount = executeDeduplication(
|
||||
sessionID,
|
||||
state,
|
||||
{ enabled: true },
|
||||
protectedTools
|
||||
)
|
||||
}
|
||||
|
||||
if (config.strategies?.supersede_writes?.enabled !== false) {
|
||||
supersedeCount = executeSupersedeWrites(
|
||||
sessionID,
|
||||
state,
|
||||
{
|
||||
enabled: true,
|
||||
aggressive: config.strategies?.supersede_writes?.aggressive || false,
|
||||
},
|
||||
protectedTools
|
||||
)
|
||||
}
|
||||
|
||||
if (config.strategies?.purge_errors?.enabled !== false) {
|
||||
purgeCount = executePurgeErrors(
|
||||
sessionID,
|
||||
state,
|
||||
{
|
||||
enabled: true,
|
||||
turns: config.strategies?.purge_errors?.turns || 5,
|
||||
},
|
||||
protectedTools
|
||||
)
|
||||
}
|
||||
|
||||
const totalPruned = state.toolIdsToPrune.size
|
||||
const tokensSaved = await applyPruning(sessionID, state)
|
||||
|
||||
log("[pruning-executor] DCP complete", {
|
||||
totalPruned,
|
||||
tokensSaved,
|
||||
deduplication: dedupCount,
|
||||
supersede: supersedeCount,
|
||||
purge: purgeCount,
|
||||
})
|
||||
|
||||
const result: PruningResult = {
|
||||
itemsPruned: totalPruned,
|
||||
totalTokensSaved: tokensSaved,
|
||||
strategies: {
|
||||
deduplication: dedupCount,
|
||||
supersedeWrites: supersedeCount,
|
||||
purgeErrors: purgeCount,
|
||||
},
|
||||
}
|
||||
|
||||
if (config.notification !== "off" && totalPruned > 0) {
|
||||
const message =
|
||||
config.notification === "detailed"
|
||||
? `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens). Dedup: ${dedupCount}, Supersede: ${supersedeCount}, Purge: ${purgeCount}`
|
||||
: `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens)`
|
||||
|
||||
await client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Dynamic Context Pruning",
|
||||
message,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
152
src/hooks/anthropic-auto-compact/pruning-purge-errors.ts
Normal file
152
src/hooks/anthropic-auto-compact/pruning-purge-errors.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState, ErroredToolCall } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface PurgeErrorsConfig {
|
||||
enabled: boolean
|
||||
turns: number
|
||||
protectedTools?: string[]
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
status?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
parts?: ToolPart[]
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readMessages(sessionID: string): MessagePart[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
|
||||
const messages: MessagePart[] = []
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (data.parts) {
|
||||
messages.push(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export function executePurgeErrors(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: PurgeErrorsConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
currentTurn++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.currentTurn = currentTurn
|
||||
|
||||
let turnCounter = 0
|
||||
let prunedCount = 0
|
||||
let tokensSaved = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
turnCounter++
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||
|
||||
if (protectedTools.has(part.tool)) continue
|
||||
|
||||
if (config.protectedTools?.includes(part.tool)) continue
|
||||
|
||||
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
if (part.state?.status !== "error") continue
|
||||
|
||||
const turnAge = currentTurn - turnCounter
|
||||
|
||||
if (turnAge >= config.turns) {
|
||||
state.toolIdsToPrune.add(part.callID)
|
||||
prunedCount++
|
||||
|
||||
const input = part.state.input
|
||||
if (input) {
|
||||
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||
}
|
||||
|
||||
const errorInfo: ErroredToolCall = {
|
||||
callID: part.callID,
|
||||
toolName: part.tool,
|
||||
turn: turnCounter,
|
||||
errorAge: turnAge,
|
||||
}
|
||||
|
||||
state.erroredTools.set(part.callID, errorInfo)
|
||||
|
||||
log("[pruning-purge-errors] pruned old error", {
|
||||
tool: part.tool,
|
||||
callID: part.callID,
|
||||
turn: turnCounter,
|
||||
errorAge: turnAge,
|
||||
threshold: config.turns,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[pruning-purge-errors] complete", {
|
||||
prunedCount,
|
||||
tokensSaved,
|
||||
currentTurn,
|
||||
threshold: config.turns,
|
||||
})
|
||||
|
||||
return prunedCount
|
||||
}
|
||||
101
src/hooks/anthropic-auto-compact/pruning-storage.ts
Normal file
101
src/hooks/anthropic-auto-compact/pruning-storage.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
status?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageData {
|
||||
parts?: ToolPart[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export async function applyPruning(
|
||||
sessionID: string,
|
||||
state: PruningState
|
||||
): Promise<number> {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) {
|
||||
log("[pruning-storage] message dir not found", { sessionID })
|
||||
return 0
|
||||
}
|
||||
|
||||
let totalTokensSaved = 0
|
||||
let filesModified = 0
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(messageDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const data: MessageData = JSON.parse(content)
|
||||
|
||||
if (!data.parts) continue
|
||||
|
||||
let modified = false
|
||||
|
||||
for (const part of data.parts) {
|
||||
if (part.type !== "tool" || !part.callID) continue
|
||||
|
||||
if (!state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
if (part.state?.input) {
|
||||
const inputStr = JSON.stringify(part.state.input)
|
||||
totalTokensSaved += estimateTokens(inputStr)
|
||||
part.state.input = { __pruned: true, reason: "DCP" }
|
||||
modified = true
|
||||
}
|
||||
|
||||
if (part.state?.output) {
|
||||
totalTokensSaved += estimateTokens(part.state.output)
|
||||
part.state.output = "[Content pruned by Dynamic Context Pruning]"
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8")
|
||||
filesModified++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log("[pruning-storage] error applying pruning", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
|
||||
log("[pruning-storage] applied pruning", {
|
||||
sessionID,
|
||||
filesModified,
|
||||
totalTokensSaved,
|
||||
})
|
||||
|
||||
return totalTokensSaved
|
||||
}
|
||||
212
src/hooks/anthropic-auto-compact/pruning-supersede.ts
Normal file
212
src/hooks/anthropic-auto-compact/pruning-supersede.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PruningState, FileOperation } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface SupersedeWritesConfig {
|
||||
enabled: boolean
|
||||
aggressive: boolean
|
||||
}
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
input?: unknown
|
||||
output?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
parts?: ToolPart[]
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readMessages(sessionID: string): MessagePart[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return []
|
||||
|
||||
const messages: MessagePart[] = []
|
||||
|
||||
try {
|
||||
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
if (data.parts) {
|
||||
messages.push(data)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
function extractFilePath(toolName: string, input: unknown): string | null {
|
||||
if (!input || typeof input !== "object") return null
|
||||
|
||||
const inputObj = input as Record<string, unknown>
|
||||
|
||||
if (toolName === "write" || toolName === "edit" || toolName === "read") {
|
||||
if (typeof inputObj.filePath === "string") {
|
||||
return inputObj.filePath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function executeSupersedeWrites(
|
||||
sessionID: string,
|
||||
state: PruningState,
|
||||
config: SupersedeWritesConfig,
|
||||
protectedTools: Set<string>
|
||||
): number {
|
||||
if (!config.enabled) return 0
|
||||
|
||||
const messages = readMessages(sessionID)
|
||||
const writesByFile = new Map<string, FileOperation[]>()
|
||||
const readsByFile = new Map<string, number[]>()
|
||||
|
||||
let currentTurn = 0
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "step-start") {
|
||||
currentTurn++
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||
|
||||
if (protectedTools.has(part.tool)) continue
|
||||
|
||||
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||
|
||||
const filePath = extractFilePath(part.tool, part.state?.input)
|
||||
if (!filePath) continue
|
||||
|
||||
if (part.tool === "write" || part.tool === "edit") {
|
||||
if (!writesByFile.has(filePath)) {
|
||||
writesByFile.set(filePath, [])
|
||||
}
|
||||
writesByFile.get(filePath)!.push({
|
||||
callID: part.callID,
|
||||
tool: part.tool,
|
||||
filePath,
|
||||
turn: currentTurn,
|
||||
})
|
||||
|
||||
if (!state.fileOperations.has(filePath)) {
|
||||
state.fileOperations.set(filePath, [])
|
||||
}
|
||||
state.fileOperations.get(filePath)!.push({
|
||||
callID: part.callID,
|
||||
tool: part.tool,
|
||||
filePath,
|
||||
turn: currentTurn,
|
||||
})
|
||||
} else if (part.tool === "read") {
|
||||
if (!readsByFile.has(filePath)) {
|
||||
readsByFile.set(filePath, [])
|
||||
}
|
||||
readsByFile.get(filePath)!.push(currentTurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let prunedCount = 0
|
||||
let tokensSaved = 0
|
||||
|
||||
for (const [filePath, writes] of writesByFile) {
|
||||
const reads = readsByFile.get(filePath) || []
|
||||
|
||||
if (config.aggressive) {
|
||||
for (const write of writes) {
|
||||
const superseded = reads.some(readTurn => readTurn > write.turn)
|
||||
if (superseded) {
|
||||
state.toolIdsToPrune.add(write.callID)
|
||||
prunedCount++
|
||||
|
||||
const input = findToolInput(messages, write.callID)
|
||||
if (input) {
|
||||
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||
}
|
||||
|
||||
log("[pruning-supersede] pruned superseded write", {
|
||||
tool: write.tool,
|
||||
callID: write.callID,
|
||||
turn: write.turn,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (writes.length > 1) {
|
||||
for (const write of writes.slice(0, -1)) {
|
||||
const superseded = reads.some(readTurn => readTurn > write.turn)
|
||||
if (superseded) {
|
||||
state.toolIdsToPrune.add(write.callID)
|
||||
prunedCount++
|
||||
|
||||
const input = findToolInput(messages, write.callID)
|
||||
if (input) {
|
||||
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||
}
|
||||
|
||||
log("[pruning-supersede] pruned superseded write (conservative)", {
|
||||
tool: write.tool,
|
||||
callID: write.callID,
|
||||
turn: write.turn,
|
||||
filePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[pruning-supersede] complete", {
|
||||
prunedCount,
|
||||
tokensSaved,
|
||||
filesTracked: writesByFile.size,
|
||||
mode: config.aggressive ? "aggressive" : "conservative",
|
||||
})
|
||||
|
||||
return prunedCount
|
||||
}
|
||||
|
||||
function findToolInput(messages: MessagePart[], callID: string): unknown | null {
|
||||
for (const msg of messages) {
|
||||
if (!msg.parts) continue
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type === "tool" && part.callID === callID && part.state?.input) {
|
||||
return part.state.input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
44
src/hooks/anthropic-auto-compact/pruning-types.ts
Normal file
44
src/hooks/anthropic-auto-compact/pruning-types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface ToolCallSignature {
|
||||
toolName: string
|
||||
signature: string
|
||||
callID: string
|
||||
turn: number
|
||||
}
|
||||
|
||||
export interface FileOperation {
|
||||
callID: string
|
||||
tool: string
|
||||
filePath: string
|
||||
turn: number
|
||||
}
|
||||
|
||||
export interface ErroredToolCall {
|
||||
callID: string
|
||||
toolName: string
|
||||
turn: number
|
||||
errorAge: number
|
||||
}
|
||||
|
||||
export interface PruningResult {
|
||||
itemsPruned: number
|
||||
totalTokensSaved: number
|
||||
strategies: {
|
||||
deduplication: number
|
||||
supersedeWrites: number
|
||||
purgeErrors: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface PruningState {
|
||||
toolIdsToPrune: Set<string>
|
||||
currentTurn: number
|
||||
fileOperations: Map<string, FileOperation[]>
|
||||
toolSignatures: Map<string, ToolCallSignature[]>
|
||||
erroredTools: Map<string, ErroredToolCall>
|
||||
}
|
||||
|
||||
export const CHARS_PER_TOKEN = 4
|
||||
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
||||
}
|
||||
@@ -1,19 +1,8 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { xdgData } from "xdg-basedir"
|
||||
|
||||
let OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||
|
||||
// Fix for macOS where xdg-basedir points to ~/Library/Application Support
|
||||
// but OpenCode (cli) uses ~/.local/share
|
||||
if (process.platform === "darwin" && !existsSync(OPENCODE_STORAGE)) {
|
||||
const localShare = join(homedir(), ".local", "share", "opencode", "storage")
|
||||
if (existsSync(localShare)) {
|
||||
OPENCODE_STORAGE = localShare
|
||||
}
|
||||
}
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path"
|
||||
|
||||
const OPENCODE_STORAGE = getOpenCodeStorageDir()
|
||||
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
|
||||
@@ -23,12 +23,18 @@ export interface TruncateState {
|
||||
lastTruncatedPartId?: string
|
||||
}
|
||||
|
||||
export interface DcpState {
|
||||
attempted: boolean
|
||||
itemsPruned: number
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
retryStateBySession: Map<string, RetryState>
|
||||
fallbackStateBySession: Map<string, FallbackState>
|
||||
truncateStateBySession: Map<string, TruncateState>
|
||||
dcpStateBySession: Map<string, DcpState>
|
||||
emptyContentAttemptBySession: Map<string, number>
|
||||
compactionInProgress: Set<string>
|
||||
}
|
||||
|
||||
@@ -34,12 +34,12 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
|
||||
|
||||
hasChecked = true
|
||||
|
||||
setTimeout(() => {
|
||||
setTimeout(async () => {
|
||||
const cachedVersion = getCachedVersion()
|
||||
const localDevVersion = getLocalDevVersion(ctx.directory)
|
||||
const displayVersion = localDevVersion ?? cachedVersion
|
||||
|
||||
showConfigErrorsIfAny(ctx).catch(() => {})
|
||||
await showConfigErrorsIfAny(ctx)
|
||||
|
||||
if (localDevVersion) {
|
||||
if (showStartupToast) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
|
||||
|
||||
interface RawHookMatcher {
|
||||
@@ -44,9 +44,9 @@ function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
|
||||
}
|
||||
|
||||
export function getClaudeSettingsPaths(customPath?: string): string[] {
|
||||
const home = homedir()
|
||||
const claudeConfigDir = getClaudeConfigDir()
|
||||
const paths = [
|
||||
join(home, ".claude", "settings.json"),
|
||||
join(claudeConfigDir, "settings.json"),
|
||||
join(process.cwd(), ".claude", "settings.json"),
|
||||
join(process.cwd(), ".claude", "settings.local.json"),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { join } from "path"
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { TodoFile, TodoItem, ClaudeCodeTodoItem } from "./types"
|
||||
|
||||
const TODO_DIR = join(homedir(), ".claude", "todos")
|
||||
const TODO_DIR = join(getClaudeConfigDir(), "todos")
|
||||
|
||||
export function getTodoPath(sessionId: string): string {
|
||||
return join(TODO_DIR, `${sessionId}-agent-${sessionId}.json`)
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
/**
|
||||
* 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 { tmpdir } from "os"
|
||||
import { randomUUID } from "crypto"
|
||||
import type { TranscriptEntry } from "./types"
|
||||
import { transformToolName } from "../../shared/tool-name"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
|
||||
const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts")
|
||||
const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "transcripts")
|
||||
|
||||
export function getTranscriptPath(sessionId: string): string {
|
||||
return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`)
|
||||
|
||||
@@ -142,8 +142,9 @@ export interface CheckResult {
|
||||
* Run comment-checker CLI with given input.
|
||||
* @param input Hook input to check
|
||||
* @param cliPath Optional explicit path to CLI binary
|
||||
* @param customPrompt Optional custom prompt to replace default warning message
|
||||
*/
|
||||
export async function runCommentChecker(input: HookInput, cliPath?: string): Promise<CheckResult> {
|
||||
export async function runCommentChecker(input: HookInput, cliPath?: string, customPrompt?: string): Promise<CheckResult> {
|
||||
const binaryPath = cliPath ?? resolvedCliPath ?? COMMENT_CHECKER_CLI_PATH
|
||||
|
||||
if (!binaryPath) {
|
||||
@@ -160,7 +161,12 @@ export async function runCommentChecker(input: HookInput, cliPath?: string): Pro
|
||||
debugLog("running comment-checker with input:", jsonInput.substring(0, 200))
|
||||
|
||||
try {
|
||||
const proc = spawn([binaryPath], {
|
||||
const args = [binaryPath]
|
||||
if (customPrompt) {
|
||||
args.push("--prompt", customPrompt)
|
||||
}
|
||||
|
||||
const proc = spawn(args, {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PendingCall } from "./types"
|
||||
import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli"
|
||||
import type { CommentCheckerConfig } from "../../config/schema"
|
||||
|
||||
import * as fs from "fs"
|
||||
import { existsSync } from "fs"
|
||||
@@ -20,6 +21,7 @@ const pendingCalls = new Map<string, PendingCall>()
|
||||
const PENDING_CALL_TTL = 60_000
|
||||
|
||||
let cliPathPromise: Promise<string | null> | null = null
|
||||
let cleanupIntervalStarted = false
|
||||
|
||||
function cleanupOldPendingCalls(): void {
|
||||
const now = Date.now()
|
||||
@@ -30,10 +32,13 @@ function cleanupOldPendingCalls(): void {
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(cleanupOldPendingCalls, 10_000)
|
||||
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
|
||||
debugLog("createCommentCheckerHooks called", { config })
|
||||
|
||||
export function createCommentCheckerHooks() {
|
||||
debugLog("createCommentCheckerHooks called")
|
||||
if (!cleanupIntervalStarted) {
|
||||
cleanupIntervalStarted = true
|
||||
setInterval(cleanupOldPendingCalls, 10_000)
|
||||
}
|
||||
|
||||
// Start background CLI initialization (may trigger lazy download)
|
||||
startBackgroundInit()
|
||||
@@ -123,7 +128,7 @@ export function createCommentCheckerHooks() {
|
||||
|
||||
// CLI mode only
|
||||
debugLog("using CLI:", cliPath)
|
||||
await processWithCli(input, pendingCall, output, cliPath)
|
||||
await processWithCli(input, pendingCall, output, cliPath, config?.custom_prompt)
|
||||
} catch (err) {
|
||||
debugLog("tool.execute.after failed:", err)
|
||||
}
|
||||
@@ -135,7 +140,8 @@ async function processWithCli(
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
pendingCall: PendingCall,
|
||||
output: { output: string },
|
||||
cliPath: string
|
||||
cliPath: string,
|
||||
customPrompt?: string
|
||||
): Promise<void> {
|
||||
debugLog("using CLI mode with path:", cliPath)
|
||||
|
||||
@@ -154,7 +160,7 @@ async function processWithCli(
|
||||
},
|
||||
}
|
||||
|
||||
const result = await runCommentChecker(hookInput, cliPath)
|
||||
const result = await runCommentChecker(hookInput, cliPath, customPrompt)
|
||||
|
||||
if (result.hasComments && result.message) {
|
||||
debugLog("CLI detected comments, appending message")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const AGENTS_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-agents",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
clearInjectedPaths,
|
||||
} from "./storage";
|
||||
import { AGENTS_FILENAME } from "./constants";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -39,6 +40,7 @@ interface EventInput {
|
||||
export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
const pendingBatchReads = new Map<string, string[]>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
|
||||
function getSessionCache(sessionID: string): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
@@ -73,11 +75,11 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
return found.reverse();
|
||||
}
|
||||
|
||||
function processFilePathForInjection(
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
@@ -91,7 +93,11 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentsPath, "utf-8");
|
||||
output.output += `\n\n[Directory Context: ${agentsPath}]\n${content}`;
|
||||
const { result, truncated } = await truncator.truncate(sessionID, content);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
|
||||
cache.add(agentsDir);
|
||||
} catch {}
|
||||
}
|
||||
@@ -127,7 +133,7 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (toolName === "read") {
|
||||
processFilePathForInjection(output.title, input.sessionID, output);
|
||||
await processFilePathForInjection(output.title, input.sessionID, output);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,7 +141,7 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
const filePaths = pendingBatchReads.get(input.callID);
|
||||
if (filePaths) {
|
||||
for (const filePath of filePaths) {
|
||||
processFilePathForInjection(filePath, input.sessionID, output);
|
||||
await processFilePathForInjection(filePath, input.sessionID, output);
|
||||
}
|
||||
pendingBatchReads.delete(input.callID);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const README_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-readme",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
clearInjectedPaths,
|
||||
} from "./storage";
|
||||
import { README_FILENAME } from "./constants";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -39,6 +40,7 @@ interface EventInput {
|
||||
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
const pendingBatchReads = new Map<string, string[]>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
|
||||
function getSessionCache(sessionID: string): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
@@ -73,11 +75,11 @@ export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
return found.reverse();
|
||||
}
|
||||
|
||||
function processFilePathForInjection(
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
@@ -91,7 +93,11 @@ export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
|
||||
try {
|
||||
const content = readFileSync(readmePath, "utf-8");
|
||||
output.output += `\n\n[Project README: ${readmePath}]\n${content}`;
|
||||
const { result, truncated } = await truncator.truncate(sessionID, content);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${readmePath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`;
|
||||
cache.add(readmeDir);
|
||||
} catch {}
|
||||
}
|
||||
@@ -127,7 +133,7 @@ export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (toolName === "read") {
|
||||
processFilePathForInjection(output.title, input.sessionID, output);
|
||||
await processFilePathForInjection(output.title, input.sessionID, output);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,7 +141,7 @@ export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
const filePaths = pendingBatchReads.get(input.callID);
|
||||
if (filePaths) {
|
||||
for (const filePath of filePaths) {
|
||||
processFilePathForInjection(filePath, input.sessionID, output);
|
||||
await processFilePathForInjection(filePath, input.sessionID, output);
|
||||
}
|
||||
pendingBatchReads.delete(input.callID);
|
||||
}
|
||||
|
||||
@@ -42,8 +42,13 @@ export function createEmptyMessageSanitizerHook(): MessagesTransformHook {
|
||||
"experimental.chat.messages.transform": async (_input, output) => {
|
||||
const { messages } = output
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "user") continue
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
const isLastMessage = i === messages.length - 1
|
||||
const isAssistant = message.info.role === "assistant"
|
||||
|
||||
// Skip final assistant message (allowed to be empty per API spec)
|
||||
if (isLastMessage && isAssistant) continue
|
||||
|
||||
const parts = message.parts
|
||||
|
||||
|
||||
@@ -21,3 +21,4 @@ export { createKeywordDetectorHook } from "./keyword-detector";
|
||||
export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
||||
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
||||
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const INTERACTIVE_BASH_SESSION_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"interactive-bash-session",
|
||||
|
||||
@@ -31,11 +31,23 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
3. Always Use Plan agent with gathered context to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
|
||||
## TDD (if test infrastructure exists)
|
||||
|
||||
1. Write spec (requirements)
|
||||
2. Write tests (failing)
|
||||
3. RED: tests fail
|
||||
4. Implement minimal code
|
||||
5. GREEN: tests pass
|
||||
6. Refactor if needed (must stay green)
|
||||
7. Next feature, repeat
|
||||
|
||||
## ZERO TOLERANCE FAILURES
|
||||
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
|
||||
- **NO MockUp Work**: When user asked you to do "port A", you must "port A", fully, 100%. No Extra feature, No reduced feature, no mock data, fully working 100% port.
|
||||
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
|
||||
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
|
||||
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
|
||||
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
|
||||
|
||||
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector");
|
||||
|
||||
export const PROJECT_MARKERS = [
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
loadInjectedRules,
|
||||
saveInjectedRules,
|
||||
} from "./storage";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
@@ -59,6 +60,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
{ contentHashes: Set<string>; realPaths: Set<string> }
|
||||
>();
|
||||
const pendingBatchFiles = new Map<string, string[]>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
|
||||
function getSessionCache(sessionID: string): {
|
||||
contentHashes: Set<string>;
|
||||
@@ -76,11 +78,11 @@ export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
return resolve(ctx.directory, path);
|
||||
}
|
||||
|
||||
function processFilePathForInjection(
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
@@ -125,7 +127,11 @@ export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
toInject.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
for (const rule of toInject) {
|
||||
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${rule.content}`;
|
||||
const { result, truncated } = await truncator.truncate(sessionID, rule.content);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`;
|
||||
}
|
||||
|
||||
saveInjectedRules(sessionID, cache);
|
||||
@@ -167,7 +173,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (TRACKED_TOOLS.includes(toolName)) {
|
||||
processFilePathForInjection(output.title, input.sessionID, output);
|
||||
await processFilePathForInjection(output.title, input.sessionID, output);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,7 +181,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const filePaths = pendingBatchFiles.get(input.callID);
|
||||
if (filePaths) {
|
||||
for (const filePath of filePaths) {
|
||||
processFilePathForInjection(filePath, input.sessionID, output);
|
||||
await processFilePathForInjection(filePath, input.sessionID, output);
|
||||
}
|
||||
pendingBatchFiles.delete(input.callID);
|
||||
}
|
||||
|
||||
140
src/hooks/session-notification-utils.ts
Normal file
140
src/hooks/session-notification-utils.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { spawn } from "bun"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
let notifySendPath: string | null = null
|
||||
let notifySendPromise: Promise<string | null> | null = null
|
||||
|
||||
let osascriptPath: string | null = null
|
||||
let osascriptPromise: Promise<string | null> | null = null
|
||||
|
||||
let powershellPath: string | null = null
|
||||
let powershellPromise: Promise<string | null> | null = null
|
||||
|
||||
let afplayPath: string | null = null
|
||||
let afplayPromise: Promise<string | null> | null = null
|
||||
|
||||
let paplayPath: string | null = null
|
||||
let paplayPromise: Promise<string | null> | null = null
|
||||
|
||||
let aplayPath: string | null = null
|
||||
let aplayPromise: Promise<string | null> | null = null
|
||||
|
||||
async function findCommand(commandName: string): Promise<string | null> {
|
||||
const isWindows = process.platform === "win32"
|
||||
const cmd = isWindows ? "where" : "which"
|
||||
|
||||
try {
|
||||
const proc = spawn([cmd, commandName], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
if (exitCode !== 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const path = stdout.trim().split("\n")[0]
|
||||
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
|
||||
return path
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNotifySendPath(): Promise<string | null> {
|
||||
if (notifySendPath !== null) return notifySendPath
|
||||
if (notifySendPromise) return notifySendPromise
|
||||
|
||||
notifySendPromise = (async () => {
|
||||
const path = await findCommand("notify-send")
|
||||
notifySendPath = path
|
||||
return path
|
||||
})()
|
||||
|
||||
return notifySendPromise
|
||||
}
|
||||
|
||||
export async function getOsascriptPath(): Promise<string | null> {
|
||||
if (osascriptPath !== null) return osascriptPath
|
||||
if (osascriptPromise) return osascriptPromise
|
||||
|
||||
osascriptPromise = (async () => {
|
||||
const path = await findCommand("osascript")
|
||||
osascriptPath = path
|
||||
return path
|
||||
})()
|
||||
|
||||
return osascriptPromise
|
||||
}
|
||||
|
||||
export async function getPowershellPath(): Promise<string | null> {
|
||||
if (powershellPath !== null) return powershellPath
|
||||
if (powershellPromise) return powershellPromise
|
||||
|
||||
powershellPromise = (async () => {
|
||||
const path = await findCommand("powershell")
|
||||
powershellPath = path
|
||||
return path
|
||||
})()
|
||||
|
||||
return powershellPromise
|
||||
}
|
||||
|
||||
export async function getAfplayPath(): Promise<string | null> {
|
||||
if (afplayPath !== null) return afplayPath
|
||||
if (afplayPromise) return afplayPromise
|
||||
|
||||
afplayPromise = (async () => {
|
||||
const path = await findCommand("afplay")
|
||||
afplayPath = path
|
||||
return path
|
||||
})()
|
||||
|
||||
return afplayPromise
|
||||
}
|
||||
|
||||
export async function getPaplayPath(): Promise<string | null> {
|
||||
if (paplayPath !== null) return paplayPath
|
||||
if (paplayPromise) return paplayPromise
|
||||
|
||||
paplayPromise = (async () => {
|
||||
const path = await findCommand("paplay")
|
||||
paplayPath = path
|
||||
return path
|
||||
})()
|
||||
|
||||
return paplayPromise
|
||||
}
|
||||
|
||||
export async function getAplayPath(): Promise<string | null> {
|
||||
if (aplayPath !== null) return aplayPath
|
||||
if (aplayPromise) return aplayPromise
|
||||
|
||||
aplayPromise = (async () => {
|
||||
const path = await findCommand("aplay")
|
||||
aplayPath = path
|
||||
return path
|
||||
})()
|
||||
|
||||
return aplayPromise
|
||||
}
|
||||
|
||||
export function startBackgroundCheck(platform: Platform): void {
|
||||
if (platform === "darwin") {
|
||||
getOsascriptPath().catch(() => {})
|
||||
getAfplayPath().catch(() => {})
|
||||
} else if (platform === "linux") {
|
||||
getNotifySendPath().catch(() => {})
|
||||
getPaplayPath().catch(() => {})
|
||||
getAplayPath().catch(() => {})
|
||||
} else if (platform === "win32") {
|
||||
getPowershellPath().catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
|
||||
import { createSessionNotification } from "./session-notification"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import * as utils from "./session-notification-utils"
|
||||
|
||||
describe("session-notification", () => {
|
||||
let notificationCalls: string[]
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
$: async (cmd: TemplateStringsArray | string) => {
|
||||
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
|
||||
// #given - track notification commands (osascript, notify-send, powershell)
|
||||
const cmdStr = typeof cmd === "string" ? cmd : cmd.join("")
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
}
|
||||
@@ -26,8 +30,15 @@ describe("session-notification", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// #given - reset state before each test
|
||||
notificationCalls = []
|
||||
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
||||
spyOn(utils, "getAfplayPath").mockResolvedValue("/usr/bin/afplay")
|
||||
spyOn(utils, "getPaplayPath").mockResolvedValue("/usr/bin/paplay")
|
||||
spyOn(utils, "getAplayPath").mockResolvedValue("/usr/bin/aplay")
|
||||
spyOn(utils, "startBackgroundCheck").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { platform } from "os"
|
||||
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
||||
import {
|
||||
getOsascriptPath,
|
||||
getNotifySendPath,
|
||||
getPowershellPath,
|
||||
getAfplayPath,
|
||||
getPaplayPath,
|
||||
getAplayPath,
|
||||
startBackgroundCheck,
|
||||
} from "./session-notification-utils"
|
||||
|
||||
interface Todo {
|
||||
content: string
|
||||
@@ -51,15 +60,25 @@ async function sendNotification(
|
||||
): Promise<void> {
|
||||
switch (p) {
|
||||
case "darwin": {
|
||||
const osascriptPath = await getOsascriptPath()
|
||||
if (!osascriptPath) return
|
||||
|
||||
const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await ctx.$`osascript -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`
|
||||
await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {})
|
||||
break
|
||||
}
|
||||
case "linux":
|
||||
await ctx.$`notify-send ${title} ${message} 2>/dev/null`.catch(() => {})
|
||||
case "linux": {
|
||||
const notifySendPath = await getNotifySendPath()
|
||||
if (!notifySendPath) return
|
||||
|
||||
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {})
|
||||
break
|
||||
}
|
||||
case "win32": {
|
||||
const powershellPath = await getPowershellPath()
|
||||
if (!powershellPath) return
|
||||
|
||||
const psTitle = title.replace(/'/g, "''")
|
||||
const psMessage = message.replace(/'/g, "''")
|
||||
const toastScript = `
|
||||
@@ -74,7 +93,7 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
|
||||
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
|
||||
$Notifier.Show($Toast)
|
||||
`.trim().replace(/\n/g, "; ")
|
||||
await ctx.$`powershell -Command ${toastScript}`.catch(() => {})
|
||||
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -82,17 +101,30 @@ $Notifier.Show($Toast)
|
||||
|
||||
async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Promise<void> {
|
||||
switch (p) {
|
||||
case "darwin":
|
||||
ctx.$`afplay ${soundPath}`.catch(() => {})
|
||||
case "darwin": {
|
||||
const afplayPath = await getAfplayPath()
|
||||
if (!afplayPath) return
|
||||
ctx.$`${afplayPath} ${soundPath}`.catch(() => {})
|
||||
break
|
||||
case "linux":
|
||||
ctx.$`paplay ${soundPath} 2>/dev/null`.catch(() => {
|
||||
ctx.$`aplay ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
})
|
||||
}
|
||||
case "linux": {
|
||||
const paplayPath = await getPaplayPath()
|
||||
if (paplayPath) {
|
||||
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
} else {
|
||||
const aplayPath = await getAplayPath()
|
||||
if (aplayPath) {
|
||||
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
}
|
||||
}
|
||||
break
|
||||
case "win32":
|
||||
ctx.$`powershell -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {})
|
||||
}
|
||||
case "win32": {
|
||||
const powershellPath = await getPowershellPath()
|
||||
if (!powershellPath) return
|
||||
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +146,8 @@ export function createSessionNotification(
|
||||
const currentPlatform = detectPlatform()
|
||||
const defaultSoundPath = getDefaultSoundPath(currentPlatform)
|
||||
|
||||
startBackgroundCheck(currentPlatform)
|
||||
|
||||
const mergedConfig = {
|
||||
title: "OpenCode",
|
||||
message: "Agent is ready for input",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path"
|
||||
import { xdgData } from "xdg-basedir"
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path"
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir()
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
|
||||
@@ -135,7 +135,16 @@ export function findEmptyMessageByIndex(sessionID: string, targetIndex: number):
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
// API index may differ from storage index due to system messages
|
||||
const indicesToTry = [targetIndex, targetIndex - 1, targetIndex - 2]
|
||||
const indicesToTry = [
|
||||
targetIndex,
|
||||
targetIndex - 1,
|
||||
targetIndex + 1,
|
||||
targetIndex - 2,
|
||||
targetIndex + 2,
|
||||
targetIndex - 3,
|
||||
targetIndex - 4,
|
||||
targetIndex - 5,
|
||||
]
|
||||
|
||||
for (const idx of indicesToTry) {
|
||||
if (idx < 0 || idx >= messages.length) continue
|
||||
@@ -223,6 +232,41 @@ export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent thinking content from previous assistant messages
|
||||
* Following Anthropic's recommendation to include thinking blocks from previous turns
|
||||
*/
|
||||
function findLastThinkingContent(sessionID: string, beforeMessageID: string): string {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
// Find the index of the current message
|
||||
const currentIndex = messages.findIndex(m => m.id === beforeMessageID)
|
||||
if (currentIndex === -1) return ""
|
||||
|
||||
// Search backwards through previous assistant messages
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
// Look for thinking parts in this message
|
||||
const parts = readParts(msg.id)
|
||||
for (const part of parts) {
|
||||
if (THINKING_TYPES.has(part.type)) {
|
||||
// Found thinking content - return it
|
||||
// Note: 'thinking' type uses 'thinking' property, 'reasoning' type uses 'text' property
|
||||
const thinking = (part as { thinking?: string; text?: string }).thinking
|
||||
const reasoning = (part as { thinking?: string; text?: string }).text
|
||||
const content = thinking || reasoning
|
||||
if (content && content.trim().length > 0) {
|
||||
return content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
@@ -230,13 +274,16 @@ export function prependThinkingPart(sessionID: string, messageID: string): boole
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Try to get thinking content from previous turns (Anthropic's recommendation)
|
||||
const previousThinking = findLastThinkingContent(sessionID, messageID)
|
||||
|
||||
const partId = `prt_0000000000_thinking`
|
||||
const part = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinking: previousThinking || "[Continuing from previous reasoning]",
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
|
||||
171
src/hooks/thinking-block-validator/index.ts
Normal file
171
src/hooks/thinking-block-validator/index.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Proactive Thinking Block Validator Hook
|
||||
*
|
||||
* Prevents "Expected thinking/redacted_thinking but found tool_use" errors
|
||||
* by validating and fixing message structure BEFORE sending to Anthropic API.
|
||||
*
|
||||
* This hook runs on the "experimental.chat.messages.transform" hook point,
|
||||
* which is called before messages are converted to ModelMessage format and
|
||||
* sent to the API.
|
||||
*
|
||||
* Key differences from session-recovery hook:
|
||||
* - PROACTIVE (prevents error) vs REACTIVE (fixes after error)
|
||||
* - Runs BEFORE API call vs AFTER API error
|
||||
* - User never sees the error vs User sees error then recovery
|
||||
*/
|
||||
|
||||
import type { Message, Part } from "@opencode-ai/sdk"
|
||||
|
||||
interface MessageWithParts {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagesTransformHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: Record<string, never>,
|
||||
output: { messages: MessageWithParts[] }
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model has extended thinking enabled
|
||||
* Uses patterns from think-mode/switcher.ts for consistency
|
||||
*/
|
||||
function isExtendedThinkingModel(modelID: string): boolean {
|
||||
if (!modelID) return false
|
||||
const lower = modelID.toLowerCase()
|
||||
|
||||
// Check for explicit thinking/high variants (always enabled)
|
||||
if (lower.includes("thinking") || lower.endsWith("-high")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for thinking-capable models (claude-4 family, claude-3)
|
||||
// Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts
|
||||
return (
|
||||
lower.includes("claude-sonnet-4") ||
|
||||
lower.includes("claude-opus-4") ||
|
||||
lower.includes("claude-3")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has any content parts (tool_use, text, or other non-thinking content)
|
||||
*/
|
||||
function hasContentParts(parts: Part[]): boolean {
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
return parts.some((part: Part) => {
|
||||
const type = part.type as string
|
||||
// Include tool parts and text parts (anything that's not thinking/reasoning)
|
||||
return type === "tool" || type === "tool_use" || type === "text"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message starts with a thinking/reasoning block
|
||||
*/
|
||||
function startsWithThinkingBlock(parts: Part[]): boolean {
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
const firstPart = parts[0]
|
||||
const type = firstPart.type as string
|
||||
return type === "thinking" || type === "reasoning"
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent thinking content from previous assistant messages
|
||||
*/
|
||||
function findPreviousThinkingContent(
|
||||
messages: MessageWithParts[],
|
||||
currentIndex: number
|
||||
): string {
|
||||
// Search backwards from current message
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.info.role !== "assistant") continue
|
||||
|
||||
// Look for thinking parts
|
||||
if (!msg.parts) continue
|
||||
for (const part of msg.parts) {
|
||||
const type = part.type as string
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
const thinking = (part as any).thinking || (part as any).text
|
||||
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
|
||||
return thinking
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a thinking block to a message's parts array
|
||||
*/
|
||||
function prependThinkingBlock(
|
||||
message: MessageWithParts,
|
||||
thinkingContent: string
|
||||
): void {
|
||||
if (!message.parts) {
|
||||
message.parts = []
|
||||
}
|
||||
|
||||
// Create synthetic thinking part
|
||||
const thinkingPart = {
|
||||
type: "thinking" as const,
|
||||
id: `prt_0000000000_synthetic_thinking`,
|
||||
sessionID: (message.info as any).sessionID || "",
|
||||
messageID: message.info.id,
|
||||
thinking: thinkingContent,
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
// Prepend to parts array
|
||||
message.parts.unshift(thinkingPart as unknown as Part)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and fix assistant messages that have tool_use but no thinking block
|
||||
*/
|
||||
export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||
return {
|
||||
"experimental.chat.messages.transform": async (_input, output) => {
|
||||
const { messages } = output
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the model info from the last user message
|
||||
const lastUserMessage = messages.findLast(m => m.info.role === "user")
|
||||
const modelID = (lastUserMessage?.info as any)?.modelID || ""
|
||||
|
||||
// Only process if extended thinking might be enabled
|
||||
if (!isExtendedThinkingModel(modelID)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Process all assistant messages
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
|
||||
// Only check assistant messages
|
||||
if (msg.info.role !== "assistant") continue
|
||||
|
||||
// Check if message has content parts but doesn't start with thinking
|
||||
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||
// Find thinking content from previous turns
|
||||
const previousThinking = findPreviousThinkingContent(messages, i)
|
||||
|
||||
// Prepend thinking block with content from previous turn or placeholder
|
||||
const thinkingContent = previousThinking || "[Continuing from previous reasoning]"
|
||||
|
||||
prependThinkingBlock(msg, thinkingContent)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
383
src/hooks/todo-continuation-enforcer.test.ts
Normal file
383
src/hooks/todo-continuation-enforcer.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
import { setMainSession } from "../features/claude-code-session-state"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
|
||||
describe("todo-continuation-enforcer", () => {
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string }>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
|
||||
]}),
|
||||
prompt: async (opts: any) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (opts: any) => {
|
||||
toastCalls.push({
|
||||
title: opts.body.title,
|
||||
message: opts.body.message,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
}
|
||||
|
||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||
return {
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
: [],
|
||||
} as any
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
test("should inject continuation when idle with incomplete todos", async () => {
|
||||
// #given - main session with incomplete todos
|
||||
const sessionID = "main-123"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #then - countdown toast shown
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(toastCalls[0].title).toBe("Todo Continuation")
|
||||
|
||||
// #then - after countdown, continuation injected
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
})
|
||||
|
||||
test("should not inject when all todos are complete", async () => {
|
||||
// #given - session with all todos complete
|
||||
const sessionID = "main-456"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.todo = async () => ({ data: [
|
||||
{ id: "1", content: "Task 1", status: "completed", priority: "high" },
|
||||
]})
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject when background tasks are running", async () => {
|
||||
// #given - session with running background tasks
|
||||
const sessionID = "main-789"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
backgroundManager: createMockBackgroundManager(true),
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject for non-main session", async () => {
|
||||
// #given - main session set, different session goes idle
|
||||
setMainSession("main-session")
|
||||
const otherSession = "other-session"
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - non-main session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID: otherSession } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should skip injection after recent error", async () => {
|
||||
// #given - session that just had an error
|
||||
const sessionID = "main-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID, error: new Error("test") } },
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (error cooldown)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should clear error state on user message and allow injection", async () => {
|
||||
// #given - session with error, then user clears it
|
||||
const sessionID = "main-error-clear"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user sends message (clears error immediately)
|
||||
await hook.handler({
|
||||
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (error was cleared by user message)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should cancel countdown on user message", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-cancel"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user sends message immediately (before 2s countdown)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #then - wait past countdown time and verify no injection
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should cancel countdown on assistant activity", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-assistant"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - assistant starts responding
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should cancel countdown on tool execution", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-tool"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - tool starts executing
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: { type: "tool.execute.before", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should skip injection during recovery mode", async () => {
|
||||
// #given - session in recovery mode
|
||||
const sessionID = "main-recovery"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - mark as recovering
|
||||
hook.markRecovering(sessionID)
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should inject after recovery complete", async () => {
|
||||
// #given - session was in recovery, now complete
|
||||
const sessionID = "main-recovery-done"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - mark as recovering then complete
|
||||
hook.markRecovering(sessionID)
|
||||
hook.markRecoveryComplete(sessionID)
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should cleanup on session deleted", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-delete"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - session is deleted during countdown
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (cleaned up)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should show countdown toast updates", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-toast"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(2)
|
||||
expect(toastCalls[0].message).toContain("2s")
|
||||
})
|
||||
|
||||
test("should not have 10s throttle between injections", async () => {
|
||||
// #given - new hook instance (no prior state)
|
||||
const sessionID = "main-no-throttle"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - first idle cycle completes
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - first injection happened
|
||||
expect(promptCalls.length).toBe(1)
|
||||
|
||||
// #when - immediately trigger second idle (no 10s wait needed)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - second injection also happened (no throttle blocking)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
}, { timeout: 10000 })
|
||||
})
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "../features/hook-message-injector"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { log } from "../shared/logger"
|
||||
import { isNonInteractive } from "./non-interactive-env/detector"
|
||||
|
||||
const HOOK_NAME = "todo-continuation-enforcer"
|
||||
|
||||
@@ -29,6 +28,13 @@ interface Todo {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
lastErrorAt?: number
|
||||
countdownTimer?: ReturnType<typeof setTimeout>
|
||||
countdownInterval?: ReturnType<typeof setInterval>
|
||||
isRecovering?: boolean
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
|
||||
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
||||
@@ -37,6 +43,10 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
|
||||
- Mark each task complete when finished
|
||||
- Do not stop until all tasks are done`
|
||||
|
||||
const COUNTDOWN_SECONDS = 2
|
||||
const TOAST_DURATION_MS = 900
|
||||
const ERROR_COOLDOWN_MS = 3_000
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
@@ -51,29 +61,29 @@ function getMessageDir(sessionID: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function detectInterrupt(error: unknown): boolean {
|
||||
function isAbortError(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)?.toLowerCase() ?? ""
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message.includes("abort")) return true
|
||||
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const COUNTDOWN_SECONDS = 2
|
||||
const TOAST_DURATION_MS = 900 // Slightly less than 1s so toasts don't overlap
|
||||
|
||||
interface CountdownState {
|
||||
secondsRemaining: number
|
||||
intervalId: ReturnType<typeof setInterval>
|
||||
function getIncompleteCount(todos: Todo[]): number {
|
||||
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
|
||||
}
|
||||
|
||||
export function createTodoContinuationEnforcer(
|
||||
@@ -81,19 +91,156 @@ export function createTodoContinuationEnforcer(
|
||||
options: TodoContinuationEnforcerOptions = {}
|
||||
): TodoContinuationEnforcer {
|
||||
const { backgroundManager } = options
|
||||
const remindedSessions = new Set<string>()
|
||||
const interruptedSessions = new Set<string>()
|
||||
const errorSessions = new Set<string>()
|
||||
const recoveringSessions = new Set<string>()
|
||||
const pendingCountdowns = new Map<string, CountdownState>()
|
||||
const preemptivelyInjectedSessions = new Set<string>()
|
||||
const sessions = new Map<string, SessionState>()
|
||||
|
||||
function getState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
if (!state) {
|
||||
state = {}
|
||||
sessions.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function cancelCountdown(sessionID: string): void {
|
||||
const state = sessions.get(sessionID)
|
||||
if (!state) return
|
||||
|
||||
if (state.countdownTimer) {
|
||||
clearTimeout(state.countdownTimer)
|
||||
state.countdownTimer = undefined
|
||||
}
|
||||
if (state.countdownInterval) {
|
||||
clearInterval(state.countdownInterval)
|
||||
state.countdownInterval = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup(sessionID: string): void {
|
||||
cancelCountdown(sessionID)
|
||||
sessions.delete(sessionID)
|
||||
}
|
||||
|
||||
const markRecovering = (sessionID: string): void => {
|
||||
recoveringSessions.add(sessionID)
|
||||
const state = getState(sessionID)
|
||||
state.isRecovering = true
|
||||
cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
|
||||
}
|
||||
|
||||
const markRecoveryComplete = (sessionID: string): void => {
|
||||
recoveringSessions.delete(sessionID)
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.isRecovering = false
|
||||
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
|
||||
}
|
||||
}
|
||||
|
||||
async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> {
|
||||
await ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "Todo Continuation",
|
||||
message: `Resuming in ${seconds}s... (${incompleteCount} tasks remaining)`,
|
||||
variant: "warning" as const,
|
||||
duration: TOAST_DURATION_MS,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
|
||||
const state = sessions.get(sessionID)
|
||||
|
||||
if (state?.isRecovering) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (state?.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: recent error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
|
||||
if (hasRunningBgTasks) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
let todos: Todo[] = []
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
todos = (response.data ?? response) as Todo[]
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(err) })
|
||||
return
|
||||
}
|
||||
|
||||
const freshIncompleteCount = getIncompleteCount(todos)
|
||||
if (freshIncompleteCount === 0) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
||||
|
||||
if (!hasWritePermission) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
||||
return
|
||||
}
|
||||
|
||||
const agentName = prevMessage?.agent?.toLowerCase() ?? ""
|
||||
if (agentName === "plan" || agentName === "planner-sisyphus") {
|
||||
log(`[${HOOK_NAME}] Skipped: plan mode agent`, { sessionID, agent: prevMessage?.agent })
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(sessionID: string, incompleteCount: number, total: number): void {
|
||||
const state = getState(sessionID)
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
let secondsRemaining = COUNTDOWN_SECONDS
|
||||
showCountdownToast(secondsRemaining, incompleteCount)
|
||||
|
||||
state.countdownInterval = setInterval(() => {
|
||||
secondsRemaining--
|
||||
if (secondsRemaining > 0) {
|
||||
showCountdownToast(secondsRemaining, incompleteCount)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
state.countdownTimer = setTimeout(() => {
|
||||
cancelCountdown(sessionID)
|
||||
injectContinuation(sessionID, incompleteCount, total)
|
||||
}, COUNTDOWN_SECONDS * 1000)
|
||||
|
||||
log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount })
|
||||
}
|
||||
|
||||
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
@@ -101,20 +248,13 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const isInterrupt = detectInterrupt(props?.error)
|
||||
errorSessions.add(sessionID)
|
||||
if (isInterrupt) {
|
||||
interruptedSessions.add(sessionID)
|
||||
}
|
||||
log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error })
|
||||
|
||||
const countdown = pendingCountdowns.get(sessionID)
|
||||
if (countdown) {
|
||||
clearInterval(countdown.intervalId)
|
||||
pendingCountdowns.delete(sessionID)
|
||||
}
|
||||
}
|
||||
if (!sessionID) return
|
||||
|
||||
const state = getState(sessionID)
|
||||
state.lastErrorAt = Date.now()
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort: isAbortError(props?.error) })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -122,261 +262,107 @@ export function createTodoContinuationEnforcer(
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
log(`[${HOOK_NAME}] session.idle received`, { sessionID })
|
||||
log(`[${HOOK_NAME}] session.idle`, { sessionID })
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID && sessionID !== mainSessionID) {
|
||||
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID, mainSessionID })
|
||||
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const existingCountdown = pendingCountdowns.get(sessionID)
|
||||
if (existingCountdown) {
|
||||
clearInterval(existingCountdown.intervalId)
|
||||
pendingCountdowns.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Cancelled existing countdown`, { sessionID })
|
||||
}
|
||||
const state = getState(sessionID)
|
||||
|
||||
// Check if session is in recovery mode - if so, skip entirely without clearing state
|
||||
if (recoveringSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: session in recovery mode`, { sessionID })
|
||||
if (state.isRecovering) {
|
||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
|
||||
|
||||
if (shouldBypass) {
|
||||
interruptedSessions.delete(sessionID)
|
||||
errorSessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Skipped: error/interrupt bypass`, { sessionID })
|
||||
if (state.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: recent error (cooldown)`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (remindedSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: already reminded this session`, { sessionID })
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
|
||||
if (hasRunningBgTasks) {
|
||||
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
// Check for incomplete todos BEFORE starting countdown
|
||||
let todos: Todo[] = []
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Fetching todos for session`, { sessionID })
|
||||
const response = await ctx.client.session.todo({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
todos = (response.data ?? response) as Todo[]
|
||||
log(`[${HOOK_NAME}] Todo API response`, { sessionID, todosCount: todos?.length ?? 0 })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Todo API error`, { sessionID, error: String(err) })
|
||||
log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(err) })
|
||||
return
|
||||
}
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
log(`[${HOOK_NAME}] No todos found`, { sessionID })
|
||||
log(`[${HOOK_NAME}] No todos`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const incomplete = todos.filter(
|
||||
(t) => t.status !== "completed" && t.status !== "cancelled"
|
||||
)
|
||||
|
||||
if (incomplete.length === 0) {
|
||||
log(`[${HOOK_NAME}] All todos completed`, { sessionID, total: todos.length })
|
||||
const incompleteCount = getIncompleteCount(todos)
|
||||
if (incompleteCount === 0) {
|
||||
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Found incomplete todos, starting countdown`, { sessionID, incomplete: incomplete.length, total: todos.length })
|
||||
|
||||
const showCountdownToast = async (seconds: number): Promise<void> => {
|
||||
await ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "Todo Continuation",
|
||||
message: `Resuming in ${seconds}s... (${incomplete.length} tasks remaining)`,
|
||||
variant: "warning" as const,
|
||||
duration: TOAST_DURATION_MS,
|
||||
},
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const executeAfterCountdown = async (): Promise<void> => {
|
||||
pendingCountdowns.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Countdown finished, executing continuation`, { sessionID })
|
||||
|
||||
// Re-check conditions after countdown
|
||||
if (recoveringSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Abort: session entered recovery mode during countdown`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Abort: error/interrupt occurred during countdown`, { sessionID })
|
||||
interruptedSessions.delete(sessionID)
|
||||
errorSessions.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
remindedSessions.add(sessionID)
|
||||
|
||||
try {
|
||||
// Get previous message's agent info to respect agent mode
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const agentHasWritePermission = !prevMessage?.tools || (prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
||||
if (!agentHasWritePermission) {
|
||||
log(`[${HOOK_NAME}] Skipped: previous agent lacks write permission`, { sessionID, agent: prevMessage?.agent, tools: prevMessage?.tools })
|
||||
remindedSessions.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Injecting continuation prompt`, { sessionID, agent: prevMessage?.agent })
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
|
||||
},
|
||||
],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
log(`[${HOOK_NAME}] Continuation prompt injected successfully`, { sessionID })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) })
|
||||
remindedSessions.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
let secondsRemaining = COUNTDOWN_SECONDS
|
||||
showCountdownToast(secondsRemaining).catch(() => {})
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
secondsRemaining--
|
||||
|
||||
if (secondsRemaining <= 0) {
|
||||
clearInterval(intervalId)
|
||||
pendingCountdowns.delete(sessionID)
|
||||
executeAfterCountdown()
|
||||
return
|
||||
}
|
||||
|
||||
const countdown = pendingCountdowns.get(sessionID)
|
||||
if (!countdown) {
|
||||
clearInterval(intervalId)
|
||||
return
|
||||
}
|
||||
|
||||
countdown.secondsRemaining = secondsRemaining
|
||||
showCountdownToast(secondsRemaining).catch(() => {})
|
||||
}, 1000)
|
||||
|
||||
pendingCountdowns.set(sessionID, { secondsRemaining, intervalId })
|
||||
startCountdown(sessionID, incompleteCount, todos.length)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
const finish = info?.finish as string | undefined
|
||||
log(`[${HOOK_NAME}] message.updated received`, { sessionID, role, finish })
|
||||
|
||||
if (sessionID && role === "user") {
|
||||
const countdown = pendingCountdowns.get(sessionID)
|
||||
if (countdown) {
|
||||
clearInterval(countdown.intervalId)
|
||||
pendingCountdowns.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Cancelled countdown on user message`, { sessionID })
|
||||
|
||||
if (!sessionID) return
|
||||
|
||||
if (role === "user") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastErrorAt = undefined
|
||||
}
|
||||
remindedSessions.delete(sessionID)
|
||||
preemptivelyInjectedSessions.delete(sessionID)
|
||||
cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] User message: cleared error state`, { sessionID })
|
||||
}
|
||||
|
||||
if (sessionID && role === "assistant" && finish) {
|
||||
remindedSessions.delete(sessionID)
|
||||
preemptivelyInjectedSessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Cleared reminded/preemptive state on assistant finish`, { sessionID })
|
||||
|
||||
const isTerminalFinish = finish && !["tool-calls", "unknown"].includes(finish)
|
||||
if (isTerminalFinish && isNonInteractive()) {
|
||||
log(`[${HOOK_NAME}] Terminal finish in non-interactive mode`, { sessionID, finish })
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
if (mainSessionID && sessionID !== mainSessionID) {
|
||||
log(`[${HOOK_NAME}] Skipped preemptive: not main session`, { sessionID, mainSessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (preemptivelyInjectedSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped preemptive: already injected`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (recoveringSessions.has(sessionID) || errorSessions.has(sessionID) || interruptedSessions.has(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped preemptive: session in error/recovery state`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
|
||||
: false
|
||||
|
||||
let hasIncompleteTodos = false
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
const todos = (response.data ?? response) as Todo[]
|
||||
hasIncompleteTodos = todos?.some((t) => t.status !== "completed" && t.status !== "cancelled") ?? false
|
||||
} catch {
|
||||
log(`[${HOOK_NAME}] Failed to fetch todos for preemptive check`, { sessionID })
|
||||
}
|
||||
|
||||
if (hasRunningBgTasks || hasIncompleteTodos) {
|
||||
log(`[${HOOK_NAME}] Preemptive injection needed`, { sessionID, hasRunningBgTasks, hasIncompleteTodos })
|
||||
preemptivelyInjectedSessions.add(sessionID)
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const prompt = hasRunningBgTasks
|
||||
? "[SYSTEM] Background tasks are still running. Wait for their completion before proceeding."
|
||||
: CONTINUATION_PROMPT
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
log(`[${HOOK_NAME}] Preemptive injection successful`, { sessionID })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Preemptive injection failed`, { sessionID, error: String(err) })
|
||||
preemptivelyInjectedSessions.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (role === "assistant") {
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
remindedSessions.delete(sessionInfo.id)
|
||||
interruptedSessions.delete(sessionInfo.id)
|
||||
errorSessions.delete(sessionInfo.id)
|
||||
recoveringSessions.delete(sessionInfo.id)
|
||||
preemptivelyInjectedSessions.delete(sessionInfo.id)
|
||||
|
||||
const countdown = pendingCountdowns.get(sessionInfo.id)
|
||||
if (countdown) {
|
||||
clearInterval(countdown.intervalId)
|
||||
pendingCountdowns.delete(sessionInfo.id)
|
||||
}
|
||||
cleanup(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
124
src/index.ts
124
src/index.ts
@@ -23,6 +23,7 @@ import {
|
||||
createNonInteractiveEnvHook,
|
||||
createInteractiveBashSessionHook,
|
||||
createEmptyMessageSanitizerHook,
|
||||
createThinkingBlockValidatorHook,
|
||||
} from "./hooks";
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||
import {
|
||||
@@ -31,15 +32,14 @@ import {
|
||||
loadOpencodeGlobalCommands,
|
||||
loadOpencodeProjectCommands,
|
||||
} from "./features/claude-code-command-loader";
|
||||
import {
|
||||
loadUserSkillsAsCommands,
|
||||
loadProjectSkillsAsCommands,
|
||||
} from "./features/claude-code-skill-loader";
|
||||
import { loadBuiltinCommands } from "./features/builtin-commands";
|
||||
|
||||
import {
|
||||
loadUserAgents,
|
||||
loadProjectAgents,
|
||||
} from "./features/claude-code-agent-loader";
|
||||
import { loadMcpConfigs } from "./features/claude-code-mcp-loader";
|
||||
import { loadAllPluginComponents } from "./features/claude-code-plugin-loader";
|
||||
import {
|
||||
setMainSession,
|
||||
getMainSessionID,
|
||||
@@ -48,9 +48,8 @@ import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt,
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { createBuiltinMcps } from "./mcp";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
|
||||
import { log, deepMerge, getUserConfigDir, addConfigLoadError } from "./shared";
|
||||
import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile } from "./shared";
|
||||
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
|
||||
import { BUILD_SYSTEM_PROMPT, BUILD_PERMISSION } from "./agents/build-prompt";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
@@ -117,11 +116,11 @@ function migrateConfigFile(configPath: string, rawConfig: Record<string, unknown
|
||||
return needsWrite;
|
||||
}
|
||||
|
||||
function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
|
||||
function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | null {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = JSON.parse(content);
|
||||
const rawConfig = parseJsonc<Record<string, unknown>>(content);
|
||||
|
||||
migrateConfigFile(configPath, rawConfig);
|
||||
|
||||
@@ -171,30 +170,32 @@ function mergeConfigs(
|
||||
...(override.disabled_hooks ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_commands: [
|
||||
...new Set([
|
||||
...(base.disabled_commands ?? []),
|
||||
...(override.disabled_commands ?? []),
|
||||
]),
|
||||
],
|
||||
claude_code: deepMerge(base.claude_code, override.claude_code),
|
||||
};
|
||||
}
|
||||
|
||||
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
|
||||
// User-level config path (OS-specific)
|
||||
const userConfigPath = path.join(
|
||||
getUserConfigDir(),
|
||||
"opencode",
|
||||
"oh-my-opencode.json"
|
||||
);
|
||||
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
|
||||
// User-level config path (OS-specific) - prefer .jsonc over .json
|
||||
const userBasePath = path.join(getUserConfigDir(), "opencode", "oh-my-opencode");
|
||||
const userDetected = detectConfigFile(userBasePath);
|
||||
const userConfigPath = userDetected.format !== "none" ? userDetected.path : userBasePath + ".json";
|
||||
|
||||
// Project-level config path
|
||||
const projectConfigPath = path.join(
|
||||
directory,
|
||||
".opencode",
|
||||
"oh-my-opencode.json"
|
||||
);
|
||||
// Project-level config path - prefer .jsonc over .json
|
||||
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
|
||||
const projectDetected = detectConfigFile(projectBasePath);
|
||||
const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : projectBasePath + ".json";
|
||||
|
||||
// Load user config first (base)
|
||||
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath) ?? {};
|
||||
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
|
||||
|
||||
// Override with project config
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath);
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
||||
if (projectConfig) {
|
||||
config = mergeConfigs(config, projectConfig);
|
||||
}
|
||||
@@ -210,7 +211,7 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
|
||||
}
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const pluginConfig = loadPluginConfig(ctx.directory);
|
||||
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
|
||||
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
|
||||
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
|
||||
|
||||
@@ -239,7 +240,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
: null;
|
||||
|
||||
const commentChecker = isHookEnabled("comment-checker")
|
||||
? createCommentCheckerHooks()
|
||||
? createCommentCheckerHooks(pluginConfig.comment_checker)
|
||||
: null;
|
||||
const toolOutputTruncator = isHookEnabled("tool-output-truncator")
|
||||
? createToolOutputTruncatorHook(ctx, { experimental: pluginConfig.experimental })
|
||||
@@ -293,6 +294,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const emptyMessageSanitizer = isHookEnabled("empty-message-sanitizer")
|
||||
? createEmptyMessageSanitizerHook()
|
||||
: null;
|
||||
const thinkingBlockValidator = isHookEnabled("thinking-block-validator")
|
||||
? createThinkingBlockValidatorHook()
|
||||
: null;
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
|
||||
@@ -339,6 +343,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
input: Record<string, never>,
|
||||
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await thinkingBlockValidator?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||
},
|
||||
@@ -369,6 +375,22 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
}
|
||||
}
|
||||
|
||||
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
|
||||
? await loadAllPluginComponents({
|
||||
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
||||
})
|
||||
: { commands: {}, skills: {}, agents: {}, mcpServers: {}, hooksConfigs: [], plugins: [], errors: [] };
|
||||
|
||||
if (pluginComponents.plugins.length > 0) {
|
||||
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
|
||||
plugins: pluginComponents.plugins.map(p => `${p.name}@${p.version}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (pluginComponents.errors.length > 0) {
|
||||
log(`Plugin load errors`, { errors: pluginComponents.errors });
|
||||
}
|
||||
|
||||
const builtinAgents = createBuiltinAgents(
|
||||
pluginConfig.disabled_agents,
|
||||
pluginConfig.agents,
|
||||
@@ -378,17 +400,16 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const userAgents = (pluginConfig.claude_code?.agents ?? true) ? loadUserAgents() : {};
|
||||
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
|
||||
const pluginAgents = pluginComponents.agents;
|
||||
|
||||
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
|
||||
const builderEnabled = pluginConfig.sisyphus_agent?.builder_enabled ?? false;
|
||||
const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
||||
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
|
||||
const replaceBuild = pluginConfig.sisyphus_agent?.replace_build ?? true;
|
||||
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
|
||||
|
||||
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
|
||||
// TODO: When OpenCode releases `default_agent` config option (PR #5313),
|
||||
// use `config.default_agent = "Sisyphus"` instead of demoting build/plan.
|
||||
// Tracking: https://github.com/sst/opencode/pull/5313
|
||||
// Set Sisyphus as default agent (feature added in OpenCode PR #5843)
|
||||
(config as { default_agent?: string }).default_agent = "Sisyphus";
|
||||
|
||||
const agentConfig: Record<string, unknown> = {
|
||||
Sisyphus: builtinAgents.Sisyphus,
|
||||
@@ -396,18 +417,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
if (builderEnabled) {
|
||||
const { name: _buildName, ...buildConfigWithoutName } = config.agent?.build ?? {};
|
||||
const builderSisyphusOverride = pluginConfig.agents?.["Builder-Sisyphus"];
|
||||
const builderSisyphusBase = {
|
||||
const openCodeBuilderOverride = pluginConfig.agents?.["OpenCode-Builder"];
|
||||
const openCodeBuilderBase = {
|
||||
...buildConfigWithoutName,
|
||||
prompt: BUILD_SYSTEM_PROMPT,
|
||||
permission: BUILD_PERMISSION,
|
||||
description: `${config.agent?.build?.description ?? "Build agent"} (OhMyOpenCode version)`,
|
||||
color: config.agent?.build?.color ?? "#32CD32",
|
||||
description: `${config.agent?.build?.description ?? "Build agent"} (OpenCode default)`,
|
||||
};
|
||||
|
||||
agentConfig["Builder-Sisyphus"] = builderSisyphusOverride
|
||||
? { ...builderSisyphusBase, ...builderSisyphusOverride }
|
||||
: builderSisyphusBase;
|
||||
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
|
||||
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
|
||||
: openCodeBuilderBase;
|
||||
}
|
||||
|
||||
if (plannerEnabled) {
|
||||
@@ -426,13 +444,25 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
: plannerSisyphusBase;
|
||||
}
|
||||
|
||||
// Filter out build/plan from config.agent - they'll be re-added as subagents if replaced
|
||||
const filteredConfigAgents = config.agent ?
|
||||
Object.fromEntries(
|
||||
Object.entries(config.agent).filter(([key]) => {
|
||||
if (key === "build") return false;
|
||||
if (key === "plan" && replacePlan) return false;
|
||||
return true;
|
||||
})
|
||||
) : {};
|
||||
|
||||
config.agent = {
|
||||
...agentConfig,
|
||||
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...config.agent,
|
||||
...(replaceBuild ? { build: { ...config.agent?.build, mode: "subagent" } } : {}),
|
||||
...pluginAgents,
|
||||
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
|
||||
// Demote build/plan to subagent mode when replaced
|
||||
build: { ...config.agent?.build, mode: "subagent" },
|
||||
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
|
||||
};
|
||||
} else {
|
||||
@@ -440,6 +470,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
...builtinAgents,
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...config.agent,
|
||||
};
|
||||
}
|
||||
@@ -478,28 +509,28 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
|
||||
? await loadMcpConfigs()
|
||||
: { servers: {} };
|
||||
|
||||
config.mcp = {
|
||||
...config.mcp,
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...mcpResult.servers,
|
||||
...pluginComponents.mcpServers,
|
||||
};
|
||||
|
||||
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
|
||||
const userCommands = (pluginConfig.claude_code?.commands ?? true) ? loadUserCommands() : {};
|
||||
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
||||
const systemCommands = config.command ?? {};
|
||||
const projectCommands = (pluginConfig.claude_code?.commands ?? true) ? loadProjectCommands() : {};
|
||||
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
||||
const userSkills = (pluginConfig.claude_code?.skills ?? true) ? loadUserSkillsAsCommands() : {};
|
||||
const projectSkills = (pluginConfig.claude_code?.skills ?? true) ? loadProjectSkillsAsCommands() : {};
|
||||
|
||||
config.command = {
|
||||
...builtinCommands,
|
||||
...userCommands,
|
||||
...userSkills,
|
||||
...opencodeGlobalCommands,
|
||||
...systemCommands,
|
||||
...projectCommands,
|
||||
...projectSkills,
|
||||
...opencodeProjectCommands,
|
||||
...pluginComponents.commands,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -610,6 +641,7 @@ export type {
|
||||
AgentOverrides,
|
||||
McpName,
|
||||
HookName,
|
||||
BuiltinCommandName,
|
||||
} from "./config";
|
||||
|
||||
// NOTE: Do NOT export functions from main index.ts!
|
||||
|
||||
60
src/shared/claude-config-dir.test.ts
Normal file
60
src/shared/claude-config-dir.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { getClaudeConfigDir } from "./claude-config-dir"
|
||||
|
||||
describe("getClaudeConfigDir", () => {
|
||||
let originalEnv: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.CLAUDE_CONFIG_DIR
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalEnv
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
})
|
||||
|
||||
test("returns CLAUDE_CONFIG_DIR when env var is set", () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = "/custom/claude/path"
|
||||
|
||||
const result = getClaudeConfigDir()
|
||||
|
||||
expect(result).toBe("/custom/claude/path")
|
||||
})
|
||||
|
||||
test("returns ~/.claude when env var is not set", () => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
const result = getClaudeConfigDir()
|
||||
|
||||
expect(result).toBe(join(homedir(), ".claude"))
|
||||
})
|
||||
|
||||
test("returns ~/.claude when env var is empty string", () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = ""
|
||||
|
||||
const result = getClaudeConfigDir()
|
||||
|
||||
expect(result).toBe(join(homedir(), ".claude"))
|
||||
})
|
||||
|
||||
test("handles absolute paths with trailing slash", () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = "/custom/path/"
|
||||
|
||||
const result = getClaudeConfigDir()
|
||||
|
||||
expect(result).toBe("/custom/path/")
|
||||
})
|
||||
|
||||
test("handles relative paths", () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = "./my-claude-config"
|
||||
|
||||
const result = getClaudeConfigDir()
|
||||
|
||||
expect(result).toBe("./my-claude-config")
|
||||
})
|
||||
})
|
||||
11
src/shared/claude-config-dir.ts
Normal file
11
src/shared/claude-config-dir.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
export function getClaudeConfigDir(): string {
|
||||
const envConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
if (envConfigDir) {
|
||||
return envConfigDir
|
||||
}
|
||||
|
||||
return join(homedir(), ".claude")
|
||||
}
|
||||
@@ -2,27 +2,20 @@ import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
|
||||
/**
|
||||
* Returns the user-level data directory based on the OS.
|
||||
* - Linux/macOS: XDG_DATA_HOME or ~/.local/share
|
||||
* - Windows: %LOCALAPPDATA%
|
||||
* Returns the user-level data directory.
|
||||
* Matches OpenCode's behavior via xdg-basedir:
|
||||
* - All platforms: XDG_DATA_HOME or ~/.local/share
|
||||
*
|
||||
* This follows XDG Base Directory specification on Unix systems
|
||||
* and Windows conventions on Windows.
|
||||
* Note: OpenCode uses xdg-basedir which returns ~/.local/share on ALL platforms
|
||||
* including Windows, so we match that behavior exactly.
|
||||
*/
|
||||
export function getDataDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
// Windows: Use %LOCALAPPDATA% (e.g., C:\Users\Username\AppData\Local)
|
||||
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local")
|
||||
}
|
||||
|
||||
// Unix: Use XDG_DATA_HOME or fallback to ~/.local/share
|
||||
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the OpenCode storage directory path.
|
||||
* - Linux/macOS: ~/.local/share/opencode/storage
|
||||
* - Windows: %LOCALAPPDATA%\opencode\storage
|
||||
* All platforms: ~/.local/share/opencode/storage
|
||||
*/
|
||||
export function getOpenCodeStorageDir(): string {
|
||||
return path.join(getDataDir(), "opencode", "storage")
|
||||
|
||||
@@ -1,164 +1,189 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4
|
||||
const DEFAULT_TARGET_MAX_TOKENS = 50_000
|
||||
const ANTHROPIC_ACTUAL_LIMIT = 200_000;
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
const DEFAULT_TARGET_MAX_TOKENS = 50_000;
|
||||
|
||||
interface AssistantMessageInfo {
|
||||
role: "assistant"
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
role: "assistant";
|
||||
tokens: {
|
||||
input: number;
|
||||
output: number;
|
||||
reasoning: number;
|
||||
cache: { read: number; write: number };
|
||||
};
|
||||
}
|
||||
|
||||
interface MessageWrapper {
|
||||
info: { role: string } & Partial<AssistantMessageInfo>
|
||||
info: { role: string } & Partial<AssistantMessageInfo>;
|
||||
}
|
||||
|
||||
export interface TruncationResult {
|
||||
result: string
|
||||
truncated: boolean
|
||||
removedCount?: number
|
||||
result: string;
|
||||
truncated: boolean;
|
||||
removedCount?: number;
|
||||
}
|
||||
|
||||
export interface TruncationOptions {
|
||||
targetMaxTokens?: number
|
||||
preserveHeaderLines?: number
|
||||
contextWindowLimit?: number
|
||||
targetMaxTokens?: number;
|
||||
preserveHeaderLines?: number;
|
||||
contextWindowLimit?: number;
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE);
|
||||
}
|
||||
|
||||
export function truncateToTokenLimit(
|
||||
output: string,
|
||||
maxTokens: number,
|
||||
preserveHeaderLines = 3
|
||||
output: string,
|
||||
maxTokens: number,
|
||||
preserveHeaderLines = 3,
|
||||
): TruncationResult {
|
||||
const currentTokens = estimateTokens(output)
|
||||
const currentTokens = estimateTokens(output);
|
||||
|
||||
if (currentTokens <= maxTokens) {
|
||||
return { result: output, truncated: false }
|
||||
}
|
||||
if (currentTokens <= maxTokens) {
|
||||
return { result: output, truncated: false };
|
||||
}
|
||||
|
||||
const lines = output.split("\n")
|
||||
const lines = output.split("\n");
|
||||
|
||||
if (lines.length <= preserveHeaderLines) {
|
||||
const maxChars = maxTokens * CHARS_PER_TOKEN_ESTIMATE
|
||||
return {
|
||||
result: output.slice(0, maxChars) + "\n\n[Output truncated due to context window limit]",
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
if (lines.length <= preserveHeaderLines) {
|
||||
const maxChars = maxTokens * CHARS_PER_TOKEN_ESTIMATE;
|
||||
return {
|
||||
result:
|
||||
output.slice(0, maxChars) +
|
||||
"\n\n[Output truncated due to context window limit]",
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
const headerLines = lines.slice(0, preserveHeaderLines)
|
||||
const contentLines = lines.slice(preserveHeaderLines)
|
||||
const headerLines = lines.slice(0, preserveHeaderLines);
|
||||
const contentLines = lines.slice(preserveHeaderLines);
|
||||
|
||||
const headerText = headerLines.join("\n")
|
||||
const headerTokens = estimateTokens(headerText)
|
||||
const truncationMessageTokens = 50
|
||||
const availableTokens = maxTokens - headerTokens - truncationMessageTokens
|
||||
const headerText = headerLines.join("\n");
|
||||
const headerTokens = estimateTokens(headerText);
|
||||
const truncationMessageTokens = 50;
|
||||
const availableTokens = maxTokens - headerTokens - truncationMessageTokens;
|
||||
|
||||
if (availableTokens <= 0) {
|
||||
return {
|
||||
result: headerText + "\n\n[Content truncated due to context window limit]",
|
||||
truncated: true,
|
||||
removedCount: contentLines.length,
|
||||
}
|
||||
}
|
||||
if (availableTokens <= 0) {
|
||||
return {
|
||||
result:
|
||||
headerText + "\n\n[Content truncated due to context window limit]",
|
||||
truncated: true,
|
||||
removedCount: contentLines.length,
|
||||
};
|
||||
}
|
||||
|
||||
const resultLines: string[] = []
|
||||
let currentTokenCount = 0
|
||||
const resultLines: string[] = [];
|
||||
let currentTokenCount = 0;
|
||||
|
||||
for (const line of contentLines) {
|
||||
const lineTokens = estimateTokens(line + "\n")
|
||||
if (currentTokenCount + lineTokens > availableTokens) {
|
||||
break
|
||||
}
|
||||
resultLines.push(line)
|
||||
currentTokenCount += lineTokens
|
||||
}
|
||||
for (const line of contentLines) {
|
||||
const lineTokens = estimateTokens(line + "\n");
|
||||
if (currentTokenCount + lineTokens > availableTokens) {
|
||||
break;
|
||||
}
|
||||
resultLines.push(line);
|
||||
currentTokenCount += lineTokens;
|
||||
}
|
||||
|
||||
const truncatedContent = [...headerLines, ...resultLines].join("\n")
|
||||
const removedCount = contentLines.length - resultLines.length
|
||||
const truncatedContent = [...headerLines, ...resultLines].join("\n");
|
||||
const removedCount = contentLines.length - resultLines.length;
|
||||
|
||||
return {
|
||||
result: truncatedContent + `\n\n[${removedCount} more lines truncated due to context window limit]`,
|
||||
truncated: true,
|
||||
removedCount,
|
||||
}
|
||||
return {
|
||||
result:
|
||||
truncatedContent +
|
||||
`\n\n[${removedCount} more lines truncated due to context window limit]`,
|
||||
truncated: true,
|
||||
removedCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getContextWindowUsage(
|
||||
ctx: PluginInput,
|
||||
sessionID: string
|
||||
): Promise<{ usedTokens: number; remainingTokens: number; usagePercentage: number } | null> {
|
||||
try {
|
||||
const response = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
): Promise<{
|
||||
usedTokens: number;
|
||||
remainingTokens: number;
|
||||
usagePercentage: number;
|
||||
} | null> {
|
||||
try {
|
||||
const response = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
});
|
||||
|
||||
const messages = (response.data ?? response) as MessageWrapper[]
|
||||
const messages = (response.data ?? response) as MessageWrapper[];
|
||||
|
||||
const assistantMessages = messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.map((m) => m.info as AssistantMessageInfo)
|
||||
const assistantMessages = messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.map((m) => m.info as AssistantMessageInfo);
|
||||
|
||||
if (assistantMessages.length === 0) return null
|
||||
if (assistantMessages.length === 0) return null;
|
||||
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
const lastTokens = lastAssistant.tokens
|
||||
const usedTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - usedTokens
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
||||
const lastTokens = lastAssistant.tokens;
|
||||
const usedTokens =
|
||||
(lastTokens?.input ?? 0) +
|
||||
(lastTokens?.cache?.read ?? 0) +
|
||||
(lastTokens?.output ?? 0);
|
||||
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - usedTokens;
|
||||
|
||||
return {
|
||||
usedTokens,
|
||||
remainingTokens,
|
||||
usagePercentage: usedTokens / ANTHROPIC_ACTUAL_LIMIT,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
usedTokens,
|
||||
remainingTokens,
|
||||
usagePercentage: usedTokens / ANTHROPIC_ACTUAL_LIMIT,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function dynamicTruncate(
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
output: string,
|
||||
options: TruncationOptions = {}
|
||||
ctx: PluginInput,
|
||||
sessionID: string,
|
||||
output: string,
|
||||
options: TruncationOptions = {},
|
||||
): Promise<TruncationResult> {
|
||||
const { targetMaxTokens = DEFAULT_TARGET_MAX_TOKENS, preserveHeaderLines = 3 } = options
|
||||
const {
|
||||
targetMaxTokens = DEFAULT_TARGET_MAX_TOKENS,
|
||||
preserveHeaderLines = 3,
|
||||
} = options;
|
||||
|
||||
const usage = await getContextWindowUsage(ctx, sessionID)
|
||||
const usage = await getContextWindowUsage(ctx, sessionID);
|
||||
|
||||
if (!usage) {
|
||||
return { result: output, truncated: false }
|
||||
}
|
||||
if (!usage) {
|
||||
// Fallback: apply conservative truncation when context usage unavailable
|
||||
return truncateToTokenLimit(output, targetMaxTokens, preserveHeaderLines);
|
||||
}
|
||||
|
||||
const maxOutputTokens = Math.min(usage.remainingTokens * 0.5, targetMaxTokens)
|
||||
const maxOutputTokens = Math.min(
|
||||
usage.remainingTokens * 0.5,
|
||||
targetMaxTokens,
|
||||
);
|
||||
|
||||
if (maxOutputTokens <= 0) {
|
||||
return {
|
||||
result: "[Output suppressed - context window exhausted]",
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
if (maxOutputTokens <= 0) {
|
||||
return {
|
||||
result: "[Output suppressed - context window exhausted]",
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return truncateToTokenLimit(output, maxOutputTokens, preserveHeaderLines)
|
||||
return truncateToTokenLimit(output, maxOutputTokens, preserveHeaderLines);
|
||||
}
|
||||
|
||||
export function createDynamicTruncator(ctx: PluginInput) {
|
||||
return {
|
||||
truncate: (sessionID: string, output: string, options?: TruncationOptions) =>
|
||||
dynamicTruncate(ctx, sessionID, output, options),
|
||||
return {
|
||||
truncate: (
|
||||
sessionID: string,
|
||||
output: string,
|
||||
options?: TruncationOptions,
|
||||
) => dynamicTruncate(ctx, sessionID, output, options),
|
||||
|
||||
getUsage: (sessionID: string) => getContextWindowUsage(ctx, sessionID),
|
||||
getUsage: (sessionID: string) => getContextWindowUsage(ctx, sessionID),
|
||||
|
||||
truncateSync: (output: string, maxTokens: number, preserveHeaderLines?: number) =>
|
||||
truncateToTokenLimit(output, maxTokens, preserveHeaderLines),
|
||||
}
|
||||
truncateSync: (
|
||||
output: string,
|
||||
maxTokens: number,
|
||||
preserveHeaderLines?: number,
|
||||
) => truncateToTokenLimit(output, maxTokens, preserveHeaderLines),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,3 +13,5 @@ export * from "./dynamic-truncator"
|
||||
export * from "./config-path"
|
||||
export * from "./data-path"
|
||||
export * from "./config-errors"
|
||||
export * from "./claude-config-dir"
|
||||
export * from "./jsonc-parser"
|
||||
|
||||
266
src/shared/jsonc-parser.test.ts
Normal file
266
src/shared/jsonc-parser.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { detectConfigFile, parseJsonc, parseJsoncSafe, readJsoncFile } from "./jsonc-parser"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
describe("parseJsonc", () => {
|
||||
test("parses plain JSON", () => {
|
||||
//#given
|
||||
const json = `{"key": "value"}`
|
||||
|
||||
//#when
|
||||
const result = parseJsonc<{ key: string }>(json)
|
||||
|
||||
//#then
|
||||
expect(result.key).toBe("value")
|
||||
})
|
||||
|
||||
test("parses JSONC with line comments", () => {
|
||||
//#given
|
||||
const jsonc = `{
|
||||
// This is a comment
|
||||
"key": "value"
|
||||
}`
|
||||
|
||||
//#when
|
||||
const result = parseJsonc<{ key: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
expect(result.key).toBe("value")
|
||||
})
|
||||
|
||||
test("parses JSONC with block comments", () => {
|
||||
//#given
|
||||
const jsonc = `{
|
||||
/* Block comment */
|
||||
"key": "value"
|
||||
}`
|
||||
|
||||
//#when
|
||||
const result = parseJsonc<{ key: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
expect(result.key).toBe("value")
|
||||
})
|
||||
|
||||
test("parses JSONC with multi-line block comments", () => {
|
||||
//#given
|
||||
const jsonc = `{
|
||||
/* Multi-line
|
||||
comment
|
||||
here */
|
||||
"key": "value"
|
||||
}`
|
||||
|
||||
//#when
|
||||
const result = parseJsonc<{ key: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
expect(result.key).toBe("value")
|
||||
})
|
||||
|
||||
test("parses JSONC with trailing commas", () => {
|
||||
//#given
|
||||
const jsonc = `{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}`
|
||||
|
||||
//#when
|
||||
const result = parseJsonc<{ key1: string; key2: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
expect(result.key1).toBe("value1")
|
||||
expect(result.key2).toBe("value2")
|
||||
})
|
||||
|
||||
test("parses JSONC with trailing comma in array", () => {
|
||||
//#given
|
||||
const jsonc = `{
|
||||
"arr": [1, 2, 3,]
|
||||
}`
|
||||
|
||||
//#when
|
||||
const result = parseJsonc<{ arr: number[] }>(jsonc)
|
||||
|
||||
//#then
|
||||
expect(result.arr).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("preserves URLs with // in strings", () => {
|
||||
//#given
|
||||
const jsonc = `{
|
||||
"url": "https://example.com"
|
||||
}`
|
||||
|
||||
//#when
|
||||
const result = parseJsonc<{ url: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
expect(result.url).toBe("https://example.com")
|
||||
})
|
||||
|
||||
test("parses complex JSONC config", () => {
|
||||
//#given
|
||||
const jsonc = `{
|
||||
// This is an example config
|
||||
"agents": {
|
||||
"oracle": { "model": "openai/gpt-5.2" }, // GPT for strategic reasoning
|
||||
},
|
||||
/* Agent overrides */
|
||||
"disabled_agents": [],
|
||||
}`
|
||||
|
||||
//#when
|
||||
const result = parseJsonc<{
|
||||
agents: { oracle: { model: string } }
|
||||
disabled_agents: string[]
|
||||
}>(jsonc)
|
||||
|
||||
//#then
|
||||
expect(result.agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(result.disabled_agents).toEqual([])
|
||||
})
|
||||
|
||||
test("throws on invalid JSON", () => {
|
||||
//#given
|
||||
const invalid = `{ "key": invalid }`
|
||||
|
||||
//#when
|
||||
//#then
|
||||
expect(() => parseJsonc(invalid)).toThrow()
|
||||
})
|
||||
|
||||
test("throws on unclosed string", () => {
|
||||
//#given
|
||||
const invalid = `{ "key": "unclosed }`
|
||||
|
||||
//#when
|
||||
//#then
|
||||
expect(() => parseJsonc(invalid)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseJsoncSafe", () => {
|
||||
test("returns data on valid JSONC", () => {
|
||||
//#given
|
||||
const jsonc = `{ "key": "value" }`
|
||||
|
||||
//#when
|
||||
const result = parseJsoncSafe<{ key: string }>(jsonc)
|
||||
|
||||
//#then
|
||||
expect(result.data).not.toBeNull()
|
||||
expect(result.data?.key).toBe("value")
|
||||
expect(result.errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("returns errors on invalid JSONC", () => {
|
||||
//#given
|
||||
const invalid = `{ "key": invalid }`
|
||||
|
||||
//#when
|
||||
const result = parseJsoncSafe(invalid)
|
||||
|
||||
//#then
|
||||
expect(result.data).toBeNull()
|
||||
expect(result.errors.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("readJsoncFile", () => {
|
||||
const testDir = join(__dirname, ".test-jsonc")
|
||||
const testFile = join(testDir, "config.jsonc")
|
||||
|
||||
test("reads and parses valid JSONC file", () => {
|
||||
//#given
|
||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||
const content = `{
|
||||
// Comment
|
||||
"test": "value"
|
||||
}`
|
||||
writeFileSync(testFile, content)
|
||||
|
||||
//#when
|
||||
const result = readJsoncFile<{ test: string }>(testFile)
|
||||
|
||||
//#then
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.test).toBe("value")
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("returns null for non-existent file", () => {
|
||||
//#given
|
||||
const nonExistent = join(testDir, "does-not-exist.jsonc")
|
||||
|
||||
//#when
|
||||
const result = readJsoncFile(nonExistent)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for malformed JSON", () => {
|
||||
//#given
|
||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||
writeFileSync(testFile, "{ invalid }")
|
||||
|
||||
//#when
|
||||
const result = readJsoncFile(testFile)
|
||||
|
||||
//#then
|
||||
expect(result).toBeNull()
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectConfigFile", () => {
|
||||
const testDir = join(__dirname, ".test-detect")
|
||||
|
||||
test("prefers .jsonc over .json", () => {
|
||||
//#given
|
||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||
const basePath = join(testDir, "config")
|
||||
writeFileSync(`${basePath}.json`, "{}")
|
||||
writeFileSync(`${basePath}.jsonc`, "{}")
|
||||
|
||||
//#when
|
||||
const result = detectConfigFile(basePath)
|
||||
|
||||
//#then
|
||||
expect(result.format).toBe("jsonc")
|
||||
expect(result.path).toBe(`${basePath}.jsonc`)
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("detects .json when .jsonc doesn't exist", () => {
|
||||
//#given
|
||||
if (!existsSync(testDir)) mkdirSync(testDir, { recursive: true })
|
||||
const basePath = join(testDir, "config")
|
||||
writeFileSync(`${basePath}.json`, "{}")
|
||||
|
||||
//#when
|
||||
const result = detectConfigFile(basePath)
|
||||
|
||||
//#then
|
||||
expect(result.format).toBe("json")
|
||||
expect(result.path).toBe(`${basePath}.json`)
|
||||
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("returns none when neither exists", () => {
|
||||
//#given
|
||||
const basePath = join(testDir, "nonexistent")
|
||||
|
||||
//#when
|
||||
const result = detectConfigFile(basePath)
|
||||
|
||||
//#then
|
||||
expect(result.format).toBe("none")
|
||||
})
|
||||
})
|
||||
66
src/shared/jsonc-parser.ts
Normal file
66
src/shared/jsonc-parser.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { parse, ParseError, printParseErrorCode } from "jsonc-parser"
|
||||
|
||||
export interface JsoncParseResult<T> {
|
||||
data: T | null
|
||||
errors: Array<{ message: string; offset: number; length: number }>
|
||||
}
|
||||
|
||||
export function parseJsonc<T = unknown>(content: string): T {
|
||||
const errors: ParseError[] = []
|
||||
const result = parse(content, errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
}) as T
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`)
|
||||
.join(", ")
|
||||
throw new SyntaxError(`JSONC parse error: ${errorMessages}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function parseJsoncSafe<T = unknown>(content: string): JsoncParseResult<T> {
|
||||
const errors: ParseError[] = []
|
||||
const data = parse(content, errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
}) as T | null
|
||||
|
||||
return {
|
||||
data: errors.length > 0 ? null : data,
|
||||
errors: errors.map((e) => ({
|
||||
message: printParseErrorCode(e.error),
|
||||
offset: e.offset,
|
||||
length: e.length,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function readJsoncFile<T = unknown>(filePath: string): T | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
return parseJsonc<T>(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function detectConfigFile(basePath: string): {
|
||||
format: "json" | "jsonc" | "none"
|
||||
path: string
|
||||
} {
|
||||
const jsoncPath = `${basePath}.jsonc`
|
||||
const jsonPath = `${basePath}.json`
|
||||
|
||||
if (existsSync(jsoncPath)) {
|
||||
return { format: "jsonc", path: jsoncPath }
|
||||
}
|
||||
if (existsSync(jsonPath)) {
|
||||
return { format: "json", path: jsonPath }
|
||||
}
|
||||
return { format: "none", path: jsonPath }
|
||||
}
|
||||
@@ -100,8 +100,6 @@ export function setSgCliPath(path: string): void {
|
||||
resolvedCliPath = path
|
||||
}
|
||||
|
||||
export const SG_CLI_PATH = getSgCliPath()
|
||||
|
||||
// CLI supported languages (25 total)
|
||||
export const CLI_LANGUAGES = [
|
||||
"bash",
|
||||
@@ -184,21 +182,20 @@ export interface EnvironmentCheckResult {
|
||||
* Call this at startup to provide early feedback about missing dependencies.
|
||||
*/
|
||||
export function checkEnvironment(): EnvironmentCheckResult {
|
||||
const cliPath = getSgCliPath()
|
||||
const result: EnvironmentCheckResult = {
|
||||
cli: {
|
||||
available: false,
|
||||
path: SG_CLI_PATH,
|
||||
path: cliPath,
|
||||
},
|
||||
napi: {
|
||||
available: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Check CLI availability
|
||||
if (existsSync(SG_CLI_PATH)) {
|
||||
if (existsSync(cliPath)) {
|
||||
result.cli.available = true
|
||||
} else if (SG_CLI_PATH === "sg") {
|
||||
// Fallback path - try which/where to find in PATH
|
||||
} else if (cliPath === "sg") {
|
||||
try {
|
||||
const { spawnSync } = require("child_process")
|
||||
const whichResult = spawnSync(process.platform === "win32" ? "where" : "which", ["sg"], {
|
||||
@@ -213,7 +210,7 @@ export function checkEnvironment(): EnvironmentCheckResult {
|
||||
result.cli.error = "Failed to check sg availability"
|
||||
}
|
||||
} else {
|
||||
result.cli.error = `Binary not found: ${SG_CLI_PATH}`
|
||||
result.cli.error = `Binary not found: ${cliPath}`
|
||||
}
|
||||
|
||||
// Check NAPI availability
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { ast_grep_search, ast_grep_replace } from "./tools"
|
||||
|
||||
export const builtinTools = {
|
||||
export const builtinTools: Record<string, ToolDefinition> = {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { formatSearchResult, formatReplaceResult } from "./utils"
|
||||
@@ -32,7 +32,7 @@ function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export const ast_grep_search = tool({
|
||||
export const ast_grep_search: ToolDefinition = tool({
|
||||
description:
|
||||
"Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
|
||||
"Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
|
||||
@@ -75,7 +75,7 @@ export const ast_grep_search = tool({
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_replace = tool({
|
||||
export const ast_grep_replace: ToolDefinition = tool({
|
||||
description:
|
||||
"Replace code patterns across filesystem with AST-aware rewriting. " +
|
||||
"Dry-run by default. Use meta-variables in rewrite to preserve matched content. " +
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { tool, type PluginInput } from "@opencode-ai/plugin"
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
|
||||
import type { BackgroundTaskArgs, BackgroundOutputArgs, BackgroundCancelArgs } from "./types"
|
||||
import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_CANCEL_DESCRIPTION } from "./constants"
|
||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function formatDuration(start: Date, end?: Date): string {
|
||||
const duration = (end ?? new Date()).getTime() - start.getTime()
|
||||
const seconds = Math.floor(duration / 1000)
|
||||
@@ -20,7 +37,15 @@ function formatDuration(start: Date, end?: Date): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function createBackgroundTask(manager: BackgroundManager) {
|
||||
type ToolContextWithMetadata = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
||||
}
|
||||
|
||||
export function createBackgroundTask(manager: BackgroundManager): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_TASK_DESCRIPTION,
|
||||
args: {
|
||||
@@ -29,17 +54,31 @@ export function createBackgroundTask(manager: BackgroundManager) {
|
||||
agent: tool.schema.string().describe("Agent type to use (any registered agent)"),
|
||||
},
|
||||
async execute(args: BackgroundTaskArgs, toolContext) {
|
||||
const ctx = toolContext as ToolContextWithMetadata
|
||||
|
||||
if (!args.agent || args.agent.trim() === "") {
|
||||
return `❌ Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)`
|
||||
}
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(ctx.sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
const parentModel = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
: undefined
|
||||
|
||||
const task = await manager.launch({
|
||||
description: args.description,
|
||||
prompt: args.prompt,
|
||||
agent: args.agent.trim(),
|
||||
parentSessionID: toolContext.sessionID,
|
||||
parentMessageID: toolContext.messageID,
|
||||
parentSessionID: ctx.sessionID,
|
||||
parentMessageID: ctx.messageID,
|
||||
parentModel,
|
||||
})
|
||||
|
||||
ctx.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: task.sessionID },
|
||||
})
|
||||
|
||||
return `Background task launched successfully.
|
||||
@@ -193,7 +232,7 @@ Session ID: ${task.sessionID}
|
||||
${textContent || "(No text output)"}`
|
||||
}
|
||||
|
||||
export function createBackgroundOutput(manager: BackgroundManager, client: OpencodeClient) {
|
||||
export function createBackgroundOutput(manager: BackgroundManager, client: OpencodeClient): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_OUTPUT_DESCRIPTION,
|
||||
args: {
|
||||
@@ -259,7 +298,7 @@ export function createBackgroundOutput(manager: BackgroundManager, client: Openc
|
||||
})
|
||||
}
|
||||
|
||||
export function createBackgroundCancel(manager: BackgroundManager, client: OpencodeClient) {
|
||||
export function createBackgroundCancel(manager: BackgroundManager, client: OpencodeClient): ToolDefinition {
|
||||
return tool({
|
||||
description: BACKGROUND_CANCEL_DESCRIPTION,
|
||||
args: {
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { tool, type PluginInput } from "@opencode-ai/plugin"
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type ToolContextWithMetadata = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
agent: string
|
||||
abort: AbortSignal
|
||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
||||
}
|
||||
|
||||
export function createCallOmoAgent(
|
||||
ctx: PluginInput,
|
||||
backgroundManager: BackgroundManager
|
||||
) {
|
||||
): ToolDefinition {
|
||||
const agentDescriptions = ALLOWED_AGENTS.map(
|
||||
(name) => `- ${name}: Specialized agent for ${name} tasks`
|
||||
).join("\n")
|
||||
@@ -27,6 +35,7 @@ export function createCallOmoAgent(
|
||||
session_id: tool.schema.string().describe("Existing Task session to continue").optional(),
|
||||
},
|
||||
async execute(args: CallOmoAgentArgs, toolContext) {
|
||||
const toolCtx = toolContext as ToolContextWithMetadata
|
||||
log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`)
|
||||
|
||||
if (!ALLOWED_AGENTS.includes(args.subagent_type as typeof ALLOWED_AGENTS[number])) {
|
||||
@@ -37,17 +46,17 @@ export function createCallOmoAgent(
|
||||
if (args.session_id) {
|
||||
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
|
||||
}
|
||||
return await executeBackground(args, toolContext, backgroundManager)
|
||||
return await executeBackground(args, toolCtx, backgroundManager)
|
||||
}
|
||||
|
||||
return await executeSync(args, toolContext, ctx)
|
||||
return await executeSync(args, toolCtx, ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function executeBackground(
|
||||
args: CallOmoAgentArgs,
|
||||
toolContext: { sessionID: string; messageID: string },
|
||||
toolContext: ToolContextWithMetadata,
|
||||
manager: BackgroundManager
|
||||
): Promise<string> {
|
||||
try {
|
||||
@@ -59,6 +68,11 @@ async function executeBackground(
|
||||
parentMessageID: toolContext.messageID,
|
||||
})
|
||||
|
||||
toolContext.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: task.sessionID },
|
||||
})
|
||||
|
||||
return `Background agent task launched successfully.
|
||||
|
||||
Task ID: ${task.id}
|
||||
@@ -79,7 +93,7 @@ Use \`background_output\` tool with task_id="${task.id}" to check progress:
|
||||
|
||||
async function executeSync(
|
||||
args: CallOmoAgentArgs,
|
||||
toolContext: { sessionID: string },
|
||||
toolContext: ToolContextWithMetadata,
|
||||
ctx: PluginInput
|
||||
): Promise<string> {
|
||||
let sessionID: string
|
||||
@@ -112,6 +126,11 @@ async function executeSync(
|
||||
log(`[call_omo_agent] Created session: ${sessionID}`)
|
||||
}
|
||||
|
||||
toolContext.metadata?.({
|
||||
title: args.description,
|
||||
metadata: { sessionId: sessionID },
|
||||
})
|
||||
|
||||
log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
|
||||
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { runRgFiles } from "./cli"
|
||||
import { formatGlobResult } from "./utils"
|
||||
|
||||
export const glob = tool({
|
||||
export const glob: ToolDefinition = tool({
|
||||
description:
|
||||
"Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " +
|
||||
"Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " +
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { runRg } from "./cli"
|
||||
import { formatGrepResult } from "./utils"
|
||||
|
||||
export const grep = tool({
|
||||
export const grep: ToolDefinition = tool({
|
||||
description:
|
||||
"Fast content search tool with safety limits (60s timeout, 10MB output). " +
|
||||
"Searches file contents using regular expressions. " +
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
createBackgroundCancel,
|
||||
} from "./background-task"
|
||||
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { PluginInput, ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
@@ -45,7 +45,7 @@ type OpencodeClient = PluginInput["client"]
|
||||
export { createCallOmoAgent } from "./call-omo-agent"
|
||||
export { createLookAt } from "./look-at"
|
||||
|
||||
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient) {
|
||||
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
|
||||
return {
|
||||
background_task: createBackgroundTask(manager),
|
||||
background_output: createBackgroundOutput(manager, client),
|
||||
@@ -53,7 +53,7 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
|
||||
}
|
||||
}
|
||||
|
||||
export const builtinTools = {
|
||||
export const builtinTools: Record<string, ToolDefinition> = {
|
||||
lsp_hover,
|
||||
lsp_goto_definition,
|
||||
lsp_find_references,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { BLOCKED_TMUX_SUBCOMMANDS, DEFAULT_TIMEOUT_MS, INTERACTIVE_BASH_DESCRIPTION } from "./constants"
|
||||
import { getCachedTmuxPath } from "./utils"
|
||||
|
||||
@@ -47,7 +47,7 @@ export function tokenizeCommand(cmd: string): string[] {
|
||||
return tokens
|
||||
}
|
||||
|
||||
export const interactive_bash = tool({
|
||||
export const interactive_bash: ToolDefinition = tool({
|
||||
description: INTERACTIVE_BASH_DESCRIPTION,
|
||||
args: {
|
||||
tmux_command: tool.schema.string().describe("The tmux command to execute (without 'tmux' prefix)"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { extname, basename } from "node:path"
|
||||
import { tool, type PluginInput } from "@opencode-ai/plugin"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||
import type { LookAtArgs } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
@@ -28,7 +29,7 @@ function inferMimeType(filePath: string): string {
|
||||
return mimeTypes[ext] || "application/octet-stream"
|
||||
}
|
||||
|
||||
export function createLookAt(ctx: PluginInput) {
|
||||
export function createLookAt(ctx: PluginInput): ToolDefinition {
|
||||
return tool({
|
||||
description: LOOK_AT_DESCRIPTION,
|
||||
args: {
|
||||
@@ -78,7 +79,7 @@ If the requested information is not found, clearly state what is missing.`
|
||||
},
|
||||
parts: [
|
||||
{ type: "text", text: prompt },
|
||||
{ type: "file", mime: mimeType, url: `file://${args.file_path}`, filename },
|
||||
{ type: "file", mime: mimeType, url: pathToFileURL(args.file_path).href, filename },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { spawn, type Subprocess } from "bun"
|
||||
import { readFileSync } from "fs"
|
||||
import { extname, resolve } from "path"
|
||||
import type { ResolvedServer } from "./config"
|
||||
import { getLanguageId } from "./config"
|
||||
import type { Diagnostic } from "./types"
|
||||
import type { Diagnostic, ResolvedServer } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
client: LSPClient
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user